Skip to content

Commit b2bd272

Browse files
notif ios: Navigate when app running but in background
1 parent ca941d1 commit b2bd272

File tree

8 files changed

+392
-23
lines changed

8 files changed

+392
-23
lines changed

ios/Runner/AppDelegate.swift

+33
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]?
@@ -16,8 +18,27 @@ import Flutter
1618
let api = NotificationHostApiImpl(notificationPayload.map { NotificationDataFromLaunch(payload: $0) })
1719
NotificationHostApiSetup.setUp(binaryMessenger: controller.binaryMessenger, api: api)
1820

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

2344
private class NotificationHostApiImpl: NotificationHostApi {
@@ -31,3 +52,15 @@ private class NotificationHostApiImpl: NotificationHostApi {
3152
maybeDataFromLaunch
3253
}
3354
}
55+
56+
class NotificationTapEventListener: NotificationTapEventsStreamHandler {
57+
var eventSink: PigeonEventSink<NotificationTapEvent>?
58+
59+
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<NotificationTapEvent>) {
60+
eventSink = sink
61+
}
62+
63+
func onNotificationTapEvent(payload: [AnyHashable : Any]) {
64+
eventSink?.success(NotificationTapEvent(payload: payload))
65+
}
66+
}

ios/Runner/Notifications.g.swift

+95
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,37 @@ struct NotificationDataFromLaunch {
8888
}
8989
}
9090

91+
/// Generated class from Pigeon that represents data sent in messages.
92+
struct NotificationTapEvent {
93+
/// The raw payload that is attached to the notification,
94+
/// holding the information required to carry out the navigation.
95+
///
96+
/// See [notificationTapEvents].
97+
var payload: [AnyHashable?: Any?]
98+
99+
100+
// swift-format-ignore: AlwaysUseLowerCamelCase
101+
static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? {
102+
let payload = pigeonVar_list[0] as! [AnyHashable?: Any?]
103+
104+
return NotificationTapEvent(
105+
payload: payload
106+
)
107+
}
108+
func toList() -> [Any?] {
109+
return [
110+
payload
111+
]
112+
}
113+
}
114+
91115
private class NotificationsPigeonCodecReader: FlutterStandardReader {
92116
override func readValue(ofType type: UInt8) -> Any? {
93117
switch type {
94118
case 129:
95119
return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?])
120+
case 130:
121+
return NotificationTapEvent.fromList(self.readValue() as! [Any?])
96122
default:
97123
return super.readValue(ofType: type)
98124
}
@@ -104,6 +130,9 @@ private class NotificationsPigeonCodecWriter: FlutterStandardWriter {
104130
if let value = value as? NotificationDataFromLaunch {
105131
super.writeByte(129)
106132
super.writeValue(value.toList())
133+
} else if let value = value as? NotificationTapEvent {
134+
super.writeByte(130)
135+
super.writeValue(value.toList())
107136
} else {
108137
super.writeValue(value)
109138
}
@@ -124,6 +153,8 @@ class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
124153
static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter())
125154
}
126155

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

lib/host/notifications.g.dart

+44
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ class NotificationDataFromLaunch {
4040
}
4141
}
4242

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

4469
class _PigeonCodec extends StandardMessageCodec {
4570
const _PigeonCodec();
@@ -51,6 +76,9 @@ class _PigeonCodec extends StandardMessageCodec {
5176
} else if (value is NotificationDataFromLaunch) {
5277
buffer.putUint8(129);
5378
writeValue(buffer, value.encode());
79+
} else if (value is NotificationTapEvent) {
80+
buffer.putUint8(130);
81+
writeValue(buffer, value.encode());
5482
} else {
5583
super.writeValue(buffer, value);
5684
}
@@ -61,12 +89,16 @@ class _PigeonCodec extends StandardMessageCodec {
6189
switch (type) {
6290
case 129:
6391
return NotificationDataFromLaunch.decode(readValue(buffer)!);
92+
case 130:
93+
return NotificationTapEvent.decode(readValue(buffer)!);
6494
default:
6595
return super.readValueOfType(type, buffer);
6696
}
6797
}
6898
}
6999

100+
const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec());
101+
70102
class NotificationHostApi {
71103
/// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is
72104
/// available for dependency injection. If it is left null, the default
@@ -110,3 +142,15 @@ class NotificationHostApi {
110142
}
111143
}
112144
}
145+
146+
Stream<NotificationTapEvent> notificationTapEvents( {String instanceName = ''}) {
147+
if (instanceName.isNotEmpty) {
148+
instanceName = '.$instanceName';
149+
}
150+
final EventChannel notificationTapEventsChannel =
151+
EventChannel('dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents$instanceName', pigeonMethodCodec);
152+
return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) {
153+
return event as NotificationTapEvent;
154+
});
155+
}
156+

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

+23
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@ class NotificationDataFromLaunch {
1717
final Map<Object?, Object?> payload;
1818
}
1919

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 [notificationTapEvents].
27+
final Map<Object?, Object?> payload;
28+
}
29+
2030
@HostApi()
2131
abstract class NotificationHostApi {
2232
/// Retrieves notification data if the app was launched by tapping on a notification.
@@ -28,3 +38,16 @@ abstract class NotificationHostApi {
2838
/// https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification
2939
NotificationDataFromLaunch? getNotificationDataFromLaunch();
3040
}
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)