Skip to content

Commit

Permalink
notif: Get token on Android, and send to server
Browse files Browse the repository at this point in the history
This implements part of zulip#320.

To make an end-to-end demo, we also listen for notification
messages, and just print them to the debug log.
  • Loading branch information
gnprice authored and chrisbobbe committed Oct 25, 2023
1 parent ff4ad6a commit 11d456d
Show file tree
Hide file tree
Showing 4 changed files with 183 additions and 0 deletions.
3 changes: 3 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -13,5 +14,7 @@ void main() {
}());
LicenseRegistry.addLicense(additionalLicenses);
LiveZulipBinding.ensureInitialized();
WidgetsFlutterBinding.ensureInitialized();
NotificationService.instance.start();
runApp(const ZulipApp());
}
28 changes: 28 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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<void> registerNotificationToken() async {
// TODO call removeListener on [dispose]
NotificationService.instance.token.addListener(_registerNotificationToken);
await _registerNotificationToken();
}

Future<void> _registerNotificationToken() async {
final token = NotificationService.instance.token.value;
if (token == null) return;
await registerFcmToken(connection, token: token);
}
}
74 changes: 74 additions & 0 deletions lib/notifications.dart
Original file line number Diff line number Diff line change
@@ -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<String?> token = ValueNotifier(null);

Future<void> 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<void> _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
}
}
78 changes: 78 additions & 0 deletions test/model/store_test.dart
Original file line number Diff line number Diff line change
@@ -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);

Expand Down Expand Up @@ -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<http.Request>()
..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 {
Expand Down

0 comments on commit 11d456d

Please sign in to comment.