From 029d4a534c48f69e6adc83f34592d00cd1ea2bfd Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 28 Feb 2025 20:10:58 +0530 Subject: [PATCH 01/11] pigeon [nfc]: Rename `notifications.dart` to `android_notifications.dart` This makes it clear that these bindings are for Android only. --- ...cations.g.kt => AndroidNotifications.g.kt} | 44 +++++++++---------- ...ations.dart => android_notifications.dart} | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) rename android/app/src/main/kotlin/com/zulip/flutter/{Notifications.g.kt => AndroidNotifications.g.kt} (94%) rename pigeon/{notifications.dart => android_notifications.dart} (99%) 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/pigeon/notifications.dart b/pigeon/android_notifications.dart similarity index 99% rename from pigeon/notifications.dart rename to pigeon/android_notifications.dart index 708ae4efb5..901369001c 100644 --- a/pigeon/notifications.dart +++ b/pigeon/android_notifications.dart @@ -4,7 +4,7 @@ import 'package:pigeon/pigeon.dart'; // 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', + kotlinOut: 'android/app/src/main/kotlin/com/zulip/flutter/AndroidNotifications.g.kt', kotlinOptions: KotlinOptions(package: 'com.zulip.flutter'), )) From e59e47a947b24386110ab595fcd197c83e9cab3b Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 27 Mar 2025 20:22:38 +0530 Subject: [PATCH 02/11] dialog [nfc]: Document required ancestors for BuildContext And fix a typo. --- lib/widgets/dialog.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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, From 8b1d40918638305ca8802e8525a8ade0e13c1960 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 27 Mar 2025 20:39:21 +0530 Subject: [PATCH 03/11] notif: Fix error message when account not found in store --- assets/l10n/app_en.arb | 6 +++--- assets/l10n/app_pl.arb | 4 ---- assets/l10n/app_ru.arb | 4 ---- lib/generated/l10n/zulip_localizations.dart | 6 +++--- lib/generated/l10n/zulip_localizations_ar.dart | 4 ++-- lib/generated/l10n/zulip_localizations_en.dart | 4 ++-- lib/generated/l10n/zulip_localizations_ja.dart | 4 ++-- lib/generated/l10n/zulip_localizations_nb.dart | 4 ++-- lib/generated/l10n/zulip_localizations_pl.dart | 4 ++-- lib/generated/l10n/zulip_localizations_ru.dart | 4 ++-- lib/generated/l10n/zulip_localizations_sk.dart | 4 ++-- lib/generated/l10n/zulip_localizations_uk.dart | 4 ++-- lib/notifications/display.dart | 2 +- test/notifications/display_test.dart | 4 ++-- 14 files changed, 25 insertions(+), 33 deletions(-) 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/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/notifications/display.dart b/lib/notifications/display.dart index 00a5f6fda5..081a2cf633 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -507,7 +507,7 @@ class NotificationDisplayManager { final zulipLocalizations = ZulipLocalizations.of(context); showErrorDialog(context: context, title: zulipLocalizations.errorNotificationOpenTitle, - message: zulipLocalizations.errorNotificationOpenAccountMissing); + message: zulipLocalizations.errorNotificationOpenAccountNotFound); return null; } diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index ccba7e24cc..ffb5d345d8 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1137,7 +1137,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 +1149,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 { From 1aad36e8a2471ffe7ccd2f66aad7b6a582dfd180 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 28 Mar 2025 19:32:30 +0530 Subject: [PATCH 04/11] binding test [nfc]: Reorder androidNotificationHost getter --- test/model/binding.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/model/binding.dart b/test/model/binding.dart index 2c70b68826..6b4de26608 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -313,12 +313,10 @@ class TestZulipBinding extends ZulipBinding { _androidNotificationHostApi = null; } - FakeAndroidNotificationHostApi? _androidNotificationHostApi; - @override - FakeAndroidNotificationHostApi get androidNotificationHost { - return (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); - } + FakeAndroidNotificationHostApi get androidNotificationHost => + (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); + FakeAndroidNotificationHostApi? _androidNotificationHostApi; /// The value that `ZulipBinding.instance.pickFiles()` should return. /// From 21d8c6ec52856ebf09b4187001643f4b3abe3749 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 10 Mar 2025 16:34:32 +0530 Subject: [PATCH 05/11] docs: Document testing push notifications on iOS Simulator --- .../howto/push-notifications-ios-simulator.md | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 docs/howto/push-notifications-ios-simulator.md diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md new file mode 100644 index 0000000000..ff9fd3b370 --- /dev/null +++ b/docs/howto/push-notifications-ios-simulator.md @@ -0,0 +1,281 @@ +# Testing Push Notifications on iOS Simulator + +For documentation on testing push notifications on Android or a real +iOS Device, see https://github.com/zulip/zulip-mobile/blob/main/docs/howto/push-notifications.md + +This doc describes how to test client side changes on iOS Simulator. +It will demonstrate how to use APNs payloads the server sends to +Apple's Push Notification service to show notifications on iOS +Simulator. + +
+ +## Receive a notification on the iOS Simulator + +Follow the following steps if you already have a valid APNs payload. + +Otherwise, you can either use one of the pre-canned payloads +provided [here](#pre-canned-payloads), or you can retrieve the APNs +payload generated by the latest dev server by following the steps +[here](#retrieve-apns-payload). + + +### 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, +and tapping on it 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' +``` + +
+ + +
+ +## Pre-canned APNs payloads + +The following pre-canned APNs payloads can be used in case you don't +have one. + +The following pre-canned payloads were generated from +Zulip Server 11.0-dev+git 8fd04b0f0, API Feature Level 377, +in April 2025. + +Also, they 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 + ] + } +} +``` + +
+ + +
+ +## Retrieve an APNs payload from dev server + +### 1. Set up dev server + +Follow +[this setup tutorial](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html) +to setup and run the dev server on same the Mac machine that hosts +the iOS Simulator. + +If you want to run the dev server on a different machine than the Mac +host, you'll need to follow extra steps +[documented here](https://github.com/zulip/zulip-mobile/blob/main/docs/howto/dev-server.md) +to make it possible for the app running on the iOS Simulator to +connect to the dev server. + + +### 2. 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. + +And 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 login to this user in the Flutter app, you'll need the password +that was generated by the development server. You can print the +password by running this command inside your `vagrant ssh` shell: +``` +$ ./manage.py print_initial_password iago@zulip.com +``` + +Then run the app on the iOS Simulator, accept the permission to +receive push notifications, and then login to the dev user +(`iago@zulip.com`). + + +### 4. Edit the server code to log the notification payload. + +We need to retrieve the APNs payload the server generates and sends +to the bouncer. To do that we can add a log statement after the +server completes generating the APNs in `zerver/lib/push_notifications.py`: + +```diff + apns_payload = get_message_payload_apns( + user_profile, + message, + trigger, + mentioned_user_group_id, + mentioned_user_group_name, + can_access_sender, + ) + gcm_payload, gcm_options = get_message_payload_gcm( + user_profile, message, mentioned_user_group_id, mentioned_user_group_name, can_access_sender + ) + logger.info("Sending push notifications to mobile clients for user %s", user_profile_id) ++ logger.info("APNS payload %s", orjson.dumps(apns_payload).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 logged payload JSON will have different structure than what an +iOS device actually receives, to fix that and save the result to a +file, run the payload through the following command: + +```shell-session +$ echo '{"alert":{"title": ...' \ + | jq '{aps: {alert: .alert, sound: .sound, badge: .badge}, zulip: .custom.zulip}' \ + > payload.json +``` From 91987929741e1510b49d929f83e7ab07d61460a2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Sat, 17 May 2025 21:20:02 -0700 Subject: [PATCH 06/11] docs: Clarify and expand a few spots in the iOS simulator notif doc Also make use of a handy shorthand within `jq`. --- .../howto/push-notifications-ios-simulator.md | 85 ++++++++++++------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/docs/howto/push-notifications-ios-simulator.md b/docs/howto/push-notifications-ios-simulator.md index ff9fd3b370..d54bb20491 100644 --- a/docs/howto/push-notifications-ios-simulator.md +++ b/docs/howto/push-notifications-ios-simulator.md @@ -1,23 +1,37 @@ # 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 +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. +This doc describes how to test client-side changes on iOS Simulator. It will demonstrate how to use APNs payloads the server sends to -Apple's Push Notification service to show notifications on iOS +the Apple Push Notification service to show notifications on iOS Simulator. -
-## Receive a notification on the iOS Simulator +### Contents -Follow the following steps if you already have a valid APNs payload. +* [Trigger a notification on the iOS Simulator](#trigger-notification) +* [Canned APNs payloads](#canned-payloads) +* [Produce sample APNs payloads](#produce-payload) -Otherwise, you can either use one of the pre-canned payloads -provided [here](#pre-canned-payloads), or you can retrieve the APNs -payload generated by the latest dev server by following the steps -[here](#retrieve-apns-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 @@ -46,8 +60,8 @@ $ xcrun simctl list devices 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, -and tapping on it should route to the respective conversation. +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] @@ -64,19 +78,21 @@ Notification sent to 'com.zulip.flutter' -
+
-## Pre-canned APNs payloads +## Canned APNs payloads The following pre-canned APNs payloads can be used in case you don't have one. -The following pre-canned payloads were generated from +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. -Also, they 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) +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, @@ -190,16 +206,16 @@ canned payloads to files `tmp/*.json`. -
+
-## Retrieve an APNs payload from dev server +## Produce sample APNs payloads ### 1. Set up dev server -Follow -[this setup tutorial](https://zulip.readthedocs.io/en/latest/development/setup-recommended.html) -to setup and run the dev server on same the Mac machine that hosts -the iOS Simulator. +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 @@ -210,10 +226,10 @@ 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. +We'll use the devlogin user `iago@zulip.com` to test notifications. +Log in to that user by going to `/devlogin` on that server on Web. -And then follow the steps [here](https://zulip.com/help/mobile-notifications) +Then follow the steps [here](https://zulip.com/help/mobile-notifications) to enable Mobile Notifications for "Channels". @@ -221,7 +237,7 @@ to enable Mobile Notifications for "Channels". -To login to this user in the Flutter app, you'll need the password +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: ``` @@ -229,7 +245,7 @@ $ ./manage.py print_initial_password iago@zulip.com ``` Then run the app on the iOS Simulator, accept the permission to -receive push notifications, and then login to the dev user +receive push notifications, and then log in as the dev user (`iago@zulip.com`). @@ -237,7 +253,7 @@ receive push notifications, and then login to the dev user We need to retrieve the APNs payload the server generates and sends to the bouncer. To do that we can add a log statement after the -server completes generating the APNs in `zerver/lib/push_notifications.py`: +server completes generating the payload in `zerver/lib/push_notifications.py`: ```diff apns_payload = get_message_payload_apns( @@ -270,12 +286,15 @@ by searching for "APNS payload". ### 6. Transform and save the payload to a file -The logged payload JSON will have different structure than what an -iOS device actually receives, to fix that and save the result to a -file, run the payload through the following command: +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: .alert, sound: .sound, badge: .badge}, zulip: .custom.zulip}' \ + | jq '{aps: {alert, sound, badge}, zulip: .custom.zulip}' \ > payload.json ``` From 346069adc3709b682f293fc10c21eee88b6b2880 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 11 Apr 2025 19:55:12 -0700 Subject: [PATCH 07/11] store: Add "blocking future" option on GlobalStoreWidget --- lib/widgets/store.dart | 11 ++++++++ test/widgets/store_test.dart | 54 ++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) 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/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); From 1f5e51802b29336fa3fcc40b9ffb2d948feb1641 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 15 May 2025 22:02:35 +0530 Subject: [PATCH 08/11] notif [nfc]: Rename NotificationOpenPayload -> NotificationNavigationData And rename it's methods to be clear that they are Android only. --- lib/notifications/display.dart | 28 ++++++++------- test/notifications/display_test.dart | 52 ++++++++++++++-------------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 081a2cf633..3bc69813f5 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -294,7 +294,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 +302,7 @@ class NotificationDisplayManager { TopicNarrow(streamId, topic), FcmMessageDmRecipient(:var allRecipientIds) => DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + }).buildAndroidNotificationUrl(); await _androidHost.notify( id: kNotificationId, @@ -498,7 +498,7 @@ class NotificationDisplayManager { assert(debugLog('got notif: url: $url')); assert(url.scheme == 'zulip' && url.host == 'notification'); - final payload = NotificationOpenPayload.parseUrl(url); + final payload = NotificationNavigationData.parseAndroidNotificationUrl(url); final account = globalStore.accounts.firstWhereOrNull( (account) => account.realmUrl.origin == payload.realmUrl.origin @@ -551,20 +551,23 @@ class NotificationDisplayManager { } } -/// The information contained in 'zulip://notification/…' internal -/// Android intent data URL, used for notification-open flow. -class NotificationOpenPayload { +/// 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 Narrow narrow; + final SendableNarrow narrow; - NotificationOpenPayload({ + NotificationNavigationData({ required this.realmUrl, required this.userId, required this.narrow, }); - factory NotificationOpenPayload.parseUrl(Uri url) { + /// 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', @@ -582,7 +585,7 @@ class NotificationOpenPayload { final realmUrl = Uri.parse(realmUrlStr); final userId = int.parse(userIdStr, radix: 10); - final Narrow narrow; + final SendableNarrow narrow; switch (narrowType) { case 'topic': final channelIdStr = url.queryParameters['channel_id']!; @@ -599,7 +602,7 @@ class NotificationOpenPayload { throw const FormatException(); } - return NotificationOpenPayload( + return NotificationNavigationData( realmUrl: realmUrl, userId: userId, narrow: narrow, @@ -610,7 +613,7 @@ class NotificationOpenPayload { } } - Uri buildUrl() { + Uri buildAndroidNotificationUrl() { return Uri( scheme: 'zulip', host: 'notification', @@ -627,7 +630,6 @@ class NotificationOpenPayload { 'narrow_type': 'dm', 'all_recipient_ids': allRecipientIds.join(','), }, - _ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'), }) }, ); diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index ffb5d345d8..de86d2c832 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -346,7 +346,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 +354,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 +1072,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 +1080,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 @@ -1198,11 +1198,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 +1210,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 +1235,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 +1243,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 +1256,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 +1301,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 +1328,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 +1347,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 +1406,7 @@ void main() { }, ]; for (final params in testCases) { - check(() => NotificationOpenPayload.parseUrl(Uri( + check(() => NotificationNavigationData.parseAndroidNotificationUrl(Uri( scheme: 'zulip', host: 'notification', queryParameters: params, @@ -1432,7 +1432,7 @@ void main() { 'channel_id': '1', 'topic': 'topic A', }); - check(() => NotificationOpenPayload.parseUrl(url)) + check(() => NotificationNavigationData.parseAndroidNotificationUrl(url)) .throws(); }); @@ -1447,7 +1447,7 @@ void main() { 'channel_id': '1', 'topic': 'topic A', }); - check(() => NotificationOpenPayload.parseUrl(url)) + check(() => NotificationNavigationData.parseAndroidNotificationUrl(url)) .throws(); }); }); @@ -1531,7 +1531,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'); From 80a89e33784aef7bb25872fa7bb8439c04808a29 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Tue, 20 May 2025 18:46:46 +0530 Subject: [PATCH 09/11] notif [nfc]: Introduce NotificationNavigationServer And move the notification navigation data parsing utilities to the new class. --- lib/notifications/display.dart | 133 ++----------------------- lib/notifications/navigate.dart | 142 +++++++++++++++++++++++++++ lib/widgets/app.dart | 41 +++++--- test/notifications/display_test.dart | 1 + 4 files changed, 176 insertions(+), 141 deletions(-) create mode 100644 lib/notifications/navigate.dart diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 3bc69813f5..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; @@ -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 = NotificationNavigationData.parseAndroidNotificationUrl(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.errorNotificationOpenAccountNotFound); - 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,88 +516,3 @@ class NotificationDisplayManager { 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 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/navigate.dart b/lib/notifications/navigate.dart new file mode 100644 index 0000000000..8c6f410be8 --- /dev/null +++ b/lib/notifications/navigate.dart @@ -0,0 +1,142 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../generated/l10n/zulip_localizations.dart'; +import '../log.dart'; +import '../model/narrow.dart'; +import '../widgets/dialog.dart'; +import '../widgets/message_list.dart'; +import '../widgets/page.dart'; +import '../widgets/store.dart'; + +/// Service for handling notification navigation. +class NotificationNavigationService { + + /// 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); + } + + 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 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/widgets/app.dart b/lib/widgets/app.dart index 54ba92588b..3cbddce33e 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,37 @@ class _ZulipAppState extends State with WidgetsBindingObserver { super.dispose(); } + 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. 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 = _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); diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index de86d2c832..938432a466 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'; From b8cfff73475aaa3af73479feefa241bf2ecf0d6c Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Fri, 28 Feb 2025 21:20:42 +0530 Subject: [PATCH 10/11] notif ios: Navigate when app launched from notification Introduces a new Pigeon API file, and adds the corresponding bindings in Swift. Unlike the `pigeon/android_notifications.dart` API this doesn't use the ZulipPlugin hack, as that is only needed when we want the Pigeon functions to be available inside a background isolate (see doc in `zulip_plugin/pubspec.yaml`). Since the notification tap will trigger an app launch first (if not running already) anyway, we can be sure that these new functions won't be running on a Dart background isolate, thus not needing the ZulipPlugin hack. --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 20 +++ ios/Runner/Notifications.g.swift | 235 ++++++++++++++++++++++++++ lib/host/notifications.dart | 1 + lib/host/notifications.g.dart | 146 ++++++++++++++++ lib/model/binding.dart | 14 ++ lib/notifications/navigate.dart | 157 ++++++++++++++++- lib/notifications/receive.dart | 3 + lib/widgets/app.dart | 14 +- pigeon/notifications.dart | 30 ++++ test/model/binding.dart | 21 +++ test/model/store_test.dart | 9 +- test/notifications/display_test.dart | 1 + test/notifications/navigate_test.dart | 149 ++++++++++++++++ test/notifications/receive_test.dart | 2 + 15 files changed, 799 insertions(+), 7 deletions(-) create mode 100644 ios/Runner/Notifications.g.swift create mode 100644 lib/host/notifications.dart create mode 100644 lib/host/notifications.g.dart create mode 100644 pigeon/notifications.dart create mode 100644 test/notifications/navigate_test.dart 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..33a0fe72cb 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -8,6 +8,26 @@ import Flutter 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) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } + +private class NotificationHostApiImpl: NotificationHostApi { + private let maybeDataFromLaunch: NotificationDataFromLaunch? + + init(_ maybeDataFromLaunch: NotificationDataFromLaunch?) { + self.maybeDataFromLaunch = maybeDataFromLaunch + } + + func getNotificationDataFromLaunch() -> NotificationDataFromLaunch? { + maybeDataFromLaunch + } +} diff --git a/ios/Runner/Notifications.g.swift b/ios/Runner/Notifications.g.swift new file mode 100644 index 0000000000..342953fbad --- /dev/null +++ b/ios/Runner/Notifications.g.swift @@ -0,0 +1,235 @@ +// 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) + } +} + +private class NotificationsPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return NotificationDataFromLaunch.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 { + 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()) +} + +/// 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) + } + } +} 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..d8448d60b8 --- /dev/null +++ b/lib/host/notifications.g.dart @@ -0,0 +1,146 @@ +// 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 _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 { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return NotificationDataFromLaunch.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +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?); + } + } +} diff --git a/lib/model/binding.dart b/lib/model/binding.dart index fb3add46da..7f9b0f6fd1 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,13 @@ class PackageInfo { }); } +class NotificationPigeonApi { + final _hostApi = notif_pigeon.NotificationHostApi(); + + Future getNotificationDataFromLaunch() => + _hostApi.getNotificationDataFromLaunch(); +} + /// A concrete binding for use in the live application. /// /// The global store returned by [getGlobalStore], and consequently by @@ -469,6 +480,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/navigate.dart b/lib/notifications/navigate.dart index 8c6f410be8..b0a32fbfdf 100644 --- a/lib/notifications/navigate.dart +++ b/lib/notifications/navigate.dart @@ -1,17 +1,91 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:collection/collection.dart'; -import 'package:flutter/material.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/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(); + + 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. /// @@ -40,6 +114,21 @@ class NotificationNavigationService { narrow: data.narrow); } + static 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, @@ -69,6 +158,72 @@ class NotificationNavigationData { 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. 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 3cbddce33e..fccd363cc1 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -169,6 +169,12 @@ 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, @@ -188,10 +194,12 @@ class _ZulipAppState extends State with WidgetsBindingObserver { 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 route = _initialRouteAndroid(context, initialRoute); + final route = defaultTargetPlatform == TargetPlatform.iOS + ? _initialRouteIos(context) + : _initialRouteAndroid(context, initialRoute); if (route != null) { return [ HomePage.buildRoute(accountId: route.accountId), @@ -229,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) { @@ -263,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/pigeon/notifications.dart b/pigeon/notifications.dart new file mode 100644 index 0000000000..efea52d9a6 --- /dev/null +++ b/pigeon/notifications.dart @@ -0,0 +1,30 @@ +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/notifications.g.dart', + swiftOut: 'ios/Runner/Notifications.g.swift', +)) + +class NotificationDataFromLaunch { + const NotificationDataFromLaunch({required this.payload}); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [NotificationHostApi.getNotificationDataFromLaunch]. + final Map payload; +} + +@HostApi() +abstract class NotificationHostApi { + /// Retrieves notification data if the app was launched by tapping on a notification. + /// + /// Returns `launchOptions.remoteNotification`, + /// which is the raw APNs data dictionary + /// if the app launch was opened by a notification tap, + /// else null. See Apple doc: + /// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification + NotificationDataFromLaunch? getNotificationDataFromLaunch(); +} diff --git a/test/model/binding.dart b/test/model/binding.dart index 6b4de26608..afeed2f266 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,6 +312,7 @@ class TestZulipBinding extends ZulipBinding { void _resetNotifications() { _androidNotificationHostApi = null; + _notificationPigeonApi = null; } @override @@ -318,6 +320,11 @@ class TestZulipBinding extends ZulipBinding { (_androidNotificationHostApi ??= FakeAndroidNotificationHostApi()); FakeAndroidNotificationHostApi? _androidNotificationHostApi; + @override + FakeNotificationPigeonApi get notificationPigeonApi => + (_notificationPigeonApi ??= FakeNotificationPigeonApi()); + FakeNotificationPigeonApi? _notificationPigeonApi; + /// The value that `ZulipBinding.instance.pickFiles()` should return. /// /// See also [takePickFilesCalls]. @@ -754,6 +761,20 @@ 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; +} + 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 938432a466..1e8a77cb58 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -122,6 +122,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/notifications/navigate_test.dart b/test/notifications/navigate_test.dart new file mode 100644 index 0000000000..7d0d5720a7 --- /dev/null +++ b/test/notifications/navigate_test.dart @@ -0,0 +1,149 @@ +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/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/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(); + + 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}) { + account ??= eg.selfAccount; + final expected = >[ + (it) => it.isA() + ..accountId.equals(account!.id) + ..page.isA(), + ]; + check(pushedRoutes.take(expected.length)).deepEquals(expected); + pushedRoutes.removeRange(0, expected.length); + } + + Future prepare(WidgetTester tester) async { + await init(); + pushedRoutes = []; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + } + + void matchesNavigation(Subject> route, Account account, Message message) { + route.isA() + ..accountId.equals(account.id) + ..page.isA() + .initNarrow.equals(SendableNarrow.ofMessage(message, + selfUserId: account.userId)); + } + + 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 account = eg.selfAccount; + final message = eg.streamMessage(); + final payload = messageApnsPayload(message, account: account); + testBinding.notificationPigeonApi.setNotificationDataFromLaunch( + NotificationDataFromLaunch(payload: payload)); + + // Now start the app. + await testBinding.globalStore.add(account, eg.initialSnapshot()); + await prepare(tester); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + // Once the app is ready, we navigate to the conversation. + await tester.pump(); + takeStartingRoutes(); + matchesNavigation(check(pushedRoutes).single, account, 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); + 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(); } From 1c0395087797495ce22d9d2748237b016f5b1246 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 3 Mar 2025 20:57:00 +0530 Subject: [PATCH 11/11] notif ios: Navigate when app running but in background --- ios/Runner/AppDelegate.swift | 33 ++++++ ios/Runner/Notifications.g.swift | 100 +++++++++++++++++ lib/host/notifications.g.dart | 64 +++++++++++ lib/model/binding.dart | 6 + lib/notifications/navigate.dart | 25 ++++- pigeon/notifications.dart | 23 ++++ test/model/binding.dart | 12 ++ test/notifications/navigate_test.dart | 151 ++++++++++++++++++++++++-- 8 files changed, 401 insertions(+), 13 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 33a0fe72cb..eefed07cd6 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -3,6 +3,8 @@ import Flutter @main @objc class AppDelegate: FlutterAppDelegate { + private var notificationTapEventListener: NotificationTapEventListener? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -16,8 +18,25 @@ import Flutter 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 { @@ -31,3 +50,17 @@ private class NotificationHostApiImpl: NotificationHostApi { 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 index 342953fbad..40db818d33 100644 --- a/ios/Runner/Notifications.g.swift +++ b/ios/Runner/Notifications.g.swift @@ -157,11 +157,42 @@ struct NotificationDataFromLaunch: Hashable { } } +/// 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) } @@ -173,6 +204,9 @@ private class NotificationsPigeonCodecWriter: FlutterStandardWriter { 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) } @@ -193,6 +227,8 @@ 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. @@ -233,3 +269,67 @@ class NotificationHostApiSetup { } } } + +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/host/notifications.g.dart b/lib/host/notifications.g.dart index d8448d60b8..a83b67c804 100644 --- a/lib/host/notifications.g.dart +++ b/lib/host/notifications.g.dart @@ -74,6 +74,51 @@ class NotificationDataFromLaunch { ; } +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(); @@ -85,6 +130,9 @@ class _PigeonCodec extends StandardMessageCodec { } 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); } @@ -95,12 +143,16 @@ class _PigeonCodec extends StandardMessageCodec { 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 @@ -144,3 +196,15 @@ class NotificationHostApi { } } } + +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 7f9b0f6fd1..4d1a0adaac 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -328,11 +328,17 @@ 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. diff --git a/lib/notifications/navigate.dart b/lib/notifications/navigate.dart index b0a32fbfdf..d4a554ceb4 100644 --- a/lib/notifications/navigate.dart +++ b/lib/notifications/navigate.dart @@ -11,6 +11,7 @@ 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'; @@ -48,6 +49,8 @@ class NotificationNavigationService { 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. @@ -114,7 +117,27 @@ class NotificationNavigationService { narrow: data.narrow); } - static NotificationNavigationData? _tryParseIosApnsPayload( + /// 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, ) { diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index efea52d9a6..66c1bd2e71 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -17,6 +17,16 @@ class NotificationDataFromLaunch { final Map payload; } +class NotificationTapEvent { + const NotificationTapEvent({required this.payload}); + + /// The raw payload that is attached to the notification, + /// holding the information required to carry out the navigation. + /// + /// See [notificationTapEvents]. + final Map payload; +} + @HostApi() abstract class NotificationHostApi { /// Retrieves notification data if the app was launched by tapping on a notification. @@ -28,3 +38,16 @@ abstract class NotificationHostApi { /// 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 afeed2f266..839242c1ce 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -773,6 +773,18 @@ class FakeNotificationPigeonApi implements NotificationPigeonApi { @override Future getNotificationDataFromLaunch() async => _notificationDataFromLaunch; + + StreamController? _notificationTapEventsStreamController; + + void addNotificationTapEvent(NotificationTapEvent event) { + _notificationTapEventsStreamController!.add(event); + } + + @override + Stream notificationTapEventsStream() { + _notificationTapEventsStreamController ??= StreamController(); + return _notificationTapEventsStreamController!.stream; + } } typedef AndroidNotificationHostApiNotifyCall = ({ diff --git a/test/notifications/navigate_test.dart b/test/notifications/navigate_test.dart index 7d0d5720a7..084c0018db 100644 --- a/test/notifications/navigate_test.dart +++ b/test/notifications/navigate_test.dart @@ -3,6 +3,7 @@ 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'; @@ -15,6 +16,7 @@ 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'; @@ -64,6 +66,7 @@ Map messageApnsPayload( void main() { TestZulipBinding.ensureInitialized(); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; Future init() async { addTearDown(testBinding.reset); @@ -78,22 +81,49 @@ void main() { late List> pushedRoutes; void takeStartingRoutes({Account? account}) { - account ??= eg.selfAccount; final expected = >[ - (it) => it.isA() - ..accountId.equals(account!.id) - ..page.isA(), + 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) async { + 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) { @@ -104,25 +134,122 @@ void main() { 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 account = eg.selfAccount; final message = eg.streamMessage(); - final payload = messageApnsPayload(message, account: account); + final payload = messageApnsPayload(message, account: eg.selfAccount); testBinding.notificationPigeonApi.setNotificationDataFromLaunch( NotificationDataFromLaunch(payload: payload)); // Now start the app. - await testBinding.globalStore.add(account, eg.initialSnapshot()); - await prepare(tester); + 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(); - matchesNavigation(check(pushedRoutes).single, account, message); + 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 { @@ -138,7 +265,7 @@ void main() { testBinding.notificationPigeonApi.setNotificationDataFromLaunch( NotificationDataFromLaunch(payload: payload)); - await prepare(tester); + await prepare(tester, dropStartingRoutes: false, withAccount: false); check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet await tester.pump();