Skip to content

Commit b873e5f

Browse files
notif ios: Navigate when app running but in background
1 parent b9ae938 commit b873e5f

File tree

8 files changed

+396
-23
lines changed

8 files changed

+396
-23
lines changed

ios/Runner/AppDelegate.swift

+39
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import Flutter
33

44
@main
55
@objc class AppDelegate: FlutterAppDelegate {
6+
private var notificationTapEventListener: NotificationTapEventListener?
7+
68
override func application(
79
_ application: UIApplication,
810
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@@ -18,8 +20,26 @@ import Flutter
1820
let api = NotificationHostApiImpl(notificationPayload.map { NotificationDataFromLaunch(payload: $0) })
1921
NotificationHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: api)
2022

23+
notificationTapEventListener = NotificationTapEventListener()
24+
NotificationTapEventsStreamHandler.register(with: controller.binaryMessenger, streamHandler: notificationTapEventListener!)
25+
26+
// Setup handler for notification tap while the app is running.
27+
UNUserNotificationCenter.current().delegate = self
28+
2129
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
2230
}
31+
32+
override func userNotificationCenter(
33+
_ center: UNUserNotificationCenter,
34+
didReceive response: UNNotificationResponse,
35+
withCompletionHandler completionHandler: @escaping () -> Void
36+
) {
37+
if let listener = notificationTapEventListener {
38+
let userInfo = response.notification.request.content.userInfo
39+
listener.onNotificationTapEvent(data: NotificationTapEvent(payload: userInfo))
40+
completionHandler()
41+
}
42+
}
2343
}
2444

2545
private class NotificationHostApiImpl: NotificationHostApi {
@@ -33,3 +53,22 @@ private class NotificationHostApiImpl: NotificationHostApi {
3353
maybeDataFromLaunch
3454
}
3555
}
56+
57+
class NotificationTapEventListener: NotificationTapEventsStreamHandler {
58+
var eventSink: PigeonEventSink<NotificationTapEvent>?
59+
60+
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<NotificationTapEvent>) {
61+
eventSink = sink
62+
}
63+
64+
func onNotificationTapEvent(data: NotificationTapEvent) {
65+
if let eventSink = eventSink {
66+
eventSink.success(data)
67+
}
68+
}
69+
70+
func onEventsDone() {
71+
eventSink?.endOfStream()
72+
eventSink = nil
73+
}
74+
}

ios/Runner/Notifications.g.swift

+93
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,35 @@ struct NotificationDataFromLaunch {
8686
}
8787
}
8888

89+
/// Generated class from Pigeon that represents data sent in messages.
90+
struct NotificationTapEvent {
91+
/// The raw payload that is attached to the notification,
92+
/// holding the information required to carry out the navigation.
93+
var payload: [AnyHashable?: Any?]
94+
95+
96+
// swift-format-ignore: AlwaysUseLowerCamelCase
97+
static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? {
98+
let payload = pigeonVar_list[0] as! [AnyHashable?: Any?]
99+
100+
return NotificationTapEvent(
101+
payload: payload
102+
)
103+
}
104+
func toList() -> [Any?] {
105+
return [
106+
payload
107+
]
108+
}
109+
}
110+
89111
private class NotificationsPigeonCodecReader: FlutterStandardReader {
90112
override func readValue(ofType type: UInt8) -> Any? {
91113
switch type {
92114
case 129:
93115
return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?])
116+
case 130:
117+
return NotificationTapEvent.fromList(self.readValue() as! [Any?])
94118
default:
95119
return super.readValue(ofType: type)
96120
}
@@ -102,6 +126,9 @@ private class NotificationsPigeonCodecWriter: FlutterStandardWriter {
102126
if let value = value as? NotificationDataFromLaunch {
103127
super.writeByte(129)
104128
super.writeValue(value.toList())
129+
} else if let value = value as? NotificationTapEvent {
130+
super.writeByte(130)
131+
super.writeValue(value.toList())
105132
} else {
106133
super.writeValue(value)
107134
}
@@ -122,6 +149,8 @@ class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
122149
static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter())
123150
}
124151

152+
var notificationsPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: NotificationsPigeonCodecReaderWriter());
153+
125154
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
126155
protocol NotificationHostApi {
127156
/// Retrieves notification data if the app was launched by tapping on a notification.
@@ -162,3 +191,67 @@ class NotificationHostApiSetup {
162191
}
163192
}
164193
}
194+
195+
private class PigeonStreamHandler<ReturnType>: NSObject, FlutterStreamHandler {
196+
private let wrapper: PigeonEventChannelWrapper<ReturnType>
197+
private var pigeonSink: PigeonEventSink<ReturnType>? = nil
198+
199+
init(wrapper: PigeonEventChannelWrapper<ReturnType>) {
200+
self.wrapper = wrapper
201+
}
202+
203+
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
204+
-> FlutterError?
205+
{
206+
pigeonSink = PigeonEventSink<ReturnType>(events)
207+
wrapper.onListen(withArguments: arguments, sink: pigeonSink!)
208+
return nil
209+
}
210+
211+
func onCancel(withArguments arguments: Any?) -> FlutterError? {
212+
pigeonSink = nil
213+
wrapper.onCancel(withArguments: arguments)
214+
return nil
215+
}
216+
}
217+
218+
class PigeonEventChannelWrapper<ReturnType> {
219+
func onListen(withArguments arguments: Any?, sink: PigeonEventSink<ReturnType>) {}
220+
func onCancel(withArguments arguments: Any?) {}
221+
}
222+
223+
class PigeonEventSink<ReturnType> {
224+
private let sink: FlutterEventSink
225+
226+
init(_ sink: @escaping FlutterEventSink) {
227+
self.sink = sink
228+
}
229+
230+
func success(_ value: ReturnType) {
231+
sink(value)
232+
}
233+
234+
func error(code: String, message: String?, details: Any?) {
235+
sink(FlutterError(code: code, message: message, details: details))
236+
}
237+
238+
func endOfStream() {
239+
sink(FlutterEndOfEventStream)
240+
}
241+
242+
}
243+
244+
class NotificationTapEventsStreamHandler: PigeonEventChannelWrapper<NotificationTapEvent> {
245+
static func register(with messenger: FlutterBinaryMessenger,
246+
instanceName: String = "",
247+
streamHandler: NotificationTapEventsStreamHandler) {
248+
var channelName = "dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents"
249+
if !instanceName.isEmpty {
250+
channelName += ".\(instanceName)"
251+
}
252+
let internalStreamHandler = PigeonStreamHandler<NotificationTapEvent>(wrapper: streamHandler)
253+
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: notificationsPigeonMethodCodec)
254+
channel.setStreamHandler(internalStreamHandler)
255+
}
256+
}
257+

lib/host/notifications.g.dart

+42
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,29 @@ class NotificationDataFromLaunch {
3838
}
3939
}
4040

41+
class NotificationTapEvent {
42+
NotificationTapEvent({
43+
required this.payload,
44+
});
45+
46+
/// The raw payload that is attached to the notification,
47+
/// holding the information required to carry out the navigation.
48+
Map<Object?, Object?> payload;
49+
50+
Object encode() {
51+
return <Object?>[
52+
payload,
53+
];
54+
}
55+
56+
static NotificationTapEvent decode(Object result) {
57+
result as List<Object?>;
58+
return NotificationTapEvent(
59+
payload: (result[0] as Map<Object?, Object?>?)!.cast<Object?, Object?>(),
60+
);
61+
}
62+
}
63+
4164

4265
class _PigeonCodec extends StandardMessageCodec {
4366
const _PigeonCodec();
@@ -49,6 +72,9 @@ class _PigeonCodec extends StandardMessageCodec {
4972
} else if (value is NotificationDataFromLaunch) {
5073
buffer.putUint8(129);
5174
writeValue(buffer, value.encode());
75+
} else if (value is NotificationTapEvent) {
76+
buffer.putUint8(130);
77+
writeValue(buffer, value.encode());
5278
} else {
5379
super.writeValue(buffer, value);
5480
}
@@ -59,12 +85,16 @@ class _PigeonCodec extends StandardMessageCodec {
5985
switch (type) {
6086
case 129:
6187
return NotificationDataFromLaunch.decode(readValue(buffer)!);
88+
case 130:
89+
return NotificationTapEvent.decode(readValue(buffer)!);
6290
default:
6391
return super.readValueOfType(type, buffer);
6492
}
6593
}
6694
}
6795

96+
const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec());
97+
6898
class NotificationHostApi {
6999
/// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is
70100
/// available for dependency injection. If it is left null, the default
@@ -108,3 +138,15 @@ class NotificationHostApi {
108138
}
109139
}
110140
}
141+
142+
Stream<NotificationTapEvent> notificationTapEvents( {String instanceName = ''}) {
143+
if (instanceName.isNotEmpty) {
144+
instanceName = '.$instanceName';
145+
}
146+
final EventChannel notificationTapEventsChannel =
147+
EventChannel('dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents$instanceName', pigeonMethodCodec);
148+
return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) {
149+
return event as NotificationTapEvent;
150+
});
151+
}
152+

lib/model/binding.dart

+6
Original file line numberDiff line numberDiff line change
@@ -314,11 +314,17 @@ class PackageInfo {
314314
});
315315
}
316316

317+
// Pigeon generates methods under `@EventChannelApi` annotated classes
318+
// in global scope of the generated file. This is a helper class to
319+
// namespace the notification related Pigeon API under a single class.
317320
class NotificationPigeonApi {
318321
final _hostApi = notif_pigeon.NotificationHostApi();
319322

320323
Future<notif_pigeon.NotificationDataFromLaunch?> getNotificationDataFromLaunch() =>
321324
_hostApi.getNotificationDataFromLaunch();
325+
326+
Stream<notif_pigeon.NotificationTapEvent> notificationTapEventsStream() =>
327+
notif_pigeon.notificationTapEvents();
322328
}
323329

324330
/// A concrete binding for use in the live application.

lib/notifications/open.dart

+40-11
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import '../host/notifications.dart';
1111
import '../log.dart';
1212
import '../model/binding.dart';
1313
import '../model/narrow.dart';
14+
import '../widgets/app.dart';
1415
import '../widgets/dialog.dart';
1516
import '../widgets/message_list.dart';
1617
import '../widgets/page.dart';
@@ -46,6 +47,8 @@ class NotificationOpenManager {
4647
switch (defaultTargetPlatform) {
4748
case TargetPlatform.iOS:
4849
_notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch();
50+
_notifPigeonApi.notificationTapEventsStream()
51+
.listen(_navigateForNotification);
4952

5053
case TargetPlatform.android:
5154
// Do nothing; we do notification routing differently on Android.
@@ -79,17 +82,8 @@ class NotificationOpenManager {
7982
if (data == null) return null;
8083
assert(debugLog('opened notif: ${jsonEncode(data.payload)}'));
8184

82-
final NotificationNavigationData notifNavData;
83-
try {
84-
notifNavData = NotificationNavigationData.fromIosApnsPayload(data.payload);
85-
} on FormatException catch (e, st) {
86-
assert(debugLog('$e\n$st'));
87-
final zulipLocalizations = ZulipLocalizations.of(context);
88-
showErrorDialog(context: context,
89-
title: zulipLocalizations.errorNotificationOpenTitle);
90-
return null;
91-
}
92-
85+
final notifNavData = _tryParsePayload(context, data.payload);
86+
if (notifNavData == null) return null; // TODO(log)
9387
return _routeForNotification(context, notifNavData);
9488
}
9589

@@ -116,6 +110,41 @@ class NotificationOpenManager {
116110
// TODO(#82): Open at specific message, not just conversation
117111
narrow: data.narrow);
118112
}
113+
114+
/// Navigates to the [MessageListPage] of the specific conversation
115+
/// for the provided payload that was attached while creating the
116+
/// notification.
117+
Future<void> _navigateForNotification(NotificationTapEvent event) async {
118+
assert(debugLog('opened notif: ${jsonEncode(event.payload)}'));
119+
120+
NavigatorState navigator = await ZulipApp.navigator;
121+
final context = navigator.context;
122+
assert(context.mounted);
123+
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
124+
125+
final notifNavData = _tryParsePayload(context, event.payload);
126+
if (notifNavData == null) return; // TODO(log)
127+
final route = _routeForNotification(context, notifNavData);
128+
if (route == null) return; // TODO(log)
129+
130+
// TODO(nav): Better interact with existing nav stack on notif open
131+
unawaited(navigator.push(route));
132+
}
133+
134+
NotificationNavigationData? _tryParsePayload(
135+
BuildContext context,
136+
Map<Object?, Object?> payload,
137+
) {
138+
try {
139+
return NotificationNavigationData.fromIosApnsPayload(payload);
140+
} on FormatException catch (e, st) {
141+
assert(debugLog('$e\n$st'));
142+
final zulipLocalizations = ZulipLocalizations.of(context);
143+
showErrorDialog(context: context,
144+
title: zulipLocalizations.errorNotificationOpenTitle);
145+
return null;
146+
}
147+
}
119148
}
120149

121150
class NotificationNavigationData {

pigeon/notifications.dart

+25
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ class NotificationDataFromLaunch {
1212

1313
/// The raw payload that is attached to the notification,
1414
/// holding the information required to carry out the navigation.
15+
///
16+
/// See [NotificationHostApi.getNotificationDataFromLaunch].
17+
final Map<Object?, Object?> payload;
18+
}
19+
20+
class NotificationTapEvent {
21+
const NotificationTapEvent({required this.payload});
22+
23+
/// The raw payload that is attached to the notification,
24+
/// holding the information required to carry out the navigation.
25+
///
26+
/// See [NotificationEventChannelApi.notificationTapEvents].
1527
final Map<Object?, Object?> payload;
1628
}
1729

@@ -26,3 +38,16 @@ abstract class NotificationHostApi {
2638
/// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification
2739
NotificationDataFromLaunch? getNotificationDataFromLaunch();
2840
}
41+
42+
@EventChannelApi()
43+
abstract class NotificationEventChannelApi {
44+
/// An event stream that emits a notification payload when the app
45+
/// encounters a notification tap, while the app is running.
46+
///
47+
/// Emits an event when
48+
/// `userNotificationCenter(_:didReceive:withCompletionHandler:)` gets
49+
/// called, indicating that the user has tapped on a notification. The
50+
/// emitted payload will be the raw APNs data dictionary from the
51+
/// `UNNotificationResponse` passed to that method.
52+
NotificationTapEvent notificationTapEvents();
53+
}

0 commit comments

Comments
 (0)