Skip to content

Commit af7dbc3

Browse files
notif: Introduce URL based notif open implementation
1 parent b96d191 commit af7dbc3

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

@@ -143,6 +138,13 @@ class NotificationDisplayManager {
143138
name: data.senderFullName,
144139
iconBitmap: await _fetchBitmap(data.senderAvatarUrl))));
145140

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

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

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

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

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

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

348334
final narrow = switch (data.recipient) {
349335
FcmMessageChannelRecipient(:var streamId, :var topic) =>
@@ -354,10 +340,10 @@ class NotificationDisplayManager {
354340

355341
assert(debugLog(' account: $account, narrow: $narrow'));
356342
// TODO(nav): Better interact with existing nav stack on notif open
357-
navigator.push(MaterialAccountWidgetRoute<void>(accountId: account.id,
343+
final route = MaterialAccountWidgetRoute<void>(accountId: account.id,
358344
// TODO(#82): Open at specific message, not just conversation
359-
page: MessageListPage(initNarrow: narrow)));
360-
return;
345+
page: MessageListPage(initNarrow: narrow));
346+
return (route, account.id);
361347
}
362348

363349
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';
@@ -147,8 +147,14 @@ void main() {
147147
final expectedGroupKey = '${data.realmUri}|${data.userId}';
148148
final expectedId =
149149
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
150+
const expectedIntentRequestCode = 0;
150151
const expectedIntentFlags =
151152
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
153+
const expectedIntentAction = IntentAction.view;
154+
final expectedIntentUri = Uri(
155+
scheme: 'zulip',
156+
host: 'notification',
157+
queryParameters: {'payload': jsonEncode(data.toJson())}).toString();
152158
final expectedSelfUserKey = '${data.realmUri}|${data.userId}';
153159

154160
final messageStyleMessagesChecks =
@@ -194,10 +200,11 @@ void main() {
194200
..inboxStyle.isNull()
195201
..autoCancel.equals(true)
196202
..contentIntent.which((it) => it.isNotNull()
197-
..requestCode.equals(expectedId)
198-
..flags.equals(expectedIntentFlags)
203+
..requestCode.equals(expectedIntentRequestCode)
199204
..intent.which((it) => it
200-
..extras.deepEquals({'payload': jsonEncode(data.toJson())}))),
205+
..action.equals(expectedIntentAction)
206+
..uri.equals(expectedIntentUri))
207+
..flags.equals(expectedIntentFlags)),
201208
(it) => it.isA<AndroidNotificationHostApiNotifyCall>()
202209
..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedGroupKey))
203210
..tag.equals(expectedGroupKey)
@@ -759,10 +766,17 @@ void main() {
759766
}
760767

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

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

860877
// Now start the app.
861878
testBinding.globalStore.add(account, eg.initialSnapshot());
@@ -897,7 +914,8 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
897914
}
898915

899916
extension on Subject<AndroidIntent> {
900-
Subject<Map<String?,String?>> get extras => has((x) => x.extras, 'extras');
917+
Subject<String> get action => has((x) => x.action, 'action');
918+
Subject<String> get uri => has((x) => x.uri, 'uri');
901919
}
902920

903921
extension on Subject<PendingIntent> {

0 commit comments

Comments
 (0)