From 899549392192c475a0a3d55b93f978e50c3cd4f2 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 2 Jan 2025 21:29:15 +0530 Subject: [PATCH 1/3] notif test [nfc]: Cleanup `TestPlatformDispatcher.defaultRouteNameTestValue` --- test/notifications/display_test.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 32d8254d6d..0dafc2b189 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1103,6 +1103,7 @@ void main() { testWidgets('at app launch', (tester) async { addTearDown(testBinding.reset); + addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); // Set up a value for `PlatformDispatcher.defaultRouteName` to return, // for determining the intial route. final account = eg.selfAccount; @@ -1112,11 +1113,11 @@ void main() { realmUrl: data.realmUrl, userId: data.userId, narrow: switch (data.recipient) { - FcmMessageChannelRecipient(:var streamId, :var topic) => - TopicNarrow(streamId, topic), - FcmMessageDmRecipient(:var allRecipientIds) => - DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), - }).buildUrl(); + FcmMessageChannelRecipient(:var streamId, :var topic) => + TopicNarrow(streamId, topic), + FcmMessageDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), + }).buildUrl(); tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); // Now start the app. From 92240c4693fc201aed4adb3504a74f9bbc2c52d1 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Thu, 2 Jan 2025 21:36:35 +0530 Subject: [PATCH 2/3] app [nfc]: Pull out `_handleGenerateInitialRoutes` --- lib/widgets/app.dart | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 75fec7bc8b..a77244bea1 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -152,6 +152,21 @@ class _ZulipAppState extends State with WidgetsBindingObserver { return super.didPushRouteInformation(routeInformation); } + InitialRouteListFactory _handleGenerateInitialRoutes(BuildContext context) { + final globalStore = GlobalStoreWidget.of(context); + + return (String initialRoute) { + // TODO(#524) choose initial account as last one used + final initialAccountId = globalStore.accounts.firstOrNull?.id; + return [ + if (initialAccountId == null) + MaterialWidgetRoute(page: const ChooseAccountPage()) + else + HomePage.buildRoute(accountId: initialAccountId), + ]; + }; + } + Future _handleInitialRoute() async { final initialRouteUrl = Uri.parse(WidgetsBinding.instance.platformDispatcher.defaultRouteName); if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { @@ -177,9 +192,6 @@ class _ZulipAppState extends State with WidgetsBindingObserver { final themeData = zulipThemeData(context); return GlobalStoreWidget( child: Builder(builder: (context) { - final globalStore = GlobalStoreWidget.of(context); - // TODO(#524) choose initial account as last one used - final initialAccountId = globalStore.accounts.firstOrNull?.id; return MaterialApp( title: 'Zulip', localizationsDelegates: ZulipLocalizations.localizationsDelegates, @@ -206,14 +218,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { // like [Navigator.push], never mere names as with [Navigator.pushNamed]. onGenerateRoute: (_) => null, - onGenerateInitialRoutes: (_) { - return [ - if (initialAccountId == null) - MaterialWidgetRoute(page: const ChooseAccountPage()) - else - HomePage.buildRoute(accountId: initialAccountId), - ]; - }); + onGenerateInitialRoutes: _handleGenerateInitialRoutes(context)); })); } } From e0ee84d937bc83e1cb7d0b47ff52cee2a6f20e56 Mon Sep 17 00:00:00 2001 From: Rajesh Malviya Date: Mon, 30 Dec 2024 23:28:20 +0530 Subject: [PATCH 3/3] notif: Use associated account as initial account; if opened from background Previously, when two accounts (Account-1 and Account-2) were logged in, the app always defaulted to showing the home page of Account-1 on launch. If the app was closed and the user opened a notification from Account-2, the navigation stack would be: HomePage(Account-1) -> MessageListPage(Account-2) This commit fixes that behaviour, now when a notification is opened while the app is closed, the home page will correspond to the account associated with the notification's conversation. This addresses #1210 for background notifications. --- lib/notifications/display.dart | 42 +++++++++++++++++++++------- lib/widgets/app.dart | 39 ++++++++++++++++++++------ test/notifications/display_test.dart | 39 ++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 8319ee65b1..8ed4ca16b5 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -14,11 +14,11 @@ import '../log.dart'; import '../model/binding.dart'; import '../model/localizations.dart'; import '../model/narrow.dart'; +import '../model/store.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'; @@ -452,6 +452,32 @@ 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 if the associated account is not found in the global + /// store. + static ({Route route, int accountId})? routeForNotification({ + required GlobalStore globalStore, + required Uri url, + }) { + assert(debugLog('got notif: url: $url')); + assert(url.scheme == 'zulip' && url.host == 'notification'); + final payload = NotificationOpenPayload.parseUrl(url); + + final account = globalStore.accounts.firstWhereOrNull((account) => + account.realmUrl == payload.realmUrl && account.userId == payload.userId); + if (account == null) return null; + + final route = MessageListPage.buildRoute( + accountId: account.id, + // TODO(#82): Open at specific message, not just conversation + narrow: payload.narrow); + return (route: route, accountId: account.id); + } + /// Navigates to the [MessageListPage] of the specific conversation /// given the `zulip://notification/…` Android intent data URL, /// generated with [NotificationOpenPayload.buildUrl] while creating @@ -459,9 +485,6 @@ class NotificationDisplayManager { static Future navigateForNotification(Uri url) async { assert(debugLog('opened notif: url: $url')); - assert(url.scheme == 'zulip' && url.host == 'notification'); - final payload = NotificationOpenPayload.parseUrl(url); - NavigatorState navigator = await ZulipApp.navigator; final context = navigator.context; assert(context.mounted); @@ -469,9 +492,10 @@ class NotificationDisplayManager { final zulipLocalizations = ZulipLocalizations.of(context); final globalStore = GlobalStoreWidget.of(context); - final account = globalStore.accounts.firstWhereOrNull((account) => - account.realmUrl == payload.realmUrl && account.userId == payload.userId); - if (account == null) { // TODO(log) + + final notificationResult = + routeForNotification(globalStore: globalStore, url: url); + if (notificationResult == null) { // TODO(log) showErrorDialog(context: context, title: zulipLocalizations.errorNotificationOpenTitle, message: zulipLocalizations.errorNotificationOpenAccountMissing); @@ -479,9 +503,7 @@ class NotificationDisplayManager { } // TODO(nav): Better interact with existing nav stack on notif open - unawaited(navigator.push(MaterialAccountWidgetRoute(accountId: account.id, - // TODO(#82): Open at specific message, not just conversation - page: MessageListPage(initNarrow: payload.narrow)))); + unawaited(navigator.push(notificationResult.route)); } static Future _fetchBitmap(Uri url) async { diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index a77244bea1..ce7f92a911 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -155,7 +155,38 @@ class _ZulipAppState extends State with WidgetsBindingObserver { InitialRouteListFactory _handleGenerateInitialRoutes(BuildContext context) { final globalStore = GlobalStoreWidget.of(context); + void showNotificationErrorDialog() async { + final navigator = await ZulipApp.navigator; + final navigatorContext = navigator.context; + assert(navigatorContext.mounted); + // TODO(linter): this is impossible as there's no actual async gap, but + // the use_build_context_synchronously lint doesn't see that. + if (!navigatorContext.mounted) return; + + final zulipLocalizations = ZulipLocalizations.of(navigatorContext); + showErrorDialog(context: navigatorContext, + title: zulipLocalizations.errorNotificationOpenTitle, + message: zulipLocalizations.errorNotificationOpenAccountMissing); + } + return (String initialRoute) { + final initialRouteUrl = Uri.parse(initialRoute); + if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { + final notificationResult = NotificationDisplayManager.routeForNotification( + globalStore: globalStore, + url: initialRouteUrl); + + if (notificationResult != null) { + return [ + HomePage.buildRoute(accountId: notificationResult.accountId), + notificationResult.route, + ]; + } else { + showNotificationErrorDialog(); + // Fallthrough to show default route below. + } + } + // TODO(#524) choose initial account as last one used final initialAccountId = globalStore.accounts.firstOrNull?.id; return [ @@ -167,18 +198,10 @@ class _ZulipAppState extends State with WidgetsBindingObserver { }; } - Future _handleInitialRoute() async { - final initialRouteUrl = Uri.parse(WidgetsBinding.instance.platformDispatcher.defaultRouteName); - if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - await NotificationDisplayManager.navigateForNotification(initialRouteUrl); - } - } - @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _handleInitialRoute(); } @override diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 0dafc2b189..1580afcbb9 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -1130,6 +1130,45 @@ void main() { takeStartingRoutes(); matchesNavigation(check(pushedRoutes).single, account, message); }); + + testWidgets('uses associated account as initial account; if initial route', (tester) async { + addTearDown(testBinding.reset); + addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue); + + final accountA = eg.selfAccount; + final accountB = eg.otherAccount; + final message = eg.streamMessage(); + final data = messageFcmMessage(message, account: accountB); + await testBinding.globalStore.add(accountA, eg.initialSnapshot()); + await testBinding.globalStore.add(accountB, eg.initialSnapshot()); + + final intentDataUrl = NotificationOpenPayload( + realmUrl: data.realmUrl, + userId: data.userId, + narrow: switch (data.recipient) { + FcmMessageChannelRecipient(:var streamId, :var topic) => + TopicNarrow(streamId, topic), + FcmMessageDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId), + }).buildUrl(); + tester.binding.platformDispatcher.defaultRouteNameTestValue = intentDataUrl.toString(); + + await prepare(tester, early: true); + check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet + + await tester.pump(); + check(pushedRoutes).deepEquals(>[ + (it) => it.isA() + ..accountId.equals(accountB.id) + ..page.isA(), + (it) => it.isA() + ..accountId.equals(accountB.id) + ..page.isA() + .initNarrow.equals(SendableNarrow.ofMessage(message, + selfUserId: accountB.userId)) + ]); + pushedRoutes.clear(); + }); }); group('NotificationOpenPayload', () {