From 4e3e0a4d175ea7c4b76d845bd07a2a6400cbe92a Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Mon, 8 Jul 2024 18:10:37 +0530 Subject: [PATCH 1/3] notif: Add messaging-style notifications support to Pigeon bindings Add methods and types for creating messaging style notifications: https://developer.android.com/develop/ui/views/notifications/build-notification#messaging-style --- .../com/zulip/flutter/Notifications.g.kt | 172 +++++++++++++++++- .../kotlin/com/zulip/flutter/ZulipPlugin.kt | 67 +++++++ lib/host/android_notifications.g.dart | 167 ++++++++++++++++- pigeon/notifications.dart | 73 ++++++++ test/model/binding.dart | 37 +++- 5 files changed, 510 insertions(+), 6 deletions(-) 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 0e0b640c9c..5aaa5b5178 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 @@ -107,6 +107,112 @@ data class InboxStyle ( ) } } + +/** + * Corresponds to `androidx.core.app.Person` + * + * See: https://developer.android.com/reference/androidx/core/app/Person + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class Person ( + /** + * An icon for this person. + * + * This should be compressed image data, in a format to be passed + * to `androidx.core.graphics.drawable.IconCompat.createWithData`. + * Supported formats include JPEG, PNG, and WEBP. + * + * See: + * https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) + */ + val iconBitmap: ByteArray? = null, + val key: String, + val name: String + +) { + companion object { + @Suppress("LocalVariableName") + fun fromList(__pigeon_list: List): Person { + val iconBitmap = __pigeon_list[0] as ByteArray? + val key = __pigeon_list[1] as String + val name = __pigeon_list[2] as String + return Person(iconBitmap, key, name) + } + } + fun toList(): List { + return listOf( + iconBitmap, + key, + name, + ) + } +} + +/** + * Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` + * + * See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class MessagingStyleMessage ( + val text: String, + val timestampMs: Long, + val person: Person + +) { + companion object { + @Suppress("LocalVariableName") + fun fromList(__pigeon_list: List): MessagingStyleMessage { + val text = __pigeon_list[0] as String + val timestampMs = __pigeon_list[1].let { num -> if (num is Int) num.toLong() else num as Long } + val person = __pigeon_list[2] as Person + return MessagingStyleMessage(text, timestampMs, person) + } + } + fun toList(): List { + return listOf( + text, + timestampMs, + person, + ) + } +} + +/** + * Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` + * + * See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class MessagingStyle ( + val user: Person, + val conversationTitle: String? = null, + val messages: List, + val isGroupConversation: Boolean + +) { + companion object { + @Suppress("LocalVariableName") + fun fromList(__pigeon_list: List): MessagingStyle { + val user = __pigeon_list[0] as Person + val conversationTitle = __pigeon_list[1] as String? + val messages = __pigeon_list[2] as List + val isGroupConversation = __pigeon_list[3] as Boolean + return MessagingStyle(user, conversationTitle, messages, isGroupConversation) + } + } + fun toList(): List { + return listOf( + user, + conversationTitle, + messages, + isGroupConversation, + ) + } +} private object NotificationsPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -120,6 +226,21 @@ private object NotificationsPigeonCodec : StandardMessageCodec() { InboxStyle.fromList(it) } } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + Person.fromList(it) + } + } + 132.toByte() -> { + return (readValue(buffer) as? List)?.let { + MessagingStyleMessage.fromList(it) + } + } + 133.toByte() -> { + return (readValue(buffer) as? List)?.let { + MessagingStyle.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -133,6 +254,18 @@ private object NotificationsPigeonCodec : StandardMessageCodec() { stream.write(130) writeValue(stream, value.toList()) } + is Person -> { + stream.write(131) + writeValue(stream, value.toList()) + } + is MessagingStyleMessage -> { + stream.write(132) + writeValue(stream, value.toList()) + } + is MessagingStyle -> { + stream.write(133) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -159,7 +292,21 @@ interface AndroidNotificationHostApi { * https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify * 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?, smallIconResourceName: String?) + 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?) + /** + * Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, + * combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. + * + * Returns the messaging style, if any, of an active notification + * that has tag `tag`. If there are several such notifications, + * an arbitrary one of them is used. + * Returns null if there are no such notifications. + * + * See: + * 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) + */ + fun getActiveNotificationMessagingStyleByTag(tag: String): MessagingStyle? companion object { /** The codec used by AndroidNotificationHostApi. */ @@ -187,9 +334,11 @@ interface AndroidNotificationHostApi { val groupKeyArg = args[9] as String? val inboxStyleArg = args[10] as InboxStyle? val isGroupSummaryArg = args[11] as Boolean? - val smallIconResourceNameArg = args[12] as String? + val messagingStyleArg = args[12] as MessagingStyle? + val numberArg = args[13].let { num -> if (num is Int) num.toLong() else num as Long? } + val smallIconResourceNameArg = args[14] as String? val wrapped: List = try { - api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, smallIconResourceNameArg) + api.notify(tagArg, idArg, autoCancelArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, groupKeyArg, inboxStyleArg, isGroupSummaryArg, messagingStyleArg, numberArg, smallIconResourceNameArg) listOf(null) } catch (exception: Throwable) { wrapError(exception) @@ -200,6 +349,23 @@ interface AndroidNotificationHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotificationMessagingStyleByTag$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tagArg = args[0] as String + val wrapped: List = try { + listOf(api.getActiveNotificationMessagingStyleByTag(tagArg)) + } 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 03db2333fd..03d60a2a4d 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt @@ -8,10 +8,38 @@ import android.util.Log import androidx.annotation.Keep import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.graphics.drawable.IconCompat import io.flutter.embedding.engine.plugins.FlutterPlugin private const val TAG = "ZulipPlugin" +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)) } + setKey(person.key) + setName(person.name) + }.build() +} + +fun toPigeonPerson(person: androidx.core.app.Person): Person { + return Person( + // The API doesn't provide a way to retrieve the icon data, + // so we set this to null. + // + // Notably, Android retains a limited number [1] of messages + // in the messaging style, and it also retains the icon data + // for persons within those messages. Therefore, there's no + // need to include the person's icon data in each message. + // Only one icon data instance is needed for each unique + // person's key in the retained messages. + // + // [1]: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle#MAXIMUM_RETAINED_MESSAGES() + null, + person.key!!, + person.name!!.toString(), + ) +} + private class AndroidNotificationHost(val context: Context) : AndroidNotificationHostApi { @SuppressLint( @@ -33,6 +61,8 @@ private class AndroidNotificationHost(val context: Context) groupKey: String?, inboxStyle: InboxStyle?, isGroupSummary: Boolean?, + messagingStyle: MessagingStyle?, + number: Long?, smallIconResourceName: String? ) { val notification = NotificationCompat.Builder(context, channelId).apply { @@ -60,11 +90,48 @@ private class AndroidNotificationHost(val context: Context) .setSummaryText(it.summaryText) ) } isGroupSummary?.let { setGroupSummary(it) } + messagingStyle?.let { messagingStyle -> + val style = NotificationCompat.MessagingStyle(toAndroidPerson(messagingStyle.user)) + .setConversationTitle(messagingStyle.conversationTitle) + .setGroupConversation(messagingStyle.isGroupConversation) + messagingStyle.messages.forEach { it?.let { + style.addMessage(NotificationCompat.MessagingStyle.Message( + it.text, + it.timestampMs, + toAndroidPerson(it.person), + )) + } } + setStyle(style) + } + number?.let { setNumber(it.toInt()) } smallIconResourceName?.let { setSmallIcon(context.resources.getIdentifier( it, "drawable", context.packageName)) } }.build() NotificationManagerCompat.from(context).notify(tag, id.toInt(), notification) } + + override fun getActiveNotificationMessagingStyleByTag(tag: String): MessagingStyle? { + val activeNotification = NotificationManagerCompat.from(context) + .activeNotifications + .find { it.tag == tag } + activeNotification?.notification?.let { notification -> + NotificationCompat.MessagingStyle + .extractMessagingStyleFromNotification(notification) + ?.let { style -> + return MessagingStyle( + toPigeonPerson(style.user), + style.conversationTitle!!.toString(), + style.messages.map { MessagingStyleMessage( + it.text!!.toString(), + it.timestamp, + toPigeonPerson(it.person!!) + ) }, + style.isGroupConversation, + ) + } + } + return null + } } /** 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 ea49034a94..ca4acda559 100644 --- a/lib/host/android_notifications.g.dart +++ b/lib/host/android_notifications.g.dart @@ -77,6 +77,121 @@ class InboxStyle { } } +/// Corresponds to `androidx.core.app.Person` +/// +/// See: https://developer.android.com/reference/androidx/core/app/Person +class Person { + Person({ + this.iconBitmap, + required this.key, + required this.name, + }); + + /// An icon for this person. + /// + /// This should be compressed image data, in a format to be passed + /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. + /// Supported formats include JPEG, PNG, and WEBP. + /// + /// See: + /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) + Uint8List? iconBitmap; + + String key; + + String name; + + Object encode() { + return [ + iconBitmap, + key, + name, + ]; + } + + static Person decode(Object result) { + result as List; + return Person( + iconBitmap: result[0] as Uint8List?, + key: result[1]! as String, + name: result[2]! as String, + ); + } +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message +class MessagingStyleMessage { + MessagingStyleMessage({ + required this.text, + required this.timestampMs, + required this.person, + }); + + String text; + + int timestampMs; + + Person person; + + Object encode() { + return [ + text, + timestampMs, + person, + ]; + } + + static MessagingStyleMessage decode(Object result) { + result as List; + return MessagingStyleMessage( + text: result[0]! as String, + timestampMs: result[1]! as int, + person: result[2]! as Person, + ); + } +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle +class MessagingStyle { + MessagingStyle({ + required this.user, + this.conversationTitle, + required this.messages, + required this.isGroupConversation, + }); + + Person user; + + String? conversationTitle; + + List messages; + + bool isGroupConversation; + + Object encode() { + return [ + user, + conversationTitle, + messages, + isGroupConversation, + ]; + } + + static MessagingStyle decode(Object result) { + result as List; + return MessagingStyle( + user: result[0]! as Person, + conversationTitle: result[1] as String?, + messages: (result[2] as List?)!.cast(), + isGroupConversation: result[3]! as bool, + ); + } +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @@ -88,6 +203,15 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is InboxStyle) { buffer.putUint8(130); writeValue(buffer, value.encode()); + } else if (value is Person) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is MessagingStyleMessage) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is MessagingStyle) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -100,6 +224,12 @@ class _PigeonCodec extends StandardMessageCodec { return PendingIntent.decode(readValue(buffer)!); case 130: return InboxStyle.decode(readValue(buffer)!); + case 131: + return Person.decode(readValue(buffer)!); + case 132: + return MessagingStyleMessage.decode(readValue(buffer)!); + case 133: + return MessagingStyle.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -136,7 +266,7 @@ class AndroidNotificationHostApi { /// See: /// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify /// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder - Future notify({String? tag, required int id, bool? autoCancel, required String channelId, int? color, PendingIntent? contentIntent, String? contentText, String? contentTitle, Map? extras, String? groupKey, InboxStyle? inboxStyle, bool? isGroupSummary, String? smallIconResourceName,}) async { + Future notify({String? tag, required int id, bool? autoCancel, required String channelId, int? color, PendingIntent? contentIntent, String? contentText, String? contentTitle, Map? extras, String? groupKey, InboxStyle? inboxStyle, bool? isGroupSummary, MessagingStyle? messagingStyle, int? number, String? smallIconResourceName,}) async { final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$__pigeon_messageChannelSuffix'; final BasicMessageChannel __pigeon_channel = BasicMessageChannel( __pigeon_channelName, @@ -144,7 +274,7 @@ class AndroidNotificationHostApi { binaryMessenger: __pigeon_binaryMessenger, ); final List? __pigeon_replyList = - await __pigeon_channel.send([tag, id, autoCancel, channelId, color, contentIntent, contentText, contentTitle, extras, groupKey, inboxStyle, isGroupSummary, smallIconResourceName]) as List?; + await __pigeon_channel.send([tag, id, autoCancel, channelId, color, contentIntent, contentText, contentTitle, extras, groupKey, inboxStyle, isGroupSummary, messagingStyle, number, smallIconResourceName]) as List?; if (__pigeon_replyList == null) { throw _createConnectionError(__pigeon_channelName); } else if (__pigeon_replyList.length > 1) { @@ -157,4 +287,37 @@ class AndroidNotificationHostApi { return; } } + + /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, + /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. + /// + /// Returns the messaging style, if any, of an active notification + /// that has tag `tag`. If there are several such notifications, + /// an arbitrary one of them is used. + /// Returns null if there are no such notifications. + /// + /// See: + /// 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) + Future getActiveNotificationMessagingStyleByTag(String tag) async { + final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getActiveNotificationMessagingStyleByTag$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = + await __pigeon_channel.send([tag]) 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 (__pigeon_replyList[0] as MessagingStyle?); + } + } } diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 2a362bba82..fe95debeaa 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -36,6 +36,64 @@ class InboxStyle { final String summaryText; } +/// Corresponds to `androidx.core.app.Person` +/// +/// See: https://developer.android.com/reference/androidx/core/app/Person +class Person { + Person({ + required this.iconBitmap, + required this.key, + required this.name, + }); + + /// An icon for this person. + /// + /// This should be compressed image data, in a format to be passed + /// to `androidx.core.graphics.drawable.IconCompat.createWithData`. + /// Supported formats include JPEG, PNG, and WEBP. + /// + /// See: + /// https://developer.android.com/reference/androidx/core/graphics/drawable/IconCompat#createWithData(byte[],int,int) + final Uint8List? iconBitmap; + + final String key; + final String name; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle.Message` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle.Message +class MessagingStyleMessage { + MessagingStyleMessage({ + required this.text, + required this.timestampMs, + required this.person, + }); + + final String text; + final int timestampMs; + final Person person; +} + +/// Corresponds to `androidx.core.app.NotificationCompat.MessagingStyle` +/// +/// See: https://developer.android.com/reference/androidx/core/app/NotificationCompat.MessagingStyle +class MessagingStyle { + MessagingStyle({ + required this.user, + required this.conversationTitle, + required this.isGroupConversation, + required this.messages, + }); + + final Person user; + final String? conversationTitle; + // TODO(pigeon): Make list item non-nullable, once pigeon supports non-nullable type arguments. + // https://github.com/flutter/flutter/issues/97848 + final List messages; + final bool isGroupConversation; +} + @HostApi() abstract class AndroidNotificationHostApi { /// Corresponds to `android.app.NotificationManager.notify`, @@ -73,8 +131,23 @@ abstract class AndroidNotificationHostApi { String? groupKey, InboxStyle? inboxStyle, bool? isGroupSummary, + MessagingStyle? messagingStyle, + int? number, String? smallIconResourceName, // NotificationCompat.Builder has lots more methods; add as needed. // Keep them alphabetized, for easy comparison with that class's docs. }); + + /// Wraps `androidx.core.app.NotificationManagerCompat.getActiveNotifications`, + /// combined with `androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification`. + /// + /// Returns the messaging style, if any, of an active notification + /// that has tag `tag`. If there are several such notifications, + /// an arbitrary one of them is used. + /// Returns null if there are no such notifications. + /// + /// See: + /// 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); } diff --git a/test/model/binding.dart b/test/model/binding.dart index d070bc1d46..3609aa7cd9 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Person; import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; @@ -506,6 +506,13 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { } List _notifyCalls = []; + final Map _activeNotificationsMessagingStyle = {}; + + /// Clears all active notifications that have been created via [notify]. + void clearActiveNotifications() { + _activeNotificationsMessagingStyle.clear(); + } + @override Future notify({ String? tag, @@ -520,6 +527,8 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { String? groupKey, InboxStyle? inboxStyle, bool? isGroupSummary, + MessagingStyle? messagingStyle, + int? number, String? smallIconResourceName, }) async { _notifyCalls.add(( @@ -535,9 +544,33 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { groupKey: groupKey, inboxStyle: inboxStyle, isGroupSummary: isGroupSummary, + messagingStyle: messagingStyle, + number: number, smallIconResourceName: smallIconResourceName, )); + + if (tag != null) { + _activeNotificationsMessagingStyle[tag] = messagingStyle == null + ? null + : MessagingStyle( + user: messagingStyle.user, + conversationTitle: messagingStyle.conversationTitle, + isGroupConversation: messagingStyle.isGroupConversation, + messages: messagingStyle.messages.map((message) => + MessagingStyleMessage( + text: message!.text, + timestampMs: message.timestampMs, + person: Person( + key: message.person.key, + name: message.person.name, + iconBitmap: null)), + ).toList()); + } } + + @override + Future getActiveNotificationMessagingStyleByTag(String tag) async => + _activeNotificationsMessagingStyle[tag]; } typedef AndroidNotificationHostApiNotifyCall = ({ @@ -553,5 +586,7 @@ typedef AndroidNotificationHostApiNotifyCall = ({ String? groupKey, InboxStyle? inboxStyle, bool? isGroupSummary, + MessagingStyle? messagingStyle, + int? number, String? smallIconResourceName, }); From 1596a1a97c594cd94e1b029d6dd1a393288402a6 Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Mon, 15 Jul 2024 13:08:43 +0530 Subject: [PATCH 2/3] notif: Create messaging-style notifications Use messaging style notifications to display messages with sender's name and avatars, along with support for displaying multiple messages from a specific topic by updating existing notification from notifications panel. See: https://developer.android.com/develop/ui/views/notifications/build-notification#messaging-style This change is similar to existing implementation in zulip-mobile: https://github.com/zulip/zulip-mobile/blob/e352f563ecf2fa9b09b688d5a65b6bc89b0358bc/android/app/src/main/java/com/zulipmobile/notifications/NotificationUiManager.kt#L177-L309 Fixes: #128 --- lib/notifications/display.dart | 63 +++++++++++-- test/notifications/display_test.dart | 128 ++++++++++++++++++++++++--- 2 files changed, 173 insertions(+), 18 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 54e8d18c19..64847b33c2 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -1,10 +1,11 @@ import 'dart:convert'; +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_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Person; import '../api/notifications.dart'; import '../host/android_notifications.dart'; @@ -92,7 +93,36 @@ class NotificationDisplayManager { static Future _onMessageFcmMessage(MessageFcmMessage data, Map dataJson) async { assert(debugLog('notif message content: ${data.content}')); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final title = switch (data.recipient) { + final groupKey = _groupKey(data); + final conversationKey = _conversationKey(data, groupKey); + + final oldMessagingStyle = await ZulipBinding.instance.androidNotificationHost + .getActiveNotificationMessagingStyleByTag(conversationKey); + + final MessagingStyle messagingStyle; + if (oldMessagingStyle != null) { + messagingStyle = oldMessagingStyle; + messagingStyle.messages = + oldMessagingStyle.messages.toList(); // Clone fixed-length list to growable. + } else { + messagingStyle = MessagingStyle( + user: Person( + key: _personKey(data.realmUri, data.userId), + name: 'You'), // TODO(i18n) + messages: [], + isGroupConversation: switch (data.recipient) { + FcmMessageStreamRecipient() => true, + FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => true, + FcmMessageDmRecipient() => false, + }); + } + + // The title typically won't change between messages in a conversation, but we + // update it anyway. This means a DM sender's display name gets updated if it's + // changed, which is a rare edge case but probably good. The main effect is that + // group-DM threads (pending #794) get titled with the latest sender, rather than + // the first. + messagingStyle.conversationTitle = switch (data.recipient) { FcmMessageStreamRecipient(:var streamName?, :var topic) => '#$streamName > $topic', FcmMessageStreamRecipient(:var topic) => @@ -103,8 +133,14 @@ class NotificationDisplayManager { FcmMessageDmRecipient() => data.senderFullName, }; - final groupKey = _groupKey(data); - final conversationKey = _conversationKey(data, groupKey); + + messagingStyle.messages.add(MessagingStyleMessage( + text: data.content, + timestampMs: data.time * 1000, + person: Person( + key: _personKey(data.realmUri, data.senderId), + name: data.senderFullName, + iconBitmap: await _fetchBitmap(data.senderAvatarUrl)))); await ZulipBinding.instance.androidNotificationHost.notify( // TODO the notification ID can be constant, instead of matching requestCode @@ -114,12 +150,12 @@ class NotificationDisplayManager { channelId: NotificationChannelManager.kChannelId, groupKey: groupKey, - contentTitle: title, - contentText: data.content, color: kZulipBrandColor.value, // TODO vary notification icon for debug smallIconResourceName: 'zulip_notification', // This name must appear in keep.xml too: https://github.com/zulip/zulip-flutter/issues/528 - // TODO(#128) inbox-style + + messagingStyle: messagingStyle, + number: messagingStyle.messages.length, contentIntent: PendingIntent( // TODO make intent URLs distinct, instead of requestCode @@ -196,6 +232,8 @@ class NotificationDisplayManager { return "${data.realmUri}|${data.userId}"; } + static String _personKey(Uri realmUri, int userId) => "$realmUri|$userId"; + static void _onNotificationOpened(NotificationResponse response) async { final payload = jsonDecode(response.payload!) as Map; final data = MessageFcmMessage.fromJson(payload); @@ -238,4 +276,15 @@ class NotificationDisplayManager { page: MessageListPage(narrow: narrow))); return; } + + static Future _fetchBitmap(Uri url) async { + try { + // TODO timeout to prevent waiting indefinitely + final resp = await http.get(url); + return resp.bodyBytes; + } catch (e) { + // TODO(log) + return null; + } + } } diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index b1a7183b87..6f7ae3cffd 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -2,10 +2,11 @@ import 'dart:convert'; import 'dart:typed_data'; 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/material.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message, Person; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/notifications.dart'; @@ -107,8 +108,10 @@ void main() { group('NotificationDisplayManager show', () { void checkNotification(MessageFcmMessage data, { + required List messageStyleMessages, required String expectedTitle, required String expectedTagComponent, + required bool expectedIsGroupConversation, }) { final expectedTag = '${data.realmUri}|${data.userId}|$expectedTagComponent'; final expectedGroupKey = '${data.realmUri}|${data.userId}'; @@ -116,16 +119,42 @@ void main() { NotificationDisplayManager.notificationIdAsHashOf(expectedTag); const expectedIntentFlags = PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent; + final expectedSelfUserKey = '${data.realmUri}|${data.userId}'; + + final messageStyleMessagesChecks = + messageStyleMessages.mapIndexed((i, messageData) { + assert(messageData.realmUri == data.realmUri); + assert(messageData.userId == data.userId); + + final expectedSenderKey = + '${messageData.realmUri}|${messageData.senderId}'; + final isLast = i == (messageStyleMessages.length - 1); + return (Subject it) => it.isA() + ..text.equals(messageData.content) + ..timestampMs.equals(messageData.time * 1000) + ..person.which((it) => it.isNotNull() + ..iconBitmap.which((it) => isLast ? it.isNotNull() : it.isNull()) + ..key.equals(expectedSenderKey) + ..name.equals(messageData.senderFullName)); + }); check(testBinding.androidNotificationHost.takeNotifyCalls()) - ..length.equals(2) - ..containsInOrder(>[ - (it) => it + .deepEquals(>[ + (it) => it.isA() ..id.equals(expectedId) ..tag.equals(expectedTag) ..channelId.equals(NotificationChannelManager.kChannelId) - ..contentTitle.equals(expectedTitle) - ..contentText.equals(data.content) + ..contentTitle.isNull() + ..contentText.isNull() + ..messagingStyle.which((it) => it.isNotNull() + ..user.which((it) => it + ..iconBitmap.isNull() + ..key.equals(expectedSelfUserKey) + ..name.equals('You')) // TODO(i18n) + ..isGroupConversation.equals(expectedIsGroupConversation) + ..conversationTitle.equals(expectedTitle) + ..messages.deepEquals(messageStyleMessagesChecks)) + ..number.equals(messageStyleMessages.length) ..color.equals(kZulipBrandColor.value) ..smallIconResourceName.equals('zulip_notification') ..extras.isNull() @@ -137,7 +166,7 @@ void main() { ..requestCode.equals(expectedId) ..flags.equals(expectedIntentFlags) ..intentPayload.equals(jsonEncode(data.toJson()))), - (it) => it + (it) => it.isA() ..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedGroupKey)) ..tag.equals(expectedGroupKey) ..channelId.equals(NotificationChannelManager.kChannelId) @@ -151,13 +180,14 @@ void main() { ..inboxStyle.which((it) => it.isNotNull() ..summaryText.equals(data.realmUri.toString())) ..autoCancel.equals(true) - ..contentIntent.isNull() + ..contentIntent.isNull(), ]); } Future checkNotifications(FakeAsync async, MessageFcmMessage data, { required String expectedTitle, required String expectedTagComponent, + required bool expectedIsGroupConversation, }) async { // We could just call `NotificationDisplayManager.onFcmMessage`. // But this way is cheap, and it provides our test coverage of @@ -166,30 +196,81 @@ void main() { testBinding.firebaseMessaging.onMessage.add( RemoteMessage(data: data.toJson())); async.flushMicrotasks(); - checkNotification(data, expectedTitle: expectedTitle, + checkNotification(data, + messageStyleMessages: [data], + expectedIsGroupConversation: expectedIsGroupConversation, + expectedTitle: expectedTitle, expectedTagComponent: expectedTagComponent); + testBinding.androidNotificationHost.clearActiveNotifications(); testBinding.firebaseMessaging.onBackgroundMessage.add( RemoteMessage(data: data.toJson())); async.flushMicrotasks(); - checkNotification(data, expectedTitle: expectedTitle, + checkNotification(data, + messageStyleMessages: [data], + expectedIsGroupConversation: expectedIsGroupConversation, + expectedTitle: expectedTitle, expectedTagComponent: expectedTagComponent); } + Future receiveFcmMessage(FakeAsync async, MessageFcmMessage data) async { + testBinding.firebaseMessaging.onMessage.add( + RemoteMessage(data: data.toJson())); + async.flushMicrotasks(); + } + test('stream message', () => awaitFakeAsync((async) async { await init(); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); await checkNotifications(async, messageFcmMessage(message, streamName: stream.name), + expectedIsGroupConversation: true, expectedTitle: '#${stream.name} > ${message.topic}', expectedTagComponent: 'stream:${message.streamId}:${message.topic}'); })); - test('stream message, stream name omitted', () => awaitFakeAsync((async) async { + test('stream message: multiple messages, same topic', () => awaitFakeAsync((async) async { + await init(); + final stream = eg.stream(); + const topic = 'topic 1'; + final message1 = eg.streamMessage(topic: topic, stream: stream); + final data1 = messageFcmMessage(message1, streamName: stream.name); + final message2 = eg.streamMessage(topic: topic, stream: stream); + final data2 = messageFcmMessage(message2, streamName: stream.name); + final message3 = eg.streamMessage(topic: topic, stream: stream); + final data3 = messageFcmMessage(message3, streamName: stream.name); + + final expectedTitle = '#${stream.name} > $topic'; + final expectedTagComponent = 'stream:${stream.streamId}:$topic'; + + await receiveFcmMessage(async, data1); + checkNotification(data1, + messageStyleMessages: [data1], + expectedIsGroupConversation: true, + expectedTitle: expectedTitle, + expectedTagComponent: expectedTagComponent); + + await receiveFcmMessage(async, data2); + checkNotification(data2, + messageStyleMessages: [data1, data2], + expectedIsGroupConversation: true, + expectedTitle: expectedTitle, + expectedTagComponent: expectedTagComponent); + + await receiveFcmMessage(async, data3); + checkNotification(data3, + messageStyleMessages: [data1, data2, data3], + expectedIsGroupConversation: true, + expectedTitle: expectedTitle, + expectedTagComponent: expectedTagComponent); + })); + + test('stream message: stream name omitted', () => awaitFakeAsync((async) async { await init(); final stream = eg.stream(); final message = eg.streamMessage(stream: stream); await checkNotifications(async, messageFcmMessage(message, streamName: null), + expectedIsGroupConversation: true, expectedTitle: '#(unknown channel) > ${message.topic}', expectedTagComponent: 'stream:${message.streamId}:${message.topic}'); })); @@ -198,6 +279,7 @@ void main() { await init(); final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]); await checkNotifications(async, messageFcmMessage(message), + expectedIsGroupConversation: true, expectedTitle: "${eg.thirdUser.fullName} to you and 1 other", expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); })); @@ -207,6 +289,7 @@ void main() { final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser, eg.fourthUser]); await checkNotifications(async, messageFcmMessage(message), + expectedIsGroupConversation: true, expectedTitle: "${eg.thirdUser.fullName} to you and 2 others", expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); })); @@ -215,6 +298,7 @@ void main() { await init(); final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await checkNotifications(async, messageFcmMessage(message), + expectedIsGroupConversation: false, expectedTitle: eg.otherUser.fullName, expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); })); @@ -223,6 +307,7 @@ void main() { await init(); final message = eg.dmMessage(from: eg.selfUser, to: []); await checkNotifications(async, messageFcmMessage(message), + expectedIsGroupConversation: false, expectedTitle: eg.selfUser.fullName, expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); })); @@ -403,6 +488,8 @@ extension on Subject { Subject get groupKey => has((x) => x.groupKey, 'groupKey'); Subject get inboxStyle => has((x) => x.inboxStyle, 'inboxStyle'); Subject get isGroupSummary => has((x) => x.isGroupSummary, 'isGroupSummary'); + Subject get messagingStyle => has((x) => x.messagingStyle, 'messagingStyle'); + Subject get number => has((x) => x.number, 'number'); Subject get smallIconResourceName => has((x) => x.smallIconResourceName, 'smallIconResourceName'); } @@ -415,3 +502,22 @@ extension on Subject { extension on Subject { Subject get summaryText => has((x) => x.summaryText, 'summaryText'); } + +extension on Subject { + Subject get user => has((x) => x.user, 'user'); + Subject get conversationTitle => has((x) => x.conversationTitle, 'conversationTitle'); + Subject> get messages => has((x) => x.messages, 'messages'); + Subject get isGroupConversation => has((x) => x.isGroupConversation, 'isGroupConversation'); +} + +extension on Subject { + Subject get iconBitmap => has((x) => x.iconBitmap, 'iconBitmap'); + Subject get key => has((x) => x.key, 'key'); + Subject get name => has((x) => x.name, 'name'); +} + +extension on Subject { + Subject get text => has((x) => x.text, 'text'); + Subject get timestampMs => has((x) => x.timestampMs, 'timestampMs'); + Subject get person => has((x) => x.person, 'person'); +} From 2393a193f54ae1411b5dc674cc03fd5adaa4ddea Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Mon, 15 Jul 2024 13:11:35 +0530 Subject: [PATCH 3/3] notif [nfc]: Use localization for self user display name --- assets/l10n/app_en.arb | 4 ++++ lib/notifications/display.dart | 2 +- test/notifications/display_test.dart | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 8c7db2bf77..9b808a5a25 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -479,5 +479,9 @@ "senderFullName": {"type": "String", "example": "Alice"}, "numOthers": {"type": "int", "example": "4"} } + }, + "notifSelfUser": "You", + "@notifSelfUser": { + "description": "Display name for the user themself, to show after replying in an Android notification" } } diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 64847b33c2..841fd13aae 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -108,7 +108,7 @@ class NotificationDisplayManager { messagingStyle = MessagingStyle( user: Person( key: _personKey(data.realmUri, data.userId), - name: 'You'), // TODO(i18n) + name: zulipLocalizations.notifSelfUser), messages: [], isGroupConversation: switch (data.recipient) { FcmMessageStreamRecipient() => true, diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index 6f7ae3cffd..d14a81da41 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/notifications.dart'; import 'package:zulip/host/android_notifications.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications/display.dart'; @@ -76,6 +77,7 @@ MessageFcmMessage messageFcmMessage( void main() { TestZulipBinding.ensureInitialized(); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; Future init() async { addTearDown(testBinding.reset); @@ -150,7 +152,7 @@ void main() { ..user.which((it) => it ..iconBitmap.isNull() ..key.equals(expectedSelfUserKey) - ..name.equals('You')) // TODO(i18n) + ..name.equals(zulipLocalizations.notifSelfUser)) ..isGroupConversation.equals(expectedIsGroupConversation) ..conversationTitle.equals(expectedTitle) ..messages.deepEquals(messageStyleMessagesChecks))