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 {