Skip to content

Commit 042a89e

Browse files
notif: Create messaging-style notifications
Fixes: #128
1 parent 3ad3a48 commit 042a89e

File tree

2 files changed

+158
-38
lines changed

2 files changed

+158
-38
lines changed

lib/notifications/display.dart

Lines changed: 46 additions & 11 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,29 @@ 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 = oldMessagingStyle != null
103+
? MessagingStyle(
104+
user: oldMessagingStyle.user,
105+
messages: oldMessagingStyle.messages?.toList() ?? [], // Clone a fixed-length list
106+
isGroupConversation: oldMessagingStyle.isGroupConversation)
107+
: MessagingStyle(
108+
user: Person(
109+
key: data.userId.toString(),
110+
name: 'You'), // TODO(i18n)
111+
messages: [],
112+
isGroupConversation: switch (data.recipient) {
113+
FcmMessageStreamRecipient() => true,
114+
FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => true,
115+
FcmMessageDmRecipient() => false,
116+
});
117+
118+
messagingStyle.conversationTitle = switch (data.recipient) {
96119
FcmMessageStreamRecipient(:var streamName?, :var topic) =>
97120
'#$streamName > $topic',
98121
FcmMessageStreamRecipient(:var topic) =>
@@ -103,8 +126,15 @@ class NotificationDisplayManager {
103126
FcmMessageDmRecipient() =>
104127
data.senderFullName,
105128
};
106-
final groupKey = _groupKey(data);
107-
final conversationKey = _conversationKey(data, groupKey);
129+
130+
messagingStyle.messages?.add(MessagingStyleMessage(
131+
text: data.content,
132+
timestampMs: data.time * 1000,
133+
person: Person(
134+
key: data.senderId.toString(),
135+
name: data.senderFullName,
136+
iconData: await _fetchBitmap(data.senderAvatarUrl))),
137+
);
108138

109139
await ZulipBinding.instance.androidNotificationHost.notify(
110140
// TODO the notification ID can be constant, instead of matching requestCode
@@ -114,8 +144,8 @@ class NotificationDisplayManager {
114144
channelId: NotificationChannelManager.kChannelId,
115145
groupKey: groupKey,
116146

117-
contentTitle: title,
118-
contentText: data.content,
147+
messagingStyle: messagingStyle,
148+
number: messagingStyle.messages?.length,
119149
color: kZulipBrandColor.value,
120150
// TODO vary notification icon for debug
121151
smallIconResourceName: 'zulip_notification', // This name must appear in keep.xml too: https://github.com/zulip/zulip-flutter/issues/528
@@ -158,11 +188,6 @@ class NotificationDisplayManager {
158188
inboxStyle: InboxStyle(
159189
// TODO(#570) Show organization name, not URL
160190
summaryText: data.realmUri.toString()),
161-
162-
// On Android 11 and lower, if autoCancel is not specified,
163-
// the summary notification may linger even after all child
164-
// notifications have been opened and cleared.
165-
// TODO(android-12): cut this autoCancel workaround
166191
autoCancel: true,
167192
);
168193
}
@@ -238,4 +263,14 @@ class NotificationDisplayManager {
238263
page: MessageListPage(narrow: narrow)));
239264
return;
240265
}
266+
267+
static Future<Uint8List?> _fetchBitmap(Uri url) async {
268+
try {
269+
final resp = await http.get(url);
270+
return resp.bodyBytes;
271+
} catch (e) {
272+
// TODO(log)
273+
return null;
274+
}
275+
}
241276
}

test/notifications/display_test.dart

Lines changed: 112 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,54 @@ 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+
126+
// List of all the checks for messages on each notify calls.
127+
final messagesChecks = <Condition<MessagingStyleMessage?>>[];
128+
for (int j = 0; j < (i + 1); j++) {
129+
final data = messages[j];
130+
messagesChecks.add((it) => it.isNotNull()
131+
..text.equals(data.content)
132+
..timestampMs.equals(data.time * 1000)
133+
..person.which((it) => it.isNotNull()
134+
..iconData.isNotNull()
135+
..key.equals(data.senderId.toString())
136+
..name.equals(data.senderFullName)));
137+
}
138+
139+
notifyCallsChecks.addAll(<Condition<AndroidNotificationHostApiNotifyCall>>[
123140
(it) => it
124141
..id.equals(expectedId)
125142
..tag.equals(expectedTag)
126143
..channelId.equals(NotificationChannelManager.kChannelId)
127-
..contentTitle.equals(expectedTitle)
128-
..contentText.equals(data.content)
144+
..contentTitle.isNull()
145+
..contentText.isNull()
146+
..messagingStyle.which((it) => it.isNotNull()
147+
..user.which((it) => it.isNotNull()
148+
..iconData.isNull()
149+
..key.equals(data.userId.toString())
150+
..name.equals('You'))
151+
..isGroupConversation.equals(expectedGroup)
152+
..conversationTitle.equals(expectedTitle)
153+
..messages.which((it) => it.isNotNull()
154+
..length.equals(messagesChecks.length)
155+
..containsInOrder(messagesChecks)))
156+
..number.equals(messagesChecks.length)
129157
..color.equals(kZulipBrandColor.value)
130158
..smallIconResourceName.equals('zulip_notification')
131159
..extras.isNull()
@@ -151,53 +179,86 @@ void main() {
151179
..inboxStyle.which((it) => it.isNotNull()
152180
..summaryText.equals(data.realmUri.toString()))
153181
..autoCancel.equals(true)
154-
..contentIntent.isNull()
182+
..contentIntent.isNull(),
155183
]);
184+
}
185+
186+
check(testBinding.androidNotificationHost.takeNotifyCalls())
187+
..length.equals(messages.length * 2)
188+
..containsInOrder(notifyCallsChecks);
156189
}
157190

158-
Future<void> checkNotifications(FakeAsync async, MessageFcmMessage data, {
191+
Future<void> checkNotifications(FakeAsync async, List<MessageFcmMessage> messages, {
159192
required String expectedTitle,
160193
required String expectedTagComponent,
194+
required bool expectedGroup,
161195
}) async {
162196
// We could just call `NotificationDisplayManager.onFcmMessage`.
163197
// But this way is cheap, and it provides our test coverage of
164198
// the logic in `NotificationService` that listens for these FCM messages.
165199

166-
testBinding.firebaseMessaging.onMessage.add(
167-
RemoteMessage(data: data.toJson()));
168-
async.flushMicrotasks();
169-
checkNotification(data, expectedTitle: expectedTitle,
200+
for (final data in messages) {
201+
testBinding.firebaseMessaging.onMessage.add(
202+
RemoteMessage(data: data.toJson()));
203+
async.flushMicrotasks();
204+
}
205+
checkNotification(messages,
206+
expectedGroup: expectedGroup,
207+
expectedTitle: expectedTitle,
170208
expectedTagComponent: expectedTagComponent);
171209

172-
testBinding.firebaseMessaging.onBackgroundMessage.add(
173-
RemoteMessage(data: data.toJson()));
174-
async.flushMicrotasks();
175-
checkNotification(data, expectedTitle: expectedTitle,
210+
for (final data in messages) {
211+
testBinding.firebaseMessaging.onBackgroundMessage.add(
212+
RemoteMessage(data: data.toJson()));
213+
async.flushMicrotasks();
214+
}
215+
checkNotification(messages,
216+
expectedGroup: expectedGroup,
217+
expectedTitle: expectedTitle,
176218
expectedTagComponent: expectedTagComponent);
177219
}
178220

179221
test('stream message', () => awaitFakeAsync((async) async {
180222
await init();
181223
final stream = eg.stream();
182224
final message = eg.streamMessage(stream: stream);
183-
await checkNotifications(async, messageFcmMessage(message, streamName: stream.name),
225+
await checkNotifications(async, [messageFcmMessage(message, streamName: stream.name)],
226+
expectedGroup: true,
184227
expectedTitle: '#${stream.name} > ${message.topic}',
185228
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
186229
}));
187230

231+
test('multiple stream messages', () => awaitFakeAsync((async) async {
232+
await init();
233+
final stream = eg.stream(streamId: 1, name: 'stream 1');
234+
final message1 = eg.streamMessage(id: 101, stream: stream);
235+
final messageData1 = messageFcmMessage(message1, streamName: stream.name);
236+
final message2 = eg.streamMessage(id: 102, stream: stream);
237+
final messageData2 = messageFcmMessage(message2, streamName: stream.name);
238+
final message3 = eg.streamMessage(id: 102, stream: stream);
239+
final messageData3 = messageFcmMessage(message3, streamName: stream.name);
240+
241+
await checkNotifications(async, [messageData1, messageData2, messageData3],
242+
expectedGroup: true,
243+
expectedTitle: '#${stream.name} > ${message2.topic}',
244+
expectedTagComponent: 'stream:${message2.streamId}:${message2.topic}');
245+
}));
246+
188247
test('stream message, stream name omitted', () => awaitFakeAsync((async) async {
189248
await init();
190249
final stream = eg.stream();
191250
final message = eg.streamMessage(stream: stream);
192-
await checkNotifications(async, messageFcmMessage(message, streamName: null),
251+
await checkNotifications(async, [messageFcmMessage(message, streamName: null)],
252+
expectedGroup: true,
193253
expectedTitle: '#(unknown channel) > ${message.topic}',
194254
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
195255
}));
196256

197257
test('group DM: 3 users', () => awaitFakeAsync((async) async {
198258
await init();
199259
final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]);
200-
await checkNotifications(async, messageFcmMessage(message),
260+
await checkNotifications(async, [messageFcmMessage(message)],
261+
expectedGroup: true,
201262
expectedTitle: "${eg.thirdUser.fullName} to you and 1 other",
202263
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
203264
}));
@@ -206,23 +267,26 @@ void main() {
206267
await init();
207268
final message = eg.dmMessage(from: eg.thirdUser,
208269
to: [eg.otherUser, eg.selfUser, eg.fourthUser]);
209-
await checkNotifications(async, messageFcmMessage(message),
270+
await checkNotifications(async, [messageFcmMessage(message)],
271+
expectedGroup: true,
210272
expectedTitle: "${eg.thirdUser.fullName} to you and 2 others",
211273
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
212274
}));
213275

214276
test('1:1 DM', () => awaitFakeAsync((async) async {
215277
await init();
216278
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
217-
await checkNotifications(async, messageFcmMessage(message),
279+
await checkNotifications(async, [messageFcmMessage(message)],
280+
expectedGroup: false,
218281
expectedTitle: eg.otherUser.fullName,
219282
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
220283
}));
221284

222285
test('self-DM', () => awaitFakeAsync((async) async {
223286
await init();
224287
final message = eg.dmMessage(from: eg.selfUser, to: []);
225-
await checkNotifications(async, messageFcmMessage(message),
288+
await checkNotifications(async, [messageFcmMessage(message)],
289+
expectedGroup: false,
226290
expectedTitle: eg.selfUser.fullName,
227291
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
228292
}));
@@ -403,6 +467,8 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
403467
Subject<String?> get groupKey => has((x) => x.groupKey, 'groupKey');
404468
Subject<InboxStyle?> get inboxStyle => has((x) => x.inboxStyle, 'inboxStyle');
405469
Subject<bool?> get isGroupSummary => has((x) => x.isGroupSummary, 'isGroupSummary');
470+
Subject<MessagingStyle?> get messagingStyle => has((x) => x.messagingStyle, 'messagingStyle');
471+
Subject<int?> get number => has((x) => x.number, 'number');
406472
Subject<String?> get smallIconResourceName => has((x) => x.smallIconResourceName, 'smallIconResourceName');
407473
}
408474

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

0 commit comments

Comments
 (0)