Skip to content

Commit 11d456d

Browse files
gnpricechrisbobbe
authored andcommitted
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.
1 parent ff4ad6a commit 11d456d

File tree

4 files changed

+183
-0
lines changed

4 files changed

+183
-0
lines changed

lib/main.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
44
import 'licenses.dart';
55
import 'log.dart';
66
import 'model/binding.dart';
7+
import 'notifications.dart';
78
import 'widgets/app.dart';
89

910
void main() {
@@ -13,5 +14,7 @@ void main() {
1314
}());
1415
LicenseRegistry.addLicense(additionalLicenses);
1516
LiveZulipBinding.ensureInitialized();
17+
WidgetsFlutterBinding.ensureInitialized();
18+
NotificationService.instance.start();
1619
runApp(const ZulipApp());
1720
}

lib/model/store.dart

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import '../api/model/initial_snapshot.dart';
1212
import '../api/model/model.dart';
1313
import '../api/route/events.dart';
1414
import '../api/route/messages.dart';
15+
import '../api/route/notifications.dart';
1516
import '../log.dart';
17+
import '../notifications.dart';
1618
import 'autocomplete.dart';
1719
import 'database.dart';
1820
import 'message_list.dart';
@@ -425,6 +427,8 @@ class LiveGlobalStore extends GlobalStore {
425427
}
426428

427429
/// A [PerAccountStore] which polls an event queue to stay up to date.
430+
// TODO decouple "live"ness from polling and registerNotificationToken;
431+
// the latter are made up of testable internal logic, not external integration
428432
class LivePerAccountStore extends PerAccountStore {
429433
LivePerAccountStore.fromInitialSnapshot({
430434
required super.account,
@@ -458,6 +462,9 @@ class LivePerAccountStore extends PerAccountStore {
458462
initialSnapshot: initialSnapshot,
459463
);
460464
store.poll();
465+
// TODO do registerNotificationToken before registerQueue:
466+
// https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807
467+
store.registerNotificationToken();
461468
return store;
462469
}
463470

@@ -479,4 +486,25 @@ class LivePerAccountStore extends PerAccountStore {
479486
}
480487
}
481488
}
489+
490+
/// Send this client's notification token to the server, now and if it changes.
491+
///
492+
/// TODO The returned future isn't especially meaningful (it may or may not
493+
/// mean we actually sent the token). Make it just `void` once we fix the
494+
/// one test that relies on the future.
495+
///
496+
/// TODO(#321) handle iOS/APNs; currently only Android/FCM
497+
// TODO(#322) save acked token, to dedupe updating it on the server
498+
// TODO(#323) track the registerFcmToken/etc request, warn if not succeeding
499+
Future<void> registerNotificationToken() async {
500+
// TODO call removeListener on [dispose]
501+
NotificationService.instance.token.addListener(_registerNotificationToken);
502+
await _registerNotificationToken();
503+
}
504+
505+
Future<void> _registerNotificationToken() async {
506+
final token = NotificationService.instance.token.value;
507+
if (token == null) return;
508+
await registerFcmToken(connection, token: token);
509+
}
482510
}

lib/notifications.dart

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import 'package:flutter/foundation.dart';
2+
3+
import 'log.dart';
4+
import 'model/binding.dart';
5+
6+
class NotificationService {
7+
static NotificationService get instance => (_instance ??= NotificationService._());
8+
static NotificationService? _instance;
9+
10+
NotificationService._();
11+
12+
/// Reset the state of the [NotificationService], for testing.
13+
///
14+
/// TODO refactor this better, perhaps unify with ZulipBinding
15+
@visibleForTesting
16+
static void debugReset() {
17+
instance.token.dispose();
18+
instance.token = ValueNotifier(null);
19+
}
20+
21+
/// The FCM registration token for this install of the app.
22+
///
23+
/// This is unique to the (app, device) pair, but not permanent.
24+
/// Most often it's the same from one run of the app to the next,
25+
/// but it can change either during a run or between them.
26+
///
27+
/// See also:
28+
/// * Upstream docs on FCM registration tokens in general:
29+
/// https://firebase.google.com/docs/cloud-messaging/manage-tokens
30+
ValueNotifier<String?> token = ValueNotifier(null);
31+
32+
Future<void> start() async {
33+
if (defaultTargetPlatform != TargetPlatform.android) return; // TODO(#321)
34+
35+
await ZulipBinding.instance.firebaseInitializeApp();
36+
37+
// TODO(#324) defer notif setup if user not logged into any accounts
38+
// (in order to avoid calling for permissions)
39+
40+
ZulipBinding.instance.firebaseMessagingOnMessage.listen(_onRemoteMessage);
41+
42+
// Get the FCM registration token, now and upon changes. See FCM API docs:
43+
// https://firebase.google.com/docs/cloud-messaging/android/client#sample-register
44+
ZulipBinding.instance.firebaseMessaging.onTokenRefresh.listen(_onTokenRefresh);
45+
await _getToken();
46+
}
47+
48+
Future<void> _getToken() async {
49+
final value = await ZulipBinding.instance.firebaseMessaging.getToken();
50+
// TODO(#323) warn user if getToken returns null, or doesn't timely return
51+
assert(debugLog("notif token: $value"));
52+
// The call to `getToken` won't cause `onTokenRefresh` to fire if we
53+
// already have a token from a previous run of the app.
54+
// So we need to use the `getToken` return value.
55+
token.value = value;
56+
}
57+
58+
void _onTokenRefresh(String value) {
59+
assert(debugLog("new notif token: $value"));
60+
// On first launch after install, our [FirebaseMessaging.getToken] call
61+
// causes this to fire, followed by completing its own future so that
62+
// `_getToken` sees the value as well. So in that case this is redundant.
63+
//
64+
// Subsequently, though, this can also potentially fire on its own, if for
65+
// some reason the FCM system decides to replace the token. So both paths
66+
// need to save the value.
67+
token.value = value;
68+
}
69+
70+
static void _onRemoteMessage(FirebaseRemoteMessage message) {
71+
assert(debugLog("notif message: ${message.data}"));
72+
// TODO(#122): parse data; show notification UI
73+
}
74+
}

test/model/store_test.dart

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import 'dart:async';
22

33
import 'package:checks/checks.dart';
4+
import 'package:http/http.dart' as http;
45
import 'package:test/scaffolding.dart';
56
import 'package:zulip/model/store.dart';
7+
import 'package:zulip/notifications.dart';
68

79
import '../api/fake_api.dart';
810
import '../example_data.dart' as eg;
11+
import '../stdlib_checks.dart';
12+
import 'binding.dart';
913
import 'test_store.dart';
1014

1115
void main() {
16+
TestZulipBinding.ensureInitialized();
17+
1218
final account1 = eg.selfAccount.copyWith(id: 1);
1319
final account2 = eg.otherAccount.copyWith(id: 2);
1420

@@ -100,6 +106,78 @@ void main() {
100106
check(await globalStore.perAccount(1)).identicalTo(store1);
101107
check(completers(1)).length.equals(1);
102108
});
109+
110+
group('PerAccountStore.registerNotificationToken', () {
111+
late LivePerAccountStore store;
112+
late FakeApiConnection connection;
113+
114+
void prepareStore() {
115+
store = eg.liveStore();
116+
connection = store.connection as FakeApiConnection;
117+
}
118+
119+
void checkLastRequest({required String token}) {
120+
check(connection.lastRequest).isA<http.Request>()
121+
..method.equals('POST')
122+
..url.path.equals('/api/v1/users/me/android_gcm_reg_id')
123+
..bodyFields.deepEquals({'token': token});
124+
}
125+
126+
test('token already known', () async {
127+
// This tests the case where [NotificationService.start] has already
128+
// learned the token before the store is created.
129+
// (This is probably the common case.)
130+
addTearDown(testBinding.reset);
131+
testBinding.firebaseMessagingInitialToken = '012abc';
132+
addTearDown(NotificationService.debugReset);
133+
await NotificationService.instance.start();
134+
135+
// On store startup, send the token.
136+
prepareStore();
137+
connection.prepare(json: {});
138+
await store.registerNotificationToken();
139+
checkLastRequest(token: '012abc');
140+
141+
// If the token changes, send it again.
142+
testBinding.firebaseMessaging.setToken('456def');
143+
connection.prepare(json: {});
144+
await null; // Run microtasks. TODO use FakeAsync for these tests.
145+
checkLastRequest(token: '456def');
146+
});
147+
148+
test('token initially unknown', () async {
149+
// This tests the case where the store is created while our
150+
// request for the token is still pending.
151+
addTearDown(testBinding.reset);
152+
testBinding.firebaseMessagingInitialToken = '012abc';
153+
addTearDown(NotificationService.debugReset);
154+
final startFuture = NotificationService.instance.start();
155+
156+
// TODO this test is a bit brittle in its interaction with asynchrony;
157+
// to fix, probably extend TestZulipBinding to control when getToken finishes.
158+
//
159+
// The aim here is to first wait for `store.registerNotificationToken`
160+
// to complete whatever it's going to do; then check no request was made;
161+
// and only after that wait for `NotificationService.start` to finish,
162+
// including its `getToken` call.
163+
164+
// On store startup, send nothing (because we have nothing to send).
165+
prepareStore();
166+
await store.registerNotificationToken();
167+
check(connection.lastRequest).isNull();
168+
169+
// When the token later appears, send it.
170+
connection.prepare(json: {});
171+
await startFuture;
172+
checkLastRequest(token: '012abc');
173+
174+
// If the token subsequently changes, send it again.
175+
testBinding.firebaseMessaging.setToken('456def');
176+
connection.prepare(json: {});
177+
await null; // Run microtasks. TODO use FakeAsync for these tests.
178+
checkLastRequest(token: '456def');
179+
});
180+
});
103181
}
104182

105183
class LoadingTestGlobalStore extends TestGlobalStore {

0 commit comments

Comments
 (0)