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 75fec7bc8b..ce7f92a911 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -152,18 +152,56 @@ class _ZulipAppState extends State with WidgetsBindingObserver { return super.didPushRouteInformation(routeInformation); } - Future _handleInitialRoute() async { - final initialRouteUrl = Uri.parse(WidgetsBinding.instance.platformDispatcher.defaultRouteName); - if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) { - await NotificationDisplayManager.navigateForNotification(initialRouteUrl); + 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 [ + if (initialAccountId == null) + MaterialWidgetRoute(page: const ChooseAccountPage()) + else + HomePage.buildRoute(accountId: initialAccountId), + ]; + }; } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _handleInitialRoute(); } @override @@ -177,9 +215,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 +241,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)); })); } } diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 32d8254d6d..1580afcbb9 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. @@ -1129,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', () {