Skip to content

notif: Support iOS! #409

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
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 = "<group>"; };
3752899A2AF472D400475D9C /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -68,7 +69,6 @@
4541FE48FEFD549440B32652 /* Pods-Runner.release.xcconfig */,
7A94C3F14B90E7FC7BC4B082 /* Pods-Runner.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
Expand Down Expand Up @@ -105,6 +105,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
3752899A2AF472D400475D9C /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
25 changes: 13 additions & 12 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
Expand All @@ -26,6 +28,17 @@
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>By allowing camera access, you can take photos and send them in Zulip messages.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose photos from your library and send them in Zulip messages.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
Expand All @@ -45,17 +58,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>NSCameraUsageDescription</key>
<string>By allowing camera access, you can take photos and send them in Zulip messages.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose photos from your library and send them in Zulip messages.</string>
</dict>
</plist>
8 changes: 8 additions & 0 deletions ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
40 changes: 35 additions & 5 deletions lib/firebase_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// TODO: Cut out Firebase for APNs and use a thinner platform-API binding.

Oh good, I think this might be wise to do eventually. 🙂

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
Expand All @@ -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';
}
23 changes: 5 additions & 18 deletions lib/model/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -92,7 +91,8 @@ abstract class ZulipBinding {
/// Initialize Firebase, to use for notifications.
///
/// This wraps [firebase_core.Firebase.initializeApp].
Future<void> firebaseInitializeApp();
Future<void> firebaseInitializeApp({
required firebase_core.FirebaseOptions options});

/// Wraps [firebase_messaging.FirebaseMessaging.instance].
firebase_messaging.FirebaseMessaging get firebaseMessaging;
Expand Down Expand Up @@ -174,22 +174,9 @@ class LiveZulipBinding extends ZulipBinding {
}

@override
Future<void> 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<void> firebaseInitializeApp({
required firebase_core.FirebaseOptions options}) {
return firebase_core.Firebase.initializeApp(options: options);
}

@override
Expand Down
5 changes: 1 addition & 4 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> registerNotificationToken() async {
Expand All @@ -557,6 +554,6 @@ class LivePerAccountStore extends PerAccountStore {
Future<void> _registerNotificationToken() async {
final token = NotificationService.instance.token.value;
if (token == null) return;
await registerFcmToken(connection, token: token);
await NotificationService.registerToken(connection, token: token);
}
}
92 changes: 75 additions & 17 deletions lib/notifications.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,33 +60,70 @@ class NotificationService {
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)

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<void> _getToken() async {
Future<void> _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<void> _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
Expand All @@ -95,6 +136,23 @@ class NotificationService {
token.value = value;
}

static Future<void> 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);
Expand Down
Loading