Skip to content

Commit 2fe7b07

Browse files
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
1 parent 0269ea7 commit 2fe7b07

File tree

2 files changed

+174
-17
lines changed

2 files changed

+174
-17
lines changed

lib/notifications/display.dart

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import 'dart:convert';
22

3+
import 'package:http/http.dart' as http;
34
import 'package:collection/collection.dart';
45
import 'package:crypto/crypto.dart';
56
import 'package:flutter/foundation.dart';
67
import 'package:flutter/widgets.dart';
7-
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
8+
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Person;
89

910
import '../api/notifications.dart';
1011
import '../host/android_notifications.dart';
@@ -92,7 +93,36 @@ class NotificationDisplayManager {
9293
static Future<void> _onMessageFcmMessage(MessageFcmMessage data, Map<String, dynamic> dataJson) async {
9394
assert(debugLog('notif message content: ${data.content}'));
9495
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
95-
final title = switch (data.recipient) {
96+
final groupKey = _groupKey(data);
97+
final conversationKey = _conversationKey(data, groupKey);
98+
99+
final oldMessagingStyle = await ZulipBinding.instance.androidNotificationHost
100+
.getActiveNotificationMessagingStyleByTag(conversationKey);
101+
102+
final MessagingStyle messagingStyle;
103+
if (oldMessagingStyle != null) {
104+
messagingStyle = oldMessagingStyle;
105+
messagingStyle.messages =
106+
oldMessagingStyle.messages.toList(); // Clone fixed-length list to growable.
107+
} else {
108+
messagingStyle = MessagingStyle(
109+
user: Person(
110+
key: _personKey(data.realmUri, data.userId),
111+
name: 'You'), // TODO(i18n)
112+
messages: [],
113+
isGroupConversation: switch (data.recipient) {
114+
FcmMessageStreamRecipient() => true,
115+
FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => true,
116+
FcmMessageDmRecipient() => false,
117+
});
118+
}
119+
120+
// The title typically won't change between messages in a conversation, but we
121+
// update it anyway. This means a DM sender's display name gets updated if it's
122+
// changed, which is a rare edge case but probably good. The main effect is that
123+
// group-DM threads (pending #794) get titled with the latest sender, rather than
124+
// the first.
125+
messagingStyle.conversationTitle = switch (data.recipient) {
96126
FcmMessageStreamRecipient(:var streamName?, :var topic) =>
97127
'#$streamName > $topic',
98128
FcmMessageStreamRecipient(:var topic) =>
@@ -103,8 +133,14 @@ class NotificationDisplayManager {
103133
FcmMessageDmRecipient() =>
104134
data.senderFullName,
105135
};
106-
final groupKey = _groupKey(data);
107-
final conversationKey = _conversationKey(data, groupKey);
136+
137+
messagingStyle.messages.add(MessagingStyleMessage(
138+
text: data.content,
139+
timestampMs: data.time * 1000,
140+
person: Person(
141+
key: _personKey(data.realmUri, data.senderId),
142+
name: data.senderFullName,
143+
iconBitmap: await _fetchBitmap(data.senderAvatarUrl))));
108144

109145
await ZulipBinding.instance.androidNotificationHost.notify(
110146
// TODO the notification ID can be constant, instead of matching requestCode
@@ -114,12 +150,12 @@ class NotificationDisplayManager {
114150
channelId: NotificationChannelManager.kChannelId,
115151
groupKey: groupKey,
116152

117-
contentTitle: title,
118-
contentText: data.content,
119153
color: kZulipBrandColor.value,
120154
// TODO vary notification icon for debug
121155
smallIconResourceName: 'zulip_notification', // This name must appear in keep.xml too: https://github.com/zulip/zulip-flutter/issues/528
122-
// TODO(#128) inbox-style
156+
157+
messagingStyle: messagingStyle,
158+
number: messagingStyle.messages.length,
123159

124160
contentIntent: PendingIntent(
125161
// TODO make intent URLs distinct, instead of requestCode
@@ -196,6 +232,8 @@ class NotificationDisplayManager {
196232
return "${data.realmUri}|${data.userId}";
197233
}
198234

235+
static String _personKey(Uri realmUri, int userId) => "$realmUri|$userId";
236+
199237
static void _onNotificationOpened(NotificationResponse response) async {
200238
final payload = jsonDecode(response.payload!) as Map<String, dynamic>;
201239
final data = MessageFcmMessage.fromJson(payload);
@@ -238,4 +276,15 @@ class NotificationDisplayManager {
238276
page: MessageListPage(narrow: narrow)));
239277
return;
240278
}
279+
280+
static Future<Uint8List?> _fetchBitmap(Uri url) async {
281+
try {
282+
// TODO timeout to prevent waiting indefinitely
283+
final resp = await http.get(url);
284+
return resp.bodyBytes;
285+
} catch (e) {
286+
// TODO(log)
287+
return null;
288+
}
289+
}
241290
}

test/notifications/display_test.dart

Lines changed: 118 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import 'dart:convert';
22
import 'dart:typed_data';
33

44
import 'package:checks/checks.dart';
5+
import 'package:collection/collection.dart';
56
import 'package:fake_async/fake_async.dart';
67
import 'package:firebase_messaging/firebase_messaging.dart';
78
import 'package:flutter/material.dart';
8-
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message;
9+
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message, Person;
910
import 'package:flutter_test/flutter_test.dart';
1011
import 'package:zulip/api/model/model.dart';
1112
import 'package:zulip/api/notifications.dart';
1213
import 'package:zulip/host/android_notifications.dart';
14+
import 'package:zulip/model/localizations.dart';
1315
import 'package:zulip/model/narrow.dart';
1416
import 'package:zulip/model/store.dart';
1517
import 'package:zulip/notifications/display.dart';
@@ -75,6 +77,7 @@ MessageFcmMessage messageFcmMessage(
7577

7678
void main() {
7779
TestZulipBinding.ensureInitialized();
80+
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
7881

7982
Future<void> init() async {
8083
addTearDown(testBinding.reset);
@@ -107,25 +110,53 @@ void main() {
107110

108111
group('NotificationDisplayManager show', () {
109112
void checkNotification(MessageFcmMessage data, {
113+
required List<MessageFcmMessage> messageStyleMessages,
110114
required String expectedTitle,
111115
required String expectedTagComponent,
116+
required bool expectedIsGroupConversation,
112117
}) {
113118
final expectedTag = '${data.realmUri}|${data.userId}|$expectedTagComponent';
114119
final expectedGroupKey = '${data.realmUri}|${data.userId}';
115120
final expectedId =
116121
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
117122
const expectedIntentFlags =
118123
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
124+
final expectedSelfUserKey = '${data.realmUri}|${data.userId}';
125+
126+
final messageStyleMessagesChecks = messageStyleMessages
127+
.mapIndexed<Condition<Object?>>((i, messageData) {
128+
assert(messageData.realmUri == data.realmUri);
129+
assert(messageData.userId == data.userId);
130+
131+
final expectedSenderKey =
132+
'${messageData.realmUri}|${messageData.senderId}';
133+
final isLast = i == (messageStyleMessages.length - 1);
134+
return (it) => it.isA<MessagingStyleMessage>()
135+
..text.equals(messageData.content)
136+
..timestampMs.equals(messageData.time * 1000)
137+
..person.which((it) => it.isNotNull()
138+
..iconBitmap.which((it) => isLast ? it.isNotNull() : it.isNull())
139+
..key.equals(expectedSenderKey)
140+
..name.equals(messageData.senderFullName));
141+
});
119142

120143
check(testBinding.androidNotificationHost.takeNotifyCalls())
121-
..length.equals(2)
122-
..containsInOrder(<Condition<AndroidNotificationHostApiNotifyCall>>[
123-
(it) => it
144+
.deepEquals(<Condition<Object?>>[
145+
(it) => it.isA<AndroidNotificationHostApiNotifyCall>()
124146
..id.equals(expectedId)
125147
..tag.equals(expectedTag)
126148
..channelId.equals(NotificationChannelManager.kChannelId)
127-
..contentTitle.equals(expectedTitle)
128-
..contentText.equals(data.content)
149+
..contentTitle.isNull()
150+
..contentText.isNull()
151+
..messagingStyle.which((it) => it.isNotNull()
152+
..user.which((it) => it
153+
..iconBitmap.isNull()
154+
..key.equals(expectedSelfUserKey)
155+
..name.equals(zulipLocalizations.notifSelfUser))
156+
..isGroupConversation.equals(expectedIsGroupConversation)
157+
..conversationTitle.equals(expectedTitle)
158+
..messages.deepEquals(messageStyleMessagesChecks))
159+
..number.equals(messageStyleMessages.length)
129160
..color.equals(kZulipBrandColor.value)
130161
..smallIconResourceName.equals('zulip_notification')
131162
..extras.isNull()
@@ -137,7 +168,7 @@ void main() {
137168
..requestCode.equals(expectedId)
138169
..flags.equals(expectedIntentFlags)
139170
..intentPayload.equals(jsonEncode(data.toJson()))),
140-
(it) => it
171+
(it) => it.isA<AndroidNotificationHostApiNotifyCall>()
141172
..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedGroupKey))
142173
..tag.equals(expectedGroupKey)
143174
..channelId.equals(NotificationChannelManager.kChannelId)
@@ -151,13 +182,14 @@ void main() {
151182
..inboxStyle.which((it) => it.isNotNull()
152183
..summaryText.equals(data.realmUri.toString()))
153184
..autoCancel.equals(true)
154-
..contentIntent.isNull()
185+
..contentIntent.isNull(),
155186
]);
156187
}
157188

158189
Future<void> checkNotifications(FakeAsync async, MessageFcmMessage data, {
159190
required String expectedTitle,
160191
required String expectedTagComponent,
192+
required bool expectedIsGroupConversation,
161193
}) async {
162194
// We could just call `NotificationDisplayManager.onFcmMessage`.
163195
// But this way is cheap, and it provides our test coverage of
@@ -166,30 +198,81 @@ void main() {
166198
testBinding.firebaseMessaging.onMessage.add(
167199
RemoteMessage(data: data.toJson()));
168200
async.flushMicrotasks();
169-
checkNotification(data, expectedTitle: expectedTitle,
201+
checkNotification(data,
202+
messageStyleMessages: [data],
203+
expectedIsGroupConversation: expectedIsGroupConversation,
204+
expectedTitle: expectedTitle,
170205
expectedTagComponent: expectedTagComponent);
206+
testBinding.androidNotificationHost.clearActiveNotifications();
171207

172208
testBinding.firebaseMessaging.onBackgroundMessage.add(
173209
RemoteMessage(data: data.toJson()));
174210
async.flushMicrotasks();
175-
checkNotification(data, expectedTitle: expectedTitle,
211+
checkNotification(data,
212+
messageStyleMessages: [data],
213+
expectedIsGroupConversation: expectedIsGroupConversation,
214+
expectedTitle: expectedTitle,
176215
expectedTagComponent: expectedTagComponent);
177216
}
178217

218+
Future<void> receiveFcmMessage(FakeAsync async, MessageFcmMessage data) async {
219+
testBinding.firebaseMessaging.onMessage.add(
220+
RemoteMessage(data: data.toJson()));
221+
async.flushMicrotasks();
222+
}
223+
179224
test('stream message', () => awaitFakeAsync((async) async {
180225
await init();
181226
final stream = eg.stream();
182227
final message = eg.streamMessage(stream: stream);
183228
await checkNotifications(async, messageFcmMessage(message, streamName: stream.name),
229+
expectedIsGroupConversation: true,
184230
expectedTitle: '#${stream.name} > ${message.topic}',
185231
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
186232
}));
187233

234+
test('multiple stream messages, same topic', () => awaitFakeAsync((async) async {
235+
await init();
236+
final stream = eg.stream();
237+
const topic = 'topic 1';
238+
final message1 = eg.streamMessage(topic: topic, stream: stream);
239+
final data1 = messageFcmMessage(message1, streamName: stream.name);
240+
final message2 = eg.streamMessage(topic: topic, stream: stream);
241+
final data2 = messageFcmMessage(message2, streamName: stream.name);
242+
final message3 = eg.streamMessage(topic: topic, stream: stream);
243+
final data3 = messageFcmMessage(message3, streamName: stream.name);
244+
245+
final expectedTitle = '#${stream.name} > $topic';
246+
final expectedTagComponent = 'stream:${stream.streamId}:$topic';
247+
248+
await receiveFcmMessage(async, data1);
249+
checkNotification(data1,
250+
messageStyleMessages: [data1],
251+
expectedIsGroupConversation: true,
252+
expectedTitle: expectedTitle,
253+
expectedTagComponent: expectedTagComponent);
254+
255+
await receiveFcmMessage(async, data2);
256+
checkNotification(data2,
257+
messageStyleMessages: [data1, data2],
258+
expectedIsGroupConversation: true,
259+
expectedTitle: expectedTitle,
260+
expectedTagComponent: expectedTagComponent);
261+
262+
await receiveFcmMessage(async, data3);
263+
checkNotification(data3,
264+
messageStyleMessages: [data1, data2, data3],
265+
expectedIsGroupConversation: true,
266+
expectedTitle: expectedTitle,
267+
expectedTagComponent: expectedTagComponent);
268+
}));
269+
188270
test('stream message, stream name omitted', () => awaitFakeAsync((async) async {
189271
await init();
190272
final stream = eg.stream();
191273
final message = eg.streamMessage(stream: stream);
192274
await checkNotifications(async, messageFcmMessage(message, streamName: null),
275+
expectedIsGroupConversation: true,
193276
expectedTitle: '#(unknown channel) > ${message.topic}',
194277
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
195278
}));
@@ -198,6 +281,7 @@ void main() {
198281
await init();
199282
final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]);
200283
await checkNotifications(async, messageFcmMessage(message),
284+
expectedIsGroupConversation: true,
201285
expectedTitle: "${eg.thirdUser.fullName} to you and 1 other",
202286
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
203287
}));
@@ -207,6 +291,7 @@ void main() {
207291
final message = eg.dmMessage(from: eg.thirdUser,
208292
to: [eg.otherUser, eg.selfUser, eg.fourthUser]);
209293
await checkNotifications(async, messageFcmMessage(message),
294+
expectedIsGroupConversation: true,
210295
expectedTitle: "${eg.thirdUser.fullName} to you and 2 others",
211296
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
212297
}));
@@ -215,6 +300,7 @@ void main() {
215300
await init();
216301
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
217302
await checkNotifications(async, messageFcmMessage(message),
303+
expectedIsGroupConversation: false,
218304
expectedTitle: eg.otherUser.fullName,
219305
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
220306
}));
@@ -223,6 +309,7 @@ void main() {
223309
await init();
224310
final message = eg.dmMessage(from: eg.selfUser, to: []);
225311
await checkNotifications(async, messageFcmMessage(message),
312+
expectedIsGroupConversation: false,
226313
expectedTitle: eg.selfUser.fullName,
227314
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
228315
}));
@@ -403,6 +490,8 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
403490
Subject<String?> get groupKey => has((x) => x.groupKey, 'groupKey');
404491
Subject<InboxStyle?> get inboxStyle => has((x) => x.inboxStyle, 'inboxStyle');
405492
Subject<bool?> get isGroupSummary => has((x) => x.isGroupSummary, 'isGroupSummary');
493+
Subject<MessagingStyle?> get messagingStyle => has((x) => x.messagingStyle, 'messagingStyle');
494+
Subject<int?> get number => has((x) => x.number, 'number');
406495
Subject<String?> get smallIconResourceName => has((x) => x.smallIconResourceName, 'smallIconResourceName');
407496
}
408497

@@ -415,3 +504,22 @@ extension on Subject<PendingIntent> {
415504
extension on Subject<InboxStyle> {
416505
Subject<String> get summaryText => has((x) => x.summaryText, 'summaryText');
417506
}
507+
508+
extension on Subject<MessagingStyle> {
509+
Subject<Person> get user => has((x) => x.user, 'user');
510+
Subject<String?> get conversationTitle => has((x) => x.conversationTitle, 'conversationTitle');
511+
Subject<List<MessagingStyleMessage?>> get messages => has((x) => x.messages, 'messages');
512+
Subject<bool> get isGroupConversation => has((x) => x.isGroupConversation, 'isGroupConversation');
513+
}
514+
515+
extension on Subject<Person> {
516+
Subject<Uint8List?> get iconBitmap => has((x) => x.iconBitmap, 'iconBitmap');
517+
Subject<String> get key => has((x) => x.key, 'key');
518+
Subject<String> get name => has((x) => x.name, 'name');
519+
}
520+
521+
extension on Subject<MessagingStyleMessage> {
522+
Subject<String> get text => has((x) => x.text, 'text');
523+
Subject<int> get timestampMs => has((x) => x.timestampMs, 'timestampMs');
524+
Subject<Person> get person => has((x) => x.person, 'person');
525+
}

0 commit comments

Comments
 (0)