Skip to content

Commit 865341e

Browse files
notif ios: Navigate when app running but in background
1 parent add4f17 commit 865341e

File tree

8 files changed

+401
-13
lines changed

8 files changed

+401
-13
lines changed

ios/Runner/AppDelegate.swift

Lines changed: 33 additions & 0 deletions
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,25 @@ 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+
UNUserNotificationCenter.current().delegate = self
25+
1926
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
2027
}
28+
29+
override func userNotificationCenter(
30+
_ center: UNUserNotificationCenter,
31+
didReceive response: UNNotificationResponse,
32+
withCompletionHandler completionHandler: @escaping () -> Void
33+
) {
34+
if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
35+
let userInfo = response.notification.request.content.userInfo
36+
notificationTapEventListener!.onNotificationTapEvent(payload: userInfo)
37+
}
38+
completionHandler()
39+
}
2140
}
2241

2342
private class NotificationHostApiImpl: NotificationHostApi {
@@ -31,3 +50,17 @@ private class NotificationHostApiImpl: NotificationHostApi {
3150
maybeDataFromLaunch
3251
}
3352
}
53+
54+
// Adapted from Pigeon's Swift example for @EventChannelApi:
55+
// https://github.com/flutter/packages/blob/2dff6213a/packages/pigeon/example/app/ios/Runner/AppDelegate.swift#L49-L74
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

Lines changed: 100 additions & 0 deletions
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

Lines changed: 64 additions & 0 deletions
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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,17 @@ class PackageInfo {
328328
});
329329
}
330330

331+
// Pigeon generates methods under `@EventChannelApi` annotated classes
332+
// in global scope of the generated file. This is a helper class to
333+
// namespace the notification related Pigeon API under a single class.
331334
class NotificationPigeonApi {
332335
final _hostApi = notif_pigeon.NotificationHostApi();
333336

334337
Future<notif_pigeon.NotificationDataFromLaunch?> getNotificationDataFromLaunch() =>
335338
_hostApi.getNotificationDataFromLaunch();
339+
340+
Stream<notif_pigeon.NotificationTapEvent> notificationTapEventsStream() =>
341+
notif_pigeon.notificationTapEvents();
336342
}
337343

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

lib/notifications/navigate.dart

Lines changed: 24 additions & 1 deletion
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';
@@ -48,6 +49,8 @@ class NotificationNavigationService {
4849
switch (defaultTargetPlatform) {
4950
case TargetPlatform.iOS:
5051
_notifDataFromLaunch = await _notifPigeonApi.getNotificationDataFromLaunch();
52+
_notifPigeonApi.notificationTapEventsStream()
53+
.listen(_navigateForNotification);
5154

5255
case TargetPlatform.android:
5356
// Do nothing; we do notification routing differently on Android.
@@ -113,7 +116,27 @@ class NotificationNavigationService {
113116
narrow: data.narrow);
114117
}
115118

116-
static NotificationNavigationData? _tryParsePayload(
119+
/// Navigates to the [MessageListPage] of the specific conversation
120+
/// for the provided payload that was attached while creating the
121+
/// notification.
122+
Future<void> _navigateForNotification(NotificationTapEvent event) async {
123+
assert(debugLog('opened notif: ${jsonEncode(event.payload)}'));
124+
125+
NavigatorState navigator = await ZulipApp.navigator;
126+
final context = navigator.context;
127+
assert(context.mounted);
128+
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
129+
130+
final notifNavData = _tryParsePayload(context, event.payload);
131+
if (notifNavData == null) return; // TODO(log)
132+
final route = routeForNotification(context, notifNavData);
133+
if (route == null) return; // TODO(log)
134+
135+
// TODO(nav): Better interact with existing nav stack on notif open
136+
unawaited(navigator.push(route));
137+
}
138+
139+
NotificationNavigationData? _tryParsePayload(
117140
BuildContext context,
118141
Map<Object?, Object?> payload,
119142
) {

pigeon/notifications.dart

Lines changed: 23 additions & 0 deletions
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+
}

test/model/binding.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,18 @@ class FakeNotificationPigeonApi implements NotificationPigeonApi {
773773
@override
774774
Future<NotificationDataFromLaunch?> getNotificationDataFromLaunch() async =>
775775
_notificationDataFromLaunch;
776+
777+
StreamController<NotificationTapEvent>? _notificationTapEventsStreamController;
778+
779+
void addNotificationTapEvent(NotificationTapEvent event) {
780+
_notificationTapEventsStreamController!.add(event);
781+
}
782+
783+
@override
784+
Stream<NotificationTapEvent> notificationTapEventsStream() {
785+
_notificationTapEventsStreamController ??= StreamController();
786+
return _notificationTapEventsStreamController!.stream;
787+
}
776788
}
777789

778790
typedef AndroidNotificationHostApiNotifyCall = ({

0 commit comments

Comments
 (0)