Skip to content

Commit

Permalink
notif: Create messaging-style notifications
Browse files Browse the repository at this point in the history
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: zulip#128
  • Loading branch information
rajveermalviya committed Jul 15, 2024
1 parent 0269ea7 commit eb54c33
Show file tree
Hide file tree
Showing 2 changed files with 173 additions and 18 deletions.
63 changes: 56 additions & 7 deletions lib/notifications/display.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Person;

import '../api/notifications.dart';
import '../host/android_notifications.dart';
Expand Down Expand Up @@ -92,7 +93,36 @@ class NotificationDisplayManager {
static Future<void> _onMessageFcmMessage(MessageFcmMessage data, Map<String, dynamic> dataJson) async {
assert(debugLog('notif message content: ${data.content}'));
final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
final title = switch (data.recipient) {
final groupKey = _groupKey(data);
final conversationKey = _conversationKey(data, groupKey);

final oldMessagingStyle = await ZulipBinding.instance.androidNotificationHost
.getActiveNotificationMessagingStyleByTag(conversationKey);

final MessagingStyle messagingStyle;
if (oldMessagingStyle != null) {
messagingStyle = oldMessagingStyle;
messagingStyle.messages =
oldMessagingStyle.messages.toList(); // Clone fixed-length list to growable.
} else {
messagingStyle = MessagingStyle(
user: Person(
key: _personKey(data.realmUri, data.userId),
name: 'You'), // TODO(i18n)
messages: [],
isGroupConversation: switch (data.recipient) {
FcmMessageStreamRecipient() => true,
FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => true,
FcmMessageDmRecipient() => false,
});
}

// The title typically won't change between messages in a conversation, but we
// update it anyway. This means a DM sender's display name gets updated if it's
// changed, which is a rare edge case but probably good. The main effect is that
// group-DM threads (pending #794) get titled with the latest sender, rather than
// the first.
messagingStyle.conversationTitle = switch (data.recipient) {
FcmMessageStreamRecipient(:var streamName?, :var topic) =>
'#$streamName > $topic',
FcmMessageStreamRecipient(:var topic) =>
Expand All @@ -103,8 +133,14 @@ class NotificationDisplayManager {
FcmMessageDmRecipient() =>
data.senderFullName,
};
final groupKey = _groupKey(data);
final conversationKey = _conversationKey(data, groupKey);

messagingStyle.messages.add(MessagingStyleMessage(
text: data.content,
timestampMs: data.time * 1000,
person: Person(
key: _personKey(data.realmUri, data.senderId),
name: data.senderFullName,
iconBitmap: await _fetchBitmap(data.senderAvatarUrl))));

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

contentTitle: title,
contentText: data.content,
color: kZulipBrandColor.value,
// TODO vary notification icon for debug
smallIconResourceName: 'zulip_notification', // This name must appear in keep.xml too: https://github.com/zulip/zulip-flutter/issues/528
// TODO(#128) inbox-style

messagingStyle: messagingStyle,
number: messagingStyle.messages.length,

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

static String _personKey(Uri realmUri, int userId) => "$realmUri|$userId";

static void _onNotificationOpened(NotificationResponse response) async {
final payload = jsonDecode(response.payload!) as Map<String, dynamic>;
final data = MessageFcmMessage.fromJson(payload);
Expand Down Expand Up @@ -238,4 +276,15 @@ class NotificationDisplayManager {
page: MessageListPage(narrow: narrow)));
return;
}

static Future<Uint8List?> _fetchBitmap(Uri url) async {
try {
// TODO timeout to prevent waiting indefinitely
final resp = await http.get(url);
return resp.bodyBytes;
} catch (e) {
// TODO(log)
return null;
}
}
}
128 changes: 117 additions & 11 deletions test/notifications/display_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import 'dart:convert';
import 'dart:typed_data';

import 'package:checks/checks.dart';
import 'package:collection/collection.dart';
import 'package:fake_async/fake_async.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message;
import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message, Person;
import 'package:flutter_test/flutter_test.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/notifications.dart';
Expand Down Expand Up @@ -107,25 +108,53 @@ void main() {

group('NotificationDisplayManager show', () {
void checkNotification(MessageFcmMessage data, {
required List<MessageFcmMessage> messageStyleMessages,
required String expectedTitle,
required String expectedTagComponent,
required bool expectedIsGroupConversation,
}) {
final expectedTag = '${data.realmUri}|${data.userId}|$expectedTagComponent';
final expectedGroupKey = '${data.realmUri}|${data.userId}';
final expectedId =
NotificationDisplayManager.notificationIdAsHashOf(expectedTag);
const expectedIntentFlags =
PendingIntentFlag.immutable | PendingIntentFlag.updateCurrent;
final expectedSelfUserKey = '${data.realmUri}|${data.userId}';

final messageStyleMessagesChecks = messageStyleMessages
.mapIndexed<Condition<Object?>>((i, messageData) {
assert(messageData.realmUri == data.realmUri);
assert(messageData.userId == data.userId);

final expectedSenderKey =
'${messageData.realmUri}|${messageData.senderId}';
final isLast = i == (messageStyleMessages.length - 1);
return (it) => it.isA<MessagingStyleMessage>()
..text.equals(messageData.content)
..timestampMs.equals(messageData.time * 1000)
..person.which((it) => it.isNotNull()
..iconBitmap.which((it) => isLast ? it.isNotNull() : it.isNull())
..key.equals(expectedSenderKey)
..name.equals(messageData.senderFullName));
});

check(testBinding.androidNotificationHost.takeNotifyCalls())
..length.equals(2)
..containsInOrder(<Condition<AndroidNotificationHostApiNotifyCall>>[
(it) => it
.deepEquals(<Condition<Object?>>[
(it) => it.isA<AndroidNotificationHostApiNotifyCall>()
..id.equals(expectedId)
..tag.equals(expectedTag)
..channelId.equals(NotificationChannelManager.kChannelId)
..contentTitle.equals(expectedTitle)
..contentText.equals(data.content)
..contentTitle.isNull()
..contentText.isNull()
..messagingStyle.which((it) => it.isNotNull()
..user.which((it) => it
..iconBitmap.isNull()
..key.equals(expectedSelfUserKey)
..name.equals('You')) // TODO(i18n)
..isGroupConversation.equals(expectedIsGroupConversation)
..conversationTitle.equals(expectedTitle)
..messages.deepEquals(messageStyleMessagesChecks))
..number.equals(messageStyleMessages.length)
..color.equals(kZulipBrandColor.value)
..smallIconResourceName.equals('zulip_notification')
..extras.isNull()
Expand All @@ -137,7 +166,7 @@ void main() {
..requestCode.equals(expectedId)
..flags.equals(expectedIntentFlags)
..intentPayload.equals(jsonEncode(data.toJson()))),
(it) => it
(it) => it.isA<AndroidNotificationHostApiNotifyCall>()
..id.equals(NotificationDisplayManager.notificationIdAsHashOf(expectedGroupKey))
..tag.equals(expectedGroupKey)
..channelId.equals(NotificationChannelManager.kChannelId)
Expand All @@ -151,13 +180,14 @@ void main() {
..inboxStyle.which((it) => it.isNotNull()
..summaryText.equals(data.realmUri.toString()))
..autoCancel.equals(true)
..contentIntent.isNull()
..contentIntent.isNull(),
]);
}

Future<void> checkNotifications(FakeAsync async, MessageFcmMessage data, {
required String expectedTitle,
required String expectedTagComponent,
required bool expectedIsGroupConversation,
}) async {
// We could just call `NotificationDisplayManager.onFcmMessage`.
// But this way is cheap, and it provides our test coverage of
Expand All @@ -166,30 +196,81 @@ void main() {
testBinding.firebaseMessaging.onMessage.add(
RemoteMessage(data: data.toJson()));
async.flushMicrotasks();
checkNotification(data, expectedTitle: expectedTitle,
checkNotification(data,
messageStyleMessages: [data],
expectedIsGroupConversation: expectedIsGroupConversation,
expectedTitle: expectedTitle,
expectedTagComponent: expectedTagComponent);
testBinding.androidNotificationHost.clearActiveNotifications();

testBinding.firebaseMessaging.onBackgroundMessage.add(
RemoteMessage(data: data.toJson()));
async.flushMicrotasks();
checkNotification(data, expectedTitle: expectedTitle,
checkNotification(data,
messageStyleMessages: [data],
expectedIsGroupConversation: expectedIsGroupConversation,
expectedTitle: expectedTitle,
expectedTagComponent: expectedTagComponent);
}

Future<void> receiveFcmMessage(FakeAsync async, MessageFcmMessage data) async {
testBinding.firebaseMessaging.onMessage.add(
RemoteMessage(data: data.toJson()));
async.flushMicrotasks();
}

test('stream message', () => awaitFakeAsync((async) async {
await init();
final stream = eg.stream();
final message = eg.streamMessage(stream: stream);
await checkNotifications(async, messageFcmMessage(message, streamName: stream.name),
expectedIsGroupConversation: true,
expectedTitle: '#${stream.name} > ${message.topic}',
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
}));

test('stream message, stream name omitted', () => awaitFakeAsync((async) async {
test('stream message: multiple messages, same topic', () => awaitFakeAsync((async) async {
await init();
final stream = eg.stream();
const topic = 'topic 1';
final message1 = eg.streamMessage(topic: topic, stream: stream);
final data1 = messageFcmMessage(message1, streamName: stream.name);
final message2 = eg.streamMessage(topic: topic, stream: stream);
final data2 = messageFcmMessage(message2, streamName: stream.name);
final message3 = eg.streamMessage(topic: topic, stream: stream);
final data3 = messageFcmMessage(message3, streamName: stream.name);

final expectedTitle = '#${stream.name} > $topic';
final expectedTagComponent = 'stream:${stream.streamId}:$topic';

await receiveFcmMessage(async, data1);
checkNotification(data1,
messageStyleMessages: [data1],
expectedIsGroupConversation: true,
expectedTitle: expectedTitle,
expectedTagComponent: expectedTagComponent);

await receiveFcmMessage(async, data2);
checkNotification(data2,
messageStyleMessages: [data1, data2],
expectedIsGroupConversation: true,
expectedTitle: expectedTitle,
expectedTagComponent: expectedTagComponent);

await receiveFcmMessage(async, data3);
checkNotification(data3,
messageStyleMessages: [data1, data2, data3],
expectedIsGroupConversation: true,
expectedTitle: expectedTitle,
expectedTagComponent: expectedTagComponent);
}));

test('stream message: stream name omitted', () => awaitFakeAsync((async) async {
await init();
final stream = eg.stream();
final message = eg.streamMessage(stream: stream);
await checkNotifications(async, messageFcmMessage(message, streamName: null),
expectedIsGroupConversation: true,
expectedTitle: '#(unknown channel) > ${message.topic}',
expectedTagComponent: 'stream:${message.streamId}:${message.topic}');
}));
Expand All @@ -198,6 +279,7 @@ void main() {
await init();
final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]);
await checkNotifications(async, messageFcmMessage(message),
expectedIsGroupConversation: true,
expectedTitle: "${eg.thirdUser.fullName} to you and 1 other",
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
}));
Expand All @@ -207,6 +289,7 @@ void main() {
final message = eg.dmMessage(from: eg.thirdUser,
to: [eg.otherUser, eg.selfUser, eg.fourthUser]);
await checkNotifications(async, messageFcmMessage(message),
expectedIsGroupConversation: true,
expectedTitle: "${eg.thirdUser.fullName} to you and 2 others",
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
}));
Expand All @@ -215,6 +298,7 @@ void main() {
await init();
final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]);
await checkNotifications(async, messageFcmMessage(message),
expectedIsGroupConversation: false,
expectedTitle: eg.otherUser.fullName,
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
}));
Expand All @@ -223,6 +307,7 @@ void main() {
await init();
final message = eg.dmMessage(from: eg.selfUser, to: []);
await checkNotifications(async, messageFcmMessage(message),
expectedIsGroupConversation: false,
expectedTitle: eg.selfUser.fullName,
expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}');
}));
Expand Down Expand Up @@ -403,6 +488,8 @@ extension on Subject<AndroidNotificationHostApiNotifyCall> {
Subject<String?> get groupKey => has((x) => x.groupKey, 'groupKey');
Subject<InboxStyle?> get inboxStyle => has((x) => x.inboxStyle, 'inboxStyle');
Subject<bool?> get isGroupSummary => has((x) => x.isGroupSummary, 'isGroupSummary');
Subject<MessagingStyle?> get messagingStyle => has((x) => x.messagingStyle, 'messagingStyle');
Subject<int?> get number => has((x) => x.number, 'number');
Subject<String?> get smallIconResourceName => has((x) => x.smallIconResourceName, 'smallIconResourceName');
}

Expand All @@ -415,3 +502,22 @@ extension on Subject<PendingIntent> {
extension on Subject<InboxStyle> {
Subject<String> get summaryText => has((x) => x.summaryText, 'summaryText');
}

extension on Subject<MessagingStyle> {
Subject<Person> get user => has((x) => x.user, 'user');
Subject<String?> get conversationTitle => has((x) => x.conversationTitle, 'conversationTitle');
Subject<List<MessagingStyleMessage?>> get messages => has((x) => x.messages, 'messages');
Subject<bool> get isGroupConversation => has((x) => x.isGroupConversation, 'isGroupConversation');
}

extension on Subject<Person> {
Subject<Uint8List?> get iconBitmap => has((x) => x.iconBitmap, 'iconBitmap');
Subject<String> get key => has((x) => x.key, 'key');
Subject<String> get name => has((x) => x.name, 'name');
}

extension on Subject<MessagingStyleMessage> {
Subject<String> get text => has((x) => x.text, 'text');
Subject<int> get timestampMs => has((x) => x.timestampMs, 'timestampMs');
Subject<Person> get person => has((x) => x.person, 'person');
}

0 comments on commit eb54c33

Please sign in to comment.