Skip to content

Commit 47703d9

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 fbef1e7 commit 47703d9

File tree

2 files changed

+169
-34
lines changed

2 files changed

+169
-34
lines changed

lib/notifications/display.dart

Lines changed: 54 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,35 @@ 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+
// For stream messages, the title remains constant. For DMs, it may
121+
// change if the DM sender's display name updates. For group DMs,
122+
// the title only contains a single sender's display name (TODO),
123+
// so it will be updated with the latest sender.
124+
messagingStyle.conversationTitle = switch (data.recipient) {
96125
FcmMessageStreamRecipient(:var streamName?, :var topic) =>
97126
'#$streamName > $topic',
98127
FcmMessageStreamRecipient(:var topic) =>
@@ -103,8 +132,14 @@ class NotificationDisplayManager {
103132
FcmMessageDmRecipient() =>
104133
data.senderFullName,
105134
};
106-
final groupKey = _groupKey(data);
107-
final conversationKey = _conversationKey(data, groupKey);
135+
136+
messagingStyle.messages.add(MessagingStyleMessage(
137+
text: data.content,
138+
timestampMs: data.time * 1000,
139+
person: Person(
140+
key: _personKey(data.realmUri, data.senderId),
141+
name: data.senderFullName,
142+
iconBitmap: await _fetchBitmap(data.senderAvatarUrl))));
108143

109144
await ZulipBinding.instance.androidNotificationHost.notify(
110145
// TODO the notification ID can be constant, instead of matching requestCode
@@ -114,12 +149,11 @@ class NotificationDisplayManager {
114149
channelId: NotificationChannelManager.kChannelId,
115150
groupKey: groupKey,
116151

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

124158
contentIntent: PendingIntent(
125159
// TODO make intent URLs distinct, instead of requestCode
@@ -196,6 +230,8 @@ class NotificationDisplayManager {
196230
return "${data.realmUri}|${data.userId}";
197231
}
198232

233+
static String _personKey(Uri realmUri, int userId) => "$realmUri|$userId";
234+
199235
static void _onNotificationOpened(NotificationResponse response) async {
200236
final payload = jsonDecode(response.payload!) as Map<String, dynamic>;
201237
final data = MessageFcmMessage.fromJson(payload);
@@ -238,4 +274,15 @@ class NotificationDisplayManager {
238274
page: MessageListPage(narrow: narrow)));
239275
return;
240276
}
277+
278+
static Future<Uint8List?> _fetchBitmap(Uri url) async {
279+
try {
280+
// TODO timeout to prevent waiting indefinitely
281+
final resp = await http.get(url);
282+
return resp.bodyBytes;
283+
} catch (e) {
284+
// TODO(log)
285+
return null;
286+
}
287+
}
241288
}

test/notifications/display_test.dart

Lines changed: 115 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import 'package:checks/checks.dart';
55
import 'package:fake_async/fake_async.dart';
66
import 'package:firebase_messaging/firebase_messaging.dart';
77
import 'package:flutter/material.dart';
8-
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message;
8+
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message, Person;
99
import 'package:flutter_test/flutter_test.dart';
1010
import 'package:zulip/api/model/model.dart';
1111
import 'package:zulip/api/notifications.dart';
@@ -106,26 +106,56 @@ void main() {
106106
});
107107

108108
group('NotificationDisplayManager show', () {
109-
void checkNotification(MessageFcmMessage data, {
109+
void checkNotification(List<MessageFcmMessage> messages, {
110110
required String expectedTitle,
111111
required String expectedTagComponent,
112+
required bool expectedGroup,
112113
}) {
113-
final expectedTag = '${data.realmUri}|${data.userId}|$expectedTagComponent';
114-
final expectedGroupKey = '${data.realmUri}|${data.userId}';
115-
final expectedId =
116-
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
117114
const expectedIntentFlags =
118115
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
119116

120-
check(testBinding.androidNotificationHost.takeNotifyCalls())
121-
..length.equals(2)
122-
..containsInOrder(<Condition<AndroidNotificationHostApiNotifyCall>>[
117+
final notifyCallsChecks = <Condition<AndroidNotificationHostApiNotifyCall>>[];
118+
for (int i = 0; i < messages.length; i++) {
119+
final data = messages[i];
120+
final expectedTag =
121+
'${data.realmUri}|${data.userId}|$expectedTagComponent';
122+
final expectedGroupKey = '${data.realmUri}|${data.userId}';
123+
final expectedId =
124+
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
125+
final expectedSelfUserKey = '${data.realmUri}|${data.userId}';
126+
127+
// List of all the checks for messages on each notify calls.
128+
final messagesChecks = <Condition<MessagingStyleMessage?>>[];
129+
for (int j = 0; j < (i + 1); j++) {
130+
final data = messages[j];
131+
final expectedSenderKey = '${data.realmUri}|${data.senderId}';
132+
messagesChecks.add((it) => it.isNotNull()
133+
..text.equals(data.content)
134+
..timestampMs.equals(data.time * 1000)
135+
..person.which((it) => it.isNotNull()
136+
..iconBitmap.isNotNull()
137+
..key.equals(expectedSenderKey)
138+
..name.equals(data.senderFullName)));
139+
}
140+
141+
notifyCallsChecks.addAll(<Condition<AndroidNotificationHostApiNotifyCall>>[
123142
(it) => it
124143
..id.equals(expectedId)
125144
..tag.equals(expectedTag)
126145
..channelId.equals(NotificationChannelManager.kChannelId)
127-
..contentTitle.equals(expectedTitle)
128-
..contentText.equals(data.content)
146+
..contentTitle.isNull()
147+
..contentText.isNull()
148+
..messagingStyle.which((it) => it.isNotNull()
149+
..user.which((it) => it.isNotNull()
150+
..iconBitmap.isNull()
151+
..key.equals(expectedSelfUserKey)
152+
..name.equals('You')) // TODO(i18n)
153+
..isGroupConversation.equals(expectedGroup)
154+
..conversationTitle.equals(expectedTitle)
155+
..messages.which((it) => it
156+
..length.equals(messagesChecks.length)
157+
..containsInOrder(messagesChecks)))
158+
..number.equals(messagesChecks.length)
129159
..color.equals(kZulipBrandColor.value)
130160
..smallIconResourceName.equals('zulip_notification')
131161
..extras.isNull()
@@ -151,53 +181,87 @@ void main() {
151181
..inboxStyle.which((it) => it.isNotNull()
152182
..summaryText.equals(data.realmUri.toString()))
153183
..autoCancel.equals(true)
154-
..contentIntent.isNull()
184+
..contentIntent.isNull(),
155185
]);
186+
}
187+
188+
check(testBinding.androidNotificationHost.takeNotifyCalls())
189+
..length.equals(messages.length * 2)
190+
..containsInOrder(notifyCallsChecks);
191+
testBinding.androidNotificationHost.clearActiveNotifications();
156192
}
157193

158-
Future<void> checkNotifications(FakeAsync async, MessageFcmMessage data, {
194+
Future<void> checkNotifications(FakeAsync async, List<MessageFcmMessage> messages, {
159195
required String expectedTitle,
160196
required String expectedTagComponent,
197+
required bool expectedGroup,
161198
}) async {
162199
// We could just call `NotificationDisplayManager.onFcmMessage`.
163200
// But this way is cheap, and it provides our test coverage of
164201
// the logic in `NotificationService` that listens for these FCM messages.
165202

166-
testBinding.firebaseMessaging.onMessage.add(
167-
RemoteMessage(data: data.toJson()));
168-
async.flushMicrotasks();
169-
checkNotification(data, expectedTitle: expectedTitle,
203+
for (final data in messages) {
204+
testBinding.firebaseMessaging.onMessage.add(
205+
RemoteMessage(data: data.toJson()));
206+
async.flushMicrotasks();
207+
}
208+
checkNotification(messages,
209+
expectedGroup: expectedGroup,
210+
expectedTitle: expectedTitle,
170211
expectedTagComponent: expectedTagComponent);
171212

172-
testBinding.firebaseMessaging.onBackgroundMessage.add(
173-
RemoteMessage(data: data.toJson()));
174-
async.flushMicrotasks();
175-
checkNotification(data, expectedTitle: expectedTitle,
213+
for (final data in messages) {
214+
testBinding.firebaseMessaging.onBackgroundMessage.add(
215+
RemoteMessage(data: data.toJson()));
216+
async.flushMicrotasks();
217+
}
218+
checkNotification(messages,
219+
expectedGroup: expectedGroup,
220+
expectedTitle: expectedTitle,
176221
expectedTagComponent: expectedTagComponent);
177222
}
178223

179224
test('stream message', () => awaitFakeAsync((async) async {
180225
await init();
181226
final stream = eg.stream();
182227
final message = eg.streamMessage(stream: stream);
183-
await checkNotifications(async, messageFcmMessage(message, streamName: stream.name),
228+
await checkNotifications(async, [messageFcmMessage(message, streamName: stream.name)],
229+
expectedGroup: true,
184230
expectedTitle: '#${stream.name} > ${message.topic}',
185231
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
186232
}));
187233

234+
test('multiple stream messages', () => awaitFakeAsync((async) async {
235+
await init();
236+
final stream = eg.stream(streamId: 1, name: 'stream 1');
237+
final message1 = eg.streamMessage(id: 101, stream: stream, timestamp: 1);
238+
final messageData1 = messageFcmMessage(message1, streamName: stream.name);
239+
final message2 = eg.streamMessage(id: 102, stream: stream, timestamp: 2);
240+
final messageData2 = messageFcmMessage(message2, streamName: stream.name);
241+
final message3 = eg.streamMessage(id: 103, stream: stream, timestamp: 3);
242+
final messageData3 = messageFcmMessage(message3, streamName: stream.name);
243+
244+
await checkNotifications(async, [messageData1, messageData2, messageData3],
245+
expectedGroup: true,
246+
expectedTitle: '#${stream.name} > ${message3.topic}',
247+
expectedTagComponent: 'stream:${message3.streamId}:${message3.topic}');
248+
}));
249+
188250
test('stream message, stream name omitted', () => awaitFakeAsync((async) async {
189251
await init();
190252
final stream = eg.stream();
191253
final message = eg.streamMessage(stream: stream);
192-
await checkNotifications(async, messageFcmMessage(message, streamName: null),
254+
await checkNotifications(async, [messageFcmMessage(message, streamName: null)],
255+
expectedGroup: true,
193256
expectedTitle: '#(unknown channel) > ${message.topic}',
194257
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
195258
}));
196259

197260
test('group DM: 3 users', () => awaitFakeAsync((async) async {
198261
await init();
199262
final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]);
200-
await checkNotifications(async, messageFcmMessage(message),
263+
await checkNotifications(async, [messageFcmMessage(message)],
264+
expectedGroup: true,
201265
expectedTitle: "${eg.thirdUser.fullName} to you and 1 other",
202266
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
203267
}));
@@ -206,23 +270,26 @@ void main() {
206270
await init();
207271
final message = eg.dmMessage(from: eg.thirdUser,
208272
to: [eg.otherUser, eg.selfUser, eg.fourthUser]);
209-
await checkNotifications(async, messageFcmMessage(message),
273+
await checkNotifications(async, [messageFcmMessage(message)],
274+
expectedGroup: true,
210275
expectedTitle: "${eg.thirdUser.fullName} to you and 2 others",
211276
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
212277
}));
213278

214279
test('1:1 DM', () => awaitFakeAsync((async) async {
215280
await init();
216281
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
217-
await checkNotifications(async, messageFcmMessage(message),
282+
await checkNotifications(async, [messageFcmMessage(message)],
283+
expectedGroup: false,
218284
expectedTitle: eg.otherUser.fullName,
219285
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
220286
}));
221287

222288
test('self-DM', () => awaitFakeAsync((async) async {
223289
await init();
224290
final message = eg.dmMessage(from: eg.selfUser, to: []);
225-
await checkNotifications(async, messageFcmMessage(message),
291+
await checkNotifications(async, [messageFcmMessage(message)],
292+
expectedGroup: false,
226293
expectedTitle: eg.selfUser.fullName,
227294
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
228295
}));
@@ -403,6 +470,8 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
403470
Subject<String?> get groupKey => has((x) => x.groupKey, 'groupKey');
404471
Subject<InboxStyle?> get inboxStyle => has((x) => x.inboxStyle, 'inboxStyle');
405472
Subject<bool?> get isGroupSummary => has((x) => x.isGroupSummary, 'isGroupSummary');
473+
Subject<MessagingStyle?> get messagingStyle => has((x) => x.messagingStyle, 'messagingStyle');
474+
Subject<int?> get number => has((x) => x.number, 'number');
406475
Subject<String?> get smallIconResourceName => has((x) => x.smallIconResourceName, 'smallIconResourceName');
407476
}
408477

@@ -415,3 +484,22 @@ extension on Subject<PendingIntent> {
415484
extension on Subject<InboxStyle> {
416485
Subject<String> get summaryText => has((x) => x.summaryText, 'summaryText');
417486
}
487+
488+
extension on Subject<MessagingStyle> {
489+
Subject<Person> get user => has((x) => x.user, 'user');
490+
Subject<String?> get conversationTitle => has((x) => x.conversationTitle, 'conversationTitle');
491+
Subject<List<MessagingStyleMessage?>> get messages => has((x) => x.messages, 'messages');
492+
Subject<bool> get isGroupConversation => has((x) => x.isGroupConversation, 'isGroupConversation');
493+
}
494+
495+
extension on Subject<Person> {
496+
Subject<Uint8List?> get iconBitmap => has((x) => x.iconBitmap, 'iconBitmap');
497+
Subject<String> get key => has((x) => x.key, 'key');
498+
Subject<String> get name => has((x) => x.name, 'name');
499+
}
500+
501+
extension on Subject<MessagingStyleMessage> {
502+
Subject<String> get text => has((x) => x.text, 'text');
503+
Subject<int> get timestampMs => has((x) => x.timestampMs, 'timestampMs');
504+
Subject<Person> get person => has((x) => x.person, 'person');
505+
}

0 commit comments

Comments
 (0)