diff --git a/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt new file mode 100644 index 0000000000..c5b8dd334a --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt @@ -0,0 +1,772 @@ +// Autogenerated from Pigeon (v25.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.zulip.flutter + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMethodCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +private fun wrapResult(result: Any?): List { + return listOf(result) +} + +private fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +/** + * Corresponds to `androidx.core.app.NotificationChannelCompat` + * + * See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class NotificationChannel ( + val id: String, + /** + * Specifies the importance level of notifications + * to be posted on this channel. + * + * Must be a valid constant from [NotificationImportance]. + */ + val importance: Long, + val name: String? = null, + val lightsEnabled: Boolean? = null, + val soundUrl: String? = null, + val vibrationPattern: LongArray? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): NotificationChannel { + val id = pigeonVar_list[0] as String + val importance = pigeonVar_list[1] as Long + val name = pigeonVar_list[2] as String? + val lightsEnabled = pigeonVar_list[3] as Boolean? + val soundUrl = pigeonVar_list[4] as String? + val vibrationPattern = pigeonVar_list[5] as LongArray? + return NotificationChannel(id, importance, name, lightsEnabled, soundUrl, vibrationPattern) + } + } + fun toList(): List { + return listOf( + id, + importance, + name, + lightsEnabled, + soundUrl, + vibrationPattern, + ) + } +} + +/** + * Corresponds to `android.content.Intent` + * + * See: + * https://developer.android.com/reference/android/content/Intent + * https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AndroidIntent ( + val action: String, + val dataUrl: String, + /** A combination of flags from [IntentFlag]. */ + val flags: Long, + val extrasData: Map +) + { + companion object { + fun fromList(pigeonVar_list: List): AndroidIntent { + val action = pigeonVar_list[0] as String + val dataUrl = pigeonVar_list[1] as String + val flags = pigeonVar_list[2] as Long + val extrasData = pigeonVar_list[3] as Map + return AndroidIntent(action, dataUrl, flags, extrasData) + } + } + fun toList(): List { + return listOf( + action, + dataUrl, + flags, + extrasData, + ) + } +} + +/** + * Corresponds to `android.app.PendingIntent`. + * + * See: https://developer.android.com/reference/android/app/PendingIntent + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class PendingIntent ( + val requestCode: Long, + val intent: AndroidIntent, + /** + * A combination of flags from [PendingIntent.flags], and others associated + * with `Intent`; see Android docs for `PendingIntent.getActivity`. + */ + val flags: Long +) + { + companion object { + fun fromList(pigeonVar_list: List): PendingIntent { + val requestCode = pigeonVar_list[0] as Long + val intent = pigeonVar_list[1] as AndroidIntent + val flags = pigeonVar_list[2] as Long + return PendingIntent(requestCode, intent, flags) + } + } + fun toList(): List { + return listOf( + requestCode, + intent, + flags, + ) + } +} + +/** + * Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` + * + * See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class InboxStyle ( + val summaryText: String +) + { + companion object { + fun fromList(pigeonVar_list: List): InboxStyle { + val summaryText = pigeonVar_list[0] as String + return InboxStyle(summaryText) + } + } + fun toList(): List { + return listOf( + summaryText, + ) + } +} + +/** + * Corresponds to `androidx.core.app.Person` + * + * See: https://developer.android.com/reference/androidx/core/app/Person + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class Person ( + /** + * An icon for this person. + * + * This should be compressed image data, in a format to be passed + * to `androidx.core.graphics.drawable.IconCompat.createWithData`. + * Supported formats include JPEG, PNG, and WEBP. + * + * See: + * https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) + */ + val iconBitmap: ByteArray? = null, + val key: String, + val name: String +) + { + companion object { + fun fromList(pigeonVar_list: List): Person { + val iconBitmap = pigeonVar_list[0] as ByteArray? + val key = pigeonVar_list[1] as String + val name = pigeonVar_list[2] as String + return Person(iconBitmap, key, name) + } + } + fun toList(): List { + return listOf( + iconBitmap, + key, + name, + ) + } +} + +/** + * Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` + * + * See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class MessagingStyleMessage ( + val text: String, + val timestampMs: Long, + val person: Person +) + { + companion object { + fun fromList(pigeonVar_list: List): MessagingStyleMessage { + val text = pigeonVar_list[0] as String + val timestampMs = pigeonVar_list[1] as Long + val person = pigeonVar_list[2] as Person + return MessagingStyleMessage(text, timestampMs, person) + } + } + fun toList(): List { + return listOf( + text, + timestampMs, + person, + ) + } +} + +/** + * Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` + * + * See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class MessagingStyle ( + val user: Person, + val conversationTitle: String? = null, + val messages: List, + val isGroupConversation: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): MessagingStyle { + val user = pigeonVar_list[0] as Person + val conversationTitle = pigeonVar_list[1] as String? + val messages = pigeonVar_list[2] as List + val isGroupConversation = pigeonVar_list[3] as Boolean + return MessagingStyle(user, conversationTitle, messages, isGroupConversation) + } + } + fun toList(): List { + return listOf( + user, + conversationTitle, + messages, + isGroupConversation, + ) + } +} + +/** + * Corresponds to `android.app.Notification` + * + * See: https://developer.android.com/reference/kotlin/android/app/Notification + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class Notification ( + val group: String, + val extras: Map +) + { + companion object { + fun fromList(pigeonVar_list: List): Notification { + val group = pigeonVar_list[0] as String + val extras = pigeonVar_list[1] as Map + return Notification(group, extras) + } + } + fun toList(): List { + return listOf( + group, + extras, + ) + } +} + +/** + * Corresponds to `android.service.notification.StatusBarNotification` + * + * See: https://developer.android.com/reference/android/service/notification/StatusBarNotification + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class StatusBarNotification ( + val id: Long, + val tag: String, + val notification: Notification +) + { + companion object { + fun fromList(pigeonVar_list: List): StatusBarNotification { + val id = pigeonVar_list[0] as Long + val tag = pigeonVar_list[1] as String + val notification = pigeonVar_list[2] as Notification + return StatusBarNotification(id, tag, notification) + } + } + fun toList(): List { + return listOf( + id, + tag, + notification, + ) + } +} + +/** + * Represents details about a notification sound stored in the + * shared media store. + * + * Returned as a list entry by + * [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class StoredNotificationSound ( + /** The display name of the sound file. */ + val fileName: String, + /** + * Specifies whether this file was created by the app. + * + * It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the + * metadata matches the app's package name. + */ + val isOwned: Boolean, + /** A `content://…` URL pointing to the sound file. */ + val contentUrl: String +) + { + companion object { + fun fromList(pigeonVar_list: List): StoredNotificationSound { + val fileName = pigeonVar_list[0] as String + val isOwned = pigeonVar_list[1] as Boolean + val contentUrl = pigeonVar_list[2] as String + return StoredNotificationSound(fileName, isOwned, contentUrl) + } + } + fun toList(): List { + return listOf( + fileName, + isOwned, + contentUrl, + ) + } +} +private open class AndroidNotificationsPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + NotificationChannel.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + AndroidIntent.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + PendingIntent.fromList(it) + } + } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { + InboxStyle.fromList(it) + } + } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { + Person.fromList(it) + } + } + 134.toByte() -> { + return (readValue(buffer) as? List)?.let { + MessagingStyleMessage.fromList(it) + } + } + 135.toByte() -> { + return (readValue(buffer) as? List)?.let { + MessagingStyle.fromList(it) + } + } + 136.toByte() -> { + return (readValue(buffer) as? List)?.let { + Notification.fromList(it) + } + } + 137.toByte() -> { + return (readValue(buffer) as? List)?.let { + StatusBarNotification.fromList(it) + } + } + 138.toByte() -> { + return (readValue(buffer) as? List)?.let { + StoredNotificationSound.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is NotificationChannel -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is AndroidIntent -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is PendingIntent -> { + stream.write(131) + writeValue(stream, value.toList()) + } + is InboxStyle -> { + stream.write(132) + writeValue(stream, value.toList()) + } + is Person -> { + stream.write(133) + writeValue(stream, value.toList()) + } + is MessagingStyleMessage -> { + stream.write(134) + writeValue(stream, value.toList()) + } + is MessagingStyle -> { + stream.write(135) + writeValue(stream, value.toList()) + } + is Notification -> { + stream.write(136) + writeValue(stream, value.toList()) + } + is StatusBarNotification -> { + stream.write(137) + writeValue(stream, value.toList()) + } + is StoredNotificationSound -> { + stream.write(138) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface AndroidNotificationHostApi { + /** + * Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. + * + * See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) + */ + fun createNotificationChannel(channel: NotificationChannel) + /** + * Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. + * + * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() + */ + fun getNotificationChannels(): List + /** + * Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` + * + * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) + */ + fun deleteNotificationChannel(channelId: String) + /** + * The list of notification sound files present under `Notifications/Zulip/` + * in the device's shared media storage, + * found with `android.content.ContentResolver.query`. + * + * This is a complex ad-hoc method. + * For detailed behavior, see its implementation. + * + * Requires minimum of Android 10 (API 29) or higher. + * + * See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) + */ + fun listStoredSoundsInNotificationsDirectory(): List + /** + * Wraps `android.content.ContentResolver.insert` combined with + * `android.content.ContentResolver.openOutputStream` and + * `android.content.res.Resources.openRawResource`. + * + * Copies a raw resource audio file to `Notifications/Zulip/` + * directory in device's shared media storage. Returns the URL + * of the target file in media store. + * + * Requires minimum of Android 10 (API 29) or higher. + * + * See: + * https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) + * https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) + * https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) + */ + fun copySoundResourceToMediaStore(targetFileDisplayName: String, sourceResourceName: String): String + /** + * Corresponds to `android.app.NotificationManager.notify`, + * combined with `androidx.core.app.NotificationCompat.Builder`. + * + * The arguments `tag` and `id` go to the `notify` call. + * The rest go to method calls on the builder. + * + * The `color` should be in the form 0xAARRGGBB. + * See [ColorExtension.argbInt]. + * + * The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` + * to get a resource ID to pass to `Builder.setSmallIcon`. + * Whatever name is passed there must appear in keep.xml too: + * see https://github.com/zulip/zulip-flutter/issues/528 . + * + * See: + * https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify + * https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder + */ + fun notify(tag: String?, id: Long, autoCancel: Boolean?, channelId: String, color: Long?, contentIntent: PendingIntent?, contentText: String?, contentTitle: String?, extras: Map?, groupKey: String?, inboxStyle: InboxStyle?, isGroupSummary: Boolean?, messagingStyle: MessagingStyle?, number: Long?, smallIconResourceName: String?) + /** + * Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, + * combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. + * + * Returns the messaging style, if any, of an active notification + * that has tag `tag`. If there are several such notifications, + * an arbitrary one of them is used. + * Returns null if there are no such notifications. + * + * See: + * https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() + * https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) + */ + fun getActiveNotificationMessagingStyleByTag(tag: String): MessagingStyle? + /** + * Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. + * + * The keys of entries to fetch from notification's extras bundle must be + * specified in the [desiredExtras] list. If this list is empty, then + * [Notifications.extras] will also be empty. If value of the matched entry + * is not of type string or is null, then that entry will be skipped. + * + * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() + */ + fun getActiveNotifications(desiredExtras: List): List + /** + * Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. + * + * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) + */ + fun cancel(tag: String?, id: Long) + + companion object { + /** The codec used by AndroidNotificationHostApi. */ + val codec: MessageCodec by lazy { + AndroidNotificationsPigeonCodec() + } + /** Sets up an instance of `AndroidNotificationHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: AndroidNotificationHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.createNotificationChannel$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val channelArg = args[0] as NotificationChannel + val wrapped: List = try { + api.createNotificationChannel(channelArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getNotificationChannels$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getNotificationChannels()) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.deleteNotificationChannel$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val channelIdArg = args[0] as String + val wrapped: List = try { + api.deleteNotificationChannel(channelIdArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.listStoredSoundsInNotificationsDirectory()) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val targetFileDisplayNameArg = args[0] as String + val sourceResourceNameArg = args[1] as String + val wrapped: List = try { + listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg)) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tagArg = args[0] as String? + val idArg = args[1] as Long + val autoCancelArg = args[2] as Boolean? + val channelIdArg = args[3] as String + val colorArg = args[4] as Long? + val contentIntentArg = args[5] as PendingIntent? + val contentTextArg = args[6] as String? + val contentTitleArg = args[7] as String? + val extrasArg = args[8] as Map? + val groupKeyArg = args[9] as String? + val inboxStyleArg = args[10] as InboxStyle? + val isGroupSummaryArg = args[11] as Boolean? + val messagingStyleArg = args[12] as MessagingStyle? + val numberArg = args[13] as Long? + val smallIconResourceNameArg = args[14] as String? + val wrapped: List = try { + api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, messagingStyleArg, numberArg, smallIconResourceNameArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotificationMessagingStyleByTag$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tagArg = args[0] as String + val wrapped: List = try { + listOf(api.getActiveNotificationMessagingStyleByTag(tagArg)) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotifications$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val desiredExtrasArg = args[0] as List + val wrapped: List = try { + listOf(api.getActiveNotifications(desiredExtrasArg)) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.cancel$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tagArg = args[0] as String? + val idArg = args[1] as Long + val wrapped: List = try { + api.cancel(tagArg, idArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt index 1829456362..76ccbf3ef9 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt @@ -1,6 +1,79 @@ package com.zulip.flutter +import android.content.Intent import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity: FlutterActivity() { +class MainActivity : FlutterActivity() { + private var notificationTapEventListener: NotificationTapEventListener? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + val maybeNotifPayload = maybeIntentExtrasData(intent) + val api = NotificationHostApiImpl(maybeNotifPayload?.let { NotificationDataFromLaunch(it) }) + NotificationHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, api) + + notificationTapEventListener = NotificationTapEventListener() + NotificationTapEventsStreamHandler.register( + flutterEngine.dartExecutor.binaryMessenger, notificationTapEventListener!! + ) + } + + override fun onNewIntent(intent: Intent) { + val maybeExtrasData = maybeIntentExtrasData(intent) + if (notificationTapEventListener != null && maybeExtrasData != null) { + notificationTapEventListener!!.onNotificationTapEvent(NotificationTapEvent(payload = maybeExtrasData)) + return + } + + super.onNewIntent(intent) + } + + override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { + notificationTapEventListener?.onEventsDone() + notificationTapEventListener = null + + super.cleanUpFlutterEngine(flutterEngine) + } + + private fun maybeIntentExtrasData(intent: Intent): Map? { + var extrasData: Map? = null + if (intent.action == Intent.ACTION_VIEW) { + val intentUrl = intent.data + if (intentUrl?.scheme == "zulip" && intentUrl.authority == "notification") { + val bundle = intent.getBundleExtra("data") + if (bundle != null) { + extrasData = + bundle.keySet().mapNotNull { key -> bundle.getString(key)?.let { key to it } } + .toMap() + } + } + } + return extrasData + } +} + +private class NotificationHostApiImpl(val maybeDataFromLaunch: NotificationDataFromLaunch?) : + NotificationHostApi { + override fun getNotificationDataFromLaunch(): NotificationDataFromLaunch? { + return maybeDataFromLaunch + } +} + +private class NotificationTapEventListener : NotificationTapEventsStreamHandler() { + private var eventSink: PigeonEventSink? = null + + override fun onListen(p0: Any?, sink: PigeonEventSink) { + eventSink = sink + } + + fun onNotificationTapEvent(data: NotificationTapEvent) { + eventSink?.success(data) + } + + fun onEventsDone() { + eventSink?.endOfStream() + eventSink = null + } } diff --git a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt index 1776f4bab4..25a19cd9f2 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt @@ -34,350 +34,50 @@ private fun wrapError(exception: Throwable): List { } } -/** - * Error class for passing custom error details to Flutter via a thrown PlatformException. - * @property code The error code. - * @property message The error message. - * @property details The error details. Must be a datatype supported by the api codec. - */ -class FlutterError ( - val code: String, - override val message: String? = null, - val details: Any? = null -) : Throwable() - -/** - * Corresponds to `androidx.core.app.NotificationChannelCompat` - * - * See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class NotificationChannel ( - val id: String, +/** Generated class from Pigeon that represents data sent in messages. */ +data class NotificationDataFromLaunch ( /** - * Specifies the importance level of notifications - * to be posted on this channel. + * The raw payload that is attached to the notification, + * holding the information required to carry out the navigation. * - * Must be a valid constant from [NotificationImportance]. - */ - val importance: Long, - val name: String? = null, - val lightsEnabled: Boolean? = null, - val soundUrl: String? = null, - val vibrationPattern: LongArray? = null -) - { - companion object { - fun fromList(pigeonVar_list: List): NotificationChannel { - val id = pigeonVar_list[0] as String - val importance = pigeonVar_list[1] as Long - val name = pigeonVar_list[2] as String? - val lightsEnabled = pigeonVar_list[3] as Boolean? - val soundUrl = pigeonVar_list[4] as String? - val vibrationPattern = pigeonVar_list[5] as LongArray? - return NotificationChannel(id, importance, name, lightsEnabled, soundUrl, vibrationPattern) - } - } - fun toList(): List { - return listOf( - id, - importance, - name, - lightsEnabled, - soundUrl, - vibrationPattern, - ) - } -} - -/** - * Corresponds to `android.content.Intent` - * - * See: - * https://developer.android.com/reference/android/content/Intent - * https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class AndroidIntent ( - val action: String, - val dataUrl: String, - /** A combination of flags from [IntentFlag]. */ - val flags: Long -) - { - companion object { - fun fromList(pigeonVar_list: List): AndroidIntent { - val action = pigeonVar_list[0] as String - val dataUrl = pigeonVar_list[1] as String - val flags = pigeonVar_list[2] as Long - return AndroidIntent(action, dataUrl, flags) - } - } - fun toList(): List { - return listOf( - action, - dataUrl, - flags, - ) - } -} - -/** - * Corresponds to `android.app.PendingIntent`. - * - * See: https://developer.android.com/reference/android/app/PendingIntent - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class PendingIntent ( - val requestCode: Long, - val intent: AndroidIntent, - /** - * A combination of flags from [PendingIntent.flags], and others associated - * with `Intent`; see Android docs for `PendingIntent.getActivity`. + * See [NotificationHostApi.getNotificationDataFromLaunch]. */ - val flags: Long -) - { - companion object { - fun fromList(pigeonVar_list: List): PendingIntent { - val requestCode = pigeonVar_list[0] as Long - val intent = pigeonVar_list[1] as AndroidIntent - val flags = pigeonVar_list[2] as Long - return PendingIntent(requestCode, intent, flags) - } - } - fun toList(): List { - return listOf( - requestCode, - intent, - flags, - ) - } -} - -/** - * Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` - * - * See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class InboxStyle ( - val summaryText: String + val payload: Map ) { companion object { - fun fromList(pigeonVar_list: List): InboxStyle { - val summaryText = pigeonVar_list[0] as String - return InboxStyle(summaryText) + fun fromList(pigeonVar_list: List): NotificationDataFromLaunch { + val payload = pigeonVar_list[0] as Map + return NotificationDataFromLaunch(payload) } } fun toList(): List { return listOf( - summaryText, + payload, ) } } -/** - * Corresponds to `androidx.core.app.Person` - * - * See: https://developer.android.com/reference/androidx/core/app/Person - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class Person ( +/** Generated class from Pigeon that represents data sent in messages. */ +data class NotificationTapEvent ( /** - * An icon for this person. + * The raw payload that is attached to the notification, + * holding the information required to carry out the navigation. * - * This should be compressed image data, in a format to be passed - * to `androidx.core.graphics.drawable.IconCompat.createWithData`. - * Supported formats include JPEG, PNG, and WEBP. - * - * See: - * https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) + * See [notificationTapEvents]. */ - val iconBitmap: ByteArray? = null, - val key: String, - val name: String -) - { - companion object { - fun fromList(pigeonVar_list: List): Person { - val iconBitmap = pigeonVar_list[0] as ByteArray? - val key = pigeonVar_list[1] as String - val name = pigeonVar_list[2] as String - return Person(iconBitmap, key, name) - } - } - fun toList(): List { - return listOf( - iconBitmap, - key, - name, - ) - } -} - -/** - * Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` - * - * See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class MessagingStyleMessage ( - val text: String, - val timestampMs: Long, - val person: Person -) - { - companion object { - fun fromList(pigeonVar_list: List): MessagingStyleMessage { - val text = pigeonVar_list[0] as String - val timestampMs = pigeonVar_list[1] as Long - val person = pigeonVar_list[2] as Person - return MessagingStyleMessage(text, timestampMs, person) - } - } - fun toList(): List { - return listOf( - text, - timestampMs, - person, - ) - } -} - -/** - * Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` - * - * See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class MessagingStyle ( - val user: Person, - val conversationTitle: String? = null, - val messages: List, - val isGroupConversation: Boolean -) - { - companion object { - fun fromList(pigeonVar_list: List): MessagingStyle { - val user = pigeonVar_list[0] as Person - val conversationTitle = pigeonVar_list[1] as String? - val messages = pigeonVar_list[2] as List - val isGroupConversation = pigeonVar_list[3] as Boolean - return MessagingStyle(user, conversationTitle, messages, isGroupConversation) - } - } - fun toList(): List { - return listOf( - user, - conversationTitle, - messages, - isGroupConversation, - ) - } -} - -/** - * Corresponds to `android.app.Notification` - * - * See: https://developer.android.com/reference/kotlin/android/app/Notification - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class Notification ( - val group: String, - val extras: Map + val payload: Map ) { companion object { - fun fromList(pigeonVar_list: List): Notification { - val group = pigeonVar_list[0] as String - val extras = pigeonVar_list[1] as Map - return Notification(group, extras) + fun fromList(pigeonVar_list: List): NotificationTapEvent { + val payload = pigeonVar_list[0] as Map + return NotificationTapEvent(payload) } } fun toList(): List { return listOf( - group, - extras, - ) - } -} - -/** - * Corresponds to `android.service.notification.StatusBarNotification` - * - * See: https://developer.android.com/reference/android/service/notification/StatusBarNotification - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class StatusBarNotification ( - val id: Long, - val tag: String, - val notification: Notification -) - { - companion object { - fun fromList(pigeonVar_list: List): StatusBarNotification { - val id = pigeonVar_list[0] as Long - val tag = pigeonVar_list[1] as String - val notification = pigeonVar_list[2] as Notification - return StatusBarNotification(id, tag, notification) - } - } - fun toList(): List { - return listOf( - id, - tag, - notification, - ) - } -} - -/** - * Represents details about a notification sound stored in the - * shared media store. - * - * Returned as a list entry by - * [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. - * - * Generated class from Pigeon that represents data sent in messages. - */ -data class StoredNotificationSound ( - /** The display name of the sound file. */ - val fileName: String, - /** - * Specifies whether this file was created by the app. - * - * It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the - * metadata matches the app's package name. - */ - val isOwned: Boolean, - /** A `content://…` URL pointing to the sound file. */ - val contentUrl: String -) - { - companion object { - fun fromList(pigeonVar_list: List): StoredNotificationSound { - val fileName = pigeonVar_list[0] as String - val isOwned = pigeonVar_list[1] as Boolean - val contentUrl = pigeonVar_list[2] as String - return StoredNotificationSound(fileName, isOwned, contentUrl) - } - } - fun toList(): List { - return listOf( - fileName, - isOwned, - contentUrl, + payload, ) } } @@ -386,52 +86,12 @@ private open class NotificationsPigeonCodec : StandardMessageCodec() { return when (type) { 129.toByte() -> { return (readValue(buffer) as? List)?.let { - NotificationChannel.fromList(it) + NotificationDataFromLaunch.fromList(it) } } 130.toByte() -> { return (readValue(buffer) as? List)?.let { - AndroidIntent.fromList(it) - } - } - 131.toByte() -> { - return (readValue(buffer) as? List)?.let { - PendingIntent.fromList(it) - } - } - 132.toByte() -> { - return (readValue(buffer) as? List)?.let { - InboxStyle.fromList(it) - } - } - 133.toByte() -> { - return (readValue(buffer) as? List)?.let { - Person.fromList(it) - } - } - 134.toByte() -> { - return (readValue(buffer) as? List)?.let { - MessagingStyleMessage.fromList(it) - } - } - 135.toByte() -> { - return (readValue(buffer) as? List)?.let { - MessagingStyle.fromList(it) - } - } - 136.toByte() -> { - return (readValue(buffer) as? List)?.let { - Notification.fromList(it) - } - } - 137.toByte() -> { - return (readValue(buffer) as? List)?.let { - StatusBarNotification.fromList(it) - } - } - 138.toByte() -> { - return (readValue(buffer) as? List)?.let { - StoredNotificationSound.fromList(it) + NotificationTapEvent.fromList(it) } } else -> super.readValueOfType(type, buffer) @@ -439,219 +99,56 @@ private open class NotificationsPigeonCodec : StandardMessageCodec() { } override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { when (value) { - is NotificationChannel -> { + is NotificationDataFromLaunch -> { stream.write(129) writeValue(stream, value.toList()) } - is AndroidIntent -> { + is NotificationTapEvent -> { stream.write(130) writeValue(stream, value.toList()) } - is PendingIntent -> { - stream.write(131) - writeValue(stream, value.toList()) - } - is InboxStyle -> { - stream.write(132) - writeValue(stream, value.toList()) - } - is Person -> { - stream.write(133) - writeValue(stream, value.toList()) - } - is MessagingStyleMessage -> { - stream.write(134) - writeValue(stream, value.toList()) - } - is MessagingStyle -> { - stream.write(135) - writeValue(stream, value.toList()) - } - is Notification -> { - stream.write(136) - writeValue(stream, value.toList()) - } - is StatusBarNotification -> { - stream.write(137) - writeValue(stream, value.toList()) - } - is StoredNotificationSound -> { - stream.write(138) - writeValue(stream, value.toList()) - } else -> super.writeValue(stream, value) } } } +val NotificationsPigeonMethodCodec = StandardMethodCodec(NotificationsPigeonCodec()); + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ -interface AndroidNotificationHostApi { - /** - * Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. - * - * See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) - */ - fun createNotificationChannel(channel: NotificationChannel) - /** - * Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. - * - * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() - */ - fun getNotificationChannels(): List - /** - * Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` - * - * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) - */ - fun deleteNotificationChannel(channelId: String) - /** - * The list of notification sound files present under `Notifications/Zulip/` - * in the device's shared media storage, - * found with `android.content.ContentResolver.query`. - * - * This is a complex ad-hoc method. - * For detailed behavior, see its implementation. - * - * Requires minimum of Android 10 (API 29) or higher. - * - * See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) - */ - fun listStoredSoundsInNotificationsDirectory(): List - /** - * Wraps `android.content.ContentResolver.insert` combined with - * `android.content.ContentResolver.openOutputStream` and - * `android.content.res.Resources.openRawResource`. - * - * Copies a raw resource audio file to `Notifications/Zulip/` - * directory in device's shared media storage. Returns the URL - * of the target file in media store. - * - * Requires minimum of Android 10 (API 29) or higher. - * - * See: - * https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) - * https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) - * https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) - */ - fun copySoundResourceToMediaStore(targetFileDisplayName: String, sourceResourceName: String): String - /** - * Corresponds to `android.app.NotificationManager.notify`, - * combined with `androidx.core.app.NotificationCompat.Builder`. - * - * The arguments `tag` and `id` go to the `notify` call. - * The rest go to method calls on the builder. - * - * The `color` should be in the form 0xAARRGGBB. - * See [ColorExtension.argbInt]. - * - * The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` - * to get a resource ID to pass to `Builder.setSmallIcon`. - * Whatever name is passed there must appear in keep.xml too: - * see https://github.com/zulip/zulip-flutter/issues/528 . - * - * See: - * https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify - * https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder - */ - fun notify(tag: String?, id: Long, autoCancel: Boolean?, channelId: String, color: Long?, contentIntent: PendingIntent?, contentText: String?, contentTitle: String?, extras: Map?, groupKey: String?, inboxStyle: InboxStyle?, isGroupSummary: Boolean?, messagingStyle: MessagingStyle?, number: Long?, smallIconResourceName: String?) +interface NotificationHostApi { /** - * Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, - * combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. - * - * Returns the messaging style, if any, of an active notification - * that has tag `tag`. If there are several such notifications, - * an arbitrary one of them is used. - * Returns null if there are no such notifications. - * - * See: - * https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() - * https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) - */ - fun getActiveNotificationMessagingStyleByTag(tag: String): MessagingStyle? - /** - * Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. - * - * The keys of entries to fetch from notification's extras bundle must be - * specified in the [desiredExtras] list. If this list is empty, then - * [Notifications.extras] will also be empty. If value of the matched entry - * is not of type string or is null, then that entry will be skipped. - * - * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() - */ - fun getActiveNotifications(desiredExtras: List): List - /** - * Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. - * - * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) + * Retrieves notification data if the app was launched by tapping on a notification. + * + * On iOS, this returns `launchOptions.remoteNotification`, + * which is the raw APNs data dictionary + * if the app launch was opened by a notification tap, + * else null. See Apple doc: + * https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + * + * On Android, this checks if the launch `intent` has the intent data uri + * starting with `zulip://notification` and has the extras bundle containing + * the notification open payload we set during creating the notification. + * Either returns the payload we set in the extras bundle, or null if the + * `intent` doesn't match the preconditions, meaning launch wasn't triggered + * by a notification. */ - fun cancel(tag: String?, id: Long) + fun getNotificationDataFromLaunch(): NotificationDataFromLaunch? companion object { - /** The codec used by AndroidNotificationHostApi. */ + /** The codec used by NotificationHostApi. */ val codec: MessageCodec by lazy { NotificationsPigeonCodec() } - /** Sets up an instance of `AndroidNotificationHostApi` to handle messages through the `binaryMessenger`. */ + /** Sets up an instance of `NotificationHostApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads - fun setUp(binaryMessenger: BinaryMessenger, api: AndroidNotificationHostApi?, messageChannelSuffix: String = "") { + fun setUp(binaryMessenger: BinaryMessenger, api: NotificationHostApi?, messageChannelSuffix: String = "") { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.createNotificationChannel$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val channelArg = args[0] as NotificationChannel - val wrapped: List = try { - api.createNotificationChannel(channelArg) - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getNotificationChannels$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { _, reply -> - val wrapped: List = try { - listOf(api.getNotificationChannels()) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.deleteNotificationChannel$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val channelIdArg = args[0] as String - val wrapped: List = try { - api.deleteNotificationChannel(channelIdArg) - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory$separatedMessageChannelSuffix", codec) + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch$separatedMessageChannelSuffix", codec) if (api != null) { channel.setMessageHandler { _, reply -> val wrapped: List = try { - listOf(api.listStoredSoundsInNotificationsDirectory()) + listOf(api.getNotificationDataFromLaunch()) } catch (exception: Throwable) { wrapError(exception) } @@ -661,109 +158,56 @@ interface AndroidNotificationHostApi { channel.setMessageHandler(null) } } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val targetFileDisplayNameArg = args[0] as String - val sourceResourceNameArg = args[1] as String - val wrapped: List = try { - listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg)) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val tagArg = args[0] as String? - val idArg = args[1] as Long - val autoCancelArg = args[2] as Boolean? - val channelIdArg = args[3] as String - val colorArg = args[4] as Long? - val contentIntentArg = args[5] as PendingIntent? - val contentTextArg = args[6] as String? - val contentTitleArg = args[7] as String? - val extrasArg = args[8] as Map? - val groupKeyArg = args[9] as String? - val inboxStyleArg = args[10] as InboxStyle? - val isGroupSummaryArg = args[11] as Boolean? - val messagingStyleArg = args[12] as MessagingStyle? - val numberArg = args[13] as Long? - val smallIconResourceNameArg = args[14] as String? - val wrapped: List = try { - api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, messagingStyleArg, numberArg, smallIconResourceNameArg) - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotificationMessagingStyleByTag$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val tagArg = args[0] as String - val wrapped: List = try { - listOf(api.getActiveNotificationMessagingStyleByTag(tagArg)) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotifications$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val desiredExtrasArg = args[0] as List - val wrapped: List = try { - listOf(api.getActiveNotifications(desiredExtrasArg)) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } - } - run { - val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.cancel$separatedMessageChannelSuffix", codec) - if (api != null) { - channel.setMessageHandler { message, reply -> - val args = message as List - val tagArg = args[0] as String? - val idArg = args[1] as Long - val wrapped: List = try { - api.cancel(tagArg, idArg) - listOf(null) - } catch (exception: Throwable) { - wrapError(exception) - } - reply.reply(wrapped) - } - } else { - channel.setMessageHandler(null) - } + } + } +} + +private class NotificationsPigeonStreamHandler( + val wrapper: NotificationsPigeonEventChannelWrapper +) : EventChannel.StreamHandler { + var pigeonSink: PigeonEventSink? = null + + override fun onListen(p0: Any?, sink: EventChannel.EventSink) { + pigeonSink = PigeonEventSink(sink) + wrapper.onListen(p0, pigeonSink!!) + } + + override fun onCancel(p0: Any?) { + pigeonSink = null + wrapper.onCancel(p0) + } +} + +interface NotificationsPigeonEventChannelWrapper { + open fun onListen(p0: Any?, sink: PigeonEventSink) {} + + open fun onCancel(p0: Any?) {} +} + +class PigeonEventSink(private val sink: EventChannel.EventSink) { + fun success(value: T) { + sink.success(value) + } + + fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + sink.error(errorCode, errorMessage, errorDetails) + } + + fun endOfStream() { + sink.endOfStream() + } +} + +abstract class NotificationTapEventsStreamHandler : NotificationsPigeonEventChannelWrapper { + companion object { + fun register(messenger: BinaryMessenger, streamHandler: NotificationTapEventsStreamHandler, instanceName: String = "") { + var channelName: String = "dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents" + if (instanceName.isNotEmpty()) { + channelName += ".$instanceName" } + val internalStreamHandler = NotificationsPigeonStreamHandler(streamHandler) + EventChannel(messenger, channelName, NotificationsPigeonMethodCodec).setStreamHandler(internalStreamHandler) } } } + diff --git a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt index eb332d786f..10478d0781 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt @@ -18,6 +18,7 @@ import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.graphics.drawable.IconCompat +import androidx.core.os.bundleOf import io.flutter.embedding.engine.plugins.FlutterPlugin private const val TAG = "ZulipPlugin" @@ -204,6 +205,10 @@ private class AndroidNotificationHost(val context: Context) MainActivity::class.java ).apply { flags = intent.flags.toInt() + putExtra( + "data", + bundleOf(*intent.extrasData.toList().toTypedArray()) + ) } }, it.flags.toInt()) ) } diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 70417e356e..2836fe41c7 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -835,9 +835,9 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" + "errorNotificationOpenAccountLoggedOut": "The account associated with this notification was logged out.", + "@errorNotificationOpenAccountLoggedOut": { + "description": "Error message when the account associated with the notification is not logged in" }, "errorReactionAddingFailedTitle": "Adding reaction failed", "@errorReactionAddingFailedTitle": { diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 260942bdcd..5c0b199c4b 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -557,10 +557,6 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "Konto związane z tym powiadomieniem już nie istnieje.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "aboutPageOpenSourceLicenses": "Licencje otwartego źródła", "@aboutPageOpenSourceLicenses": { "description": "Item title in About Zulip page to navigate to Licenses page" diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index b0717b14ab..71efe1ea64 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -287,10 +287,6 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "Учетной записи, связанной с этим оповещением, больше нет.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "switchAccountButton": "Сменить учетную запись", "@switchAccountButton": { "description": "Label for main-menu button leading to the choose-account page." diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md new file mode 100644 index 0000000000..fb611bee9a --- /dev/null +++ b/docs/howto/push-notifications-ios-simulator.md @@ -0,0 +1,233 @@ +# Testing Push Notifications on iOS Simulator + +For documentation on testing push notifications on Android or a real +iOS Device, see https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md + +This doc describes how to test client side changes on iOS Simulator. +It will demonstrate how to use APNs payloads the server sends to +Apple's Push Notification service to show notifications on iOS +Simulator. + +## 1. Setup dev server + +[Follow the steps in this section to setup a development server, and +register the development server to the production bouncer](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md#server). + +## 2. (Optional) Setup the dev user to receive mobile notifications. + +_Skip to step 6 in which you'll use pre-forged notification payloads, +these intermediate steps records how to get those payloads, which may +be useful in future._ + +We'll use the devlogin user `iago@zulip.com` to test notifications, +log in to that user by going to `/devlogin` on that server on Web. + +And then follow the steps [here](https://zulip.com/help/mobile-notifications) +to enable Mobile Notifications for "Channels". + +## 3. (Optional) Login to the dev user on zulip-flutter. + + + +To login to this user in the Flutter app, you'll need the password +that was generated by the development server. You can print the +password by running this command inside your `vagrant ssh` shell: +``` +$ ./manage.py print_initial_password iago@zulip.com +``` + +Then run the app on the iOS Simulator, accept the permission to +receive push notifications, and then login to the dev user +(`iago@zulip.com`). + +## 4. (Optional) Edit the server code to log the notification payload. + +We need to retrieve the APNs payload the server generates and sends +to the bouncer. To do that we can add a log statement after the +server completes generating the APNs in `zerver/lib/push_notifications.py`: + +```diff + apns_payload = get_message_payload_apns( + user_profile, + message, + trigger, + mentioned_user_group_id, + mentioned_user_group_name, + can_access_sender, + ) + gcm_payload, gcm_options = get_message_payload_gcm( + user_profile, message, mentioned_user_group_id, mentioned_user_group_name, can_access_sender + ) + logger.info("Sending push notifications to mobile clients for user %s", user_profile_id) ++ logger.info("APNS payload %s", orjson.dumps(apns_payload)) + + android_devices = list( + PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.FCM).order_by("id") +``` + +## 5. (Optional) Send messages to the dev user + +To generate notifications to the dev user `iago@zulip.com` we need to +send messages from another user. For a variety of different types of +payloads try sending a message in a topic, a message in a group DM, +and one in one-one DM. Then look for the payloads in the server logs +by searching for "APNS payload". + +The logged payload JSON will have different structure than what an +iOS device actually receives, to fix that, run the payload through +the following command: + +```shell-session +$ echo '{"alert":{"title": ...' | jq '{aps: {alert: .alert, sound: .sound, badge: .badge}, zulip: .custom.zulip}' +``` + +## 6. Push APNs payload to iOS Simulator + +_If you skipped steps 2-5, you'll need pre-forged APNs payloads for +existing messages in a default development server messages for the +user `iago@zulip.com`:_ + +
+Preforged payload: dm.json + +```json +{ + "aps": { + "alert": { + "title": "Zoe", + "subtitle": "", + "body": "But wouldn't that show you contextually who is in the audience before you have to open the compose box?" + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 7, + "sender_email": "user7@zulipdev.com", + "time": 1740890583, + "recipient_type": "private", + "message_ids": [ + 87 + ] + } +} +``` + +
+ +
+Preforged payload: group_dm.json + +```json +{ + "aps": { + "alert": { + "title": "Othello, the Moor of Venice, Polonius (guest), Iago", + "subtitle": "Othello, the Moor of Venice:", + "body": "Sit down awhile; And let us once again assail your ears, That are so fortified against our story What we have two nights seen." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 12, + "sender_email": "user12@zulipdev.com", + "time": 1740533641, + "recipient_type": "private", + "pm_users": "11,12,13", + "message_ids": [ + 17 + ] + } +} +``` + +
+ +
+Preforged payload: stream.json + +```json +{ + "aps": { + "alert": { + "title": "#devel > plotter", + "subtitle": "Desdemona:", + "body": "Despite the fact that such a claim at first glance seems counterintuitive, it is derived from known results. Electrical engineering follows a cycle of four phases: location, refinement, visualization, and evaluation." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 9, + "sender_email": "user9@zulipdev.com", + "time": 1740558997, + "recipient_type": "stream", + "stream": "devel", + "stream_id": 11, + "topic": "plotter", + "message_ids": [ + 40 + ] + } +} +``` + +
+ +To receive a notification on the iOS Simulator, we need to push +the APNs payload to the specific running iOS Simulator by using it's +device ID, you can get the device ID by running the following command: + +```shell-session +$ xcrun simctl list 'devices' 'booted' +``` + +
+Example output: + +```shell-session +$ xcrun simctl list 'devices' 'booted' +== Devices == +-- iOS 18.3 -- + iPhone 16 Pro (90CC33B2-679B-4053-B380-7B986A29F28C) (Booted) +``` + +
+ +And then push the payload using the following command: + +```shell-session +$ xcrun simctl push [device-id] com.zulip.flutter [payload json path] +``` + +
+Example output: + +```shell-session +$ xcrun simctl push 90CC33B2-679B-4053-B380-7B986A29F28C com.zulip.flutter ./dm.json +Notification sent to 'com.zulip.flutter' +``` + +
+ +Now, on the iOS Simulator you should have a notification and tapping +on it should route to the specific conversation. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b4928e2220..7df051a142 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34E9F082D776BEB0009AED2 /* Notifications.g.swift */; }; F311C174AF9C005CE4AADD72 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -48,6 +49,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.g.swift; sourceTree = ""; }; B3AF53A72CA20BD10039801D /* Zulip.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Zulip.xcconfig; path = Flutter/Zulip.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ @@ -115,6 +117,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -297,6 +300,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b636303481..b518f56023 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,11 +3,72 @@ import Flutter @main @objc class AppDelegate: FlutterAppDelegate { + private var notificationTapEventListener: NotificationTapEventListener? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + guard let controller = window?.rootViewController as? FlutterViewController else { + fatalError("rootViewController is not type FlutterViewController") + } + + // Retrieve the remote notification payload from launch options; + // this will be null if the launch wasn't triggered by a notification. + let notificationPayload = launchOptions?[.remoteNotification] as? [AnyHashable : Any] + let api = NotificationHostApiImpl(notificationPayload.map { NotificationDataFromLaunch(payload: $0) }) + NotificationHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: api) + + notificationTapEventListener = NotificationTapEventListener() + NotificationTapEventsStreamHandler.register(with: controller.binaryMessenger, streamHandler: notificationTapEventListener!) + + // Setup handler for notification tap while the app is running. + UNUserNotificationCenter.current().delegate = self + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if let listener = notificationTapEventListener { + let userInfo = response.notification.request.content.userInfo + listener.onNotificationTapEvent(event: NotificationTapEvent(payload: userInfo)) + completionHandler() + } + } +} + +private class NotificationHostApiImpl: NotificationHostApi { + private let maybeDataFromLaunch: NotificationDataFromLaunch? + + init(_ maybeDataFromLaunch: NotificationDataFromLaunch?) { + self.maybeDataFromLaunch = maybeDataFromLaunch + } + + func getNotificationDataFromLaunch() -> NotificationDataFromLaunch? { + maybeDataFromLaunch + } +} + +class NotificationTapEventListener: NotificationTapEventsStreamHandler { + var eventSink: PigeonEventSink? + + override func onListen(withArguments arguments: Any?, sink: PigeonEventSink) { + eventSink = sink + } + + func onNotificationTapEvent(event: NotificationTapEvent) { + if let eventSink = eventSink { + eventSink.success(event) + } + } + + func onEventsDone() { + eventSink?.endOfStream() + eventSink = nil + } } diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift new file mode 100644 index 0000000000..cc9b709477 --- /dev/null +++ b/ios/Runner/Notifications.g.swift @@ -0,0 +1,275 @@ +// Autogenerated from Pigeon (v25.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NotificationDataFromLaunch { + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + var payload: [AnyHashable?: Any?] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationDataFromLaunch? { + let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] + + return NotificationDataFromLaunch( + payload: payload + ) + } + func toList() -> [Any?] { + return [ + payload + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NotificationTapEvent { + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + var payload: [AnyHashable?: Any?] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? { + let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] + + return NotificationTapEvent( + payload: payload + ) + } + func toList() -> [Any?] { + return [ + payload + ] + } +} + +private class NotificationsPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?]) + case 130: + return NotificationTapEvent.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NotificationsPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NotificationDataFromLaunch { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? NotificationTapEvent { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NotificationsPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NotificationsPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NotificationsPigeonCodecWriter(data: data) + } +} + +class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter()) +} + +var notificationsPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: NotificationsPigeonCodecReaderWriter()); + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// On iOS, this returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + /// + /// On Android, this checks if the launch `intent` has the intent data uri + /// starting with `zulip://notification` and has the extras bundle containing + /// the notification open payload we set during creating the notification. + /// Either returns the payload we set in the extras bundle, or null if the + /// `intent` doesn't match the preconditions, meaning launch wasn't triggered + /// by a notification. + func getNotificationDataFromLaunch() throws -> NotificationDataFromLaunch? +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class NotificationHostApiSetup { + static var codec: FlutterStandardMessageCodec { NotificationsPigeonCodec.shared } + /// Sets up an instance of `NotificationHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NotificationHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// On iOS, this returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + /// + /// On Android, this checks if the launch `intent` has the intent data uri + /// starting with `zulip://notification` and has the extras bundle containing + /// the notification open payload we set during creating the notification. + /// Either returns the payload we set in the extras bundle, or null if the + /// `intent` doesn't match the preconditions, meaning launch wasn't triggered + /// by a notification. + let getNotificationDataFromLaunchChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getNotificationDataFromLaunchChannel.setMessageHandler { _, reply in + do { + let result = try api.getNotificationDataFromLaunch() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getNotificationDataFromLaunchChannel.setMessageHandler(nil) + } + } +} + +private class PigeonStreamHandler: NSObject, FlutterStreamHandler { + private let wrapper: PigeonEventChannelWrapper + private var pigeonSink: PigeonEventSink? = nil + + init(wrapper: PigeonEventChannelWrapper) { + self.wrapper = wrapper + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + pigeonSink = PigeonEventSink(events) + wrapper.onListen(withArguments: arguments, sink: pigeonSink!) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + pigeonSink = nil + wrapper.onCancel(withArguments: arguments) + return nil + } +} + +class PigeonEventChannelWrapper { + func onListen(withArguments arguments: Any?, sink: PigeonEventSink) {} + func onCancel(withArguments arguments: Any?) {} +} + +class PigeonEventSink { + private let sink: FlutterEventSink + + init(_ sink: @escaping FlutterEventSink) { + self.sink = sink + } + + func success(_ value: ReturnType) { + sink(value) + } + + func error(code: String, message: String?, details: Any?) { + sink(FlutterError(code: code, message: message, details: details)) + } + + func endOfStream() { + sink(FlutterEndOfEventStream) + } + +} + +class NotificationTapEventsStreamHandler: PigeonEventChannelWrapper { + static func register(with messenger: FlutterBinaryMessenger, + instanceName: String = "", + streamHandler: NotificationTapEventsStreamHandler) { + var channelName = "dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents" + if !instanceName.isEmpty { + channelName += ".\(instanceName)" + } + let internalStreamHandler = PigeonStreamHandler(wrapper: streamHandler) + let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: notificationsPigeonMethodCodec) + channel.setStreamHandler(internalStreamHandler) + } +} + diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 3203569966..b2919212e1 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1221,11 +1221,11 @@ abstract class ZulipLocalizations { /// **'Failed to open notification'** String get errorNotificationOpenTitle; - /// Error message when the account associated with the notification is not found + /// Error message when the account associated with the notification is not logged in /// /// In en, this message translates to: - /// **'The account associated with this notification no longer exists.'** - String get errorNotificationOpenAccountMissing; + /// **'The account associated with this notification was logged out.'** + String get errorNotificationOpenAccountLoggedOut; /// Error title when adding a message reaction fails /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 20ad3cbe24..04d0ae1816 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -653,7 +653,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountLoggedOut => 'The account associated with this notification was logged out.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a88981fc26..7a17e7b45b 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -653,7 +653,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountLoggedOut => 'The account associated with this notification was logged out.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index be6eee8870..6a43a65d1e 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -653,7 +653,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountLoggedOut => 'The account associated with this notification was logged out.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 8e51c7a19b..751db443f1 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -653,7 +653,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountLoggedOut => 'The account associated with this notification was logged out.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index 0e4009a462..97f036b0ce 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -653,7 +653,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Otwieranie powiadomienia bez powodzenia'; @override - String get errorNotificationOpenAccountMissing => 'Konto związane z tym powiadomieniem już nie istnieje.'; + String get errorNotificationOpenAccountLoggedOut => 'The account associated with this notification was logged out.'; @override String get errorReactionAddingFailedTitle => 'Dodanie reakcji bez powodzenia'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index f3ec9d623c..bfbb1715b6 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -653,7 +653,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не удалось открыть оповещения'; @override - String get errorNotificationOpenAccountMissing => 'Учетной записи, связанной с этим оповещением, больше нет.'; + String get errorNotificationOpenAccountLoggedOut => 'The account associated with this notification was logged out.'; @override String get errorReactionAddingFailedTitle => 'Не удалось добавить реакцию'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 6d22409eb5..68e978580e 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -653,7 +653,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Nepodarilo sa otvoriť oznámenie'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountLoggedOut => 'The account associated with this notification was logged out.'; @override String get errorReactionAddingFailedTitle => 'Nepodarilo sa pridať reakciu'; diff --git a/lib/host/android_notifications.g.dart b/lib/host/android_notifications.g.dart index bc29b2d794..57fe24eed6 100644 --- a/lib/host/android_notifications.g.dart +++ b/lib/host/android_notifications.g.dart @@ -78,6 +78,7 @@ class AndroidIntent { required this.action, required this.dataUrl, this.flags = 0, + required this.extrasData, }); String action; @@ -87,11 +88,14 @@ class AndroidIntent { /// A combination of flags from [IntentFlag]. int flags; + Map extrasData; + Object encode() { return [ action, dataUrl, flags, + extrasData, ]; } @@ -101,6 +105,7 @@ class AndroidIntent { action: result[0]! as String, dataUrl: result[1]! as String, flags: result[2]! as int, + extrasData: (result[3] as Map?)!.cast(), ); } } diff --git a/lib/host/notifications.dart b/lib/host/notifications.dart new file mode 100644 index 0000000000..6c3e593e2c --- /dev/null +++ b/lib/host/notifications.dart @@ -0,0 +1 @@ +export './notifications.g.dart'; diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart new file mode 100644 index 0000000000..70f6f59193 --- /dev/null +++ b/lib/host/notifications.g.dart @@ -0,0 +1,163 @@ +// Autogenerated from Pigeon (v25.0.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +class NotificationDataFromLaunch { + NotificationDataFromLaunch({ + required this.payload, + }); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + Map payload; + + Object encode() { + return [ + payload, + ]; + } + + static NotificationDataFromLaunch decode(Object result) { + result as List; + return NotificationDataFromLaunch( + payload: (result[0] as Map?)!.cast(), + ); + } +} + +class NotificationTapEvent { + NotificationTapEvent({ + required this.payload, + }); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + Map payload; + + Object encode() { + return [ + payload, + ]; + } + + static NotificationTapEvent decode(Object result) { + result as List; + return NotificationTapEvent( + payload: (result[0] as Map?)!.cast(), + ); + } +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is NotificationDataFromLaunch) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is NotificationTapEvent) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return NotificationDataFromLaunch.decode(readValue(buffer)!); + case 130: + return NotificationTapEvent.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec()); + +class NotificationHostApi { + /// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NotificationHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// On iOS, this returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + /// + /// On Android, this checks if the launch `intent` has the intent data uri + /// starting with `zulip://notification` and has the extras bundle containing + /// the notification open payload we set during creating the notification. + /// Either returns the payload we set in the extras bundle, or null if the + /// `intent` doesn't match the preconditions, meaning launch wasn't triggered + /// by a notification. + Future getNotificationDataFromLaunch() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as NotificationDataFromLaunch?); + } + } +} + +Stream notificationTapEvents( {String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel notificationTapEventsChannel = + EventChannel('dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents$instanceName', pigeonMethodCodec); + return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) { + return event as NotificationTapEvent; + }); +} + diff --git a/lib/model/binding.dart b/lib/model/binding.dart index d8e860dca0..199758e9df 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:wakelock_plus/wakelock_plus.dart' as wakelock_plus; import '../host/android_notifications.dart'; +import '../host/notifications.dart' as notif_pigeon; import '../log.dart'; import '../widgets/store.dart'; import 'store.dart'; @@ -168,6 +169,9 @@ abstract class ZulipBinding { /// Wraps the [AndroidNotificationHostApi] constructor. AndroidNotificationHostApi get androidNotificationHost; + /// Wraps the [notif_pigeon.NotificationHostApi] class. + NotificationPigeonApi get notificationPigeonApi; + /// Pick files from the media library, via package:file_picker. /// /// This wraps [file_picker.pickFiles]. @@ -310,6 +314,19 @@ class PackageInfo { }); } +// Pigeon generates methods under `@EventChannelApi` annotated classes +// in global scope of the generated file. This is a helper class to +// namespace the notification related Pigeon API under a single class. +class NotificationPigeonApi { + final _hostApi = notif_pigeon.NotificationHostApi(); + + Future getNotificationDataFromLaunch() => + _hostApi.getNotificationDataFromLaunch(); + + Stream notificationTapEventsStream() => + notif_pigeon.notificationTapEvents(); +} + /// A concrete binding for use in the live application. /// /// The global store returned by [getGlobalStore], and consequently by @@ -442,6 +459,9 @@ class LiveZulipBinding extends ZulipBinding { @override AndroidNotificationHostApi get androidNotificationHost => AndroidNotificationHostApi(); + @override + NotificationPigeonApi get notificationPigeonApi => NotificationPigeonApi(); + @override Future pickFiles({ bool allowMultiple = false, diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 1d74db6854..871e0e3a30 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -2,25 +2,18 @@ import 'dart:async'; import 'dart:io'; import 'package:http/http.dart' as http; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart' hide Notification; import '../api/model/model.dart'; import '../api/notifications.dart'; -import '../generated/l10n/zulip_localizations.dart'; import '../host/android_notifications.dart'; import '../log.dart'; import '../model/binding.dart'; import '../model/localizations.dart'; import '../model/narrow.dart'; -import '../widgets/app.dart'; import '../widgets/color.dart'; -import '../widgets/dialog.dart'; -import '../widgets/message_list.dart'; -import '../widgets/page.dart'; -import '../widgets/store.dart'; import '../widgets/theme.dart'; +import 'open.dart'; AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost; @@ -280,7 +273,7 @@ class NotificationDisplayManager { name: data.senderFullName, iconBitmap: await _fetchBitmap(data.senderAvatarUrl)))); - final intentDataUrl = NotificationOpenPayload( + final intentExtras = NotificationNavigationData( realmUrl: data.realmUrl, userId: data.userId, narrow: switch (data.recipient) { @@ -288,7 +281,22 @@ class NotificationDisplayManager { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).toAndroidIntentExtras(); + + // Our own code doesn't look at the message details in this URL, + // the "data URL" we put on the intent. Instead, we get data from + // the "extra" we put on the intent. + // + // But the URL needs to be distinct each time; if it were the same + // for two notifications, then we'd get the same PendingIntent twice. + // That's because PendingIntents get reused when the Intents are + // equal, and for that purpose extras don't count. See doc: + // https://developer.android.com/reference/android/app/PendingIntent + // and in particular the discussion of Intent.filterEquals. + final intentUrl = Uri( + scheme: 'zulip', + host: 'notification', + path: _messageKey(data)); await _androidHost.notify( id: kNotificationId, @@ -314,7 +322,7 @@ class NotificationDisplayManager { flags: PendingIntentFlag.immutable, intent: AndroidIntent( action: IntentAction.view, - dataUrl: intentDataUrl.toString(), + dataUrl: intentUrl.toString(), // See these sections in the Android docs: // https://developer.android.com/guide/components/activities/tasks-and-back-stack#TaskLaunchModes // https://developer.android.com/reference/android/content/Intent#FLAG_ACTIVITY_CLEAR_TOP @@ -329,7 +337,8 @@ class NotificationDisplayManager { // notification manager does; so use that. It has no effect as long // as we only have one activity; but if we add more, it will destroy // all the activities on top of the target one. - flags: IntentFlag.activityClearTop | IntentFlag.activityNewTask)), + flags: IntentFlag.activityClearTop | IntentFlag.activityNewTask, + extrasData: intentExtras)), autoCancel: true, ); @@ -437,6 +446,10 @@ class NotificationDisplayManager { @visibleForTesting static const kExtraLastZulipMessageId = 'lastZulipMessageId'; + static String _messageKey(MessageFcmMessage data) { + return '${_groupKey(data)}|${data.zulipMessageId}'; + } + static String _conversationKey(MessageFcmMessage data, String groupKey) { final conversation = switch (data.recipient) { FcmMessageChannelRecipient(:var streamId, :var topic) => 'stream:$streamId:${topic.canonicalize()}', @@ -453,59 +466,6 @@ class NotificationDisplayManager { static String _personKey(Uri realmUrl, int userId) => "$realmUrl|$userId"; - /// Provides the route and the account ID by parsing the notification URL. - /// - /// The URL must have been generated using [NotificationOpenPayload.buildUrl] - /// while creating the notification. - /// - /// Returns null and shows an error dialog if the associated account is not - /// found in the global store. - static AccountRoute? routeForNotification({ - required BuildContext context, - required Uri url, - }) { - final globalStore = GlobalStoreWidget.of(context); - - assert(debugLog('got notif: url: $url')); - assert(url.scheme == 'zulip' && url.host == 'notification'); - final payload = NotificationOpenPayload.parseUrl(url); - - final account = globalStore.accounts.firstWhereOrNull( - (account) => account.realmUrl.origin == payload.realmUrl.origin - && account.userId == payload.userId); - if (account == null) { // TODO(log) - final zulipLocalizations = ZulipLocalizations.of(context); - showErrorDialog(context: context, - title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountMissing); - return null; - } - - return MessageListPage.buildRoute( - accountId: account.id, - // TODO(#82): Open at specific message, not just conversation - narrow: payload.narrow); - } - - /// Navigates to the [MessageListPage] of the specific conversation - /// given the `zulip://notification/…` Android intent data URL, - /// generated with [NotificationOpenPayload.buildUrl] while creating - /// the notification. - static Future navigateForNotification(Uri url) async { - assert(debugLog('opened notif: url: $url')); - - NavigatorState navigator = await ZulipApp.navigator; - final context = navigator.context; - assert(context.mounted); - if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that - - final route = routeForNotification(context: context, url: url); - if (route == null) return; // TODO(log) - - // TODO(nav): Better interact with existing nav stack on notif open - unawaited(navigator.push(route)); - } - static Future _fetchBitmap(Uri url) async { try { // TODO timeout to prevent waiting indefinitely @@ -519,86 +479,3 @@ class NotificationDisplayManager { return null; } } - -/// The information contained in 'zulip://notification/…' internal -/// Android intent data URL, used for notification-open flow. -class NotificationOpenPayload { - final Uri realmUrl; - final int userId; - final Narrow narrow; - - NotificationOpenPayload({ - required this.realmUrl, - required this.userId, - required this.narrow, - }); - - factory NotificationOpenPayload.parseUrl(Uri url) { - if (url case Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': var realmUrlStr, - 'user_id': var userIdStr, - 'narrow_type': var narrowType, - // In case of narrowType == 'topic': - // 'channel_id' and 'topic' handled below. - - // In case of narrowType == 'dm': - // 'all_recipient_ids' handled below. - }, - )) { - final realmUrl = Uri.parse(realmUrlStr); - final userId = int.parse(userIdStr, radix: 10); - - final Narrow narrow; - switch (narrowType) { - case 'topic': - final channelIdStr = url.queryParameters['channel_id']!; - final channelId = int.parse(channelIdStr, radix: 10); - final topicStr = url.queryParameters['topic']!; - narrow = TopicNarrow(channelId, TopicName(topicStr)); - case 'dm': - final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; - final allRecipientIds = allRecipientIdsStr.split(',') - .map((idStr) => int.parse(idStr, radix: 10)) - .toList(growable: false); - narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId); - default: - throw const FormatException(); - } - - return NotificationOpenPayload( - realmUrl: realmUrl, - userId: userId, - narrow: narrow, - ); - } else { - // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 - throw const FormatException(); - } - } - - Uri buildUrl() { - return Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': realmUrl.toString(), - 'user_id': userId.toString(), - ...(switch (narrow) { - TopicNarrow(streamId: var channelId, :var topic) => { - 'narrow_type': 'topic', - 'channel_id': channelId.toString(), - 'topic': topic.apiName, - }, - DmNarrow(:var allRecipientIds) => { - 'narrow_type': 'dm', - 'all_recipient_ids': allRecipientIds.join(','), - }, - _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'), - }) - }, - ); - } -} diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart new file mode 100644 index 0000000000..e6561b0140 --- /dev/null +++ b/lib/notifications/open.dart @@ -0,0 +1,296 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../host/notifications.dart'; +import '../log.dart'; +import '../model/binding.dart'; +import '../model/narrow.dart'; +import '../widgets/app.dart'; +import '../widgets/dialog.dart'; +import '../widgets/message_list.dart'; +import '../widgets/page.dart'; +import '../widgets/store.dart'; + +NotificationPigeonApi get _notifPigeonApi => ZulipBinding.instance.notificationPigeonApi; + +/// Service for handling notification navigation. +class NotificationOpenManager { + static NotificationOpenManager get instance => (_instance ??= NotificationOpenManager._()); + static NotificationOpenManager? _instance; + + NotificationOpenManager._(); + + /// Reset the state of the [NotificationOpenManager], for testing. + @visibleForTesting + static void debugReset() { + _instance = null; + } + + NotificationDataFromLaunch? _notifDataFromLaunch; + + /// A [Future] that completes to signal that the initialization of + /// [NotificationOpenManager] has completed or errored. + Future? get initializationFuture => _initializedSignal?.future; + + Completer? _initializedSignal; + + Future init() async { + assert(_initializedSignal == null); + _initializedSignal ??= Completer(); + try { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + _notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch(); + _notifPigeonApi.notificationTapEventsStream() + .listen(_navigateForNotification); + + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Do nothing; we don't offer notifications on these platforms. + break; + } + } finally { + _initializedSignal!.complete(); + } + } + + /// Provides the route to open if the app was launched through a tap on + /// a notification. + /// + /// Returns null if app launch wasn't triggered by a notification, or if + /// an error occurs while determining the route for the notification, in + /// which case an error dialog is also shown. + /// + /// The context argument is used to look up [GlobalStoreWidget], + /// [ZulipLocalizations], the [Navigator] and [Theme], where the latter + /// three are used to show an error dialog if there is a failure. + AccountRoute? routeForNotificationFromLaunch({required BuildContext context}) { + final data = _notifDataFromLaunch; + if (data == null) return null; + assert(debugLog('opened notif: ${jsonEncode(data.payload)}')); + + final notifNavData = _tryParsePayload(context, data.payload); + if (notifNavData == null) return null; // TODO(log) + return _routeForNotification(context, notifNavData); + } + + /// Provides the route to open by parsing the notification payload. + /// + /// Returns null and shows an error dialog if the associated account is not + /// found in the global store. + AccountRoute? _routeForNotification(BuildContext context, NotificationNavigationData data) { + final globalStore = GlobalStoreWidget.of(context); + + final account = globalStore.accounts.firstWhereOrNull( + (account) => account.realmUrl.origin == data.realmUrl.origin + && account.userId == data.userId); + if (account == null) { // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle, + message: zulipLocalizations.errorNotificationOpenAccountLoggedOut); + return null; + } + + return MessageListPage.buildRoute( + accountId: account.id, + // TODO(#82): Open at specific message, not just conversation + narrow: data.narrow); + } + + /// Navigates to the [MessageListPage] of the specific conversation + /// for the provided payload that was attached while creating the + /// notification. + Future _navigateForNotification(NotificationTapEvent event) async { + assert(debugLog('opened notif: ${jsonEncode(event.payload)}')); + + NavigatorState navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + final notifNavData = _tryParsePayload(context, event.payload); + if (notifNavData == null) return; // TODO(log) + final route = _routeForNotification(context, notifNavData); + if (route == null) return; // TODO(log) + + // TODO(nav): Better interact with existing nav stack on notif open + unawaited(navigator.push(route)); + } + + NotificationNavigationData? _tryParsePayload( + BuildContext context, + Map payload, + ) { + try { + return switch (defaultTargetPlatform) { + TargetPlatform.android => + NotificationNavigationData.fromAndroidIntentExtras(payload), + TargetPlatform.iOS => + NotificationNavigationData.fromIosApnsPayload(payload), + _ => + throw UnsupportedError('Unsupported target platform: ' + '$defaultTargetPlatform'), + }; + } on FormatException catch (e, st) { + assert(debugLog('$e\n$st')); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle); + return null; + } + } +} + +class NotificationNavigationData { + final Uri realmUrl; + final int userId; + final Narrow narrow; + + NotificationNavigationData({ + required this.realmUrl, + required this.userId, + required this.narrow, + }) : assert(narrow is TopicNarrow || narrow is DmNarrow); + + /// Parses the iOS APNs payload and retrieves the information + /// required for navigation. + factory NotificationNavigationData.fromIosApnsPayload(Map payload) { + if (payload case { + 'zulip': { + 'user_id': final int userId, + 'sender_id': final int senderId, + } && final zulipData, + }) { + final eventType = zulipData['event']; + if (eventType != null && eventType != 'message') { + // On Android, we also receive "remove" notification messages, tagged + // with an `event` field with value 'remove'. As of Zulip Server 10, + // however, these are not yet sent to iOS devices, and we don't have a + // way to handle them even if they were. + // + // The messages we currently do receive, and can handle, are analogous + // to Android notification messages of event type 'message'. On the + // assumption that some future version of the Zulip server will send + // explicit event types in APNs messages, accept messages with that + // `event` value, but no other. + throw const FormatException(); + } + + final String realmUrl; + switch (zulipData) { + case {'realm_url': final String value}: + realmUrl = value; + case {'realm_uri': final String value}: + realmUrl = value; + default: + throw const FormatException(); + } + + final Narrow narrow = switch (zulipData) { + { + 'recipient_type': 'stream', + // TODO(server-5) remove this comment. + // We require 'stream_id' here but that is new from Server 5.0, + // resulting in failure on pre-5.0 servers. + 'stream_id': final int streamId, + 'topic': final String topic, + } => + TopicNarrow(streamId, TopicName(topic)), + + {'recipient_type': 'private', 'pm_users': final String pmUsers} => + DmNarrow( + allRecipientIds: pmUsers + .split(',') + .map((e) => int.parse(e, radix: 10)) + .toList(growable: false)..sort(), + selfUserId: userId), + + {'recipient_type': 'private'} => + DmNarrow.withUser(senderId, selfUserId: userId), + + _ => throw const FormatException(), + }; + + return NotificationNavigationData( + realmUrl: Uri.parse(realmUrl), + userId: userId, + narrow: narrow); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + /// Parses the Android notification open data that was created using + /// [toAndroidIntentExtras]. + factory NotificationNavigationData.fromAndroidIntentExtras(Map payload) { + if (payload case { + 'realm_url': final String realmUrlStr, + 'user_id': final String userIdStr, + } && final data) { + final userId = int.parse(userIdStr, radix: 10); + + final narrow = switch (data) { + { + 'narrow_type': 'topic', + 'channel_id': final String channelIdStr, + 'topic': final String topicStr, + } => + TopicNarrow( + int.parse(channelIdStr, radix: 10), + TopicName.fromJson(topicStr)), + + { + 'narrow_type': 'dm', + 'all_recipient_ids': final String allRecipientIdsStr, + } => + DmNarrow( + allRecipientIds: allRecipientIdsStr + .split(',') + .map((e) => int.parse(e, radix: 10)) + .toList(growable: false)..sort(), + selfUserId: userId), + + _ => throw const FormatException(), + }; + + return NotificationNavigationData( + realmUrl: Uri.parse(realmUrlStr), + userId: userId, + narrow: narrow); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + Map toAndroidIntentExtras() { + return { + 'realm_url': realmUrl.toString(), + 'user_id': userId.toString(), + ...(switch (narrow) { + TopicNarrow(streamId: final channelId, :final topic) => { + 'narrow_type': 'topic', + 'channel_id': channelId.toString(), + 'topic': topic.toJson(), + }, + DmNarrow(:final allRecipientIds) => { + 'narrow_type': 'dm', + 'all_recipient_ids': allRecipientIds.join(','), + }, + // This case should be unreachable. + _ => throw UnsupportedError('Unknown narrow of type "${narrow.runtimeType}"'), + }), + }; + } +} diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index 738bb98a2e..f9aa2fb654 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -8,6 +8,7 @@ import '../firebase_options.dart'; import '../log.dart'; import '../model/binding.dart'; import 'display.dart'; +import 'open.dart'; @pragma('vm:entry-point') class NotificationService { @@ -53,6 +54,8 @@ class NotificationService { Future start() async { switch (defaultTargetPlatform) { case TargetPlatform.android: + await NotificationOpenManager.instance.init(); + await ZulipBinding.instance.firebaseInitializeApp( options: kFirebaseOptionsAndroid); @@ -77,6 +80,8 @@ class NotificationService { await _getFcmToken(); case TargetPlatform.iOS: // TODO(#324): defer requesting notif permission + await NotificationOpenManager.instance.init(); + await ZulipBinding.instance.firebaseInitializeApp( options: kFirebaseOptionsIos); diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index d251b88b58..b5ae91db30 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -7,9 +7,10 @@ import 'package:flutter/scheduler.dart'; import '../generated/l10n/zulip_localizations.dart'; import '../log.dart'; import '../model/actions.dart'; +import '../model/binding.dart'; import '../model/localizations.dart'; import '../model/store.dart'; -import '../notifications/display.dart'; +import '../notifications/open.dart'; import 'about_zulip.dart'; import 'dialog.dart'; import 'home.dart'; @@ -151,10 +152,13 @@ class ZulipApp extends StatefulWidget { } class _ZulipAppState extends State with WidgetsBindingObserver { + late final Future _globalStoreFuture; + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + _globalStoreFuture = ZulipBinding.instance.getGlobalStoreUniquely(); } @override @@ -163,27 +167,18 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } - List> _handleGenerateInitialRoutes(String initialRoute) { + List> _handleGenerateInitialRoutes(_) { // The `_ZulipAppState.context` lacks the required ancestors. Instead // we use the Navigator which should be available when this callback is - // called and it's context should have the required ancestors. + // called and its context should have the required ancestors. final context = ZulipApp.navigatorKey.currentContext!; - final initialRouteUrl = Uri.tryParse(initialRoute); - if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - final route = NotificationDisplayManager.routeForNotification( - context: context, - url: initialRouteUrl); - - if (route != null) { - return [ - HomePage.buildRoute(accountId: route.accountId), - route, - ]; - } else { - // The account didn't match any existing accounts, - // fall through to show the default route below. - } + final route = NotificationOpenManager.instance.routeForNotificationFromLaunch(context: context); + if (route != null) { + return [ + HomePage.buildRoute(accountId: route.accountId), + route, + ]; } final globalStore = GlobalStoreWidget.of(context); @@ -199,57 +194,117 @@ class _ZulipAppState extends State with WidgetsBindingObserver { @override Future didPushRouteInformation(routeInformation) async { - switch (routeInformation.uri) { - case Uri(scheme: 'zulip', host: 'login') && var url: - await LoginPage.handleWebAuthUrl(url); - return true; - case Uri(scheme: 'zulip', host: 'notification') && var url: - await NotificationDisplayManager.navigateForNotification(url); - return true; + if (routeInformation.uri + case Uri(scheme: 'zulip', host: 'login') && var url) { + await LoginPage.handleWebAuthUrl(url); + return true; } return super.didPushRouteInformation(routeInformation); } @override Widget build(BuildContext context) { - return GlobalStoreWidget( - child: Builder(builder: (context) { - return MaterialApp( - onGenerateTitle: (BuildContext context) { - return ZulipLocalizations.of(context).zulipAppTitle; - }, - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - // The context has to be taken from the [Builder] because - // [zulipThemeData] requires access to [GlobalStoreWidget] in the tree. - theme: zulipThemeData(context), - - navigatorKey: ZulipApp.navigatorKey, - navigatorObservers: [ - if (widget.navigatorObservers != null) - ...widget.navigatorObservers!, - _PreventEmptyStack(), - ], - builder: (BuildContext context, Widget? child) { - if (!ZulipApp.ready.value) { - SchedulerBinding.instance.addPostFrameCallback( - (_) => widget._declareReady()); - } - GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); - return child!; - }, + return DeferredBuilderWidget( + future: Future.wait([ + _globalStoreFuture, + if (NotificationOpenManager.instance.initializationFuture + case final Future future) + future, + ]), + builder: (context, result) { + final [store, ...] = result; + return GlobalStoreWidget( + store: store as GlobalStore, + child: Builder(builder: (context) { + return MaterialApp( + onGenerateTitle: (BuildContext context) { + return ZulipLocalizations.of(context).zulipAppTitle; + }, + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, + // The context has to be taken from the [Builder] because + // [zulipThemeData] requires access to [GlobalStoreWidget] in the tree. + theme: zulipThemeData(context), + + navigatorKey: ZulipApp.navigatorKey, + navigatorObservers: [ + if (widget.navigatorObservers != null) + ...widget.navigatorObservers!, + _PreventEmptyStack(), + ], + builder: (BuildContext context, Widget? child) { + if (!ZulipApp.ready.value) { + SchedulerBinding.instance.addPostFrameCallback( + (_) => widget._declareReady()); + } + GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); + return child!; + }, + + // We use onGenerateInitialRoutes for the real work of specifying the + // initial nav state. To do that we need [MaterialApp] to decide to + // build a [Navigator]... which means specifying either `home`, `routes`, + // `onGenerateRoute`, or `onUnknownRoute`. Make it `onGenerateRoute`. + // It never actually gets called, though: `onGenerateInitialRoutes` + // handles startup, and then we always push whole routes with methods + // like [Navigator.push], never mere names as with [Navigator.pushNamed]. + onGenerateRoute: (_) => null, + + onGenerateInitialRoutes: _handleGenerateInitialRoutes); + })); + }); + } +} + +/// A widget that defers the builder until the provided [future] completes. +/// +/// It shows a placeholder widget while it waits for the [future] +/// to complete. +class DeferredBuilderWidget extends StatefulWidget { + const DeferredBuilderWidget({ + super.key, + required this.future, + required this.builder, + this.placeholderBuilder = _defaultPlaceHolderBuilder, + }); + + final Future future; + + /// The widget to build when [future] completes, with its result + /// passed as `result`. + final Widget Function(BuildContext context, T result) builder; + + /// The placeholder widget to build while waiting for the [future] + /// to complete. + /// + /// By default, it will build the [LoadingPlaceholder]. + final Widget Function(BuildContext context) placeholderBuilder; - // We use onGenerateInitialRoutes for the real work of specifying the - // initial nav state. To do that we need [MaterialApp] to decide to - // build a [Navigator]... which means specifying either `home`, `routes`, - // `onGenerateRoute`, or `onUnknownRoute`. Make it `onGenerateRoute`. - // It never actually gets called, though: `onGenerateInitialRoutes` - // handles startup, and then we always push whole routes with methods - // like [Navigator.push], never mere names as with [Navigator.pushNamed]. - onGenerateRoute: (_) => null, - - onGenerateInitialRoutes: _handleGenerateInitialRoutes); - })); + static Widget _defaultPlaceHolderBuilder(BuildContext context) { + return const LoadingPlaceholder(); + } + + @override + State> createState() => _DeferredBuilderWidgetState(); +} + +class _DeferredBuilderWidgetState extends State> { + T? _result; + + @override + void initState() { + super.initState(); + () async { + _result = await widget.future; + if (mounted) setState(() {}); + }(); + } + + @override + Widget build(BuildContext context) { + final result = _result; + if (result == null) return widget.placeholderBuilder(context); + return widget.builder(context, result); } } diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 1b1c1d4713..9e0f3c0a72 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -31,10 +31,13 @@ class DialogStatus { /// /// The [DialogStatus.closed] field of the return value can be used /// for waiting for the dialog to be closed. +/// +/// The context argument is used to look up [ZulipLocalizations], +/// the [Navigator] and [Theme] for the dialog. // This API is inspired by [ScaffoldManager.showSnackBar]. We wrap // [showDialog]'s return value, a [Future], inside [DialogStatus] // whose documentation can be accessed. This helps avoid confusion when -// intepreting the meaning of the [Future]. +// interpreting the meaning of the [Future]. DialogStatus showErrorDialog({ required BuildContext context, required String title, diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart index ab287b745a..d0cfe9bddf 100644 --- a/lib/widgets/store.dart +++ b/lib/widgets/store.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import '../model/binding.dart'; -import '../model/database.dart'; import '../model/settings.dart'; import '../model/store.dart'; import 'page.dart'; @@ -15,15 +13,14 @@ import 'page.dart'; /// * [GlobalStoreWidget.of], to get access to the data. /// * [PerAccountStoreWidget], for the user's data associated with a /// particular Zulip account. -class GlobalStoreWidget extends StatefulWidget { - const GlobalStoreWidget({ +class GlobalStoreWidget extends InheritedNotifier { + GlobalStoreWidget({ super.key, - this.placeholder = const LoadingPlaceholder(), - required this.child, - }); - - final Widget placeholder; - final Widget child; + required GlobalStore store, + required Widget child, + }) : super(notifier: store, + child: _GlobalSettingsStoreInheritedWidget( + store: store.settings, child: child)); /// The app's global data store. /// @@ -48,7 +45,7 @@ class GlobalStoreWidget extends StatefulWidget { /// * [PerAccountStoreWidget.of], for the user's data associated with a /// particular Zulip account. static GlobalStore of(BuildContext context) { - final widget = context.dependOnInheritedWidgetOfExactType<_GlobalStoreInheritedWidget>(); + final widget = context.dependOnInheritedWidgetOfExactType(); assert(widget != null, 'No GlobalStoreWidget ancestor'); return widget!.store; } @@ -75,43 +72,6 @@ class GlobalStoreWidget extends StatefulWidget { return widget!.store; } - @override - State createState() => _GlobalStoreWidgetState(); -} - -class _GlobalStoreWidgetState extends State { - GlobalStore? store; - - @override - void initState() { - super.initState(); - (() async { - final store = await ZulipBinding.instance.getGlobalStoreUniquely(); - setState(() { - this.store = store; - }); - })(); - } - - @override - Widget build(BuildContext context) { - final store = this.store; - if (store == null) return widget.placeholder; - return _GlobalStoreInheritedWidget(store: store, child: widget.child); - } -} - -// This is separate from [GlobalStoreWidget] only because we need -// a [StatefulWidget] to get hold of the store, and an [InheritedWidget] to -// provide it to descendants, and one widget can't be both of those. -class _GlobalStoreInheritedWidget extends InheritedNotifier { - _GlobalStoreInheritedWidget({ - required GlobalStore store, - required Widget child, - }) : super(notifier: store, - child: _GlobalSettingsStoreInheritedWidget( - store: store.settings, child: child)); - GlobalStore get store => notifier!; } diff --git a/pigeon/android_notifications.dart b/pigeon/android_notifications.dart new file mode 100644 index 0000000000..58d88a5ffb --- /dev/null +++ b/pigeon/android_notifications.dart @@ -0,0 +1,308 @@ +import 'package:pigeon/pigeon.dart'; + +// To rebuild this pigeon's output after editing this file, +// run `tools/check pigeon --fix`. +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/host/android_notifications.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt', + kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), +)) + +/// Corresponds to `androidx.core.app.NotificationChannelCompat` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat +class NotificationChannel { + /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder` + /// + /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder + NotificationChannel({ + required this.id, + required this.importance, + this.name, + this.lightsEnabled, + this.soundUrl, + this.vibrationPattern, + }); + + final String id; + + /// Specifies the importance level of notifications + /// to be posted on this channel. + /// + /// Must be a valid constant from [NotificationImportance]. + final int importance; + + final String? name; + final bool? lightsEnabled; + final String? soundUrl; + final Int64List? vibrationPattern; +} + +/// Corresponds to `android.content.Intent` +/// +/// See: +/// https://developer.android.com/reference/android/content/Intent +/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) +class AndroidIntent { + AndroidIntent({required this.action, required this.dataUrl, this.flags = 0, required this.extrasData}); + + final String action; + final String dataUrl; + + /// A combination of flags from [IntentFlag]. + final int flags; + + final Map extrasData; +} + +/// Corresponds to `android.app.PendingIntent`. +/// +/// See: https://developer.android.com/reference/android/app/PendingIntent +class PendingIntent { + /// Corresponds to `PendingIntent.getActivity`. + PendingIntent({required this.requestCode, required this.intent, required this.flags}); + + final int requestCode; + final AndroidIntent intent; + + /// A combination of flags from [PendingIntent.flags], and others associated + /// with `Intent`; see Android docs for `PendingIntent.getActivity`. + final int flags; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle +class InboxStyle { + InboxStyle({required this.summaryText}); + + final String summaryText; +} + +/// Corresponds to `androidx.core.app.Person` +/// +/// See: https://developer.android.com/reference/androidx/core/app/Person +class Person { + Person({ + required this.iconBitmap, + required this.key, + required this.name, + }); + + /// An icon for this person. + /// + /// This should be compressed image data, in a format to be passed + /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. + /// Supported formats include JPEG, PNG, and WEBP. + /// + /// See: + /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) + final Uint8List? iconBitmap; + + final String key; + final String name; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message +class MessagingStyleMessage { + MessagingStyleMessage({ + required this.text, + required this.timestampMs, + required this.person, + }); + + final String text; + final int timestampMs; + final Person person; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle +class MessagingStyle { + MessagingStyle({ + required this.user, + required this.conversationTitle, + required this.isGroupConversation, + required this.messages, + }); + + final Person user; + final String? conversationTitle; + final List messages; + final bool isGroupConversation; +} + +/// Corresponds to `android.app.Notification` +/// +/// See: https://developer.android.com/reference/kotlin/android/app/Notification +class Notification { + Notification({required this.group, required this.extras}); + + final String group; + final Map extras; + // Various other properties too; add them if needed. +} + +/// Corresponds to `android.service.notification.StatusBarNotification` +/// +/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification +class StatusBarNotification { + StatusBarNotification({required this.id, required this.tag, required this.notification}); + + final int id; + final String tag; + final Notification notification; + + // Ignore `groupKey` and `key`. While the `.groupKey` contains the + // `.notification.group`, and the `.key` contains the `.id` and `.tag`, + // they also have more stuff added on (and their structure doesn't seem to + // be documented.) + // final String? groupKey; + // final String? key; + + // Various other properties too; add them if needed. +} + +/// Represents details about a notification sound stored in the +/// shared media store. +/// +/// Returned as a list entry by +/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. +class StoredNotificationSound { + StoredNotificationSound({ + required this.fileName, + required this.isOwned, + required this.contentUrl, + }); + + /// The display name of the sound file. + final String fileName; + + /// Specifies whether this file was created by the app. + /// + /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the + /// metadata matches the app's package name. + final bool isOwned; + + /// A `content://…` URL pointing to the sound file. + final String contentUrl; +} + +@HostApi() +abstract class AndroidNotificationHostApi { + /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. + /// + /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) + void createNotificationChannel(NotificationChannel channel); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() + List getNotificationChannels(); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) + void deleteNotificationChannel(String channelId); + + /// The list of notification sound files present under `Notifications/Zulip/` + /// in the device's shared media storage, + /// found with `android.content.ContentResolver.query`. + /// + /// This is a complex ad-hoc method. + /// For detailed behavior, see its implementation. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) + List listStoredSoundsInNotificationsDirectory(); + + /// Wraps `android.content.ContentResolver.insert` combined with + /// `android.content.ContentResolver.openOutputStream` and + /// `android.content.res.Resources.openRawResource`. + /// + /// Copies a raw resource audio file to `Notifications/Zulip/` + /// directory in device's shared media storage. Returns the URL + /// of the target file in media store. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: + /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) + /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) + /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) + String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}); + + /// Corresponds to `android.app.NotificationManager.notify`, + /// combined with `androidx.core.app.NotificationCompat.Builder`. + /// + /// The arguments `tag` and `id` go to the `notify` call. + /// The rest go to method calls on the builder. + /// + /// The `color` should be in the form 0xAARRGGBB. + /// See [ColorExtension.argbInt]. + /// + /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` + /// to get a resource ID to pass to `Builder.setSmallIcon`. + /// Whatever name is passed there must appear in keep.xml too: + /// see https://github.com/zulip/zulip-flutter/issues/528 . + /// + /// See: + /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify + /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder + // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin. + // As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed. + // https://github.com/flutter/flutter/issues/134777 + void notify({ + String? tag, + required int id, + + // The remaining arguments go to method calls on NotificationCompat.Builder. + bool? autoCancel, + required String channelId, + int? color, + PendingIntent? contentIntent, + String? contentText, + String? contentTitle, + Map? extras, + String? groupKey, + InboxStyle? inboxStyle, + bool? isGroupSummary, + MessagingStyle? messagingStyle, + int? number, + String? smallIconResourceName, + // NotificationCompat.Builder has lots more methods; add as needed. + // Keep them alphabetized, for easy comparison with that class's docs. + }); + + /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, + /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. + /// + /// Returns the messaging style, if any, of an active notification + /// that has tag `tag`. If there are several such notifications, + /// an arbitrary one of them is used. + /// Returns null if there are no such notifications. + /// + /// See: + /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() + /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) + MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. + /// + /// The keys of entries to fetch from notification's extras bundle must be + /// specified in the [desiredExtras] list. If this list is empty, then + /// [Notifications.extras] will also be empty. If value of the matched entry + /// is not of type string or is null, then that entry will be skipped. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() + List getActiveNotifications({required List desiredExtras}); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) + void cancel({String? tag, required int id}); +} diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 708ae4efb5..034890ac0f 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -3,304 +3,70 @@ import 'package:pigeon/pigeon.dart'; // To rebuild this pigeon's output after editing this file, // run `tools/check pigeon --fix`. @ConfigurePigeon(PigeonOptions( - dartOut: 'lib/host/android_notifications.g.dart', + dartOut: 'lib/host/notifications.g.dart', + swiftOut: 'ios/Runner/Notifications.g.swift', kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt', - kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), + kotlinOptions: KotlinOptions( + package: 'com.zulip.flutter', + // One error class is already generated in AndroidNotifications.g.kt , + // so avoid generating another one, preventing duplicate class build errors. + includeErrorClass: false), )) -/// Corresponds to `androidx.core.app.NotificationChannelCompat` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat -class NotificationChannel { - /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder` - /// - /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder - NotificationChannel({ - required this.id, - required this.importance, - this.name, - this.lightsEnabled, - this.soundUrl, - this.vibrationPattern, - }); - - final String id; +class NotificationDataFromLaunch { + const NotificationDataFromLaunch({required this.payload}); - /// Specifies the importance level of notifications - /// to be posted on this channel. + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. /// - /// Must be a valid constant from [NotificationImportance]. - final int importance; - - final String? name; - final bool? lightsEnabled; - final String? soundUrl; - final Int64List? vibrationPattern; -} - -/// Corresponds to `android.content.Intent` -/// -/// See: -/// https://developer.android.com/reference/android/content/Intent -/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) -class AndroidIntent { - AndroidIntent({required this.action, required this.dataUrl, this.flags = 0}); - - final String action; - final String dataUrl; - - /// A combination of flags from [IntentFlag]. - final int flags; -} - -/// Corresponds to `android.app.PendingIntent`. -/// -/// See: https://developer.android.com/reference/android/app/PendingIntent -class PendingIntent { - /// Corresponds to `PendingIntent.getActivity`. - PendingIntent({required this.requestCode, required this.intent, required this.flags}); - - final int requestCode; - final AndroidIntent intent; - - /// A combination of flags from [PendingIntent.flags], and others associated - /// with `Intent`; see Android docs for `PendingIntent.getActivity`. - final int flags; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle -class InboxStyle { - InboxStyle({required this.summaryText}); - - final String summaryText; + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + final Map payload; } -/// Corresponds to `androidx.core.app.Person` -/// -/// See: https://developer.android.com/reference/androidx/core/app/Person -class Person { - Person({ - required this.iconBitmap, - required this.key, - required this.name, - }); +class NotificationTapEvent { + const NotificationTapEvent({required this.payload}); - /// An icon for this person. + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. /// - /// This should be compressed image data, in a format to be passed - /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. - /// Supported formats include JPEG, PNG, and WEBP. - /// - /// See: - /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) - final Uint8List? iconBitmap; - - final String key; - final String name; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message -class MessagingStyleMessage { - MessagingStyleMessage({ - required this.text, - required this.timestampMs, - required this.person, - }); - - final String text; - final int timestampMs; - final Person person; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle -class MessagingStyle { - MessagingStyle({ - required this.user, - required this.conversationTitle, - required this.isGroupConversation, - required this.messages, - }); - - final Person user; - final String? conversationTitle; - final List messages; - final bool isGroupConversation; -} - -/// Corresponds to `android.app.Notification` -/// -/// See: https://developer.android.com/reference/kotlin/android/app/Notification -class Notification { - Notification({required this.group, required this.extras}); - - final String group; - final Map extras; - // Various other properties too; add them if needed. -} - -/// Corresponds to `android.service.notification.StatusBarNotification` -/// -/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification -class StatusBarNotification { - StatusBarNotification({required this.id, required this.tag, required this.notification}); - - final int id; - final String tag; - final Notification notification; - - // Ignore `groupKey` and `key`. While the `.groupKey` contains the - // `.notification.group`, and the `.key` contains the `.id` and `.tag`, - // they also have more stuff added on (and their structure doesn't seem to - // be documented.) - // final String? groupKey; - // final String? key; - - // Various other properties too; add them if needed. -} - -/// Represents details about a notification sound stored in the -/// shared media store. -/// -/// Returned as a list entry by -/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. -class StoredNotificationSound { - StoredNotificationSound({ - required this.fileName, - required this.isOwned, - required this.contentUrl, - }); - - /// The display name of the sound file. - final String fileName; - - /// Specifies whether this file was created by the app. - /// - /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the - /// metadata matches the app's package name. - final bool isOwned; - - /// A `content://…` URL pointing to the sound file. - final String contentUrl; + /// See [notificationTapEvents]. + final Map payload; } @HostApi() -abstract class AndroidNotificationHostApi { - /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. - /// - /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) - void createNotificationChannel(NotificationChannel channel); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() - List getNotificationChannels(); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) - void deleteNotificationChannel(String channelId); - - /// The list of notification sound files present under `Notifications/Zulip/` - /// in the device's shared media storage, - /// found with `android.content.ContentResolver.query`. - /// - /// This is a complex ad-hoc method. - /// For detailed behavior, see its implementation. - /// - /// Requires minimum of Android 10 (API 29) or higher. - /// - /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) - List listStoredSoundsInNotificationsDirectory(); - - /// Wraps `android.content.ContentResolver.insert` combined with - /// `android.content.ContentResolver.openOutputStream` and - /// `android.content.res.Resources.openRawResource`. - /// - /// Copies a raw resource audio file to `Notifications/Zulip/` - /// directory in device's shared media storage. Returns the URL - /// of the target file in media store. - /// - /// Requires minimum of Android 10 (API 29) or higher. - /// - /// See: - /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) - /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) - /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) - String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}); - - /// Corresponds to `android.app.NotificationManager.notify`, - /// combined with `androidx.core.app.NotificationCompat.Builder`. - /// - /// The arguments `tag` and `id` go to the `notify` call. - /// The rest go to method calls on the builder. - /// - /// The `color` should be in the form 0xAARRGGBB. - /// See [ColorExtension.argbInt]. - /// - /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` - /// to get a resource ID to pass to `Builder.setSmallIcon`. - /// Whatever name is passed there must appear in keep.xml too: - /// see https://github.com/zulip/zulip-flutter/issues/528 . - /// - /// See: - /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify - /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder - // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin. - // As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed. - // https://github.com/flutter/flutter/issues/134777 - void notify({ - String? tag, - required int id, - - // The remaining arguments go to method calls on NotificationCompat.Builder. - bool? autoCancel, - required String channelId, - int? color, - PendingIntent? contentIntent, - String? contentText, - String? contentTitle, - Map? extras, - String? groupKey, - InboxStyle? inboxStyle, - bool? isGroupSummary, - MessagingStyle? messagingStyle, - int? number, - String? smallIconResourceName, - // NotificationCompat.Builder has lots more methods; add as needed. - // Keep them alphabetized, for easy comparison with that class's docs. - }); - - /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, - /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. - /// - /// Returns the messaging style, if any, of an active notification - /// that has tag `tag`. If there are several such notifications, - /// an arbitrary one of them is used. - /// Returns null if there are no such notifications. - /// - /// See: - /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() - /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) - MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. - /// - /// The keys of entries to fetch from notification's extras bundle must be - /// specified in the [desiredExtras] list. If this list is empty, then - /// [Notifications.extras] will also be empty. If value of the matched entry - /// is not of type string or is null, then that entry will be skipped. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() - List getActiveNotifications({required List desiredExtras}); +abstract class NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// On iOS, this returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + /// + /// On Android, this checks if the launch `intent` has the intent data uri + /// starting with `zulip://notification` and has the extras bundle containing + /// the notification open payload we set during creating the notification. + /// Either returns the payload we set in the extras bundle, or null if the + /// `intent` doesn't match the preconditions, meaning launch wasn't triggered + /// by a notification. + NotificationDataFromLaunch? getNotificationDataFromLaunch(); +} - /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) - void cancel({String? tag, required int id}); +@EventChannelApi() +abstract class NotificationEventChannelApi { + /// An event stream that emits a notification payload when the app + /// encounters a notification tap, while the app is running. + /// + /// On iOS, this emits an event when + /// `userNotificationCenter(_:didReceive:withCompletionHandler:)` gets + /// called, indicating that the user has tapped on a notification. The + /// emitted payload will be the raw APNs data dictionary from the + /// `UNNotificationResponse` passed to that method. + /// + /// On Android, this emits an event when `onNewIntent` gets called, and + /// the intent matches preconditions of having a data uri starting with + /// `zulip://notification` and an extras bundle containing the notification + /// open payload we set during creating the notification. The emitted payload + /// will be the same payload we set in the extras bundle. + NotificationTapEvent notificationTapEvents(); } diff --git a/test/model/binding.dart b/test/model/binding.dart index 17c9565770..9e03284c86 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:zulip/host/android_notifications.dart'; +import 'package:zulip/host/notifications.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; @@ -279,14 +280,18 @@ class TestZulipBinding extends ZulipBinding { void _resetNotifications() { _androidNotificationHostApi = null; + _notificationPigeonApi = null; } + @override + FakeAndroidNotificationHostApi get androidNotificationHost => + (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); FakeAndroidNotificationHostApi? _androidNotificationHostApi; @override - FakeAndroidNotificationHostApi get androidNotificationHost { - return (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); - } + FakeNotificationPigeonApi get notificationPigeonApi => + (_notificationPigeonApi ??= FakeNotificationPigeonApi()); + FakeNotificationPigeonApi? _notificationPigeonApi; /// The value that `ZulipBinding.instance.pickFiles()` should return. /// @@ -724,6 +729,32 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { } } +class FakeNotificationPigeonApi implements NotificationPigeonApi { + NotificationDataFromLaunch? _notificationDataFromLaunch; + + /// Populates the notification data for launch to be returned + /// by [getNotificationDataFromLaunch]. + void setNotificationDataFromLaunch(NotificationDataFromLaunch? data) { + _notificationDataFromLaunch = data; + } + + @override + Future getNotificationDataFromLaunch() async => + _notificationDataFromLaunch; + + StreamController? _notificationTapEventsStreamController; + + void addNotificationTapEvent(NotificationTapEvent event) { + _notificationTapEventsStreamController!.add(event); + } + + @override + Stream notificationTapEventsStream() { + _notificationTapEventsStreamController ??= StreamController(); + return _notificationTapEventsStreamController!.stream; + } +} + typedef AndroidNotificationHostApiNotifyCall = ({ String? tag, int id, diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 4c83cd5da0..1c5da6236e 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -16,6 +16,7 @@ import 'package:zulip/api/route/realm.dart'; import 'package:zulip/log.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; import '../api/fake_api.dart'; @@ -1132,6 +1133,7 @@ void main() { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); await NotificationService.instance.start(); // On store startup, send the token. @@ -1159,6 +1161,7 @@ void main() { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); final startFuture = NotificationService.instance.start(); // TODO this test is a bit brittle in its interaction with asynchrony; diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index b1c56b55b1..a28546929a 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; @@ -6,7 +5,6 @@ import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; import 'package:fake_async/fake_async.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart' hide Notification; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; @@ -18,25 +16,15 @@ import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/display.dart'; +import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; -import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/color.dart'; -import 'package:zulip/widgets/home.dart'; -import 'package:zulip/widgets/message_list.dart'; -import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/theme.dart'; import '../fake_async.dart'; import '../model/binding.dart'; import '../example_data.dart' as eg; -import '../model/narrow_checks.dart'; -import '../stdlib_checks.dart'; import '../test_images.dart'; -import '../test_navigation.dart'; -import '../widgets/dialog_checks.dart'; -import '../widgets/message_list_checks.dart'; -import '../widgets/page_checks.dart'; - MessageFcmMessage messageFcmMessage( Message zulipMessage, { String? streamName, @@ -118,6 +106,7 @@ void main() { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); NotificationService.debugBackgroundIsolateIsLive = false; await NotificationService.instance.start(); } @@ -340,18 +329,12 @@ void main() { final expectedTag = '${data.realmUrl}|${data.userId}|$expectedTagComponent'; final expectedGroupKey = '${data.realmUrl}|${data.userId}'; + final expectedMessageKey = '$expectedGroupKey|${data.zulipMessageId}'; const expectedPendingIntentFlags = PendingIntentFlag.immutable; const expectedIntentFlags = IntentFlag.activityClearTop | IntentFlag.activityNewTask; final expectedSelfUserKey = '${data.realmUrl}|${data.userId}'; - final expectedIntentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + final expectedIntentDataUrl = + Uri(scheme: 'zulip', host: 'notification', path: expectedMessageKey); final messageStyleMessagesChecks = messageStyleMessages.mapIndexed((i, messageData) { @@ -956,423 +939,6 @@ void main() { check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); }))); }); - - group('NotificationDisplayManager open', () { - late List> pushedRoutes; - - void takeStartingRoutes({Account? account, bool withAccount = true}) { - account ??= eg.selfAccount; - final expected = >[ - if (withAccount) - (it) => it.isA() - ..accountId.equals(account!.id) - ..page.isA() - else - (it) => it.isA().page.isA(), - ]; - check(pushedRoutes.take(expected.length)).deepEquals(expected); - pushedRoutes.removeRange(0, expected.length); - } - - Future prepare(WidgetTester tester, - {bool early = false, bool withAccount = true}) async { - await init(); - pushedRoutes = []; - final testNavObserver = TestNavigatorObserver() - ..onPushed = (route, prevRoute) => pushedRoutes.add(route); - // This uses [ZulipApp] instead of [TestZulipApp] because notification - // logic uses `await ZulipApp.navigator`. - await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); - if (early) { - check(pushedRoutes).isEmpty(); - return; - } - await tester.pump(); - takeStartingRoutes(withAccount: withAccount); - check(pushedRoutes).isEmpty(); - } - - Future openNotification(WidgetTester tester, Account account, Message message) async { - final data = messageFcmMessage(message, account: account); - final intentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); - unawaited( - WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); - await tester.idle(); // let navigateForNotification find navigator - } - - void matchesNavigation(Subject> route, Account account, Message message) { - route.isA() - ..accountId.equals(account.id) - ..page.isA() - .initNarrow.equals(SendableNarrow.ofMessage(message, - selfUserId: account.userId)); - } - - Future checkOpenNotification(WidgetTester tester, Account account, Message message) async { - await openNotification(tester, account, message); - matchesNavigation(check(pushedRoutes).single, account, message); - pushedRoutes.clear(); - } - - testWidgets('stream message', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); - }); - - testWidgets('direct message', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await checkOpenNotification(tester, eg.selfAccount, - eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); - }); - - testWidgets('account queried by realmUrl origin component', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add( - eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), - eg.initialSnapshot()); - await prepare(tester); - - await checkOpenNotification(tester, - eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example/')), - eg.streamMessage()); - await checkOpenNotification(tester, - eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), - eg.streamMessage()); - }); - - testWidgets('no accounts', (tester) async { - await prepare(tester, withAccount: false); - await openNotification(tester, eg.selfAccount, eg.streamMessage()); - await tester.pump(); - check(pushedRoutes.single).isA>(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); - }); - - testWidgets('mismatching account', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester); - await openNotification(tester, eg.otherAccount, eg.streamMessage()); - await tester.pump(); - check(pushedRoutes.single).isA>(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); - }); - - testWidgets('find account among several', (tester) async { - addTearDown(testBinding.reset); - final realmUrlA = Uri.parse('https://a-chat.example/'); - final realmUrlB = Uri.parse('https://chat-b.example/'); - final user1 = eg.user(); - final user2 = eg.user(); - final accounts = [ - eg.account(id: 1001, realmUrl: realmUrlA, user: user1), - eg.account(id: 1002, realmUrl: realmUrlA, user: user2), - eg.account(id: 1003, realmUrl: realmUrlB, user: user1), - eg.account(id: 1004, realmUrl: realmUrlB, user: user2), - ]; - for (final account in accounts) { - await testBinding.globalStore.add(account, eg.initialSnapshot()); - } - await prepare(tester); - - await checkOpenNotification(tester, accounts[0], eg.streamMessage()); - await checkOpenNotification(tester, accounts[1], eg.streamMessage()); - await checkOpenNotification(tester, accounts[2], eg.streamMessage()); - await checkOpenNotification(tester, accounts[3], eg.streamMessage()); - }); - - testWidgets('wait for app to become ready', (tester) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - await prepare(tester, early: true); - final message = eg.streamMessage(); - await openNotification(tester, eg.selfAccount, message); - // The app should still not be ready (or else this test won't work right). - check(ZulipApp.ready.value).isFalse(); - check(ZulipApp.navigatorKey.currentState).isNull(); - // And the openNotification hasn't caused any navigation yet. - check(pushedRoutes).isEmpty(); - - // Now let the GlobalStore get loaded and the app's main UI get mounted. - await tester.pump(); - // The navigator first pushes the starting routes… - takeStartingRoutes(); - // … and then the one the notification leads to. - matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); - }); - - testWidgets('at app launch', (tester) async { - addTearDown(testBinding.reset); - // Set up a value for `PlatformDispatcher.defaultRouteName` to return, - // for determining the intial route. - final account = eg.selfAccount; - final message = eg.streamMessage(); - final data = messageFcmMessage(message, account: account); - final intentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); - addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); - tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); - - // Now start the app. - await testBinding.globalStore.add(account, eg.initialSnapshot()); - await prepare(tester, early: true); - check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet - - // Once the app is ready, we navigate to the conversation. - await tester.pump(); - takeStartingRoutes(); - matchesNavigation(check(pushedRoutes).single, account, message); - }); - - testWidgets('uses associated account as initial account; if initial route', (tester) async { - addTearDown(testBinding.reset); - - final accountA = eg.selfAccount; - final accountB = eg.otherAccount; - final message = eg.streamMessage(); - final data = messageFcmMessage(message, account: accountB); - await testBinding.globalStore.add(accountA, eg.initialSnapshot()); - await testBinding.globalStore.add(accountB, eg.initialSnapshot()); - - final intentDataUrl = NotificationOpenPayload( - realmUrl: data.realmUrl, - userId: data.userId, - narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); - addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); - tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); - - await prepare(tester, early: true); - check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet - - await tester.pump(); - takeStartingRoutes(account: accountB); - matchesNavigation(check(pushedRoutes).single, accountB, message); - }); - }); - - group('NotificationOpenPayload', () { - test('smoke round-trip', () { - // DM narrow - var payload = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ); - var url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(payload.realmUrl) - ..userId.equals(payload.userId) - ..narrow.equals(payload.narrow); - - // Topic narrow - payload = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: eg.topicNarrow(1, 'topic A'), - ); - url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(payload.realmUrl) - ..userId.equals(payload.userId) - ..narrow.equals(payload.narrow); - }); - - test('buildUrl: smoke DM', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - }); - - test('buildUrl: smoke topic', () { - final url = NotificationOpenPayload( - realmUrl: Uri.parse('http://chat.example'), - userId: 1001, - narrow: eg.topicNarrow(1, 'topic A'), - ).buildUrl(); - check(url) - ..scheme.equals('zulip') - ..host.equals('notification') - ..queryParameters.deepEquals({ - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - }); - - test('parse: smoke DM', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..allRecipientIds.deepEquals([1001, 1002]) - ..otherRecipientIds.deepEquals([1002])); - }); - - test('parse: smoke topic', () { - final url = Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(NotificationOpenPayload.parseUrl(url)) - ..realmUrl.equals(Uri.parse('http://chat.example')) - ..userId.equals(1001) - ..narrow.which((it) => it.isA() - ..streamId.equals(1) - ..topic.equals(eg.t('topic A'))); - }); - - test('parse: fails when missing any expected query parameters', () { - final testCases = >[ - { - // 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - // 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - // 'channel_id': '1', - 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - // 'topic': 'topic A', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - // 'narrow_type': 'dm', - 'all_recipient_ids': '1001,1002', - }, - { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'dm', - // 'all_recipient_ids': '1001,1002', - }, - ]; - for (final params in testCases) { - check(() => NotificationOpenPayload.parseUrl(Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: params, - ))) - // Missing 'realm_url', 'user_id' and 'narrow_type' - // throws 'FormatException'. - // Missing 'channel_id', 'topic', when narrow_type == 'topic' - // throws 'TypeError'. - // Missing 'all_recipient_ids', when narrow_type == 'dm' - // throws 'TypeError'. - .throws(); - } - }); - - test('parse: fails when scheme is not "zulip"', () { - final url = Uri( - scheme: 'http', - host: 'notification', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); - }); - - test('parse: fails when host is not "notification"', () { - final url = Uri( - scheme: 'zulip', - host: 'example', - queryParameters: { - 'realm_url': 'http://chat.example', - 'user_id': '1001', - 'narrow_type': 'topic', - 'channel_id': '1', - 'topic': 'topic A', - }); - check(() => NotificationOpenPayload.parseUrl(url)) - .throws(); - }); - }); } extension on Subject { @@ -1452,9 +1018,3 @@ extension on Subject { Subject get notification => has((x) => x.notification, 'notification'); Subject get tag => has((x) => x.tag, 'tag'); } - -extension on Subject { - Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); - Subject get userId => has((x) => x.userId, 'userId'); - Subject get narrow => has((x) => x.narrow, 'narrow'); -} diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart new file mode 100644 index 0000000000..d1f686b101 --- /dev/null +++ b/test/notifications/open_test.dart @@ -0,0 +1,335 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/notifications.dart'; +import 'package:zulip/host/notifications.dart'; +import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications/open.dart'; +import 'package:zulip/notifications/receive.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../test_navigation.dart'; +import '../widgets/dialog_checks.dart'; +import '../widgets/message_list_checks.dart'; +import '../widgets/page_checks.dart'; +import 'display_test.dart'; + +Map messageApnsPayload( + Message zulipMessage, { + String? streamName, + Account? account, +}) { + account ??= eg.selfAccount; + return { + "aps": { + "alert": { + "title": "test", + "subtitle": "test", + "body": zulipMessage.content, + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulip.example.cloud", + "realm_id": 4, + "realm_uri": account.realmUrl.toString(), + "realm_url": account.realmUrl.toString(), + "realm_name": "Test", + "user_id": account.userId, + "sender_id": zulipMessage.senderId, + "sender_email": zulipMessage.senderEmail, + "time": zulipMessage.timestamp, + "message_ids": [zulipMessage.id], + ...(switch (zulipMessage) { + StreamMessage(:var streamId, :var topic) => { + "recipient_type": "stream", + "stream_id": streamId, + if (streamName != null) "stream": streamName, + "topic": topic, + }, + DmMessage(allRecipientIds: [_, _, _, ...]) => { + "recipient_type": "private", + "pm_users": zulipMessage.allRecipientIds.join(","), + }, + DmMessage() => {"recipient_type": "private"}, + }), + }, + }; +} + +void main() { + TestZulipBinding.ensureInitialized(); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + Future init() async { + addTearDown(testBinding.reset); + testBinding.firebaseMessagingInitialToken = '012abc'; + addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); + NotificationService.debugBackgroundIsolateIsLive = false; + await NotificationService.instance.start(); + } + + group('NotificationOpenManager', () { + late List> pushedRoutes; + + void takeStartingRoutes({Account? account}) { + final expected = >[ + if (account != null) + (it) => it.isA() + ..accountId.equals(account.id) + ..page.isA() + else + (it) => it.isA().page.isA(), + ]; + check(pushedRoutes.take(expected.length)).deepEquals(expected); + pushedRoutes.removeRange(0, expected.length); + } + + Future prepare( + WidgetTester tester, { + bool dropStartingRoutes = true, + Account? account, + bool withAccount = true, + }) async { + if (withAccount) { + account ??= eg.selfAccount; + await testBinding.globalStore.add(account, eg.initialSnapshot()); + } + await init(); + pushedRoutes = []; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + // This uses [ZulipApp] instead of [TestZulipApp] because notification + // logic uses `await ZulipApp.navigator`. + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + if (!dropStartingRoutes) { + check(pushedRoutes).isEmpty(); + return; + } + await tester.pump(); + takeStartingRoutes(account: account); + check(pushedRoutes).isEmpty(); + } + + Map notificationOpenPayload(Account account, Message message) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + final data = messageFcmMessage(message, account: account); + final intentExtrasData = NotificationNavigationData( + realmUrl: data.realmUrl, + userId: data.userId, + narrow: switch (data.recipient) { + FcmMessageChannelRecipient(:var streamId, :var topic) => + TopicNarrow(streamId, topic), + FcmMessageDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), + }).toAndroidIntentExtras(); + return intentExtrasData; + + case TargetPlatform.iOS: + return messageApnsPayload(message, account: account); + + default: + throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); + } + } + + Future openNotification(WidgetTester tester, Account account, Message message) async { + final payload = notificationOpenPayload(account, message); + testBinding.notificationPigeonApi.addNotificationTapEvent( + NotificationTapEvent(payload: payload)); + await tester.idle(); // let navigateForNotification find navigator + } + + void matchesNavigation(Subject> route, Account account, Message message) { + route.isA() + ..accountId.equals(account.id) + ..page.isA() + .initNarrow.equals(SendableNarrow.ofMessage(message, + selfUserId: account.userId)); + } + + Future checkOpenNotification(WidgetTester tester, Account account, Message message) async { + await openNotification(tester, account, message); + matchesNavigation(check(pushedRoutes).single, account, message); + pushedRoutes.clear(); + } + + testWidgets('stream message', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('direct message', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('account queried by realmUrl origin component', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester, + account: eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example'))); + + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example/')), + eg.streamMessage()); + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), + eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('no accounts', (tester) async { + await prepare(tester, withAccount: false); + await openNotification(tester, eg.selfAccount, eg.streamMessage()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountLoggedOut))); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('mismatching account', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester); + await openNotification(tester, eg.otherAccount, eg.streamMessage()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountLoggedOut))); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('find account among several', (tester) async { + addTearDown(testBinding.reset); + final realmUrlA = Uri.parse('https://a-chat.example/'); + final realmUrlB = Uri.parse('https://chat-b.example/'); + final user1 = eg.user(); + final user2 = eg.user(); + final accounts = [ + eg.account(id: 1001, realmUrl: realmUrlA, user: user1), + eg.account(id: 1002, realmUrl: realmUrlA, user: user2), + eg.account(id: 1003, realmUrl: realmUrlB, user: user1), + eg.account(id: 1004, realmUrl: realmUrlB, user: user2), + ]; + for (final account in accounts) { + await testBinding.globalStore.add(account, eg.initialSnapshot()); + } + + await prepare(tester, dropStartingRoutes: false, withAccount: false); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + await tester.pump(); + takeStartingRoutes(account: accounts[0]); + + await checkOpenNotification(tester, accounts[0], eg.streamMessage()); + await checkOpenNotification(tester, accounts[1], eg.streamMessage()); + await checkOpenNotification(tester, accounts[2], eg.streamMessage()); + await checkOpenNotification(tester, accounts[3], eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('wait for app to become ready', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester, dropStartingRoutes: false); + final message = eg.streamMessage(); + await openNotification(tester, eg.selfAccount, message); + // The app should still not be ready (or else this test won't work right). + check(ZulipApp.ready.value).isFalse(); + check(ZulipApp.navigatorKey.currentState).isNull(); + // And the openNotification hasn't caused any navigation yet. + check(pushedRoutes).isEmpty(); + + // Now let the GlobalStore get loaded and the app's main UI get mounted. + await tester.pump(); + // The navigator first pushes the starting routes… + takeStartingRoutes(account: eg.selfAccount); + // … and then the one the notification leads to. + matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('at app launch', (tester) async { + addTearDown(testBinding.reset); + // Set up a value for `PlatformDispatcher.defaultRouteName` to return, + // for determining the initial route. + final message = eg.streamMessage(); + + final payload = notificationOpenPayload(eg.selfAccount, message); + testBinding.notificationPigeonApi.setNotificationDataFromLaunch( + NotificationDataFromLaunch(payload: payload)); + + // Now start the app. + await prepare(tester, dropStartingRoutes: false); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + // Once the app is ready, we navigate to the conversation. + await tester.pump(); + takeStartingRoutes(account: eg.selfAccount); + matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + + testWidgets('uses associated account as initial account; if initial route', (tester) async { + addTearDown(testBinding.reset); + + final accountA = eg.selfAccount; + final accountB = eg.otherAccount; + final message = eg.streamMessage(); + await testBinding.globalStore.add(accountA, eg.initialSnapshot()); + await testBinding.globalStore.add(accountB, eg.initialSnapshot()); + + final payload = notificationOpenPayload(accountB, message); + testBinding.notificationPigeonApi.setNotificationDataFromLaunch( + NotificationDataFromLaunch(payload: payload)); + + await prepare(tester, dropStartingRoutes: false, withAccount: false); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + await tester.pump(); + takeStartingRoutes(account: accountB); + matchesNavigation(check(pushedRoutes).single, accountB, message); + }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); + }); + + group('NotificationNavigationData', () { + test('(Android) smoke round-trip', () { + // DM narrow + var payload = NotificationNavigationData( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), + ); + check(NotificationNavigationData.fromAndroidIntentExtras(payload.toAndroidIntentExtras())) + ..realmUrl.equals(payload.realmUrl) + ..userId.equals(payload.userId) + ..narrow.equals(payload.narrow); + + // Topic narrow + payload = NotificationNavigationData( + realmUrl: Uri.parse('http://chat.example'), + userId: 1001, + narrow: eg.topicNarrow(1, 'topic A'), + ); + check(NotificationNavigationData.fromAndroidIntentExtras(payload.toAndroidIntentExtras())) + ..realmUrl.equals(payload.realmUrl) + ..userId.equals(payload.userId) + ..narrow.equals(payload.narrow); + }); + }); +} + +extension on Subject { + Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); + Subject get userId => has((x) => x.userId, 'userId'); + Subject get narrow => has((x) => x.narrow, 'narrow'); +} diff --git a/test/notifications/receive_test.dart b/test/notifications/receive_test.dart index 3b7907b1c2..ce810f51d5 100644 --- a/test/notifications/receive_test.dart +++ b/test/notifications/receive_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; import '../model/binding.dart'; @@ -12,6 +13,7 @@ void main() { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); NotificationService.debugBackgroundIsolateIsLive = false; await NotificationService.instance.start(); } diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index f7941bc6df..da1926f2aa 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -15,7 +15,6 @@ import 'package:zulip/widgets/content.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; -import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/text.dart'; import '../example_data.dart' as eg; @@ -1139,9 +1138,9 @@ void main() { final httpClient = prepareBoringImageHttpClient(); - await tester.pumpWidget(GlobalStoreWidget( - child: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: RealmContentNetworkImage(src)))); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: RealmContentNetworkImage(src))); await tester.pump(); await tester.pump(); @@ -1181,9 +1180,9 @@ void main() { await store.addUser(user); prepareBoringImageHttpClient(); - await tester.pumpWidget(GlobalStoreWidget( - child: PerAccountStoreWidget(accountId: eg.selfAccount.id, - child: AvatarImage(userId: user.userId, size: size ?? 30)))); + await tester.pumpWidget(TestZulipApp( + accountId: eg.selfAccount.id, + child: AvatarImage(userId: user.userId, size: size ?? 30))); await tester.pump(); await tester.pump(); tester.widget(find.byType(AvatarImage)); diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index f8da5e24a0..d11c5b1863 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -6,7 +6,7 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/settings.dart'; -import 'package:zulip/model/store.dart'; +import 'package:zulip/model/binding.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/inbox.dart'; import 'package:zulip/widgets/page.dart'; @@ -15,9 +15,9 @@ import 'package:zulip/widgets/store.dart'; import '../flutter_checks.dart'; import '../model/binding.dart'; import '../example_data.dart' as eg; -import '../model/store_checks.dart'; import '../model/test_store.dart'; import '../test_navigation.dart'; +import 'test_app.dart'; /// A widget whose state uses [PerAccountStoreAwareStateMixin]. class MyWidgetWithMixin extends StatefulWidget { @@ -58,43 +58,17 @@ extension MyWidgetWithMixinStateChecks on Subject { void main() { TestZulipBinding.ensureInitialized(); - testWidgets('GlobalStoreWidget loads data while showing placeholder', (tester) async { - addTearDown(testBinding.reset); - - GlobalStore? globalStore; - await tester.pumpWidget( - GlobalStoreWidget( - child: Builder( - builder: (context) { - globalStore = GlobalStoreWidget.of(context); - return const SizedBox.shrink(); - }))); - // First, shows a loading page instead of child. - check(tester.any(find.byType(CircularProgressIndicator))).isTrue(); - check(globalStore).isNull(); - - await tester.pump(); - // Then after loading, mounts child instead, with provided store. - check(tester.any(find.byType(CircularProgressIndicator))).isFalse(); - check(globalStore).identicalTo(testBinding.globalStore); - - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - check(globalStore).isNotNull() - .accountEntries.single - .equals((accountId: eg.selfAccount.id, account: eg.selfAccount)); - }); - testWidgets('GlobalStoreWidget.of updates dependents', (tester) async { addTearDown(testBinding.reset); List? accountIds; await tester.pumpWidget( - Directionality(textDirection: TextDirection.ltr, - child: GlobalStoreWidget( - child: Builder(builder: (context) { + TestZulipApp( + child: Builder( + builder: (context) { accountIds = GlobalStoreWidget.of(context).accountIds.toList(); return SizedBox.shrink(); - })))); + }))); await tester.pump(); check(accountIds).isNotNull().isEmpty(); @@ -109,7 +83,7 @@ void main() { ThemeSetting? themeSetting; await tester.pumpWidget( - GlobalStoreWidget( + TestZulipApp( child: Builder( builder: (context) { themeSetting = GlobalStoreWidget.settingsOf(context).themeSetting; @@ -128,16 +102,13 @@ void main() { addTearDown(testBinding.reset); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: GlobalStoreWidget( - child: PerAccountStoreWidget( - accountId: eg.selfAccount.id, - child: Builder( - builder: (context) { - final store = PerAccountStoreWidget.of(context); - return Text('found store, account: ${store.accountId}'); - }))))); + TestZulipApp( + accountId: eg.selfAccount.id, + child: Builder( + builder: (context) { + final store = PerAccountStoreWidget.of(context); + return Text('found store, account: ${store.accountId}'); + }))); await tester.pump(); await tester.pump(); @@ -149,13 +120,18 @@ void main() { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, - child: GlobalStoreWidget( - // no PerAccountStoreWidget - child: Builder( - builder: (context) { - final store = PerAccountStoreWidget.of(context); - return Text('found store, account: ${store.accountId}'); - })))); + child: DeferredBuilderWidget( + future: testBinding.getGlobalStoreUniquely(), + builder: (context, store) { + return GlobalStoreWidget( + store: store, + // no PerAccountStoreWidget + child: Builder( + builder: (context) { + final store = PerAccountStoreWidget.of(context); + return Text('found store, account: ${store.accountId}'); + })); + }))); await tester.pump(); check(tester.takeException()) .has((x) => x.toString(), 'toString') // TODO(checks): what's a good convention for this? @@ -166,18 +142,24 @@ void main() { await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); addTearDown(testBinding.reset); + final globalStoreFuture = testBinding.getGlobalStoreUniquely(); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, - child: GlobalStoreWidget( - child: PerAccountStoreWidget( - key: const ValueKey(1), - accountId: eg.selfAccount.id, - child: Builder( - builder: (context) { - final store = PerAccountStoreWidget.of(context); - return Text('found store, account: ${store.accountId}'); - }))))); + child: DeferredBuilderWidget( + future: globalStoreFuture, + builder: (context, store) { + return GlobalStoreWidget( + store: store, + child: PerAccountStoreWidget( + key: const ValueKey(1), + accountId: eg.selfAccount.id, + child: Builder( + builder: (context) { + final store = PerAccountStoreWidget.of(context); + return Text('found store, account: ${store.accountId}'); + }))); + }))); // First, the global store has to load. check(tester.any(find.byType(PerAccountStoreWidget))).isFalse(); @@ -197,15 +179,20 @@ void main() { await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, - child: GlobalStoreWidget( - child: PerAccountStoreWidget( - key: const ValueKey(2), - accountId: eg.selfAccount.id, - child: Builder( - builder: (context) { - final store = PerAccountStoreWidget.of(context); - return Text('found store, account: ${store.accountId}'); - }))))); + child: DeferredBuilderWidget( + future: globalStoreFuture, + builder: (context, store) { + return GlobalStoreWidget( + store: store, + child: PerAccountStoreWidget( + key: const ValueKey(2), + accountId: eg.selfAccount.id, + child: Builder( + builder: (context) { + final store = PerAccountStoreWidget.of(context); + return Text('found store, account: ${store.accountId}'); + }))); + }))); // (... even one that really is separate, with its own fresh state node ...) check(tester.state(find.byType(PerAccountStoreWidget))) @@ -286,6 +273,7 @@ void main() { final accountId = eg.selfAccount.id; await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final globalStoreFuture = ZulipBinding.instance.getGlobalStoreUniquely(); Future pumpWithParams({required bool light, required int accountId}) async { // TODO use [TestZulipApp] @@ -293,10 +281,15 @@ void main() { await tester.pumpWidget( MaterialApp( theme: light ? ThemeData.light() : ThemeData.dark(), - home: GlobalStoreWidget( - child: PerAccountStoreWidget( - accountId: accountId, - child: MyWidgetWithMixin(key: widgetWithMixinKey))))); + home: DeferredBuilderWidget( + future: globalStoreFuture, + builder: (context, store) { + return GlobalStoreWidget( + store: store, + child: PerAccountStoreWidget( + accountId: accountId, + child: MyWidgetWithMixin(key: widgetWithMixinKey))); + }))); } // [onNewStore] called initially diff --git a/test/widgets/test_app.dart b/test/widgets/test_app.dart index 5cec418cdd..0078c30842 100644 --- a/test/widgets/test_app.dart +++ b/test/widgets/test_app.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:zulip/generated/l10n/zulip_localizations.dart'; +import 'package:zulip/model/binding.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/store.dart'; import 'package:zulip/widgets/theme.dart'; /// A lightweight mock of [ZulipApp], suitable for most widget tests. -class TestZulipApp extends StatelessWidget { +class TestZulipApp extends StatefulWidget { const TestZulipApp({ super.key, this.accountId, @@ -43,46 +46,65 @@ class TestZulipApp extends StatelessWidget { /// Defaults to `Placeholder()`. final Widget child; + @override + State createState() => _TestZulipAppState(); +} + +class _TestZulipAppState extends State { + late final Future _globalStoreFuture; + + @override + void initState() { + super.initState(); + _globalStoreFuture = ZulipBinding.instance.getGlobalStoreUniquely(); + } + @override Widget build(BuildContext context) { - return GlobalStoreWidget(child: Builder(builder: (context) { - assert(() { - if (accountId != null && !skipAssertAccountExists) { - final account = GlobalStoreWidget.of(context).getAccount(accountId!); - if (account == null) { - throw FlutterError.fromParts([ - ErrorSummary( - 'TestZulipApp() was called with [accountId] but a corresponding ' - 'Account was not found in the GlobalStore.'), - ErrorHint( - 'If [child] needs per-account data, consider calling ' - '`testBinding.globalStore.add` before pumping `TestZulipApp`.'), - ErrorHint( - 'If [child] is not specific to an account, omit [accountId].'), - ErrorHint( - 'If you are testing behavior when an account is logged out, ' - 'consider building ZulipApp instead of TestZulipApp, ' - 'or pass `skipAssertAccountExists: true`.'), - ]); - } - } - return true; - }()); + return DeferredBuilderWidget( + future: _globalStoreFuture, + builder: (context, store) { + return GlobalStoreWidget( + store: store, + child: Builder(builder: (context) { + assert(() { + if (widget.accountId != null && !widget.skipAssertAccountExists) { + final account = GlobalStoreWidget.of(context).getAccount(widget.accountId!); + if (account == null) { + throw FlutterError.fromParts([ + ErrorSummary( + 'TestZulipApp() was called with [accountId] but a corresponding ' + 'Account was not found in the GlobalStore.'), + ErrorHint( + 'If [child] needs per-account data, consider calling ' + '`testBinding.globalStore.add` before pumping `TestZulipApp`.'), + ErrorHint( + 'If [child] is not specific to an account, omit [accountId].'), + ErrorHint( + 'If you are testing behavior when an account is logged out, ' + 'consider building ZulipApp instead of TestZulipApp, ' + 'or pass `skipAssertAccountExists: true`.'), + ]); + } + } + return true; + }()); - return MaterialApp( - title: 'Zulip', - localizationsDelegates: ZulipLocalizations.localizationsDelegates, - supportedLocales: ZulipLocalizations.supportedLocales, - // The context has to be taken from the [Builder] because - // [zulipThemeData] requires access to [GlobalStoreWidget] in the tree. - theme: zulipThemeData(context), + return MaterialApp( + title: 'Zulip', + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, + // The context has to be taken from the [Builder] because + // [zulipThemeData] requires access to [GlobalStoreWidget] in the tree. + theme: zulipThemeData(context), - navigatorObservers: navigatorObservers ?? const [], + navigatorObservers: widget.navigatorObservers ?? const [], - home: accountId != null - ? PerAccountStoreWidget(accountId: accountId!, - child: PageRoot(child: child)) - : PageRoot(child: child)); - })); + home: widget.accountId != null + ? PerAccountStoreWidget(accountId: widget.accountId!, + child: PageRoot(child: widget.child)) + : PageRoot(child: widget.child)); + })); + }); } }