Skip to content

Commit 85de0bf

Browse files
committed
notif: Navigate to conversation when app launched from notif, too
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
1 parent ce08309 commit 85de0bf

File tree

2 files changed

+91
-32
lines changed

2 files changed

+91
-32
lines changed

lib/notifications.dart

+24-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'package:collection/collection.dart';
44
import 'package:crypto/crypto.dart';
55
import 'package:flutter/foundation.dart';
6+
import 'package:flutter/widgets.dart';
67
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
78

89
import 'api/notifications.dart';
@@ -194,6 +195,10 @@ class NotificationDisplayManager {
194195
),
195196
onDidReceiveNotificationResponse: _onNotificationOpened,
196197
);
198+
final launchDetails = await ZulipBinding.instance.notifications.getNotificationAppLaunchDetails();
199+
if (launchDetails?.didNotificationLaunchApp ?? false) {
200+
_handleNotificationAppLaunch(launchDetails!.notificationResponse);
201+
}
197202
await NotificationChannelManager._ensureChannel();
198203
}
199204

@@ -278,10 +283,25 @@ class NotificationDisplayManager {
278283
static void _onNotificationOpened(NotificationResponse response) async {
279284
final data = MessageFcmMessage.fromJson(jsonDecode(response.payload!));
280285
assert(debugLog('opened notif: message ${data.zulipMessageId}, content ${data.content}'));
281-
final navigator = ZulipApp.navigatorKey.currentState;
282-
if (navigator == null) return; // TODO(log) handle
286+
_navigateForNotification(data);
287+
}
288+
289+
static void _handleNotificationAppLaunch(NotificationResponse? response) async {
290+
assert(response != null);
291+
if (response == null) return; // TODO(log) seems like a bug in flutter_local_notifications if this can happen
292+
293+
final data = MessageFcmMessage.fromJson(jsonDecode(response.payload!));
294+
assert(debugLog('launched from notif: message ${data.zulipMessageId}, content ${data.content}'));
295+
_navigateForNotification(data);
296+
}
297+
298+
static void _navigateForNotification(MessageFcmMessage data) async {
299+
NavigatorState navigator = await ZulipApp.navigator;
300+
final context = navigator.context;
301+
assert(context.mounted);
302+
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
283303

284-
final globalStore = GlobalStoreWidget.of(navigator.context);
304+
final globalStore = GlobalStoreWidget.of(context);
285305
final account = globalStore.accounts.firstWhereOrNull((account) =>
286306
account.realmUrl == data.realmUri && account.userId == data.userId);
287307
if (account == null) return; // TODO(log)
@@ -299,5 +319,6 @@ class NotificationDisplayManager {
299319
page: PerAccountStoreWidget(accountId: account.id,
300320
// TODO(#82): Open at specific message, not just conversation
301321
child: MessageListPage(narrow: narrow))));
322+
return;
302323
}
303324
}

test/notifications_test.dart

+67-29
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import 'package:zulip/widgets/message_list.dart';
1616
import 'package:zulip/widgets/page.dart';
1717
import 'package:zulip/widgets/store.dart';
1818

19+
import 'flutter_checks.dart';
1920
import 'model/binding.dart';
2021
import 'example_data.dart' as eg;
2122
import 'test_navigation.dart';
@@ -185,72 +186,67 @@ void main() {
185186
group('NotificationDisplayManager open', () {
186187
late List<Route<dynamic>> pushedRoutes;
187188

188-
Future<void> prepare(WidgetTester tester) async {
189+
Future<void> prepare(WidgetTester tester, {bool early = false}) async {
189190
await init();
190191
pushedRoutes = [];
191192
final testNavObserver = TestNavigatorObserver()
192193
..onPushed = (route, prevRoute) => pushedRoutes.add(route);
193194
await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
195+
if (early) {
196+
check(pushedRoutes).isEmpty();
197+
return;
198+
}
194199
await tester.pump();
195200
check(pushedRoutes).length.equals(1);
196201
pushedRoutes.clear();
197202
}
198203

199-
void openNotification(Account account, Message message) {
204+
Future<void> openNotification(Account account, Message message) async {
200205
final fcmMessage = messageFcmMessage(message, account: account);
201206
testBinding.notifications.receiveNotificationResponse(NotificationResponse(
202207
notificationResponseType: NotificationResponseType.selectedNotification,
203208
payload: jsonEncode(fcmMessage)));
209+
await null; // let _navigateForNotification find navigator
204210
}
205211

206-
void checkOpenedMessageList({required int expectedAccountId, required Narrow expectedNarrow}) {
207-
check(pushedRoutes).single.isA<WidgetRoute>().page
212+
void matchesNavigation(Subject<Route> route, Account account, Message message) {
213+
route.isA<WidgetRoute>().page
208214
.isA<PerAccountStoreWidget>()
209-
..accountId.equals(expectedAccountId)
215+
..accountId.equals(account.id)
210216
..child.isA<MessageListPage>()
211-
.narrow.equals(expectedNarrow);
212-
pushedRoutes.clear();
217+
.narrow.equals(SendableNarrow.ofMessage(message,
218+
selfUserId: account.userId));
213219
}
214220

215-
void checkOpenNotification(Account account, Message message) {
216-
openNotification(account, message);
217-
checkOpenedMessageList(
218-
expectedAccountId: account.id,
219-
expectedNarrow: SendableNarrow.ofMessage(message,
220-
selfUserId: account.userId));
221+
Future<void> checkOpenNotification(Account account, Message message) async {
222+
await openNotification(account, message);
223+
matchesNavigation(check(pushedRoutes).single, account, message);
224+
pushedRoutes.clear();
221225
}
222226

223227
testWidgets('stream message', (tester) async {
224228
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
225229
await prepare(tester);
226-
checkOpenNotification(eg.selfAccount, eg.streamMessage());
230+
await checkOpenNotification(eg.selfAccount, eg.streamMessage());
227231
});
228232

229233
testWidgets('direct message', (tester) async {
230234
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
231235
await prepare(tester);
232-
checkOpenNotification(eg.selfAccount,
236+
await checkOpenNotification(eg.selfAccount,
233237
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]));
234238
});
235239

236-
testWidgets('no widgets in tree', (tester) async {
237-
await init();
238-
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
239-
240-
openNotification(eg.selfAccount, message);
241-
// nothing happened, but nothing blew up
242-
});
243-
244240
testWidgets('no accounts', (tester) async {
245241
await prepare(tester);
246-
openNotification(eg.selfAccount, eg.streamMessage());
242+
await openNotification(eg.selfAccount, eg.streamMessage());
247243
check(pushedRoutes).isEmpty();
248244
});
249245

250246
testWidgets('mismatching account', (tester) async {
251247
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
252248
await prepare(tester);
253-
openNotification(eg.otherAccount, eg.streamMessage());
249+
await openNotification(eg.otherAccount, eg.streamMessage());
254250
check(pushedRoutes).isEmpty();
255251
});
256252

@@ -268,10 +264,52 @@ void main() {
268264
}
269265
await prepare(tester);
270266

271-
checkOpenNotification(accounts[0], eg.streamMessage());
272-
checkOpenNotification(accounts[1], eg.streamMessage());
273-
checkOpenNotification(accounts[2], eg.streamMessage());
274-
checkOpenNotification(accounts[3], eg.streamMessage());
267+
await checkOpenNotification(accounts[0], eg.streamMessage());
268+
await checkOpenNotification(accounts[1], eg.streamMessage());
269+
await checkOpenNotification(accounts[2], eg.streamMessage());
270+
await checkOpenNotification(accounts[3], eg.streamMessage());
271+
});
272+
273+
testWidgets('wait for app to become ready', (tester) async {
274+
testBinding.globalStore.insertAccount(eg.selfAccount.toCompanion(false));
275+
await prepare(tester, early: true);
276+
final message = eg.streamMessage();
277+
await openNotification(eg.selfAccount, message);
278+
// The app should still not be ready (or else this test won't work right).
279+
check(ZulipApp.ready.value).isFalse();
280+
check(ZulipApp.navigatorKey.currentState).isNull();
281+
// And the openNotification hasn't caused any navigation yet.
282+
check(pushedRoutes).isEmpty();
283+
284+
// Now let the GlobalStore get loaded and the app's main UI get mounted.
285+
await tester.pump();
286+
// The navigator first pushes the home route…
287+
check(pushedRoutes).length.equals(2);
288+
check(pushedRoutes[0]).settings.name.equals("/");
289+
// … and then the one the notification leads to.
290+
matchesNavigation(check(pushedRoutes[1]), eg.selfAccount, message);
291+
});
292+
293+
testWidgets('at app launch', (tester) async {
294+
// Set up a value for `getNotificationLaunchDetails` to return.
295+
final account = eg.selfAccount;
296+
final message = eg.streamMessage();
297+
final response = NotificationResponse(
298+
notificationResponseType: NotificationResponseType.selectedNotification,
299+
payload: jsonEncode(messageFcmMessage(message, account: account)));
300+
testBinding.notifications.appLaunchDetails =
301+
NotificationAppLaunchDetails(true, notificationResponse: response);
302+
303+
// Now start the app.
304+
testBinding.globalStore.insertAccount(account.toCompanion(false));
305+
await prepare(tester, early: true);
306+
check(pushedRoutes).isEmpty(); // GlobalStore hasn't loaded yet
307+
308+
// Once the app is ready, we navigate to the conversation.
309+
await tester.pump();
310+
check(pushedRoutes).length.equals(2);
311+
check(pushedRoutes[0]).settings.name.equals("/");
312+
matchesNavigation(check(pushedRoutes[1]), account, message);
275313
});
276314
});
277315
}

0 commit comments

Comments
 (0)