Skip to content

Commit

Permalink
notif: Navigate to conversation when app launched from notif, too
Browse files Browse the repository at this point in the history
As a bonus, this also means that if you manage to open a notification
in the brief time between the app launching and the GlobalStore data
getting loaded from local disk, we'll properly wait for that data and
then navigate to the conversation in that case too.

Fixes: #123
  • Loading branch information
gnprice authored and chrisbobbe committed Nov 6, 2023
1 parent 4dbfba4 commit 681a744
Show file tree
Hide file tree
Showing 2 changed files with 91 additions and 32 deletions.
27 changes: 24 additions & 3 deletions lib/notifications.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';

import 'api/notifications.dart';
Expand Down Expand Up @@ -194,6 +195,10 @@ class NotificationDisplayManager {
),
onDidReceiveNotificationResponse: _onNotificationOpened,
);
final launchDetails = await ZulipBinding.instance.notifications.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp ?? false) {
_handleNotificationAppLaunch(launchDetails!.notificationResponse);
}
await NotificationChannelManager._ensureChannel();
}

Expand Down Expand Up @@ -278,10 +283,25 @@ class NotificationDisplayManager {
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 = ZulipApp.navigatorKey.currentState;
if (navigator == null) return; // TODO(log) handle
_navigateForNotification(data);
}

static void _handleNotificationAppLaunch(NotificationResponse? response) async {
assert(response != null);
if (response == null) return; // TODO(log) seems like a bug in flutter_local_notifications if this can happen

final data = MessageFcmMessage.fromJson(jsonDecode(response.payload!));
assert(debugLog('launched from notif: message ${data.zulipMessageId}, content ${data.content}'));
_navigateForNotification(data);
}

static void _navigateForNotification(MessageFcmMessage data) async {
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 globalStore = GlobalStoreWidget.of(navigator.context);
final globalStore = GlobalStoreWidget.of(context);
final account = globalStore.accounts.firstWhereOrNull((account) =>
account.realmUrl == data.realmUri && account.userId == data.userId);
if (account == null) return; // TODO(log)
Expand All @@ -299,5 +319,6 @@ class NotificationDisplayManager {
page: PerAccountStoreWidget(accountId: account.id,
// TODO(#82): Open at specific message, not just conversation
child: MessageListPage(narrow: narrow))));
return;
}
}
96 changes: 67 additions & 29 deletions test/notifications_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'package:zulip/widgets/message_list.dart';
import 'package:zulip/widgets/page.dart';
import 'package:zulip/widgets/store.dart';

import 'flutter_checks.dart';
import 'model/binding.dart';
import 'example_data.dart' as eg;
import 'test_navigation.dart';
Expand Down Expand Up @@ -185,72 +186,67 @@ void main() {
group('NotificationDisplayManager open', () {
late List<Route<dynamic>> pushedRoutes;

Future<void> prepare(WidgetTester tester) async {
Future<void> prepare(WidgetTester tester, {bool early = false}) async {
await init();
pushedRoutes = [];
final testNavObserver = TestNavigatorObserver()
..onPushed = (route, prevRoute) => pushedRoutes.add(route);
await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
if (early) {
check(pushedRoutes).isEmpty();
return;
}
await tester.pump();
check(pushedRoutes).length.equals(1);
pushedRoutes.clear();
}

void openNotification(Account account, Message message) {
Future<void> openNotification(Account account, Message message) async {
final fcmMessage = messageFcmMessage(message, account: account);
testBinding.notifications.receiveNotificationResponse(NotificationResponse(
notificationResponseType: NotificationResponseType.selectedNotification,
payload: jsonEncode(fcmMessage)));
await null; // let _navigateForNotification find navigator
}

void checkOpenedMessageList({required int expectedAccountId, required Narrow expectedNarrow}) {
check(pushedRoutes).single.isA<WidgetRoute>().page
void matchesNavigation(Subject<Route> route, Account account, Message message) {
route.isA<WidgetRoute>().page
.isA<PerAccountStoreWidget>()
..accountId.equals(expectedAccountId)
..accountId.equals(account.id)
..child.isA<MessageListPage>()
.narrow.equals(expectedNarrow);
pushedRoutes.clear();
.narrow.equals(SendableNarrow.ofMessage(message,
selfUserId: account.userId));
}

void checkOpenNotification(Account account, Message message) {
openNotification(account, message);
checkOpenedMessageList(
expectedAccountId: account.id,
expectedNarrow: SendableNarrow.ofMessage(message,
selfUserId: account.userId));
Future<void> checkOpenNotification(Account account, Message message) async {
await openNotification(account, message);
matchesNavigation(check(pushedRoutes).single, account, message);
pushedRoutes.clear();
}

testWidgets('stream message', (tester) async {
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
await prepare(tester);
checkOpenNotification(eg.selfAccount, eg.streamMessage());
await checkOpenNotification(eg.selfAccount, eg.streamMessage());
});

testWidgets('direct message', (tester) async {
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
await prepare(tester);
checkOpenNotification(eg.selfAccount,
await 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());
await 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());
await openNotification(eg.otherAccount, eg.streamMessage());
check(pushedRoutes).isEmpty();
});

Expand All @@ -268,10 +264,52 @@ void main() {
}
await prepare(tester);

checkOpenNotification(accounts[0], eg.streamMessage());
checkOpenNotification(accounts[1], eg.streamMessage());
checkOpenNotification(accounts[2], eg.streamMessage());
checkOpenNotification(accounts[3], eg.streamMessage());
await checkOpenNotification(accounts[0], eg.streamMessage());
await checkOpenNotification(accounts[1], eg.streamMessage());
await checkOpenNotification(accounts[2], eg.streamMessage());
await checkOpenNotification(accounts[3], eg.streamMessage());
});

testWidgets('wait for app to become ready', (tester) async {
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
await prepare(tester, early: true);
final message = eg.streamMessage();
await openNotification(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 home route…
check(pushedRoutes).length.equals(2);
check(pushedRoutes[0]).settings.name.equals("/");
// … and then the one the notification leads to.
matchesNavigation(check(pushedRoutes[1]), eg.selfAccount, message);
});

testWidgets('at app launch', (tester) async {
// Set up a value for `getNotificationLaunchDetails` to return.
final account = eg.selfAccount;
final message = eg.streamMessage();
final response = NotificationResponse(
notificationResponseType: NotificationResponseType.selectedNotification,
payload: jsonEncode(messageFcmMessage(message, account: account)));
testBinding.notifications.appLaunchDetails =
NotificationAppLaunchDetails(true, notificationResponse: response);

// Now start the app.
testBinding.globalStore.insertAccount(account.toCompanion(false));
await prepare(tester, early: true);
check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet

// Once the app is ready, we navigate to the conversation.
await tester.pump();
check(pushedRoutes).length.equals(2);
check(pushedRoutes[0]).settings.name.equals("/");
matchesNavigation(check(pushedRoutes[1]), account, message);
});
});
}
Expand Down

0 comments on commit 681a744

Please sign in to comment.