From 9a61f7b644b2b7959f3f8d2a9d76544716defc1f Mon Sep 17 00:00:00 2001 From: rajveermalviya Date: Mon, 24 Jun 2024 21:12:53 +0530 Subject: [PATCH] notif: Create messaging-style notifications Fixes: #128 --- lib/notifications/display.dart | 57 +++++++++++++++++++++------ test/notifications/display_test.dart | 59 +++++++++++++++++++++++++--- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 54e8d18c192..1b0f877da68 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -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'; @@ -92,7 +93,29 @@ class NotificationDisplayManager { static Future _onMessageFcmMessage(MessageFcmMessage data, Map 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 = oldMessagingStyle != null + ? MessagingStyle( + user: oldMessagingStyle.user, + messages: oldMessagingStyle.messages?.toList() ?? [], // Clone a fixed-length list + isGroupConversation: oldMessagingStyle.isGroupConversation) + : MessagingStyle( + user: Person( + key: data.userId.toString(), + name: 'You'), // TODO(i18n) + messages: [], + isGroupConversation: switch (data.recipient) { + FcmMessageStreamRecipient() => true, + FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => true, + FcmMessageDmRecipient() => false, + }); + + messagingStyle.conversationTitle = switch (data.recipient) { FcmMessageStreamRecipient(:var streamName?, :var topic) => '#$streamName > $topic', FcmMessageStreamRecipient(:var topic) => @@ -103,8 +126,15 @@ 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: data.senderId.toString(), + name: data.senderFullName, + iconData: await _fetchBitmap(data.senderAvatarUrl))), + ); await ZulipBinding.instance.androidNotificationHost.notify( // TODO the notification ID can be constant, instead of matching requestCode @@ -114,8 +144,8 @@ class NotificationDisplayManager { channelId: NotificationChannelManager.kChannelId, groupKey: groupKey, - contentTitle: title, - contentText: data.content, + messagingStyle: messagingStyle, + number: messagingStyle.messages?.length, 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 @@ -158,11 +188,6 @@ class NotificationDisplayManager { inboxStyle: InboxStyle( // TODO(#570) Show organization name, not URL summaryText: data.realmUri.toString()), - - // On Android 11 and lower, if autoCancel is not specified, - // the summary notification may linger even after all child - // notifications have been opened and cleared. - // TODO(android-12): cut this autoCancel workaround autoCancel: true, ); } @@ -238,4 +263,14 @@ class NotificationDisplayManager { page: MessageListPage(narrow: narrow))); return; } + + static Future _fetchBitmap(Uri url) async { + try { + final resp = await http.get(url); + return resp.bodyBytes; + } catch (e) { + // TODO(log) + return null; + } + } } diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index b1a7183b874..82073a0b622 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -5,7 +5,7 @@ import 'package:checks/checks.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'; @@ -109,6 +109,7 @@ void main() { void checkNotification(MessageFcmMessage data, { required String expectedTitle, required String expectedTagComponent, + required bool expectedGroup, }) { final expectedTag = '${data.realmUri}|${data.userId}|$expectedTagComponent'; final expectedGroupKey = '${data.realmUri}|${data.userId}'; @@ -124,8 +125,24 @@ void main() { ..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.isNotNull() + ..iconData.isNull() + ..key.equals(data.userId.toString()) + ..name.equals('You')) + ..isGroupConversation.equals(expectedGroup) + ..conversationTitle.equals(expectedTitle) + ..messages.which((it) => it.isNotNull() + ..single.which((it) => it.isNotNull() + ..text.equals(data.content) + ..timestampMs.equals(data.time * 1000) + ..person.which((it) => it.isNotNull() + ..iconData.isNotNull() + ..key.equals(data.senderId.toString()) + ..name.equals(data.senderFullName))))) + ..number.equals(1) ..color.equals(kZulipBrandColor.value) ..smallIconResourceName.equals('zulip_notification') ..extras.isNull() @@ -158,6 +175,7 @@ void main() { Future checkNotifications(FakeAsync async, MessageFcmMessage data, { required String expectedTitle, required String expectedTagComponent, + required bool expectedGroup, }) async { // We could just call `NotificationDisplayManager.onFcmMessage`. // But this way is cheap, and it provides our test coverage of @@ -166,13 +184,17 @@ void main() { testBinding.firebaseMessaging.onMessage.add( RemoteMessage(data: data.toJson())); async.flushMicrotasks(); - checkNotification(data, expectedTitle: expectedTitle, + checkNotification(data, + expectedGroup: expectedGroup, + expectedTitle: expectedTitle, expectedTagComponent: expectedTagComponent); testBinding.firebaseMessaging.onBackgroundMessage.add( RemoteMessage(data: data.toJson())); async.flushMicrotasks(); - checkNotification(data, expectedTitle: expectedTitle, + checkNotification(data, + expectedGroup: expectedGroup, + expectedTitle: expectedTitle, expectedTagComponent: expectedTagComponent); } @@ -181,6 +203,7 @@ void main() { final stream = eg.stream(); final message = eg.streamMessage(stream: stream); await checkNotifications(async, messageFcmMessage(message, streamName: stream.name), + expectedGroup: true, expectedTitle: '#${stream.name} > ${message.topic}', expectedTagComponent: 'stream:${message.streamId}:${message.topic}'); })); @@ -190,6 +213,7 @@ void main() { final stream = eg.stream(); final message = eg.streamMessage(stream: stream); await checkNotifications(async, messageFcmMessage(message, streamName: null), + expectedGroup: true, expectedTitle: '#(unknown channel) > ${message.topic}', expectedTagComponent: 'stream:${message.streamId}:${message.topic}'); })); @@ -198,6 +222,7 @@ void main() { await init(); final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]); await checkNotifications(async, messageFcmMessage(message), + expectedGroup: true, expectedTitle: "${eg.thirdUser.fullName} to you and 1 other", expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); })); @@ -207,6 +232,7 @@ void main() { final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser, eg.fourthUser]); await checkNotifications(async, messageFcmMessage(message), + expectedGroup: true, expectedTitle: "${eg.thirdUser.fullName} to you and 2 others", expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); })); @@ -215,6 +241,7 @@ void main() { await init(); final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); await checkNotifications(async, messageFcmMessage(message), + expectedGroup: false, expectedTitle: eg.otherUser.fullName, expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); })); @@ -223,6 +250,7 @@ void main() { await init(); final message = eg.dmMessage(from: eg.selfUser, to: []); await checkNotifications(async, messageFcmMessage(message), + expectedGroup: false, expectedTitle: eg.selfUser.fullName, expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); })); @@ -403,6 +431,8 @@ extension on Subject { Subject get groupKey => has((x) => x.groupKey, 'groupKey'); Subject get inboxStyle => has((x) => x.inboxStyle, 'inboxStyle'); Subject get isGroupSummary => has((x) => x.isGroupSummary, 'isGroupSummary'); + Subject get messagingStyle => has((x) => x.messagingStyle, 'messagingStyle'); + Subject get number => has((x) => x.number, 'number'); Subject get smallIconResourceName => has((x) => x.smallIconResourceName, 'smallIconResourceName'); } @@ -415,3 +445,22 @@ extension on Subject { extension on Subject { Subject get summaryText => has((x) => x.summaryText, 'summaryText'); } + +extension on Subject { + Subject get user => has((x) => x.user, 'user'); + Subject get conversationTitle => has((x) => x.conversationTitle, 'conversationTitle'); + Subject?> get messages => has((x) => x.messages, 'messages'); + Subject get isGroupConversation => has((x) => x.isGroupConversation, 'isGroupConversation'); +} + +extension on Subject { + Subject get iconData => has((x) => x.iconData, 'iconData'); + Subject get key => has((x) => x.key, 'key'); + Subject get name => has((x) => x.name, 'name'); +} + +extension on Subject { + Subject get text => has((x) => x.text, 'text'); + Subject get timestampMs => has((x) => x.timestampMs, 'timestampMs'); + Subject get person => has((x) => x.person, 'person'); +}