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/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); 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/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; } } 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}));