From 90ad2b98b231607e53fdef1ebe6ae3172334ceac Mon Sep 17 00:00:00 2001 From: Greg Price Date: Fri, 16 Jun 2023 20:55:44 -0700 Subject: [PATCH 1/7] deps: Add firebase_core, firebase_messaging --- ios/Podfile.lock | 85 ++++++++++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 4 + macos/Podfile.lock | 86 +++++++++++++++++++ pubspec.lock | 56 ++++++++++++ pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 7 files changed, 237 insertions(+) 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/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/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 From 33f6a526d64089c09c11412c70350d823c24c306 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 16 Oct 2023 17:10:25 -0700 Subject: [PATCH 2/7] notif: Add Firebase options --- lib/firebase_options.dart | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 lib/firebase_options.dart 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', +); From 639d6161964c02d2308eb5773c70e01e1adfd0e6 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Wed, 4 Oct 2023 16:29:55 -0700 Subject: [PATCH 3/7] test [nfc]: Split up TestZulipBinding.reset This will help keep things organized as we add more and more features to the binding class, by letting the data and logic for each feature live all in one contiguous section of the code. --- test/model/binding.dart | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/test/model/binding.dart b/test/model/binding.dart index 49bd7c672b..81f6da7fbb 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -55,16 +55,9 @@ 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(); } /// The current global store offered to a [GlobalStoreWidget]. @@ -80,6 +73,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 +112,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,6 +146,10 @@ class TestZulipBinding extends ZulipBinding { BaseDeviceInfo deviceInfoResult = _defaultDeviceInfoResult; static final _defaultDeviceInfoResult = AndroidDeviceInfo(sdkInt: 33); + void _resetDeviceInfo() { + deviceInfoResult = _defaultDeviceInfoResult; + } + @override Future deviceInfo() { return Future(() => deviceInfoResult); From 2a7d14546662bb18fe8c590e41249b73bcf497fc Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 16 Oct 2023 17:12:34 -0700 Subject: [PATCH 4/7] binding: Add firebaseInitializeApp, firebaseMessaging --- lib/model/binding.dart | 46 ++++++++++++++++++++++++++ test/model/binding.dart | 73 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) 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/test/model/binding.dart b/test/model/binding.dart index 81f6da7fbb..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'; @@ -58,6 +62,7 @@ class TestZulipBinding extends ZulipBinding { _resetStore(); _resetLaunchUrl(); _resetDeviceInfo(); + _resetFirebase(); } /// The current global store offered to a [GlobalStoreWidget]. @@ -154,4 +159,72 @@ class TestZulipBinding extends ZulipBinding { 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(); } From b8290cd115d5c7ccd4740f2df2dd8fd24db90e18 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 16 Oct 2023 17:14:14 -0700 Subject: [PATCH 5/7] api: Add routes registerFcmToken, registerApnsToken This code is so trivial that I'm torn on whether the tests are actually useful; they feel a lot like just repeating the implementation. But they were easy to write. --- lib/api/route/notifications.dart | 30 +++++++++++++ test/api/route/notifications_test.dart | 59 ++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 lib/api/route/notifications.dart create mode 100644 test/api/route/notifications_test.dart 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/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'); + }); + }); + }); +} From ff4ad6a022691360386be53fdd89d1cc46e65923 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 16 Oct 2023 17:16:03 -0700 Subject: [PATCH 6/7] test: Add eg.liveStore, a LivePerAccountStore --- test/example_data.dart | 8 ++++++++ 1 file changed, 8 insertions(+) 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(), + ); +} From 11d456d6fe653a5760511d24d1406ad519c74434 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 16 Oct 2023 17:17:54 -0700 Subject: [PATCH 7/7] notif: Get token on Android, and send to server This implements part of #320. To make an end-to-end demo, we also listen for notification messages, and just print them to the debug log. --- lib/main.dart | 3 ++ lib/model/store.dart | 28 ++++++++++++++ lib/notifications.dart | 74 ++++++++++++++++++++++++++++++++++++ test/model/store_test.dart | 78 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 lib/notifications.dart 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/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/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 {