diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index dc1c088e1e..84ebb988e5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 157DB5307E82FD55510A52F6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 3752899A2AF472D400475D9C /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3EAE3F3F518B95B7BFEB4FE7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4541FE48FEFD549440B32652 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -68,7 +69,6 @@ 4541FE48FEFD549440B32652 /* Pods-Runner.release.xcconfig */, 7A94C3F14B90E7FC7BC4B082 /* Pods-Runner.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -105,6 +105,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 3752899A2AF472D400475D9C /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -358,6 +359,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 66KHCWMEYB; ENABLE_BITCODE = NO; @@ -487,6 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 66KHCWMEYB; ENABLE_BITCODE = NO; @@ -510,6 +513,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = 66KHCWMEYB; ENABLE_BITCODE = NO; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 28d1c53236..db97fb0d3e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -26,6 +28,17 @@ LSRequiresIPhoneOS + NSCameraUsageDescription + By allowing camera access, you can take photos and send them in Zulip messages. + NSPhotoLibraryUsageDescription + Choose photos from your library and send them in Zulip messages. + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + fetch + remote-notification + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -45,17 +58,5 @@ UIViewControllerBasedStatusBarAppearance - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - fetch - - NSCameraUsageDescription - By allowing camera access, you can take photos and send them in Zulip messages. - NSPhotoLibraryUsageDescription - Choose photos from your library and send them in Zulip messages. diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000000..903def2af5 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index 64a4d29baf..f746c9391e 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -15,10 +15,38 @@ import 'package:firebase_core/firebase_core.dart'; const kFirebaseOptionsAndroid = FirebaseOptions( // This `appId` and `messagingSenderId` are the same as in zulip-mobile; // see zulip-mobile:android/app/src/main/res/values/firebase.xml . - appId: '1:835904834568:android:6ae61ae43a7c3410', - messagingSenderId: '835904834568', + appId: '1:${_ZulipFirebaseOptions.projectNumber}:android:6ae61ae43a7c3410', + messagingSenderId: _ZulipFirebaseOptions.projectNumber, + projectId: _ZulipFirebaseOptions.projectId, + apiKey: _ZulipFirebaseOptions.firebaseApiKey, +); + +/// Configuration used for finding the notification token on iOS. +/// +/// On iOS, we don't use Firebase to actually deliver notifications; +/// rather the Zulip notification bouncer service communicates with +/// the Apple Push Notification service (APNs) directly. +/// +/// But we do use the Firebase library as a convenient binding to the +/// platform API for the setup steps of requesting the user's permission +/// to show notifications, and getting the token that the service uses +/// to represent that permission. +/// These values are similar to [kFirebaseOptionsAndroid] but are for iOS, +/// and they let us initialize the Firebase library so that we can do that. +/// +/// TODO: Cut out Firebase for APNs and use a thinner platform-API binding. +const kFirebaseOptionsIos = FirebaseOptions( + appId: '1:${_ZulipFirebaseOptions.projectNumber}:ios:9cad34899ca57ba6', + messagingSenderId: _ZulipFirebaseOptions.projectNumber, + projectId: _ZulipFirebaseOptions.projectId, + apiKey: _ZulipFirebaseOptions.firebaseApiKey, +); - projectId: 'zulip-android', +abstract class _ZulipFirebaseOptions { + static const projectNumber = '835904834568'; + + // Despite its value, this name applies across Android and iOS. + static const projectId = 'zulip-android'; // Despite the name, this Google Cloud "API key" is a very different kind // of thing from a Zulip "API key". In particular, it's designed to be @@ -29,8 +57,10 @@ const kFirebaseOptionsAndroid = FirebaseOptions( // This key was created fresh for this use in zulip-flutter. // It's easy to create additional keys associated with the same `appId` // and other details above, and to enable or disable individual keys. + // See the Google Cloud console: + // https://console.cloud.google.com/apis/credentials // // TODO: Perhaps use a different key in published builds; still fundamentally // public, but would avoid accidental reuse in dev or modified builds. - apiKey: 'AIzaSyC6kw5sqCYjxQl2Lbd_8MDmc1lu2EG0pY4', -); + static const firebaseApiKey = 'AIzaSyC6kw5sqCYjxQl2Lbd_8MDmc1lu2EG0pY4'; +} diff --git a/lib/model/binding.dart b/lib/model/binding.dart index 79fe0e0632..dec26808d3 100644 --- a/lib/model/binding.dart +++ b/lib/model/binding.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; -import '../firebase_options.dart'; import '../widgets/store.dart'; import 'store.dart'; @@ -92,7 +91,8 @@ abstract class ZulipBinding { /// Initialize Firebase, to use for notifications. /// /// This wraps [firebase_core.Firebase.initializeApp]. - Future firebaseInitializeApp(); + Future firebaseInitializeApp({ + required firebase_core.FirebaseOptions options}); /// Wraps [firebase_messaging.FirebaseMessaging.instance]. firebase_messaging.FirebaseMessaging get firebaseMessaging; @@ -174,22 +174,9 @@ class LiveZulipBinding extends ZulipBinding { } @override - Future firebaseInitializeApp() { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - return firebase_core.Firebase.initializeApp(options: kFirebaseOptionsAndroid); - - case TargetPlatform.iOS: - // TODO(#321): Set up Firebase on iOS. (Or do something else instead.) - return Future.value(); - - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - case TargetPlatform.fuchsia: - // Do nothing; we don't offer notifications on these platforms. - return Future.value(); - } + Future firebaseInitializeApp({ + required firebase_core.FirebaseOptions options}) { + return firebase_core.Firebase.initializeApp(options: options); } @override diff --git a/lib/model/store.dart b/lib/model/store.dart index 5ee3ca74cb..adb6270275 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -12,7 +12,6 @@ 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'; @@ -544,8 +543,6 @@ class LivePerAccountStore extends PerAccountStore { /// 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 registerNotificationToken() async { @@ -557,6 +554,6 @@ class LivePerAccountStore extends PerAccountStore { Future _registerNotificationToken() async { final token = NotificationService.instance.token.value; if (token == null) return; - await registerFcmToken(connection, token: token); + await NotificationService.registerToken(connection, token: token); } } diff --git a/lib/notifications.dart b/lib/notifications.dart index 5b9bfe4fab..d7a3c58be2 100644 --- a/lib/notifications.dart +++ b/lib/notifications.dart @@ -2,11 +2,15 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'api/core.dart'; import 'api/notifications.dart'; +import 'api/route/notifications.dart'; +import 'firebase_options.dart'; import 'log.dart'; import 'model/binding.dart'; import 'model/narrow.dart'; @@ -56,33 +60,70 @@ class NotificationService { ValueNotifier token = ValueNotifier(null); Future 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) - - await NotificationDisplayManager._init(); - ZulipBinding.instance.firebaseMessagingOnMessage.listen(_onForegroundMessage); - ZulipBinding.instance.firebaseMessagingOnBackgroundMessage(_onBackgroundMessage); - - // 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(); + switch (defaultTargetPlatform) { + case TargetPlatform.android: + // TODO(#324) defer notif setup if user not logged into any accounts + // (in order to avoid calling for permissions) + await ZulipBinding.instance.firebaseInitializeApp( + options: kFirebaseOptionsAndroid); + + await NotificationDisplayManager._init(); + ZulipBinding.instance.firebaseMessagingOnMessage + .listen(_onForegroundMessage); + ZulipBinding.instance.firebaseMessagingOnBackgroundMessage( + _onBackgroundMessage); + + // 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 _getFcmToken(); + + case TargetPlatform.iOS: // TODO(#324): defer requesting notif permission + await ZulipBinding.instance.firebaseInitializeApp( + options: kFirebaseOptionsIos); + + // Docs on this API: https://firebase.flutter.dev/docs/messaging/permissions/ + final settings = await ZulipBinding.instance.firebaseMessaging + .requestPermission(); + assert(debugLog('notif settings: $settings')); + switch (settings.authorizationStatus) { + case AuthorizationStatus.denied: + return; + case AuthorizationStatus.authorized: + case AuthorizationStatus.provisional: + case AuthorizationStatus.notDetermined: + } + + await _getApnsToken(); + // TODO does iOS need token refresh too? + + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + // Do nothing; we don't offer notifications on these platforms. + break; + } } - Future _getToken() async { + Future _getFcmToken() 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")); + assert(debugLog("notif FCM 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; } + Future _getApnsToken() async { + final value = await ZulipBinding.instance.firebaseMessaging.getAPNSToken(); + // TODO(#323) warn user if getAPNSToken returns null, or doesn't timely return + assert(debugLog("notif APNs token: $value")); + token.value = value; + } + void _onTokenRefresh(String value) { assert(debugLog("new notif token: $value")); // On first launch after install, our [FirebaseMessaging.getToken] call @@ -95,6 +136,23 @@ class NotificationService { token.value = value; } + static Future registerToken(ApiConnection connection, {required String token}) async { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + await registerFcmToken(connection, token: token); + + case TargetPlatform.iOS: + const appBundleId = 'com.zulip.flutter'; // TODO(#407) find actual value live + await registerApnsToken(connection, token: token, appid: appBundleId); + + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + assert(false); + } + } + static void _onForegroundMessage(FirebaseRemoteMessage message) { assert(debugLog("notif message: ${message.data}")); _onRemoteMessage(message); diff --git a/test/model/binding.dart b/test/model/binding.dart index d2e6a10edc..955ae829c0 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -174,7 +175,7 @@ class TestZulipBinding extends ZulipBinding { FakeFirebaseMessaging? _firebaseMessaging; @override - Future firebaseInitializeApp() async { + Future firebaseInitializeApp({required FirebaseOptions options}) async { _firebaseInitialized = true; } @@ -212,6 +213,55 @@ class TestZulipBinding extends ZulipBinding { } class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { + //////////////////////////////// + // Permissions. + + NotificationSettings requestPermissionResult = const NotificationSettings( + alert: AppleNotificationSetting.enabled, + announcement: AppleNotificationSetting.disabled, + authorizationStatus: AuthorizationStatus.authorized, + badge: AppleNotificationSetting.enabled, + carPlay: AppleNotificationSetting.disabled, + lockScreen: AppleNotificationSetting.enabled, + notificationCenter: AppleNotificationSetting.enabled, + showPreviews: AppleShowPreviewSetting.whenAuthenticated, + timeSensitive: AppleNotificationSetting.disabled, + criticalAlert: AppleNotificationSetting.disabled, + sound: AppleNotificationSetting.enabled, + ); + + List takeRequestPermissionCalls() { + final result = _requestPermissionCalls; + _requestPermissionCalls = []; + return result; + } + List _requestPermissionCalls = []; + + @override + Future requestPermission({ + bool alert = true, + bool announcement = false, + bool badge = true, + bool carPlay = false, + bool criticalAlert = false, + bool provisional = false, + bool sound = true, + }) async { + _requestPermissionCalls.add(( + alert: alert, + announcement: announcement, + badge: badge, + carPlay: carPlay, + criticalAlert: criticalAlert, + provisional: provisional, + sound: sound, + )); + return requestPermissionResult; + } + + //////////////////////////////// + // Tokens. + String? _initialToken; /// Set the token to a new value, as if it were newly generated. @@ -247,6 +297,27 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { @override Stream get onTokenRefresh => _tokenController.stream; + @override + Future getAPNSToken() async { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // In principle the APNs token is unrelated to any FCM token. + // But for tests it's convenient to have just one version of + // [TestBinding.firebaseMessagingInitialToken]. + return _initialToken; + + case TargetPlatform.android: + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + return null; + } + } + + //////////////////////////////// + // Messages. + StreamController onMessage = StreamController.broadcast(); /// Controls [TestZulipBinding.firebaseMessagingOnBackgroundMessage]. @@ -256,6 +327,16 @@ class FakeFirebaseMessaging extends Fake implements FirebaseMessaging { StreamController onBackgroundMessage = StreamController.broadcast(); } +typedef FirebaseMessagingRequestPermissionCall = ({ + bool alert, + bool announcement, + bool badge, + bool carPlay, + bool criticalAlert, + bool provisional, + bool sound, +}); + class FakeFlutterLocalNotificationsPlugin extends Fake implements FlutterLocalNotificationsPlugin { InitializationSettings? initializationSettings; DidReceiveNotificationResponseCallback? onDidReceiveNotificationResponse; diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 889aca20fb..1032eb7ebf 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:checks/checks.dart'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; @@ -117,14 +118,21 @@ void main() { connection = store.connection as FakeApiConnection; } - void checkLastRequest({required String token}) { + void checkLastRequestApns({required String token, required String appid}) { + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/apns_device_token') + ..bodyFields.deepEquals({'token': token, 'appid': appid}); + } + + void checkLastRequestFcm({required String token}) { check(connection.lastRequest).isA() ..method.equals('POST') ..url.path.equals('/api/v1/users/me/android_gcm_reg_id') ..bodyFields.deepEquals({'token': token}); } - test('token already known', () async { + testAndroidIos('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.) @@ -137,16 +145,22 @@ void main() { 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'); + if (defaultTargetPlatform == TargetPlatform.android) { + checkLastRequestFcm(token: '012abc'); + } else { + checkLastRequestApns(token: '012abc', appid: 'com.zulip.flutter'); + } + + if (defaultTargetPlatform == TargetPlatform.android) { + // If the token changes, send it again. + testBinding.firebaseMessaging.setToken('456def'); + connection.prepare(json: {}); + await null; // Run microtasks. TODO use FakeAsync for these tests. + checkLastRequestFcm(token: '456def'); + } }); - test('token initially unknown', () async { + testAndroidIos('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); @@ -170,13 +184,19 @@ void main() { // 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'); + if (defaultTargetPlatform == TargetPlatform.android) { + checkLastRequestFcm(token: '012abc'); + } else { + checkLastRequestApns(token: '012abc', appid: 'com.zulip.flutter'); + } + + if (defaultTargetPlatform == TargetPlatform.android) { + // 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. + checkLastRequestFcm(token: '456def'); + } }); }); @@ -239,3 +259,13 @@ class LoadingTestGlobalStore extends TestGlobalStore { return completer.future; } } + +void testAndroidIos(String description, FutureOr Function() body) { + test('$description (Android)', body); + test('$description (iOS)', () async { + final origTargetPlatform = debugDefaultTargetPlatformOverride; + addTearDown(() => debugDefaultTargetPlatformOverride = origTargetPlatform); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await body(); + }); +} diff --git a/test/notifications_test.dart b/test/notifications_test.dart index 71692cc72c..e9281b8c04 100644 --- a/test/notifications_test.dart +++ b/test/notifications_test.dart @@ -81,6 +81,14 @@ void main() { await NotificationService.instance.start(); } + group('permissions', () { + testWidgets('on iOS request permission', (tester) async { + await init(); + check(testBinding.firebaseMessaging.takeRequestPermissionCalls()) + .length.equals(1); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); + }); + group('NotificationChannelManager', () { test('smoke', () async { await init();