From d17939e0bf7fbc2feec89de200ba0feed7a1931a Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 16 Jun 2023 23:02:35 -0700 Subject: [PATCH 1/3] deps: Add flutter_local_notifications and its platform interface We'll use the latter for writing our binding class. --- ios/Podfile.lock | 6 ++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 6 ++ pubspec.lock | 56 +++++++++++++++++++ pubspec.yaml | 2 + 5 files changed, 72 insertions(+) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8ae1beee7e..1c4933ca61 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -70,6 +70,8 @@ PODS: - GoogleUtilities/UserDefaults (~> 7.8) - nanopb (< 2.30910.0, >= 2.30908.0) - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter - GoogleDataTransport (9.2.5): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30910.0, >= 2.30908.0) @@ -135,6 +137,7 @@ DEPENDENCIES: - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -172,6 +175,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_messaging/ios" Flutter: :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" package_info_plus: @@ -199,6 +204,7 @@ SPEC CHECKSUMS: FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee FirebaseMessaging: 80b4a086d20ed4fd385a702f4bfa920e14f5064d Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8917e6721b..d9af90d6c9 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import device_info_plus import file_selector_macos import firebase_core import firebase_messaging +import flutter_local_notifications import package_info_plus import path_provider_foundation import share_plus @@ -20,6 +21,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index f01dcfa96c..653a53b977 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -36,6 +36,8 @@ PODS: - GoogleUtilities/Reachability (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) - nanopb (< 2.30910.0, >= 2.30908.0) + - flutter_local_notifications (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - GoogleDataTransport (9.2.5): - GoogleUtilities/Environment (~> 7.7) @@ -94,6 +96,7 @@ DEPENDENCIES: - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) + - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) @@ -123,6 +126,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos firebase_messaging: :path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos + flutter_local_notifications: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos FlutterMacOS: :path: Flutter/ephemeral package_info_plus: @@ -146,6 +151,7 @@ SPEC CHECKSUMS: FirebaseCoreInternal: 26233f705cc4531236818a07ac84d20c333e505a FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee FirebaseMessaging: 80b4a086d20ed4fd385a702f4bfa920e14f5064d + flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 diff --git a/pubspec.lock b/pubspec.lock index 26120ba792..ca6bec8ce1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -249,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + url: "https://pub.dev" + source: hosted + version: "0.7.8" device_info_plus: dependency: "direct main" description: @@ -414,6 +422,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "6d11ea777496061e583623aaf31923f93a9409ef8fcaeeefdd6cd78bf4fe5bb3" + url: "https://pub.dev" + source: hosted + version: "16.1.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "33f741ef47b5f63cc7f78fe75eeeac7e19f171ff3c3df054d84c1e38bedb6a03" + url: "https://pub.dev" + source: hosted + version: "4.0.0+1" + flutter_local_notifications_platform_interface: + dependency: "direct main" + description: + name: flutter_local_notifications_platform_interface + sha256: "7cf643d6d5022f3baed0be777b0662cce5919c0a7b86e700299f22dc4ae660ef" + url: "https://pub.dev" + source: hosted + version: "7.0.0+1" flutter_localizations: dependency: "direct main" description: flutter @@ -757,6 +789,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" platform: dependency: transitive description: @@ -994,6 +1034,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.9" + timezone: + dependency: transitive + description: + name: timezone + sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" + url: "https://pub.dev" + source: hosted + version: "0.9.2" timing: dependency: transitive description: @@ -1154,6 +1202,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + xml: + dependency: transitive + description: + name: xml + sha256: "5bc72e1e45e941d825fd7468b9b4cc3b9327942649aeb6fc5cdbf135f0a86e84" + url: "https://pub.dev" + source: hosted + version: "6.3.0" yaml: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 04a9553008..cd8c14a1ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,8 @@ dependencies: sdk: flutter firebase_messaging: ^14.6.3 firebase_core: ^2.14.0 + flutter_local_notifications_platform_interface: ^7.0.0+1 + flutter_local_notifications: ^16.1.0 dev_dependencies: flutter_test: From 24b3ad025a5b9ed6a7e4c697b39f612eebdf0be2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 12 Oct 2023 16:31:10 -0700 Subject: [PATCH 2/3] binding: Add flutter_local_notifications bindings --- lib/model/binding.dart | 7 +++ test/model/binding.dart | 105 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 7da72e348f..5c26beb12f 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -2,6 +2,7 @@ import 'package:device_info_plus/device_info_plus.dart' as device_info_plus; import 'package:firebase_core/firebase_core.dart' as firebase_core; import 'package:firebase_messaging/firebase_messaging.dart' as firebase_messaging; import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import '../firebase_options.dart'; @@ -98,6 +99,9 @@ abstract class ZulipBinding { /// Wraps [firebase_messaging.FirebaseMessaging.onMessage]. Stream get firebaseMessagingOnMessage; + + /// Wraps the [FlutterLocalNotificationsPlugin] singleton constructor. + FlutterLocalNotificationsPlugin get notifications; } /// Like [device_info_plus.BaseDeviceInfo], but without things we don't use. @@ -194,4 +198,7 @@ class LiveZulipBinding extends ZulipBinding { Stream get firebaseMessagingOnMessage { return firebase_messaging.FirebaseMessaging.onMessage; } + + @override + FlutterLocalNotificationsPlugin get notifications => FlutterLocalNotificationsPlugin(); } diff --git a/test/model/binding.dart b/test/model/binding.dart index 5939808652..c83b7535c2 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_local_notifications_platform_interface/flutter_local_notifications_platform_interface.dart'; import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:zulip/model/binding.dart'; @@ -63,6 +65,7 @@ class TestZulipBinding extends ZulipBinding { _resetLaunchUrl(); _resetDeviceInfo(); _resetFirebase(); + _resetNotifications(); } /// The current global store offered to a [GlobalStoreWidget]. @@ -188,6 +191,17 @@ class TestZulipBinding extends ZulipBinding { @override Stream get firebaseMessagingOnMessage => firebaseMessaging.onMessage.stream; + + void _resetNotifications() { + _notificationsPlugin = null; + } + + FakeFlutterLocalNotificationsPlugin? _notificationsPlugin; + + @override + FakeFlutterLocalNotificationsPlugin get notifications { + return (_notificationsPlugin ??= FakeFlutterLocalNotificationsPlugin()); + } } class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { @@ -228,3 +242,94 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { StreamController onMessage = StreamController.broadcast(); } + +class FakeFlutterLocalNotificationsPlugin extends Fake implements FlutterLocalNotificationsPlugin { + InitializationSettings? initializationSettings; + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse; + DidReceiveBackgroundNotificationResponseCallback? onDidReceiveBackgroundNotificationResponse; + + @override + Future initialize( + InitializationSettings initializationSettings, { + DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse, + DidReceiveBackgroundNotificationResponseCallback? onDidReceiveBackgroundNotificationResponse, + }) async { + assert(this.initializationSettings == null); + this.initializationSettings = initializationSettings; + this.onDidReceiveNotificationResponse = onDidReceiveNotificationResponse; + this.onDidReceiveBackgroundNotificationResponse = onDidReceiveBackgroundNotificationResponse; + return true; + } + + FlutterLocalNotificationsPlatform? _platform; + + @override + T? resolvePlatformSpecificImplementation() { + // This follows the logic of the base class's implementation, + // but supplies our fakes for the per-platform classes. + assert(initializationSettings != null); + assert(T != FlutterLocalNotificationsPlatform); + if (kIsWeb) return null; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + assert(_platform == null || _platform is FakeAndroidFlutterLocalNotificationsPlugin); + if (T != AndroidFlutterLocalNotificationsPlugin) return null; + return (_platform ??= FakeAndroidFlutterLocalNotificationsPlugin()) as T?; + + case TargetPlatform.iOS: + assert(_platform == null || _platform is FakeIOSFlutterLocalNotificationsPlugin); + if (T != IOSFlutterLocalNotificationsPlugin) return null; + return (_platform ??= FakeIOSFlutterLocalNotificationsPlugin()) as T?; + + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + return null; + } + } + + /// Consume the log of calls made to [show]. + /// + /// This returns a list of the arguments to all calls made + /// to [show] since the last call to this method. + List takeShowCalls() { + final result = _showCalls; + _showCalls = []; + return result; + } + List _showCalls = []; + + @override + Future show(int id, String? title, String? body, + NotificationDetails? notificationDetails, {String? payload}) async { + assert(initializationSettings != null); + _showCalls.add((id, title, body, notificationDetails, payload: payload)); + } +} + +typedef FlutterLocalNotificationsPluginShowCall = ( + int id, String? title, String? body, + NotificationDetails? notificationDetails, {String? payload} +); + +class FakeAndroidFlutterLocalNotificationsPlugin extends Fake implements AndroidFlutterLocalNotificationsPlugin { + /// Consume the log of calls made to [createNotificationChannel]. + /// + /// This returns a list of the arguments to all calls made + /// to [createNotificationChannel] since the last call to this method. + List takeCreatedChannels() { + final result = _createdChannels; + _createdChannels = []; + return result; + } + List _createdChannels = []; + + @override + Future createNotificationChannel(AndroidNotificationChannel notificationChannel) async { + _createdChannels.add(notificationChannel); + } +} + +class FakeIOSFlutterLocalNotificationsPlugin extends Fake implements IOSFlutterLocalNotificationsPlugin { +} From b6badf550a713aa7a29d31c64a9899496caf583e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 27 Oct 2023 10:23:36 -0700 Subject: [PATCH 3/3] 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'); +}