diff --git a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt similarity index 94% rename from android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt rename to android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt index f4862e2b0b..39207e3470 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt @@ -13,7 +13,7 @@ import io.flutter.plugin.common.StandardMethodCodec import io.flutter.plugin.common.StandardMessageCodec import java.io.ByteArrayOutputStream import java.nio.ByteBuffer -private object NotificationsPigeonUtils { +private object AndroidNotificationsPigeonUtils { fun wrapResult(result: Any?): List { return listOf(result) @@ -128,7 +128,7 @@ data class NotificationChannel ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -171,7 +171,7 @@ data class AndroidIntent ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -215,7 +215,7 @@ data class PendingIntent ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -249,7 +249,7 @@ data class InboxStyle ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -299,7 +299,7 @@ data class Person ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -339,7 +339,7 @@ data class MessagingStyleMessage ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -382,7 +382,7 @@ data class MessagingStyle ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -419,7 +419,7 @@ data class Notification ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -459,7 +459,7 @@ data class StatusBarNotification ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } @@ -509,11 +509,11 @@ data class StoredNotificationSound ( if (this === other) { return true } - return NotificationsPigeonUtils.deepEquals(toList(), other.toList()) } + return AndroidNotificationsPigeonUtils.deepEquals(toList(), other.toList()) } override fun hashCode(): Int = toList().hashCode() } -private open class NotificationsPigeonCodec : StandardMessageCodec() { +private open class AndroidNotificationsPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { 129.toByte() -> { @@ -721,7 +721,7 @@ interface AndroidNotificationHostApi { companion object { /** The codec used by AndroidNotificationHostApi. */ val codec: MessageCodec by lazy { - NotificationsPigeonCodec() + AndroidNotificationsPigeonCodec() } /** Sets up an instance of `AndroidNotificationHostApi` to handle messages through the `binaryMessenger`. */ @JvmOverloads @@ -737,7 +737,7 @@ interface AndroidNotificationHostApi { api.createNotificationChannel(channelArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -752,7 +752,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getNotificationChannels()) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -770,7 +770,7 @@ interface AndroidNotificationHostApi { api.deleteNotificationChannel(channelIdArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -785,7 +785,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.listStoredSoundsInNotificationsDirectory()) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -803,7 +803,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg)) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -835,7 +835,7 @@ interface AndroidNotificationHostApi { api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, messagingStyleArg, numberArg, smallIconResourceNameArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -852,7 +852,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getActiveNotificationMessagingStyleByTag(tagArg)) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -869,7 +869,7 @@ interface AndroidNotificationHostApi { val wrapped: List = try { listOf(api.getActiveNotifications(desiredExtrasArg)) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } @@ -888,7 +888,7 @@ interface AndroidNotificationHostApi { api.cancel(tagArg, idArg) listOf(null) } catch (exception: Throwable) { - NotificationsPigeonUtils.wrapError(exception) + AndroidNotificationsPigeonUtils.wrapError(exception) } reply.reply(wrapped) } diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ea2e10cff3..212d3ac760 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -848,9 +848,9 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" + "errorNotificationOpenAccountNotFound": "The account associated with this notification could not be found.", + "@errorNotificationOpenAccountNotFound": { + "description": "Error message when the account associated with the notification could not be found" }, "errorReactionAddingFailedTitle": "Adding reaction failed", "@errorReactionAddingFailedTitle": { diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index da8d2c8664..63875bf9ba 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -557,10 +557,6 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "Konto związane z tym powiadomieniem już nie istnieje.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "aboutPageOpenSourceLicenses": "Licencje otwartego źródła", "@aboutPageOpenSourceLicenses": { "description": "Item title in About Zulip page to navigate to Licenses page" diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index 57cf48e3e0..aceefe65c6 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -287,10 +287,6 @@ "@errorNotificationOpenTitle": { "description": "Error title when notification opening fails" }, - "errorNotificationOpenAccountMissing": "Учетной записи, связанной с этим оповещением, больше нет.", - "@errorNotificationOpenAccountMissing": { - "description": "Error message when the account associated with the notification is not found" - }, "switchAccountButton": "Сменить учетную запись", "@switchAccountButton": { "description": "Label for main-menu button leading to the choose-account page." diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md new file mode 100644 index 0000000000..5803215a48 --- /dev/null +++ b/docs/howto/push-notifications-ios-simulator.md @@ -0,0 +1,253 @@ +# Testing Push Notifications on iOS Simulator + +For documentation on testing push notifications on Android or a real +iOS Device, see https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md + +This doc describes how to test client side changes on iOS Simulator. +It will demonstrate how to use APNs payloads the server sends to +Apple's Push Notification service to show notifications on iOS +Simulator. + +## 1. (Optional) Setup dev server + +_Skip to step 6 in which you will use canned notification payloads, +these intermediate steps records how to get those payloads, which may +be useful in future._ + +Follow +[this setup tutorial](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html) +to setup and run the dev server on same the Mac machine that hosts +the iOS Simulator. + +If you want to run the dev server on a different machine than the Mac +host, you'll need to follow extra steps +[documented here](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md) +to make it possible for the app running on the iOS Simulator to +connect to the dev server. + +## 2. (Optional) Setup the dev user to receive mobile notifications. + +We'll use the devlogin user `iago@zulip.com` to test notifications, +log in to that user by going to `/devlogin` on that server on Web. + +And then follow the steps [here](https://zulip.com/help/mobile-notifications) +to enable Mobile Notifications for "Channels". + +## 3. (Optional) Login to the dev user on zulip-flutter. + + + +To login to this user in the Flutter app, you'll need the password +that was generated by the development server. You can print the +password by running this command inside your `vagrant ssh` shell: +``` +$ ./manage.py print_initial_password iago@zulip.com +``` + +Then run the app on the iOS Simulator, accept the permission to +receive push notifications, and then login to the dev user +(`iago@zulip.com`). + +## 4. (Optional) Edit the server code to log the notification payload. + +We need to retrieve the APNs payload the server generates and sends +to the bouncer. To do that we can add a log statement after the +server completes generating the APNs in `zerver/lib/push_notifications.py`: + +```diff + apns_payload = get_message_payload_apns( + user_profile, + message, + trigger, + mentioned_user_group_id, + mentioned_user_group_name, + can_access_sender, + ) + gcm_payload, gcm_options = get_message_payload_gcm( + user_profile, message, mentioned_user_group_id, mentioned_user_group_name, can_access_sender + ) + logger.info("Sending push notifications to mobile clients for user %s", user_profile_id) ++ logger.info("APNS payload %s", orjson.dumps(apns_payload)) + + android_devices = list( + PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.FCM).order_by("id") +``` + +## 5. (Optional) Send messages to the dev user + +To generate notifications to the dev user `iago@zulip.com` we need to +send messages from another user. For a variety of different types of +payloads try sending a message in a topic, a message in a group DM, +and one in one-one DM. Then look for the payloads in the server logs +by searching for "APNS payload". + +The logged payload JSON will have different structure than what an +iOS device actually receives, to fix that, run the payload through +the following command: + +```shell-session +$ echo '{"alert":{"title": ...' | jq '{aps: {alert: .alert, sound: .sound, badge: .badge}, zulip: .custom.zulip}' +``` + +## 6. Push APNs payload to iOS Simulator + +_If you skipped steps 2-5, you'll need pre-forged APNs payloads for +existing messages in a default development server messages for the +user `iago@zulip.com`:_ + +These canned payloads were generated from Zulip Server 11.0-dev+git +8fd04b0f0, API Feature Level 377, in April 2025. + +These canned payloads assume that EXTERNAL_HOST has its default value +for the dev server. If you've +[set EXTERNAL_HOST to use an IP address](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md#4-set-external_host) +in order to enable your device to connect to the dev server, you'll +need to adjust the `realm_url` fields. You can do this by a +find-and-replace for `localhost`; for example, +`perl -i -0pe s/localhost/10.0.2.2/g tmp/*.json` after saving the +canned payloads to files `tmp/*.json`. + +
+Payload: dm.json + +```json +{ + "aps": { + "alert": { + "title": "Zoe", + "subtitle": "", + "body": "But wouldn't that show you contextually who is in the audience before you have to open the compose box?" + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 7, + "sender_email": "user7@zulipdev.com", + "time": 1740890583, + "recipient_type": "private", + "message_ids": [ + 87 + ] + } +} +``` + +
+ +
+Payload: group_dm.json + +```json +{ + "aps": { + "alert": { + "title": "Othello, the Moor of Venice, Polonius (guest), Iago", + "subtitle": "Othello, the Moor of Venice:", + "body": "Sit down awhile; And let us once again assail your ears, That are so fortified against our story What we have two nights seen." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 12, + "sender_email": "user12@zulipdev.com", + "time": 1740533641, + "recipient_type": "private", + "pm_users": "11,12,13", + "message_ids": [ + 17 + ] + } +} +``` + +
+ +
+Payload: stream.json + +```json +{ + "aps": { + "alert": { + "title": "#devel > plotter", + "subtitle": "Desdemona:", + "body": "Despite the fact that such a claim at first glance seems counterintuitive, it is derived from known results. Electrical engineering follows a cycle of four phases: location, refinement, visualization, and evaluation." + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulipdev.com:9991", + "realm_id": 2, + "realm_uri": "http://localhost:9991", + "realm_url": "http://localhost:9991", + "realm_name": "Zulip Dev", + "user_id": 11, + "sender_id": 9, + "sender_email": "user9@zulipdev.com", + "time": 1740558997, + "recipient_type": "stream", + "stream": "devel", + "stream_id": 11, + "topic": "plotter", + "message_ids": [ + 40 + ] + } +} +``` + +
+ +To receive a notification on the iOS Simulator, we need to push +the APNs payload to the specific running iOS Simulator by using it's +device ID, you can get the device ID by running the following command: + +```shell-session +$ xcrun simctl list devices booted +``` + +
+Example output: + +```shell-session +$ xcrun simctl list devices booted +== Devices == +-- iOS 18.3 -- + iPhone 16 Pro (90CC33B2-679B-4053-B380-7B986A29F28C) (Booted) +``` + +
+ +And then push the payload using the following command: + +```shell-session +$ xcrun simctl push [device-id] com.zulip.flutter [payload json path] +``` + +
+Example output: + +```shell-session +$ xcrun simctl push 90CC33B2-679B-4053-B380-7B986A29F28C com.zulip.flutter ./dm.json +Notification sent to 'com.zulip.flutter' +``` + +
+ +Now, on the iOS Simulator you should have a notification and tapping +on it should route to the specific conversation. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index b4928e2220..7df051a142 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B34E9F082D776BEB0009AED2 /* Notifications.g.swift */; }; F311C174AF9C005CE4AADD72 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -48,6 +49,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.g.swift; sourceTree = ""; }; B3AF53A72CA20BD10039801D /* Zulip.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Zulip.xcconfig; path = Flutter/Zulip.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ @@ -115,6 +117,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + B34E9F082D776BEB0009AED2 /* Notifications.g.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -297,6 +300,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + B34E9F092D776BEB0009AED2 /* Notifications.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b636303481..193adfef7c 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,11 +3,64 @@ import Flutter @main @objc class AppDelegate: FlutterAppDelegate { + private var notificationTapEventListener: NotificationTapEventListener? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + let controller = window?.rootViewController as! FlutterViewController + + // Retrieve the remote notification payload from launch options; + // this will be null if the launch wasn't triggered by a notification. + let notificationPayload = launchOptions?[.remoteNotification] as? [AnyHashable : Any] + let api = NotificationHostApiImpl(notificationPayload.map { NotificationDataFromLaunch(payload: $0) }) + NotificationHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: api) + + notificationTapEventListener = NotificationTapEventListener() + NotificationTapEventsStreamHandler.register(with: controller.binaryMessenger, streamHandler: notificationTapEventListener!) + + // Setup handler for notification tap while the app is running. + UNUserNotificationCenter.current().delegate = self + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + if response.actionIdentifier == UNNotificationDefaultActionIdentifier { + let listener = notificationTapEventListener! + let userInfo = response.notification.request.content.userInfo + listener.onNotificationTapEvent(payload: userInfo) + } + completionHandler() + } +} + +private class NotificationHostApiImpl: NotificationHostApi { + private let maybeDataFromLaunch: NotificationDataFromLaunch? + + init(_ maybeDataFromLaunch: NotificationDataFromLaunch?) { + self.maybeDataFromLaunch = maybeDataFromLaunch + } + + func getNotificationDataFromLaunch() -> NotificationDataFromLaunch? { + maybeDataFromLaunch + } +} + +class NotificationTapEventListener: NotificationTapEventsStreamHandler { + var eventSink: PigeonEventSink? + + override func onListen(withArguments arguments: Any?, sink: PigeonEventSink) { + eventSink = sink + } + + func onNotificationTapEvent(payload: [AnyHashable : Any]) { + eventSink?.success(NotificationTapEvent(payload: payload)) + } } diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift new file mode 100644 index 0000000000..40db818d33 --- /dev/null +++ b/ios/Runner/Notifications.g.swift @@ -0,0 +1,335 @@ +// Autogenerated from Pigeon (v25.3.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +func deepEqualsNotifications(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case is (Void, Void): + return true + + case let (cleanLhsHashable, cleanRhsHashable) as (AnyHashable, AnyHashable): + return cleanLhsHashable == cleanRhsHashable + + case let (cleanLhsArray, cleanRhsArray) as ([Any?], [Any?]): + guard cleanLhsArray.count == cleanRhsArray.count else { return false } + for (index, element) in cleanLhsArray.enumerated() { + if !deepEqualsNotifications(element, cleanRhsArray[index]) { + return false + } + } + return true + + case let (cleanLhsDictionary, cleanRhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard cleanLhsDictionary.count == cleanRhsDictionary.count else { return false } + for (key, cleanLhsValue) in cleanLhsDictionary { + guard cleanRhsDictionary.index(forKey: key) != nil else { return false } + if !deepEqualsNotifications(cleanLhsValue, cleanRhsDictionary[key]!) { + return false + } + } + return true + + default: + // Any other type shouldn't be able to be used with pigeon. File an issue if you find this to be untrue. + return false + } +} + +func deepHashNotifications(value: Any?, hasher: inout Hasher) { + if let valueList = value as? [AnyHashable] { + for item in valueList { deepHashNotifications(value: item, hasher: &hasher) } + return + } + + if let valueDict = value as? [AnyHashable: AnyHashable] { + for key in valueDict.keys { + hasher.combine(key) + deepHashNotifications(value: valueDict[key]!, hasher: &hasher) + } + return + } + + if let hashableValue = value as? AnyHashable { + hasher.combine(hashableValue.hashValue) + } + + return hasher.combine(String(describing: value)) +} + + + +/// 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. +struct NotificationTapEvent: Hashable { + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + var payload: [AnyHashable?: Any?] + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? { + let payload = pigeonVar_list[0] as! [AnyHashable?: Any?] + + return NotificationTapEvent( + payload: payload + ) + } + func toList() -> [Any?] { + return [ + payload + ] + } + static func == (lhs: NotificationTapEvent, rhs: NotificationTapEvent) -> Bool { + return deepEqualsNotifications(lhs.toList(), rhs.toList()) } + func hash(into hasher: inout Hasher) { + deepHashNotifications(value: toList(), hasher: &hasher) + } +} + +private class NotificationsPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?]) + case 130: + return NotificationTapEvent.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class NotificationsPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NotificationDataFromLaunch { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? NotificationTapEvent { + super.writeByte(130) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class NotificationsPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return NotificationsPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return NotificationsPigeonCodecWriter(data: data) + } +} + +class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter()) +} + +var notificationsPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: NotificationsPigeonCodecReaderWriter()); + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// 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 + private var pigeonSink: PigeonEventSink? = nil + + init(wrapper: PigeonEventChannelWrapper) { + self.wrapper = wrapper + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + pigeonSink = PigeonEventSink(events) + wrapper.onListen(withArguments: arguments, sink: pigeonSink!) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + pigeonSink = nil + wrapper.onCancel(withArguments: arguments) + return nil + } +} + +class PigeonEventChannelWrapper { + func onListen(withArguments arguments: Any?, sink: PigeonEventSink) {} + func onCancel(withArguments arguments: Any?) {} +} + +class PigeonEventSink { + private let sink: FlutterEventSink + + init(_ sink: @escaping FlutterEventSink) { + self.sink = sink + } + + func success(_ value: ReturnType) { + sink(value) + } + + func error(code: String, message: String?, details: Any?) { + sink(FlutterError(code: code, message: message, details: details)) + } + + func endOfStream() { + sink(FlutterEndOfEventStream) + } + +} + +class NotificationTapEventsStreamHandler: PigeonEventChannelWrapper { + static func register(with messenger: FlutterBinaryMessenger, + instanceName: String = "", + streamHandler: NotificationTapEventsStreamHandler) { + var channelName = "dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents" + if !instanceName.isEmpty { + channelName += ".\(instanceName)" + } + let internalStreamHandler = PigeonStreamHandler(wrapper: streamHandler) + let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: notificationsPigeonMethodCodec) + channel.setStreamHandler(internalStreamHandler) + } +} + diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index e8b15440e3..a8745b08ce 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1235,11 +1235,11 @@ abstract class ZulipLocalizations { /// **'Failed to open notification'** String get errorNotificationOpenTitle; - /// Error message when the account associated with the notification is not found + /// Error message when the account associated with the notification could not be found /// /// In en, this message translates to: - /// **'The account associated with this notification no longer exists.'** - String get errorNotificationOpenAccountMissing; + /// **'The account associated with this notification could not be found.'** + String get errorNotificationOpenAccountNotFound; /// Error title when adding a message reaction fails /// diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index c2478f4613..3d66b73179 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -661,7 +661,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 289ba33af2..13c734449e 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -661,7 +661,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 00537f73a2..d316d87fe9 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -661,7 +661,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 3c063e91da..f4f9f03a12 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -661,7 +661,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Failed to open notification'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Adding reaction failed'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index d4c3a033d9..b5a35bef04 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -661,7 +661,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Otwieranie powiadomienia bez powodzenia'; @override - String get errorNotificationOpenAccountMissing => 'Konto związane z tym powiadomieniem już nie istnieje.'; + String get errorNotificationOpenAccountNotFound => 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Dodanie reakcji bez powodzenia'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index cb09ca516e..c6c08f9adc 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -661,7 +661,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не удалось открыть оповещения'; @override - String get errorNotificationOpenAccountMissing => 'Учетной записи, связанной с этим оповещением, больше нет.'; + String get errorNotificationOpenAccountNotFound => 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Не удалось добавить реакцию'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 0cb42c3a37..31e9208202 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -661,7 +661,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Nepodarilo sa otvoriť oznámenie'; @override - String get errorNotificationOpenAccountMissing => 'The account associated with this notification no longer exists.'; + String get errorNotificationOpenAccountNotFound => 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Nepodarilo sa pridať reakciu'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index b7e6d792f2..79afe98ce4 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -661,7 +661,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get errorNotificationOpenTitle => 'Не вдалося відкрити сповіщення'; @override - String get errorNotificationOpenAccountMissing => 'Обліковий запис, пов’язаний із цим сповіщенням, більше не існує.'; + String get errorNotificationOpenAccountNotFound => 'The account associated with this notification could not be found.'; @override String get errorReactionAddingFailedTitle => 'Не вдалося додати реакцію'; diff --git a/lib/host/notifications.dart b/lib/host/notifications.dart new file mode 100644 index 0000000000..6c3e593e2c --- /dev/null +++ b/lib/host/notifications.dart @@ -0,0 +1 @@ +export './notifications.g.dart'; diff --git a/lib/host/notifications.g.dart b/lib/host/notifications.g.dart new file mode 100644 index 0000000000..a83b67c804 --- /dev/null +++ b/lib/host/notifications.g.dart @@ -0,0 +1,210 @@ +// Autogenerated from Pigeon (v25.3.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} +bool _deepEquals(Object? a, Object? b) { + if (a is List && b is List) { + return a.length == b.length && + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + } + if (a is Map && b is Map) { + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); + } + return a == 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()) +; +} + +class NotificationTapEvent { + NotificationTapEvent({ + required this.payload, + }); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + Map payload; + + List _toList() { + return [ + payload, + ]; + } + + Object encode() { + return _toList(); } + + static NotificationTapEvent decode(Object result) { + result as List; + return NotificationTapEvent( + payload: (result[0] as Map?)!.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NotificationTapEvent || 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 _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is NotificationDataFromLaunch) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is NotificationTapEvent) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return NotificationDataFromLaunch.decode(readValue(buffer)!); + case 130: + return NotificationTapEvent.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec()); + +class NotificationHostApi { + /// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NotificationHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// 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 String pigeonVar_channelName = 'dev.flutter.pigeon.zulip.NotificationHostApi.getNotificationDataFromLaunch$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return (pigeonVar_replyList[0] as NotificationDataFromLaunch?); + } + } +} + +Stream notificationTapEvents( {String instanceName = ''}) { + if (instanceName.isNotEmpty) { + instanceName = '.$instanceName'; + } + final EventChannel notificationTapEventsChannel = + EventChannel('dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents$instanceName', pigeonMethodCodec); + return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) { + return event as NotificationTapEvent; + }); +} + diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 9c66346bec..3b22480d46 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:wakelock_plus/wakelock_plus.dart' as wakelock_plus; import '../host/android_notifications.dart'; +import '../host/notifications.dart' as notif_pigeon; import '../log.dart'; import '../widgets/store.dart'; import 'store.dart'; @@ -168,6 +169,9 @@ abstract class ZulipBinding { /// Wraps the [AndroidNotificationHostApi] constructor. AndroidNotificationHostApi get androidNotificationHost; + /// Wraps the [notif_pigeon.NotificationHostApi] class. + NotificationPigeonApi get notificationPigeonApi; + /// Pick files from the media library, via package:file_picker. /// /// This wraps [file_picker.pickFiles]. @@ -310,6 +314,19 @@ class PackageInfo { }); } +// Pigeon generates methods under `@EventChannelApi` annotated classes +// in global scope of the generated file. This is a helper class to +// namespace the notification related Pigeon API under a single class. +class NotificationPigeonApi { + final _hostApi = notif_pigeon.NotificationHostApi(); + + Future getNotificationDataFromLaunch() => + _hostApi.getNotificationDataFromLaunch(); + + Stream notificationTapEventsStream() => + notif_pigeon.notificationTapEvents(); +} + /// A concrete binding for use in the live application. /// /// The global store returned by [getGlobalStore], and consequently by @@ -442,6 +459,9 @@ class LiveZulipBinding extends ZulipBinding { @override AndroidNotificationHostApi get androidNotificationHost => AndroidNotificationHostApi(); + @override + NotificationPigeonApi get notificationPigeonApi => NotificationPigeonApi(); + @override Future pickFiles({ bool allowMultiple = false, diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 00a5f6fda5..081a2cf633 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -507,7 +507,7 @@ class NotificationDisplayManager { final zulipLocalizations = ZulipLocalizations.of(context); showErrorDialog(context: context, title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountMissing); + message: zulipLocalizations.errorNotificationOpenAccountNotFound); return null; } diff --git a/lib/notifications/open.dart b/lib/notifications/open.dart new file mode 100644 index 0000000000..1bf3503f9c --- /dev/null +++ b/lib/notifications/open.dart @@ -0,0 +1,229 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../host/notifications.dart'; +import '../log.dart'; +import '../model/binding.dart'; +import '../model/narrow.dart'; +import '../widgets/app.dart'; +import '../widgets/dialog.dart'; +import '../widgets/message_list.dart'; +import '../widgets/page.dart'; +import '../widgets/store.dart'; + +NotificationPigeonApi get _notifPigeonApi => ZulipBinding.instance.notificationPigeonApi; + +/// Service for handling notification navigation. +class NotificationOpenManager { + static NotificationOpenManager get instance => (_instance ??= NotificationOpenManager._()); + static NotificationOpenManager? _instance; + + NotificationOpenManager._(); + + /// Reset the state of the [NotificationOpenManager], for testing. + @visibleForTesting + static void debugReset() { + _instance = null; + } + + NotificationDataFromLaunch? _notifDataFromLaunch; + + /// A [Future] that completes to signal that the initialization of + /// [NotificationOpenManager] has completed or errored. + Future? get initializationFuture => _initializedSignal?.future; + + Completer? _initializedSignal; + + Future init() async { + assert(_initializedSignal == null); + _initializedSignal ??= Completer(); + try { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + _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: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + // Do nothing; we don't offer notifications on these platforms. + break; + } + } finally { + _initializedSignal!.complete(); + } + } + + /// Provides the route to open if the app was launched through a tap on + /// a notification. + /// + /// Returns null if app launch wasn't triggered by a notification, or if + /// an error occurs while determining the route for the notification, in + /// which case an error dialog is also shown. + /// + /// The context argument is used to look up [GlobalStoreWidget], + /// [ZulipLocalizations], the [Navigator] and [Theme], where the latter + /// three are used to show an error dialog if there is a failure. + AccountRoute? routeForNotificationFromLaunch({required BuildContext context}) { + final data = _notifDataFromLaunch; + if (data == null) return null; + assert(debugLog('opened notif: ${jsonEncode(data.payload)}')); + + final notifNavData = _tryParsePayload(context, data.payload); + if (notifNavData == null) return null; // TODO(log) + return _routeForNotification(context, notifNavData); + } + + /// Provides the route to open by parsing the notification payload. + /// + /// Returns null and shows an error dialog if the associated account is not + /// found in the global store. + AccountRoute? _routeForNotification(BuildContext context, NotificationNavigationData data) { + final globalStore = GlobalStoreWidget.of(context); + + final account = globalStore.accounts.firstWhereOrNull( + (account) => account.realmUrl.origin == data.realmUrl.origin + && account.userId == data.userId); + if (account == null) { // TODO(log) + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle, + message: zulipLocalizations.errorNotificationOpenAccountNotFound); + return null; + } + + return MessageListPage.buildRoute( + accountId: account.id, + // TODO(#82): Open at specific message, not just conversation + narrow: data.narrow); + } + + /// Navigates to the [MessageListPage] of the specific conversation + /// for the provided payload that was attached while creating the + /// notification. + Future _navigateForNotification(NotificationTapEvent event) async { + assert(debugLog('opened notif: ${jsonEncode(event.payload)}')); + + NavigatorState navigator = await ZulipApp.navigator; + final context = navigator.context; + assert(context.mounted); + if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that + + final notifNavData = _tryParsePayload(context, event.payload); + if (notifNavData == null) return; // TODO(log) + final route = _routeForNotification(context, notifNavData); + if (route == null) return; // TODO(log) + + // TODO(nav): Better interact with existing nav stack on notif open + unawaited(navigator.push(route)); + } + + NotificationNavigationData? _tryParsePayload( + BuildContext context, + Map payload, + ) { + try { + return NotificationNavigationData.fromIosApnsPayload(payload); + } on FormatException catch (e, st) { + assert(debugLog('$e\n$st')); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle); + return null; + } + } +} + +class NotificationNavigationData { + final Uri realmUrl; + final int userId; + final Narrow narrow; + + NotificationNavigationData({ + required this.realmUrl, + required this.userId, + required this.narrow, + }) : assert(narrow is TopicNarrow || narrow is DmNarrow); + + /// Parses the iOS APNs payload and retrieves the information + /// required for navigation. + factory NotificationNavigationData.fromIosApnsPayload(Map payload) { + if (payload case { + 'zulip': { + 'user_id': final int userId, + 'sender_id': final int senderId, + } && final zulipData, + }) { + final eventType = zulipData['event']; + if (eventType != null && eventType != 'message') { + // On Android, we also receive "remove" notification messages, tagged + // with an `event` field with value 'remove'. As of Zulip Server 10, + // however, these are not yet sent to iOS devices, and we don't have a + // way to handle them even if they were. + // + // The messages we currently do receive, and can handle, are analogous + // to Android notification messages of event type 'message'. On the + // assumption that some future version of the Zulip server will send + // explicit event types in APNs messages, accept messages with that + // `event` value, but no other. + throw const FormatException(); + } + + final String realmUrl; + switch (zulipData) { + case {'realm_url': final String value}: + realmUrl = value; + case {'realm_uri': final String value}: + realmUrl = value; + default: + throw const FormatException(); + } + + final Narrow narrow = switch (zulipData) { + { + 'recipient_type': 'stream', + // TODO(server-5) remove this comment. + // We require 'stream_id' here but that is new from Server 5.0, + // resulting in failure on pre-5.0 servers. + 'stream_id': final int streamId, + 'topic': final String topic, + } => + TopicNarrow(streamId, TopicName(topic)), + + {'recipient_type': 'private', 'pm_users': final String pmUsers} => + DmNarrow( + allRecipientIds: pmUsers + .split(',') + .map((e) => int.parse(e, radix: 10)) + .toList(growable: false)..sort(), + selfUserId: userId), + + {'recipient_type': 'private'} => + DmNarrow.withUser(senderId, selfUserId: userId), + + _ => throw const FormatException(), + }; + + return NotificationNavigationData( + realmUrl: Uri.parse(realmUrl), + userId: userId, + narrow: narrow); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } +} diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index 738bb98a2e..05fc8531c7 100644 --- a/lib/notifications/receive.dart +++ b/lib/notifications/receive.dart @@ -8,6 +8,7 @@ import '../firebase_options.dart'; import '../log.dart'; import '../model/binding.dart'; import 'display.dart'; +import 'open.dart'; @pragma('vm:entry-point') class NotificationService { @@ -77,6 +78,8 @@ class NotificationService { await _getFcmToken(); case TargetPlatform.iOS: // TODO(#324): defer requesting notif permission + await NotificationOpenManager.instance.init(); + await ZulipBinding.instance.firebaseInitializeApp( options: kFirebaseOptionsIos); diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 54ba92588b..e822406aa6 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -10,6 +10,7 @@ import '../model/actions.dart'; import '../model/localizations.dart'; import '../model/store.dart'; import '../notifications/display.dart'; +import '../notifications/open.dart'; import 'about_zulip.dart'; import 'dialog.dart'; import 'home.dart'; @@ -202,6 +203,31 @@ class _ZulipAppState extends State with WidgetsBindingObserver { ]; } + List> _handleGenerateInitialRoutesIos(_) { + // 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 = NotificationOpenManager.instance.routeForNotificationFromLaunch(context: context); + if (route != null) { + return [ + HomePage.buildRoute(accountId: route.accountId), + route, + ]; + } + + final globalStore = GlobalStoreWidget.of(context); + // TODO(#524) choose initial account as last one used + final initialAccountId = globalStore.accounts.firstOrNull?.id; + return [ + if (initialAccountId == null) + MaterialWidgetRoute(page: const ChooseAccountPage()) + else + HomePage.buildRoute(accountId: initialAccountId), + ]; + } + @override Future didPushRouteInformation(routeInformation) async { switch (routeInformation.uri) { @@ -218,6 +244,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { return GlobalStoreWidget( + blockingFuture: NotificationOpenManager.instance.initializationFuture, child: Builder(builder: (context) { return MaterialApp( onGenerateTitle: (BuildContext context) { @@ -252,8 +279,10 @@ class _ZulipAppState extends State with WidgetsBindingObserver { // handles startup, and then we always push whole routes with methods // like [Navigator.push], never mere names as with [Navigator.pushNamed]. onGenerateRoute: (_) => null, - - onGenerateInitialRoutes: _handleGenerateInitialRoutes); + // TODO migrate Android's handling to the new Pigeon API. + onGenerateInitialRoutes: defaultTargetPlatform == TargetPlatform.iOS + ? _handleGenerateInitialRoutesIos + : _handleGenerateInitialRoutes); })); } } diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 0d2b4c5a7d..112e928a05 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -49,10 +49,13 @@ class DialogStatus { /// /// The [DialogStatus.result] field of the return value can be used /// for waiting for the dialog to be closed. +/// +/// The context argument is used to look up [ZulipLocalizations], +/// the [Navigator] and [Theme] for the dialog. // This API is inspired by [ScaffoldManager.showSnackBar]. We wrap // [showDialog]'s return value, a [Future], inside [DialogStatus] // whose documentation can be accessed. This helps avoid confusion when -// intepreting the meaning of the [Future]. +// interpreting the meaning of the [Future]. DialogStatus showErrorDialog({ required BuildContext context, required String title, diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart index ab287b745a..f5fe4d3cc6 100644 --- a/lib/widgets/store.dart +++ b/lib/widgets/store.dart @@ -18,10 +18,18 @@ import 'page.dart'; class GlobalStoreWidget extends StatefulWidget { const GlobalStoreWidget({ super.key, + this.blockingFuture, this.placeholder = const LoadingPlaceholder(), required this.child, }); + /// An additional future to await before showing the child. + /// + /// If [blockingFuture] is non-null, then this widget will build [child] + /// only after the future completes. This widget's behavior is not affected + /// by whether the future's completion is with a value or with an error. + final Future? blockingFuture; + final Widget placeholder; final Widget child; @@ -87,6 +95,9 @@ class _GlobalStoreWidgetState extends State { super.initState(); (() async { final store = await ZulipBinding.instance.getGlobalStoreUniquely(); + if (widget.blockingFuture != null) { + await widget.blockingFuture!.catchError((_) {}); + } setState(() { this.store = store; }); diff --git a/pigeon/android_notifications.dart b/pigeon/android_notifications.dart new file mode 100644 index 0000000000..901369001c --- /dev/null +++ b/pigeon/android_notifications.dart @@ -0,0 +1,306 @@ +import 'package:pigeon/pigeon.dart'; + +// To rebuild this pigeon's output after editing this file, +// run `tools/check pigeon --fix`. +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/host/android_notifications.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt', + kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), +)) + +/// Corresponds to `androidx.core.app.NotificationChannelCompat` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat +class NotificationChannel { + /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder` + /// + /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder + NotificationChannel({ + required this.id, + required this.importance, + this.name, + this.lightsEnabled, + this.soundUrl, + this.vibrationPattern, + }); + + final String id; + + /// Specifies the importance level of notifications + /// to be posted on this channel. + /// + /// Must be a valid constant from [NotificationImportance]. + final int importance; + + final String? name; + final bool? lightsEnabled; + final String? soundUrl; + final Int64List? vibrationPattern; +} + +/// Corresponds to `android.content.Intent` +/// +/// See: +/// https://developer.android.com/reference/android/content/Intent +/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) +class AndroidIntent { + AndroidIntent({required this.action, required this.dataUrl, this.flags = 0}); + + final String action; + final String dataUrl; + + /// A combination of flags from [IntentFlag]. + final int flags; +} + +/// Corresponds to `android.app.PendingIntent`. +/// +/// See: https://developer.android.com/reference/android/app/PendingIntent +class PendingIntent { + /// Corresponds to `PendingIntent.getActivity`. + PendingIntent({required this.requestCode, required this.intent, required this.flags}); + + final int requestCode; + final AndroidIntent intent; + + /// A combination of flags from [PendingIntent.flags], and others associated + /// with `Intent`; see Android docs for `PendingIntent.getActivity`. + final int flags; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle +class InboxStyle { + InboxStyle({required this.summaryText}); + + final String summaryText; +} + +/// Corresponds to `androidx.core.app.Person` +/// +/// See: https://developer.android.com/reference/androidx/core/app/Person +class Person { + Person({ + required this.iconBitmap, + required this.key, + required this.name, + }); + + /// An icon for this person. + /// + /// This should be compressed image data, in a format to be passed + /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. + /// Supported formats include JPEG, PNG, and WEBP. + /// + /// See: + /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) + final Uint8List? iconBitmap; + + final String key; + final String name; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message +class MessagingStyleMessage { + MessagingStyleMessage({ + required this.text, + required this.timestampMs, + required this.person, + }); + + final String text; + final int timestampMs; + final Person person; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle +class MessagingStyle { + MessagingStyle({ + required this.user, + required this.conversationTitle, + required this.isGroupConversation, + required this.messages, + }); + + final Person user; + final String? conversationTitle; + final List messages; + final bool isGroupConversation; +} + +/// Corresponds to `android.app.Notification` +/// +/// See: https://developer.android.com/reference/kotlin/android/app/Notification +class Notification { + Notification({required this.group, required this.extras}); + + final String group; + final Map extras; + // Various other properties too; add them if needed. +} + +/// Corresponds to `android.service.notification.StatusBarNotification` +/// +/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification +class StatusBarNotification { + StatusBarNotification({required this.id, required this.tag, required this.notification}); + + final int id; + final String tag; + final Notification notification; + + // Ignore `groupKey` and `key`. While the `.groupKey` contains the + // `.notification.group`, and the `.key` contains the `.id` and `.tag`, + // they also have more stuff added on (and their structure doesn't seem to + // be documented.) + // final String? groupKey; + // final String? key; + + // Various other properties too; add them if needed. +} + +/// Represents details about a notification sound stored in the +/// shared media store. +/// +/// Returned as a list entry by +/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. +class StoredNotificationSound { + StoredNotificationSound({ + required this.fileName, + required this.isOwned, + required this.contentUrl, + }); + + /// The display name of the sound file. + final String fileName; + + /// Specifies whether this file was created by the app. + /// + /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the + /// metadata matches the app's package name. + final bool isOwned; + + /// A `content://…` URL pointing to the sound file. + final String contentUrl; +} + +@HostApi() +abstract class AndroidNotificationHostApi { + /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. + /// + /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) + void createNotificationChannel(NotificationChannel channel); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() + List getNotificationChannels(); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) + void deleteNotificationChannel(String channelId); + + /// The list of notification sound files present under `Notifications/Zulip/` + /// in the device's shared media storage, + /// found with `android.content.ContentResolver.query`. + /// + /// This is a complex ad-hoc method. + /// For detailed behavior, see its implementation. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) + List listStoredSoundsInNotificationsDirectory(); + + /// Wraps `android.content.ContentResolver.insert` combined with + /// `android.content.ContentResolver.openOutputStream` and + /// `android.content.res.Resources.openRawResource`. + /// + /// Copies a raw resource audio file to `Notifications/Zulip/` + /// directory in device's shared media storage. Returns the URL + /// of the target file in media store. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: + /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) + /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) + /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) + String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}); + + /// Corresponds to `android.app.NotificationManager.notify`, + /// combined with `androidx.core.app.NotificationCompat.Builder`. + /// + /// The arguments `tag` and `id` go to the `notify` call. + /// The rest go to method calls on the builder. + /// + /// The `color` should be in the form 0xAARRGGBB. + /// See [ColorExtension.argbInt]. + /// + /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` + /// to get a resource ID to pass to `Builder.setSmallIcon`. + /// Whatever name is passed there must appear in keep.xml too: + /// see https://github.com/zulip/zulip-flutter/issues/528 . + /// + /// See: + /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify + /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder + // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin. + // As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed. + // https://github.com/flutter/flutter/issues/134777 + void notify({ + String? tag, + required int id, + + // The remaining arguments go to method calls on NotificationCompat.Builder. + bool? autoCancel, + required String channelId, + int? color, + PendingIntent? contentIntent, + String? contentText, + String? contentTitle, + Map? extras, + String? groupKey, + InboxStyle? inboxStyle, + bool? isGroupSummary, + MessagingStyle? messagingStyle, + int? number, + String? smallIconResourceName, + // NotificationCompat.Builder has lots more methods; add as needed. + // Keep them alphabetized, for easy comparison with that class's docs. + }); + + /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, + /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. + /// + /// Returns the messaging style, if any, of an active notification + /// that has tag `tag`. If there are several such notifications, + /// an arbitrary one of them is used. + /// Returns null if there are no such notifications. + /// + /// See: + /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() + /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) + MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. + /// + /// The keys of entries to fetch from notification's extras bundle must be + /// specified in the [desiredExtras] list. If this list is empty, then + /// [Notifications.extras] will also be empty. If value of the matched entry + /// is not of type string or is null, then that entry will be skipped. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() + List getActiveNotifications({required List desiredExtras}); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) + void cancel({String? tag, required int id}); +} diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 708ae4efb5..66c1bd2e71 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -3,304 +3,51 @@ import 'package:pigeon/pigeon.dart'; // To rebuild this pigeon's output after editing this file, // run `tools/check pigeon --fix`. @ConfigurePigeon(PigeonOptions( - dartOut: 'lib/host/android_notifications.g.dart', - kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt', - kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), + dartOut: 'lib/host/notifications.g.dart', + swiftOut: 'ios/Runner/Notifications.g.swift', )) -/// Corresponds to `androidx.core.app.NotificationChannelCompat` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat -class NotificationChannel { - /// Corresponds to `androidx.core.app.NotificationChannelCompat.Builder` - /// - /// See: https://developer.android.com/reference/androidx/core/app/NotificationChannelCompat.Builder - NotificationChannel({ - required this.id, - required this.importance, - this.name, - this.lightsEnabled, - this.soundUrl, - this.vibrationPattern, - }); - - final String id; +class NotificationDataFromLaunch { + const NotificationDataFromLaunch({required this.payload}); - /// Specifies the importance level of notifications - /// to be posted on this channel. + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. /// - /// Must be a valid constant from [NotificationImportance]. - final int importance; - - final String? name; - final bool? lightsEnabled; - final String? soundUrl; - final Int64List? vibrationPattern; -} - -/// Corresponds to `android.content.Intent` -/// -/// See: -/// https://developer.android.com/reference/android/content/Intent -/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E) -class AndroidIntent { - AndroidIntent({required this.action, required this.dataUrl, this.flags = 0}); - - final String action; - final String dataUrl; - - /// A combination of flags from [IntentFlag]. - final int flags; -} - -/// Corresponds to `android.app.PendingIntent`. -/// -/// See: https://developer.android.com/reference/android/app/PendingIntent -class PendingIntent { - /// Corresponds to `PendingIntent.getActivity`. - PendingIntent({required this.requestCode, required this.intent, required this.flags}); - - final int requestCode; - final AndroidIntent intent; - - /// A combination of flags from [PendingIntent.flags], and others associated - /// with `Intent`; see Android docs for `PendingIntent.getActivity`. - final int flags; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.InboxStyle` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.InboxStyle -class InboxStyle { - InboxStyle({required this.summaryText}); - - final String summaryText; + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + final Map payload; } -/// Corresponds to `androidx.core.app.Person` -/// -/// See: https://developer.android.com/reference/androidx/core/app/Person -class Person { - Person({ - required this.iconBitmap, - required this.key, - required this.name, - }); +class NotificationTapEvent { + const NotificationTapEvent({required this.payload}); - /// An icon for this person. + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. /// - /// This should be compressed image data, in a format to be passed - /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. - /// Supported formats include JPEG, PNG, and WEBP. - /// - /// See: - /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) - final Uint8List? iconBitmap; - - final String key; - final String name; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message -class MessagingStyleMessage { - MessagingStyleMessage({ - required this.text, - required this.timestampMs, - required this.person, - }); - - final String text; - final int timestampMs; - final Person person; -} - -/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` -/// -/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle -class MessagingStyle { - MessagingStyle({ - required this.user, - required this.conversationTitle, - required this.isGroupConversation, - required this.messages, - }); - - final Person user; - final String? conversationTitle; - final List messages; - final bool isGroupConversation; -} - -/// Corresponds to `android.app.Notification` -/// -/// See: https://developer.android.com/reference/kotlin/android/app/Notification -class Notification { - Notification({required this.group, required this.extras}); - - final String group; - final Map extras; - // Various other properties too; add them if needed. -} - -/// Corresponds to `android.service.notification.StatusBarNotification` -/// -/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification -class StatusBarNotification { - StatusBarNotification({required this.id, required this.tag, required this.notification}); - - final int id; - final String tag; - final Notification notification; - - // Ignore `groupKey` and `key`. While the `.groupKey` contains the - // `.notification.group`, and the `.key` contains the `.id` and `.tag`, - // they also have more stuff added on (and their structure doesn't seem to - // be documented.) - // final String? groupKey; - // final String? key; - - // Various other properties too; add them if needed. -} - -/// Represents details about a notification sound stored in the -/// shared media store. -/// -/// Returned as a list entry by -/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. -class StoredNotificationSound { - StoredNotificationSound({ - required this.fileName, - required this.isOwned, - required this.contentUrl, - }); - - /// The display name of the sound file. - final String fileName; - - /// Specifies whether this file was created by the app. - /// - /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the - /// metadata matches the app's package name. - final bool isOwned; - - /// A `content://…` URL pointing to the sound file. - final String contentUrl; + /// See [notificationTapEvents]. + final Map payload; } @HostApi() -abstract class AndroidNotificationHostApi { - /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. - /// - /// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat) - void createNotificationChannel(NotificationChannel channel); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat() - List getNotificationChannels(); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel` - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) - void deleteNotificationChannel(String channelId); - - /// The list of notification sound files present under `Notifications/Zulip/` - /// in the device's shared media storage, - /// found with `android.content.ContentResolver.query`. - /// - /// This is a complex ad-hoc method. - /// For detailed behavior, see its implementation. - /// - /// Requires minimum of Android 10 (API 29) or higher. - /// - /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) - List listStoredSoundsInNotificationsDirectory(); - - /// Wraps `android.content.ContentResolver.insert` combined with - /// `android.content.ContentResolver.openOutputStream` and - /// `android.content.res.Resources.openRawResource`. - /// - /// Copies a raw resource audio file to `Notifications/Zulip/` - /// directory in device's shared media storage. Returns the URL - /// of the target file in media store. - /// - /// Requires minimum of Android 10 (API 29) or higher. - /// - /// See: - /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) - /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) - /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) - String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}); - - /// Corresponds to `android.app.NotificationManager.notify`, - /// combined with `androidx.core.app.NotificationCompat.Builder`. - /// - /// The arguments `tag` and `id` go to the `notify` call. - /// The rest go to method calls on the builder. - /// - /// The `color` should be in the form 0xAARRGGBB. - /// See [ColorExtension.argbInt]. - /// - /// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier` - /// to get a resource ID to pass to `Builder.setSmallIcon`. - /// Whatever name is passed there must appear in keep.xml too: - /// see https://github.com/zulip/zulip-flutter/issues/528 . - /// - /// See: - /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify - /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder - // TODO(pigeon): Try ProxyApi for Notification objects, once that exists for Kotlin. - // As of 2024-03, ProxyApi is actively being implemented; the Dart side just landed. - // https://github.com/flutter/flutter/issues/134777 - void notify({ - String? tag, - required int id, - - // The remaining arguments go to method calls on NotificationCompat.Builder. - bool? autoCancel, - required String channelId, - int? color, - PendingIntent? contentIntent, - String? contentText, - String? contentTitle, - Map? extras, - String? groupKey, - InboxStyle? inboxStyle, - bool? isGroupSummary, - MessagingStyle? messagingStyle, - int? number, - String? smallIconResourceName, - // NotificationCompat.Builder has lots more methods; add as needed. - // Keep them alphabetized, for easy comparison with that class's docs. - }); - - /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, - /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. - /// - /// Returns the messaging style, if any, of an active notification - /// that has tag `tag`. If there are several such notifications, - /// an arbitrary one of them is used. - /// Returns null if there are no such notifications. - /// - /// See: - /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() - /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) - MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. - /// - /// The keys of entries to fetch from notification's extras bundle must be - /// specified in the [desiredExtras] list. If this list is empty, then - /// [Notifications.extras] will also be empty. If value of the matched entry - /// is not of type string or is null, then that entry will be skipped. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() - List getActiveNotifications({required List desiredExtras}); - - /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. - /// - /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) - void cancel({String? tag, required int id}); +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(); +} + +@EventChannelApi() +abstract class NotificationEventChannelApi { + /// An event stream that emits a notification payload when the app + /// encounters a notification tap, while the app is running. + /// + /// 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. + NotificationTapEvent notificationTapEvents(); } diff --git a/test/model/binding.dart b/test/model/binding.dart index 73b4ed5513..ebec2d2232 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:zulip/host/android_notifications.dart'; +import 'package:zulip/host/notifications.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; @@ -305,14 +306,18 @@ class TestZulipBinding extends ZulipBinding { void _resetNotifications() { _androidNotificationHostApi = null; + _notificationPigeonApi = null; } + @override + FakeAndroidNotificationHostApi get androidNotificationHost => + (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); FakeAndroidNotificationHostApi? _androidNotificationHostApi; @override - FakeAndroidNotificationHostApi get androidNotificationHost { - return (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); - } + FakeNotificationPigeonApi get notificationPigeonApi => + (_notificationPigeonApi ??= FakeNotificationPigeonApi()); + FakeNotificationPigeonApi? _notificationPigeonApi; /// The value that `ZulipBinding.instance.pickFiles()` should return. /// @@ -750,6 +755,32 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { } } +class FakeNotificationPigeonApi implements NotificationPigeonApi { + NotificationDataFromLaunch? _notificationDataFromLaunch; + + /// Populates the notification data for launch to be returned + /// by [getNotificationDataFromLaunch]. + void setNotificationDataFromLaunch(NotificationDataFromLaunch? data) { + _notificationDataFromLaunch = data; + } + + @override + Future getNotificationDataFromLaunch() async => + _notificationDataFromLaunch; + + StreamController? _notificationTapEventsStreamController; + + void addNotificationTapEvent(NotificationTapEvent event) { + _notificationTapEventsStreamController!.add(event); + } + + @override + Stream notificationTapEventsStream() { + _notificationTapEventsStreamController ??= StreamController(); + return _notificationTapEventsStreamController!.stream; + } +} + typedef AndroidNotificationHostApiNotifyCall = ({ String? tag, int id, diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 1dfbb51273..67211ebabb 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -18,6 +18,7 @@ import 'package:zulip/api/route/realm.dart'; import 'package:zulip/log.dart'; import 'package:zulip/model/actions.dart'; import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; import '../api/fake_api.dart'; @@ -352,7 +353,7 @@ void main() { // TODO test database gets updated correctly (an integration test with sqlite?) }); - + test('GlobalStore.updateZulipVersionData', () async { final [currentZulipVersion, newZulipVersion ] = ['10.0-beta2-302-gf5b08b11f4', '10.0-beta2-351-g75ac8fe961']; @@ -1291,6 +1292,7 @@ void main() { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); await NotificationService.instance.start(); // On store startup, send the token. @@ -1318,6 +1320,7 @@ void main() { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); final startFuture = NotificationService.instance.start(); // TODO this test is a bit brittle in its interaction with asynchrony; diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index ccba7e24cc..99f1a88486 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -18,6 +18,7 @@ import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/display.dart'; +import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/color.dart'; @@ -121,6 +122,7 @@ void main() { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); NotificationService.debugBackgroundIsolateIsLive = false; await NotificationService.instance.start(); } @@ -1137,7 +1139,7 @@ void main() { check(pushedRoutes.single).isA>(); await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); }); testWidgets('mismatching account', (tester) async { @@ -1149,7 +1151,7 @@ void main() { check(pushedRoutes.single).isA>(); await tester.tap(find.byWidget(checkErrorDialog(tester, expectedTitle: zulipLocalizations.errorNotificationOpenTitle, - expectedMessage: zulipLocalizations.errorNotificationOpenAccountMissing))); + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); }); testWidgets('find account among several', (tester) async { diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart new file mode 100644 index 0000000000..f6ca094151 --- /dev/null +++ b/test/notifications/open_test.dart @@ -0,0 +1,276 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/host/notifications.dart'; +import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications/open.dart'; +import 'package:zulip/notifications/receive.dart'; +import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/home.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; + +import '../example_data.dart' as eg; +import '../model/binding.dart'; +import '../test_navigation.dart'; +import '../widgets/dialog_checks.dart'; +import '../widgets/message_list_checks.dart'; +import '../widgets/page_checks.dart'; + +Map messageApnsPayload( + Message zulipMessage, { + String? streamName, + Account? account, +}) { + account ??= eg.selfAccount; + return { + "aps": { + "alert": { + "title": "test", + "subtitle": "test", + "body": zulipMessage.content, + }, + "sound": "default", + "badge": 0, + }, + "zulip": { + "server": "zulip.example.cloud", + "realm_id": 4, + "realm_uri": account.realmUrl.toString(), + "realm_url": account.realmUrl.toString(), + "realm_name": "Test", + "user_id": account.userId, + "sender_id": zulipMessage.senderId, + "sender_email": zulipMessage.senderEmail, + "time": zulipMessage.timestamp, + "message_ids": [zulipMessage.id], + ...(switch (zulipMessage) { + StreamMessage(:var streamId, :var topic) => { + "recipient_type": "stream", + "stream_id": streamId, + if (streamName != null) "stream": streamName, + "topic": topic, + }, + DmMessage(allRecipientIds: [_, _, _, ...]) => { + "recipient_type": "private", + "pm_users": zulipMessage.allRecipientIds.join(","), + }, + DmMessage() => {"recipient_type": "private"}, + }), + }, + }; +} + +void main() { + TestZulipBinding.ensureInitialized(); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + Future init() async { + addTearDown(testBinding.reset); + testBinding.firebaseMessagingInitialToken = '012abc'; + addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); + NotificationService.debugBackgroundIsolateIsLive = false; + await NotificationService.instance.start(); + } + + group('NotificationOpenManager', () { + late List> pushedRoutes; + + void takeStartingRoutes({Account? account}) { + final expected = >[ + if (account != null) + (it) => it.isA() + ..accountId.equals(account.id) + ..page.isA() + else + (it) => it.isA().page.isA(), + ]; + check(pushedRoutes.take(expected.length)).deepEquals(expected); + pushedRoutes.removeRange(0, expected.length); + } + + Future prepare( + WidgetTester tester, { + bool dropStartingRoutes = true, + Account? account, + bool withAccount = true, + }) async { + if (withAccount) { + account ??= eg.selfAccount; + await testBinding.globalStore.add(account, eg.initialSnapshot()); + } + await init(); + pushedRoutes = []; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + // This uses [ZulipApp] instead of [TestZulipApp] because notification + // logic uses `await ZulipApp.navigator`. + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + if (!dropStartingRoutes) { + check(pushedRoutes).isEmpty(); + return; + } + await tester.pump(); + takeStartingRoutes(account: account); + check(pushedRoutes).isEmpty(); + } + + Future openNotification(WidgetTester tester, Account account, Message message) async { + final payload = messageApnsPayload(message, account: account); + testBinding.notificationPigeonApi.addNotificationTapEvent( + NotificationTapEvent(payload: payload)); + await tester.idle(); // let navigateForNotification find navigator + } + + void matchesNavigation(Subject> route, Account account, Message message) { + route.isA() + ..accountId.equals(account.id) + ..page.isA() + .initNarrow.equals(SendableNarrow.ofMessage(message, + selfUserId: account.userId)); + } + + Future checkOpenNotification(WidgetTester tester, Account account, Message message) async { + await openNotification(tester, account, message); + matchesNavigation(check(pushedRoutes).single, account, message); + pushedRoutes.clear(); + } + + testWidgets('(iOS) stream message', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + + testWidgets('(iOS) direct message', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester); + await checkOpenNotification(tester, eg.selfAccount, + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + + testWidgets('(iOS) account queried by realmUrl origin component', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester, + account: eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example'))); + + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example/')), + eg.streamMessage()); + await checkOpenNotification(tester, + eg.selfAccount.copyWith(realmUrl: Uri.parse('http://chat.example')), + eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + + testWidgets('(iOS) no accounts', (tester) async { + await prepare(tester, withAccount: false); + await openNotification(tester, eg.selfAccount, eg.streamMessage()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + + testWidgets('(iOS) mismatching account', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester); + await openNotification(tester, eg.otherAccount, eg.streamMessage()); + await tester.pump(); + check(pushedRoutes.single).isA>(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorNotificationOpenTitle, + expectedMessage: zulipLocalizations.errorNotificationOpenAccountNotFound))); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + + testWidgets('(iOS) find account among several', (tester) async { + addTearDown(testBinding.reset); + final realmUrlA = Uri.parse('https://a-chat.example/'); + final realmUrlB = Uri.parse('https://chat-b.example/'); + final user1 = eg.user(); + final user2 = eg.user(); + final accounts = [ + eg.account(id: 1001, realmUrl: realmUrlA, user: user1), + eg.account(id: 1002, realmUrl: realmUrlA, user: user2), + eg.account(id: 1003, realmUrl: realmUrlB, user: user1), + eg.account(id: 1004, realmUrl: realmUrlB, user: user2), + ]; + for (final account in accounts) { + await testBinding.globalStore.add(account, eg.initialSnapshot()); + } + + await prepare(tester, dropStartingRoutes: false, withAccount: false); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + await tester.pump(); + takeStartingRoutes(account: accounts[0]); + + await checkOpenNotification(tester, accounts[0], eg.streamMessage()); + await checkOpenNotification(tester, accounts[1], eg.streamMessage()); + await checkOpenNotification(tester, accounts[2], eg.streamMessage()); + await checkOpenNotification(tester, accounts[3], eg.streamMessage()); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + + testWidgets('(iOS) wait for app to become ready', (tester) async { + addTearDown(testBinding.reset); + await prepare(tester, dropStartingRoutes: false); + final message = eg.streamMessage(); + await openNotification(tester, eg.selfAccount, message); + // The app should still not be ready (or else this test won't work right). + check(ZulipApp.ready.value).isFalse(); + check(ZulipApp.navigatorKey.currentState).isNull(); + // And the openNotification hasn't caused any navigation yet. + check(pushedRoutes).isEmpty(); + + // Now let the GlobalStore get loaded and the app's main UI get mounted. + await tester.pump(); + // The navigator first pushes the starting routes… + takeStartingRoutes(account: eg.selfAccount); + // … and then the one the notification leads to. + matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + + testWidgets('(iOS) at app launch', (tester) async { + addTearDown(testBinding.reset); + // Set up a value for `PlatformDispatcher.defaultRouteName` to return, + // for determining the initial route. + final message = eg.streamMessage(); + final payload = messageApnsPayload(message, account: eg.selfAccount); + testBinding.notificationPigeonApi.setNotificationDataFromLaunch( + NotificationDataFromLaunch(payload: payload)); + + // Now start the app. + await prepare(tester, dropStartingRoutes: false); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + // Once the app is ready, we navigate to the conversation. + await tester.pump(); + takeStartingRoutes(account: eg.selfAccount); + matchesNavigation(check(pushedRoutes).single, eg.selfAccount, message); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + + testWidgets('(iOS) uses associated account as initial account; if initial route', (tester) async { + addTearDown(testBinding.reset); + + final accountA = eg.selfAccount; + final accountB = eg.otherAccount; + final message = eg.streamMessage(); + await testBinding.globalStore.add(accountA, eg.initialSnapshot()); + await testBinding.globalStore.add(accountB, eg.initialSnapshot()); + + final payload = messageApnsPayload(message, account: accountB); + testBinding.notificationPigeonApi.setNotificationDataFromLaunch( + NotificationDataFromLaunch(payload: payload)); + + await prepare(tester, dropStartingRoutes: false, withAccount: false); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + await tester.pump(); + takeStartingRoutes(account: accountB); + matchesNavigation(check(pushedRoutes).single, accountB, message); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); + }); +} diff --git a/test/notifications/receive_test.dart b/test/notifications/receive_test.dart index 3b7907b1c2..ce810f51d5 100644 --- a/test/notifications/receive_test.dart +++ b/test/notifications/receive_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/notifications/open.dart'; import 'package:zulip/notifications/receive.dart'; import '../model/binding.dart'; @@ -12,6 +13,7 @@ void main() { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); + addTearDown(NotificationOpenManager.debugReset); NotificationService.debugBackgroundIsolateIsLive = false; await NotificationService.instance.start(); } diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index f8da5e24a0..54490ede93 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -70,12 +70,12 @@ void main() { return const SizedBox.shrink(); }))); // First, shows a loading page instead of child. - check(tester.any(find.byType(CircularProgressIndicator))).isTrue(); + check(find.byType(CircularProgressIndicator)).findsOne(); check(globalStore).isNull(); await tester.pump(); // Then after loading, mounts child instead, with provided store. - check(tester.any(find.byType(CircularProgressIndicator))).isFalse(); + check(find.byType(CircularProgressIndicator)).findsNothing(); check(globalStore).identicalTo(testBinding.globalStore); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); @@ -84,6 +84,56 @@ void main() { .equals((accountId: eg.selfAccount.id, account: eg.selfAccount)); }); + testWidgets('GlobalStoreWidget awaits blockingFuture', (tester) async { + addTearDown(testBinding.reset); + + final completer = Completer(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + blockingFuture: completer.future, + child: Text('done')))); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + // Even after the store must have loaded, + // still shows loading page while blockingFuture is pending. + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes… + completer.complete(); + await tester.pump(); + // … mounts child instead of the loading page. + check(find.byType(CircularProgressIndicator)).findsNothing(); + check(find.text('done')).findsOne(); + }); + + testWidgets('GlobalStoreWidget handles failed blockingFuture like success', (tester) async { + addTearDown(testBinding.reset); + + final completer = Completer(); + await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + blockingFuture: completer.future, + child: Text('done')))); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + // Even after the store must have loaded, + // still shows loading page while blockingFuture is pending. + check(find.byType(CircularProgressIndicator)).findsOne(); + check(find.text('done')).findsNothing(); + + // Once blockingFuture completes, even with an error… + completer.completeError(Exception('oops')); + await tester.pump(); + // … mounts child instead of the loading page. + check(find.byType(CircularProgressIndicator)).findsNothing(); + check(find.text('done')).findsOne(); + }); + testWidgets('GlobalStoreWidget.of updates dependents', (tester) async { addTearDown(testBinding.reset);