Skip to content

Commit b21756f

Browse files
notif: Introduce URL based notif open implementation
1 parent e16885b commit b21756f

File tree

8 files changed

+123
-81
lines changed

8 files changed

+123
-81
lines changed

android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,23 +92,27 @@ data class NotificationChannel (
9292
*
9393
* See:
9494
* https://developer.android.com/reference/android/content/Intent
95+
* https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E)
9596
*
9697
* Generated class from Pigeon that represents data sent in messages.
9798
*/
9899
data class AndroidIntent (
99-
val extras: Map<String?, String?>
100+
val action: String,
101+
val uri: String
100102

101103
) {
102104
companion object {
103105
@Suppress("LocalVariableName")
104106
fun fromList(__pigeon_list: List<Any?>): AndroidIntent {
105-
val extras = __pigeon_list[0] as Map<String?, String?>
106-
return AndroidIntent(extras)
107+
val action = __pigeon_list[0] as String
108+
val uri = __pigeon_list[1] as String
109+
return AndroidIntent(action, uri)
107110
}
108111
}
109112
fun toList(): List<Any?> {
110113
return listOf(
111-
extras,
114+
action,
115+
uri,
112116
)
113117
}
114118
}

android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.zulip.flutter
33
import android.annotation.SuppressLint
44
import android.content.Context
55
import android.content.Intent
6+
import android.net.Uri
67
import android.os.Bundle
78
import android.util.Log
89
import androidx.annotation.Keep
@@ -82,13 +83,12 @@ private class AndroidNotificationHost(val context: Context)
8283
contentIntent?.let { setContentIntent(
8384
android.app.PendingIntent.getActivity(context,
8485
it.requestCode.toInt(),
85-
Intent(context, MainActivity::class.java).apply {
86-
// This action name and extra name are special to
87-
// FlutterLocalNotificationsPlugin, which handles receiving the Intent.
88-
// TODO take care of receiving the notification-opened Intent ourselves
89-
action = "SELECT_NOTIFICATION"
90-
it.intent.extras.forEach { (k, v) -> putExtra(k!!, v!!) }
91-
},
86+
it.intent.let { intent -> Intent(
87+
intent.action,
88+
Uri.parse(intent.uri),
89+
context,
90+
MainActivity::class.java
91+
) },
9292
it.flags.toInt())
9393
) }
9494
contentText?.let { setContentText(it) }

lib/host/android_notifications.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,11 @@ abstract class PendingIntentFlag {
6060
/// Corresponds to `FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT`.
6161
static const allowUnsafeImplicitIntent = 1 << 24;
6262
}
63+
64+
/// For use in [AndroidIntent.action].
65+
///
66+
/// See: https://developer.android.com/reference/android/content/Intent#constants_1
67+
abstract class IntentAction {
68+
/// Corresponds to `ACTION_VIEW`.
69+
static const view = 'android.intent.action.VIEW';
70+
}

lib/host/android_notifications.g.dart

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,23 +67,29 @@ class NotificationChannel {
6767
///
6868
/// See:
6969
/// https://developer.android.com/reference/android/content/Intent
70+
/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E)
7071
class AndroidIntent {
7172
AndroidIntent({
72-
required this.extras,
73+
required this.action,
74+
required this.uri,
7375
});
7476

75-
Map<String?, String?> extras;
77+
String action;
78+
79+
String uri;
7680

7781
Object encode() {
7882
return <Object?>[
79-
extras,
83+
action,
84+
uri,
8085
];
8186
}
8287

8388
static AndroidIntent decode(Object result) {
8489
result as List<Object?>;
8590
return AndroidIntent(
86-
extras: (result[0] as Map<Object?, Object?>?)!.cast<String?, String?>(),
91+
action: result[0]! as String,
92+
uri: result[1]! as String,
8793
);
8894
}
8995
}

lib/notifications/display.dart

Lines changed: 28 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,7 @@ class NotificationDisplayManager {
7272
const InitializationSettings(
7373
android: AndroidInitializationSettings('zulip_notification'),
7474
),
75-
onDidReceiveNotificationResponse: _onNotificationOpened,
7675
);
77-
final launchDetails = await ZulipBinding.instance.notifications.getNotificationAppLaunchDetails();
78-
if (launchDetails?.didNotificationLaunchApp ?? false) {
79-
_handleNotificationAppLaunch(launchDetails!.notificationResponse);
80-
}
8176
await NotificationChannelManager._ensureChannel();
8277
}
8378

@@ -141,6 +136,13 @@ class NotificationDisplayManager {
141136
name: data.senderFullName,
142137
iconBitmap: await _fetchBitmap(data.senderAvatarUrl))));
143138

139+
// TODO use actual Zulip narrow links.
140+
final intentUrl = Uri(
141+
scheme: 'zulip',
142+
host: 'notification',
143+
queryParameters: {'payload': jsonEncode(data.toJson())},
144+
);
145+
144146
await ZulipBinding.instance.androidNotificationHost.notify(
145147
// TODO the notification ID can be constant, instead of matching requestCode
146148
// (This is a legacy of `flutter_local_notifications`.)
@@ -156,25 +158,15 @@ class NotificationDisplayManager {
156158
messagingStyle: messagingStyle,
157159
number: messagingStyle.messages.length,
158160

159-
contentIntent: PendingIntent(
160-
// TODO make intent URLs distinct, instead of requestCode
161-
// (This way is a legacy of flutter_local_notifications.)
162-
// The Intent objects we make for different conversations look the same.
163-
// They differ in their extras, but that doesn't count:
164-
// https://developer.android.com/reference/android/app/PendingIntent
165-
//
166-
// This leaves only PendingIntent.requestCode to distinguish one
167-
// PendingIntent from another; the plugin sets that to the notification ID.
168-
// We need a distinct PendingIntent for each conversation, so that the
169-
// notifications can lead to the right conversations when opened.
170-
// So, use a hash of the conversation key.
171-
requestCode: notificationIdAsHashOf(conversationKey),
161+
contentIntent: PendingIntent(
162+
requestCode: 0,
163+
intent: AndroidIntent(
164+
action: IntentAction.view,
165+
uri: intentUrl.toString()),
172166

173167
// TODO is setting PendingIntentFlag.updateCurrent OK?
174168
// (That's a legacy of `flutter_local_notifications`.)
175169
flags: PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent,
176-
intent: AndroidIntent(
177-
extras: <String, String>{'payload': jsonEncode(dataJson)}),
178170
// TODO this doesn't set the Intent flags we set in zulip-mobile; is that OK?
179171
// (This is a legacy of `flutter_local_notifications`.)
180172
),
@@ -234,33 +226,27 @@ class NotificationDisplayManager {
234226

235227
static String _personKey(Uri realmUri, int userId) => "$realmUri|$userId";
236228

237-
static void _onNotificationOpened(NotificationResponse response) async {
238-
final payload = jsonDecode(response.payload!) as Map<String, dynamic>;
239-
final data = MessageFcmMessage.fromJson(payload);
240-
assert(debugLog('opened notif: message ${data.zulipMessageId}, content ${data.content}'));
241-
_navigateForNotification(data);
242-
}
243-
244-
static void _handleNotificationAppLaunch(NotificationResponse? response) async {
245-
assert(response != null);
246-
if (response == null) return; // TODO(log) seems like a bug in flutter_local_notifications if this can happen
247-
248-
final payload = jsonDecode(response.payload!) as Map<String, dynamic>;
249-
final data = MessageFcmMessage.fromJson(payload);
250-
assert(debugLog('launched from notif: message ${data.zulipMessageId}, content ${data.content}'));
251-
_navigateForNotification(data);
252-
}
253-
254-
static void _navigateForNotification(MessageFcmMessage data) async {
229+
static Future<void> navigateForNotification(Uri url) async {
255230
NavigatorState navigator = await ZulipApp.navigator;
256231
final context = navigator.context;
257232
assert(context.mounted);
258233
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
259234

235+
final result = getRouteForNotification(context, url);
236+
if (result == null) return; // TODO(log)
237+
await navigator.push(result.$1);
238+
}
239+
240+
static (Route<dynamic>, int)? getRouteForNotification(BuildContext context, Uri url) {
241+
assert(url.scheme == 'zulip' && url.host == 'notification');
242+
final payload = jsonDecode(url.queryParameters['payload']!) as Map<String, dynamic>;
243+
final data = MessageFcmMessage.fromJson(payload);
244+
assert(debugLog('launched from notif: message ${data.zulipMessageId}, content ${data.content}'));
245+
260246
final globalStore = GlobalStoreWidget.of(context);
261247
final account = globalStore.accounts.firstWhereOrNull((account) =>
262248
account.realmUrl == data.realmUri && account.userId == data.userId);
263-
if (account == null) return; // TODO(log)
249+
if (account == null) return null; // TODO(log)
264250

265251
final narrow = switch (data.recipient) {
266252
FcmMessageChannelRecipient(:var streamId, :var topic) =>
@@ -271,10 +257,10 @@ class NotificationDisplayManager {
271257

272258
assert(debugLog(' account: $account, narrow: $narrow'));
273259
// TODO(nav): Better interact with existing nav stack on notif open
274-
navigator.push(MaterialAccountWidgetRoute<void>(accountId: account.id,
260+
final route = MaterialAccountWidgetRoute<void>(accountId: account.id,
275261
// TODO(#82): Open at specific message, not just conversation
276-
page: MessageListPage(initNarrow: narrow)));
277-
return;
262+
page: MessageListPage(initNarrow: narrow));
263+
return (route, account.id);
278264
}
279265

280266
static Future<Uint8List?> _fetchBitmap(Uri url) async {

lib/widgets/app.dart

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
77

88
import '../model/localizations.dart';
99
import '../model/narrow.dart';
10+
import '../notifications/display.dart';
1011
import 'about_zulip.dart';
1112
import 'app_bar.dart';
1213
import 'inbox.dart';
@@ -88,11 +89,13 @@ class ZulipApp extends StatefulWidget {
8889
class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
8990
@override
9091
Future<bool> didPushRouteInformation(routeInformation) async {
91-
if (routeInformation case RouteInformation(
92-
uri: Uri(scheme: 'zulip', host: 'login') && var url)
93-
) {
94-
await LoginPage.handleWebAuthUrl(url);
95-
return true;
92+
switch (routeInformation.uri) {
93+
case Uri(scheme: 'zulip', host: 'login') && var url:
94+
await LoginPage.handleWebAuthUrl(url);
95+
return true;
96+
case Uri(scheme: 'zulip', host: 'notification') && var url:
97+
await NotificationDisplayManager.navigateForNotification(url);
98+
return true;
9699
}
97100
return super.didPushRouteInformation(routeInformation);
98101
}
@@ -143,16 +146,31 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
143146
// like [Navigator.push], never mere names as with [Navigator.pushNamed].
144147
onGenerateRoute: (_) => null,
145148

146-
onGenerateInitialRoutes: (_) {
149+
onGenerateInitialRoutes: (initialRoute) {
150+
Route<dynamic>? notificationRoute;
151+
int? accountId;
152+
if (initialRoute != '/') {
153+
final url = Uri.tryParse(initialRoute);
154+
if (url case Uri(scheme: 'zulip', host: 'notification')) {
155+
final result = NotificationDisplayManager.getRouteForNotification(context, url);
156+
if (result != null) {
157+
(notificationRoute, accountId) = result;
158+
}
159+
}
160+
}
147161
return [
148162
MaterialWidgetRoute(page: const ChooseAccountPage()),
149-
if (initialAccountId != null) ...[
163+
if (notificationRoute != null && accountId != null)...[
164+
HomePage.buildRoute(accountId: accountId),
165+
InboxPage.buildRoute(accountId: accountId),
166+
notificationRoute,
167+
] else if (initialAccountId != null) ...[
150168
HomePage.buildRoute(accountId: initialAccountId),
151169
InboxPage.buildRoute(accountId: initialAccountId),
152170
],
153171
];
154172
});
155-
}));
173+
}));
156174
}
157175
}
158176

pigeon/notifications.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ class NotificationChannel {
4040
///
4141
/// See:
4242
/// https://developer.android.com/reference/android/content/Intent
43+
/// https://developer.android.com/reference/android/content/Intent#Intent(java.lang.String,%20android.net.Uri,%20android.content.Context,%20java.lang.Class%3C?%3E)
4344
class AndroidIntent {
44-
AndroidIntent({required this.extras});
45+
AndroidIntent({required this.action, required this.uri});
4546

46-
final Map<String?, String?> extras;
47+
final String action;
48+
final String uri;
4749
}
4850

4951
/// Corresponds to `android.app.PendingIntent`.

test/notifications/display_test.dart

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import 'package:checks/checks.dart';
66
import 'package:collection/collection.dart';
77
import 'package:fake_async/fake_async.dart';
88
import 'package:firebase_messaging/firebase_messaging.dart';
9+
import 'package:flutter/services.dart';
910
import 'package:flutter/widgets.dart';
10-
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message, Person;
1111
import 'package:flutter_test/flutter_test.dart';
1212
import 'package:http/http.dart' as http;
1313
import 'package:http/testing.dart' as http_testing;
@@ -133,8 +133,14 @@ void main() {
133133
final expectedGroupKey = '${data.realmUri}|${data.userId}';
134134
final expectedId =
135135
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
136+
const expectedIntentRequestCode = 0;
136137
const expectedIntentFlags =
137138
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
139+
const expectedIntentAction = IntentAction.view;
140+
final expectedIntentUri = Uri(
141+
scheme: 'zulip',
142+
host: 'notification',
143+
queryParameters: {'payload': jsonEncode(data.toJson())}).toString();
138144
final expectedSelfUserKey = '${data.realmUri}|${data.userId}';
139145

140146
final messageStyleMessagesChecks =
@@ -177,10 +183,11 @@ void main() {
177183
..inboxStyle.isNull()
178184
..autoCancel.equals(true)
179185
..contentIntent.which((it) => it.isNotNull()
180-
..requestCode.equals(expectedId)
181-
..flags.equals(expectedIntentFlags)
186+
..requestCode.equals(expectedIntentRequestCode)
182187
..intent.which((it) => it
183-
..extras.deepEquals({'payload': jsonEncode(data.toJson())}))),
188+
..action.equals(expectedIntentAction)
189+
..uri.equals(expectedIntentUri))
190+
..flags.equals(expectedIntentFlags)),
184191
(it) => it.isA<AndroidNotificationHostApiNotifyCall>()
185192
..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedGroupKey))
186193
..tag.equals(expectedGroupKey)
@@ -536,10 +543,17 @@ void main() {
536543
}
537544

538545
Future<void> openNotification(WidgetTester tester, Account account, Message message) async {
539-
final fcmMessage = messageFcmMessage(message, account: account);
540-
testBinding.notifications.receiveNotificationResponse(NotificationResponse(
541-
notificationResponseType: NotificationResponseType.selectedNotification,
542-
payload: jsonEncode(fcmMessage)));
546+
final data = messageFcmMessage(message, account: account);
547+
final url = Uri(
548+
scheme: 'zulip',
549+
host: 'notification',
550+
queryParameters: {'payload': jsonEncode(data.toJson())}).toString();
551+
552+
final ByteData platformMessage = const JSONMethodCodec().encodeMethodCall(
553+
MethodCall('pushRouteInformation', {'location': url.toString()}));
554+
tester.binding.defaultBinaryMessenger.handlePlatformMessage(
555+
'flutter/navigation', platformMessage, null);
556+
543557
await tester.idle(); // let _navigateForNotification find navigator
544558
}
545559

@@ -628,11 +642,14 @@ void main() {
628642
// Set up a value for `getNotificationLaunchDetails` to return.
629643
final account = eg.selfAccount;
630644
final message = eg.streamMessage();
631-
final response = NotificationResponse(
632-
notificationResponseType: NotificationResponseType.selectedNotification,
633-
payload: jsonEncode(messageFcmMessage(message, account: account)));
634-
testBinding.notifications.appLaunchDetails =
635-
NotificationAppLaunchDetails(true, notificationResponse: response);
645+
646+
final data = messageFcmMessage(message, account: account);
647+
final url = Uri(
648+
scheme: 'zulip',
649+
host: 'notification',
650+
queryParameters: {'payload': jsonEncode(data.toJson())});
651+
652+
tester.binding.platformDispatcher.defaultRouteNameTestValue = url.toString();
636653

637654
// Now start the app.
638655
testBinding.globalStore.add(account, eg.initialSnapshot());
@@ -674,7 +691,8 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
674691
}
675692

676693
extension on Subject<AndroidIntent> {
677-
Subject<Map<String?,String?>> get extras => has((x) => x.extras, 'extras');
694+
Subject<String> get action => has((x) => x.action, 'action');
695+
Subject<String> get uri => has((x) => x.uri, 'uri');
678696
}
679697

680698
extension on Subject<PendingIntent> {

0 commit comments

Comments
 (0)