diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 5c26beb12f..79fe0e0632 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -100,6 +100,9 @@ abstract class ZulipBinding { /// Wraps [firebase_messaging.FirebaseMessaging.onMessage]. Stream get firebaseMessagingOnMessage; + /// Wraps [firebase_messaging.FirebaseMessaging.onBackgroundMessage]. + void firebaseMessagingOnBackgroundMessage(firebase_messaging.BackgroundMessageHandler handler); + /// Wraps the [FlutterLocalNotificationsPlugin] singleton constructor. FlutterLocalNotificationsPlugin get notifications; } @@ -199,6 +202,11 @@ class LiveZulipBinding extends ZulipBinding { return firebase_messaging.FirebaseMessaging.onMessage; } + @override + void firebaseMessagingOnBackgroundMessage(firebase_messaging.BackgroundMessageHandler handler) { + firebase_messaging.FirebaseMessaging.onBackgroundMessage(handler); + } + @override FlutterLocalNotificationsPlugin get notifications => FlutterLocalNotificationsPlugin(); } diff --git a/lib/notifications.dart b/lib/notifications.dart index cb6f57275f..cd1d550b84 100644 --- a/lib/notifications.dart +++ b/lib/notifications.dart @@ -18,9 +18,23 @@ class NotificationService { @visibleForTesting static void debugReset() { instance.token.dispose(); - instance.token = ValueNotifier(null); + _instance = null; + assert(debugBackgroundIsolateIsLive = true); } + /// Whether a background isolate should initialize [LiveZulipBinding]. + /// + /// Ordinarily a [ZulipBinding.firebaseMessagingOnBackgroundMessage] callback + /// will be invoked in a background isolate where it must set up its + /// [ZulipBinding], just as the `main` function does for most of the app. + /// Consequently, by default we have that callback initialize + /// [LiveZulipBinding], just like `main` does. + /// + /// In a test that behavior is undesirable. Tests that will cause + /// [ZulipBinding.firebaseMessagingOnBackgroundMessage] callbacks + /// to get invoked should therefore set this to false. + static bool debugBackgroundIsolateIsLive = true; + /// The FCM registration token for this install of the app. /// /// This is unique to the (app, device) pair, but not permanent. @@ -41,7 +55,8 @@ class NotificationService { // (in order to avoid calling for permissions) await NotificationDisplayManager._init(); - ZulipBinding.instance.firebaseMessagingOnMessage.listen(_onRemoteMessage); + ZulipBinding.instance.firebaseMessagingOnMessage.listen(_onForegroundMessage); + ZulipBinding.instance.firebaseMessagingOnBackgroundMessage(_onBackgroundMessage); // Get the FCM registration token, now and upon changes. See FCM API docs: // https://firebase.google.com/docs/cloud-messaging/android/client#sample-register @@ -71,8 +86,41 @@ class NotificationService { token.value = value; } - static void _onRemoteMessage(FirebaseRemoteMessage message) { + static void _onForegroundMessage(FirebaseRemoteMessage message) { assert(debugLog("notif message: ${message.data}")); + _onRemoteMessage(message); + } + + static Future _onBackgroundMessage(FirebaseRemoteMessage message) async { + // This callback will run in a separate isolate from the rest of the app. + // See docs: + // https://firebase.flutter.dev/docs/messaging/usage/#background-messages + _initBackgroundIsolate(); + + assert(debugLog("notif message in background: ${message.data}")); + _onRemoteMessage(message); + } + + static void _initBackgroundIsolate() { + bool isolateIsLive = true; + assert(() { + isolateIsLive = debugBackgroundIsolateIsLive; + return true; + }()); + if (!isolateIsLive) { + return; + } + + // Compare these setup steps to the ones in `main` in lib/main.dart . + assert(() { + debugLogEnabled = true; + return true; + }()); + LiveZulipBinding.ensureInitialized(); + NotificationDisplayManager._init(); // TODO call this just once per isolate + } + + static void _onRemoteMessage(FirebaseRemoteMessage message) { final data = FcmMessage.fromJson(message.data); switch (data) { case MessageFcmMessage(): NotificationDisplayManager._onMessageFcmMessage(data, message.data); diff --git a/test/model/binding.dart b/test/model/binding.dart index c83b7535c2..663442f5bd 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -192,6 +192,11 @@ class TestZulipBinding extends ZulipBinding { @override Stream get firebaseMessagingOnMessage => firebaseMessaging.onMessage.stream; + @override + void firebaseMessagingOnBackgroundMessage(BackgroundMessageHandler handler) { + firebaseMessaging.onBackgroundMessage.stream.listen(handler); + } + void _resetNotifications() { _notificationsPlugin = null; } @@ -241,6 +246,12 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { Stream get onTokenRefresh => _tokenController.stream; StreamController onMessage = StreamController.broadcast(); + + /// Controls [TestZulipBinding.firebaseMessagingOnBackgroundMessage]. + /// + /// Calling [StreamController.add] on this will cause a call + /// to any handler registered through that method. + StreamController onBackgroundMessage = StreamController.broadcast(); } class FakeFlutterLocalNotificationsPlugin extends Fake implements FlutterLocalNotificationsPlugin { diff --git a/test/notifications_test.dart b/test/notifications_test.dart index 40c37cd75e..0396f4caa8 100644 --- a/test/notifications_test.dart +++ b/test/notifications_test.dart @@ -68,6 +68,7 @@ void main() { addTearDown(testBinding.reset); testBinding.firebaseMessagingInitialToken = '012abc'; addTearDown(NotificationService.debugReset); + NotificationService.debugBackgroundIsolateIsLive = false; await NotificationService.instance.start(); } @@ -93,13 +94,10 @@ void main() { }); group('NotificationDisplayManager', () { - Future checkNotification(MessageFcmMessage data, { + void checkNotification(MessageFcmMessage data, { required String expectedTitle, required String expectedTagComponent, - }) async { - testBinding.firebaseMessaging.onMessage.add( - RemoteMessage(data: data.toJson())); - await null; + }) { check(testBinding.notifications.takeShowCalls()).single ..id.equals(NotificationDisplayManager.kNotificationId) ..title.equals(expectedTitle) @@ -112,11 +110,28 @@ void main() { ); } + Future checkNotifications(MessageFcmMessage data, { + required String expectedTitle, + required String expectedTagComponent, + }) async { + testBinding.firebaseMessaging.onMessage.add( + RemoteMessage(data: data.toJson())); + await null; + checkNotification(data, expectedTitle: expectedTitle, + expectedTagComponent: expectedTagComponent); + + testBinding.firebaseMessaging.onBackgroundMessage.add( + RemoteMessage(data: data.toJson())); + await null; + checkNotification(data, expectedTitle: expectedTitle, + expectedTagComponent: expectedTagComponent); + } + test('stream message', () async { await init(); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); - await checkNotification(messageFcmMessage(message, streamName: stream.name), + await checkNotifications(messageFcmMessage(message, streamName: stream.name), expectedTitle: '${stream.name} > ${message.subject}', expectedTagComponent: 'stream:${message.streamId}:${message.subject}'); }); @@ -125,7 +140,7 @@ void main() { await init(); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); - await checkNotification(messageFcmMessage(message, streamName: null), + await checkNotifications(messageFcmMessage(message, streamName: null), expectedTitle: '(unknown stream) > ${message.subject}', expectedTagComponent: 'stream:${message.streamId}:${message.subject}'); }); @@ -133,7 +148,7 @@ void main() { test('group DM', () async { await init(); final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]); - await checkNotification(messageFcmMessage(message), + await checkNotifications(messageFcmMessage(message), expectedTitle: "${eg.thirdUser.fullName} to you and 1 others", expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); }); @@ -141,7 +156,7 @@ void main() { test('1:1 DM', () async { await init(); final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); - await checkNotification(messageFcmMessage(message), + await checkNotifications(messageFcmMessage(message), expectedTitle: eg.otherUser.fullName, expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); }); @@ -149,7 +164,7 @@ void main() { test('self-DM', () async { await init(); final message = eg.dmMessage(from: eg.selfUser, to: []); - await checkNotification(messageFcmMessage(message), + await checkNotifications(messageFcmMessage(message), expectedTitle: eg.selfUser.fullName, expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); });