Skip to content

Commit 4eb4ff8

Browse files
notif: Create summary notification
Fixes: #569 Fixes: #571
1 parent e851aad commit 4eb4ff8

File tree

7 files changed

+155
-16
lines changed

7 files changed

+155
-16
lines changed

android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt

+35-3
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,35 @@ data class PendingIntent (
8181
)
8282
}
8383
}
84+
85+
/** Generated class from Pigeon that represents data sent in messages. */
86+
data class InboxStyle (
87+
val summaryText: String
88+
89+
) {
90+
companion object {
91+
@Suppress("UNCHECKED_CAST")
92+
fun fromList(list: List<Any?>): InboxStyle {
93+
val summaryText = list[0] as String
94+
return InboxStyle(summaryText)
95+
}
96+
}
97+
fun toList(): List<Any?> {
98+
return listOf<Any?>(
99+
summaryText,
100+
)
101+
}
102+
}
84103
@Suppress("UNCHECKED_CAST")
85104
private object AndroidNotificationHostApiCodec : StandardMessageCodec() {
86105
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
87106
return when (type) {
88107
128.toByte() -> {
108+
return (readValue(buffer) as? List<Any?>)?.let {
109+
InboxStyle.fromList(it)
110+
}
111+
}
112+
129.toByte() -> {
89113
return (readValue(buffer) as? List<Any?>)?.let {
90114
PendingIntent.fromList(it)
91115
}
@@ -95,10 +119,14 @@ private object AndroidNotificationHostApiCodec : StandardMessageCodec() {
95119
}
96120
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
97121
when (value) {
98-
is PendingIntent -> {
122+
is InboxStyle -> {
99123
stream.write(128)
100124
writeValue(stream, value.toList())
101125
}
126+
is PendingIntent -> {
127+
stream.write(129)
128+
writeValue(stream, value.toList())
129+
}
102130
else -> super.writeValue(stream, value)
103131
}
104132
}
@@ -125,7 +153,7 @@ interface AndroidNotificationHostApi {
125153
* https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify
126154
* https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder
127155
*/
128-
fun notify(tag: String?, id: Long, channelId: String, color: Long?, contentIntent: PendingIntent?, contentText: String?, contentTitle: String?, extras: Map<String?, String?>?, smallIconResourceName: String?)
156+
fun notify(tag: String?, id: Long, channelId: String, color: Long?, contentIntent: PendingIntent?, contentText: String?, contentTitle: String?, extras: Map<String?, String?>?, smallIconResourceName: String?, groupKey: String?, isGroupSummary: Boolean?, inboxStyle: InboxStyle?, autoCancel: Boolean?)
129157

130158
companion object {
131159
/** The codec used by AndroidNotificationHostApi. */
@@ -150,9 +178,13 @@ interface AndroidNotificationHostApi {
150178
val contentTitleArg = args[6] as String?
151179
val extrasArg = args[7] as Map<String?, String?>?
152180
val smallIconResourceNameArg = args[8] as String?
181+
val groupKeyArg = args[9] as String?
182+
val isGroupSummaryArg = args[10] as Boolean?
183+
val inboxStyleArg = args[11] as InboxStyle?
184+
val autoCancelArg = args[12] as Boolean?
153185
var wrapped: List<Any?>
154186
try {
155-
api.notify(tagArg, idArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, smallIconResourceNameArg)
187+
api.notify(tagArg, idArg, channelIdArg, colorArg, contentIntentArg, contentTextArg, contentTitleArg, extrasArg, smallIconResourceNameArg, groupKeyArg, isGroupSummaryArg, inboxStyleArg, autoCancelArg)
156188
wrapped = listOf<Any?>(null)
157189
} catch (exception: Throwable) {
158190
wrapped = wrapError(exception)

android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt

+12-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ private class AndroidNotificationHost(val context: Context)
2929
contentText: String?,
3030
contentTitle: String?,
3131
extras: Map<String?, String?>?,
32-
smallIconResourceName: String?
32+
smallIconResourceName: String?,
33+
groupKey: String?,
34+
isGroupSummary: Boolean?,
35+
inboxStyle: InboxStyle?,
36+
autoCancel: Boolean?
3337
) {
3438
val notification = NotificationCompat.Builder(context, channelId).apply {
3539
color?.let { setColor(it.toInt()) }
@@ -51,6 +55,13 @@ private class AndroidNotificationHost(val context: Context)
5155
Bundle().apply { it.forEach { (k, v) -> putString(k, v) } } ) }
5256
smallIconResourceName?.let { setSmallIcon(context.resources.getIdentifier(
5357
it, "drawable", context.packageName)) }
58+
groupKey?.let { setGroup(it) }
59+
isGroupSummary?.let { setGroupSummary(it) }
60+
inboxStyle?.let { setStyle(
61+
NotificationCompat.InboxStyle()
62+
.setSummaryText(it.summaryText)
63+
) }
64+
autoCancel?.let { setAutoCancel(it) }
5465
}.build()
5566
NotificationManagerCompat.from(context).notify(tag, id.toInt(), notification)
5667
}

lib/host/android_notifications.g.dart

+29-3
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,37 @@ class PendingIntent {
5353
}
5454
}
5555

56+
class InboxStyle {
57+
InboxStyle({
58+
required this.summaryText,
59+
});
60+
61+
String summaryText;
62+
63+
Object encode() {
64+
return <Object?>[
65+
summaryText,
66+
];
67+
}
68+
69+
static InboxStyle decode(Object result) {
70+
result as List<Object?>;
71+
return InboxStyle(
72+
summaryText: result[0]! as String,
73+
);
74+
}
75+
}
76+
5677
class _AndroidNotificationHostApiCodec extends StandardMessageCodec {
5778
const _AndroidNotificationHostApiCodec();
5879
@override
5980
void writeValue(WriteBuffer buffer, Object? value) {
60-
if (value is PendingIntent) {
81+
if (value is InboxStyle) {
6182
buffer.putUint8(128);
6283
writeValue(buffer, value.encode());
84+
} else if (value is PendingIntent) {
85+
buffer.putUint8(129);
86+
writeValue(buffer, value.encode());
6387
} else {
6488
super.writeValue(buffer, value);
6589
}
@@ -69,6 +93,8 @@ class _AndroidNotificationHostApiCodec extends StandardMessageCodec {
6993
Object? readValueOfType(int type, ReadBuffer buffer) {
7094
switch (type) {
7195
case 128:
96+
return InboxStyle.decode(readValue(buffer)!);
97+
case 129:
7298
return PendingIntent.decode(readValue(buffer)!);
7399
default:
74100
return super.readValueOfType(type, buffer);
@@ -106,15 +132,15 @@ class AndroidNotificationHostApi {
106132
/// See:
107133
/// https://developer.android.com/reference/kotlin/android/app/NotificationManager.html#notify
108134
/// https://developer.android.com/reference/androidx/core/app/NotificationCompat.Builder
109-
Future<void> notify({String? tag, required int id, required String channelId, int? color, PendingIntent? contentIntent, String? contentText, String? contentTitle, Map<String?, String?>? extras, String? smallIconResourceName,}) async {
135+
Future<void> notify({String? tag, required int id, required String channelId, int? color, PendingIntent? contentIntent, String? contentText, String? contentTitle, Map<String?, String?>? extras, String? smallIconResourceName, String? groupKey, bool? isGroupSummary, InboxStyle? inboxStyle, bool? autoCancel,}) async {
110136
final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$__pigeon_messageChannelSuffix';
111137
final BasicMessageChannel<Object?> __pigeon_channel = BasicMessageChannel<Object?>(
112138
__pigeon_channelName,
113139
pigeonChannelCodec,
114140
binaryMessenger: __pigeon_binaryMessenger,
115141
);
116142
final List<Object?>? __pigeon_replyList =
117-
await __pigeon_channel.send(<Object?>[tag, id, channelId, color, contentIntent, contentText, contentTitle, extras, smallIconResourceName]) as List<Object?>?;
143+
await __pigeon_channel.send(<Object?>[tag, id, channelId, color, contentIntent, contentText, contentTitle, extras, smallIconResourceName, groupKey, isGroupSummary, inboxStyle, autoCancel]) as List<Object?>?;
118144
if (__pigeon_replyList == null) {
119145
throw _createConnectionError(__pigeon_channelName);
120146
} else if (__pigeon_replyList.length > 1) {

lib/notifications/display.dart

+23-5
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ class NotificationDisplayManager {
8989
}
9090
}
9191

92-
static void _onMessageFcmMessage(MessageFcmMessage data, Map<String, dynamic> dataJson) {
92+
static Future<void> _onMessageFcmMessage(MessageFcmMessage data, Map<String, dynamic> dataJson) async {
9393
assert(debugLog('notif message content: ${data.content}'));
9494
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
9595
final title = switch (data.recipient) {
@@ -103,13 +103,16 @@ class NotificationDisplayManager {
103103
FcmMessageDmRecipient() =>
104104
data.senderFullName,
105105
};
106-
final conversationKey = _conversationKey(data);
107-
ZulipBinding.instance.androidNotificationHost.notify(
106+
final groupKey = _groupKey(data);
107+
final conversationKey = _conversationKey(data, groupKey);
108+
109+
await ZulipBinding.instance.androidNotificationHost.notify(
108110
// TODO the notification ID can be constant, instead of matching requestCode
109111
// (This is a legacy of `flutter_local_notifications`.)
110112
id: notificationIdAsHashOf(conversationKey),
111113
tag: conversationKey,
112114
channelId: NotificationChannelManager.kChannelId,
115+
groupKey: groupKey,
113116

114117
contentTitle: title,
115118
contentText: data.content,
@@ -139,6 +142,22 @@ class NotificationDisplayManager {
139142
// TODO this doesn't set the Intent flags we set in zulip-mobile; is that OK?
140143
// (This is a legacy of `flutter_local_notifications`.)
141144
),
145+
autoCancel: true,
146+
);
147+
148+
await ZulipBinding.instance.androidNotificationHost.notify(
149+
id: notificationIdAsHashOf(groupKey),
150+
channelId: NotificationChannelManager.kChannelId,
151+
tag: groupKey,
152+
groupKey: groupKey,
153+
isGroupSummary: true,
154+
color: kZulipBrandColor.value,
155+
// TODO vary notification icon for debug
156+
smallIconResourceName: 'zulip_notification', // This name must appear in keep.xml too: https://github.com/zulip/zulip-flutter/issues/528
157+
inboxStyle: InboxStyle(
158+
// TODO(#570) Show organization name, not URL
159+
summaryText: data.realmUri.toString()),
160+
autoCancel: true,
142161
);
143162
}
144163

@@ -157,8 +176,7 @@ class NotificationDisplayManager {
157176
| ((bytes[3] & 0x7f) << 24);
158177
}
159178

160-
static String _conversationKey(MessageFcmMessage data) {
161-
final groupKey = _groupKey(data);
179+
static String _conversationKey(MessageFcmMessage data, String groupKey) {
162180
final conversation = switch (data.recipient) {
163181
FcmMessageStreamRecipient(:var streamId, :var topic) => 'stream:$streamId:$topic',
164182
FcmMessageDmRecipient(:var allRecipientIds) => 'dm:${allRecipientIds.join(',')}',

pigeon/notifications.dart

+10
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ class PendingIntent {
2727
final int flags;
2828
}
2929

30+
class InboxStyle {
31+
InboxStyle({required this.summaryText});
32+
33+
final String summaryText;
34+
}
35+
3036
@HostApi()
3137
abstract class AndroidNotificationHostApi {
3238
/// Corresponds to `android.app.NotificationManager.notify`,
@@ -59,6 +65,10 @@ abstract class AndroidNotificationHostApi {
5965
String? contentTitle,
6066
Map<String?, String?>? extras,
6167
String? smallIconResourceName,
68+
String? groupKey,
69+
bool? isGroupSummary,
70+
InboxStyle? inboxStyle,
71+
bool? autoCancel,
6272
// NotificationCompat.Builder has lots more methods; add as needed.
6373
});
6474
}

test/model/binding.dart

+12
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,10 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
503503
String? contentTitle,
504504
Map<String?, String?>? extras,
505505
String? smallIconResourceName,
506+
String? groupKey,
507+
bool? isGroupSummary,
508+
InboxStyle? inboxStyle,
509+
bool? autoCancel,
506510
}) async {
507511
_notifyCalls.add((
508512
tag: tag,
@@ -514,6 +518,10 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
514518
contentTitle: contentTitle,
515519
extras: extras,
516520
smallIconResourceName: smallIconResourceName,
521+
groupKey: groupKey,
522+
isGroupSummary: isGroupSummary,
523+
inboxStyle: inboxStyle,
524+
autoCancel: autoCancel,
517525
));
518526
}
519527
}
@@ -528,4 +536,8 @@ typedef AndroidNotificationHostApiNotifyCall = ({
528536
String? contentTitle,
529537
Map<String?, String?>? extras,
530538
String? smallIconResourceName,
539+
String? groupKey,
540+
bool? isGroupSummary,
541+
InboxStyle? inboxStyle,
542+
bool? autoCancel,
531543
});

test/notifications/display_test.dart

+34-4
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,14 @@ void main() {
110110
required String expectedTitle,
111111
required String expectedTagComponent,
112112
}) {
113-
final expectedTag = '${data.realmUri}|${data.userId}|$expectedTagComponent';
113+
final expectedGroupKey = '${data.realmUri}|${data.userId}';
114+
final expectedTag = '$expectedGroupKey|$expectedTagComponent';
114115
final expectedId =
115116
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
116117
const expectedIntentFlags =
117118
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
118-
check(testBinding.androidNotificationHost.takeNotifyCalls()).single
119+
final calls = testBinding.androidNotificationHost.takeNotifyCalls();
120+
check(calls[0])
119121
..id.equals(expectedId)
120122
..tag.equals(expectedTag)
121123
..channelId.equals(NotificationChannelManager.kChannelId)
@@ -124,11 +126,31 @@ void main() {
124126
..color.equals(kZulipBrandColor.value)
125127
..smallIconResourceName.equals('zulip_notification')
126128
..extras.isNull()
129+
..groupKey.equals(expectedGroupKey)
130+
..isGroupSummary.isNull()
131+
..inboxStyle.isNull()
132+
..autoCancel.equals(true)
127133
..contentIntent.which((it) => it.isNotNull()
128134
..requestCode.equals(expectedId)
129135
..flags.equals(expectedIntentFlags)
130136
..intentPayload.equals(jsonEncode(data.toJson()))
131137
);
138+
check(calls[1])
139+
..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedGroupKey))
140+
..tag.equals(expectedGroupKey)
141+
..channelId.equals(NotificationChannelManager.kChannelId)
142+
..contentTitle.isNull()
143+
..contentText.isNull()
144+
..color.equals(kZulipBrandColor.value)
145+
..smallIconResourceName.equals('zulip_notification')
146+
..extras.isNull()
147+
..groupKey.equals(expectedGroupKey)
148+
..isGroupSummary.equals(true)
149+
..inboxStyle.which((it) => it.isNotNull()
150+
..summaryText.equals(data.realmUri.toString())
151+
)
152+
..autoCancel.equals(true)
153+
..contentIntent.isNull();
132154
}
133155

134156
Future<void> checkNotifications(FakeAsync async, MessageFcmMessage data, {
@@ -157,7 +179,7 @@ void main() {
157179
final stream = eg.stream();
158180
final message = eg.streamMessage(stream: stream);
159181
await checkNotifications(async, messageFcmMessage(message, streamName: stream.name),
160-
expectedTitle: '${stream.name} > ${message.subject}',
182+
expectedTitle: '#${stream.name} > ${message.subject}',
161183
expectedTagComponent: 'stream:${message.streamId}:${message.subject}');
162184
}));
163185

@@ -166,7 +188,7 @@ void main() {
166188
final stream = eg.stream();
167189
final message = eg.streamMessage(stream: stream);
168190
await checkNotifications(async, messageFcmMessage(message, streamName: null),
169-
expectedTitle: '(unknown stream) > ${message.subject}',
191+
expectedTitle: '#(unknown stream) > ${message.subject}',
170192
expectedTagComponent: 'stream:${message.streamId}:${message.subject}');
171193
}));
172194

@@ -376,10 +398,18 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
376398
Subject<String?> get contentTitle => has((x) => x.contentTitle, 'contentTitle');
377399
Subject<Map<String?, String?>?> get extras => has((x) => x.extras, 'extras');
378400
Subject<String?> get smallIconResourceName => has((x) => x.smallIconResourceName, 'smallIconResourceName');
401+
Subject<String?> get groupKey => has((x) => x.groupKey, 'groupKey');
402+
Subject<bool?> get isGroupSummary => has((x) => x.isGroupSummary, 'isGroupSummary');
403+
Subject<InboxStyle?> get inboxStyle => has((x) => x.inboxStyle, 'inboxStyle');
404+
Subject<bool?> get autoCancel => has((x) => x.autoCancel, 'autoCancel');
379405
}
380406

381407
extension on Subject<PendingIntent> {
382408
Subject<int> get requestCode => has((x) => x.requestCode, 'requestCode');
383409
Subject<String> get intentPayload => has((x) => x.intentPayload, 'intentPayload');
384410
Subject<int> get flags => has((x) => x.flags, 'flags');
385411
}
412+
413+
extension on Subject<InboxStyle> {
414+
Subject<String> get summaryText => has((x) => x.summaryText, 'summaryText');
415+
}

0 commit comments

Comments
 (0)