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 d11bf43eda..833a9f7f79 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -911,9 +911,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..d54bb20491 --- /dev/null +++ b/docs/howto/push-notifications-ios-simulator.md @@ -0,0 +1,300 @@ +# 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 +the Apple Push Notification service to show notifications on iOS +Simulator. + + +### Contents + +* [Trigger a notification on the iOS Simulator](#trigger-notification) +* [Canned APNs payloads](#canned-payloads) +* [Produce sample APNs payloads](#produce-payload) + + +
+ +## Trigger a notification on the iOS Simulator + +The iOS Simulator permits delivering a notification payload +artificially, as if APNs had delivered it to the device, +but without actually involving APNs or any other server. + +As input for this operation, you'll need an APNs payload, +i.e. a JSON blob representing what APNs might deliver to the app +for a notification. + +To get an APNs payload, you can generate one from a Zulip dev server +by following the [instructions in a section below](#produce-payload), +or you can use one of the payloads included +in this document [below](#canned-payloads). + + +### 1. Determine the device ID of the iOS Simulator + +To receive a notification on the iOS Simulator, we need to first +determine the device ID of the iOS Simulator, to specify which +Simulator instance we want to push the payload to. + +```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) +``` + +
+ + +### 2. Trigger a notification by pushing the payload to the iOS Simulator + +By running the following command with a valid APNs payload, you should +receive a notification on the iOS Simulator for the zulip-flutter app. +Tapping on the notification should route to the respective conversation. + +```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' +``` + +
+ + +
+ +## Canned APNs payloads + +The following pre-canned APNs payloads can be used in case you don't +have one. + +These canned payloads were generated from +Zulip Server 11.0-dev+git 8fd04b0f0, API Feature Level 377, +in April 2025. +The `user_id` is that of `iago@zulip.com` in the Zulip dev environment. + +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 + ] + } +} +``` + +
+ + +
+ +## Produce sample APNs payloads + +### 1. Set up dev server + +To set up and run the dev server on the same Mac machine that hosts +the iOS Simulator, follow Zulip's +[standard instructions](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html) +for setting up a dev server. + +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. Set up 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. + +Then follow the steps [here](https://zulip.com/help/mobile-notifications) +to enable Mobile Notifications for "Channels". + + +### 3. Log in as the dev user on zulip-flutter. + + + +To log in as 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 log in as the dev user +(`iago@zulip.com`). + + +### 4. 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 payload 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).decode()) + + android_devices = list( + PushDeviceToken.objects.filter(user=user_profile, kind=PushDeviceToken.FCM).order_by("id") +``` + + +### 5. 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". + + +### 6. Transform and save the payload to a file + +The payload JSON recorded in the steps above is in the form the +Zulip server sends to the bouncer. The bouncer restructures this +slightly to produce the actual payload which it sends to APNs, +and which APNs delivers to the app on the device. +To apply the same restructuring, run the payload through +the following `jq` command: + +```shell-session +$ echo '{"alert":{"title": ...' \ + | jq '{aps: {alert, sound, badge}, zulip: .custom.zulip}' \ + > payload.json +``` 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..eefed07cd6 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!) + + 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 userInfo = response.notification.request.content.userInfo + notificationTapEventListener!.onNotificationTapEvent(payload: userInfo) + } + completionHandler() + } +} + +private class NotificationHostApiImpl: NotificationHostApi { + private let maybeDataFromLaunch: NotificationDataFromLaunch? + + init(_ maybeDataFromLaunch: NotificationDataFromLaunch?) { + self.maybeDataFromLaunch = maybeDataFromLaunch + } + + func getNotificationDataFromLaunch() -> NotificationDataFromLaunch? { + maybeDataFromLaunch + } +} + +// Adapted from Pigeon's Swift example for @EventChannelApi: +// https://github.com/flutter/packages/blob/2dff6213a/packages/pigeon/example/app/ios/Runner/AppDelegate.swift#L49-L74 +class NotificationTapEventListener: NotificationTapEventsStreamHandler { + var eventSink: PigeonEventSink? + + override func onListen(withArguments arguments: Any?, sink: PigeonEventSink) { + eventSink = sink + } + + func onNotificationTapEvent(payload: [AnyHashable : Any]) { + eventSink?.success(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 75b70a0377..64fa12e3d2 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1340,11 +1340,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 98dd9a7af6..939b4b6994 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -741,8 +741,8 @@ 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 0a746ff634..0c0b86183d 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -741,8 +741,8 @@ 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 74a2d4bedb..0e0de2c6af 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -741,8 +741,8 @@ 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 02913278b8..35960caba6 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -741,8 +741,8 @@ 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 657e1757d1..428a10072b 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -751,8 +751,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations { '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 5d8899290d..d0cbe4e61e 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -755,8 +755,8 @@ 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 3ff534eca5..631dd38a52 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -743,8 +743,8 @@ 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 a756bdba6a..0e83d6aa96 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -755,8 +755,8 @@ 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 fb3add46da..4d1a0adaac 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'; @@ -180,6 +181,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]. @@ -324,6 +328,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 @@ -469,6 +486,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..02d7609678 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -8,7 +8,6 @@ import 'package:http/http.dart' as http; import '../api/model/model.dart'; import '../api/notifications.dart'; -import '../generated/l10n/zulip_localizations.dart'; import '../host/android_notifications.dart'; import '../log.dart'; import '../model/binding.dart'; @@ -16,11 +15,9 @@ import '../model/localizations.dart'; import '../model/narrow.dart'; import '../widgets/app.dart'; import '../widgets/color.dart'; -import '../widgets/dialog.dart'; import '../widgets/message_list.dart'; -import '../widgets/page.dart'; -import '../widgets/store.dart'; import '../widgets/theme.dart'; +import 'navigate.dart'; AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost; @@ -294,7 +291,7 @@ class NotificationDisplayManager { name: data.senderFullName, iconBitmap: await _fetchBitmap(data.senderAvatarUrl)))); - final intentDataUrl = NotificationOpenPayload( + final intentDataUrl = NotificationNavigationData( realmUrl: data.realmUrl, userId: data.userId, narrow: switch (data.recipient) { @@ -302,7 +299,7 @@ class NotificationDisplayManager { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); await _androidHost.notify( id: kNotificationId, @@ -481,42 +478,6 @@ class NotificationDisplayManager { static String _personKey(Uri realmUrl, int userId) => "$realmUrl|$userId"; - /// Provides the route and the account ID by parsing the notification URL. - /// - /// The URL must have been generated using [NotificationOpenPayload.buildUrl] - /// while creating the notification. - /// - /// Returns null and shows an error dialog if the associated account is not - /// found in the global store. - static AccountRoute? routeForNotification({ - required BuildContext context, - required Uri url, - }) { - assert(defaultTargetPlatform == TargetPlatform.android); - - final globalStore = GlobalStoreWidget.of(context); - - assert(debugLog('got notif: url: $url')); - assert(url.scheme == 'zulip' && url.host == 'notification'); - final payload = NotificationOpenPayload.parseUrl(url); - - final account = globalStore.accounts.firstWhereOrNull( - (account) => account.realmUrl.origin == payload.realmUrl.origin - && account.userId == payload.userId); - if (account == null) { // TODO(log) - final zulipLocalizations = ZulipLocalizations.of(context); - showErrorDialog(context: context, - title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountMissing); - return null; - } - - return MessageListPage.buildRoute( - accountId: account.id, - // TODO(#82): Open at specific message, not just conversation - narrow: payload.narrow); - } - /// Navigates to the [MessageListPage] of the specific conversation /// given the `zulip://notification/…` Android intent data URL, /// generated with [NotificationOpenPayload.buildUrl] while creating @@ -530,7 +491,12 @@ class NotificationDisplayManager { assert(context.mounted); if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that - final route = routeForNotification(context: context, url: url); + assert(url.scheme == 'zulip' && url.host == 'notification'); + final payload = + NotificationNavigationService.tryParseAndroidNotificationUrl(context, url); + if (payload == null) return; // TODO(log) + + final route = NotificationNavigationService.routeForNotification(context, payload); if (route == null) return; // TODO(log) // TODO(nav): Better interact with existing nav stack on notif open @@ -550,86 +516,3 @@ class NotificationDisplayManager { return null; } } - -/// The information contained in 'zulip://notification/…' internal -/// Android intent data URL, used for notification-open flow. -class NotificationOpenPayload { - final Uri realmUrl; - final int userId; - final Narrow narrow; - - NotificationOpenPayload({ - required this.realmUrl, - required this.userId, - required this.narrow, - }); - - factory NotificationOpenPayload.parseUrl(Uri url) { - if (url case Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': var realmUrlStr, - 'user_id': var userIdStr, - 'narrow_type': var narrowType, - // In case of narrowType == 'topic': - // 'channel_id' and 'topic' handled below. - - // In case of narrowType == 'dm': - // 'all_recipient_ids' handled below. - }, - )) { - final realmUrl = Uri.parse(realmUrlStr); - final userId = int.parse(userIdStr, radix: 10); - - final Narrow narrow; - switch (narrowType) { - case 'topic': - final channelIdStr = url.queryParameters['channel_id']!; - final channelId = int.parse(channelIdStr, radix: 10); - final topicStr = url.queryParameters['topic']!; - narrow = TopicNarrow(channelId, TopicName(topicStr)); - case 'dm': - final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; - final allRecipientIds = allRecipientIdsStr.split(',') - .map((idStr) => int.parse(idStr, radix: 10)) - .toList(growable: false); - narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId); - default: - throw const FormatException(); - } - - return NotificationOpenPayload( - realmUrl: realmUrl, - userId: userId, - narrow: narrow, - ); - } else { - // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 - throw const FormatException(); - } - } - - Uri buildUrl() { - return Uri( - scheme: 'zulip', - host: 'notification', - queryParameters: { - 'realm_url': realmUrl.toString(), - 'user_id': userId.toString(), - ...(switch (narrow) { - TopicNarrow(streamId: var channelId, :var topic) => { - 'narrow_type': 'topic', - 'channel_id': channelId.toString(), - 'topic': topic.apiName, - }, - DmNarrow(:var allRecipientIds) => { - 'narrow_type': 'dm', - 'all_recipient_ids': allRecipientIds.join(','), - }, - _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'), - }) - }, - ); - } -} diff --git a/lib/notifications/navigate.dart b/lib/notifications/navigate.dart new file mode 100644 index 0000000000..d4a554ceb4 --- /dev/null +++ b/lib/notifications/navigate.dart @@ -0,0 +1,320 @@ +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 NotificationNavigationService { + static NotificationNavigationService get instance => (_instance ??= NotificationNavigationService._()); + static NotificationNavigationService? _instance; + + NotificationNavigationService._(); + + /// Reset the state of the [NotificationNavigationService], for testing. + @visibleForTesting + static void debugReset() { + _instance = null; + } + + NotificationDataFromLaunch? _notifDataFromLaunch; + + /// A [Future] that completes to signal that the initialization of + /// [NotificationNavigationService] has completed or errored. + /// + /// Returns null if [start] wasn't called yet. + Future? get initializationFuture => _initializedSignal?.future; + + Completer? _initializedSignal; + + Future start() 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 the latter case an error dialog is also shown. + /// + /// The context argument is used to look up the [Navigator], which is used + /// to show an error dialog if there is a failure. + AccountRoute? routeForNotificationFromLaunch({required BuildContext context}) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final data = _notifDataFromLaunch; + if (data == null) return null; + assert(debugLog('opened notif: ${jsonEncode(data.payload)}')); + + final notifNavData = _tryParseIosApnsPayload(context, data.payload); + if (notifNavData == null) return null; // TODO(log) + + return routeForNotification(context, 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. + static 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 = _tryParseIosApnsPayload(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? _tryParseIosApnsPayload( + 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; + } + } + + static NotificationNavigationData? tryParseAndroidNotificationUrl( + BuildContext context, + Uri url, + ) { + try { + return NotificationNavigationData.parseAndroidNotificationUrl(url); + } on FormatException catch (e, st) { + assert(debugLog('$e\n$st')); + final zulipLocalizations = ZulipLocalizations.of(context); + showErrorDialog(context: context, + title: zulipLocalizations.errorNotificationOpenTitle); + return null; + } + } +} + +/// The data from a notification that describes what to do +/// when the user opens the notification. +class NotificationNavigationData { + final Uri realmUrl; + final int userId; + final SendableNarrow narrow; + + NotificationNavigationData({ + required this.realmUrl, + required this.userId, + required this.narrow, + }); + + /// 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 realmUrl = switch (zulipData) { + {'realm_url': final String value} => value, + {'realm_uri': final String value} => value, + _ => throw const FormatException(), + }; + + final narrow = switch (zulipData) { + { + 'recipient_type': 'stream', + // TODO(server-5) remove this comment. + // We require 'stream_id' here but that is new from Server 5.0, + // resulting in failure on pre-5.0 servers. + 'stream_id': final int streamId, + 'topic': final String topic, + } => + TopicNarrow(streamId, TopicName(topic)), + + {'recipient_type': 'private', 'pm_users': final String pmUsers} => + DmNarrow( + allRecipientIds: pmUsers + .split(',') + .map((e) => int.parse(e, radix: 10)) + .toList(growable: false) + ..sort(), + selfUserId: userId), + + {'recipient_type': 'private'} => + DmNarrow.withUser(senderId, selfUserId: userId), + + _ => throw const FormatException(), + }; + + return NotificationNavigationData( + realmUrl: Uri.parse(realmUrl), + userId: userId, + narrow: narrow); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + /// Parses the internal Android notification url, that was created using + /// [buildAndroidNotificationUrl], and retrieves the information required + /// for navigation. + factory NotificationNavigationData.parseAndroidNotificationUrl(Uri url) { + if (url case Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': var realmUrlStr, + 'user_id': var userIdStr, + 'narrow_type': var narrowType, + // In case of narrowType == 'topic': + // 'channel_id' and 'topic' handled below. + + // In case of narrowType == 'dm': + // 'all_recipient_ids' handled below. + }, + )) { + final realmUrl = Uri.parse(realmUrlStr); + final userId = int.parse(userIdStr, radix: 10); + + final SendableNarrow narrow; + switch (narrowType) { + case 'topic': + final channelIdStr = url.queryParameters['channel_id']!; + final channelId = int.parse(channelIdStr, radix: 10); + final topicStr = url.queryParameters['topic']!; + narrow = TopicNarrow(channelId, TopicName(topicStr)); + case 'dm': + final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!; + final allRecipientIds = allRecipientIdsStr.split(',') + .map((idStr) => int.parse(idStr, radix: 10)) + .toList(growable: false); + narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId); + default: + throw const FormatException(); + } + + return NotificationNavigationData( + realmUrl: realmUrl, + userId: userId, + narrow: narrow, + ); + } else { + // TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537 + throw const FormatException(); + } + } + + Uri buildAndroidNotificationUrl() { + return Uri( + scheme: 'zulip', + host: 'notification', + queryParameters: { + 'realm_url': realmUrl.toString(), + 'user_id': userId.toString(), + ...(switch (narrow) { + TopicNarrow(streamId: var channelId, :var topic) => { + 'narrow_type': 'topic', + 'channel_id': channelId.toString(), + 'topic': topic.apiName, + }, + DmNarrow(:var allRecipientIds) => { + 'narrow_type': 'dm', + 'all_recipient_ids': allRecipientIds.join(','), + }, + }) + }, + ); + } +} diff --git a/lib/notifications/receive.dart b/lib/notifications/receive.dart index d60469ff30..a7ab54fe94 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 'navigate.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 NotificationNavigationService.instance.start(); + await ZulipBinding.instance.firebaseInitializeApp( options: kFirebaseOptionsIos); diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 54ba92588b..fccd363cc1 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/navigate.dart'; import 'about_zulip.dart'; import 'dialog.dart'; import 'home.dart'; @@ -168,27 +169,45 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } + AccountRoute? _initialRouteIos(BuildContext context) { + return NotificationNavigationService.instance + .routeForNotificationFromLaunch(context: context); + } + + // TODO migrate Android's notification navigation to use the new Pigeon API. + AccountRoute? _initialRouteAndroid( + BuildContext context, + String initialRoute, + ) { + final initialRouteUrl = Uri.tryParse(initialRoute); + if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { + final data = + NotificationNavigationService.tryParseAndroidNotificationUrl(context, initialRouteUrl); + if (data == null) return null; + + return NotificationNavigationService.routeForNotification(context, data); + } + + return null; + } + List> _handleGenerateInitialRoutes(String initialRoute) { // The `_ZulipAppState.context` lacks the required ancestors. Instead // we use the Navigator which should be available when this callback is - // called and it's context should have the required ancestors. + // called and its context should have the required ancestors. final context = ZulipApp.navigatorKey.currentContext!; - final initialRouteUrl = Uri.tryParse(initialRoute); - if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - final route = NotificationDisplayManager.routeForNotification( - context: context, - url: initialRouteUrl); - - if (route != null) { - return [ - HomePage.buildRoute(accountId: route.accountId), - route, - ]; - } else { - // The account didn't match any existing accounts, - // fall through to show the default route below. - } + final route = defaultTargetPlatform == TargetPlatform.iOS + ? _initialRouteIos(context) + : _initialRouteAndroid(context, initialRoute); + if (route != null) { + return [ + HomePage.buildRoute(accountId: route.accountId), + route, + ]; + } else { + // The account didn't match any existing accounts, + // fall through to show the default route below. } final globalStore = GlobalStoreWidget.of(context); @@ -218,6 +237,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { return GlobalStoreWidget( + blockingFuture: NotificationNavigationService.instance.initializationFuture, child: Builder(builder: (context) { return MaterialApp( onGenerateTitle: (BuildContext context) { @@ -252,7 +272,6 @@ 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); })); } diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 08ce8f08c7..4d269cddba 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -52,10 +52,12 @@ class DialogStatus { /// /// Prose in [message] should have final punctuation: /// https://github.com/zulip/zulip-flutter/pull/1498#issuecomment-2853578577 +/// +/// The context argument should be a descendant of the app's main [Navigator]. // 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, @@ -86,6 +88,8 @@ DialogStatus showErrorDialog({ /// If the dialog was canceled, /// either with the cancel button or by tapping outside the dialog's area, /// it completes with null. +/// +/// The context argument should be a descendant of the app's main [Navigator]. DialogStatus showSuggestedActionDialog({ 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 2c70b68826..839242c1ce 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'; @@ -311,14 +312,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. /// @@ -756,6 +761,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 ef0a7a72be..a3b07999c3 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/navigate.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']; @@ -1290,8 +1291,9 @@ void main() { // (This is probably the common case.) addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; - addTearDown(NotificationService.debugReset); testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); + addTearDown(NotificationService.debugReset); + addTearDown(NotificationNavigationService.debugReset); await NotificationService.instance.start(); // On store startup, send the token. @@ -1318,8 +1320,9 @@ void main() { // request for the token is still pending. addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; - addTearDown(NotificationService.debugReset); testBinding.packageInfoResult = eg.packageInfo(packageName: 'com.zulip.flutter'); + addTearDown(NotificationService.debugReset); + addTearDown(NotificationNavigationService.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..1e8a77cb58 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/navigate.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(NotificationNavigationService.debugReset); NotificationService.debugBackgroundIsolateIsLive = false; await NotificationService.instance.start(); } @@ -346,7 +348,7 @@ void main() { const expectedPendingIntentFlags = PendingIntentFlag.immutable; const expectedIntentFlags = IntentFlag.activityClearTop | IntentFlag.activityNewTask; final expectedSelfUserKey = '${data.realmUrl}|${data.userId}'; - final expectedIntentDataUrl = NotificationOpenPayload( + final expectedIntentDataUrl = NotificationNavigationData( realmUrl: data.realmUrl, userId: data.userId, narrow: switch (data.recipient) { @@ -354,7 +356,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); final messageStyleMessagesChecks = messageStyleMessages.mapIndexed((i, messageData) { @@ -1072,7 +1074,7 @@ void main() { Future openNotification(WidgetTester tester, Account account, Message message) async { final data = messageFcmMessage(message, account: account); - final intentDataUrl = NotificationOpenPayload( + final intentDataUrl = NotificationNavigationData( realmUrl: data.realmUrl, userId: data.userId, narrow: switch (data.recipient) { @@ -1080,7 +1082,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); unawaited( WidgetsBinding.instance.handlePushRoute(intentDataUrl.toString())); await tester.idle(); // let navigateForNotification find navigator @@ -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 { @@ -1198,11 +1200,11 @@ void main() { testWidgets('at app launch', (tester) async { addTearDown(testBinding.reset); // Set up a value for `PlatformDispatcher.defaultRouteName` to return, - // for determining the intial route. + // for determining the initial route. final account = eg.selfAccount; final message = eg.streamMessage(); final data = messageFcmMessage(message, account: account); - final intentDataUrl = NotificationOpenPayload( + final intentDataUrl = NotificationNavigationData( realmUrl: data.realmUrl, userId: data.userId, narrow: switch (data.recipient) { @@ -1210,7 +1212,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); @@ -1235,7 +1237,7 @@ void main() { await testBinding.globalStore.add(accountA, eg.initialSnapshot()); await testBinding.globalStore.add(accountB, eg.initialSnapshot()); - final intentDataUrl = NotificationOpenPayload( + final intentDataUrl = NotificationNavigationData( realmUrl: data.realmUrl, userId: data.userId, narrow: switch (data.recipient) { @@ -1243,7 +1245,7 @@ void main() { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); @@ -1256,39 +1258,39 @@ void main() { }); }); - group('NotificationOpenPayload', () { + group('NotificationNavigationData', () { test('smoke round-trip', () { // DM narrow - var payload = NotificationOpenPayload( + var payload = NotificationNavigationData( realmUrl: Uri.parse('http://chat.example'), userId: 1001, narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), ); - var url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) + var url = payload.buildAndroidNotificationUrl(); + check(NotificationNavigationData.parseAndroidNotificationUrl(url)) ..realmUrl.equals(payload.realmUrl) ..userId.equals(payload.userId) ..narrow.equals(payload.narrow); // Topic narrow - payload = NotificationOpenPayload( + payload = NotificationNavigationData( realmUrl: Uri.parse('http://chat.example'), userId: 1001, narrow: eg.topicNarrow(1, 'topic A'), ); - url = payload.buildUrl(); - check(NotificationOpenPayload.parseUrl(url)) + url = payload.buildAndroidNotificationUrl(); + check(NotificationNavigationData.parseAndroidNotificationUrl(url)) ..realmUrl.equals(payload.realmUrl) ..userId.equals(payload.userId) ..narrow.equals(payload.narrow); }); test('buildUrl: smoke DM', () { - final url = NotificationOpenPayload( + final url = NotificationNavigationData( realmUrl: Uri.parse('http://chat.example'), userId: 1001, narrow: DmNarrow(allRecipientIds: [1001, 1002], selfUserId: 1001), - ).buildUrl(); + ).buildAndroidNotificationUrl(); check(url) ..scheme.equals('zulip') ..host.equals('notification') @@ -1301,11 +1303,11 @@ void main() { }); test('buildUrl: smoke topic', () { - final url = NotificationOpenPayload( + final url = NotificationNavigationData( realmUrl: Uri.parse('http://chat.example'), userId: 1001, narrow: eg.topicNarrow(1, 'topic A'), - ).buildUrl(); + ).buildAndroidNotificationUrl(); check(url) ..scheme.equals('zulip') ..host.equals('notification') @@ -1328,7 +1330,7 @@ void main() { 'narrow_type': 'dm', 'all_recipient_ids': '1001,1002', }); - check(NotificationOpenPayload.parseUrl(url)) + check(NotificationNavigationData.parseAndroidNotificationUrl(url)) ..realmUrl.equals(Uri.parse('http://chat.example')) ..userId.equals(1001) ..narrow.which((it) => it.isA() @@ -1347,7 +1349,7 @@ void main() { 'channel_id': '1', 'topic': 'topic A', }); - check(NotificationOpenPayload.parseUrl(url)) + check(NotificationNavigationData.parseAndroidNotificationUrl(url)) ..realmUrl.equals(Uri.parse('http://chat.example')) ..userId.equals(1001) ..narrow.which((it) => it.isA() @@ -1406,7 +1408,7 @@ void main() { }, ]; for (final params in testCases) { - check(() => NotificationOpenPayload.parseUrl(Uri( + check(() => NotificationNavigationData.parseAndroidNotificationUrl(Uri( scheme: 'zulip', host: 'notification', queryParameters: params, @@ -1432,7 +1434,7 @@ void main() { 'channel_id': '1', 'topic': 'topic A', }); - check(() => NotificationOpenPayload.parseUrl(url)) + check(() => NotificationNavigationData.parseAndroidNotificationUrl(url)) .throws(); }); @@ -1447,7 +1449,7 @@ void main() { 'channel_id': '1', 'topic': 'topic A', }); - check(() => NotificationOpenPayload.parseUrl(url)) + check(() => NotificationNavigationData.parseAndroidNotificationUrl(url)) .throws(); }); }); @@ -1531,7 +1533,7 @@ extension on Subject { Subject get tag => has((x) => x.tag, 'tag'); } -extension on Subject { +extension on Subject { Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); Subject get userId => has((x) => x.userId, 'userId'); Subject get narrow => has((x) => x.narrow, 'narrow'); diff --git a/test/notifications/navigate_test.dart b/test/notifications/navigate_test.dart new file mode 100644 index 0000000000..084c0018db --- /dev/null +++ b/test/notifications/navigate_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/navigate.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(NotificationNavigationService.debugReset); + NotificationService.debugBackgroundIsolateIsLive = false; + await NotificationService.instance.start(); + } + + group('NotificationNavigationService', () { + 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..f064e4aaff 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/navigate.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(NotificationNavigationService.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);