From 8a7e94aa5bb3d7ecd0f4312872ba8ac0ef34e608 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Wed, 29 Apr 2026 18:29:23 +0530 Subject: [PATCH 1/2] ios [nfc]: Move NotificationTapEventListener to its own Swift file --- ios/Runner.xcodeproj/project.pbxproj | 4 ++++ ios/Runner/AppDelegate.swift | 16 ---------------- ios/Runner/NotificationTapEventListener.swift | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 16 deletions(-) create mode 100644 ios/Runner/NotificationTapEventListener.swift diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6b55b0667..432ae4c92 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34E9F082D776BEB0009AED2 /* Notifications.g.swift */; }; B35E11A62F484E6800DE4085 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; B378A5012F45B08F0031EFA1 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = B378A4FA2F45B08F0031EFA1 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + B3C086B42FA237E1001ACFDD /* NotificationTapEventListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3C086B32FA237E1001ACFDD /* NotificationTapEventListener.swift */; }; B3D425322F6D40C200F9AE69 /* IosNative.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B340EB372F5B092B007AD309 /* IosNative.g.swift */; }; B3D425332F6D40C200F9AE69 /* IosNativeHostApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = B32717682F6C49E5007682B1 /* IosNativeHostApi.swift */; }; F311C174AF9C005CE4AADD72 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */; }; @@ -92,6 +93,7 @@ B34E9F082D776BEB0009AED2 /* Notifications.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.g.swift; sourceTree = ""; }; B378A4FA2F45B08F0031EFA1 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B3AF53A72CA20BD10039801D /* Zulip.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Zulip.xcconfig; path = Flutter/Zulip.xcconfig; sourceTree = ""; }; + B3C086B32FA237E1001ACFDD /* NotificationTapEventListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTapEventListener.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -216,6 +218,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + B3C086B32FA237E1001ACFDD /* NotificationTapEventListener.swift */, B32717682F6C49E5007682B1 /* IosNativeHostApi.swift */, B34E9F082D776BEB0009AED2 /* Notifications.g.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, @@ -575,6 +578,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, B340EB382F5B092B007AD309 /* IosNative.g.swift in Sources */, B32717692F6C49E5007682B1 /* IosNativeHostApi.swift in Sources */, + B3C086B42FA237E1001ACFDD /* NotificationTapEventListener.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index d8010d6c3..46e7daf15 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -71,19 +71,3 @@ private class NotificationHostApiImpl: NotificationHostApi { } } -// Adapted from Pigeon's Swift example for @EventChannelApi: -// https://github.com/flutter/packages/blob/2dff6213a/packages/pigeon/example/app/ios/Runner/AppDelegate.swift#L49-L74 -class NotificationTapEventListener: NotificationTapEventsStreamHandler { - var eventSink: PigeonEventSink? - - override func onListen( - withArguments arguments: Any?, - sink: PigeonEventSink - ) { - eventSink = sink - } - - func onNotificationTapEvent(payload: [AnyHashable: Any]) { - eventSink?.success(IosNotificationTapEvent(payload: payload)) - } -} diff --git a/ios/Runner/NotificationTapEventListener.swift b/ios/Runner/NotificationTapEventListener.swift new file mode 100644 index 000000000..670ef86e9 --- /dev/null +++ b/ios/Runner/NotificationTapEventListener.swift @@ -0,0 +1,19 @@ +import Flutter +import UIKit + +// Adapted from Pigeon's Swift example for @EventChannelApi: +// https://github.com/flutter/packages/blob/2dff6213a/packages/pigeon/example/app/ios/Runner/AppDelegate.swift#L49-L74 +class NotificationTapEventListener: NotificationTapEventsStreamHandler { + var eventSink: PigeonEventSink? + + override func onListen( + withArguments arguments: Any?, + sink: PigeonEventSink + ) { + eventSink = sink + } + + func onNotificationTapEvent(payload: [AnyHashable: Any]) { + eventSink?.success(IosNotificationTapEvent(payload: payload)) + } +} From bd0306b693eff0db6b7c095ee09179f9921d035e Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 19 Mar 2026 19:45:54 +0530 Subject: [PATCH 2/2] notif ios: Buffer notification tap events; same as on Android It turns out that `userNotificationCenter(_:didReceive:withCompletionHandler:)` does execute when app is terminated and later opened by tapping on a notification, so there is no need to check `launchOptions` in `application(_:didFinishLaunchingWithOptions:)` to handle this case. But because we currently handle both cases, there is a potential bug where if both are handled, it could cause double navigations for the same notification. This is not observed in practice, probably because we do not buffer events from `userNotificationCenter(_:didReceive:withCompletionHandler:)` currently and those events may be missed until Flutter init is complete and the handler (in NotificationOpenService.init) is setup. So, remove the unnecessary handlers for `launchOptions`. This also unifies our notification opening implementation between Android and iOS, where we handle all types of notification tap events via the same Pigeon EventChannel API. --- .../flutter/notifications/Notifications.g.kt | 109 +--------------- ios/Runner/AppDelegate.swift | 25 ---- ios/Runner/NotificationTapEventListener.swift | 21 ++- ios/Runner/Notifications.g.swift | 106 +-------------- lib/host/notifications.g.dart | 122 +----------------- lib/model/binding.dart | 5 - lib/notifications/open.dart | 91 ++----------- lib/notifications/receive.dart | 2 +- lib/widgets/app.dart | 44 +++---- pigeon/notifications.dart | 22 ---- test/model/binding.dart | 12 -- test/notifications/open_test.dart | 73 ++++------- 12 files changed, 82 insertions(+), 550 deletions(-) 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 index 57908eb47..ea6a5a0ad 100644 --- 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 @@ -14,26 +14,6 @@ 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) @@ -78,40 +58,6 @@ class FlutterError ( 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. @@ -202,16 +148,11 @@ 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() -> { + 130.toByte() -> { return (readValue(buffer) as? List)?.let { AndroidNotificationTapEvent.fromList(it) } @@ -221,16 +162,12 @@ private open class NotificationsPigeonCodec : StandardMessageCodec() { } override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { when (value) { - is NotificationDataFromLaunch -> { - stream.write(129) - writeValue(stream, value.toList()) - } is IosNotificationTapEvent -> { - stream.write(130) + stream.write(129) writeValue(stream, value.toList()) } is AndroidNotificationTapEvent -> { - stream.write(131) + stream.write(130) writeValue(stream, value.toList()) } else -> super.writeValue(stream, value) @@ -240,46 +177,6 @@ private open class NotificationsPigeonCodec : StandardMessageCodec() { 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 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 46e7daf15..b38a11674 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -20,18 +20,6 @@ import UIKit IosNativeHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: IosNativeHostApiImpl()) - // 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, @@ -58,16 +46,3 @@ import UIKit completionHandler() } } - -private class NotificationHostApiImpl: NotificationHostApi { - private let maybeDataFromLaunch: NotificationDataFromLaunch? - - init(_ maybeDataFromLaunch: NotificationDataFromLaunch?) { - self.maybeDataFromLaunch = maybeDataFromLaunch - } - - func getNotificationDataFromLaunch() -> NotificationDataFromLaunch? { - maybeDataFromLaunch - } -} - diff --git a/ios/Runner/NotificationTapEventListener.swift b/ios/Runner/NotificationTapEventListener.swift index 670ef86e9..d312c5319 100644 --- a/ios/Runner/NotificationTapEventListener.swift +++ b/ios/Runner/NotificationTapEventListener.swift @@ -5,15 +5,34 @@ import UIKit // https://github.com/flutter/packages/blob/2dff6213a/packages/pigeon/example/app/ios/Runner/AppDelegate.swift#L49-L74 class NotificationTapEventListener: NotificationTapEventsStreamHandler { var eventSink: PigeonEventSink? + var buffer: [NotificationTapEvent] = [] override func onListen( withArguments arguments: Any?, sink: PigeonEventSink ) { eventSink = sink + if !buffer.isEmpty { + buffer.forEach { + sink.success($0) + } + buffer.removeAll() + } + } + + override func onCancel(withArguments arguments: Any?) { + if let eventSink = self.eventSink { + eventSink.endOfStream() + self.eventSink = nil + } } func onNotificationTapEvent(payload: [AnyHashable: Any]) { - eventSink?.success(IosNotificationTapEvent(payload: payload)) + let event = IosNotificationTapEvent(payload: payload) + if let eventSink = self.eventSink { + eventSink.success(event) + } else { + buffer.append(event) + } } } diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift index 7483f97cd..0e4969a6f 100644 --- a/ios/Runner/Notifications.g.swift +++ b/ios/Runner/Notifications.g.swift @@ -29,32 +29,6 @@ final class PigeonError: Error { } } -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 } @@ -128,35 +102,6 @@ func deepHashNotifications(value: Any?, hasher: inout Hasher) { -/// Generated class from Pigeon that represents data sent in messages. -struct NotificationDataFromLaunch: Hashable { - /// 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 - ] - } - static func == (lhs: NotificationDataFromLaunch, rhs: NotificationDataFromLaunch) -> 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. /// This protocol should not be extended by any user class outside of the generated file. protocol NotificationTapEvent { @@ -235,10 +180,8 @@ 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 IosNotificationTapEvent.fromList(self.readValue() as! [Any?]) - case 131: + case 130: return AndroidNotificationTapEvent.fromList(self.readValue() as! [Any?]) default: return super.readValue(ofType: type) @@ -248,14 +191,11 @@ private class NotificationsPigeonCodecReader: FlutterStandardReader { private class NotificationsPigeonCodecWriter: FlutterStandardWriter { override func writeValue(_ value: Any) { - if let value = value as? NotificationDataFromLaunch { + if let value = value as? IosNotificationTapEvent { super.writeByte(129) super.writeValue(value.toList()) - } 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.writeByte(130) super.writeValue(value.toList()) } else { super.writeValue(value) @@ -279,46 +219,6 @@ class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable 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. - /// - /// 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 - 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. - /// - /// 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 - 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 diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart index 437c82de4..cee149e13 100644 --- a/lib/host/notifications.g.dart +++ b/lib/host/notifications.g.dart @@ -7,32 +7,6 @@ import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; import 'package:flutter/services.dart'; - -Object? _extractReplyValueOrThrow( - List? replyList, - String channelName, { - required bool isNullValid, -}) { - if (replyList == null) { - throw PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel: "$channelName".', - ); - } else if (replyList.length > 1) { - throw PlatformException( - code: replyList[0]! as String, - message: replyList[1] as String?, - details: replyList[2], - ); - } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } - return replyList.firstOrNull; -} - bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { return a.length == b.length && @@ -48,51 +22,6 @@ bool _deepEquals(Object? a, Object? b) { } -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; - - List _toList() { - return [ - payload, - ]; - } - - Object encode() { - return _toList(); } - - static NotificationDataFromLaunch decode(Object result) { - result as List; - return NotificationDataFromLaunch( - payload: (result[0] as Map?)!.cast(), - ); - } - - @override - // ignore: avoid_equals_and_hash_code_on_mutable_classes - bool operator ==(Object other) { - if (other is! NotificationDataFromLaunch || 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()) -; -} - sealed class NotificationTapEvent { } @@ -202,14 +131,11 @@ class _PigeonCodec extends StandardMessageCodec { 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 IosNotificationTapEvent) { - buffer.putUint8(130); + buffer.putUint8(129); writeValue(buffer, value.encode()); } else if (value is AndroidNotificationTapEvent) { - buffer.putUint8(131); + buffer.putUint8(130); writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); @@ -220,10 +146,8 @@ class _PigeonCodec extends StandardMessageCodec { Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { case 129: - return NotificationDataFromLaunch.decode(readValue(buffer)!); - case 130: return IosNotificationTapEvent.decode(readValue(buffer)!); - case 131: + case 130: return AndroidNotificationTapEvent.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -233,46 +157,6 @@ class _PigeonCodec extends StandardMessageCodec { 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. - /// - /// 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 - Future getNotificationDataFromLaunch() async { - final pigeonVar_channelName = 'dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch$pigeonVar_messageChannelSuffix'; - final pigeonVar_channel = BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final pigeonVar_replyList = await pigeonVar_sendFuture as List?; - - final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( - pigeonVar_replyList, - pigeonVar_channelName, - isNullValid: true, - ) - ; - return pigeonVar_replyValue as NotificationDataFromLaunch?; - } -} - /// An event stream that emits a notification payload /// when a notification is tapped. /// diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 2ecd79df1..6136f5896 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -359,11 +359,6 @@ class PackageInfo { // 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(); - /// An event stream that emits a notification payload /// when a notification is tapped. /// diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart index e80adf159..6bb5a1c7f 100644 --- a/lib/notifications/open.dart +++ b/lib/notifications/open.dart @@ -33,68 +33,22 @@ class NotificationOpenService { _instance = null; } - NotificationDataFromLaunch? _notifDataFromLaunch; - - /// A [Future] that completes to signal that the initialization of - /// [NotificationNavigationService] has completed - /// (with either success or failure). - /// - /// Null if [start] hasn't been called. - Future? get initialized => _initializedSignal?.future; - - Completer? _initializedSignal; - - Future start() async { - assert(_initializedSignal == null); - _initializedSignal = Completer(); - try { - switch (defaultTargetPlatform) { - case TargetPlatform.iOS: - // 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`. - _notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch(); - - _notifPigeonApi.notificationTapEventsStream() - .listen(_navigateForNotification); - - case TargetPlatform.android: - _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(); + void start() { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + _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; } } - /// 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 the latter case an error dialog is also shown. - /// - /// The context argument should be a descendant of the app's main [Navigator]. - AccountRoute? routeForNotificationFromLaunch({required BuildContext context}) { - assert(defaultTargetPlatform == TargetPlatform.iOS); - final data = _notifDataFromLaunch; - if (data == null) return null; - assert(debugLog('opened notif: ${jsonEncode(data.payload)}')); - - final notifNavData = _tryParseIosApnsPayload(context, data.payload); - if (notifNavData == null) return null; // TODO(log) - - return routeForNotification(context: context, data: notifNavData); - } - /// Finds the account associated with the given notification. /// /// Returns null and shows an error dialog if the associated account is not @@ -120,25 +74,6 @@ class NotificationOpenService { return account; } - /// 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. - /// - /// The context argument should be a descendant of the app's main [Navigator]. - static AccountRoute? routeForNotification({ - required BuildContext context, - required NotificationOpenPayload data, - }) { - final account = _accountForNotification(context: context, data: data); - if (account == null) return null; - - return MessageListPage.buildRoute( - accountId: account.id, - // TODO(#1565): Open at specific message, not just conversation - narrow: data.narrow); - } - /// Navigate appropriately for opening the given notification. static void _navigateForNotificationPayload( NavigatorState navigator, NotificationOpenPayload data) { diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index 3229e00cc..b747c057b 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -64,7 +64,7 @@ class NotificationService { } Future start() async { - await NotificationOpenService.instance.start(); + NotificationOpenService.instance.start(); switch (defaultTargetPlatform) { case TargetPlatform.android: diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index e64b7a245..203cb39c7 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -9,7 +9,6 @@ import '../log.dart'; import '../model/actions.dart'; import '../model/localizations.dart'; import '../model/store.dart'; -import '../notifications/open.dart'; import 'about_zulip.dart'; import 'dialog.dart'; import 'home.dart'; @@ -183,38 +182,28 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } - AccountRoute? _initialRouteIos(BuildContext context) { - return NotificationOpenService.instance - .routeForNotificationFromLaunch(context: context); - } - 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!; - if (defaultTargetPlatform == TargetPlatform.iOS) { - final route = _initialRouteIos(context); - if (route != null) { - return [ - HomePage.buildRoute(accountId: route.accountId), - route, - ]; - } - } else { - // On Android, we ignore any notification at this step, and handle - // any initial notification by a navigation after the first frame. - // See [NotificationOpenService.start], and the buffering in - // NotificationTapEventListener.kt when onListen is not yet called. - // - // The navigation causes a small visible glitch where one loading spinner - // gets replaced by another; see recordings: - // https://github.com/zulip/zulip-flutter/pull/2043#discussion_r2794138972 - // TODO it'd be nice to avoid that glitch by controlling the initial route. - // We accept this glitch as a workaround for an upstream issue: - // https://github.com/flutter/flutter/issues/178305 - } + // We ignore any notification at this step, and handle any initial + // notification by a navigation after the first frame. + // See [NotificationOpenService.start], and the buffering in + // NotificationTapEventListener.kt (on Android) and + // NotificationTapEventListener.swift (on iOS) when onListen is not yet + // called. + // + // The navigation causes a small visible glitch where one loading spinner + // gets replaced by another. + // See recordings for Android (but behaviour is similar on iOS): + // https://github.com/zulip/zulip-flutter/pull/2043#discussion_r2794138972 + // + // TODO(android) consider removing this glitch on Android, either when + // the following upstream issue is fixed: + // https://github.com/flutter/flutter/issues/178305 + // or by making a synchronous call to `MainActivity.getIntent` using FFI. final globalStore = GlobalStoreWidget.of(context); final lastVisitedAccountId = globalStore.lastVisitedAccount?.id; @@ -241,7 +230,6 @@ class _ZulipAppState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { return GlobalStoreWidget( - blockingFuture: NotificationOpenService.instance.initialized, child: Builder(builder: (context) { return MaterialApp( onGenerateTitle: (BuildContext context) { diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 565b13b6f..41c71a6d4 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -9,16 +9,6 @@ import 'package:pigeon/pigeon.dart'; kotlinOptions: KotlinOptions(package: 'com.zulip.flutter.notifications'), )) -class NotificationDataFromLaunch { - const 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]. - final Map payload; -} - sealed class NotificationTapEvent { const NotificationTapEvent(); } @@ -51,18 +41,6 @@ class AndroidNotificationTapEvent extends NotificationTapEvent { final String dataUrl; } -@HostApi() -abstract class 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 - NotificationDataFromLaunch? getNotificationDataFromLaunch(); -} - /// An event stream that emits a notification payload /// when a notification is tapped. /// diff --git a/test/model/binding.dart b/test/model/binding.dart index c3ab63851..e3d32d305 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -908,18 +908,6 @@ 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; - final _notificationTapEventsStreamController = StreamController(); diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart index 92e276038..220ed0363 100644 --- a/test/notifications/open_test.dart +++ b/test/notifications/open_test.dart @@ -172,54 +172,33 @@ void main() { } } - Future openNotification( + void scheduleNotificationTapEvent( WidgetTester tester, Account account, Message message, { bool encrypted = true, - }) async { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - final intentDataUrl = notificationUrlForMessage(account, message); - testBinding.notificationPigeonApi.addNotificationTapEvent( - AndroidNotificationTapEvent(dataUrl: intentDataUrl.toString())); - await tester.idle(); // let navigateForNotification find navigator - - case TargetPlatform.iOS: - final payload = messageApnsPayload(account, message, encrypted: encrypted); - testBinding.notificationPigeonApi.addNotificationTapEvent( - IosNotificationTapEvent(payload: payload)); - await tester.idle(); // let navigateForNotification find navigator - - default: - throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); - } + }) { + final event = switch (defaultTargetPlatform) { + TargetPlatform.android => AndroidNotificationTapEvent( + dataUrl: notificationUrlForMessage(account, message).toString()), + TargetPlatform.iOS => IosNotificationTapEvent( + payload: messageApnsPayload(account, message, encrypted: encrypted)), + _ => throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'), + }; + + // Set up an event to be emitted from + // `notificationPigeonApi.notificationTapEventsStream`. + testBinding.notificationPigeonApi.addNotificationTapEvent(event); } - void setupNotificationDataForLaunch( + Future openNotification( WidgetTester tester, Account account, Message message, { bool encrypted = true, - }) { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - // Set up an event to be emitted from - // `notificationPigeonApi.notificationTapEventsStream`. - final intentDataUrl = notificationUrlForMessage(account, message); - testBinding.notificationPigeonApi.addNotificationTapEvent( - AndroidNotificationTapEvent(dataUrl: intentDataUrl.toString())); - - case TargetPlatform.iOS: - // Set up a value to return for - // `notificationPigeonApi.getNotificationDataFromLaunch`. - final payload = messageApnsPayload(account, message, encrypted: encrypted); - testBinding.notificationPigeonApi.setNotificationDataFromLaunch( - NotificationDataFromLaunch(payload: payload)); - - default: - throw UnsupportedError('Unsupported target platform: "$defaultTargetPlatform"'); - } + }) async { + scheduleNotificationTapEvent(tester, account, message, encrypted: encrypted); + await tester.idle(); // let navigateForNotification find navigator } void takeHomePageReplacement(int accountId) { @@ -381,7 +360,7 @@ void main() { addTearDown(testBinding.reset); final account = eg.selfAccount; final message = eg.streamMessage(); - setupNotificationDataForLaunch(tester, account, message); + scheduleNotificationTapEvent(tester, account, message); // Now start the app. await testBinding.globalStore.add(account, eg.initialSnapshot()); @@ -398,7 +377,7 @@ void main() { addTearDown(testBinding.reset); final account = eg.selfAccount; final message = eg.streamMessage(); - setupNotificationDataForLaunch(tester, account, message, encrypted: false); + scheduleNotificationTapEvent(tester, account, message, encrypted: false); // Now start the app. await testBinding.globalStore.add(account, eg.initialSnapshot()); @@ -420,7 +399,7 @@ void main() { await testBinding.globalStore.add(accountA, eg.initialSnapshot()); await testBinding.globalStore.add(accountB, eg.initialSnapshot( realmUsers: [eg.otherUser])); - setupNotificationDataForLaunch(tester, accountB, message); + scheduleNotificationTapEvent(tester, accountB, message); await prepare(tester, early: true); check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet @@ -558,20 +537,14 @@ void main() { accountB, eg.initialSnapshot(realmUsers: [eg.otherUser]), markLastVisited: false); check(testBinding.globalStore).lastVisitedAccount.equals(accountA); - setupNotificationDataForLaunch(tester, accountB, message); + scheduleNotificationTapEvent(tester, accountB, message); await prepare(tester, early: true); check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet await tester.pump(); - if (defaultTargetPlatform == TargetPlatform.android) { - takeHomePageRouteForAccount(accountA.id); // initial account on launch - takeHomePageReplacement(accountB.id); // replaced by associated account - } else { - // On iOS, associated account is determined early while generating - // initial routes. See `_ZulipAppState._handleGenerateInitialRoutes`. - takeHomePageRouteForAccount(accountB.id); - } + takeHomePageRouteForAccount(accountA.id); // initial account on launch + takeHomePageReplacement(accountB.id); // replaced by associated account matchesNavigation(check(pushedRoutes).single, accountB, message); check(testBinding.globalStore).lastVisitedAccount.equals(accountB); }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));