Skip to content

Commit

Permalink
notif: Create messaging-style notifications
Browse files Browse the repository at this point in the history
Fixes: zulip#128
  • Loading branch information
rajveermalviya committed Jun 24, 2024
1 parent b87f5d9 commit 9a61f7b
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 16 deletions.
57 changes: 46 additions & 11 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,29 @@ 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 = 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) =>
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -238,4 +263,14 @@ class NotificationDisplayManager {
page: MessageListPage(narrow: narrow)));
return;
}

static Future<Uint8List?> _fetchBitmap(Uri url) async {
try {
final resp = await http.get(url);
return resp.bodyBytes;
} catch (e) {
// TODO(log)
return null;
}
}
}
59 changes: 54 additions & 5 deletions test/notifications/display_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}';
Expand All @@ -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()
Expand Down Expand Up @@ -158,6 +175,7 @@ void main() {
Future<void> 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
Expand All @@ -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);
}

Expand All @@ -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}');
}));
Expand All @@ -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}');
}));
Expand All @@ -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(",")}');
}));
Expand All @@ -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(",")}');
}));
Expand All @@ -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(",")}');
}));
Expand All @@ -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(",")}');
}));
Expand Down Expand Up @@ -403,6 +431,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 +445,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 iconData => has((x) => x.iconData, 'iconData');
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 9a61f7b

Please sign in to comment.