Skip to content

Commit 7ba7cc4

Browse files
notif ios: Navigate when app running but in background
1 parent 37683db commit 7ba7cc4

File tree

8 files changed

+417
-23
lines changed

8 files changed

+417
-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

+100
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,42 @@ struct NotificationDataFromLaunch: Hashable {
157157
}
158158
}
159159

160+
/// Generated class from Pigeon that represents data sent in messages.
161+
struct NotificationTapEvent: Hashable {
162+
/// The raw payload that is attached to the notification,
163+
/// holding the information required to carry out the navigation.
164+
///
165+
/// See [notificationTapEvents].
166+
var payload: [AnyHashable?: Any?]
167+
168+
169+
// swift-format-ignore: AlwaysUseLowerCamelCase
170+
static func fromList(_ pigeonVar_list: [Any?]) -> NotificationTapEvent? {
171+
let payload = pigeonVar_list[0] as! [AnyHashable?: Any?]
172+
173+
return NotificationTapEvent(
174+
payload: payload
175+
)
176+
}
177+
func toList() -> [Any?] {
178+
return [
179+
payload
180+
]
181+
}
182+
static func == (lhs: NotificationTapEvent, rhs: NotificationTapEvent) -> Bool {
183+
return deepEqualsNotifications(lhs.toList(), rhs.toList()) }
184+
func hash(into hasher: inout Hasher) {
185+
deepHashNotifications(value: toList(), hasher: &hasher)
186+
}
187+
}
188+
160189
private class NotificationsPigeonCodecReader: FlutterStandardReader {
161190
override func readValue(ofType type: UInt8) -> Any? {
162191
switch type {
163192
case 129:
164193
return NotificationDataFromLaunch.fromList(self.readValue() as! [Any?])
194+
case 130:
195+
return NotificationTapEvent.fromList(self.readValue() as! [Any?])
165196
default:
166197
return super.readValue(ofType: type)
167198
}
@@ -173,6 +204,9 @@ private class NotificationsPigeonCodecWriter: FlutterStandardWriter {
173204
if let value = value as? NotificationDataFromLaunch {
174205
super.writeByte(129)
175206
super.writeValue(value.toList())
207+
} else if let value = value as? NotificationTapEvent {
208+
super.writeByte(130)
209+
super.writeValue(value.toList())
176210
} else {
177211
super.writeValue(value)
178212
}
@@ -193,6 +227,8 @@ class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
193227
static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter())
194228
}
195229

230+
var notificationsPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: NotificationsPigeonCodecReaderWriter());
231+
196232
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
197233
protocol NotificationHostApi {
198234
/// Retrieves notification data if the app was launched by tapping on a notification.
@@ -233,3 +269,67 @@ class NotificationHostApiSetup {
233269
}
234270
}
235271
}
272+
273+
private class PigeonStreamHandler<ReturnType>: NSObject, FlutterStreamHandler {
274+
private let wrapper: PigeonEventChannelWrapper<ReturnType>
275+
private var pigeonSink: PigeonEventSink<ReturnType>? = nil
276+
277+
init(wrapper: PigeonEventChannelWrapper<ReturnType>) {
278+
self.wrapper = wrapper
279+
}
280+
281+
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
282+
-> FlutterError?
283+
{
284+
pigeonSink = PigeonEventSink<ReturnType>(events)
285+
wrapper.onListen(withArguments: arguments, sink: pigeonSink!)
286+
return nil
287+
}
288+
289+
func onCancel(withArguments arguments: Any?) -> FlutterError? {
290+
pigeonSink = nil
291+
wrapper.onCancel(withArguments: arguments)
292+
return nil
293+
}
294+
}
295+
296+
class PigeonEventChannelWrapper<ReturnType> {
297+
func onListen(withArguments arguments: Any?, sink: PigeonEventSink<ReturnType>) {}
298+
func onCancel(withArguments arguments: Any?) {}
299+
}
300+
301+
class PigeonEventSink<ReturnType> {
302+
private let sink: FlutterEventSink
303+
304+
init(_ sink: @escaping FlutterEventSink) {
305+
self.sink = sink
306+
}
307+
308+
func success(_ value: ReturnType) {
309+
sink(value)
310+
}
311+
312+
func error(code: String, message: String?, details: Any?) {
313+
sink(FlutterError(code: code, message: message, details: details))
314+
}
315+
316+
func endOfStream() {
317+
sink(FlutterEndOfEventStream)
318+
}
319+
320+
}
321+
322+
class NotificationTapEventsStreamHandler: PigeonEventChannelWrapper<NotificationTapEvent> {
323+
static func register(with messenger: FlutterBinaryMessenger,
324+
instanceName: String = "",
325+
streamHandler: NotificationTapEventsStreamHandler) {
326+
var channelName = "dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents"
327+
if !instanceName.isEmpty {
328+
channelName += ".\(instanceName)"
329+
}
330+
let internalStreamHandler = PigeonStreamHandler<NotificationTapEvent>(wrapper: streamHandler)
331+
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: notificationsPigeonMethodCodec)
332+
channel.setStreamHandler(internalStreamHandler)
333+
}
334+
}
335+

lib/host/notifications.g.dart

+64
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,51 @@ class NotificationDataFromLaunch {
7474
;
7575
}
7676

77+
class NotificationTapEvent {
78+
NotificationTapEvent({
79+
required this.payload,
80+
});
81+
82+
/// The raw payload that is attached to the notification,
83+
/// holding the information required to carry out the navigation.
84+
///
85+
/// See [notificationTapEvents].
86+
Map<Object?, Object?> payload;
87+
88+
List<Object?> _toList() {
89+
return <Object?>[
90+
payload,
91+
];
92+
}
93+
94+
Object encode() {
95+
return _toList(); }
96+
97+
static NotificationTapEvent decode(Object result) {
98+
result as List<Object?>;
99+
return NotificationTapEvent(
100+
payload: (result[0] as Map<Object?, Object?>?)!.cast<Object?, Object?>(),
101+
);
102+
}
103+
104+
@override
105+
// ignore: avoid_equals_and_hash_code_on_mutable_classes
106+
bool operator ==(Object other) {
107+
if (other is! NotificationTapEvent || other.runtimeType != runtimeType) {
108+
return false;
109+
}
110+
if (identical(this, other)) {
111+
return true;
112+
}
113+
return _deepEquals(encode(), other.encode());
114+
}
115+
116+
@override
117+
// ignore: avoid_equals_and_hash_code_on_mutable_classes
118+
int get hashCode => Object.hashAll(_toList())
119+
;
120+
}
121+
77122

78123
class _PigeonCodec extends StandardMessageCodec {
79124
const _PigeonCodec();
@@ -85,6 +130,9 @@ class _PigeonCodec extends StandardMessageCodec {
85130
} else if (value is NotificationDataFromLaunch) {
86131
buffer.putUint8(129);
87132
writeValue(buffer, value.encode());
133+
} else if (value is NotificationTapEvent) {
134+
buffer.putUint8(130);
135+
writeValue(buffer, value.encode());
88136
} else {
89137
super.writeValue(buffer, value);
90138
}
@@ -95,12 +143,16 @@ class _PigeonCodec extends StandardMessageCodec {
95143
switch (type) {
96144
case 129:
97145
return NotificationDataFromLaunch.decode(readValue(buffer)!);
146+
case 130:
147+
return NotificationTapEvent.decode(readValue(buffer)!);
98148
default:
99149
return super.readValueOfType(type, buffer);
100150
}
101151
}
102152
}
103153

154+
const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec());
155+
104156
class NotificationHostApi {
105157
/// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is
106158
/// available for dependency injection. If it is left null, the default
@@ -144,3 +196,15 @@ class NotificationHostApi {
144196
}
145197
}
146198
}
199+
200+
Stream<NotificationTapEvent> notificationTapEvents( {String instanceName = ''}) {
201+
if (instanceName.isNotEmpty) {
202+
instanceName = '.$instanceName';
203+
}
204+
final EventChannel notificationTapEventsChannel =
205+
EventChannel('dev.flutter.pigeon.zulip.NotificationEventChannelApi.notificationTapEvents$instanceName', pigeonMethodCodec);
206+
return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) {
207+
return event as NotificationTapEvent;
208+
});
209+
}
210+

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 {

0 commit comments

Comments
 (0)