Skip to content

Commit 4b2ade0

Browse files
notif ios: Navigate when app running but in background
1 parent ab3ff84 commit 4b2ade0

File tree

8 files changed

+282
-0
lines changed

8 files changed

+282
-0
lines changed

ios/Runner/AppDelegate.swift

Lines changed: 39 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]?
@@ -18,8 +20,26 @@ import Flutter
1820
let api = NotificationHostApiImpl(notificationData.map { NotificationPayloadForOpen(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: NotificationPayloadForOpen(payload: userInfo))
40+
completionHandler()
41+
}
42+
}
2343
}
2444

2545
private class NotificationHostApiImpl: NotificationHostApi {
@@ -33,3 +53,22 @@ private class NotificationHostApiImpl: NotificationHostApi {
3353
maybeNotifPayload
3454
}
3555
}
56+
57+
class NotificationTapEventListener: NotificationTapEventsStreamHandler {
58+
var eventSink: PigeonEventSink<NotificationPayloadForOpen>?
59+
60+
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<NotificationPayloadForOpen>) {
61+
eventSink = sink
62+
}
63+
64+
func onNotificationTapEvent(data: NotificationPayloadForOpen) {
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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ class NotificationsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
123123
static let shared = NotificationsPigeonCodec(readerWriter: NotificationsPigeonCodecReaderWriter())
124124
}
125125

126+
var notificationsPigeonMethodCodec = FlutterStandardMethodCodec(readerWriter: NotificationsPigeonCodecReaderWriter());
127+
126128
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
127129
protocol NotificationHostApi {
128130
/// Retrieves notification data if the app was launched by tapping on a notification.
@@ -165,3 +167,67 @@ class NotificationHostApiSetup {
165167
}
166168
}
167169
}
170+
171+
private class PigeonStreamHandler<ReturnType>: NSObject, FlutterStreamHandler {
172+
private let wrapper: PigeonEventChannelWrapper<ReturnType>
173+
private var pigeonSink: PigeonEventSink<ReturnType>? = nil
174+
175+
init(wrapper: PigeonEventChannelWrapper<ReturnType>) {
176+
self.wrapper = wrapper
177+
}
178+
179+
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
180+
-> FlutterError?
181+
{
182+
pigeonSink = PigeonEventSink<ReturnType>(events)
183+
wrapper.onListen(withArguments: arguments, sink: pigeonSink!)
184+
return nil
185+
}
186+
187+
func onCancel(withArguments arguments: Any?) -> FlutterError? {
188+
pigeonSink = nil
189+
wrapper.onCancel(withArguments: arguments)
190+
return nil
191+
}
192+
}
193+
194+
class PigeonEventChannelWrapper<ReturnType> {
195+
func onListen(withArguments arguments: Any?, sink: PigeonEventSink<ReturnType>) {}
196+
func onCancel(withArguments arguments: Any?) {}
197+
}
198+
199+
class PigeonEventSink<ReturnType> {
200+
private let sink: FlutterEventSink
201+
202+
init(_ sink: @escaping FlutterEventSink) {
203+
self.sink = sink
204+
}
205+
206+
func success(_ value: ReturnType) {
207+
sink(value)
208+
}
209+
210+
func error(code: String, message: String?, details: Any?) {
211+
sink(FlutterError(code: code, message: message, details: details))
212+
}
213+
214+
func endOfStream() {
215+
sink(FlutterEndOfEventStream)
216+
}
217+
218+
}
219+
220+
class NotificationTapEventsStreamHandler: PigeonEventChannelWrapper<NotificationPayloadForOpen> {
221+
static func register(with messenger: FlutterBinaryMessenger,
222+
instanceName: String = "",
223+
streamHandler: NotificationTapEventsStreamHandler) {
224+
var channelName = "dev.flutter.pigeon.zulip.NotificationHostEvents.notificationTapEvents"
225+
if !instanceName.isEmpty {
226+
channelName += ".\(instanceName)"
227+
}
228+
let internalStreamHandler = PigeonStreamHandler<NotificationPayloadForOpen>(wrapper: streamHandler)
229+
let channel = FlutterEventChannel(name: channelName, binaryMessenger: messenger, codec: notificationsPigeonMethodCodec)
230+
channel.setStreamHandler(internalStreamHandler)
231+
}
232+
}
233+

lib/host/notifications.g.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ class _PigeonCodec extends StandardMessageCodec {
6565
}
6666
}
6767

68+
const StandardMethodCodec pigeonMethodCodec = StandardMethodCodec(_PigeonCodec());
69+
6870
class NotificationHostApi {
6971
/// Constructor for [NotificationHostApi]. The [binaryMessenger] named argument is
7072
/// available for dependency injection. If it is left null, the default
@@ -109,3 +111,15 @@ class NotificationHostApi {
109111
}
110112
}
111113
}
114+
115+
Stream<NotificationPayloadForOpen> notificationTapEvents( {String instanceName = ''}) {
116+
if (instanceName.isNotEmpty) {
117+
instanceName = '.$instanceName';
118+
}
119+
final EventChannel notificationTapEventsChannel =
120+
EventChannel('dev.flutter.pigeon.zulip.NotificationHostEvents.notificationTapEvents$instanceName', pigeonMethodCodec);
121+
return notificationTapEventsChannel.receiveBroadcastStream().map((dynamic event) {
122+
return event as NotificationPayloadForOpen;
123+
});
124+
}
125+

lib/model/binding.dart

Lines changed: 6 additions & 0 deletions
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 _notifInteractionHost = notif_pigeon.NotificationHostApi();
319322

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

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

lib/notifications/open.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import '../host/notifications.dart';
1212
import '../log.dart';
1313
import '../model/binding.dart';
1414
import '../model/narrow.dart';
15+
import '../widgets/app.dart';
1516
import '../widgets/dialog.dart';
1617
import '../widgets/message_list.dart';
1718
import '../widgets/page.dart';
@@ -40,6 +41,8 @@ class NotificationOpenManager {
4041
switch (defaultTargetPlatform) {
4142
case TargetPlatform.iOS:
4243
_notifLaunchData = await _notifPigeonApi.getNotificationDataFromLaunch();
44+
_notifPigeonApi.notificationTapEventsStream()
45+
.listen(_navigateForNotification);
4346

4447
case TargetPlatform.android:
4548
// Do nothing; we do notification routing differently on Android.
@@ -95,6 +98,24 @@ class NotificationOpenManager {
9598
// TODO(#82): Open at specific message, not just conversation
9699
narrow: openData.narrow);
97100
}
101+
102+
/// Navigates to the [MessageListPage] of the specific conversation
103+
/// for the provided payload that was attached while creating the
104+
/// notification.
105+
Future<void> _navigateForNotification(NotificationPayloadForOpen payload) async {
106+
assert(debugLog('opened notif: ${jsonEncode(payload.payload)}'));
107+
108+
NavigatorState navigator = await ZulipApp.navigator;
109+
final context = navigator.context;
110+
assert(context.mounted);
111+
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
112+
113+
final route = _routeForNotification(context, payload);
114+
if (route == null) return; // TODO(log)
115+
116+
// TODO(nav): Better interact with existing nav stack on notif open
117+
unawaited(navigator.push(route));
118+
}
98119
}
99120

100121
class NotificationDataForOpen {

pigeon/notifications.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,16 @@ abstract class NotificationHostApi {
2626
/// See: https://developer.apple.com/documentation/uikit/uiapplication/launchoptionskey/remotenotification
2727
NotificationPayloadForOpen? getNotificationDataFromLaunch();
2828
}
29+
30+
@EventChannelApi()
31+
abstract class NotificationHostEvents {
32+
/// An event stream that emits a notification payload when the app
33+
/// encounters a notification tap, while the app is running.
34+
///
35+
/// On iOS, this emits an event when
36+
/// `userNotificationCenter(_:didReceive:withCompletionHandler:)` gets
37+
/// called, indicating that the user has tapped on a notification. The
38+
/// emitted payload will be the raw APNs data from the
39+
/// `UNNotificationResponse` passed to the method.
40+
NotificationPayloadForOpen notificationTapEvents();
41+
}

test/model/binding.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,18 @@ class FakeNotificationPigeonApi implements NotificationPigeonApi {
745745
@override
746746
Future<NotificationPayloadForOpen?> getNotificationDataFromLaunch() async =>
747747
_notificationDataFromLaunch;
748+
749+
StreamController<NotificationPayloadForOpen>? _notificationTapEventsStreamController;
750+
751+
void addNotificationTapEvent(NotificationPayloadForOpen data) {
752+
_notificationTapEventsStreamController!.add(data);
753+
}
754+
755+
@override
756+
Stream<NotificationPayloadForOpen> notificationTapEventsStream() {
757+
_notificationTapEventsStreamController ??= StreamController();
758+
return _notificationTapEventsStreamController!.stream;
759+
}
748760
}
749761

750762
typedef AndroidNotificationHostApiNotifyCall = ({

0 commit comments

Comments
 (0)