diff --git a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt index cf3e96cc95..8ef77f5a19 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt @@ -256,6 +256,65 @@ data class MessagingStyle ( ) } } + +/** + * Corresponds to `android.app.Notification` + * + * See: https://developer.android.com/reference/kotlin/android/app/Notification + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class Notification ( + val group: String, + val extras: Map + +) { + companion object { + @Suppress("LocalVariableName") + fun fromList(__pigeon_list: List): Notification { + val group = __pigeon_list[0] as String + val extras = __pigeon_list[1] as Map + return Notification(group, extras) + } + } + fun toList(): List { + return listOf( + group, + extras, + ) + } +} + +/** + * Corresponds to `android.service.notification.StatusBarNotification` + * + * See: https://developer.android.com/reference/android/service/notification/StatusBarNotification + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class StatusBarNotification ( + val id: Long, + val tag: String, + val notification: Notification + +) { + companion object { + @Suppress("LocalVariableName") + fun fromList(__pigeon_list: List): StatusBarNotification { + val id = __pigeon_list[0].let { num -> if (num is Int) num.toLong() else num as Long } + val tag = __pigeon_list[1] as String + val notification = __pigeon_list[2] as Notification + return StatusBarNotification(id, tag, notification) + } + } + fun toList(): List { + return listOf( + id, + tag, + notification, + ) + } +} private object NotificationsPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -289,6 +348,16 @@ private object NotificationsPigeonCodec : StandardMessageCodec() { MessagingStyle.fromList(it) } } + 135.toByte() -> { + return (readValue(buffer) as? List)?.let { + Notification.fromList(it) + } + } + 136.toByte() -> { + return (readValue(buffer) as? List)?.let { + StatusBarNotification.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -318,6 +387,14 @@ private object NotificationsPigeonCodec : StandardMessageCodec() { stream.write(134) writeValue(stream, value.toList()) } + is Notification -> { + stream.write(135) + writeValue(stream, value.toList()) + } + is StatusBarNotification -> { + stream.write(136) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -365,6 +442,23 @@ interface AndroidNotificationHostApi { * https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) */ fun getActiveNotificationMessagingStyleByTag(tag: String): MessagingStyle? + /** + * Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. + * + * The keys of entries to fetch from notification's extras bundle must be + * specified in the [desiredExtras] list. If this list is empty, then + * [Notifications.extras] will also be empty. If value of the matched entry + * is not of type string or is null, then that entry will be skipped. + * + * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() + */ + fun getActiveNotifications(desiredExtras: List): List + /** + * Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. + * + * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) + */ + fun cancel(tag: String?, id: Long) companion object { /** The codec used by AndroidNotificationHostApi. */ @@ -442,6 +536,42 @@ interface AndroidNotificationHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotifications$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val desiredExtrasArg = args[0] as List + val wrapped: List = try { + listOf(api.getActiveNotifications(desiredExtrasArg)) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.cancel$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tagArg = args[0] as String? + val idArg = args[1].let { num -> if (num is Int) num.toLong() else num as Long } + val wrapped: List = try { + api.cancel(tagArg, idArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt index c357f70043..875ac17a7c 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt @@ -143,6 +143,25 @@ private class AndroidNotificationHost(val context: Context) } return null } + + override fun getActiveNotifications(desiredExtras: List): List { + return NotificationManagerCompat.from(context).activeNotifications.map { + StatusBarNotification( + it.id.toLong(), + it.tag, + Notification( + it.notification.group, + desiredExtras + .associateWith { key -> it.notification.extras.getString(key) } + .filter { entry -> entry.value != null } + ), + ) + } + } + + override fun cancel(tag: String?, id: Long) { + NotificationManagerCompat.from(context).cancel(tag, id.toInt()) + } } /** A Flutter plugin for the Zulip app's ad-hoc needs. */ diff --git a/lib/host/android_notifications.g.dart b/lib/host/android_notifications.g.dart index 605e16c580..030547d041 100644 --- a/lib/host/android_notifications.g.dart +++ b/lib/host/android_notifications.g.dart @@ -240,6 +240,69 @@ class MessagingStyle { } } +/// Corresponds to `android.app.Notification` +/// +/// See: https://developer.android.com/reference/kotlin/android/app/Notification +class Notification { + Notification({ + required this.group, + required this.extras, + }); + + String group; + + Map extras; + + Object encode() { + return [ + group, + extras, + ]; + } + + static Notification decode(Object result) { + result as List; + return Notification( + group: result[0]! as String, + extras: (result[1] as Map?)!.cast(), + ); + } +} + +/// Corresponds to `android.service.notification.StatusBarNotification` +/// +/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification +class StatusBarNotification { + StatusBarNotification({ + required this.id, + required this.tag, + required this.notification, + }); + + int id; + + String tag; + + Notification notification; + + Object encode() { + return [ + id, + tag, + notification, + ]; + } + + static StatusBarNotification decode(Object result) { + result as List; + return StatusBarNotification( + id: result[0]! as int, + tag: result[1]! as String, + notification: result[2]! as Notification, + ); + } +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @@ -263,6 +326,12 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is MessagingStyle) { buffer.putUint8(134); writeValue(buffer, value.encode()); + } else if (value is Notification) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else if (value is StatusBarNotification) { + buffer.putUint8(136); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -283,6 +352,10 @@ class _PigeonCodec extends StandardMessageCodec { return MessagingStyleMessage.decode(readValue(buffer)!); case 134: return MessagingStyle.decode(readValue(buffer)!); + case 135: + return Notification.decode(readValue(buffer)!); + case 136: + return StatusBarNotification.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -398,4 +471,64 @@ class AndroidNotificationHostApi { return (__pigeon_replyList[0] as MessagingStyle?); } } + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. + /// + /// The keys of entries to fetch from notification's extras bundle must be + /// specified in the [desiredExtras] list. If this list is empty, then + /// [Notifications.extras] will also be empty. If value of the matched entry + /// is not of type string or is null, then that entry will be skipped. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() + Future> getActiveNotifications({required List desiredExtras}) async { + final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotifications$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = + await __pigeon_channel.send([desiredExtras]) as List?; + if (__pigeon_replyList == null) { + throw _createConnectionError(__pigeon_channelName); + } else if (__pigeon_replyList.length > 1) { + throw PlatformException( + code: __pigeon_replyList[0]! as String, + message: __pigeon_replyList[1] as String?, + details: __pigeon_replyList[2], + ); + } else if (__pigeon_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (__pigeon_replyList[0] as List?)!.cast(); + } + } + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) + Future cancel({String? tag, required int id}) async { + final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.cancel$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = + await __pigeon_channel.send([tag, id]) as List?; + if (__pigeon_replyList == null) { + throw _createConnectionError(__pigeon_channelName); + } else if (__pigeon_replyList.length > 1) { + throw PlatformException( + code: __pigeon_replyList[0]! as String, + message: __pigeon_replyList[1] as String?, + details: __pigeon_replyList[2], + ); + } else { + return; + } + } } diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index c0aa0ca831..6b517e5159 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -5,7 +5,7 @@ import 'package:http/http.dart' as http; import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/widgets.dart' hide Notification; import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Person; import '../api/notifications.dart'; @@ -20,6 +20,8 @@ import '../widgets/page.dart'; import '../widgets/store.dart'; import '../widgets/theme.dart'; +AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost; + /// Service for configuring our Android "notification channel". class NotificationChannelManager { @visibleForTesting @@ -54,7 +56,7 @@ class NotificationChannelManager { // channel ID and delete it. See zulip-mobile's `createNotificationChannel` // in android/app/src/main/java/com/zulipmobile/notifications/NotificationChannelManager.kt . static Future _ensureChannel() async { - await ZulipBinding.instance.androidNotificationHost.createNotificationChannel(NotificationChannel( + await _androidHost.createNotificationChannel(NotificationChannel( id: kChannelId, name: 'Messages', // TODO(i18n) importance: NotificationImportance.high, @@ -84,7 +86,7 @@ class NotificationDisplayManager { static void onFcmMessage(FcmMessage data, Map dataJson) { switch (data) { case MessageFcmMessage(): _onMessageFcmMessage(data, dataJson); - case RemoveFcmMessage(): break; // TODO(#341) handle + case RemoveFcmMessage(): _onRemoveFcmMessage(data); case UnexpectedFcmMessage(): break; // TODO(log) } } @@ -95,7 +97,7 @@ class NotificationDisplayManager { final groupKey = _groupKey(data); final conversationKey = _conversationKey(data, groupKey); - final oldMessagingStyle = await ZulipBinding.instance.androidNotificationHost + final oldMessagingStyle = await _androidHost .getActiveNotificationMessagingStyleByTag(conversationKey); final MessagingStyle messagingStyle; @@ -141,7 +143,7 @@ class NotificationDisplayManager { name: data.senderFullName, iconBitmap: await _fetchBitmap(data.senderAvatarUrl)))); - await ZulipBinding.instance.androidNotificationHost.notify( + await _androidHost.notify( // TODO the notification ID can be constant, instead of matching requestCode // (This is a legacy of `flutter_local_notifications`.) id: notificationIdAsHashOf(conversationKey), @@ -155,6 +157,10 @@ class NotificationDisplayManager { messagingStyle: messagingStyle, number: messagingStyle.messages.length, + extras: { + // Used to decide when a `RemoveFcmMessage` event should clear this notification. + kExtraLastZulipMessageId: data.zulipMessageId.toString(), + }, contentIntent: PendingIntent( // TODO make intent URLs distinct, instead of requestCode @@ -180,7 +186,7 @@ class NotificationDisplayManager { autoCancel: true, ); - await ZulipBinding.instance.androidNotificationHost.notify( + await _androidHost.notify( id: notificationIdAsHashOf(groupKey), tag: groupKey, channelId: NotificationChannelManager.kChannelId, @@ -202,6 +208,83 @@ class NotificationDisplayManager { ); } + static void _onRemoveFcmMessage(RemoveFcmMessage data) async { + // We have an FCM message telling us that some Zulip messages were read + // and should no longer appear as notifications. We'll remove their + // conversations' notifications, if appropriate, and then the whole + // notification group if it's now empty. + assert(debugLog('notif remove zulipMessageIds: ${data.zulipMessageIds}')); + + // There may be a lot of messages mentioned here, across a lot of + // conversations. But they'll all be for one account, so they'll + // fall under one notification group. + final groupKey = _groupKey(data); + + // Find any conversations we can cancel the notification for. + // The API doesn't lend itself to removing individual messages as + // they're read, so we wait until we're ready to remove the whole + // conversation's notification. For background discussion, see: + // https://github.com/zulip/zulip-mobile/pull/4842#pullrequestreview-725817909 + var haveRemaining = false; + final activeNotifications = await _androidHost.getActiveNotifications( + desiredExtras: [kExtraLastZulipMessageId]); + for (final statusBarNotification in activeNotifications) { + if (statusBarNotification == null) continue; // TODO(pigeon) eliminate this case + + // The StatusBarNotification object describes an active notification in the UI. + // Its `.tag`, `.id`, and `.notification` are the same values as we passed to + // [AndroidNotificationHostApi.notify] (and so to `NotificationManager#notify` + // in the underlying Android APIs). So these are good to match on and inspect. + final notification = statusBarNotification.notification; + + // Sadly we don't get toString on Pigeon data classes: flutter#59027 + assert(debugLog(' existing notif' + ' id: ${statusBarNotification.id}, tag: ${statusBarNotification.tag},' + ' notification: (group: ${notification.group}, extras: ${notification.extras}))')); + + // Don't act on notifications that are for other Zulip accounts/identities. + if (notification.group != groupKey) continue; + + // Don't act on the summary notification for the group. + if (statusBarNotification.tag == groupKey) continue; + + final lastMessageIdStr = notification.extras[kExtraLastZulipMessageId]; + assert(lastMessageIdStr != null); + if (lastMessageIdStr == null) continue; // TODO(log) + final lastMessageId = int.parse(lastMessageIdStr, radix: 10); + if (data.zulipMessageIds.contains(lastMessageId)) { + // The latest Zulip message in this conversation was read. + // That's our cue to cancel the notification for the conversation. + await _androidHost.cancel( + tag: statusBarNotification.tag, id: statusBarNotification.id); + assert(debugLog(' … notif cancelled.')); + } else { + // This notification is for another conversation that's still unread. + // We won't cancel the summary notification. + haveRemaining = true; + } + } + + if (!haveRemaining) { + // The notification group is now empty; it had no notifications we didn't + // just cancel, except the summary notification. Cancel that one too. + // + // Even though we enable the `autoCancel` flag for summary notification + // during creation, the summary notification doesn't get auto canceled if + // child notifications are canceled programatically as done above. + await _androidHost.cancel( + tag: groupKey, id: notificationIdAsHashOf(groupKey)); + } + } + + /// A key we use in [Notification.extras] for the [Message.id] of the + /// latest Zulip message in the notification's conversation. + /// + /// We use this to determine if a [RemoveFcmMessage] event should + /// clear that specific notification. + @visibleForTesting + static const kExtraLastZulipMessageId = 'lastZulipMessageId'; + /// A notification ID, derived as a hash of the given string key. /// /// The result fits in 31 bits, the size of a nonnegative Java `int`, diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 39842d8cc2..601410880c 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -122,6 +122,37 @@ class MessagingStyle { final bool isGroupConversation; } +/// Corresponds to `android.app.Notification` +/// +/// See: https://developer.android.com/reference/kotlin/android/app/Notification +class Notification { + Notification({required this.group, required this.extras}); + + final String group; + final Map extras; + // Various other properties too; add them if needed. +} + +/// Corresponds to `android.service.notification.StatusBarNotification` +/// +/// See: https://developer.android.com/reference/android/service/notification/StatusBarNotification +class StatusBarNotification { + StatusBarNotification({required this.id, required this.tag, required this.notification}); + + final int id; + final String tag; + final Notification notification; + + // Ignore `groupKey` and `key`. While the `.groupKey` contains the + // `.notification.group`, and the `.key` contains the `.id` and `.tag`, + // they also have more stuff added on (and their structure doesn't seem to + // be documented.) + // final String? groupKey; + // final String? key; + + // Various other properties too; add them if needed. +} + @HostApi() abstract class AndroidNotificationHostApi { /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. @@ -183,4 +214,19 @@ abstract class AndroidNotificationHostApi { /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getActiveNotifications() /// https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) MessagingStyle? getActiveNotificationMessagingStyleByTag(String tag); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.getActiveNotifications`. + /// + /// The keys of entries to fetch from notification's extras bundle must be + /// specified in the [desiredExtras] list. If this list is empty, then + /// [Notifications.extras] will also be empty. If value of the matched entry + /// is not of type string or is null, then that entry will be skipped. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#getActiveNotifications() + List getActiveNotifications({required List desiredExtras}); + + /// Corresponds to `androidx.core.app.NotificationManagerCompat.cancel`. + /// + /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat?hl=en#cancel(java.lang.String,int) + void cancel({String? tag, required int id}); } diff --git a/test/model/binding.dart b/test/model/binding.dart index 8892040079..7b0c056caa 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -555,10 +555,14 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { } List _notifyCalls = []; + Iterable get activeNotifications => _activeNotifications.values; + final Map<(int, String?), StatusBarNotification> _activeNotifications = {}; + final Map _activeNotificationsMessagingStyle = {}; /// Clears all active notifications that have been created via [notify]. void clearActiveNotifications() { + _activeNotifications.clear(); _activeNotificationsMessagingStyle.clear(); } @@ -599,6 +603,11 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { )); if (tag != null) { + _activeNotifications[(id, tag)] = StatusBarNotification( + id: id, + notification: Notification(group: groupKey ?? '', extras: extras ?? {}), + tag: tag); + _activeNotificationsMessagingStyle[tag] = messagingStyle == null ? null : MessagingStyle( @@ -613,13 +622,31 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { key: message.person.key, name: message.person.name, iconBitmap: null)), - ).toList()); + ).toList(growable: false)); } } @override Future getActiveNotificationMessagingStyleByTag(String tag) async => _activeNotificationsMessagingStyle[tag]; + + @override + Future> getActiveNotifications({required List desiredExtras}) async { + return _activeNotifications.values.map((statusNotif) { + final notificationExtras = statusNotif.notification.extras; + statusNotif.notification.extras = Map.fromEntries( + desiredExtras + .map((key) => MapEntry(key, notificationExtras[key])) + .where((entry) => entry.value != null) + ); + return statusNotif; + }).toList(growable: false); + } + + @override + Future cancel({String? tag, required int id}) async { + _activeNotifications.remove((id, tag)); + } } typedef AndroidNotificationHostApiNotifyCall = ({ diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 16f50fdc63..2cf45d8cf9 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -6,7 +6,7 @@ import 'package:checks/checks.dart'; import 'package:collection/collection.dart'; import 'package:fake_async/fake_async.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/widgets.dart' hide Notification; import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message, Person; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -74,6 +74,20 @@ MessageFcmMessage messageFcmMessage( }) as MessageFcmMessage; } +RemoveFcmMessage removeFcmMessage(List zulipMessages, {Account? account}) { + account ??= eg.selfAccount; + return FcmMessage.fromJson({ + "event": "remove", + + "server": "zulip.example.cloud", + "realm_id": "4", + "realm_uri": account.realmUrl.toString(), + "user_id": account.userId.toString(), + + "zulip_message_ids": zulipMessages.map((e) => e.id).join(','), + }) as RemoveFcmMessage; +} + void main() { TestZulipBinding.ensureInitialized(); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; @@ -171,7 +185,10 @@ void main() { ..number.equals(messageStyleMessages.length) ..color.equals(kZulipBrandColor.value) ..smallIconResourceName.equals('zulip_notification') - ..extras.isNull() + ..extras.which((it) => it.isNotNull() + ..deepEquals({ + NotificationDisplayManager.kExtraLastZulipMessageId: data.zulipMessageId.toString(), + })) ..groupKey.equals(expectedGroupKey) ..isGroupSummary.isNull() ..inboxStyle.isNull() @@ -227,12 +244,34 @@ void main() { expectedTagComponent: expectedTagComponent); } - Future receiveFcmMessage(FakeAsync async, MessageFcmMessage data) async { + Future receiveFcmMessage(FakeAsync async, FcmMessage data) async { testBinding.firebaseMessaging.onMessage.add( RemoteMessage(data: data.toJson())); async.flushMicrotasks(); } + Condition conditionActiveNotif(MessageFcmMessage data, String tagComponent) { + final expectedGroupKey = '${data.realmUri}|${data.userId}'; + final expectedTag = '$expectedGroupKey|$tagComponent'; + return (it) => it.isA() + ..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedTag)) + ..notification.which((it) => it + ..group.equals(expectedGroupKey) + ..extras.deepEquals({ + NotificationDisplayManager.kExtraLastZulipMessageId: data.zulipMessageId.toString(), + })) + ..tag.equals(expectedTag); + } + + Condition conditionSummaryActiveNotif(String expectedGroupKey) { + return (it) => it.isA() + ..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedGroupKey)) + ..notification.which((it) => it + ..group.equals(expectedGroupKey) + ..extras.isEmpty()) + ..tag.equals(expectedGroupKey); + } + test('stream message', () => runWithHttpClient(() => awaitFakeAsync((async) async { await init(); final stream = eg.stream(); @@ -495,6 +534,190 @@ void main() { expectedTitle: eg.selfUser.fullName, expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); }))); + + test('remove: smoke', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final message = eg.streamMessage(); + final data = messageFcmMessage(message); + final expectedGroupKey = '${data.realmUri}|${data.userId}'; + + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + + // Check on foreground event; onMessage + await receiveFcmMessage(async, data); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data, 'stream:${message.streamId}:${message.topic}'), + conditionSummaryActiveNotif(expectedGroupKey), + ]); + testBinding.firebaseMessaging.onMessage.add( + RemoteMessage(data: removeFcmMessage([message]).toJson())); + async.flushMicrotasks(); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + + // Check on background event; onBackgroundMessage + await receiveFcmMessage(async, data); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data, 'stream:${message.streamId}:${message.topic}'), + conditionSummaryActiveNotif(expectedGroupKey), + ]); + testBinding.firebaseMessaging.onBackgroundMessage.add( + RemoteMessage(data: removeFcmMessage([message]).toJson())); + async.flushMicrotasks(); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + }))); + + test('remove: clears conversation only if the removal event is for the last message', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final stream = eg.stream(); + const topicA = 'Topic A'; + final message1 = eg.streamMessage(stream: stream, topic: topicA); + final data1 = messageFcmMessage(message1, streamName: stream.name); + final message2 = eg.streamMessage(stream: stream, topic: topicA); + final data2 = messageFcmMessage(message2, streamName: stream.name); + final message3 = eg.streamMessage(stream: stream, topic: topicA); + final data3 = messageFcmMessage(message3, streamName: stream.name); + final expectedGroupKey = '${data1.realmUri}|${data1.userId}'; + + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + + await receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data2); + await receiveFcmMessage(async, data3); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data3, 'stream:${stream.streamId}:$topicA'), + conditionSummaryActiveNotif(expectedGroupKey), + ]); + + // A RemoveFcmMessage for the first two messages; the notification stays. + receiveFcmMessage(async, removeFcmMessage([message1, message2])); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data3, 'stream:${stream.streamId}:$topicA'), + conditionSummaryActiveNotif(expectedGroupKey), + ]); + + // Then a RemoveFcmMessage for the last message; clear the notification. + receiveFcmMessage(async, removeFcmMessage([message3])); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + }))); + + test('remove: clears summary notification only if all conversation notifications are cleared', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final stream = eg.stream(); + const topicA = 'Topic A'; + final message1 = eg.streamMessage(stream: stream, topic: topicA); + final data1 = messageFcmMessage(message1, streamName: stream.name); + const topicB = 'Topic B'; + final message2 = eg.streamMessage(stream: stream, topic: topicB); + final data2 = messageFcmMessage(message2, streamName: stream.name); + final expectedGroupKey = '${data1.realmUri}|${data1.userId}'; + + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + + // Two notifications for different conversations; but same account. + await receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data2); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data1, 'stream:${stream.streamId}:$topicA'), + conditionSummaryActiveNotif(expectedGroupKey), + conditionActiveNotif(data2, 'stream:${stream.streamId}:$topicB'), + ]); + + // A RemoveFcmMessage for first conversation; only clears the first conversation notif. + await receiveFcmMessage(async, removeFcmMessage([message1])); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionSummaryActiveNotif(expectedGroupKey), + conditionActiveNotif(data2, 'stream:${stream.streamId}:$topicB'), + ]); + + // Then a RemoveFcmMessage for the only remaining conversation; + // clears both the conversation notif and summary notif. + await receiveFcmMessage(async, removeFcmMessage([message2])); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + }))); + + + test('remove: different realm URLs but same user-ids and same message-ids', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final stream = eg.stream(); + const topic = 'Some Topic'; + + final account1 = eg.account( + realmUrl: Uri.parse('https://1.chat.example'), + id: 1001, + user: eg.user(userId: 1001)); + final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic); + final data1 = + messageFcmMessage(message1, account: account1, streamName: stream.name); + final groupKey1 = '${account1.realmUrl}|${account1.userId}'; + + final account2 = eg.account( + realmUrl: Uri.parse('https://2.chat.example'), + id: 1001, + user: eg.user(userId: 1001)); + final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic); + final data2 = + messageFcmMessage(message2, account: account2, streamName: stream.name); + final groupKey2 = '${account2.realmUrl}|${account2.userId}'; + + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + + await receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data2); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data1, 'stream:${stream.streamId}:$topic'), + conditionSummaryActiveNotif(groupKey1), + conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'), + conditionSummaryActiveNotif(groupKey2), + ]); + + await receiveFcmMessage(async, removeFcmMessage([message1], account: account1)); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'), + conditionSummaryActiveNotif(groupKey2), + ]); + + await receiveFcmMessage(async, removeFcmMessage([message2], account: account2)); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + }))); + + test('remove: different user-ids but same realm URL and same message-ids', () => runWithHttpClient(() => awaitFakeAsync((async) async { + await init(); + final realmUrl = eg.realmUrl; + final stream = eg.stream(); + const topic = 'Some Topic'; + + final account1 = eg.account(id: 1001, user: eg.user(userId: 1001), realmUrl: realmUrl); + final message1 = eg.streamMessage(id: 1000, stream: stream, topic: topic); + final data1 = + messageFcmMessage(message1, account: account1, streamName: stream.name); + final groupKey1 = '${account1.realmUrl}|${account1.userId}'; + + final account2 = eg.account(id: 1002, user: eg.user(userId: 1002), realmUrl: realmUrl); + final message2 = eg.streamMessage(id: 1000, stream: stream, topic: topic); + final data2 = + messageFcmMessage(message2, account: account2, streamName: stream.name); + final groupKey2 = '${account2.realmUrl}|${account2.userId}'; + + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + + await receiveFcmMessage(async, data1); + await receiveFcmMessage(async, data2); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data1, 'stream:${stream.streamId}:$topic'), + conditionSummaryActiveNotif(groupKey1), + conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'), + conditionSummaryActiveNotif(groupKey2), + ]); + + await receiveFcmMessage(async, removeFcmMessage([message1], account: account1)); + check(testBinding.androidNotificationHost.activeNotifications).deepEquals(>[ + conditionActiveNotif(data2, 'stream:${stream.streamId}:$topic'), + conditionSummaryActiveNotif(groupKey2), + ]); + + await receiveFcmMessage(async, removeFcmMessage([message2], account: account2)); + check(testBinding.androidNotificationHost.activeNotifications).isEmpty(); + }))); }); group('NotificationDisplayManager open', () { @@ -700,3 +923,14 @@ extension on Subject { Subject get timestampMs => has((x) => x.timestampMs, 'timestampMs'); Subject get person => has((x) => x.person, 'person'); } + +extension on Subject { + Subject get group => has((x) => x.group, 'group'); + Subject> get extras => has((x) => x.extras, 'extras'); +} + +extension on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get notification => has((x) => x.notification, 'notification'); + Subject get tag => has((x) => x.tag, 'tag'); +}