diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e473dc839e..9eabbee053 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -37,14 +37,73 @@ PODS: - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter + - Firebase/CoreOnly (10.15.0): + - FirebaseCore (= 10.15.0) + - Firebase/Messaging (10.15.0): + - Firebase/CoreOnly + - FirebaseMessaging (~> 10.15.0) + - firebase_core (2.17.0): + - Firebase/CoreOnly (= 10.15.0) + - Flutter + - firebase_messaging (14.6.9): + - Firebase/Messaging (= 10.15.0) + - firebase_core + - Flutter + - FirebaseCore (10.15.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Logger (~> 7.8) + - FirebaseCoreInternal (10.16.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseInstallations (10.16.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - PromisesObjC (~> 2.1) + - FirebaseMessaging (10.15.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Reachability (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - nanopb (< 2.30910.0, >= 2.30908.0) - Flutter (1.0.0) + - GoogleDataTransport (9.2.5): + - GoogleUtilities/Environment (~> 7.7) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/AppDelegateSwizzler (7.11.5): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (7.11.5): + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.11.5): + - GoogleUtilities/Environment + - GoogleUtilities/Network (7.11.5): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.11.5)" + - GoogleUtilities/Reachability (7.11.5): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (7.11.5): + - GoogleUtilities/Logger - image_picker_ios (0.0.1): - Flutter + - nanopb (2.30909.0): + - nanopb/decode (= 2.30909.0) + - nanopb/encode (= 2.30909.0) + - nanopb/decode (2.30909.0) + - nanopb/encode (2.30909.0) - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - PromisesObjC (2.3.1) - SDWebImage (5.15.5): - SDWebImage/Core (= 5.15.5) - SDWebImage/Core (5.15.5) @@ -73,6 +132,8 @@ DEPENDENCIES: - app_settings (from `.symlinks/plugins/app_settings/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -85,6 +146,15 @@ SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery + - Firebase + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - FirebaseMessaging + - GoogleDataTransport + - GoogleUtilities + - nanopb + - PromisesObjC - SDWebImage - sqlite3 - SwiftyGif @@ -96,6 +166,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_messaging: + :path: ".symlinks/plugins/firebase_messaging/ios" Flutter: :path: Flutter image_picker_ios: @@ -117,10 +191,21 @@ SPEC CHECKSUMS: DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de + Firebase: 66043bd4579e5b73811f96829c694c7af8d67435 + firebase_core: 28e84c2a4fcf6a50ef83f47b145ded8c1fa331e4 + firebase_messaging: 91ec967913a5d144f951b3c3ac17a57796d38735 + FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e + FirebaseCoreInternal: 26233f705cc4531236818a07ac84d20c333e505a + FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee + FirebaseMessaging: 0c0ae1eb722ef0c07f7801e5ded8dccd1357d6d4 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 + GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 + nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 SDWebImage: fd7e1a22f00303e058058278639bf6196ee431fe share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 sqlite3: e0a0623a33a20a47cb5921552aebc6e9e437dc91 diff --git a/lib/api/route/notifications.dart b/lib/api/route/notifications.dart new file mode 100644 index 0000000000..754de97178 --- /dev/null +++ b/lib/api/route/notifications.dart @@ -0,0 +1,30 @@ + +import '../core.dart'; + +// This endpoint is undocumented. Compare zulip-mobile: +// https://github.com/zulip/zulip-mobile/blob/86d94fa89/src/api/notifications/savePushToken.js +// and see the server implementation: +// https://github.com/zulip/zulip/blob/34ceafadd/zproject/urls.py#L383 +// https://github.com/zulip/zulip/blob/34ceafadd/zerver/views/push_notifications.py#L47 +Future registerFcmToken(ApiConnection connection, { + required String token, +}) { + return connection.post('registerFcmToken', (_) {}, 'users/me/android_gcm_reg_id', { + 'token': RawParameter(token), + }); +} + +// This endpoint is undocumented. Compare zulip-mobile: +// https://github.com/zulip/zulip-mobile/blob/86d94fa89/src/api/notifications/savePushToken.js +// and see the server implementation: +// https://github.com/zulip/zulip/blob/34ceafadd/zproject/urls.py#L378-L381 +// https://github.com/zulip/zulip/blob/34ceafadd/zerver/views/push_notifications.py#L34 +Future registerApnsToken(ApiConnection connection, { + required String token, + String? appid, +}) { + return connection.post('registerApnsToken', (_) {}, 'users/me/apns_device_token', { + 'token': RawParameter(token), + if (appid != null) 'appid': RawParameter(appid), + }); +} diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 0000000000..64a4d29baf --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,36 @@ +import 'package:firebase_core/firebase_core.dart'; + +/// Configuration used for receiving notifications on Android. +/// +/// This set of options is used for receiving notifications +/// through the Zulip notification bouncer service: +/// https://zulip.readthedocs.io/en/latest/production/mobile-push-notifications.html +/// +/// These values represent public identifiers for that service +/// as an application registered with the relevant Google service: +/// we deliver Android notifications through Firebase Cloud Messaging (FCM). +/// The values are derived from a `google-services.json` file. +/// For details, see: +/// https://developers.google.com/android/guides/google-services-plugin#processing_the_json_file +const kFirebaseOptionsAndroid = FirebaseOptions( + // This `appId` and `messagingSenderId` are the same as in zulip-mobile; + // see zulip-mobile:android/app/src/main/res/values/firebase.xml . + appId: '1:835904834568:android:6ae61ae43a7c3410', + messagingSenderId: '835904834568', + + projectId: 'zulip-android', + + // Despite the name, this Google Cloud "API key" is a very different kind + // of thing from a Zulip "API key". In particular, it's designed to be + // included in published builds of client applications, and therefore + // fundamentally public. See docs: + // https://cloud.google.com/docs/authentication/api-keys + // + // This key was created fresh for this use in zulip-flutter. + // It's easy to create additional keys associated with the same `appId` + // and other details above, and to enable or disable individual keys. + // + // TODO: Perhaps use a different key in published builds; still fundamentally + // public, but would avoid accidental reuse in dev or modified builds. + apiKey: 'AIzaSyC6kw5sqCYjxQl2Lbd_8MDmc1lu2EG0pY4', +); diff --git a/lib/main.dart b/lib/main.dart index 7c961fc7cd..b4bfbaf202 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'licenses.dart'; import 'log.dart'; import 'model/binding.dart'; +import 'notifications.dart'; import 'widgets/app.dart'; void main() { @@ -13,5 +14,7 @@ void main() { }()); LicenseRegistry.addLicense(additionalLicenses); LiveZulipBinding.ensureInitialized(); + WidgetsFlutterBinding.ensureInitialized(); + NotificationService.instance.start(); runApp(const ZulipApp()); } diff --git a/lib/model/binding.dart b/lib/model/binding.dart index adf19f9e90..7da72e348f 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -1,13 +1,19 @@ 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:url_launcher/url_launcher.dart' as url_launcher; +import '../firebase_options.dart'; import '../widgets/store.dart'; import 'store.dart'; /// Alias for [url_launcher.LaunchMode]. typedef UrlLaunchMode = url_launcher.LaunchMode; +/// Alias for [firebase_messaging.RemoteMessage]. +typedef FirebaseRemoteMessage = firebase_messaging.RemoteMessage; + /// A singleton service providing the app's data and use of Flutter plugins. /// /// Only one instance will be constructed in the lifetime of the app, @@ -81,6 +87,17 @@ abstract class ZulipBinding { /// /// This wraps [device_info_plus.DeviceInfoPlugin.deviceInfo]. Future deviceInfo(); + + /// Initialize Firebase, to use for notifications. + /// + /// This wraps [firebase_core.Firebase.initializeApp]. + Future firebaseInitializeApp(); + + /// Wraps [firebase_messaging.FirebaseMessaging.instance]. + firebase_messaging.FirebaseMessaging get firebaseMessaging; + + /// Wraps [firebase_messaging.FirebaseMessaging.onMessage]. + Stream get firebaseMessagingOnMessage; } /// Like [device_info_plus.BaseDeviceInfo], but without things we don't use. @@ -148,4 +165,33 @@ class LiveZulipBinding extends ZulipBinding { _ => throw UnimplementedError(), }; } + + @override + Future firebaseInitializeApp() { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return firebase_core.Firebase.initializeApp(options: kFirebaseOptionsAndroid); + + case TargetPlatform.iOS: + // TODO(#321): Set up Firebase on iOS. (Or do something else instead.) + return Future.value(); + + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + // Do nothing; we don't offer notifications on these platforms. + return Future.value(); + } + } + + @override + firebase_messaging.FirebaseMessaging get firebaseMessaging { + return firebase_messaging.FirebaseMessaging.instance; + } + + @override + Stream get firebaseMessagingOnMessage { + return firebase_messaging.FirebaseMessaging.onMessage; + } } diff --git a/lib/model/store.dart b/lib/model/store.dart index 28898fc8fd..655fa64310 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -12,7 +12,9 @@ import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import '../api/route/events.dart'; import '../api/route/messages.dart'; +import '../api/route/notifications.dart'; import '../log.dart'; +import '../notifications.dart'; import 'autocomplete.dart'; import 'database.dart'; import 'message_list.dart'; @@ -425,6 +427,8 @@ class LiveGlobalStore extends GlobalStore { } /// A [PerAccountStore] which polls an event queue to stay up to date. +// TODO decouple "live"ness from polling and registerNotificationToken; +// the latter are made up of testable internal logic, not external integration class LivePerAccountStore extends PerAccountStore { LivePerAccountStore.fromInitialSnapshot({ required super.account, @@ -458,6 +462,9 @@ class LivePerAccountStore extends PerAccountStore { initialSnapshot: initialSnapshot, ); store.poll(); + // TODO do registerNotificationToken before registerQueue: + // https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807 + store.registerNotificationToken(); return store; } @@ -479,4 +486,25 @@ class LivePerAccountStore extends PerAccountStore { } } } + + /// Send this client's notification token to the server, now and if it changes. + /// + /// TODO The returned future isn't especially meaningful (it may or may not + /// mean we actually sent the token). Make it just `void` once we fix the + /// one test that relies on the future. + /// + /// TODO(#321) handle iOS/APNs; currently only Android/FCM + // TODO(#322) save acked token, to dedupe updating it on the server + // TODO(#323) track the registerFcmToken/etc request, warn if not succeeding + Future registerNotificationToken() async { + // TODO call removeListener on [dispose] + NotificationService.instance.token.addListener(_registerNotificationToken); + await _registerNotificationToken(); + } + + Future _registerNotificationToken() async { + final token = NotificationService.instance.token.value; + if (token == null) return; + await registerFcmToken(connection, token: token); + } } diff --git a/lib/notifications.dart b/lib/notifications.dart new file mode 100644 index 0000000000..2e891e2f5e --- /dev/null +++ b/lib/notifications.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart'; + +import 'log.dart'; +import 'model/binding.dart'; + +class NotificationService { + static NotificationService get instance => (_instance ??= NotificationService._()); + static NotificationService? _instance; + + NotificationService._(); + + /// Reset the state of the [NotificationService], for testing. + /// + /// TODO refactor this better, perhaps unify with ZulipBinding + @visibleForTesting + static void debugReset() { + instance.token.dispose(); + instance.token = ValueNotifier(null); + } + + /// The FCM registration token for this install of the app. + /// + /// This is unique to the (app, device) pair, but not permanent. + /// Most often it's the same from one run of the app to the next, + /// but it can change either during a run or between them. + /// + /// See also: + /// * Upstream docs on FCM registration tokens in general: + /// https://firebase.google.com/docs/cloud-messaging/manage-tokens + ValueNotifier token = ValueNotifier(null); + + Future start() async { + if (defaultTargetPlatform != TargetPlatform.android) return; // TODO(#321) + + await ZulipBinding.instance.firebaseInitializeApp(); + + // TODO(#324) defer notif setup if user not logged into any accounts + // (in order to avoid calling for permissions) + + ZulipBinding.instance.firebaseMessagingOnMessage.listen(_onRemoteMessage); + + // Get the FCM registration token, now and upon changes. See FCM API docs: + // https://firebase.google.com/docs/cloud-messaging/android/client#sample-register + ZulipBinding.instance.firebaseMessaging.onTokenRefresh.listen(_onTokenRefresh); + await _getToken(); + } + + Future _getToken() async { + final value = await ZulipBinding.instance.firebaseMessaging.getToken(); + // TODO(#323) warn user if getToken returns null, or doesn't timely return + assert(debugLog("notif token: $value")); + // The call to `getToken` won't cause `onTokenRefresh` to fire if we + // already have a token from a previous run of the app. + // So we need to use the `getToken` return value. + token.value = value; + } + + void _onTokenRefresh(String value) { + assert(debugLog("new notif token: $value")); + // On first launch after install, our [FirebaseMessaging.getToken] call + // causes this to fire, followed by completing its own future so that + // `_getToken` sees the value as well. So in that case this is redundant. + // + // Subsequently, though, this can also potentially fire on its own, if for + // some reason the FCM system decides to replace the token. So both paths + // need to save the value. + token.value = value; + } + + static void _onRemoteMessage(FirebaseRemoteMessage message) { + assert(debugLog("notif message: ${message.data}")); + // TODO(#122): parse data; show notification UI + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3f56ae83b4..8917e6721b 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,8 @@ import Foundation import device_info_plus import file_selector_macos +import firebase_core +import firebase_messaging import package_info_plus import path_provider_foundation import share_plus @@ -16,6 +18,8 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) 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 b5fc07ba1a..61a84d1f15 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -3,12 +3,72 @@ PODS: - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS + - Firebase/CoreOnly (10.15.0): + - FirebaseCore (= 10.15.0) + - Firebase/Messaging (10.15.0): + - Firebase/CoreOnly + - FirebaseMessaging (~> 10.15.0) + - firebase_core (2.17.0): + - Firebase/CoreOnly (~> 10.15.0) + - FlutterMacOS + - firebase_messaging (14.6.9): + - Firebase/CoreOnly (~> 10.15.0) + - Firebase/Messaging (~> 10.15.0) + - firebase_core + - FlutterMacOS + - FirebaseCore (10.15.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Logger (~> 7.8) + - FirebaseCoreInternal (10.16.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseInstallations (10.16.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - PromisesObjC (~> 2.1) + - FirebaseMessaging (10.15.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleDataTransport (~> 9.2) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Reachability (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - nanopb (< 2.30910.0, >= 2.30908.0) - FlutterMacOS (1.0.0) + - GoogleDataTransport (9.2.5): + - GoogleUtilities/Environment (~> 7.7) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/AppDelegateSwizzler (7.11.5): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (7.11.5): + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.11.5): + - GoogleUtilities/Environment + - GoogleUtilities/Network (7.11.5): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.11.5)" + - GoogleUtilities/Reachability (7.11.5): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (7.11.5): + - GoogleUtilities/Logger + - nanopb (2.30909.0): + - nanopb/decode (= 2.30909.0) + - nanopb/encode (= 2.30909.0) + - nanopb/decode (2.30909.0) + - nanopb/encode (2.30909.0) - package_info_plus (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - PromisesObjC (2.3.1) - share_plus (0.0.1): - FlutterMacOS - sqlite3 (3.43.1): @@ -32,6 +92,8 @@ PODS: DEPENDENCIES: - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - 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`) - 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`) @@ -41,6 +103,15 @@ DEPENDENCIES: SPEC REPOS: trunk: + - Firebase + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - FirebaseMessaging + - GoogleDataTransport + - GoogleUtilities + - nanopb + - PromisesObjC - sqlite3 EXTERNAL SOURCES: @@ -48,6 +119,10 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + firebase_core: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos + firebase_messaging: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos FlutterMacOS: :path: Flutter/ephemeral package_info_plus: @@ -64,9 +139,20 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + Firebase: 66043bd4579e5b73811f96829c694c7af8d67435 + firebase_core: 4d1711af1e10f9907f468e04f995c59f60bdc710 + firebase_messaging: 801a6ebd7785263051db042f1d75f4151cecbad0 + FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e + FirebaseCoreInternal: 26233f705cc4531236818a07ac84d20c333e505a + FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee + FirebaseMessaging: 0c0ae1eb722ef0c07f7801e5ded8dccd1357d6d4 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 + GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 + nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 sqlite3: e0a0623a33a20a47cb5921552aebc6e9e437dc91 sqlite3_flutter_libs: 9939d86d0f5a3f8f0e91feb4f333e01c9bb4cd89 diff --git a/pubspec.lock b/pubspec.lock index c7bed69ed3..d227e03ec9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "65.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: d84d98f1992976775f83083523a34c5d22fea191eec3abb2bd09537fb623c2e0 + url: "https://pub.dev" + source: hosted + version: "1.3.7" analyzer: dependency: transitive description: @@ -337,6 +345,54 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+1" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "95580fa07c8ca3072a2bb1fecd792616a33f8683477d25b7d29d3a6a399e6ece" + url: "https://pub.dev" + source: hosted + version: "2.17.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: b63e3be6c96ef5c33bdec1aab23c91eb00696f6452f0519401d640938c94cba2 + url: "https://pub.dev" + source: hosted + version: "4.8.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: e8c408923cd3a25bd342c576a114f2126769cd1a57106a4edeaa67ea4a84e962 + url: "https://pub.dev" + source: hosted + version: "2.8.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "67f9d7c87457e71ad78ee81e332f232b8a24f7d5e338f8c958fa7d6e9e0e3636" + url: "https://pub.dev" + source: hosted + version: "14.6.9" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "8c7ced3201886ad7ba37f344c1468ccfc08abb3023922e0e5a016eaf38abb96c" + url: "https://pub.dev" + source: hosted + version: "4.5.8" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: b601322bdb44e2fefe4cc7b85ef0dbb7a2479d4a7653a51340821cf8d60696b5 + url: "https://pub.dev" + source: hosted + version: "3.5.8" fixnum: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d357326ce6..f2960b1c71 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,8 @@ dependencies: url_launcher: ^6.1.11 flutter_localizations: sdk: flutter + firebase_messaging: ^14.6.3 + firebase_core: ^2.14.0 dev_dependencies: flutter_test: diff --git a/test/api/route/notifications_test.dart b/test/api/route/notifications_test.dart new file mode 100644 index 0000000000..7601bd5a9d --- /dev/null +++ b/test/api/route/notifications_test.dart @@ -0,0 +1,59 @@ +import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; +import 'package:test/scaffolding.dart'; +import 'package:zulip/api/route/notifications.dart'; + +import '../../stdlib_checks.dart'; +import '../fake_api.dart'; + +void main() { + group('registerFcmToken', () { + Future checkRegisterFcmToken(FakeApiConnection connection, { + required String token, + }) async { + connection.prepare(json: {}); + await registerFcmToken(connection, token: token); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/android_gcm_reg_id') + ..bodyFields.deepEquals({ + 'token': token, + }); + } + + test('smoke', () { + return FakeApiConnection.with_((connection) async { + await checkRegisterFcmToken(connection, token: 'asdf'); + }); + }); + }); + + group('registerApnsToken', () { + Future checkRegisterApnsToken(FakeApiConnection connection, { + required String token, + required String? appid, + }) async { + connection.prepare(json: {}); + await registerApnsToken(connection, token: token, appid: appid); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/apns_device_token') + ..bodyFields.deepEquals({ + 'token': token, + if (appid != null) 'appid': appid, + }); + } + + test('no appid', () { + return FakeApiConnection.with_((connection) async { + await checkRegisterApnsToken(connection, token: 'asdf', appid: null); + }); + }); + + test('with appid', () { + return FakeApiConnection.with_((connection) async { + await checkRegisterApnsToken(connection, token: 'asdf', appid: 'qwer'); + }); + }); + }); +} diff --git a/test/example_data.dart b/test/example_data.dart index 4992695e0c..b8225b266b 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -344,3 +344,11 @@ PerAccountStore store({Account? account, InitialSnapshot? initialSnapshot}) { initialSnapshot: initialSnapshot ?? _initialSnapshot(), ); } + +LivePerAccountStore liveStore({Account? account, InitialSnapshot? initialSnapshot}) { + return LivePerAccountStore.fromInitialSnapshot( + account: account ?? selfAccount, + connection: FakeApiConnection.fromAccount(account ?? selfAccount), + initialSnapshot: initialSnapshot ?? _initialSnapshot(), + ); +} diff --git a/test/model/binding.dart b/test/model/binding.dart index 49bd7c672b..5939808652 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -1,4 +1,8 @@ +import 'dart:async'; + +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; +import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/store.dart'; @@ -55,16 +59,10 @@ class TestZulipBinding extends ZulipBinding { /// should clean up by calling this method. Typically this is done using /// [addTearDown], like `addTearDown(testBinding.reset);`. void reset() { - _globalStore?.dispose(); - _globalStore = null; - assert(() { - _debugAlreadyLoadedStore = false; - return true; - }()); - - launchUrlResult = true; - _launchUrlCalls = null; - deviceInfoResult = _defaultDeviceInfoResult; + _resetStore(); + _resetLaunchUrl(); + _resetDeviceInfo(); + _resetFirebase(); } /// The current global store offered to a [GlobalStoreWidget]. @@ -80,6 +78,15 @@ class TestZulipBinding extends ZulipBinding { bool _debugAlreadyLoadedStore = false; + void _resetStore() { + _globalStore?.dispose(); + _globalStore = null; + assert(() { + _debugAlreadyLoadedStore = false; + return true; + }()); + } + @override Future loadGlobalStore() { assert(() { @@ -110,6 +117,11 @@ class TestZulipBinding extends ZulipBinding { /// See also [takeLaunchUrlCalls]. bool launchUrlResult = true; + void _resetLaunchUrl() { + launchUrlResult = true; + _launchUrlCalls = null; + } + /// Consume the log of calls made to `ZulipBinding.instance.launchUrl()`. /// /// This returns a list of the arguments to all calls made @@ -139,8 +151,80 @@ class TestZulipBinding extends ZulipBinding { BaseDeviceInfo deviceInfoResult = _defaultDeviceInfoResult; static final _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33); + void _resetDeviceInfo() { + deviceInfoResult = _defaultDeviceInfoResult; + } + @override Future deviceInfo() { return Future(() => deviceInfoResult); } + + void _resetFirebase() { + _firebaseInitialized = false; + _firebaseMessaging = null; + } + + bool _firebaseInitialized = false; + FakeFirebaseMessaging? _firebaseMessaging; + + @override + Future firebaseInitializeApp() async { + _firebaseInitialized = true; + } + + /// The value `firebaseMessaging.getToken` will initialize the token to. + /// + /// After `firebaseMessaging.getToken` has been called once, this has no effect. + set firebaseMessagingInitialToken(String value) { + (_firebaseMessaging ??= FakeFirebaseMessaging())._initialToken = value; + } + + @override + FakeFirebaseMessaging get firebaseMessaging { + assert(_firebaseInitialized); + return (_firebaseMessaging ??= FakeFirebaseMessaging()); + } + + @override + Stream get firebaseMessagingOnMessage => firebaseMessaging.onMessage.stream; +} + +class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { + String? _initialToken; + + /// Set the token to a new value, as if it were newly generated. + /// + /// This will cause listeners of [onTokenRefresh] to be called, but + /// in a microtask, not synchronously. + void setToken(String value) { + _token = value; + _tokenController.add(value); + } + + String? _token; + + final StreamController _tokenController = + StreamController.broadcast(); + + @override + Future getToken({String? vapidKey}) async { + assert(vapidKey == null); + if (_token == null) { + assert(_initialToken != null, + 'Tests that call [NotificationService.start], or otherwise cause' + ' a call to `ZulipBinding.instance.firebaseMessaging.getToken`,' + ' must set `testBinding.firebaseMessagingInitialToken` first.'); + + // This causes [onTokenRefresh] to fire, just like the real [getToken] + // does when no token exists (e.g., on first run after install). + setToken(_initialToken!); + } + return _token; + } + + @override + Stream get onTokenRefresh => _tokenController.stream; + + StreamController onMessage = StreamController.broadcast(); } diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 70105e94e9..b4c4390fec 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -1,14 +1,20 @@ import 'dart:async'; import 'package:checks/checks.dart'; +import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/model/store.dart'; +import 'package:zulip/notifications.dart'; import '../api/fake_api.dart'; import '../example_data.dart' as eg; +import '../stdlib_checks.dart'; +import 'binding.dart'; import 'test_store.dart'; void main() { + TestZulipBinding.ensureInitialized(); + final account1 = eg.selfAccount.copyWith(id: 1); final account2 = eg.otherAccount.copyWith(id: 2); @@ -100,6 +106,78 @@ void main() { check(await globalStore.perAccount(1)).identicalTo(store1); check(completers(1)).length.equals(1); }); + + group('PerAccountStore.registerNotificationToken', () { + late LivePerAccountStore store; + late FakeApiConnection connection; + + void prepareStore() { + store = eg.liveStore(); + connection = store.connection as FakeApiConnection; + } + + void checkLastRequest({required String token}) { + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/android_gcm_reg_id') + ..bodyFields.deepEquals({'token': token}); + } + + test('token already known', () async { + // This tests the case where [NotificationService.start] has already + // learned the token before the store is created. + // (This is probably the common case.) + addTearDown(testBinding.reset); + testBinding.firebaseMessagingInitialToken = '012abc'; + addTearDown(NotificationService.debugReset); + await NotificationService.instance.start(); + + // On store startup, send the token. + prepareStore(); + connection.prepare(json: {}); + await store.registerNotificationToken(); + checkLastRequest(token: '012abc'); + + // If the token changes, send it again. + testBinding.firebaseMessaging.setToken('456def'); + connection.prepare(json: {}); + await null; // Run microtasks. TODO use FakeAsync for these tests. + checkLastRequest(token: '456def'); + }); + + test('token initially unknown', () async { + // This tests the case where the store is created while our + // request for the token is still pending. + addTearDown(testBinding.reset); + testBinding.firebaseMessagingInitialToken = '012abc'; + addTearDown(NotificationService.debugReset); + final startFuture = NotificationService.instance.start(); + + // TODO this test is a bit brittle in its interaction with asynchrony; + // to fix, probably extend TestZulipBinding to control when getToken finishes. + // + // The aim here is to first wait for `store.registerNotificationToken` + // to complete whatever it's going to do; then check no request was made; + // and only after that wait for `NotificationService.start` to finish, + // including its `getToken` call. + + // On store startup, send nothing (because we have nothing to send). + prepareStore(); + await store.registerNotificationToken(); + check(connection.lastRequest).isNull(); + + // When the token later appears, send it. + connection.prepare(json: {}); + await startFuture; + checkLastRequest(token: '012abc'); + + // If the token subsequently changes, send it again. + testBinding.firebaseMessaging.setToken('456def'); + connection.prepare(json: {}); + await null; // Run microtasks. TODO use FakeAsync for these tests. + checkLastRequest(token: '456def'); + }); + }); } class LoadingTestGlobalStore extends TestGlobalStore { diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index c79d65f773..0d4b4d65c2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -14,6 +15,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); Sqlite3FlutterLibsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3595b75df1..4a4d9be3e7 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + firebase_core share_plus sqlite3_flutter_libs url_launcher_windows