Skip to content

Commit 2ad33fd

Browse files
notif: Introduce URL based notif open implementation
1 parent 49a8fc8 commit 2ad33fd

File tree

8 files changed

+124
-82
lines changed

8 files changed

+124
-82
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
@@ -75,12 +75,7 @@ class NotificationDisplayManager {
7575
const InitializationSettings(
7676
android: AndroidInitializationSettings('zulip_notification'),
7777
),
78-
onDidReceiveNotificationResponse: _onNotificationOpened,
7978
);
80-
final launchDetails = await ZulipBinding.instance.notifications.getNotificationAppLaunchDetails();
81-
if (launchDetails?.didNotificationLaunchApp ?? false) {
82-
_handleNotificationAppLaunch(launchDetails!.notificationResponse);
83-
}
8479
await NotificationChannelManager._ensureChannel();
8580
}
8681

@@ -144,6 +139,13 @@ class NotificationDisplayManager {
144139
name: data.senderFullName,
145140
iconBitmap: await _fetchBitmap(data.senderAvatarUrl))));
146141

142+
// TODO use actual Zulip narrow links.
143+
final intentUrl = Uri(
144+
scheme: 'zulip',
145+
host: 'notification',
146+
queryParameters: {'payload': jsonEncode(data.toJson())},
147+
);
148+
147149
await _androidHost.notify(
148150
// TODO the notification ID can be constant, instead of matching requestCode
149151
// (This is a legacy of `flutter_local_notifications`.)
@@ -163,25 +165,15 @@ class NotificationDisplayManager {
163165
kExtraLastZulipMessageId: data.zulipMessageId.toString(),
164166
},
165167

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

180174
// TODO is setting PendingIntentFlag.updateCurrent OK?
181175
// (That's a legacy of `flutter_local_notifications`.)
182176
flags: PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent,
183-
intent: AndroidIntent(
184-
extras: <String, String>{'payload': jsonEncode(dataJson)}),
185177
// TODO this doesn't set the Intent flags we set in zulip-mobile; is that OK?
186178
// (This is a legacy of `flutter_local_notifications`.)
187179
),
@@ -318,33 +310,27 @@ class NotificationDisplayManager {
318310

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

321-
static void _onNotificationOpened(NotificationResponse response) async {
322-
final payload = jsonDecode(response.payload!) as Map<String, dynamic>;
323-
final data = MessageFcmMessage.fromJson(payload);
324-
assert(debugLog('opened notif: message ${data.zulipMessageId}, content ${data.content}'));
325-
_navigateForNotification(data);
326-
}
327-
328-
static void _handleNotificationAppLaunch(NotificationResponse? response) async {
329-
assert(response != null);
330-
if (response == null) return; // TODO(log) seems like a bug in flutter_local_notifications if this can happen
331-
332-
final payload = jsonDecode(response.payload!) as Map<String, dynamic>;
333-
final data = MessageFcmMessage.fromJson(payload);
334-
assert(debugLog('launched from notif: message ${data.zulipMessageId}, content ${data.content}'));
335-
_navigateForNotification(data);
336-
}
337-
338-
static void _navigateForNotification(MessageFcmMessage data) async {
313+
static Future<void> navigateForNotification(Uri url) async {
339314
NavigatorState navigator = await ZulipApp.navigator;
340315
final context = navigator.context;
341316
assert(context.mounted);
342317
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
343318

319+
final result = getRouteForNotification(context, url);
320+
if (result == null) return; // TODO(log)
321+
await navigator.push(result.$1);
322+
}
323+
324+
static (Route<dynamic>, int)? getRouteForNotification(BuildContext context, Uri url) {
325+
assert(url.scheme == 'zulip' && url.host == 'notification');
326+
final payload = jsonDecode(url.queryParameters['payload']!) as Map<String, dynamic>;
327+
final data = MessageFcmMessage.fromJson(payload);
328+
assert(debugLog('launched from notif: message ${data.zulipMessageId}, content ${data.content}'));
329+
344330
final globalStore = GlobalStoreWidget.of(context);
345331
final account = globalStore.accounts.firstWhereOrNull((account) =>
346332
account.realmUrl == data.realmUri && account.userId == data.userId);
347-
if (account == null) return; // TODO(log)
333+
if (account == null) return null; // TODO(log)
348334

349335
final narrow = switch (data.recipient) {
350336
FcmMessageChannelRecipient(:var streamId, :var topic) =>
@@ -355,10 +341,10 @@ class NotificationDisplayManager {
355341

356342
assert(debugLog(' account: $account, narrow: $narrow'));
357343
// TODO(nav): Better interact with existing nav stack on notif open
358-
navigator.push(MaterialAccountWidgetRoute<void>(accountId: account.id,
344+
final route = MaterialAccountWidgetRoute<void>(accountId: account.id,
359345
// TODO(#82): Open at specific message, not just conversation
360-
page: MessageListPage(initNarrow: narrow)));
361-
return;
346+
page: MessageListPage(initNarrow: narrow));
347+
return (route, account.id);
362348
}
363349

364350
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: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ 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/widgets.dart' hide Notification;
10-
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message, Person;
119
import 'package:flutter_test/flutter_test.dart';
10+
import 'package:flutter/services.dart';
11+
import 'package:flutter/widgets.dart' hide Notification;
1212
import 'package:http/http.dart' as http;
1313
import 'package:http/testing.dart' as http_testing;
1414
import 'package:zulip/api/model/model.dart';
@@ -148,8 +148,14 @@ void main() {
148148
final expectedGroupKey = '${data.realmUri}|${data.userId}';
149149
final expectedId =
150150
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
151+
const expectedIntentRequestCode = 0;
151152
const expectedIntentFlags =
152153
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
154+
const expectedIntentAction = IntentAction.view;
155+
final expectedIntentUri = Uri(
156+
scheme: 'zulip',
157+
host: 'notification',
158+
queryParameters: {'payload': jsonEncode(data.toJson())}).toString();
153159
final expectedSelfUserKey = '${data.realmUri}|${data.userId}';
154160

155161
final messageStyleMessagesChecks =
@@ -195,10 +201,11 @@ void main() {
195201
..inboxStyle.isNull()
196202
..autoCancel.equals(true)
197203
..contentIntent.which((it) => it.isNotNull()
198-
..requestCode.equals(expectedId)
199-
..flags.equals(expectedIntentFlags)
204+
..requestCode.equals(expectedIntentRequestCode)
200205
..intent.which((it) => it
201-
..extras.deepEquals({'payload': jsonEncode(data.toJson())}))),
206+
..action.equals(expectedIntentAction)
207+
..uri.equals(expectedIntentUri))
208+
..flags.equals(expectedIntentFlags)),
202209
(it) => it.isA<AndroidNotificationHostApiNotifyCall>()
203210
..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedGroupKey))
204211
..tag.equals(expectedGroupKey)
@@ -760,10 +767,17 @@ void main() {
760767
}
761768

762769
Future<void> openNotification(WidgetTester tester, Account account, Message message) async {
763-
final fcmMessage = messageFcmMessage(message, account: account);
764-
testBinding.notifications.receiveNotificationResponse(NotificationResponse(
765-
notificationResponseType: NotificationResponseType.selectedNotification,
766-
payload: jsonEncode(fcmMessage)));
770+
final data = messageFcmMessage(message, account: account);
771+
final url = Uri(
772+
scheme: 'zulip',
773+
host: 'notification',
774+
queryParameters: {'payload': jsonEncode(data.toJson())}).toString();
775+
776+
final ByteData platformMessage = const JSONMethodCodec().encodeMethodCall(
777+
MethodCall('pushRouteInformation', {'location': url.toString()}));
778+
tester.binding.defaultBinaryMessenger.handlePlatformMessage(
779+
'flutter/navigation', platformMessage, null);
780+
767781
await tester.idle(); // let _navigateForNotification find navigator
768782
}
769783

@@ -852,11 +866,14 @@ void main() {
852866
// Set up a value for `getNotificationLaunchDetails` to return.
853867
final account = eg.selfAccount;
854868
final message = eg.streamMessage();
855-
final response = NotificationResponse(
856-
notificationResponseType: NotificationResponseType.selectedNotification,
857-
payload: jsonEncode(messageFcmMessage(message, account: account)));
858-
testBinding.notifications.appLaunchDetails =
859-
NotificationAppLaunchDetails(true, notificationResponse: response);
869+
870+
final data = messageFcmMessage(message, account: account);
871+
final url = Uri(
872+
scheme: 'zulip',
873+
host: 'notification',
874+
queryParameters: {'payload': jsonEncode(data.toJson())});
875+
876+
tester.binding.platformDispatcher.defaultRouteNameTestValue = url.toString();
860877

861878
// Now start the app.
862879
testBinding.globalStore.add(account, eg.initialSnapshot());
@@ -898,7 +915,8 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
898915
}
899916

900917
extension on Subject<AndroidIntent> {
901-
Subject<Map<String?,String?>> get extras => has((x) => x.extras, 'extras');
918+
Subject<String> get action => has((x) => x.action, 'action');
919+
Subject<String> get uri => has((x) => x.uri, 'uri');
902920
}
903921

904922
extension on Subject<PendingIntent> {

0 commit comments

Comments
 (0)