From 9228efa03228ee168c419e85f32943ea0fc55815 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 18 Dec 2025 18:38:41 +0530 Subject: [PATCH 1/3] notif: Move NotificationOpenService init at the start of NotificationService init NotificationOpenService.instance.start already handles all the platforms itself, by doing nothing on all except iOS, so move it's initialization out of the platform-specific switch here. --- lib/notifications/receive.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index 3930645747..d501a5d97c 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -53,6 +53,8 @@ class NotificationService { ValueNotifier token = ValueNotifier(null); Future start() async { + await NotificationOpenService.instance.start(); + switch (defaultTargetPlatform) { case TargetPlatform.android: await ZulipBinding.instance.firebaseInitializeApp( @@ -79,8 +81,6 @@ class NotificationService { await _getFcmToken(); case TargetPlatform.iOS: // TODO(#324): defer requesting notif permission - await NotificationOpenService.instance.start(); - await ZulipBinding.instance.firebaseInitializeApp( options: kFirebaseOptionsIos); From 26e3ba5918787c048594c0d8b14fc63054e0a063 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 18 Dec 2025 18:51:29 +0530 Subject: [PATCH 2/3] notif test: Allow scheduling mock notification tap events before subscription Initialize StreamController early, this will allow scheduling mock notification tap events (via `addNotificationTapEvent`) even before `notificationTapEventsStream` is called. --- test/model/binding.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/model/binding.dart b/test/model/binding.dart index c7fe2bf639..2f704fd130 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -779,16 +779,16 @@ class FakeNotificationPigeonApi implements NotificationPigeonApi { Future getNotificationDataFromLaunch() async => _notificationDataFromLaunch; - StreamController? _notificationTapEventsStreamController; + late final _notificationTapEventsStreamController = + StreamController(); void addNotificationTapEvent(NotificationTapEvent event) { - _notificationTapEventsStreamController!.add(event); + _notificationTapEventsStreamController.add(event); } @override Stream notificationTapEventsStream() { - _notificationTapEventsStreamController ??= StreamController(); - return _notificationTapEventsStreamController!.stream; + return _notificationTapEventsStreamController.stream; } } From 3521955c2de044bb83ad84c91c490a4109c3a524 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 18 Dec 2025 19:56:23 +0530 Subject: [PATCH 3/3] notif: On Android handle notification taps via Pigeon API Instead of relying on Flutter's deeplinks implementation for routing the notification URL, handle the Android Intents generated by notification taps ourselves using Pigeon to pass those events over to the Dart layer from the Java layer. The upstream Flutter's deeplinks implementation has a bug where if the deeplink is triggered after the app was killed by the OS when it was in background, the app will get launched again but the route/link will not reach the Flutter's navigation handlers. See: https://github.com/flutter/flutter/issues/178305 In the failure case we seem to be receiving the Android Intent for the notification tap from the OS via `MainActivity.onNewIntent` without any problems. So, to workaround that upstream bug this commit changes the implementation to handle these Android Intents ourselves. Fixes: #1567 --- .../kotlin/com/zulip/flutter/MainActivity.kt | 22 +- .../NotificationTapEventListener.kt | 47 +++ .../flutter/notifications/Notifications.g.kt | 318 ++++++++++++++++++ ios/Runner/Notifications.g.swift | 52 ++- lib/host/notifications.g.dart | 67 +++- lib/notifications/open.dart | 37 +- lib/widgets/app.dart | 39 +-- pigeon/notifications.dart | 32 +- test/notifications/open_test.dart | 23 +- 9 files changed, 567 insertions(+), 70 deletions(-) create mode 100644 android/app/src/main/kotlin/com/zulip/flutter/notifications/NotificationTapEventListener.kt create mode 100644 android/app/src/main/kotlin/com/zulip/flutter/notifications/Notifications.g.kt 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 cad696eecf..9b7cef7e11 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/MainActivity.kt @@ -1,20 +1,27 @@ package com.zulip.flutter import android.content.Intent +import com.zulip.flutter.notifications.NotificationTapEventListener +import com.zulip.flutter.notifications.NotificationTapEventsStreamHandler import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine class MainActivity : FlutterActivity() { private var androidIntentEventListener: AndroidIntentEventListener? = null + private var notificationTapEventListener: NotificationTapEventListener? = null override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) androidIntentEventListener = AndroidIntentEventListener() AndroidIntentEventsStreamHandler.register( - flutterEngine.dartExecutor.binaryMessenger, - androidIntentEventListener!! + flutterEngine.dartExecutor.binaryMessenger, androidIntentEventListener!! ) + notificationTapEventListener = NotificationTapEventListener() + NotificationTapEventsStreamHandler.register( + flutterEngine.dartExecutor.binaryMessenger, notificationTapEventListener!! + ) + maybeHandleIntent(intent) } @@ -35,6 +42,17 @@ class MainActivity : FlutterActivity() { return true } + Intent.ACTION_VIEW -> { + if (notificationTapEventListener!!.maybeHandleViewNotif(intent)) { + // Notification tapped + return true + } + + // Let Flutter handle other intents, in particular the web-auth intents + // have ACTION_VIEW, scheme "zulip", and authority "login". + return false + } + // For other intents, let Flutter handle it. else -> return false } diff --git a/android/app/src/main/kotlin/com/zulip/flutter/notifications/NotificationTapEventListener.kt b/android/app/src/main/kotlin/com/zulip/flutter/notifications/NotificationTapEventListener.kt new file mode 100644 index 0000000000..068fc39aec --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/notifications/NotificationTapEventListener.kt @@ -0,0 +1,47 @@ +package com.zulip.flutter.notifications + +import android.content.Intent +import android.net.Uri + +class NotificationTapEventListener : NotificationTapEventsStreamHandler() { + private var eventSink: PigeonEventSink? = null + private val buffer = mutableListOf() + + override fun onListen(p0: Any?, sink: PigeonEventSink) { + eventSink = sink + if (buffer.isNotEmpty()) { + buffer.forEach { sink.success(it) } + buffer.clear() + } + } + + private fun onNotificationTapEvent(dataUrl: Uri) { + val event = AndroidNotificationTapEvent(dataUrl.toString()) + if (eventSink != null) { + eventSink!!.success(event) + } else { + buffer.add(event) + } + } + + /** + * Recognize if the ACTION_VIEW intent came from tapping a notification; handle it if so + * + * If the intent is recognized, sends a notification tap event via + * the Pigeon event stream to the Dart layer and returns true. + * Else does nothing and returns false. + * + * Do not call if `intent.action` is not ACTION_VIEW. + */ + fun maybeHandleViewNotif(intent: Intent): Boolean { + assert(intent.action == Intent.ACTION_VIEW) + + val url = intent.data + if (url?.scheme == "zulip" && url.authority == "notification") { + onNotificationTapEvent(url) + return true + } + + return false + } +} diff --git a/android/app/src/main/kotlin/com/zulip/flutter/notifications/Notifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/notifications/Notifications.g.kt new file mode 100644 index 0000000000..37c9db8e1d --- /dev/null +++ b/android/app/src/main/kotlin/com/zulip/flutter/notifications/Notifications.g.kt @@ -0,0 +1,318 @@ +// Autogenerated from Pigeon (v26.1.2), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + +package com.zulip.flutter.notifications + +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 object NotificationsPigeonUtils { + + fun wrapResult(result: Any?): List { + return listOf(result) + } + + 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) + ) + } + } + fun deepEquals(a: Any?, b: Any?): Boolean { + if (a is ByteArray && b is ByteArray) { + return a.contentEquals(b) + } + if (a is IntArray && b is IntArray) { + return a.contentEquals(b) + } + if (a is LongArray && b is LongArray) { + return a.contentEquals(b) + } + if (a is DoubleArray && b is DoubleArray) { + return a.contentEquals(b) + } + if (a is Array<*> && b is Array<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is List<*> && b is List<*>) { + return a.size == b.size && + a.indices.all{ deepEquals(a[it], b[it]) } + } + if (a is Map<*, *> && b is Map<*, *>) { + return a.size == b.size && a.all { + (b as Map).contains(it.key) && + deepEquals(it.value, b[it.key]) + } + } + return a == b + } + +} + +/** + * 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() + +/** Generated class from Pigeon that represents data sent in messages. */ +data class NotificationDataFromLaunch ( + /** + * The raw payload that is attached to the notification, + * holding the information required to carry out the navigation. + * + * See [NotificationHostApi.getNotificationDataFromLaunch]. + */ + val payload: Map +) + { + companion object { + fun fromList(pigeonVar_list: List): NotificationDataFromLaunch { + val payload = pigeonVar_list[0] as Map + return NotificationDataFromLaunch(payload) + } + } + fun toList(): List { + return listOf( + payload, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is NotificationDataFromLaunch) { + return false + } + if (this === other) { + return true + } + return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Generated class from Pigeon that represents data sent in messages. + * This class should not be extended by any user class outside of the generated file. + */ +sealed class NotificationTapEvent +/** Generated class from Pigeon that represents data sent in messages. */ +data class IosNotificationTapEvent ( + /** + * The raw payload that is attached to the notification, + * holding the information required to carry out the navigation. + * + * See [notificationTapEvents]. + */ + val payload: Map +) : NotificationTapEvent() + { + companion object { + fun fromList(pigeonVar_list: List): IosNotificationTapEvent { + val payload = pigeonVar_list[0] as Map + return IosNotificationTapEvent(payload) + } + } + fun toList(): List { + return listOf( + payload, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is IosNotificationTapEvent) { + return false + } + if (this === other) { + return true + } + return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} + +/** Generated class from Pigeon that represents data sent in messages. */ +data class AndroidNotificationTapEvent ( + /** + * The intent data URL that was provided when the notification was created + * during `NotificationDisplayManager._onMessageFcmMessage`. + * + * Also see [notificationTapEvents]. + */ + val dataUrl: String +) : NotificationTapEvent() + { + companion object { + fun fromList(pigeonVar_list: List): AndroidNotificationTapEvent { + val dataUrl = pigeonVar_list[0] as String + return AndroidNotificationTapEvent(dataUrl) + } + } + fun toList(): List { + return listOf( + dataUrl, + ) + } + override fun equals(other: Any?): Boolean { + if (other !is AndroidNotificationTapEvent) { + return false + } + if (this === other) { + return true + } + return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + + override fun hashCode(): Int = toList().hashCode() +} +private open class NotificationsPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as? List)?.let { + NotificationDataFromLaunch.fromList(it) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + IosNotificationTapEvent.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + AndroidNotificationTapEvent.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is NotificationDataFromLaunch -> { + stream.write(129) + writeValue(stream, value.toList()) + } + is IosNotificationTapEvent -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is AndroidNotificationTapEvent -> { + stream.write(131) + 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 NotificationHostApi { + /** + * Retrieves notification data if the app was launched by tapping on a notification. + * + * 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 + */ + fun getNotificationDataFromLaunch(): NotificationDataFromLaunch? + + companion object { + /** The codec used by NotificationHostApi. */ + val codec: MessageCodec by lazy { + NotificationsPigeonCodec() + } + /** Sets up an instance of `NotificationHostApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: NotificationHostApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getNotificationDataFromLaunch()) + } catch (exception: Throwable) { + NotificationsPigeonUtils.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/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift index 08a619e044..c947bdace7 100644 --- a/ios/Runner/Notifications.g.swift +++ b/ios/Runner/Notifications.g.swift @@ -158,7 +158,13 @@ struct NotificationDataFromLaunch: Hashable { } /// Generated class from Pigeon that represents data sent in messages. -struct NotificationTapEvent: Hashable { +/// This protocol should not be extended by any user class outside of the generated file. +protocol NotificationTapEvent { + +} + +/// Generated class from Pigeon that represents data sent in messages. +struct IosNotificationTapEvent: NotificationTapEvent { /// The raw payload that is attached to the notification, /// holding the information required to carry out the navigation. /// @@ -167,10 +173,10 @@ struct NotificationTapEvent: Hashable { // swift-format-ignore: AlwaysUseLowerCamelCase - static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? { + static func fromList(_ pigeonVar_list: [Any?]) -> IosNotificationTapEvent? { let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] - return NotificationTapEvent( + return IosNotificationTapEvent( payload: payload ) } @@ -179,7 +185,36 @@ struct NotificationTapEvent: Hashable { payload ] } - static func == (lhs: NotificationTapEvent, rhs: NotificationTapEvent) -> Bool { + static func == (lhs: IosNotificationTapEvent, rhs: IosNotificationTapEvent) -> Bool { + return deepEqualsNotifications(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNotifications(value: toList(), hasher: &hasher) + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct AndroidNotificationTapEvent: NotificationTapEvent { + /// The intent data URL that was provided when the notification was created + /// during `NotificationDisplayManager._onMessageFcmMessage`. + /// + /// Also see [notificationTapEvents]. + var dataUrl: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> AndroidNotificationTapEvent? { + let dataUrl = pigeonVar_list[0] as! String + + return AndroidNotificationTapEvent( + dataUrl: dataUrl + ) + } + func toList() -> [Any?] { + return [ + dataUrl + ] + } + static func == (lhs: AndroidNotificationTapEvent, rhs: AndroidNotificationTapEvent) -> Bool { return deepEqualsNotifications(lhs.toList(), rhs.toList()) } func hash(into hasher: inout Hasher) { deepHashNotifications(value: toList(), hasher: &hasher) @@ -192,7 +227,9 @@ private class NotificationsPigeonCodecReader: FlutterStandardReader { case 129: return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?]) case 130: - return NotificationTapEvent.fromList(self.readValue() as! [Any?]) + return IosNotificationTapEvent.fromList(self.readValue() as! [Any?]) + case 131: + return AndroidNotificationTapEvent.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) } @@ -204,9 +241,12 @@ private class NotificationsPigeonCodecWriter: FlutterStandardWriter { if let value = value as? NotificationDataFromLaunch { super.writeByte(129) super.writeValue(value.toList()) - } else if let value = value as? NotificationTapEvent { + } else if let value = value as? IosNotificationTapEvent { super.writeByte(130) super.writeValue(value.toList()) + } else if let value = value as? AndroidNotificationTapEvent { + super.writeByte(131) + super.writeValue(value.toList()) } else { super.writeValue(value) } diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart index 351f2045ca..5c8f279a4f 100644 --- a/lib/host/notifications.g.dart +++ b/lib/host/notifications.g.dart @@ -74,8 +74,11 @@ class NotificationDataFromLaunch { ; } -class NotificationTapEvent { - NotificationTapEvent({ +sealed class NotificationTapEvent { +} + +class IosNotificationTapEvent extends NotificationTapEvent { + IosNotificationTapEvent({ required this.payload, }); @@ -94,9 +97,9 @@ class NotificationTapEvent { Object encode() { return _toList(); } - static NotificationTapEvent decode(Object result) { + static IosNotificationTapEvent decode(Object result) { result as List; - return NotificationTapEvent( + return IosNotificationTapEvent( payload: (result[0] as Map?)!.cast(), ); } @@ -104,7 +107,52 @@ class NotificationTapEvent { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes bool operator ==(Object other) { - if (other is! NotificationTapEvent || other.runtimeType != runtimeType) { + if (other is! IosNotificationTapEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()) +; +} + +class AndroidNotificationTapEvent extends NotificationTapEvent { + AndroidNotificationTapEvent({ + required this.dataUrl, + }); + + /// The intent data URL that was provided when the notification was created + /// during `NotificationDisplayManager._onMessageFcmMessage`. + /// + /// Also see [notificationTapEvents]. + String dataUrl; + + List _toList() { + return [ + dataUrl, + ]; + } + + Object encode() { + return _toList(); } + + static AndroidNotificationTapEvent decode(Object result) { + result as List; + return AndroidNotificationTapEvent( + dataUrl: result[0]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AndroidNotificationTapEvent || other.runtimeType != runtimeType) { return false; } if (identical(this, other)) { @@ -130,9 +178,12 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is NotificationDataFromLaunch) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is NotificationTapEvent) { + } else if (value is IosNotificationTapEvent) { buffer.putUint8(130); writeValue(buffer, value.encode()); + } else if (value is AndroidNotificationTapEvent) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -144,7 +195,9 @@ class _PigeonCodec extends StandardMessageCodec { case 129: return NotificationDataFromLaunch.decode(readValue(buffer)!); case 130: - return NotificationTapEvent.decode(readValue(buffer)!); + return IosNotificationTapEvent.decode(readValue(buffer)!); + case 131: + return AndroidNotificationTapEvent.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index 2813c79ce0..f7cb92073d 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -49,13 +49,17 @@ class NotificationOpenService { try { switch (defaultTargetPlatform) { case TargetPlatform.iOS: - _notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch(); + case TargetPlatform.android: + // On iOS, the notification tap that causes a launch of the app is + // handled a bit differently than on Android where all types of + // notification tap events are served via the + // `notificationTapEventsStream`. + if (defaultTargetPlatform == TargetPlatform.iOS) { + _notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch(); + } + _notifPigeonApi.notificationTapEventsStream() .listen(_navigateForNotification); - - case TargetPlatform.android: - // Do nothing; we do notification routing differently on Android. - // TODO migrate Android to use the new Pigeon API. break; case TargetPlatform.fuchsia: @@ -119,10 +123,19 @@ class NotificationOpenService { narrow: data.narrow); } + static Future _navigateForNotification(NotificationTapEvent event) async { + switch (event) { + case IosNotificationTapEvent(): + return _navigateForNotificationIos(event); + case AndroidNotificationTapEvent(): + return _navigateForNotificationAndroid(event); + } + } + /// Navigates to the [MessageListPage] of the specific conversation /// for the provided payload that was attached while creating the /// notification. - static Future _navigateForNotification(NotificationTapEvent event) async { + static Future _navigateForNotificationIos(IosNotificationTapEvent event) async { assert(defaultTargetPlatform == TargetPlatform.iOS); assert(debugLog('opened notif: ${jsonEncode(event.payload)}')); @@ -143,11 +156,15 @@ class NotificationOpenService { } /// Navigates to the [MessageListPage] of the specific conversation - /// given the `zulip://notification/…` Android intent data URL, + /// given the [AndroidNotificationTapEvent] which carries the + /// `zulip://notification/…` Android intent data URL, /// generated with [NotificationOpenPayload.buildAndroidNotificationUrl] /// while creating the notification. - static Future navigateForAndroidNotificationUrl(Uri url) async { + static Future _navigateForNotificationAndroid(AndroidNotificationTapEvent event) async { assert(defaultTargetPlatform == TargetPlatform.android); + + final url = Uri.tryParse(event.dataUrl); + if (url == null) return; // TODO(log) assert(debugLog('opened notif: url: $url')); NavigatorState navigator = await ZulipApp.navigator; @@ -156,7 +173,7 @@ class NotificationOpenService { 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 assert(url.scheme == 'zulip' && url.host == 'notification'); - final data = tryParseAndroidNotificationUrl(context: context, url: url); + final data = _tryParseAndroidNotificationUrl(context: context, url: url); if (data == null) return; // TODO(log) final route = routeForNotification(context: context, data: data); if (route == null) return; // TODO(log) @@ -182,7 +199,7 @@ class NotificationOpenService { } } - static NotificationOpenPayload? tryParseAndroidNotificationUrl({ + static NotificationOpenPayload? _tryParseAndroidNotificationUrl({ required BuildContext context, required Uri url, }) { diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 69dd62d7c9..742b4c0350 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -174,40 +174,20 @@ class _ZulipAppState extends State with WidgetsBindingObserver { .routeForNotificationFromLaunch(context: context); } - // TODO migrate Android's notification navigation to use the new Pigeon API. - AccountRoute? _initialRouteAndroid( - BuildContext context, - String initialRoute, - ) { - final initialRouteUrl = Uri.tryParse(initialRoute); - if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - assert(debugLog('got notif: url: $initialRouteUrl')); - final data = NotificationOpenService.tryParseAndroidNotificationUrl( - context: context, - url: initialRouteUrl); - if (data == null) return null; // TODO(log) - return NotificationOpenService.routeForNotification( - context: context, - data: data); - } - - return null; - } - List> _handleGenerateInitialRoutes(String initialRoute) { // The `_ZulipAppState.context` lacks the required ancestors. Instead // we use the Navigator which should be available when this callback is // called and its context should have the required ancestors. final context = ZulipApp.navigatorKey.currentContext!; - final route = defaultTargetPlatform == TargetPlatform.iOS - ? _initialRouteIos(context) - : _initialRouteAndroid(context, initialRoute); - if (route != null) { - return [ - HomePage.buildRoute(accountId: route.accountId), - route, - ]; + if (defaultTargetPlatform == TargetPlatform.iOS) { + final route = _initialRouteIos(context); + if (route != null) { + return [ + HomePage.buildRoute(accountId: route.accountId), + route, + ]; + } } final globalStore = GlobalStoreWidget.of(context); @@ -228,9 +208,6 @@ class _ZulipAppState extends State with WidgetsBindingObserver { case Uri(scheme: 'zulip', host: 'login') && var url: await LoginPage.handleWebAuthUrl(url); return true; - case Uri(scheme: 'zulip', host: 'notification') && var url: - await NotificationOpenService.navigateForAndroidNotificationUrl(url); - return true; } return super.didPushRouteInformation(routeInformation); } diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 66c1bd2e71..a36ecfde9f 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -5,6 +5,8 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon(PigeonOptions( dartOut: 'lib/host/notifications.g.dart', swiftOut: 'ios/Runner/Notifications.g.swift', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/notifications/Notifications.g.kt', + kotlinOptions: KotlinOptions(package: 'com.zulip.flutter.notifications'), )) class NotificationDataFromLaunch { @@ -17,8 +19,12 @@ class NotificationDataFromLaunch { final Map payload; } -class NotificationTapEvent { - const NotificationTapEvent({required this.payload}); +sealed class NotificationTapEvent { + const NotificationTapEvent(); +} + +class IosNotificationTapEvent extends NotificationTapEvent { + const IosNotificationTapEvent({required this.payload}); /// The raw payload that is attached to the notification, /// holding the information required to carry out the navigation. @@ -27,6 +33,16 @@ class NotificationTapEvent { final Map payload; } +class AndroidNotificationTapEvent extends NotificationTapEvent { + const AndroidNotificationTapEvent({required this.dataUrl}); + + /// The intent data URL that was provided when the notification was created + /// during `NotificationDisplayManager._onMessageFcmMessage`. + /// + /// Also see [notificationTapEvents]. + final String dataUrl; +} + @HostApi() abstract class NotificationHostApi { /// Retrieves notification data if the app was launched by tapping on a notification. @@ -42,12 +58,20 @@ abstract class NotificationHostApi { @EventChannelApi() abstract class NotificationEventChannelApi { /// An event stream that emits a notification payload when the app - /// encounters a notification tap, while the app is running. + /// encounters a notification tap, on iOS and Android while the app is + /// running, or only on Android when apps was launched by tapping a + /// notification. /// - /// Emits an event when + /// On iOS, 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, emits an event when the initial launch intent + /// (`MainActivity.intent`) or the intent received via + /// `MainActivity.onNewIntent` is an ACTION_VIEW intent and the associated + /// data URL has the "zulip" scheme, and "notification" authority. The + /// emitted event will carry the intent data URL. NotificationTapEvent notificationTapEvents(); } diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index 509e692f21..4f62233ccf 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -153,14 +151,14 @@ void main() { switch (defaultTargetPlatform) { case TargetPlatform.android: final intentDataUrl = androidNotificationUrlForMessage(account, message); - unawaited( - WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); + testBinding.notificationPigeonApi.addNotificationTapEvent( + AndroidNotificationTapEvent(dataUrl: intentDataUrl.toString())); await tester.idle(); // let navigateForNotification find navigator case TargetPlatform.iOS: final payload = messageApnsPayload(message, account: account); testBinding.notificationPigeonApi.addNotificationTapEvent( - NotificationTapEvent(payload: payload)); + IosNotificationTapEvent(payload: payload)); await tester.idle(); // let navigateForNotification find navigator default: @@ -171,11 +169,11 @@ void main() { void setupNotificationDataForLaunch(WidgetTester tester, Account account, Message message) { switch (defaultTargetPlatform) { case TargetPlatform.android: - // Set up a value for `PlatformDispatcher.defaultRouteName` to return, - // for determining the initial route. + // Set up an event to be emitted, after the app is launched to + // generate a navigation. final intentDataUrl = androidNotificationUrlForMessage(account, message); - addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); - tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + testBinding.notificationPigeonApi.addNotificationTapEvent( + AndroidNotificationTapEvent(dataUrl: intentDataUrl.toString())); case TargetPlatform.iOS: // Set up a value to return for @@ -395,7 +393,12 @@ void main() { check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet await tester.pump(); - takeHomePageRouteForAccount(accountB.id); // because associated account + if (defaultTargetPlatform == TargetPlatform.android) { + takeHomePageRouteForAccount(accountA.id); // initial account on launch + takeHomePageReplacement(accountB.id); // replaced by associated account + } else { + takeHomePageRouteForAccount(accountB.id); // because associated account + } matchesNavigation(check(pushedRoutes).single, accountB, message); check(testBinding.globalStore).lastVisitedAccount.equals(accountB); }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));