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 committed Oct 17, 2023
1 parent 37bfeca commit b7d8055
Show file tree
Hide file tree
Showing 4 changed files with 165 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());
}
20 changes: 20 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 @@ -451,6 +453,7 @@ class LivePerAccountStore extends PerAccountStore {
initialSnapshot: initialSnapshot,
);
store.poll();
store.registerNotificationToken();
return store;
}

Expand All @@ -472,4 +475,21 @@ class LivePerAccountStore extends PerAccountStore {
}
}
}

/// Send this client's notification token to the server, now and if it changes.
///
/// 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);
}
}
72 changes: 72 additions & 0 deletions lib/notifications.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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(log) if null
assert(debugLog("notif token: $value"));
// On a typical launch of the app (other than the first one after install),
// this is the only way we learn the token value; onTokenRefresh never fires.
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;
}

void _onRemoteMessage(FirebaseRemoteMessage message) {
assert(debugLog("notif message: ${message.data}"));
// TODO(#122): parse data; show notification UI
}
}
70 changes: 70 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,70 @@ 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();

// 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 b7d8055

Please sign in to comment.