diff --git a/.idea/modules.xml b/.idea/modules.xml index 9ff1386a7f..b09507c4ac 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -3,6 +3,7 @@ + - + \ No newline at end of file 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 f86861e909..d896f319d3 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? = null, + 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/kotlin/android/service/notification/StatusBarNotification + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class StatusBarNotification ( + val id: Long, + val notification: Notification, + val tag: String? = null + +) { + 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 notification = __pigeon_list[1] as Notification + val tag = __pigeon_list[2] as String? + return StatusBarNotification(id, notification, tag) + } + } + fun toList(): List { + return listOf( + id, + notification, + tag, + ) + } +} 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) } } @@ -351,6 +428,7 @@ interface AndroidNotificationHostApi { * https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder */ fun notify(tag: String?, id: Long, autoCancel: Boolean?, channelId: String, color: Long?, contentIntent: PendingIntent?, contentText: String?, contentTitle: String?, extras: Map?, groupKey: String?, inboxStyle: InboxStyle?, isGroupSummary: Boolean?, messagingStyle: MessagingStyle?, number: Long?, smallIconResourceName: String?) + fun getActiveNotifications(): List /** * Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, * combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. @@ -365,6 +443,12 @@ interface AndroidNotificationHostApi { * https://developer.android.com/reference/kotlin/androidx/core/app/NotificationCompat.MessagingStyle#extractMessagingStyleFromNotification(android.app.Notification) */ fun getActiveNotificationMessagingStyleByTag(tag: String): MessagingStyle? + /** + * Corresponds to `android.app.NotificationManager.cancel`. + * + * See: https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#cancel + */ + fun cancel(tag: String?, id: Long) companion object { /** The codec used by AndroidNotificationHostApi. */ @@ -425,6 +509,21 @@ interface AndroidNotificationHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotifications$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getActiveNotifications()) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotificationMessagingStyleByTag$separatedMessageChannelSuffix", codec) if (api != null) { @@ -442,6 +541,25 @@ interface AndroidNotificationHostApi { 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..3314ef2695 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt @@ -1,6 +1,7 @@ package com.zulip.flutter import android.annotation.SuppressLint +import android.app.NotificationManager import android.content.Context import android.content.Intent import android.os.Bundle @@ -14,6 +15,13 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin private const val TAG = "ZulipPlugin" +private fun Bundle.stringsMap(): Map { + return keySet().associateWith { + try { get(it) as CharSequence? } + catch (e: Throwable) { null } + } +} + fun toAndroidPerson(person: Person): androidx.core.app.Person { return androidx.core.app.Person.Builder().apply { person.iconBitmap?.let { setIcon(IconCompat.createWithData(it, 0, it.size)) } @@ -121,6 +129,20 @@ private class AndroidNotificationHost(val context: Context) NotificationManagerCompat.from(context).notify(tag, id.toInt(), notification) } + override fun getActiveNotifications(): List { + val notificationManager = context.getSystemService(NotificationManager::class.java)!! + return notificationManager.activeNotifications.map { + StatusBarNotification( + id = it.id.toLong(), + tag = it.tag, + notification = Notification( + group = it.notification.group, + extras = it.notification.extras.stringsMap() as Map, + ) + ) + } + } + override fun getActiveNotificationMessagingStyleByTag(tag: String): MessagingStyle? { val activeNotification = NotificationManagerCompat.from(context) .activeNotifications @@ -143,6 +165,11 @@ private class AndroidNotificationHost(val context: Context) } return null } + + override fun cancel(tag: String?, id: Long) { + val notificationManager = context.getSystemService(NotificationManager::class.java)!! + notificationManager.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 413376731e..ce1068fae2 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({ + 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/kotlin/android/service/notification/StatusBarNotification +class StatusBarNotification { + StatusBarNotification({ + required this.id, + required this.notification, + this.tag, + }); + + int id; + + Notification notification; + + String? tag; + + Object encode() { + return [ + id, + notification, + tag, + ]; + } + + static StatusBarNotification decode(Object result) { + result as List; + return StatusBarNotification( + id: result[0]! as int, + notification: result[1]! as Notification, + tag: result[2] as String?, + ); + } +} + 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); } @@ -366,6 +439,33 @@ class AndroidNotificationHostApi { } } + Future> getActiveNotifications() 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(null) 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(); + } + } + /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. /// @@ -398,4 +498,29 @@ class AndroidNotificationHostApi { return (__pigeon_replyList[0] as MessagingStyle?); } } + + /// Corresponds to `android.app.NotificationManager.cancel`. + /// + /// See: https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#cancel + 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 0787a61bee..ad7a73c997 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -84,7 +84,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) } } @@ -156,6 +156,10 @@ class NotificationDisplayManager { messagingStyle: messagingStyle, number: messagingStyle.messages.length, + extras: { + _extraZulipMessageId: data.zulipMessageId.toString(), + }, + contentIntent: PendingIntent( // TODO make intent URLs distinct, instead of requestCode // (This way is a legacy of flutter_local_notifications.) @@ -202,6 +206,34 @@ class NotificationDisplayManager { ); } + static void _onRemoveFcmMessage(RemoveFcmMessage data) async { + assert(debugLog('notif remove zulipMessageIds: ${data.zulipMessageIds}')); + + final api = AndroidNotificationHostApi(); + final notifs = await api.getActiveNotifications(); + for (final statusBarNotification in notifs) { + if (statusBarNotification == null) continue; // TODO(pigeon) eliminate this case + 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}))')); + + final rawZulipMessageId = notification.extras[_extraZulipMessageId]; + if (rawZulipMessageId is! String) continue; + final zulipMessageId = int.parse(rawZulipMessageId, radix: 10); + if (data.zulipMessageIds.contains(zulipMessageId)) { + // The latest Zulip message in this conversation was read. + // That's our cue to cancel the notification for the conversation. + await api.cancel(tag: statusBarNotification.tag, id: statusBarNotification.id); + assert(debugLog(' … notif cancelled.')); + } + } + } + + static const _extraZulipMessageId = 'zulipMessageId'; + /// 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..3f1074928b 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/kotlin/android/service/notification/StatusBarNotification +class StatusBarNotification { + StatusBarNotification({required this.id, required this.notification, required this.tag}); + + final int id; + final Notification notification; + final String? tag; + + // 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`. @@ -171,6 +202,12 @@ abstract class AndroidNotificationHostApi { // Keep them alphabetized, for easy comparison with that class's docs. }); + /// Corresponds to `android.app.NotificationManager.getActiveNotifications`. + /// + /// See: https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#getactivenotifications + // TODO accept a list of extras to care about + List getActiveNotifications(); + /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. /// @@ -183,4 +220,9 @@ 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 `android.app.NotificationManager.cancel`. + /// + /// See: https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#cancel + void cancel({String? tag, required int id}); }