Skip to content

Commit b92ed6a

Browse files
committed
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 a5725c9 commit b92ed6a

File tree

4 files changed

+165
-0
lines changed

4 files changed

+165
-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: 20 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';
@@ -451,6 +453,7 @@ class LivePerAccountStore extends PerAccountStore {
451453
initialSnapshot: initialSnapshot,
452454
);
453455
store.poll();
456+
store.registerNotificationToken();
454457
return store;
455458
}
456459

@@ -472,4 +475,21 @@ class LivePerAccountStore extends PerAccountStore {
472475
}
473476
}
474477
}
478+
479+
/// Send this client's notification token to the server, now and if it changes.
480+
///
481+
/// TODO(#321) handle iOS/APNs; currently only Android/FCM
482+
// TODO(#322) save acked token, to dedupe updating it on the server
483+
// TODO(#323) track the registerFcmToken/etc request, warn if not succeeding
484+
Future<void> registerNotificationToken() async {
485+
// TODO call removeListener on [dispose]
486+
NotificationService.instance.token.addListener(_registerNotificationToken);
487+
await _registerNotificationToken();
488+
}
489+
490+
Future<void> _registerNotificationToken() async {
491+
final token = NotificationService.instance.token.value;
492+
if (token == null) return;
493+
await registerFcmToken(connection, token: token);
494+
}
475495
}

lib/notifications.dart

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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(); // TODO(log) if null
50+
assert(debugLog("notif token: $value"));
51+
// On a typical launch of the app (other than the first one after install),
52+
// this is the only way we learn the token value; onTokenRefresh never fires.
53+
token.value = value;
54+
}
55+
56+
void _onTokenRefresh(String value) {
57+
assert(debugLog("new notif token: $value"));
58+
// On first launch after install, our [FirebaseMessaging.getToken] call
59+
// causes this to fire, followed by completing its own future so that
60+
// `_getToken` sees the value as well. So in that case this is redundant.
61+
//
62+
// Subsequently, though, this can also potentially fire on its own, if for
63+
// some reason the FCM system decides to replace the token. So both paths
64+
// need to save the value.
65+
token.value = value;
66+
}
67+
68+
static void _onRemoteMessage(FirebaseRemoteMessage message) {
69+
assert(debugLog("notif message: ${message.data}"));
70+
// TODO(#122): parse data; show notification UI
71+
}
72+
}

test/model/store_test.dart

Lines changed: 70 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,70 @@ 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+
// On store startup, send nothing (because we have nothing to send).
157+
prepareStore();
158+
await store.registerNotificationToken();
159+
check(connection.lastRequest).isNull();
160+
161+
// When the token later appears, send it.
162+
connection.prepare(json: {});
163+
await startFuture;
164+
checkLastRequest(token: '012abc');
165+
166+
// If the token subsequently changes, send it again.
167+
testBinding.firebaseMessaging.setToken('456def');
168+
connection.prepare(json: {});
169+
await null; // Run microtasks. TODO use FakeAsync for these tests.
170+
checkLastRequest(token: '456def');
171+
});
172+
});
103173
}
104174

105175
class LoadingTestGlobalStore extends TestGlobalStore {

0 commit comments

Comments
 (0)