Skip to content

Commit 0fc0a0f

Browse files
rajveermalviyagnprice
authored andcommitted
notif: Handle opening of notification on Android using data URL intents
Replaces the notification-open handling provided by `package:flutter_local_notifications` with a custom Intent data URL-based approach. Notifications will now be created with a `PendingIntent` containing a `zulip://notification…` URL. When a notification is opened, the Android intent's data URL is passed through Flutter's platform-navigation system, triggering a route. If the app is already in the foreground, the `ZulipApp` widget's overridden `didPushRouteInformation` function handles the route. If the app is not running, the intent data URL becomes the initial route, read from `WidgetsBinding.instance.platformDispatcher.defaultRouteName` in the `ZulipApp` widget. In both cases, the intent data URL is then passed to `NotificationDisplayManager.navigateForNotification`, where it is parsed and used to generate the route to the `MessageListPage` for the relevant conversation.
1 parent 311271e commit 0fc0a0f

File tree

10 files changed

+437
-80
lines changed

10 files changed

+437
-80
lines changed

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,26 +90,31 @@ data class NotificationChannel (
9090
/**
9191
* Corresponds to `android.content.Intent`
9292
*
93-
* See: https://developer.android.com/reference/android/content/Intent
93+
* See:
94+
* 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)
9496
*
9597
* Generated class from Pigeon that represents data sent in messages.
9698
*/
9799
data class AndroidIntent (
98100
val action: String,
101+
val dataUrl: String,
99102
val extras: Map<String?, String?>
100103

101104
) {
102105
companion object {
103106
@Suppress("LocalVariableName")
104107
fun fromList(__pigeon_list: List<Any?>): AndroidIntent {
105108
val action = __pigeon_list[0] as String
106-
val extras = __pigeon_list[1] as Map<String?, String?>
107-
return AndroidIntent(action, extras)
109+
val dataUrl = __pigeon_list[1] as String
110+
val extras = __pigeon_list[2] as Map<String?, String?>
111+
return AndroidIntent(action, dataUrl, extras)
108112
}
109113
}
110114
fun toList(): List<Any?> {
111115
return listOf(
112116
action,
117+
dataUrl,
113118
extras,
114119
)
115120
}

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

Lines changed: 7 additions & 2 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
@@ -97,8 +98,12 @@ private class AndroidNotificationHost(val context: Context)
9798
contentIntent?.let { setContentIntent(
9899
android.app.PendingIntent.getActivity(context,
99100
it.requestCode.toInt(),
100-
it.intent.let { intent -> Intent(context, MainActivity::class.java).apply {
101-
action = intent.action
101+
it.intent.let { intent -> Intent(
102+
intent.action,
103+
Uri.parse(intent.dataUrl),
104+
context,
105+
MainActivity::class.java
106+
).apply {
102107
intent.extras.forEach { (k, v) -> putExtra(k!!, v!!) }
103108
} },
104109
it.flags.toInt())

assets/l10n/app_en.arb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,5 +558,13 @@
558558
"pollWidgetOptionsMissing": "This poll has no options yet.",
559559
"@pollWidgetOptionsMissing": {
560560
"description": "Text to display for a poll when it has no options"
561+
},
562+
"errorNotificationOpenTitle": "Failed to open notification",
563+
"@errorNotificationOpenTitle": {
564+
"description": "Error title when notification opening fails"
565+
},
566+
"errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.",
567+
"@errorNotificationOpenAccountMissing": {
568+
"description": "Error message when the account associated with the notification is not found"
561569
}
562570
}

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: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,26 @@ class NotificationChannel {
6565

6666
/// Corresponds to `android.content.Intent`
6767
///
68-
/// See: https://developer.android.com/reference/android/content/Intent
68+
/// See:
69+
/// 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)
6971
class AndroidIntent {
7072
AndroidIntent({
7173
required this.action,
74+
required this.dataUrl,
7275
required this.extras,
7376
});
7477

7578
String action;
7679

80+
String dataUrl;
81+
7782
Map<String?, String?> extras;
7883

7984
Object encode() {
8085
return <Object?>[
8186
action,
87+
dataUrl,
8288
extras,
8389
];
8490
}
@@ -87,7 +93,8 @@ class AndroidIntent {
8793
result as List<Object?>;
8894
return AndroidIntent(
8995
action: result[0]! as String,
90-
extras: (result[1] as Map<Object?, Object?>?)!.cast<String?, String?>(),
96+
dataUrl: result[1]! as String,
97+
extras: (result[2] as Map<Object?, Object?>?)!.cast<String?, String?>(),
9198
);
9299
}
93100
}

lib/notifications/display.dart

Lines changed: 115 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import 'dart:io';
55
import 'package:http/http.dart' as http;
66
import 'package:collection/collection.dart';
77
import 'package:crypto/crypto.dart';
8+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
89
import 'package:flutter/foundation.dart';
910
import 'package:flutter/widgets.dart' hide Notification;
10-
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Person;
1111

1212
import '../api/notifications.dart';
1313
import '../host/android_notifications.dart';
@@ -17,6 +17,7 @@ import '../model/localizations.dart';
1717
import '../model/narrow.dart';
1818
import '../widgets/app.dart';
1919
import '../widgets/color.dart';
20+
import '../widgets/dialog.dart';
2021
import '../widgets/message_list.dart';
2122
import '../widgets/page.dart';
2223
import '../widgets/store.dart';
@@ -95,16 +96,6 @@ class NotificationChannelManager {
9596
/// Service for managing the notifications shown to the user.
9697
class NotificationDisplayManager {
9798
static Future<void> init() async {
98-
await ZulipBinding.instance.notifications.initialize(
99-
const InitializationSettings(
100-
android: AndroidInitializationSettings('zulip_notification'),
101-
),
102-
onDidReceiveNotificationResponse: _onNotificationOpened,
103-
);
104-
final launchDetails = await ZulipBinding.instance.notifications.getNotificationAppLaunchDetails();
105-
if (launchDetails?.didNotificationLaunchApp ?? false) {
106-
_handleNotificationAppLaunch(launchDetails!.notificationResponse);
107-
}
10899
await NotificationChannelManager.ensureChannel();
109100
}
110101

@@ -168,6 +159,16 @@ class NotificationDisplayManager {
168159
name: data.senderFullName,
169160
iconBitmap: await _fetchBitmap(data.senderAvatarUrl))));
170161

162+
final intentDataUrl = NotificationOpenPayload(
163+
realmUrl: data.realmUrl,
164+
userId: data.userId,
165+
narrow: switch (data.recipient) {
166+
FcmMessageChannelRecipient(:var streamId, :var topic) =>
167+
TopicNarrow(streamId, topic),
168+
FcmMessageDmRecipient(:var allRecipientIds) =>
169+
DmNarrow(allRecipientIds: allRecipientIds, selfUserId: data.userId),
170+
}).buildUrl();
171+
171172
await _androidHost.notify(
172173
// TODO the notification ID can be constant, instead of matching requestCode
173174
// (This is a legacy of `flutter_local_notifications`.)
@@ -205,13 +206,9 @@ class NotificationDisplayManager {
205206
// (That's a legacy of `flutter_local_notifications`.)
206207
flags: PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent,
207208
intent: AndroidIntent(
208-
// This action name and extra name are special to
209-
// FlutterLocalNotificationsPlugin, which handles receiving the Intent.
210-
// TODO take care of receiving the notification-opened Intent ourselves
211-
action: 'SELECT_NOTIFICATION',
212-
extras: <String, String>{
213-
'payload': jsonEncode(dataJson),
214-
}),
209+
action: IntentAction.view,
210+
dataUrl: intentDataUrl.toString(),
211+
extras: {}),
215212
// TODO this doesn't set the Intent flags we set in zulip-mobile; is that OK?
216213
// (This is a legacy of `flutter_local_notifications`.)
217214
),
@@ -348,46 +345,36 @@ class NotificationDisplayManager {
348345

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

351-
static void _onNotificationOpened(NotificationResponse response) async {
352-
final payload = jsonDecode(response.payload!) as Map<String, dynamic>;
353-
final data = MessageFcmMessage.fromJson(payload);
354-
assert(debugLog('opened notif: message ${data.zulipMessageId}, content ${data.content}'));
355-
_navigateForNotification(data);
356-
}
357-
358-
static void _handleNotificationAppLaunch(NotificationResponse? response) async {
359-
assert(response != null);
360-
if (response == null) return; // TODO(log) seems like a bug in flutter_local_notifications if this can happen
348+
/// Navigates to the [MessageListPage] of the specific conversation
349+
/// given the `zulip://notification/…` Android intent data URL,
350+
/// generated with [NotificationOpenPayload.buildUrl] while creating
351+
/// the notification.
352+
static Future<void> navigateForNotification(Uri url) async {
353+
assert(debugLog('opened notif: url: $url'));
361354

362-
final payload = jsonDecode(response.payload!) as Map<String, dynamic>;
363-
final data = MessageFcmMessage.fromJson(payload);
364-
assert(debugLog('launched from notif: message ${data.zulipMessageId}, content ${data.content}'));
365-
_navigateForNotification(data);
366-
}
355+
assert(url.scheme == 'zulip' && url.host == 'notification');
356+
final payload = NotificationOpenPayload.parseUrl(url);
367357

368-
static void _navigateForNotification(MessageFcmMessage data) async {
369358
NavigatorState navigator = await ZulipApp.navigator;
370359
final context = navigator.context;
371360
assert(context.mounted);
372361
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
373362

363+
final zulipLocalizations = ZulipLocalizations.of(context);
374364
final globalStore = GlobalStoreWidget.of(context);
375365
final account = globalStore.accounts.firstWhereOrNull((account) =>
376-
account.realmUrl == data.realmUrl && account.userId == data.userId);
377-
if (account == null) return; // TODO(log)
378-
379-
final narrow = switch (data.recipient) {
380-
FcmMessageChannelRecipient(:var streamId, :var topic) =>
381-
TopicNarrow(streamId, topic),
382-
FcmMessageDmRecipient(:var allRecipientIds) =>
383-
DmNarrow(allRecipientIds: allRecipientIds, selfUserId: account.userId),
384-
};
366+
account.realmUrl == payload.realmUrl && account.userId == payload.userId);
367+
if (account == null) { // TODO(log)
368+
showErrorDialog(context: context,
369+
title: zulipLocalizations.errorNotificationOpenTitle,
370+
message: zulipLocalizations.errorNotificationOpenAccountMissing);
371+
return;
372+
}
385373

386-
assert(debugLog(' account: $account, narrow: $narrow'));
387374
// TODO(nav): Better interact with existing nav stack on notif open
388375
unawaited(navigator.push(MaterialAccountWidgetRoute<void>(accountId: account.id,
389376
// TODO(#82): Open at specific message, not just conversation
390-
page: MessageListPage(initNarrow: narrow))));
377+
page: MessageListPage(initNarrow: payload.narrow))));
391378
}
392379

393380
static Future<Uint8List?> _fetchBitmap(Uri url) async {
@@ -403,3 +390,86 @@ class NotificationDisplayManager {
403390
return null;
404391
}
405392
}
393+
394+
/// The information contained in 'zulip://notification/…' internal
395+
/// Android intent data URL, used for notification-open flow.
396+
class NotificationOpenPayload {
397+
final Uri realmUrl;
398+
final int userId;
399+
final Narrow narrow;
400+
401+
NotificationOpenPayload({
402+
required this.realmUrl,
403+
required this.userId,
404+
required this.narrow,
405+
});
406+
407+
factory NotificationOpenPayload.parseUrl(Uri url) {
408+
if (url case Uri(
409+
scheme: 'zulip',
410+
host: 'notification',
411+
queryParameters: {
412+
'realm_url': var realmUrlStr,
413+
'user_id': var userIdStr,
414+
'narrow_type': var narrowType,
415+
// In case of narrowType == 'topic':
416+
// 'channel_id' and 'topic' handled below.
417+
418+
// In case of narrowType == 'dm':
419+
// 'all_recipient_ids' handled below.
420+
},
421+
)) {
422+
final realmUrl = Uri.parse(realmUrlStr);
423+
final userId = int.parse(userIdStr, radix: 10);
424+
425+
final Narrow narrow;
426+
switch (narrowType) {
427+
case 'topic':
428+
final channelIdStr = url.queryParameters['channel_id']!;
429+
final channelId = int.parse(channelIdStr, radix: 10);
430+
final topic = url.queryParameters['topic']!;
431+
narrow = TopicNarrow(channelId, topic);
432+
case 'dm':
433+
final allRecipientIdsStr = url.queryParameters['all_recipient_ids']!;
434+
final allRecipientIds = allRecipientIdsStr.split(',')
435+
.map((idStr) => int.parse(idStr, radix: 10))
436+
.toList(growable: false);
437+
narrow = DmNarrow(allRecipientIds: allRecipientIds, selfUserId: userId);
438+
default:
439+
throw const FormatException();
440+
}
441+
442+
return NotificationOpenPayload(
443+
realmUrl: realmUrl,
444+
userId: userId,
445+
narrow: narrow,
446+
);
447+
} else {
448+
// TODO(dart): simplify after https://github.com/dart-lang/language/issues/2537
449+
throw const FormatException();
450+
}
451+
}
452+
453+
Uri buildUrl() {
454+
return Uri(
455+
scheme: 'zulip',
456+
host: 'notification',
457+
queryParameters: <String, String>{
458+
'realm_url': realmUrl.toString(),
459+
'user_id': userId.toString(),
460+
...(switch (narrow) {
461+
TopicNarrow(streamId: var channelId, :var topic) => {
462+
'narrow_type': 'topic',
463+
'channel_id': channelId.toString(),
464+
'topic': topic,
465+
},
466+
DmNarrow(:var allRecipientIds) => {
467+
'narrow_type': 'dm',
468+
'all_recipient_ids': allRecipientIds.join(','),
469+
},
470+
_ => throw UnsupportedError('Found an unexpected Narrow of type ${narrow.runtimeType}.'),
471+
})
472+
},
473+
);
474+
}
475+
}

lib/widgets/app.dart

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
88
import '../log.dart';
99
import '../model/localizations.dart';
1010
import '../model/narrow.dart';
11+
import '../notifications/display.dart';
1112
import 'about_zulip.dart';
1213
import 'app_bar.dart';
1314
import 'dialog.dart';
@@ -144,19 +145,29 @@ class ZulipApp extends StatefulWidget {
144145
class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
145146
@override
146147
Future<bool> didPushRouteInformation(routeInformation) async {
147-
if (routeInformation case RouteInformation(
148-
uri: Uri(scheme: 'zulip', host: 'login') && var url)
149-
) {
150-
await LoginPage.handleWebAuthUrl(url);
151-
return true;
148+
switch (routeInformation.uri) {
149+
case Uri(scheme: 'zulip', host: 'login') && var url:
150+
await LoginPage.handleWebAuthUrl(url);
151+
return true;
152+
case Uri(scheme: 'zulip', host: 'notification') && var url:
153+
await NotificationDisplayManager.navigateForNotification(url);
154+
return true;
152155
}
153156
return super.didPushRouteInformation(routeInformation);
154157
}
155158

159+
Future<void> _handleInitialRoute() async {
160+
final initialRouteUrl = Uri.parse(WidgetsBinding.instance.platformDispatcher.defaultRouteName);
161+
if (initialRouteUrl case Uri(scheme: 'zulip', host: 'notification')) {
162+
await NotificationDisplayManager.navigateForNotification(initialRouteUrl);
163+
}
164+
}
165+
156166
@override
157167
void initState() {
158168
super.initState();
159169
WidgetsBinding.instance.addObserver(this);
170+
_handleInitialRoute();
160171
}
161172

162173
@override

0 commit comments

Comments
 (0)