Skip to content

Commit 69574ae

Browse files
gnpricechrisbobbe
authored andcommitted
notif: Support iOS!
Fixes: #321
1 parent b9fce3f commit 69574ae

File tree

4 files changed

+107
-18
lines changed

4 files changed

+107
-18
lines changed

lib/firebase_options.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,31 @@ const kFirebaseOptionsAndroid = FirebaseOptions(
2121
apiKey: _ZulipFirebaseOptions.firebaseApiKey,
2222
);
2323

24+
/// Configuration used for finding the notification token on iOS.
25+
///
26+
/// On iOS, we don't use Firebase to actually deliver notifications;
27+
/// rather the Zulip notification bouncer service communicates with
28+
/// the Apple Push Notification service (APNs) directly.
29+
///
30+
/// But we do use the Firebase library as a convenient binding to the
31+
/// platform API for the setup steps of requesting the user's permission
32+
/// to show notifications, and getting the token that the service uses
33+
/// to represent that permission.
34+
/// These values are similar to [kFirebaseOptionsAndroid] but are for iOS,
35+
/// and they let us initialize the Firebase library so that we can do that.
36+
///
37+
/// TODO: Cut out Firebase for APNs and use a thinner platform-API binding.
38+
const kFirebaseOptionsIos = FirebaseOptions(
39+
appId: '1:${_ZulipFirebaseOptions.projectNumber}:ios:9cad34899ca57ba6',
40+
messagingSenderId: _ZulipFirebaseOptions.projectNumber,
41+
projectId: _ZulipFirebaseOptions.projectId,
42+
apiKey: _ZulipFirebaseOptions.firebaseApiKey,
43+
);
44+
2445
abstract class _ZulipFirebaseOptions {
2546
static const projectNumber = '835904834568';
2647

48+
// Despite its value, this name applies across Android and iOS.
2749
static const projectId = 'zulip-android';
2850

2951
// Despite the name, this Google Cloud "API key" is a very different kind

lib/notifications.dart

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:convert';
22

33
import 'package:collection/collection.dart';
44
import 'package:crypto/crypto.dart';
5+
import 'package:firebase_messaging/firebase_messaging.dart';
56
import 'package:flutter/foundation.dart';
67
import 'package:flutter/widgets.dart';
78
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
@@ -78,7 +79,25 @@ class NotificationService {
7879
.listen(_onTokenRefresh);
7980
await _getFcmToken();
8081

81-
case TargetPlatform.iOS:
82+
case TargetPlatform.iOS: // TODO(#324): defer requesting notif permission
83+
await ZulipBinding.instance.firebaseInitializeApp(
84+
options: kFirebaseOptionsIos);
85+
86+
// Docs on this API: https://firebase.flutter.dev/docs/messaging/permissions/
87+
final settings = await ZulipBinding.instance.firebaseMessaging
88+
.requestPermission();
89+
assert(debugLog('notif settings: $settings'));
90+
switch (settings.authorizationStatus) {
91+
case AuthorizationStatus.denied:
92+
return;
93+
case AuthorizationStatus.authorized:
94+
case AuthorizationStatus.provisional:
95+
case AuthorizationStatus.notDetermined:
96+
}
97+
98+
await _getApnsToken();
99+
// TODO does iOS need token refresh too?
100+
82101
case TargetPlatform.linux:
83102
case TargetPlatform.macOS:
84103
case TargetPlatform.windows:
@@ -98,6 +117,13 @@ class NotificationService {
98117
token.value = value;
99118
}
100119

120+
Future<void> _getApnsToken() async {
121+
final value = await ZulipBinding.instance.firebaseMessaging.getAPNSToken();
122+
// TODO(#323) warn user if getAPNSToken returns null, or doesn't timely return
123+
assert(debugLog("notif APNs token: $value"));
124+
token.value = value;
125+
}
126+
101127
void _onTokenRefresh(String value) {
102128
assert(debugLog("new notif token: $value"));
103129
// On first launch after install, our [FirebaseMessaging.getToken] call
@@ -116,6 +142,9 @@ class NotificationService {
116142
await registerFcmToken(connection, token: token);
117143

118144
case TargetPlatform.iOS:
145+
const appBundleId = 'com.zulip.flutter'; // TODO(#407) find actual value live
146+
await registerApnsToken(connection, token: token, appid: appBundleId);
147+
119148
case TargetPlatform.linux:
120149
case TargetPlatform.macOS:
121150
case TargetPlatform.windows:

test/model/store_test.dart

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async';
22

33
import 'package:checks/checks.dart';
4+
import 'package:flutter/foundation.dart';
45
import 'package:http/http.dart' as http;
56
import 'package:test/scaffolding.dart';
67
import 'package:zulip/api/model/events.dart';
@@ -117,14 +118,21 @@ void main() {
117118
connection = store.connection as FakeApiConnection;
118119
}
119120

120-
void checkLastRequest({required String token}) {
121+
void checkLastRequestApns({required String token, required String appid}) {
122+
check(connection.lastRequest).isA<http.Request>()
123+
..method.equals('POST')
124+
..url.path.equals('/api/v1/users/me/apns_device_token')
125+
..bodyFields.deepEquals({'token': token, 'appid': appid});
126+
}
127+
128+
void checkLastRequestFcm({required String token}) {
121129
check(connection.lastRequest).isA<http.Request>()
122130
..method.equals('POST')
123131
..url.path.equals('/api/v1/users/me/android_gcm_reg_id')
124132
..bodyFields.deepEquals({'token': token});
125133
}
126134

127-
test('token already known', () async {
135+
testAndroidIos('token already known', () async {
128136
// This tests the case where [NotificationService.start] has already
129137
// learned the token before the store is created.
130138
// (This is probably the common case.)
@@ -137,16 +145,22 @@ void main() {
137145
prepareStore();
138146
connection.prepare(json: {});
139147
await store.registerNotificationToken();
140-
checkLastRequest(token: '012abc');
141-
142-
// If the token changes, send it again.
143-
testBinding.firebaseMessaging.setToken('456def');
144-
connection.prepare(json: {});
145-
await null; // Run microtasks. TODO use FakeAsync for these tests.
146-
checkLastRequest(token: '456def');
148+
if (defaultTargetPlatform == TargetPlatform.android) {
149+
checkLastRequestFcm(token: '012abc');
150+
} else {
151+
checkLastRequestApns(token: '012abc', appid: 'com.zulip.flutter');
152+
}
153+
154+
if (defaultTargetPlatform == TargetPlatform.android) {
155+
// If the token changes, send it again.
156+
testBinding.firebaseMessaging.setToken('456def');
157+
connection.prepare(json: {});
158+
await null; // Run microtasks. TODO use FakeAsync for these tests.
159+
checkLastRequestFcm(token: '456def');
160+
}
147161
});
148162

149-
test('token initially unknown', () async {
163+
testAndroidIos('token initially unknown', () async {
150164
// This tests the case where the store is created while our
151165
// request for the token is still pending.
152166
addTearDown(testBinding.reset);
@@ -170,13 +184,19 @@ void main() {
170184
// When the token later appears, send it.
171185
connection.prepare(json: {});
172186
await startFuture;
173-
checkLastRequest(token: '012abc');
174-
175-
// If the token subsequently changes, send it again.
176-
testBinding.firebaseMessaging.setToken('456def');
177-
connection.prepare(json: {});
178-
await null; // Run microtasks. TODO use FakeAsync for these tests.
179-
checkLastRequest(token: '456def');
187+
if (defaultTargetPlatform == TargetPlatform.android) {
188+
checkLastRequestFcm(token: '012abc');
189+
} else {
190+
checkLastRequestApns(token: '012abc', appid: 'com.zulip.flutter');
191+
}
192+
193+
if (defaultTargetPlatform == TargetPlatform.android) {
194+
// If the token subsequently changes, send it again.
195+
testBinding.firebaseMessaging.setToken('456def');
196+
connection.prepare(json: {});
197+
await null; // Run microtasks. TODO use FakeAsync for these tests.
198+
checkLastRequestFcm(token: '456def');
199+
}
180200
});
181201
});
182202

@@ -239,3 +259,13 @@ class LoadingTestGlobalStore extends TestGlobalStore {
239259
return completer.future;
240260
}
241261
}
262+
263+
void testAndroidIos(String description, FutureOr<void> Function() body) {
264+
test('$description (Android)', body);
265+
test('$description (iOS)', () async {
266+
final origTargetPlatform = debugDefaultTargetPlatformOverride;
267+
addTearDown(() => debugDefaultTargetPlatformOverride = origTargetPlatform);
268+
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
269+
await body();
270+
});
271+
}

test/notifications_test.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ void main() {
8181
await NotificationService.instance.start();
8282
}
8383

84+
group('permissions', () {
85+
testWidgets('on iOS request permission', (tester) async {
86+
await init();
87+
check(testBinding.firebaseMessaging.takeRequestPermissionCalls())
88+
.length.equals(1);
89+
}, variant: TargetPlatformVariant.only(TargetPlatform.iOS));
90+
});
91+
8492
group('NotificationChannelManager', () {
8593
test('smoke', () async {
8694
await init();

0 commit comments

Comments
 (0)