Skip to content

Commit 80a89e3

Browse files
notif [nfc]: Introduce NotificationNavigationServer
And move the notification navigation data parsing utilities to the new class.
1 parent 1f5e518 commit 80a89e3

File tree

4 files changed

+176
-141
lines changed

4 files changed

+176
-141
lines changed

lib/notifications/display.dart

Lines changed: 7 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,16 @@ import 'package:http/http.dart' as http;
88

99
import '../api/model/model.dart';
1010
import '../api/notifications.dart';
11-
import '../generated/l10n/zulip_localizations.dart';
1211
import '../host/android_notifications.dart';
1312
import '../log.dart';
1413
import '../model/binding.dart';
1514
import '../model/localizations.dart';
1615
import '../model/narrow.dart';
1716
import '../widgets/app.dart';
1817
import '../widgets/color.dart';
19-
import '../widgets/dialog.dart';
2018
import '../widgets/message_list.dart';
21-
import '../widgets/page.dart';
22-
import '../widgets/store.dart';
2319
import '../widgets/theme.dart';
20+
import 'navigate.dart';
2421

2522
AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost;
2623

@@ -481,42 +478,6 @@ class NotificationDisplayManager {
481478

482479
static String _personKey(Uri realmUrl, int userId) => "$realmUrl|$userId";
483480

484-
/// Provides the route and the account ID by parsing the notification URL.
485-
///
486-
/// The URL must have been generated using [NotificationOpenPayload.buildUrl]
487-
/// while creating the notification.
488-
///
489-
/// Returns null and shows an error dialog if the associated account is not
490-
/// found in the global store.
491-
static AccountRoute<void>? routeForNotification({
492-
required BuildContext context,
493-
required Uri url,
494-
}) {
495-
assert(defaultTargetPlatform == TargetPlatform.android);
496-
497-
final globalStore = GlobalStoreWidget.of(context);
498-
499-
assert(debugLog('got notif: url: $url'));
500-
assert(url.scheme == 'zulip' && url.host == 'notification');
501-
final payload = NotificationNavigationData.parseAndroidNotificationUrl(url);
502-
503-
final account = globalStore.accounts.firstWhereOrNull(
504-
(account) => account.realmUrl.origin == payload.realmUrl.origin
505-
&& account.userId == payload.userId);
506-
if (account == null) { // TODO(log)
507-
final zulipLocalizations = ZulipLocalizations.of(context);
508-
showErrorDialog(context: context,
509-
title: zulipLocalizations.errorNotificationOpenTitle,
510-
message: zulipLocalizations.errorNotificationOpenAccountNotFound);
511-
return null;
512-
}
513-
514-
return MessageListPage.buildRoute(
515-
accountId: account.id,
516-
// TODO(#82): Open at specific message, not just conversation
517-
narrow: payload.narrow);
518-
}
519-
520481
/// Navigates to the [MessageListPage] of the specific conversation
521482
/// given the `zulip://notification/…` Android intent data URL,
522483
/// generated with [NotificationOpenPayload.buildUrl] while creating
@@ -530,7 +491,12 @@ class NotificationDisplayManager {
530491
assert(context.mounted);
531492
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
532493

533-
final route = routeForNotification(context: context, url: url);
494+
assert(url.scheme == 'zulip' && url.host == 'notification');
495+
final payload =
496+
NotificationNavigationService.tryParseAndroidNotificationUrl(context, url);
497+
if (payload == null) return; // TODO(log)
498+
499+
final route = NotificationNavigationService.routeForNotification(context, payload);
534500
if (route == null) return; // TODO(log)
535501

536502
// TODO(nav): Better interact with existing nav stack on notif open
@@ -550,88 +516,3 @@ class NotificationDisplayManager {
550516
return null;
551517
}
552518
}
553-
554-
/// The data from a notification that describes what to do
555-
/// when the user opens the notification.
556-
class NotificationNavigationData {
557-
final Uri realmUrl;
558-
final int userId;
559-
final SendableNarrow narrow;
560-
561-
NotificationNavigationData({
562-
required this.realmUrl,
563-
required this.userId,
564-
required this.narrow,
565-
});
566-
567-
/// Parses the internal Android notification url, that was created using
568-
/// [buildAndroidNotificationUrl], and retrieves the information required
569-
/// for navigation.
570-
factory NotificationNavigationData.parseAndroidNotificationUrl(Uri url) {
571-
if (url case Uri(
572-
scheme: 'zulip',
573-
host: 'notification',
574-
queryParameters: {
575-
'realm_url': var realmUrlStr,
576-
'user_id': var userIdStr,
577-
'narrow_type': var narrowType,
578-
// In case of narrowType == 'topic':
579-
// 'channel_id' and 'topic' handled below.
580-
581-
// In case of narrowType == 'dm':
582-
// 'all_recipient_ids' handled below.
583-
},
584-
)) {
585-
final realmUrl = Uri.parse(realmUrlStr);
586-
final userId = int.parse(userIdStr, radix: 10);
587-
588-
final SendableNarrow narrow;
589-
switch (narrowType) {
590-
case 'topic':
591-
final channelIdStr = url.queryParameters['channel_id']!;
592-
final channelId = int.parse(channelIdStr, radix: 10);
593-
final topicStr = url.queryParameters['topic']!;
594-
narrow = TopicNarrow(channelId, TopicName(topicStr));
595-
case 'dm':
596-
final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!;
597-
final allRecipientIds = allRecipientIdsStr.split(',')
598-
.map((idStr) => int.parse(idStr, radix: 10))
599-
.toList(growable: false);
600-
narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId);
601-
default:
602-
throw const FormatException();
603-
}
604-
605-
return NotificationNavigationData(
606-
realmUrl: realmUrl,
607-
userId: userId,
608-
narrow: narrow,
609-
);
610-
} else {
611-
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
612-
throw const FormatException();
613-
}
614-
}
615-
616-
Uri buildAndroidNotificationUrl() {
617-
return Uri(
618-
scheme: 'zulip',
619-
host: 'notification',
620-
queryParameters: <String, String>{
621-
'realm_url': realmUrl.toString(),
622-
'user_id': userId.toString(),
623-
...(switch (narrow) {
624-
TopicNarrow(streamId: var channelId, :var topic) => {
625-
'narrow_type': 'topic',
626-
'channel_id': channelId.toString(),
627-
'topic': topic.apiName,
628-
},
629-
DmNarrow(:var allRecipientIds) => {
630-
'narrow_type': 'dm',
631-
'all_recipient_ids': allRecipientIds.join(','),
632-
},
633-
})
634-
},
635-
);
636-
}
637-
}

lib/notifications/navigate.dart

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import 'package:collection/collection.dart';
2+
import 'package:flutter/material.dart';
3+
4+
import '../api/model/model.dart';
5+
import '../generated/l10n/zulip_localizations.dart';
6+
import '../log.dart';
7+
import '../model/narrow.dart';
8+
import '../widgets/dialog.dart';
9+
import '../widgets/message_list.dart';
10+
import '../widgets/page.dart';
11+
import '../widgets/store.dart';
12+
13+
/// Service for handling notification navigation.
14+
class NotificationNavigationService {
15+
16+
/// Provides the route to open by parsing the notification payload.
17+
///
18+
/// Returns null and shows an error dialog if the associated account is not
19+
/// found in the global store.
20+
static AccountRoute<void>? routeForNotification(
21+
BuildContext context,
22+
NotificationNavigationData data,
23+
) {
24+
final globalStore = GlobalStoreWidget.of(context);
25+
26+
final account = globalStore.accounts.firstWhereOrNull(
27+
(account) => account.realmUrl.origin == data.realmUrl.origin
28+
&& account.userId == data.userId);
29+
if (account == null) { // TODO(log)
30+
final zulipLocalizations = ZulipLocalizations.of(context);
31+
showErrorDialog(context: context,
32+
title: zulipLocalizations.errorNotificationOpenTitle,
33+
message: zulipLocalizations.errorNotificationOpenAccountNotFound);
34+
return null;
35+
}
36+
37+
return MessageListPage.buildRoute(
38+
accountId: account.id,
39+
// TODO(#82): Open at specific message, not just conversation
40+
narrow: data.narrow);
41+
}
42+
43+
static NotificationNavigationData? tryParseAndroidNotificationUrl(
44+
BuildContext context,
45+
Uri url,
46+
) {
47+
try {
48+
return NotificationNavigationData.parseAndroidNotificationUrl(url);
49+
} on FormatException catch (e, st) {
50+
assert(debugLog('$e\n$st'));
51+
final zulipLocalizations = ZulipLocalizations.of(context);
52+
showErrorDialog(context: context,
53+
title: zulipLocalizations.errorNotificationOpenTitle);
54+
return null;
55+
}
56+
}
57+
}
58+
59+
/// The data from a notification that describes what to do
60+
/// when the user opens the notification.
61+
class NotificationNavigationData {
62+
final Uri realmUrl;
63+
final int userId;
64+
final SendableNarrow narrow;
65+
66+
NotificationNavigationData({
67+
required this.realmUrl,
68+
required this.userId,
69+
required this.narrow,
70+
});
71+
72+
/// Parses the internal Android notification url, that was created using
73+
/// [buildAndroidNotificationUrl], and retrieves the information required
74+
/// for navigation.
75+
factory NotificationNavigationData.parseAndroidNotificationUrl(Uri url) {
76+
if (url case Uri(
77+
scheme: 'zulip',
78+
host: 'notification',
79+
queryParameters: {
80+
'realm_url': var realmUrlStr,
81+
'user_id': var userIdStr,
82+
'narrow_type': var narrowType,
83+
// In case of narrowType == 'topic':
84+
// 'channel_id' and 'topic' handled below.
85+
86+
// In case of narrowType == 'dm':
87+
// 'all_recipient_ids' handled below.
88+
},
89+
)) {
90+
final realmUrl = Uri.parse(realmUrlStr);
91+
final userId = int.parse(userIdStr, radix: 10);
92+
93+
final SendableNarrow narrow;
94+
switch (narrowType) {
95+
case 'topic':
96+
final channelIdStr = url.queryParameters['channel_id']!;
97+
final channelId = int.parse(channelIdStr, radix: 10);
98+
final topicStr = url.queryParameters['topic']!;
99+
narrow = TopicNarrow(channelId, TopicName(topicStr));
100+
case 'dm':
101+
final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!;
102+
final allRecipientIds = allRecipientIdsStr.split(',')
103+
.map((idStr) => int.parse(idStr, radix: 10))
104+
.toList(growable: false);
105+
narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId);
106+
default:
107+
throw const FormatException();
108+
}
109+
110+
return NotificationNavigationData(
111+
realmUrl: realmUrl,
112+
userId: userId,
113+
narrow: narrow,
114+
);
115+
} else {
116+
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
117+
throw const FormatException();
118+
}
119+
}
120+
121+
Uri buildAndroidNotificationUrl() {
122+
return Uri(
123+
scheme: 'zulip',
124+
host: 'notification',
125+
queryParameters: <String, String>{
126+
'realm_url': realmUrl.toString(),
127+
'user_id': userId.toString(),
128+
...(switch (narrow) {
129+
TopicNarrow(streamId: var channelId, :var topic) => {
130+
'narrow_type': 'topic',
131+
'channel_id': channelId.toString(),
132+
'topic': topic.apiName,
133+
},
134+
DmNarrow(:var allRecipientIds) => {
135+
'narrow_type': 'dm',
136+
'all_recipient_ids': allRecipientIds.join(','),
137+
},
138+
})
139+
},
140+
);
141+
}
142+
}

lib/widgets/app.dart

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import '../model/actions.dart';
1010
import '../model/localizations.dart';
1111
import '../model/store.dart';
1212
import '../notifications/display.dart';
13+
import '../notifications/navigate.dart';
1314
import 'about_zulip.dart';
1415
import 'dialog.dart';
1516
import 'home.dart';
@@ -168,27 +169,37 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
168169
super.dispose();
169170
}
170171

172+
AccountRoute<void>? _initialRouteAndroid(
173+
BuildContext context,
174+
String initialRoute,
175+
) {
176+
final initialRouteUrl = Uri.tryParse(initialRoute);
177+
if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) {
178+
final data =
179+
NotificationNavigationService.tryParseAndroidNotificationUrl(context, initialRouteUrl);
180+
if (data == null) return null;
181+
182+
return NotificationNavigationService.routeForNotification(context, data);
183+
}
184+
185+
return null;
186+
}
187+
171188
List<Route<dynamic>> _handleGenerateInitialRoutes(String initialRoute) {
172189
// The `_ZulipAppState.context` lacks the required ancestors. Instead
173190
// we use the Navigator which should be available when this callback is
174191
// called and it's context should have the required ancestors.
175192
final context = ZulipApp.navigatorKey.currentContext!;
176193

177-
final initialRouteUrl = Uri.tryParse(initialRoute);
178-
if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) {
179-
final route = NotificationDisplayManager.routeForNotification(
180-
context: context,
181-
url: initialRouteUrl);
182-
183-
if (route != null) {
184-
return [
185-
HomePage.buildRoute(accountId: route.accountId),
186-
route,
187-
];
188-
} else {
189-
// The account didn't match any existing accounts,
190-
// fall through to show the default route below.
191-
}
194+
final route = _initialRouteAndroid(context, initialRoute);
195+
if (route != null) {
196+
return [
197+
HomePage.buildRoute(accountId: route.accountId),
198+
route,
199+
];
200+
} else {
201+
// The account didn't match any existing accounts,
202+
// fall through to show the default route below.
192203
}
193204

194205
final globalStore = GlobalStoreWidget.of(context);

test/notifications/display_test.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'package:zulip/model/localizations.dart';
1818
import 'package:zulip/model/narrow.dart';
1919
import 'package:zulip/model/store.dart';
2020
import 'package:zulip/notifications/display.dart';
21+
import 'package:zulip/notifications/navigate.dart';
2122
import 'package:zulip/notifications/receive.dart';
2223
import 'package:zulip/widgets/app.dart';
2324
import 'package:zulip/widgets/color.dart';

0 commit comments

Comments
 (0)