From 4bbf87d272c47dfff5bc5b03635ce9ff53998541 Mon Sep 17 00:00:00 2001 From: Jonathan Almeida Date: Fri, 28 Feb 2020 17:09:06 -0500 Subject: [PATCH] Closes #6730: Lazily initialize account manager on new push message --- .../org/mozilla/fenix/FenixApplication.kt | 10 +- .../fenix/components/BackgroundServices.kt | 34 +---- .../mozilla/fenix/components/Components.kt | 2 + .../java/org/mozilla/fenix/components/Push.kt | 39 ++++++ .../mozilla/fenix/push/FirebasePushService.kt | 62 +++++++++ .../mozilla/fenix/push/PushFxaIntegration.kt | 127 ++++++++++++++++++ .../components/BackgroundServicesTest.kt | 7 +- 7 files changed, 240 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/components/Push.kt create mode 100644 app/src/main/java/org/mozilla/fenix/push/FirebasePushService.kt create mode 100644 app/src/main/java/org/mozilla/fenix/push/PushFxaIntegration.kt diff --git a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt index ef78b408e..80576eff0 100644 --- a/app/src/main/java/org/mozilla/fenix/FenixApplication.kt +++ b/app/src/main/java/org/mozilla/fenix/FenixApplication.kt @@ -36,6 +36,7 @@ import mozilla.components.support.webextensions.WebExtensionSupport import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.metrics.MetricServiceType import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.push.PushFxaIntegration import org.mozilla.fenix.session.NotificationSessionObserver import org.mozilla.fenix.session.PerformanceActivityLifecycleCallbacks import org.mozilla.fenix.session.VisibilityLifecycleCallback @@ -175,14 +176,17 @@ open class FenixApplication : LocaleAwareApplication() { // Sets the PushFeature as the singleton instance for push messages to go to. // We need the push feature setup here to deliver messages in the case where the service // starts up the app first. - components.backgroundServices.push?.let { autoPushFeature -> + components.push.feature?.let { Logger.info("AutoPushFeature is configured, initializing it...") // Install the AutoPush singleton to receive messages. - PushProcessor.install(autoPushFeature) + PushProcessor.install(it) + + // Perform a one-time initialization of the account manager if a message is received. + PushFxaIntegration(it, lazy { components.backgroundServices.accountManager }).launch() // Initialize the service. This could potentially be done in a coroutine in the future. - autoPushFeature.initialize() + it.initialize() } } diff --git a/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt b/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt index 0e9b8a2a9..e072fffc4 100644 --- a/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt +++ b/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt @@ -17,8 +17,6 @@ import mozilla.components.concept.sync.DeviceType import mozilla.components.concept.sync.OAuthAccount import mozilla.components.feature.accounts.push.FxaPushSupportFeature import mozilla.components.feature.accounts.push.SendTabFeature -import mozilla.components.feature.push.AutoPushFeature -import mozilla.components.feature.push.PushConfig import mozilla.components.lib.crash.CrashReporter import mozilla.components.service.fxa.DeviceConfig import mozilla.components.service.fxa.ServerConfig @@ -30,7 +28,6 @@ import mozilla.components.service.fxa.manager.SCOPE_SYNC import mozilla.components.service.fxa.manager.SyncEnginesStorage import mozilla.components.service.fxa.sync.GlobalSyncableStoreProvider import mozilla.components.service.sync.logins.SyncableLoginsStorage -import mozilla.components.support.base.log.logger.Logger import org.mozilla.fenix.Config import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.R @@ -47,6 +44,7 @@ import org.mozilla.fenix.test.Mockable @Mockable class BackgroundServices( private val context: Context, + private val push: Push, crashReporter: CrashReporter, historyStorage: PlacesHistoryStorage, bookmarkStorage: PlacesBookmarksStorage, @@ -86,10 +84,6 @@ class BackgroundServices( syncPeriodInMinutes = 240L) // four hours } - private val pushService by lazy { FirebasePushService() } - - val push by lazy { makePushConfig()?.let { makePush(it) } } - init { // Make the "history", "bookmark", and "passwords" stores accessible to workers spawned by the sync manager. GlobalSyncableStoreProvider.configureStore(SyncEngine.History to historyStorage) @@ -106,30 +100,6 @@ class BackgroundServices( val accountManager = makeAccountManager(context, serverConfig, deviceConfig, syncConfig) - @VisibleForTesting(otherwise = PRIVATE) - fun makePush(pushConfig: PushConfig): AutoPushFeature { - return AutoPushFeature( - context = context, - service = pushService, - config = pushConfig - ) - } - - @VisibleForTesting(otherwise = PRIVATE) - fun makePushConfig(): PushConfig? { - val logger = Logger("PushConfig") - val projectIdKey = context.getString(R.string.pref_key_push_project_id) - val resId = context.resources.getIdentifier(projectIdKey, "string", context.packageName) - if (resId == 0) { - logger.warn("No firebase configuration found; cannot support push service.") - return null - } - - logger.debug("Creating push configuration for autopush.") - val projectId = context.resources.getString(resId) - return PushConfig(projectId) - } - @VisibleForTesting(otherwise = PRIVATE) fun makeAccountManager( context: Context, @@ -166,7 +136,7 @@ class BackgroundServices( accountManager.register(accountAbnormalities) // Enable push if it's configured. - push?.let { autoPushFeature -> + push.feature?.let { autoPushFeature -> FxaPushSupportFeature(context, accountManager, autoPushFeature) } diff --git a/app/src/main/java/org/mozilla/fenix/components/Components.kt b/app/src/main/java/org/mozilla/fenix/components/Components.kt index 3115d556f..f0414ef85 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Components.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -26,6 +26,7 @@ class Components(private val context: Context) { val backgroundServices by lazy { BackgroundServices( context, + push, analytics.crashReporter, core.historyStorage, core.bookmarksStorage, @@ -78,4 +79,5 @@ class Components(private val context: Context) { val clipboardHandler by lazy { ClipboardHandler(context) } val migrationStore by lazy { MigrationStore() } val performance by lazy { PerformanceComponent() } + val push by lazy { Push(context) } } diff --git a/app/src/main/java/org/mozilla/fenix/components/Push.kt b/app/src/main/java/org/mozilla/fenix/components/Push.kt new file mode 100644 index 000000000..16b417bee --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/Push.kt @@ -0,0 +1,39 @@ +package org.mozilla.fenix.components + +import android.content.Context +import mozilla.components.feature.push.AutoPushFeature +import mozilla.components.feature.push.PushConfig +import mozilla.components.support.base.log.logger.Logger +import org.mozilla.fenix.R + +/** + * Component group for push services. These components use services that strongly depend on + * push messaging (e.g. WebPush, SendTab). + */ +class Push(context: Context) { + val feature by lazy { + pushConfig?.let { config -> + AutoPushFeature( + context = context, + service = pushService, + config = config + ) + } + } + + private val pushConfig: PushConfig? by lazy { + val logger = Logger("PushConfig") + val projectIdKey = context.getString(R.string.pref_key_push_project_id) + val resId = context.resources.getIdentifier(projectIdKey, "string", context.packageName) + if (resId == 0) { + logger.warn("No firebase configuration found; cannot support push service.") + return@lazy null + } + + logger.debug("Creating push configuration for autopush.") + val projectId = context.resources.getString(resId) + PushConfig(projectId) + } + + private val pushService by lazy { FirebasePushService() } +} diff --git a/app/src/main/java/org/mozilla/fenix/push/FirebasePushService.kt b/app/src/main/java/org/mozilla/fenix/push/FirebasePushService.kt new file mode 100644 index 000000000..df990ad31 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/push/FirebasePushService.kt @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.push + +import android.annotation.SuppressLint +import com.google.firebase.messaging.RemoteMessage +import com.google.firebase.messaging.FirebaseMessagingService +import com.leanplum.LeanplumPushFirebaseMessagingService +import mozilla.components.concept.push.PushService +import mozilla.components.lib.push.firebase.AbstractFirebasePushService +import mozilla.components.feature.push.AutoPushFeature + +/** + * A wrapper class that only exists to delegate to [FirebaseMessagingService] instances. + * + * Implementation notes: + * + * This was a doozy... + * + * With Firebase Cloud Messaging, we've been given some tight constraints in order to get this to + * work: + * - We want to have multiple FCM message receivers for AutoPush and LeanPlum (for now), however + * there can only be one registered [FirebaseMessagingService] in the AndroidManifest. + * - The [LeanplumPushFirebaseMessagingService] does not function as expected unless it's the + * inherited service that receives the messages. + * - The [AutoPushService] is not strongly tied to being the inherited service, but the + * [AutoPushFeature] requires a reference to the push instance as a [PushService]. + * + * We tried creating an empty [FirebaseMessagingService] that can hold a list of the services + * for delegating, but the [LeanplumPushFirebaseMessagingService] tries to get a reference to the + * Application Context, however,since the FCM service runs in a background process that gives a + * nullptr. Within LeanPlum, this is something that is probably provided internally. + * + * We tried to pass in an instance of the [AbstractFirebasePushService] to [FirebasePushService] + * through the constructor and delegate the implementation of a [PushService] to that, but alas, + * the service requires you to have an empty default constructor in order for the OS to do the + * initialization. For this reason, we created a singleton instance of the AutoPush instance since + * that lets us easily delegate the implementation to that, as well as make invocations when FCM + * receives new messages. + */ +class FirebasePushService : LeanplumPushFirebaseMessagingService(), + PushService by AutoPushService { + + override fun onNewToken(newToken: String) { + AutoPushService.onNewToken(newToken) + super.onNewToken(newToken) + } + + override fun onMessageReceived(remoteMessage: RemoteMessage?) { + AutoPushService.onMessageReceived(remoteMessage) + super.onMessageReceived(remoteMessage) + } +} + +/** + * A singleton instance of the FirebasePushService needed for communicating between FCM and the + * [AutoPushFeature]. + */ +@SuppressLint("MissingFirebaseInstanceTokenRefresh") // Implemented internally. +object AutoPushService : AbstractFirebasePushService() diff --git a/app/src/main/java/org/mozilla/fenix/push/PushFxaIntegration.kt b/app/src/main/java/org/mozilla/fenix/push/PushFxaIntegration.kt new file mode 100644 index 000000000..3630eed8c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/push/PushFxaIntegration.kt @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.push + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.concept.sync.AccountObserver +import mozilla.components.concept.sync.AuthType +import mozilla.components.concept.sync.OAuthAccount +import mozilla.components.feature.accounts.push.FxaPushSupportFeature +import mozilla.components.feature.accounts.push.SendTabFeature +import mozilla.components.feature.push.AutoPushFeature +import mozilla.components.feature.push.PushScope +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.fxa.manager.ext.withConstellation +import org.mozilla.fenix.components.BackgroundServices +import org.mozilla.fenix.components.Push + +/** + * A lazy initializer for FxaAccountManager if it isn't already initialized. + * + * Implementation notes: For push notifications, we need to initialize the service on + * Application#onCreate as soon as possible in order to receive messages. These are then decrypted + * and the observers of the push feature are notified. + * + * One of our observers is [FxaAccountManager] that needs to know about messages like Send Tab, + * new account logins, etc. This however comes at the cost of having the account manager + * initialized and observing the push feature when it initializes (which once again happens on + * application create) - the total cost of startup time now is additive for the both of them. + * + * What this integration class aims to do, is to observe the push feature immediately in order to act + * as a (temporary) delegate, and when we see a push message from FxA, only then we should + * initialize and deliver the message. + * + * Once FxaAccountManager is initialized, we no longer need this integration as there already are + * existing features to support these feature requirements, so we safely unregister ourselves. + * See: [FxaPushSupportFeature] and [SendTabFeature]. + * + * A solution that we considered was to pass in [BackgroundServices] to the [Push] class + * and lazily invoke the account manager - that lead to a cyclic dependency of initialization since + * [BackgroundServices] also depends on [Push] directly for observing messages via the account-based + * features. + * + * Another solution was to create a message buffer to queue up the messages until the account could + * consume them - this added the complexity of maintaining a buffer, the possibility of flooding the + * buffer, and delaying the delivery of high importance messages like Send Tab which are required to + * be processed immediately. + * + * Our final solution ended up being more concise that the above options that met all our required + * assurances, and most importantly, maintainable. + */ +class PushFxaIntegration( + private val pushFeature: AutoPushFeature, + lazyAccountManager: Lazy +) { + private val observer = + OneTimePushMessageObserver( + lazyAccountManager, + pushFeature + ) + + /** + * Starts the observer. + * + * This should be done before or as soon as push is initialized. + */ + fun launch() { + pushFeature.register(observer) + } +} + +/** + * Observes push messages from [AutoPushFeature], then initializes [FxaAccountManager] if it isn't + * already. + */ +internal class OneTimePushMessageObserver( + private val lazyAccountManager: Lazy, + private val pushFeature: AutoPushFeature +) : AutoPushFeature.Observer { + override fun onMessageReceived(scope: PushScope, message: ByteArray?) { + + // Ignore empty push messages. + val rawBytes = message ?: return + + // If the push scope has the FxA prefix, we know this is for us. + if (scope.contains(FxaPushSupportFeature.PUSH_SCOPE_PREFIX)) { + + // If we aren't initialized, then we should do the initialization and message delivery. + if (!lazyAccountManager.isInitialized()) { + + CoroutineScope(Dispatchers.Main).launch { + val fxaObserver = OneTimeMessageDeliveryObserver(lazyAccountManager, rawBytes) + + // Start observing the account manager, so that we can deliver our message + // only when we are authenticated and are capable of processing it. + lazyAccountManager.value.register(fxaObserver) + } + } + + // Remove ourselves when we're done. + pushFeature.unregister(this) + } + } +} + +/** + * Waits for the [FxaAccountManager] to authenticate itself in order to deliver the [message], then + * unregisters itself once complete. + */ +internal class OneTimeMessageDeliveryObserver( + private val lazyAccount: Lazy, + private val message: ByteArray +) : AccountObserver { + override fun onAuthenticated( + account: OAuthAccount, + authType: AuthType + ) { + lazyAccount.value.withConstellation { + it.processRawEventAsync(String(message)) + } + + lazyAccount.value.unregister(this) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/components/BackgroundServicesTest.kt b/app/src/test/java/org/mozilla/fenix/components/BackgroundServicesTest.kt index 500c68a3c..a18a9e250 100644 --- a/app/src/test/java/org/mozilla/fenix/components/BackgroundServicesTest.kt +++ b/app/src/test/java/org/mozilla/fenix/components/BackgroundServicesTest.kt @@ -13,8 +13,6 @@ import io.mockk.verify import mozilla.components.concept.sync.AccountObserver import mozilla.components.concept.sync.AuthType import mozilla.components.concept.sync.OAuthAccount -import mozilla.components.feature.push.AutoPushFeature -import mozilla.components.feature.push.PushConfig import mozilla.components.service.fxa.DeviceConfig import mozilla.components.service.fxa.ServerConfig import mozilla.components.service.fxa.SyncConfig @@ -27,16 +25,13 @@ import org.mozilla.fenix.components.metrics.MetricController class BackgroundServicesTest { class TestableBackgroundServices( val context: Context - ) : BackgroundServices(context, mockk(), mockk(), mockk(), mockk()) { + ) : BackgroundServices(context, mockk(), mockk(), mockk(), mockk(), mockk()) { override fun makeAccountManager( context: Context, serverConfig: ServerConfig, deviceConfig: DeviceConfig, syncConfig: SyncConfig? ) = mockk(relaxed = true) - - override fun makePushConfig() = mockk(relaxed = true) - override fun makePush(pushConfig: PushConfig) = mockk(relaxed = true) } @Test