Skip to content

Commit 1596a1a

Browse files
rajveermalviyagnprice
authored andcommitted
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 4e3e0a4 commit 1596a1a

File tree

2 files changed

+173
-18
lines changed

2 files changed

+173
-18
lines changed

lib/notifications/display.dart

+56-7
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

+117-11
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ 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';
@@ -107,25 +108,53 @@ void main() {
107108

108109
group('NotificationDisplayManager show', () {
109110
void checkNotification(MessageFcmMessage data, {
111+
required List<MessageFcmMessage> messageStyleMessages,
110112
required String expectedTitle,
111113
required String expectedTagComponent,
114+
required bool expectedIsGroupConversation,
112115
}) {
113116
final expectedTag = '${data.realmUri}|${data.userId}|$expectedTagComponent';
114117
final expectedGroupKey = '${data.realmUri}|${data.userId}';
115118
final expectedId =
116119
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
117120
const expectedIntentFlags =
118121
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
122+
final expectedSelfUserKey = '${data.realmUri}|${data.userId}';
123+
124+
final messageStyleMessagesChecks =
125+
messageStyleMessages.mapIndexed((i, messageData) {
126+
assert(messageData.realmUri == data.realmUri);
127+
assert(messageData.userId == data.userId);
128+
129+
final expectedSenderKey =
130+
'${messageData.realmUri}|${messageData.senderId}';
131+
final isLast = i == (messageStyleMessages.length - 1);
132+
return (Subject<Object?> it) => it.isA<MessagingStyleMessage>()
133+
..text.equals(messageData.content)
134+
..timestampMs.equals(messageData.time * 1000)
135+
..person.which((it) => it.isNotNull()
136+
..iconBitmap.which((it) => isLast ? it.isNotNull() : it.isNull())
137+
..key.equals(expectedSenderKey)
138+
..name.equals(messageData.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('You')) // TODO(i18n)
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,14 @@ 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

158187
Future<void> checkNotifications(FakeAsync async, MessageFcmMessage data, {
159188
required String expectedTitle,
160189
required String expectedTagComponent,
190+
required bool expectedIsGroupConversation,
161191
}) async {
162192
// We could just call `NotificationDisplayManager.onFcmMessage`.
163193
// But this way is cheap, and it provides our test coverage of
@@ -166,30 +196,81 @@ void main() {
166196
testBinding.firebaseMessaging.onMessage.add(
167197
RemoteMessage(data: data.toJson()));
168198
async.flushMicrotasks();
169-
checkNotification(data, expectedTitle: expectedTitle,
199+
checkNotification(data,
200+
messageStyleMessages: [data],
201+
expectedIsGroupConversation: expectedIsGroupConversation,
202+
expectedTitle: expectedTitle,
170203
expectedTagComponent: expectedTagComponent);
204+
testBinding.androidNotificationHost.clearActiveNotifications();
171205

172206
testBinding.firebaseMessaging.onBackgroundMessage.add(
173207
RemoteMessage(data: data.toJson()));
174208
async.flushMicrotasks();
175-
checkNotification(data, expectedTitle: expectedTitle,
209+
checkNotification(data,
210+
messageStyleMessages: [data],
211+
expectedIsGroupConversation: expectedIsGroupConversation,
212+
expectedTitle: expectedTitle,
176213
expectedTagComponent: expectedTagComponent);
177214
}
178215

216+
Future<void> receiveFcmMessage(FakeAsync async, MessageFcmMessage data) async {
217+
testBinding.firebaseMessaging.onMessage.add(
218+
RemoteMessage(data: data.toJson()));
219+
async.flushMicrotasks();
220+
}
221+
179222
test('stream message', () => awaitFakeAsync((async) async {
180223
await init();
181224
final stream = eg.stream();
182225
final message = eg.streamMessage(stream: stream);
183226
await checkNotifications(async, messageFcmMessage(message, streamName: stream.name),
227+
expectedIsGroupConversation: true,
184228
expectedTitle: '#${stream.name} > ${message.topic}',
185229
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
186230
}));
187231

188-
test('stream message, stream name omitted', () => awaitFakeAsync((async) async {
232+
test('stream message: multiple messages, same topic', () => awaitFakeAsync((async) async {
233+
await init();
234+
final stream = eg.stream();
235+
const topic = 'topic 1';
236+
final message1 = eg.streamMessage(topic: topic, stream: stream);
237+
final data1 = messageFcmMessage(message1, streamName: stream.name);
238+
final message2 = eg.streamMessage(topic: topic, stream: stream);
239+
final data2 = messageFcmMessage(message2, streamName: stream.name);
240+
final message3 = eg.streamMessage(topic: topic, stream: stream);
241+
final data3 = messageFcmMessage(message3, streamName: stream.name);
242+
243+
final expectedTitle = '#${stream.name} > $topic';
244+
final expectedTagComponent = 'stream:${stream.streamId}:$topic';
245+
246+
await receiveFcmMessage(async, data1);
247+
checkNotification(data1,
248+
messageStyleMessages: [data1],
249+
expectedIsGroupConversation: true,
250+
expectedTitle: expectedTitle,
251+
expectedTagComponent: expectedTagComponent);
252+
253+
await receiveFcmMessage(async, data2);
254+
checkNotification(data2,
255+
messageStyleMessages: [data1, data2],
256+
expectedIsGroupConversation: true,
257+
expectedTitle: expectedTitle,
258+
expectedTagComponent: expectedTagComponent);
259+
260+
await receiveFcmMessage(async, data3);
261+
checkNotification(data3,
262+
messageStyleMessages: [data1, data2, data3],
263+
expectedIsGroupConversation: true,
264+
expectedTitle: expectedTitle,
265+
expectedTagComponent: expectedTagComponent);
266+
}));
267+
268+
test('stream message: stream name omitted', () => awaitFakeAsync((async) async {
189269
await init();
190270
final stream = eg.stream();
191271
final message = eg.streamMessage(stream: stream);
192272
await checkNotifications(async, messageFcmMessage(message, streamName: null),
273+
expectedIsGroupConversation: true,
193274
expectedTitle: '#(unknown channel) > ${message.topic}',
194275
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
195276
}));
@@ -198,6 +279,7 @@ void main() {
198279
await init();
199280
final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]);
200281
await checkNotifications(async, messageFcmMessage(message),
282+
expectedIsGroupConversation: true,
201283
expectedTitle: "${eg.thirdUser.fullName} to you and 1 other",
202284
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
203285
}));
@@ -207,6 +289,7 @@ void main() {
207289
final message = eg.dmMessage(from: eg.thirdUser,
208290
to: [eg.otherUser, eg.selfUser, eg.fourthUser]);
209291
await checkNotifications(async, messageFcmMessage(message),
292+
expectedIsGroupConversation: true,
210293
expectedTitle: "${eg.thirdUser.fullName} to you and 2 others",
211294
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
212295
}));
@@ -215,6 +298,7 @@ void main() {
215298
await init();
216299
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
217300
await checkNotifications(async, messageFcmMessage(message),
301+
expectedIsGroupConversation: false,
218302
expectedTitle: eg.otherUser.fullName,
219303
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
220304
}));
@@ -223,6 +307,7 @@ void main() {
223307
await init();
224308
final message = eg.dmMessage(from: eg.selfUser, to: []);
225309
await checkNotifications(async, messageFcmMessage(message),
310+
expectedIsGroupConversation: false,
226311
expectedTitle: eg.selfUser.fullName,
227312
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
228313
}));
@@ -403,6 +488,8 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
403488
Subject<String?> get groupKey => has((x) => x.groupKey, 'groupKey');
404489
Subject<InboxStyle?> get inboxStyle => has((x) => x.inboxStyle, 'inboxStyle');
405490
Subject<bool?> get isGroupSummary => has((x) => x.isGroupSummary, 'isGroupSummary');
491+
Subject<MessagingStyle?> get messagingStyle => has((x) => x.messagingStyle, 'messagingStyle');
492+
Subject<int?> get number => has((x) => x.number, 'number');
406493
Subject<String?> get smallIconResourceName => has((x) => x.smallIconResourceName, 'smallIconResourceName');
407494
}
408495

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

0 commit comments

Comments
 (0)