Skip to content

Commit f6db5c0

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 4eb89c7 commit f6db5c0

File tree

2 files changed

+174
-20
lines changed

2 files changed

+174
-20
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 & 13 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,51 @@ 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
}) {
113-
final expectedTag = '${data.realmUri}|${data.userId}|$expectedTagComponent';
118+
const expectedIntentFlags =
119+
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
120+
final expectedTag =
121+
'${data.realmUri}|${data.userId}|$expectedTagComponent';
114122
final expectedGroupKey = '${data.realmUri}|${data.userId}';
115123
final expectedId =
116124
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
117-
const expectedIntentFlags =
118-
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
125+
final expectedSelfUserKey = '${data.realmUri}|${data.userId}';
126+
127+
// List of all the checks for messages on each notify calls.
128+
final messageStyleMessagesChecks = messageStyleMessages.mapIndexed<Condition<Object?>>((i, data) {
129+
final expectedSenderKey = '${data.realmUri}|${data.senderId}';
130+
return (it) => it.isA<MessagingStyleMessage>()
131+
..text.equals(data.content)
132+
..timestampMs.equals(data.time * 1000)
133+
..person.which((it) => it.isNotNull()
134+
..iconBitmap.which((it) => i == (messageStyleMessages.length - 1)
135+
? it.isNotNull()
136+
: it.isNull())
137+
..key.equals(expectedSenderKey)
138+
..name.equals(data.senderFullName));
139+
});
119140

120141
check(testBinding.androidNotificationHost.takeNotifyCalls())
121-
..length.equals(2)
122-
..containsInOrder(<Condition<AndroidNotificationHostApiNotifyCall>>[
123-
(it) => it
142+
.deepEquals(<Condition<Object?>>[
143+
(it) => it.isA<AndroidNotificationHostApiNotifyCall>()
124144
..id.equals(expectedId)
125145
..tag.equals(expectedTag)
126146
..channelId.equals(NotificationChannelManager.kChannelId)
127-
..contentTitle.equals(expectedTitle)
128-
..contentText.equals(data.content)
147+
..contentTitle.isNull()
148+
..contentText.isNull()
149+
..messagingStyle.which((it) => it.isNotNull()
150+
..user.which((it) => it
151+
..iconBitmap.isNull()
152+
..key.equals(expectedSelfUserKey)
153+
..name.equals(zulipLocalizations.notifSelfUser))
154+
..isGroupConversation.equals(expectedIsGroupConversation)
155+
..conversationTitle.equals(expectedTitle)
156+
..messages.deepEquals(messageStyleMessagesChecks))
157+
..number.equals(messageStyleMessages.length)
129158
..color.equals(kZulipBrandColor.value)
130159
..smallIconResourceName.equals('zulip_notification')
131160
..extras.isNull()
@@ -137,7 +166,7 @@ void main() {
137166
..requestCode.equals(expectedId)
138167
..flags.equals(expectedIntentFlags)
139168
..intentPayload.equals(jsonEncode(data.toJson()))),
140-
(it) => it
169+
(it) => it.isA<AndroidNotificationHostApiNotifyCall>()
141170
..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedGroupKey))
142171
..tag.equals(expectedGroupKey)
143172
..channelId.equals(NotificationChannelManager.kChannelId)
@@ -151,13 +180,15 @@ void main() {
151180
..inboxStyle.which((it) => it.isNotNull()
152181
..summaryText.equals(data.realmUri.toString()))
153182
..autoCancel.equals(true)
154-
..contentIntent.isNull()
183+
..contentIntent.isNull(),
155184
]);
156185
}
157186

187+
158188
Future<void> checkNotifications(FakeAsync async, MessageFcmMessage data, {
159189
required String expectedTitle,
160190
required String expectedTagComponent,
191+
required bool expectedIsGroupConversation,
161192
}) async {
162193
// We could just call `NotificationDisplayManager.onFcmMessage`.
163194
// But this way is cheap, and it provides our test coverage of
@@ -166,30 +197,79 @@ void main() {
166197
testBinding.firebaseMessaging.onMessage.add(
167198
RemoteMessage(data: data.toJson()));
168199
async.flushMicrotasks();
169-
checkNotification(data, expectedTitle: expectedTitle,
200+
checkNotification(data,
201+
messageStyleMessages: [data],
202+
expectedIsGroupConversation: expectedIsGroupConversation,
203+
expectedTitle: expectedTitle,
170204
expectedTagComponent: expectedTagComponent);
205+
testBinding.androidNotificationHost.clearActiveNotifications();
171206

172207
testBinding.firebaseMessaging.onBackgroundMessage.add(
173208
RemoteMessage(data: data.toJson()));
174209
async.flushMicrotasks();
175-
checkNotification(data, expectedTitle: expectedTitle,
210+
checkNotification(data,
211+
messageStyleMessages: [data],
212+
expectedIsGroupConversation: expectedIsGroupConversation,
213+
expectedTitle: expectedTitle,
176214
expectedTagComponent: expectedTagComponent);
215+
testBinding.androidNotificationHost.clearActiveNotifications();
216+
}
217+
218+
Future<void> receiveFcmMessage(FakeAsync async, MessageFcmMessage data) async {
219+
testBinding.firebaseMessaging.onMessage.add(
220+
RemoteMessage(data: data.toJson()));
221+
async.flushMicrotasks();
177222
}
178223

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+
final message1 = eg.streamMessage(topic: 'topic 1', stream: stream, timestamp: 1);
238+
final data1 = messageFcmMessage(message1, streamName: stream.name);
239+
final message2 = eg.streamMessage(topic: 'topic 1', stream: stream, timestamp: 2);
240+
final data2 = messageFcmMessage(message2, streamName: stream.name);
241+
final message3 = eg.streamMessage(topic: 'topic 1', stream: stream, timestamp: 3);
242+
final data3 = messageFcmMessage(message3, streamName: stream.name);
243+
244+
await receiveFcmMessage(async, data1);
245+
checkNotification(data1,
246+
messageStyleMessages: [data1],
247+
expectedIsGroupConversation: true,
248+
expectedTitle: '#${stream.name} > ${message1.topic}',
249+
expectedTagComponent: 'stream:${message1.streamId}:${message1.topic}');
250+
251+
await receiveFcmMessage(async, data2);
252+
checkNotification(data2,
253+
messageStyleMessages: [data1, data2],
254+
expectedIsGroupConversation: true,
255+
expectedTitle: '#${stream.name} > ${message2.topic}',
256+
expectedTagComponent: 'stream:${message2.streamId}:${message2.topic}');
257+
258+
await receiveFcmMessage(async, data3);
259+
checkNotification(data3,
260+
messageStyleMessages: [data1, data2, data3],
261+
expectedIsGroupConversation: true,
262+
expectedTitle: '#${stream.name} > ${message3.topic}',
263+
expectedTagComponent: 'stream:${message3.streamId}:${message3.topic}');
264+
testBinding.androidNotificationHost.clearActiveNotifications();
265+
}));
266+
188267
test('stream message, stream name omitted', () => awaitFakeAsync((async) async {
189268
await init();
190269
final stream = eg.stream();
191270
final message = eg.streamMessage(stream: stream);
192271
await checkNotifications(async, messageFcmMessage(message, streamName: null),
272+
expectedIsGroupConversation: true,
193273
expectedTitle: '#(unknown channel) > ${message.topic}',
194274
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
195275
}));
@@ -198,6 +278,7 @@ void main() {
198278
await init();
199279
final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]);
200280
await checkNotifications(async, messageFcmMessage(message),
281+
expectedIsGroupConversation: true,
201282
expectedTitle: "${eg.thirdUser.fullName} to you and 1 other",
202283
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
203284
}));
@@ -207,6 +288,7 @@ void main() {
207288
final message = eg.dmMessage(from: eg.thirdUser,
208289
to: [eg.otherUser, eg.selfUser, eg.fourthUser]);
209290
await checkNotifications(async, messageFcmMessage(message),
291+
expectedIsGroupConversation: true,
210292
expectedTitle: "${eg.thirdUser.fullName} to you and 2 others",
211293
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
212294
}));
@@ -215,6 +297,7 @@ void main() {
215297
await init();
216298
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
217299
await checkNotifications(async, messageFcmMessage(message),
300+
expectedIsGroupConversation: false,
218301
expectedTitle: eg.otherUser.fullName,
219302
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
220303
}));
@@ -223,6 +306,7 @@ void main() {
223306
await init();
224307
final message = eg.dmMessage(from: eg.selfUser, to: []);
225308
await checkNotifications(async, messageFcmMessage(message),
309+
expectedIsGroupConversation: false,
226310
expectedTitle: eg.selfUser.fullName,
227311
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
228312
}));
@@ -403,6 +487,8 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
403487
Subject<String?> get groupKey => has((x) => x.groupKey, 'groupKey');
404488
Subject<InboxStyle?> get inboxStyle => has((x) => x.inboxStyle, 'inboxStyle');
405489
Subject<bool?> get isGroupSummary => has((x) => x.isGroupSummary, 'isGroupSummary');
490+
Subject<MessagingStyle?> get messagingStyle => has((x) => x.messagingStyle, 'messagingStyle');
491+
Subject<int?> get number => has((x) => x.number, 'number');
406492
Subject<String?> get smallIconResourceName => has((x) => x.smallIconResourceName, 'smallIconResourceName');
407493
}
408494

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

0 commit comments

Comments
 (0)