From 2096aa4e7081f96bb077d71d21449a2259ceec6d Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 27 Oct 2023 10:23:36 -0700 Subject: [PATCH] notif: Show notifications! on Android, in foreground This implements most of the remaining work of #320. It doesn't yet deliver most of the user-facing value: for that, the remaining piece will be to make notifications appear when the app is in the background as well as when it's in the foreground. I have a draft for that and it works, but the tests require some more fiddling which I may not get to today, so I wanted to get the bulk of this posted for review. Many other improvements still to be made are tracked as their own issues: most notably iOS support #321, and opening a conversation by tapping the notification #123. --- .../res/drawable-hdpi/zulip_notification.webp | Bin 0 -> 278 bytes .../res/drawable-mdpi/zulip_notification.webp | Bin 0 -> 186 bytes .../drawable-xhdpi/zulip_notification.webp | Bin 0 -> 354 bytes .../drawable-xxhdpi/zulip_notification.webp | Bin 0 -> 504 bytes .../drawable-xxxhdpi/zulip_notification.webp | Bin 0 -> 680 bytes lib/notifications.dart | 121 +++++++++- test/notifications_test.dart | 226 ++++++++++++++++++ 7 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/res/drawable-hdpi/zulip_notification.webp create mode 100644 android/app/src/main/res/drawable-mdpi/zulip_notification.webp create mode 100644 android/app/src/main/res/drawable-xhdpi/zulip_notification.webp create mode 100644 android/app/src/main/res/drawable-xxhdpi/zulip_notification.webp create mode 100644 android/app/src/main/res/drawable-xxxhdpi/zulip_notification.webp create mode 100644 test/notifications_test.dart diff --git a/android/app/src/main/res/drawable-hdpi/zulip_notification.webp b/android/app/src/main/res/drawable-hdpi/zulip_notification.webp new file mode 100644 index 0000000000000000000000000000000000000000..be2acb4729e9238d5a43ce3a153a204fe7ae5850 GIT binary patch literal 278 zcmV+x0qOoyNk&Ev0RRA3MM6+kP&iBi0RR9mBftm{mB5lD$yLMsFP+Hji+K3Hwj`32Z=i9;+kL zin%*7uB7*nTD|N+yFz@Mvxi@W$8YMNBU>qz-A255vMY>hTW8YjE8EtFB>O^{w#Imw zhkYQ^s$*nc^gju-nZFWT#({EXPf28|2%d3Jg}x_&GLZ*n9PYPfPf0}oGmr7*UzsF< z%{-=FYYy{ZM|{h+&)JvMxveYAK0Mx?bW^3=#O>GN|E~=QjvaSETrqS<`jylk-z%m) cK32$1b8kqi2#)#jpMb3&I$jgeemMa*7)6_e7XSbN literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-mdpi/zulip_notification.webp b/android/app/src/main/res/drawable-mdpi/zulip_notification.webp new file mode 100644 index 0000000000000000000000000000000000000000..d5c701ad9676418536ca0db989aaf2da5e515468 GIT binary patch literal 186 zcmV;r07d^&Nk&Gp00012MM6+kP&iDb0000l7r+G&l{ju2$&ohyr1@z~5AG7E^JZOy z+W-KQ28V<{ct9s^VB!eAV0VYQ-H=WkQ+Ido1mqg=>W==yFEy~@uWfE++%v4cJ@bmw z6I6iAwgCavxN==JQJF~eTZQm+3CINUVWv&hTCfn^8EMm9DTy8}?b8QJ-rscZtaW{5 o;#+fFm6-_Yuj6)dpc1WZc4g5ssEnrZdYlSWbsT%C0M&&905*J8HUIzs literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-xhdpi/zulip_notification.webp b/android/app/src/main/res/drawable-xhdpi/zulip_notification.webp new file mode 100644 index 0000000000000000000000000000000000000000..fe0ba6ffb89f60bb3777697b54ac0645152c75c4 GIT binary patch literal 354 zcmV-o0iFI*Nk&Fm0RRA3MM6+kP&iCZ0RR9mFTe{B5;&41DU$yGYCmY0&LeeTLAu)h z_aBaJZO5x3wv*CU3M66x$Aq)a+H8Gzl{xVN@#+aDvhZ5+`Ee46d$5Br}H3}MIq zfX#V4h0&+TT>^`7@jn4;zm{VcdW^RM;T|(g&>uS!oQ*k8a5Sc$pgqQsz%mA9bDcHt z$&Dv;yvhmQJ_{26;eJbebgW~ho@V-%33BY|S8S-=VngchlN{(CZU21x9&Nu&djOau za^GGU5(A?p-?v-3#Gsh@r|8-~L1J)hl|t86(Gx@Is=8LthD!{Cmny~P2#_UjiDC2& ze0DB8XH_ZeF(6`9(S}f{iZ-$d(4bGjv|g>3`z^60fHZ~g1Xzq?!r6+zcg61Hcfg|jnv#Nc z0XH|m(zxbQCfwgF&X3R{TE2nh@kPDH%miWwy=o$D#X6@qGLQ zrQ69^J>ZaZw8Z@ES;5y)D)-fo+_y1mf$7^A6@Z0HbUXT&-07&;-aNcJgB1MPc8`oh zW$l5$$>@wir~-T|JK1iXV5k!P`z3Kan!xA?a3wP*qYl_ip*77Mjml?~LS%|+cxIFz zjsB&D&Vknwa5DN49x4TAz#A34L%;*8P?>mquODK2a3n+J>I0a~_H-oro7rp+1C>78 zs0bHI-L8H~W+CXD+Zb`p&uxsPkG|rpd?(R$H%Ia8{!$$26@zdA2k*erIAK}8>X(0? z_~BhdF7O}WgF@}HHwpyuT|#3bp;sX{Lso@>jPD9N8TKk1+zb;L#wz6B%>NqG3`V63 u*YvIY&o*^m>-nbDwFvdMnqR?P4QE$tx#W1|TSBr)x2ydBN zHNKw-*rKPd-Qa|3Id6A%BG6ioNLYXo7rplNK?&}`$~sV5HFx+WsNxzL^PWq^rHdZ& zglXkP^{8vqDlc91h+||A6d2iqrifNxy66FIuu6>Vn|LJ^neKJtl2DnEeeNrdN=ecv z+oAV$<~hqsNy&wa_7DP`x=!mrDe1ch4Av;!>%=MYm6DBD08MA;yMMJtqEhnl9kjt3 zeP6%mCsZlL`3L%*cl;tpp@Kw>T~#k zGK=yToMY%`l8ft`@JbBR3T|WQCn9VZ3?eEpX?s10p&y8{i#>{{ybL`6qZs;mxOfkS zQ59E^Up=lmjx227N#N^)dhL=ifrKAI)dy_sk&tzSkzjM(utiU^y}j{88atqpCCo4h z{A0AjLPs>RfCe_hi}E}L9a73X( (_instance ??= NotificationService._()); @@ -38,6 +40,7 @@ class NotificationService { // TODO(#324) defer notif setup if user not logged into any accounts // (in order to avoid calling for permissions) + await NotificationDisplayManager._init(); ZulipBinding.instance.firebaseMessagingOnMessage.listen(_onRemoteMessage); // Get the FCM registration token, now and upon changes. See FCM API docs: @@ -71,9 +74,121 @@ class NotificationService { static void _onRemoteMessage(FirebaseRemoteMessage message) { assert(debugLog("notif message: ${message.data}")); final data = FcmMessage.fromJson(message.data); - if (data is MessageFcmMessage) { - assert(debugLog('notif message content: ${data.content}')); - // TODO(#122): show notification UI + switch (data) { + case MessageFcmMessage(): NotificationDisplayManager._onMessageFcmMessage(data, message.data); + case RemoveFcmMessage(): break; // TODO(#341) handle + case UnexpectedFcmMessage(): break; // TODO(log) } } } + +/// Service for configuring our Android "notification channel". +class NotificationChannelManager { + @visibleForTesting + static const kChannelId = 'messages-1'; + + /// The vibration pattern we set for notifications. + // We try to set a vibration pattern that, with the phone in one's pocket, + // is both distinctly present and distinctly different from the default. + // Discussion: https://chat.zulip.org/#narrow/stream/48-mobile/topic/notification.20vibration.20pattern/near/1284530 + @visibleForTesting + static final kVibrationPattern = Int64List.fromList([0, 125, 100, 450]); + + /// Create our notification channel, if it doesn't already exist. + // + // NOTE when changing anything here: the changes will not take effect + // for existing installs of the app! That's because we'll have already + // created the channel with the old settings, and they're in the user's + // hands from there. Our choices are: + // + // * Leave the old settings in place for existing installs, so the + // changes only apply to new installs. + // + // * Change `kChannelId`, so that we abandon the old channel and use + // a new one. Existing installs will get the new settings. + // + // This also means that if the user has changed any of the notification + // settings for the channel -- like "override Do Not Disturb", or "use + // a different sound", or "don't pop on screen" -- their changes get + // reset. So this has to be done sparingly. + // + // If we do this, we should also look for any channel with the old + // channel ID and delete it. See zulip-mobile's `createNotificationChannel` + // in android/app/src/main/java/com/zulipmobile/notifications/NotificationChannelManager.kt . + static Future _ensureChannel() async { + final plugin = ZulipBinding.instance.notifications; + await plugin.resolvePlatformSpecificImplementation() + ?.createNotificationChannel(AndroidNotificationChannel( + kChannelId, + 'Messages', // TODO(i18n) + importance: Importance.high, + enableLights: true, + vibrationPattern: kVibrationPattern, + // TODO(#340) sound + )); + } +} + +/// Service for managing the notifications shown to the user. +class NotificationDisplayManager { + // We rely on the tag instead. + @visibleForTesting + static const kNotificationId = 0; + + static Future _init() async { + await ZulipBinding.instance.notifications.initialize( + const InitializationSettings( + android: AndroidInitializationSettings('zulip_notification'), + ), + ); + await NotificationChannelManager._ensureChannel(); + } + + static void _onMessageFcmMessage(MessageFcmMessage data, Map dataJson) { + assert(debugLog('notif message content: ${data.content}')); + final title = switch (data.recipient) { + FcmMessageStreamRecipient(:var streamName?, :var topic) => + '$streamName > $topic', + FcmMessageStreamRecipient(:var topic) => + '(unknown stream) > $topic', // TODO get stream name from data + FcmMessageDmRecipient(:var allRecipientIds) when allRecipientIds.length > 2 => + '${data.senderFullName} to you and ${allRecipientIds.length - 2} others', // TODO(i18n), also plural; TODO use others' names, from data + FcmMessageDmRecipient() => + data.senderFullName, + }; + ZulipBinding.instance.notifications.show( + kNotificationId, + title, + data.content, + NotificationDetails(android: AndroidNotificationDetails( + NotificationChannelManager.kChannelId, + // This [FlutterLocalNotificationsPlugin.show] call can potentially create + // a new channel, if our channel doesn't already exist. That *shouldn't* + // happen; if it does, it won't get the right settings. Set the channel + // name in that case to something that has a chance of warning the user, + // and that can serve as a signature to diagnose the situation in support. + // But really we should fix flutter_local_notifications to not do that + // (see issue linked below), or replace that package entirely (#351). + '(Zulip internal error)', // TODO never implicitly create channel: https://github.com/MaikuB/flutter_local_notifications/issues/2135 + tag: _conversationKey(data), + color: kZulipBrandColor, + icon: 'zulip_notification', // TODO vary for debug + // TODO(#128) inbox-style + ))); + } + + static String _conversationKey(MessageFcmMessage data) { + final groupKey = _groupKey(data); + final conversation = switch (data.recipient) { + FcmMessageStreamRecipient(:var streamId, :var topic) => 'stream:$streamId:$topic', + FcmMessageDmRecipient(:var allRecipientIds) => 'dm:${allRecipientIds.join(',')}', + }; + return '$groupKey|$conversation'; + } + + static String _groupKey(FcmMessageWithIdentity data) { + // The realm URL can't contain a `|`, because `|` is not a URL code point: + // https://url.spec.whatwg.org/#url-code-points + return "${data.realmUri}|${data.userId}"; + } +} diff --git a/test/notifications_test.dart b/test/notifications_test.dart new file mode 100644 index 0000000000..40c37cd75e --- /dev/null +++ b/test/notifications_test.dart @@ -0,0 +1,226 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:checks/checks.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/notifications.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications.dart'; +import 'package:zulip/widgets/app.dart'; + +import 'model/binding.dart'; +import 'example_data.dart' as eg; + +FakeAndroidFlutterLocalNotificationsPlugin get notifAndroid => + testBinding.notifications + .resolvePlatformSpecificImplementation() + as FakeAndroidFlutterLocalNotificationsPlugin; + +MessageFcmMessage messageFcmMessage( + Message zulipMessage, { + String? streamName, + Account? account, +}) { + account ??= eg.selfAccount; + final narrow = SendableNarrow.ofMessage(zulipMessage, selfUserId: account.userId); + return FcmMessage.fromJson({ + "event": "message", + + "server": "zulip.example.cloud", + "realm_id": "4", + "realm_uri": account.realmUrl.toString(), + "user_id": account.userId.toString(), + + "zulip_message_id": zulipMessage.id.toString(), + "time": zulipMessage.timestamp.toString(), + "content": zulipMessage.content, + + "sender_id": zulipMessage.senderId.toString(), + "sender_avatar_url": "${account.realmUrl}avatar/${zulipMessage.senderId}.jpeg", + "sender_full_name": zulipMessage.senderFullName.toString(), + + ...(switch (narrow) { + TopicNarrow(:var streamId, :var topic) => { + "recipient_type": "stream", + "stream_id": streamId.toString(), + if (streamName != null) "stream": streamName, + "topic": topic, + }, + DmNarrow(allRecipientIds: [_, _, _, ...]) => { + "recipient_type": "private", + "pm_users": narrow.allRecipientIds.join(","), + }, + DmNarrow() => { + "recipient_type": "private", + }, + }), + }) as MessageFcmMessage; +} + +void main() { + TestZulipBinding.ensureInitialized(); + + Future init() async { + addTearDown(testBinding.reset); + testBinding.firebaseMessagingInitialToken = '012abc'; + addTearDown(NotificationService.debugReset); + await NotificationService.instance.start(); + } + + group('NotificationChannelManager', () { + test('smoke', () async { + await init(); + check(notifAndroid.takeCreatedChannels()).single + ..id.equals(NotificationChannelManager.kChannelId) + ..name.equals('Messages') + ..description.isNull() + ..groupId.isNull() + ..importance.equals(Importance.high) + ..playSound.isTrue() + ..sound.isNull() + ..enableVibration.isTrue() + ..vibrationPattern.isNotNull().deepEquals( + NotificationChannelManager.kVibrationPattern) + ..showBadge.isTrue() + ..enableLights.isTrue() + ..ledColor.isNull() + ; + }); + }); + + group('NotificationDisplayManager', () { + Future checkNotification(MessageFcmMessage data, { + required String expectedTitle, + required String expectedTagComponent, + }) async { + testBinding.firebaseMessaging.onMessage.add( + RemoteMessage(data: data.toJson())); + await null; + check(testBinding.notifications.takeShowCalls()).single + ..id.equals(NotificationDisplayManager.kNotificationId) + ..title.equals(expectedTitle) + ..body.equals(data.content) + ..notificationDetails.isNotNull().android.isNotNull().which(it() + ..channelId.equals(NotificationChannelManager.kChannelId) + ..tag.equals('${data.realmUri}|${data.userId}|$expectedTagComponent') + ..color.equals(kZulipBrandColor) + ..icon.equals('zulip_notification') + ); + } + + test('stream message', () async { + await init(); + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream); + await checkNotification(messageFcmMessage(message, streamName: stream.name), + expectedTitle: '${stream.name} > ${message.subject}', + expectedTagComponent: 'stream:${message.streamId}:${message.subject}'); + }); + + test('stream message, stream name omitted', () async { + await init(); + final stream = eg.stream(); + final message = eg.streamMessage(stream: stream); + await checkNotification(messageFcmMessage(message, streamName: null), + expectedTitle: '(unknown stream) > ${message.subject}', + expectedTagComponent: 'stream:${message.streamId}:${message.subject}'); + }); + + test('group DM', () async { + await init(); + final message = eg.dmMessage(from: eg.thirdUser, to: [eg.otherUser, eg.selfUser]); + await checkNotification(messageFcmMessage(message), + expectedTitle: "${eg.thirdUser.fullName} to you and 1 others", + expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); + }); + + test('1:1 DM', () async { + await init(); + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + await checkNotification(messageFcmMessage(message), + expectedTitle: eg.otherUser.fullName, + expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); + }); + + test('self-DM', () async { + await init(); + final message = eg.dmMessage(from: eg.selfUser, to: []); + await checkNotification(messageFcmMessage(message), + expectedTitle: eg.selfUser.fullName, + expectedTagComponent: 'dm:${message.allRecipientIds.join(",")}'); + }); + }); +} + +extension AndroidNotificationChannelChecks on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get name => has((x) => x.name, 'name'); + Subject get description => has((x) => x.description, 'description'); + Subject get groupId => has((x) => x.groupId, 'groupId'); + Subject get importance => has((x) => x.importance, 'importance'); + Subject get playSound => has((x) => x.playSound, 'playSound'); + Subject get sound => has((x) => x.sound, 'sound'); + Subject get enableVibration => has((x) => x.enableVibration, 'enableVibration'); + Subject get enableLights => has((x) => x.enableLights, 'enableLights'); + Subject get vibrationPattern => has((x) => x.vibrationPattern, 'vibrationPattern'); + Subject get ledColor => has((x) => x.ledColor, 'ledColor'); + Subject get showBadge => has((x) => x.showBadge, 'showBadge'); +} + +extension ShowCallChecks on Subject { + Subject get id => has((x) => x.$1, 'id'); + Subject get title => has((x) => x.$2, 'title'); + Subject get body => has((x) => x.$3, 'body'); + Subject get notificationDetails => has((x) => x.$4, 'notificationDetails'); + Subject get payload => has((x) => x.payload, 'payload'); +} + +extension NotificationDetailsChecks on Subject { + Subject get android => has((x) => x.android, 'android'); + Subject get iOS => has((x) => x.iOS, 'iOS'); + Subject get macOS => has((x) => x.macOS, 'macOS'); + Subject get linux => has((x) => x.linux, 'linux'); +} + +extension AndroidNotificationDetailsChecks on Subject { + // The upstream [AndroidNotificationDetails] has many more properties + // which only apply to creating a channel, or to notifications before + // channels were introduced in Android 8. We ignore those here. + Subject get icon => has((x) => x.icon, 'icon'); + Subject get channelId => has((x) => x.channelId, 'channelId'); + Subject get styleInformation => has((x) => x.styleInformation, 'styleInformation'); + Subject get groupKey => has((x) => x.groupKey, 'groupKey'); + Subject get setAsGroupSummary => has((x) => x.setAsGroupSummary, 'setAsGroupSummary'); + Subject get groupAlertBehavior => has((x) => x.groupAlertBehavior, 'groupAlertBehavior'); + Subject get autoCancel => has((x) => x.autoCancel, 'autoCancel'); + Subject get ongoing => has((x) => x.ongoing, 'ongoing'); + Subject get color => has((x) => x.color, 'color'); + Subject?> get largeIcon => has((x) => x.largeIcon, 'largeIcon'); + Subject get onlyAlertOnce => has((x) => x.onlyAlertOnce, 'onlyAlertOnce'); + Subject get showWhen => has((x) => x.showWhen, 'showWhen'); + Subject get when => has((x) => x.when, 'when'); + Subject get usesChronometer => has((x) => x.usesChronometer, 'usesChronometer'); + Subject get chronometerCountDown => has((x) => x.chronometerCountDown, 'chronometerCountDown'); + Subject get showProgress => has((x) => x.showProgress, 'showProgress'); + Subject get maxProgress => has((x) => x.maxProgress, 'maxProgress'); + Subject get progress => has((x) => x.progress, 'progress'); + Subject get indeterminate => has((x) => x.indeterminate, 'indeterminate'); + Subject get ticker => has((x) => x.ticker, 'ticker'); + Subject get channelAction => has((x) => x.channelAction, 'channelAction'); + Subject get visibility => has((x) => x.visibility, 'visibility'); + Subject get timeoutAfter => has((x) => x.timeoutAfter, 'timeoutAfter'); + Subject get category => has((x) => x.category, 'category'); + Subject get fullScreenIntent => has((x) => x.fullScreenIntent, 'fullScreenIntent'); + Subject get shortcutId => has((x) => x.shortcutId, 'shortcutId'); + Subject get additionalFlags => has((x) => x.additionalFlags, 'additionalFlags'); + Subject?> get actions => has((x) => x.actions, 'actions'); + Subject get subText => has((x) => x.subText, 'subText'); + Subject get tag => has((x) => x.tag, 'tag'); + Subject get colorized => has((x) => x.colorized, 'colorized'); + Subject get number => has((x) => x.number, 'number'); + Subject get audioAttributesUsage => has((x) => x.audioAttributesUsage, 'audioAttributesUsage'); +}