From da899b398a1fee6fa8a3a9b08f1fb0ed5fef4971 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 1 Nov 2023 17:40:55 -0700 Subject: [PATCH] wip notif: Navigate to conversation on tapping notification Fixes: 123 --- lib/notifications.dart | 35 ++++++++++++ lib/widgets/app.dart | 3 + test/notifications_test.dart | 108 ++++++++++++++++++++++++++++++++++- 3 files changed, 143 insertions(+), 3 deletions(-) diff --git a/lib/notifications.dart b/lib/notifications.dart index 865bec9acc9..172b3c15812 100644 --- a/lib/notifications.dart +++ b/lib/notifications.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -7,7 +8,11 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'api/notifications.dart'; import 'log.dart'; import 'model/binding.dart'; +import 'model/narrow.dart'; import 'widgets/app.dart'; +import 'widgets/message_list.dart'; +import 'widgets/page.dart'; +import 'widgets/store.dart'; class NotificationService { static NotificationService get instance => (_instance ??= NotificationService._()); @@ -187,6 +192,7 @@ class NotificationDisplayManager { const InitializationSettings( android: AndroidInitializationSettings('zulip_notification'), ), + onDidReceiveNotificationResponse: _onNotificationOpened, ); await NotificationChannelManager._ensureChannel(); } @@ -215,6 +221,7 @@ class NotificationDisplayManager { notificationIdAsHashOf(conversationKey), title, data.content, + payload: jsonEncode(dataJson), NotificationDetails(android: AndroidNotificationDetails( NotificationChannelManager.kChannelId, // This [FlutterLocalNotificationsPlugin.show] call can potentially create @@ -229,6 +236,9 @@ class NotificationDisplayManager { color: kZulipBrandColor, icon: 'zulip_notification', // TODO vary for debug // TODO(#128) inbox-style + + // TODO plugin sets PendingIntent.FLAG_UPDATE_CURRENT; is that OK? + // TODO plugin doesn't set our Intent flags; is that OK? ))); } @@ -261,4 +271,29 @@ class NotificationDisplayManager { // https://url.spec.whatwg.org/#url-code-points return "${data.realmUri}|${data.userId}"; } + + static void _onNotificationOpened(NotificationResponse response) async { + final data = MessageFcmMessage.fromJson(jsonDecode(response.payload!)); + assert(debugLog('opened notif: message ${data.zulipMessageId}, content ${data.content}')); + final navigator = navigatorKey.currentState; + if (navigator == null) return; // TODO(log) handle + + final globalStore = GlobalStoreWidget.of(navigator.context); + final account = globalStore.accounts.firstWhereOrNull((account) => + account.realmUrl == data.realmUri && account.userId == data.userId); + if (account == null) return; // TODO(log) + + final narrow = switch (data.recipient) { + FcmMessageStreamRecipient(:var streamId, :var topic) => + TopicNarrow(streamId, topic), + FcmMessageDmRecipient(:var allRecipientIds) => + DmNarrow(allRecipientIds: allRecipientIds, selfUserId: account.userId), + }; + + assert(debugLog(' account: $account, narrow: $narrow')); + // TODO(nav): Better interact with existing nav stack on notif open + navigator.push(MaterialWidgetRoute( + page: PerAccountStoreWidget(accountId: account.id, + child: MessageListPage(narrow: narrow)))); + } } diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index c1228d6341f..7d42a10d108 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -10,6 +10,8 @@ import 'page.dart'; import 'recent_dm_conversations.dart'; import 'store.dart'; +final navigatorKey = GlobalKey(); + class ZulipApp extends StatelessWidget { const ZulipApp({super.key, this.navigatorObservers}); @@ -54,6 +56,7 @@ class ZulipApp extends StatelessWidget { localizationsDelegates: ZulipLocalizations.localizationsDelegates, supportedLocales: ZulipLocalizations.supportedLocales, theme: theme, + navigatorKey: navigatorKey, navigatorObservers: navigatorObservers ?? const [], builder: (BuildContext context, Widget? child) { GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); diff --git a/test/notifications_test.dart b/test/notifications_test.dart index b9691c1ca8a..5b7530570b7 100644 --- a/test/notifications_test.dart +++ b/test/notifications_test.dart @@ -1,19 +1,27 @@ +import 'dart:convert'; import 'dart:typed_data'; -import 'dart:ui'; import 'package:checks/checks.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message; -import 'package:test/scaffolding.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/notifications.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications.dart'; import 'package:zulip/widgets/app.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/page.dart'; +import 'package:zulip/widgets/store.dart'; import 'model/binding.dart'; import 'example_data.dart' as eg; +import 'test_navigation.dart'; +import 'widgets/message_list_checks.dart'; +import 'widgets/page_checks.dart'; +import 'widgets/store_checks.dart'; FakeAndroidFlutterLocalNotificationsPlugin get notifAndroid => testBinding.notifications @@ -93,7 +101,7 @@ void main() { }); }); - group('NotificationDisplayManager', () { + group('NotificationDisplayManager show', () { void checkNotification(MessageFcmMessage data, { required String expectedTitle, required String expectedTagComponent, @@ -105,6 +113,7 @@ void main() { ..id.equals(expectedId) ..title.equals(expectedTitle) ..body.equals(data.content) + ..payload.equals(jsonEncode(data.toJson())) ..notificationDetails.isNotNull().android.isNotNull().which(it() ..channelId.equals(NotificationChannelManager.kChannelId) ..tag.equals(expectedTag) @@ -172,6 +181,99 @@ void main() { expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); }); }); + + group('NotificationDisplayManager open', () { + late List> pushedRoutes; + + Future prepare(WidgetTester tester) async { + await init(); + pushedRoutes = []; + final testNavObserver = TestNavigatorObserver() + ..onPushed = (route, prevRoute) => pushedRoutes.add(route); + await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver])); + await tester.pump(); + check(pushedRoutes).length.equals(1); + pushedRoutes.clear(); + } + + void openNotification(Account account, Message message) { + final fcmMessage = messageFcmMessage(message, account: account); + testBinding.notifications.receiveNotificationResponse(NotificationResponse( + notificationResponseType: NotificationResponseType.selectedNotification, + payload: jsonEncode(fcmMessage))); + } + + void checkOpenedMessageList({required int expectedAccountId, required Narrow expectedNarrow}) { + check(pushedRoutes).single.isA().page + .isA() + ..accountId.equals(expectedAccountId) + ..child.isA() + .narrow.equals(expectedNarrow); + pushedRoutes.clear(); + } + + void checkOpenNotification(Account account, Message message) { + openNotification(account, message); + checkOpenedMessageList( + expectedAccountId: account.id, + expectedNarrow: SendableNarrow.ofMessage(message, + selfUserId: account.userId)); + } + + testWidgets('stream message', (tester) async { + testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + await prepare(tester); + checkOpenNotification(eg.selfAccount, eg.streamMessage()); + }); + + testWidgets('direct message', (tester) async { + testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + await prepare(tester); + checkOpenNotification(eg.selfAccount, + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser])); + }); + + testWidgets('no widgets in tree', (tester) async { + await init(); + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + + openNotification(eg.selfAccount, message); + // nothing happened, but nothing blew up + }); + + testWidgets('no accounts', (tester) async { + await prepare(tester); + openNotification(eg.selfAccount, eg.streamMessage()); + check(pushedRoutes).isEmpty(); + }); + + testWidgets('mismatching account', (tester) async { + testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false)); + await prepare(tester); + openNotification(eg.otherAccount, eg.streamMessage()); + check(pushedRoutes).isEmpty(); + }); + + testWidgets('find account among several', (tester) async { + final realmUrlA = Uri.parse('https://a-chat.example/'); + final realmUrlB = Uri.parse('https://chat-b.example/'); + final accounts = [ + eg.account(id: 1001, realmUrl: realmUrlA, user: eg.user(userId: 123)), + eg.account(id: 1002, realmUrl: realmUrlA, user: eg.user(userId: 234)), + eg.account(id: 1003, realmUrl: realmUrlB, user: eg.user(userId: 123)), + eg.account(id: 1004, realmUrl: realmUrlB, user: eg.user(userId: 234)), + ]; + for (final account in accounts) { + testBinding.globalStore.insertAccount(account.toCompanion(false)); + } + await prepare(tester); + + checkOpenNotification(accounts[0], eg.streamMessage()); + checkOpenNotification(accounts[1], eg.streamMessage()); + checkOpenNotification(accounts[2], eg.streamMessage()); + checkOpenNotification(accounts[3], eg.streamMessage()); + }); + }); } extension AndroidNotificationChannelChecks on Subject {