Skip to content
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

notif: Get token on Android, and send to server #325

Merged
merged 7 commits into from
Oct 25, 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
85 changes: 85 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,73 @@ PODS:
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (10.15.0):
- FirebaseCore (= 10.15.0)
- Firebase/Messaging (10.15.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 10.15.0)
- firebase_core (2.17.0):
- Firebase/CoreOnly (= 10.15.0)
- Flutter
- firebase_messaging (14.6.9):
- Firebase/Messaging (= 10.15.0)
- firebase_core
- Flutter
- FirebaseCore (10.15.0):
- FirebaseCoreInternal (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Logger (~> 7.8)
- FirebaseCoreInternal (10.16.0):
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseInstallations (10.16.0):
- FirebaseCore (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- PromisesObjC (~> 2.1)
- FirebaseMessaging (10.15.0):
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- GoogleDataTransport (~> 9.2)
- GoogleUtilities/AppDelegateSwizzler (~> 7.8)
- GoogleUtilities/Environment (~> 7.8)
- GoogleUtilities/Reachability (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- nanopb (< 2.30910.0, >= 2.30908.0)
- Flutter (1.0.0)
- GoogleDataTransport (9.2.5):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30910.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/AppDelegateSwizzler (7.11.5):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Environment (7.11.5):
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.11.5):
- GoogleUtilities/Environment
- GoogleUtilities/Network (7.11.5):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.11.5)"
- GoogleUtilities/Reachability (7.11.5):
- GoogleUtilities/Logger
- GoogleUtilities/UserDefaults (7.11.5):
- GoogleUtilities/Logger
- image_picker_ios (0.0.1):
- Flutter
- nanopb (2.30909.0):
- nanopb/decode (= 2.30909.0)
- nanopb/encode (= 2.30909.0)
- nanopb/decode (2.30909.0)
- nanopb/encode (2.30909.0)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- PromisesObjC (2.3.1)
- SDWebImage (5.15.5):
- SDWebImage/Core (= 5.15.5)
- SDWebImage/Core (5.15.5)
Expand Down Expand Up @@ -73,6 +132,8 @@ DEPENDENCIES:
- app_settings (from `.symlinks/plugins/app_settings/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
Expand All @@ -85,6 +146,15 @@ SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- SDWebImage
- sqlite3
- SwiftyGif
Expand All @@ -96,6 +166,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
:path: Flutter
image_picker_ios:
Expand All @@ -117,10 +191,21 @@ SPEC CHECKSUMS:
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de
Firebase: 66043bd4579e5b73811f96829c694c7af8d67435
firebase_core: 28e84c2a4fcf6a50ef83f47b145ded8c1fa331e4
firebase_messaging: 91ec967913a5d144f951b3c3ac17a57796d38735
FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e
FirebaseCoreInternal: 26233f705cc4531236818a07ac84d20c333e505a
FirebaseInstallations: b822f91a61f7d1ba763e5ccc9d4f2e6f2ed3b3ee
FirebaseMessaging: 0c0ae1eb722ef0c07f7801e5ded8dccd1357d6d4
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2
GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4
SDWebImage: fd7e1a22f00303e058058278639bf6196ee431fe
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
sqlite3: e0a0623a33a20a47cb5921552aebc6e9e437dc91
Expand Down
30 changes: 30 additions & 0 deletions lib/api/route/notifications.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@

import '../core.dart';

// This endpoint is undocumented. Compare zulip-mobile:
// https://github.com/zulip/zulip-mobile/blob/86d94fa89/src/api/notifications/savePushToken.js
// and see the server implementation:
// https://github.com/zulip/zulip/blob/34ceafadd/zproject/urls.py#L383
// https://github.com/zulip/zulip/blob/34ceafadd/zerver/views/push_notifications.py#L47
Future<void> registerFcmToken(ApiConnection connection, {
required String token,
}) {
return connection.post('registerFcmToken', (_) {}, 'users/me/android_gcm_reg_id', {
'token': RawParameter(token),
});
}

// This endpoint is undocumented. Compare zulip-mobile:
// https://github.com/zulip/zulip-mobile/blob/86d94fa89/src/api/notifications/savePushToken.js
// and see the server implementation:
// https://github.com/zulip/zulip/blob/34ceafadd/zproject/urls.py#L378-L381
// https://github.com/zulip/zulip/blob/34ceafadd/zerver/views/push_notifications.py#L34
Future<void> registerApnsToken(ApiConnection connection, {
required String token,
String? appid,
}) {
return connection.post('registerApnsToken', (_) {}, 'users/me/apns_device_token', {
'token': RawParameter(token),
if (appid != null) 'appid': RawParameter(appid),
});
}
36 changes: 36 additions & 0 deletions lib/firebase_options.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'package:firebase_core/firebase_core.dart';

/// Configuration used for receiving notifications on Android.
///
/// This set of options is used for receiving notifications
/// through the Zulip notification bouncer service:
/// https://zulip.readthedocs.io/en/latest/production/mobile-push-notifications.html
///
/// These values represent public identifiers for that service
/// as an application registered with the relevant Google service:
/// we deliver Android notifications through Firebase Cloud Messaging (FCM).
/// The values are derived from a `google-services.json` file.
/// For details, see:
/// https://developers.google.com/android/guides/google-services-plugin#processing_the_json_file
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',

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
// included in published builds of client applications, and therefore
// fundamentally public. See docs:
// https://cloud.google.com/docs/authentication/api-keys
//
// 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.
//
// 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',
);
3 changes: 3 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'licenses.dart';
import 'log.dart';
import 'model/binding.dart';
import 'notifications.dart';
import 'widgets/app.dart';

void main() {
Expand All @@ -13,5 +14,7 @@ void main() {
}());
LicenseRegistry.addLicense(additionalLicenses);
LiveZulipBinding.ensureInitialized();
WidgetsFlutterBinding.ensureInitialized();
NotificationService.instance.start();
runApp(const ZulipApp());
}
46 changes: 46 additions & 0 deletions lib/model/binding.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import 'package:device_info_plus/device_info_plus.dart' as device_info_plus;
import 'package:firebase_core/firebase_core.dart' as firebase_core;
import 'package:firebase_messaging/firebase_messaging.dart' as firebase_messaging;
import 'package:flutter/foundation.dart';
import 'package:url_launcher/url_launcher.dart' as url_launcher;

import '../firebase_options.dart';
import '../widgets/store.dart';
import 'store.dart';

/// Alias for [url_launcher.LaunchMode].
typedef UrlLaunchMode = url_launcher.LaunchMode;

/// Alias for [firebase_messaging.RemoteMessage].
typedef FirebaseRemoteMessage = firebase_messaging.RemoteMessage;

/// A singleton service providing the app's data and use of Flutter plugins.
///
/// Only one instance will be constructed in the lifetime of the app,
Expand Down Expand Up @@ -81,6 +87,17 @@ abstract class ZulipBinding {
///
/// This wraps [device_info_plus.DeviceInfoPlugin.deviceInfo].
Future<BaseDeviceInfo> deviceInfo();

/// Initialize Firebase, to use for notifications.
///
/// This wraps [firebase_core.Firebase.initializeApp].
Future<void> firebaseInitializeApp();

/// Wraps [firebase_messaging.FirebaseMessaging.instance].
firebase_messaging.FirebaseMessaging get firebaseMessaging;

/// Wraps [firebase_messaging.FirebaseMessaging.onMessage].
Stream<firebase_messaging.RemoteMessage> get firebaseMessagingOnMessage;
}

/// Like [device_info_plus.BaseDeviceInfo], but without things we don't use.
Expand Down Expand Up @@ -148,4 +165,33 @@ class LiveZulipBinding extends ZulipBinding {
_ => throw UnimplementedError(),
};
}

@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();
}
}

@override
firebase_messaging.FirebaseMessaging get firebaseMessaging {
return firebase_messaging.FirebaseMessaging.instance;
}

@override
Stream<firebase_messaging.RemoteMessage> get firebaseMessagingOnMessage {
return firebase_messaging.FirebaseMessaging.onMessage;
}
}
28 changes: 28 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ 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';
import 'database.dart';
import 'message_list.dart';
Expand Down Expand Up @@ -425,6 +427,8 @@ class LiveGlobalStore extends GlobalStore {
}

/// A [PerAccountStore] which polls an event queue to stay up to date.
// TODO decouple "live"ness from polling and registerNotificationToken;
// the latter are made up of testable internal logic, not external integration
class LivePerAccountStore extends PerAccountStore {
LivePerAccountStore.fromInitialSnapshot({
required super.account,
Expand Down Expand Up @@ -458,6 +462,9 @@ class LivePerAccountStore extends PerAccountStore {
initialSnapshot: initialSnapshot,
);
store.poll();
// TODO do registerNotificationToken before registerQueue:
// https://github.com/zulip/zulip-flutter/pull/325#discussion_r1365982807
store.registerNotificationToken();
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does the token-registration call want to go earlier than this, which comes after /register? For some users, /register will regularly take several seconds.

In particular, for the case where the user has already been getting and expecting notifications, if the device token has changed since the last run of the app, we have kind of an urgent task to try to register the new token so we don't unnecessarily drop notifications. (I see zulip-mobile doesn't do this earlier than /register success, but I wonder if that needs to be carried over to zulip-flutter.)

I see that we don't have our hands on a LivePerAccountStore (to call its registerNotificationToken) until after /register succeeds…hmm. But as a comment on LivePerAccountStore.load says, we might someday load a LivePerAccountStore from local storage, and that would let us call a LivePerAccountStore.registerNotificationToken sooner than the /register request.

…But actually, does the job of registerNotificationToken really need to wait until we have a LivePerAccountStore? (Does it need to be a method on LivePerAccountStore?) As implemented, it looks like its only dependencies are the NotificationService instance and an ApiConnection. The former is global, and for the latter we already have the value we need before starting the /register request. We may in the future need an ackedPushToken, to condition on whether the new token matches the old. But we can get that from the Account we already have our hands on before starting the /register request.

Copy link
Collaborator

Choose a reason for hiding this comment

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

As implemented, it looks like its only dependencies are […]

I guess it rightly has another dependency: some means of tracking when the listener it adds on NotificationService.instance.token needs to be removed. That's a convenient role for LivePerAccountStore to fill…hmm. I see the "TODO call removeListener on [dispose]" in registerNotificationToken.

Copy link
Member Author

Choose a reason for hiding this comment

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

Does the token-registration call want to go earlier than this, which comes after /register?

Hmm, good thought. I think I basically just copied this aspect from zulip-mobile.

I guess it rightly has another dependency: some means of tracking when the listener it adds on NotificationService.instance.token needs to be removed. That's a convenient role for LivePerAccountStore to fill…hmm. I see the "TODO call removeListener on [dispose]" in registerNotificationToken.

Yeah. But that's really just because on dispose, we may be about to make a new LivePerAccountStore for the same account — so it's a way of preventing duplication. If the listener lived somewhere else that had a different lifetime, then we'd want to cancel it based on that thing's lifetime instead.

I guess there's the complication (which we'll eventually implement) that the user's API key could become invalid, or the user or realm get deactivated. In that case we'll want to tear down whatever has that listener, along with the LivePerAccountStore and/or whatever's doing the polling for events.

…But actually, does the job of registerNotificationToken really need to wait until we have a LivePerAccountStore? (Does it need to be a method on LivePerAccountStore?)

Yeah, I don't totally love having it here. It's here because it seems parallel to the event-polling loop poll; and because this is the most obvious place for the code to live if it's going to be initiated after we get the register response, i.e. from load.

return store;
}

Expand All @@ -479,4 +486,25 @@ class LivePerAccountStore extends PerAccountStore {
}
}
}

/// Send this client's notification token to the server, now and if it changes.
///
/// 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 {
// TODO call removeListener on [dispose]
NotificationService.instance.token.addListener(_registerNotificationToken);
await _registerNotificationToken();
}

Future<void> _registerNotificationToken() async {
final token = NotificationService.instance.token.value;
if (token == null) return;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Ah interesting; I guess this early return doesn't make things fall down if the get-device-token request happens to complete after the await _registerNotificationToken() (above) is called. That's because of this listener we add:

NotificationService.instance.token.addListener(_registerNotificationToken);

With that, it looks like we'll be sure to dispatch the register-with-server request as soon as we have a good result from the get-device-token request, which is good.

(And that behavior is covered in the tests; nice!)

Still, although that race is handled for users' benefit, I wonder if _registerNotificationToken and its public wrapper registerNotificationToken have an unnecessarily misleading return type. I think their callers can't tell by awaiting the Future whether the token registration succeeded, failed (as with an API error), or is still pending. Is that right? Maybe void as a return type is a better match for that behavior.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe void is the right return type given that.

I see that the tests use the Future<void> return type by awaiting the returned Future. Instead of doing that, I wonder if e.g. FakeAsync.elapse could be used instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, this return type actually was void in a draft before I wrote the tests.

Instead of doing that, I wonder if e.g. FakeAsync.elapse could be used instead?

It's a bit tricky because in the "initially unknown" test case, we first want to wait for store.registerNotificationToken to complete whatever it's going to do, then check no request was made, and only after that wait for NotificationService.start to finish, including its getToken call.

I think a fully robust solution probably involves extending TestZulipBinding so that the test can control when getToken finishes.

await registerFcmToken(connection, token: token);
}
}
Loading