diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:34:42 +0000 |
commit | da4c7e7ed675c3bf405668739c3012d140856109 (patch) | |
tree | cdd868dba063fecba609a1d819de271f0d51b23e /mobile/android/android-components/components/feature/push | |
parent | Adding upstream version 125.0.3. (diff) | |
download | firefox-da4c7e7ed675c3bf405668739c3012d140856109.tar.xz firefox-da4c7e7ed675c3bf405668739c3012d140856109.zip |
Adding upstream version 126.0.upstream/126.0
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/feature/push')
9 files changed, 1131 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/feature/push/README.md b/mobile/android/android-components/components/feature/push/README.md new file mode 100644 index 0000000000..292f0e2478 --- /dev/null +++ b/mobile/android/android-components/components/feature/push/README.md @@ -0,0 +1,154 @@ +# [Android Components](../../../README.md) > Feature > Push + +A component that implements push notifications with a supported push service. + +## Usage + +Add a supported push service for providing the encrypted messages (for example, Firebase Cloud Messaging via `lib-push-firebase`): +```kotlin +class FirebasePush : AbstractFirebasePushService() +``` + +Create a push configuration with the project info and also place the required service's API keys in the project directory: + +```kotlin +PushConfig( + senderId = "push-test-f408f", + serverHost = "updates.push.services.mozilla.com", + serviceType = ServiceType.FCM, + protocol = Protocol.HTTPS +) +``` + +We can then start the AutoPushFeature to get the subscription info and decrypted push message: +```kotlin +val service = FirebasePush() + +val feature = AutoPushFeature( + context = context, + service = pushService, + config = pushConfig +) + +// To start the feature and the service. +feature.initialize() + +// To stop the feature and the service. +feature.shutdown() + +// To receive the subscription info for all the subscription changes. +feature.register(object : AutoPushFeature.Observer { + override fun onSubscriptionChanged(scope: PushScope) { + // Handle subscription info here. + } +}) + +// Subscribe for a unique scope (identifier). +feature.subscribe("push_subscription_scope_id") + +// To receive messages: +feature.register(object : AutoPushFeature.Observer { + override fun onMessageReceived(scope: String, message: ByteArray?) { + // Handle decrypted message here. + } +}) +``` + +### Setting up the dependency + +Use Gradle to download the library from [maven.mozilla.org](https://maven.mozilla.org/) ([Setup repository](../../../README.md#maven-repository)): + +```Groovy +implementation "org.mozilla.components:feature-push:{latest-version}" +``` + +## Implementation Notes + +The features of WebPush/AutoPush are to: +1. Implement the WebPush specification for Mozilla services/products. +2. Provide a bridge between a mobile or desktop client and server app without the server needing to know about ecosystem-specific push providers (FCM/APN/ADM). +3. End-to-end encryption between client app and server app. + +### Key actors & processes +Below is a sequence diagram for the four main processes that take place for push messaging: +1. (First-install) initialization +2. Creating a WebPush subscription +3. Sending WebPush message +4. Un-subscribing (e.g. account log-out) + +* **Client App**: this is your Android device that includes the `AutoPushFeature` that contains the AutoPush rust component to create/delete subscriptions and encrypt/decrypt messages. As well as the`lib-push-firebase` which is the push service bridge. +* **Server App** - the application server that has a web push server implementation. +* **AutoPush** - the server bridge between app servers and their clients. +* **Push Provider** - the platform push service that does the "last-mile" message delivered. + +![generated sequence diagram](assets/autopush-sequence-diagram.png) + +<details> + +<summary>Sequence diagram source code</summary> + +<!-- Github Markdown has support for rendering mermaid graphs; use mermaid.js.org to generate output for the diagram alternatively --> + +```mermaid +sequenceDiagram + participant Device as Client App + participant AutoPush + participant Provider as Push Provider (FCM/APN) + participant Server as Server App + rect rgb(191, 223, 255) + Note over Device,Server: Initialization + Note right of Device: Generate pub-priv keys + Device->>Provider: Request device registration token + Provider-->>Device: Receive device registration token + Device->>AutoPush: Send token + end + rect rgb(191, 223, 255) + Note over Device,Server: Creating a push subscription + Device->>AutoPush: Request subscription endpoint + AutoPush-->>Device: Receive subscription + Device->>Server: Send subscription endpoint + public key + end + rect rgb(191, 223, 255) + Note over Device,Server: Sending WebPush message + Note left of Server: Encrypt message + Server->>AutoPush: Send encrypted message + AutoPush->>Provider: Forward encrypted message + Provider->>Device: Deliver encrypted message + Note right of Device: Decrypt message + end + rect rgb(191, 223, 255) + Note over Device,Server: Un-subscribing (e.g. account log-out) + Device->>AutoPush: Unsubscribe + Device->>Server: Unsubscribe (notify server that the subscription is dead) + end +``` + +</details> + +### Miscellaneous + +Q. Why do we need to verify connections, and what happens when we do? +- Various services may need to communicate with us via push messages. Examples: FxA events (send tab, etc), WebPush (a web app receives a push message from its server). +- To send these push messages, services (FxA, random internet servers talking to their web apps) post an HTTP request to a "push endpoint" maintained by [Mozilla's Autopush service][0]. This push endpoint is specific to its recipient - so one instance of an app may have many endpoints associated with it: one for the current FxA device, a few for web apps, etc. +- Important point here: servers (FxA, services behind web apps, etc.) need to be told about subscription info we get from Autopush. +- Here is where things start to get complicated: client (us) and server (Autopush) may disagree on which channels are associated with the current UAID (remember: our subscriptions are per-channel). Channels may expire (TTL'd) or may be deleted by some server's Cron job if they're unused. For example, if this happens, services that use this subscription info (e.g. FxA servers) to communication with their clients (FxA devices) will fail to deliver push messages. +- So the client needs to be able to find out that this is the case, re-create channel subscriptions on Autopush, and update any dependent services with new subscription info (e.g. update the FxA device record for `PushType.Services`, or notify the JS code with a `pushsubscriptionchanged` event for WebPush). +- The Autopush side of this is `verify_connection` API - we're expected to call this periodically, and that library will compare channel registrations that the server knows about vs those that the client knows about. +- If those are misaligned, we need to re-register affected (or, all?) channels, and notify related services so that they may update their own server-side records. +- For FxA, this means that we need to have an instance of the rust FirefoxAccount object around in order to call `setDevicePushSubscriptionAsync` once we re-generate our push subscription. +- For consumers such as Fenix, easiest way to access that method is via an `account manager`. +- However, neither account object itself, nor the account manager, aren't available from within a Worker. It's possible to "re-hydrate" (instantiate rust object from the locally persisted state) a FirefoxAccount instance, but that's a separate can of worms, and needs to be carefully considered. +- Similarly for WebPush (in the future), we will need to have Gecko around in order to fire `pushsubscriptionchanged` javascript events. + +Q. Where do we find more details about AutoPush? +- The Cloud Services team have [an architecture doc][0] for in-depth details on how the AutoPush server works with clients. + +[0]: https://autopush.readthedocs.io/en/latest/architecture.html + +## License + + 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/ + +[0]: https://github.com/mozilla-services/autopush diff --git a/mobile/android/android-components/components/feature/push/assets/autopush-sequence-diagram.png b/mobile/android/android-components/components/feature/push/assets/autopush-sequence-diagram.png Binary files differnew file mode 100644 index 0000000000..4ffc961d31 --- /dev/null +++ b/mobile/android/android-components/components/feature/push/assets/autopush-sequence-diagram.png diff --git a/mobile/android/android-components/components/feature/push/build.gradle b/mobile/android/android-components/components/feature/push/build.gradle new file mode 100644 index 0000000000..d1003afad1 --- /dev/null +++ b/mobile/android/android-components/components/feature/push/build.gradle @@ -0,0 +1,48 @@ +/* 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/. */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + defaultConfig { + minSdkVersion config.minSdkVersion + compileSdk config.compileSdkVersion + targetSdkVersion config.targetSdkVersion + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + namespace 'mozilla.components.feature.push' +} + + +dependencies { + implementation project(':concept-push') + + implementation ComponentsDependencies.mozilla_appservices_push + + // Remove when the MessageBus is implemented somewhere else. + implementation project(':support-base') + + implementation ComponentsDependencies.kotlin_coroutines + implementation ComponentsDependencies.androidx_work_runtime + implementation ComponentsDependencies.androidx_lifecycle_runtime + + testImplementation project(':support-test') + + testImplementation ComponentsDependencies.androidx_test_core + testImplementation ComponentsDependencies.androidx_test_junit + testImplementation ComponentsDependencies.testing_robolectric + testImplementation ComponentsDependencies.testing_coroutines +} + +apply from: '../../../android-lint.gradle' +apply from: '../../../publish.gradle' +ext.configurePublish(config.componentsGroupId, archivesBaseName, project.ext.description) diff --git a/mobile/android/android-components/components/feature/push/proguard-rules.pro b/mobile/android/android-components/components/feature/push/proguard-rules.pro new file mode 100644 index 0000000000..f1b424510d --- /dev/null +++ b/mobile/android/android-components/components/feature/push/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/mobile/android/android-components/components/feature/push/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/push/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/feature/push/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- 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/. --> +<manifest /> diff --git a/mobile/android/android-components/components/feature/push/src/main/java/mozilla/components/feature/push/AutoPushFeature.kt b/mobile/android/android-components/components/feature/push/src/main/java/mozilla/components/feature/push/AutoPushFeature.kt new file mode 100644 index 0000000000..23aa1eab89 --- /dev/null +++ b/mobile/android/android-components/components/feature/push/src/main/java/mozilla/components/feature/push/AutoPushFeature.kt @@ -0,0 +1,412 @@ +/* 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 mozilla.components.feature.push + +import android.content.Context +import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import mozilla.appservices.push.BridgeType +import mozilla.appservices.push.PushApiException +import mozilla.appservices.push.PushApiException.UaidNotRecognizedException +import mozilla.appservices.push.PushConfiguration +import mozilla.appservices.push.PushHttpProtocol +import mozilla.appservices.push.PushManager +import mozilla.appservices.push.PushManagerInterface +import mozilla.appservices.push.SubscriptionResponse +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.concept.push.PushError +import mozilla.components.concept.push.PushProcessor +import mozilla.components.concept.push.PushService +import mozilla.components.support.base.log.logger.Logger +import mozilla.components.support.base.observer.Observable +import mozilla.components.support.base.observer.ObserverRegistry +import mozilla.components.support.base.utils.NamedThreadFactory +import java.io.File +import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext + +typealias PushScope = String +typealias AppServerKey = String + +/** + * A implementation of a [PushProcessor] that should live as a singleton by being installed + * in the Application's onCreate. It receives messages from a service and forwards them + * to be decrypted and routed. + * + * ```kotlin + * class Application { + * override fun onCreate() { + * val feature = AutoPushFeature(context, service, configuration) + * PushProvider.install(push) + * } + * } + * ``` + * + * Observe for subscription information changes for each registered scope: + * + * ```kotlin + * feature.register(object: AutoPushFeature.Observer { + * override fun onSubscriptionChanged(scope: PushScope) { } + * }) + * + * feature.subscribe("push_subscription_scope_id") + * ``` + * + * You should also observe for push messages: + * + * ```kotlin + * feature.register(object: AutoPushFeature.Observer { + * override fun onMessageReceived(scope: PushScope, message: ByteArray?) { } + * }) + * ``` + * + * @param context the application [Context]. + * @param service A [PushService] bridge that receives the encrypted push messages - eg, Firebase. + * @param config An instance of [PushConfig] to configure the feature. + * @param coroutineContext An instance of [CoroutineContext] used for executing async push tasks. + * @param crashReporter An optional instance of a [CrashReporting]. + */ + +@Suppress("LargeClass") +class AutoPushFeature( + private val context: Context, + private val service: PushService, + val config: PushConfig, + coroutineContext: CoroutineContext = Executors.newSingleThreadExecutor( + NamedThreadFactory("AutoPushFeature"), + ).asCoroutineDispatcher(), + private val crashReporter: CrashReporting? = null, +) : PushProcessor, Observable<AutoPushFeature.Observer> by ObserverRegistry() { + + private val logger = Logger("AutoPushFeature") + + // The preference that stores new registration tokens. + private val prefToken: String? + get() = preferences(context).getString(PREF_TOKEN, null) + + private val coroutineScope = CoroutineScope(coroutineContext) + SupervisorJob() + exceptionHandler { onError(it) } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var connection: PushManagerInterface? = null + + /** + * Starts the push feature and initialization work needed. Also starts the [PushService] to ensure new messages + * come through. + */ + override fun initialize() { + // If we have a token, initialize the rust component on a different thread. + coroutineScope.launch { + if (connection == null) { + val databasePath = File(context.filesDir, DB_NAME).canonicalPath + connection = PushManager( + PushConfiguration( + serverHost = config.serverHost, + httpProtocol = config.protocol.toRustHttpProtocol(), + bridgeType = config.serviceType.toBridgeType(), + senderId = config.senderId, + databasePath = databasePath, + // Default is one request in 24 hours + verifyConnectionRateLimiter = null, + ), + ) + } + prefToken?.let { token -> + logger.debug("Initializing rust component with the cached token.") + connection?.update(token) + verifyActiveSubscriptions() + } + } + // Starts the (FCM) push feature so that we receive messages if the service is not already started (safe call). + service.start(context) + } + + /** + * Un-subscribes from all push message channels and stops periodic verifications. + * + * We do not stop the push service in case there are other consumers are using it as well. The app should + * explicitly stop the service if desired. + * + * This should only be done on an account logout or app data deletion. + */ + override fun shutdown() { + withConnection { + it.unsubscribeAll() + } + } + + /** + * New registration tokens are received and sent to the AutoPush server which also performs subscriptions for + * each push type and notifies the subscribers. + */ + override fun onNewToken(newToken: String) { + val currentConnection = connection + coroutineScope.launch { + logger.info("Received a new registration token from push service.") + + saveToken(context, newToken) + + // Tell the autopush service about it and update subscriptions. + currentConnection?.update(newToken) + verifyActiveSubscriptions() + } + } + + /** + * New encrypted messages received from a supported push messaging service. + */ + override fun onMessageReceived(message: Map<String, String>) { + withConnection { + val decryptResponse = it.decrypt( + payload = message, + ) + logger.info("New push message decrypted.") + notifyObservers { onMessageReceived(decryptResponse.scope, decryptResponse.result.toByteArray()) } + } + } + + override fun onError(error: PushError) { + logger.error("${error.javaClass.simpleName} error: ${error.message}") + + crashReporter?.submitCaughtException(error) + } + + /** + * Subscribes for push notifications and invokes the [onSubscribe] callback with the subscription information. + * + * @param scope The subscription identifier which usually represents the website's URI. + * @param appServerKey An optional key provided by the application server. + * @param onSubscribeError The callback invoked with an [Exception] if the call does not successfully complete. + * @param onSubscribe The callback invoked when a subscription for the [scope] is created. + */ + fun subscribe( + scope: String, + appServerKey: String? = null, + onSubscribeError: (Exception) -> Unit = {}, + onSubscribe: ((AutoPushSubscription) -> Unit) = {}, + ) { + withConnection(errorBlock = { exception -> onSubscribeError(exception) }) { + val sub = it.subscribe(scope, appServerKey ?: "") + onSubscribe(sub.toPushSubscription(scope, appServerKey ?: "")) + } + } + + /** + * Un-subscribes from a valid subscription and invokes the [onUnsubscribe] callback with the result. + * + * @param scope The subscription identifier which usually represents the website's URI. + * @param onUnsubscribeError The callback invoked with an [Exception] if the call does not successfully complete. + * @param onUnsubscribe The callback invoked when a subscription for the [scope] is removed. + */ + fun unsubscribe( + scope: String, + onUnsubscribeError: (Exception) -> Unit = {}, + onUnsubscribe: (Boolean) -> Unit = {}, + ) { + withConnection(errorBlock = { exception -> onUnsubscribeError(exception) }) { + onUnsubscribe(it.unsubscribe(scope)) + } + } + + /** + * Checks if a subscription for the [scope] already exists. + * + * @param scope The subscription identifier which usually represents the website's URI. + * @param appServerKey An optional key provided by the application server. + * @param block The callback invoked when a subscription for the [scope] is found, otherwise null. Note: this will + * not execute on the calls thread. + */ + fun getSubscription( + scope: String, + appServerKey: String? = null, + block: (AutoPushSubscription?) -> Unit, + ) { + withConnection { + block(it.getSubscription(scope)?.toPushSubscription(scope, appServerKey)) + } + } + + /** + * Deletes the FCM registration token locally so that it forces the service to get a new one the + * next time hits it's messaging server. + * XXX - this is suspect - the only caller of this is FxA, and it calls it when the device + * record indicates the end-point is expired. If that's truly necessary, then it will mean + * push never recovers for non-FxA users. If that's not truly necessary, we should remove it! + */ + override fun renewRegistration() { + logger.warn("Forcing FCM registration renewal by deleting our (cached) token.") + + // Remove the cached token we have. + deleteToken(context) + + // Tell the service to delete the token as well, which will trigger a new token to be + // retrieved the next time it hits the server. + service.deleteToken() + + // Starts the service if needed to trigger a new registration. + service.start(context) + } + + /** + * Verifies status (active, expired) of the push subscriptions and then notifies observers. + */ + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun verifyActiveSubscriptions(forceVerify: Boolean = false) { + withConnection { + val subscriptionChanges = it.verifyConnection(forceVerify) + + if (subscriptionChanges.isNotEmpty()) { + logger.info("Subscriptions have changed; notifying observers..") + + subscriptionChanges.forEach { sub -> + notifyObservers { onSubscriptionChanged(sub.scope) } + } + } else { + logger.info("No change to subscriptions. Doing nothing.") + } + } + } + + private fun saveToken(context: Context, value: String) { + preferences(context).edit().putString(PREF_TOKEN, value).apply() + } + + private fun deleteToken(context: Context) { + preferences(context).edit().remove(PREF_TOKEN).apply() + } + + private fun preferences(context: Context): SharedPreferences = + context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE) + + /** + * Observers that want to receive updates for new subscriptions and messages. + */ + interface Observer { + + /** + * A subscription for the scope is available. + */ + fun onSubscriptionChanged(scope: PushScope) = Unit + + /** + * A messaged has been received for the [scope]. + */ + fun onMessageReceived(scope: PushScope, message: ByteArray?) = Unit + } + + private fun exceptionHandler(onError: (PushError) -> Unit) = CoroutineExceptionHandler { _, e -> + when (e) { + is UaidNotRecognizedException, + -> onError(PushError.Rust(e, e.message.orEmpty())) + else -> logger.warn("Internal error occurred in AutoPushFeature.", e) + } + } + + companion object { + internal const val PREFERENCE_NAME = "mozac_feature_push" + internal const val PREF_TOKEN = "token" + internal const val DB_NAME = "push.sqlite" + } + + private fun withConnection(errorBlock: (Exception) -> Unit = {}, block: (PushManagerInterface) -> Unit) { + val currentConnection = connection + currentConnection?.let { + coroutineScope.launch { + try { + block(it) + } catch (e: PushApiException) { + errorBlock(e) + + // rethrow + throw e + } + } + } + } +} + +/** + * Supported push services. This are currently limited to Firebase Cloud Messaging and + * (previously) Amazon Device Messaging. + */ +enum class ServiceType { + FCM, +} + +/** + * Supported network protocols. + */ +enum class Protocol { + HTTP, + HTTPS, +} + +/** + * The subscription information from AutoPush that can be used to send push messages to other devices. + */ +data class AutoPushSubscription( + val scope: PushScope, + val endpoint: String, + val publicKey: String, + val authKey: String, + val appServerKey: String?, +) + +/** + * Configuration object for initializing the Push Manager with an AutoPush server. + * + * @param senderId The project identifier set by the server. Contact your server ops team to know what value to set. + * @param serverHost The sync server address. + * @param protocol The socket protocol to use when communicating with the server. + * @param serviceType The push services that the AutoPush server supports. + * @param disableRateLimit A flag to disable our rate-limit logic. This is useful when debugging. + */ +data class PushConfig( + val senderId: String, + val serverHost: String = "updates.push.services.mozilla.com", + val protocol: Protocol = Protocol.HTTPS, + val serviceType: ServiceType = ServiceType.FCM, + val disableRateLimit: Boolean = false, +) + +/** + * Helper function to get the corresponding support [BridgeType] from the support set. + */ +@VisibleForTesting +internal fun ServiceType.toBridgeType() = when (this) { + ServiceType.FCM -> BridgeType.FCM +} + +/** + * A helper to convert the internal data class. + */ +private fun Protocol.toRustHttpProtocol(): PushHttpProtocol { + return when (this) { + Protocol.HTTPS -> PushHttpProtocol.HTTPS + Protocol.HTTP -> PushHttpProtocol.HTTP + } +} + +/** + * A helper to convert the internal data class. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +fun SubscriptionResponse.toPushSubscription( + scope: String, + appServerKey: AppServerKey? = null, +): AutoPushSubscription { + return AutoPushSubscription( + scope = scope, + endpoint = subscriptionInfo.endpoint, + authKey = subscriptionInfo.keys.auth, + publicKey = subscriptionInfo.keys.p256dh, + appServerKey = appServerKey, + ) +} diff --git a/mobile/android/android-components/components/feature/push/src/test/java/mozilla/components/feature/push/AutoPushFeatureTest.kt b/mobile/android/android-components/components/feature/push/src/test/java/mozilla/components/feature/push/AutoPushFeatureTest.kt new file mode 100644 index 0000000000..f6d49600f6 --- /dev/null +++ b/mobile/android/android-components/components/feature/push/src/test/java/mozilla/components/feature/push/AutoPushFeatureTest.kt @@ -0,0 +1,489 @@ +/* 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 mozilla.components.feature.push + +import android.content.Context +import android.content.SharedPreferences +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.appservices.push.BridgeType +import mozilla.appservices.push.DecryptResponse +import mozilla.appservices.push.KeyInfo +import mozilla.appservices.push.PushApiException +import mozilla.appservices.push.PushManagerInterface +import mozilla.appservices.push.PushSubscriptionChanged +import mozilla.appservices.push.SubscriptionInfo +import mozilla.appservices.push.SubscriptionResponse +import mozilla.components.concept.base.crash.CrashReporting +import mozilla.components.concept.push.PushError +import mozilla.components.concept.push.PushService +import mozilla.components.feature.push.AutoPushFeature.Companion.PREFERENCE_NAME +import mozilla.components.feature.push.AutoPushFeature.Companion.PREF_TOKEN +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.nullable +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import mozilla.components.support.test.whenever +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyString +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class AutoPushFeatureTest { + + @get:Rule + val coroutinesTestRule = MainCoroutineRule() + + private val connection: PushManagerInterface = mock() + + @Test + fun `initialize starts push service`() { + val service: PushService = mock() + val config = PushConfig("push-test") + val feature = AutoPushFeature(testContext, service, config) + feature.connection = connection + + feature.initialize() + + verify(service).start(testContext) + + verifyNoMoreInteractions(service) + } + + @Test + fun `updateToken not called if no token in prefs`() = runTestOnMain { + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + feature.connection = connection + + verify(connection, never()).update(anyString()) + } + + @Test + fun `updateToken called if token is in prefs`() = runTestOnMain { + preference(testContext).edit().putString(PREF_TOKEN, "token").apply() + + val feature = AutoPushFeature( + testContext, + mock(), + mock(), + coroutineContext = coroutineContext, + ) + + feature.connection = connection + + feature.initialize() + + verify(connection).update("token") + } + + @Test + fun `shutdown stops service and unsubscribes all`() = runTestOnMain { + val service: PushService = mock() + + AutoPushFeature(testContext, service, mock(), coroutineContext).also { + it.connection = connection + it.shutdown() + } + + verify(connection).unsubscribeAll() + } + + @Test + fun `onNewToken updates connection and saves pref`() = runTestOnMain { + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + feature.connection = connection + + whenever(connection.subscribe(anyString(), nullable())).thenReturn(mock()) + + feature.onNewToken("token") + + verify(connection).update("token") + + val pref = preference(testContext).getString(PREF_TOKEN, null) + assertNotNull(pref) + assertEquals("token", pref) + } + + @Test + fun `onMessageReceived decrypts message and notifies observers`() = runTestOnMain { + val encryptedMessage: Map<String, String> = mock() + val owner: LifecycleOwner = mock() + val lifecycle: Lifecycle = mock() + val observer: AutoPushFeature.Observer = mock() + whenever(owner.lifecycle).thenReturn(lifecycle) + whenever(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED) + whenever(connection.decrypt(any())) + .thenReturn(null) // If we get null, we shouldn't notify observers. + .thenReturn(DecryptResponse(result = "test".toByteArray().asList(), scope = "testScope")) + + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + feature.connection = connection + feature.register(observer) + + feature.onMessageReceived(encryptedMessage) + + verify(observer, never()).onMessageReceived("testScope", "test".toByteArray()) + + feature.onMessageReceived(encryptedMessage) + + verify(observer).onMessageReceived("testScope", "test".toByteArray()) + } + + @Test + fun `subscribe calls native layer and notifies observers`() = runTestOnMain { + val connection: PushManagerInterface = mock() + + var invoked = false + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + whenever(connection.subscribe(any(), any())).thenReturn( + SubscriptionResponse( + channelId = "test-cid", + subscriptionInfo = SubscriptionInfo( + endpoint = "https://foo", + keys = KeyInfo(auth = "auth", p256dh = "p256dh"), + ), + ), + ) + feature.connection = connection + + feature.subscribe("testScope") { + invoked = true + } + + assertTrue(invoked) + } + + @Test + fun `subscribe invokes error callback`() = runTestOnMain { + val subscription: AutoPushSubscription = mock() + var invoked = false + var errorInvoked = false + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + feature.connection = connection + + feature.subscribe( + scope = "testScope", + onSubscribeError = { + errorInvoked = true + }, + onSubscribe = { + invoked = true + }, + ) + + assertFalse(invoked) + assertFalse(errorInvoked) + + whenever(connection.subscribe(anyString(), nullable())).thenAnswer { throw PushApiException.InternalException("") } + whenever(subscription.scope).thenReturn("testScope") + + feature.subscribe( + scope = "testScope", + onSubscribeError = { + errorInvoked = true + }, + onSubscribe = { + invoked = true + }, + ) + + assertFalse(invoked) + assertTrue(errorInvoked) + } + + @Test + fun `unsubscribe calls native layer and notifies observers`() = runTestOnMain { + var invoked = false + var errorInvoked = false + + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + feature.connection = connection + feature.unsubscribe( + scope = "testScope", + onUnsubscribeError = { + errorInvoked = true + }, + onUnsubscribe = { + invoked = true + }, + ) + + assertTrue(invoked) + assertFalse(errorInvoked) + } + + @Test + fun `unsubscribe invokes error callback on native exception`() = runTestOnMain { + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + feature.connection = connection + var invoked = false + var errorInvoked = false + + whenever(connection.unsubscribe(anyString())).thenAnswer { throw PushApiException.InternalException("") } + + feature.unsubscribe( + scope = "testScope", + onUnsubscribeError = { + errorInvoked = true + }, + onUnsubscribe = { + invoked = true + }, + ) + + assertFalse(invoked) + assertTrue(errorInvoked) + } + + @Test + fun `getSubscription returns null when there is no subscription`() = runTestOnMain { + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + feature.connection = connection + var invoked = false + + whenever(connection.getSubscription(anyString())).thenReturn(null) + + feature.getSubscription( + scope = "testScope", + appServerKey = null, + ) { + invoked = it == null + } + + assertTrue(invoked) + } + + @Test + fun `getSubscription invokes subscribe when there is a subscription`() = runTestOnMain { + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + feature.connection = connection + var invoked = false + + whenever(connection.getSubscription(anyString())).thenReturn( + SubscriptionResponse( + channelId = "cid", + subscriptionInfo = SubscriptionInfo( + endpoint = "endpoint", + keys = KeyInfo( + auth = "auth", + p256dh = "p256dh", + ), + ), + ), + ) + + feature.getSubscription( + scope = "testScope", + appServerKey = null, + ) { + invoked = it != null + } + + assertTrue(invoked) + } + + @Test + fun `forceRegistrationRenewal deletes pref and calls service`() = runTestOnMain { + val service: PushService = mock() + val feature = AutoPushFeature(testContext, service, mock(), coroutineContext) + feature.connection = connection + + feature.renewRegistration() + + verify(service).deleteToken() + verify(service).start(testContext) + + val pref = preference(testContext).getString(PREF_TOKEN, null) + assertNull(pref) + } + + @Test + fun `verifyActiveSubscriptions notifies observers`() = runTestOnMain { + val connection: PushManagerInterface = mock() + val owner: LifecycleOwner = mock() + val lifecycle: Lifecycle = mock() + val observers: AutoPushFeature.Observer = mock() + val feature = AutoPushFeature(testContext, mock(), mock(), coroutineContext) + whenever(connection.verifyConnection()).thenReturn(emptyList()) + feature.connection = connection + whenever(owner.lifecycle).thenReturn(lifecycle) + whenever(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED) + + feature.register(observers) + + // When there are NO subscription updates, observers should not be notified. + feature.verifyActiveSubscriptions() + + verify(observers, never()).onSubscriptionChanged(any()) + + // When there are no subscription updates, observers should not be notified. + whenever(connection.verifyConnection()).thenReturn(emptyList()) + feature.verifyActiveSubscriptions() + + verify(observers, never()).onSubscriptionChanged(any()) + + // When there are subscription updates, observers should be notified. + whenever(connection.verifyConnection()).thenReturn(listOf(PushSubscriptionChanged(scope = "scope", channelId = "1246"))) + feature.verifyActiveSubscriptions() + + verify(observers).onSubscriptionChanged("scope") + } + + @Test + fun `new FCM token executes verifyActiveSubscription`() = runTestOnMain { + val feature = spy( + AutoPushFeature( + context = testContext, + service = mock(), + config = mock(), + coroutineContext = coroutineContext, + ), + ) + feature.connection = connection + + feature.initialize() + // no token yet so should not have even tried. + verify(feature, never()).verifyActiveSubscriptions() + + // new token == "check now" + feature.onNewToken("test-token") + verify(feature).verifyActiveSubscriptions() + } + + @Test + fun `verification doesn't happen until we've got the token`() = runTestOnMain { + val feature = spy( + AutoPushFeature( + context = testContext, + service = mock(), + config = mock(), + coroutineContext = coroutineContext, + ), + ) + + feature.connection = connection + + feature.initialize() + + verify(feature, never()).verifyActiveSubscriptions() + } + + @Test + fun `crash reporter is notified of errors`() = runTestOnMain { + val connection: PushManagerInterface = mock() + val crashReporter: CrashReporting = mock() + val feature = AutoPushFeature( + context = testContext, + service = mock(), + config = mock(), + coroutineContext = coroutineContext, + crashReporter = crashReporter, + ) + feature.connection = connection + + feature.onError(PushError.Rust(PushError.MalformedMessage("Bad things happened!"))) + + verify(crashReporter).submitCaughtException(any<PushError.Rust>()) + } + + @Test + fun `Non-Internal errors are submitted to crash reporter`() = runTestOnMain { + val crashReporter: CrashReporting = mock() + val feature = AutoPushFeature( + context = testContext, + service = mock(), + config = mock(), + coroutineContext = coroutineContext, + crashReporter = crashReporter, + ) + + feature.connection = connection + + whenever(connection.unsubscribe(any())).thenAnswer { + throw PushApiException.UaidNotRecognizedException("test") + } + feature.unsubscribe("123") {} + + verify(crashReporter).submitCaughtException(any<PushError.Rust>()) + } + + @Test + fun `Internal errors errors are not reported`() = runTestOnMain { + val crashReporter: CrashReporting = mock() + val feature = AutoPushFeature( + context = testContext, + service = mock(), + config = mock(), + coroutineContext = coroutineContext, + crashReporter = crashReporter, + ) + + feature.connection = connection + + whenever(connection.unsubscribe(any())).thenAnswer { throw PushApiException.InternalException("") } + + feature.unsubscribe("123") {} + + verify(crashReporter, never()).submitCaughtException(any<PushError.Rust>()) + } + + @Test + fun `asserts PushConfig's default values`() { + val config = PushConfig("sample-browser") + assertEquals("sample-browser", config.senderId) + assertEquals("updates.push.services.mozilla.com", config.serverHost) + assertEquals(Protocol.HTTPS, config.protocol) + assertEquals(ServiceType.FCM, config.serviceType) + } + + @Test + fun `transform response to PushSubscription`() { + val response = SubscriptionResponse( + "992a0f0542383f1ea5ef51b7cf4ae6c4", + SubscriptionInfo("https://mozilla.com", KeyInfo("123", "456")), + ) + val sub = response.toPushSubscription("scope") + + assertEquals(response.subscriptionInfo.endpoint, sub.endpoint) + assertEquals(response.subscriptionInfo.keys.auth, sub.authKey) + assertEquals(response.subscriptionInfo.keys.p256dh, sub.publicKey) + assertEquals("scope", sub.scope) + assertNull(sub.appServerKey) + + val sub2 = response.toPushSubscription("scope", "key") + + assertEquals(response.subscriptionInfo.endpoint, sub.endpoint) + assertEquals(response.subscriptionInfo.keys.auth, sub.authKey) + assertEquals(response.subscriptionInfo.keys.p256dh, sub.publicKey) + assertEquals("scope", sub2.scope) + assertEquals("key", sub2.appServerKey) + } + + @Test + fun `ServiceType to BridgeType`() { + assertEquals(BridgeType.FCM, ServiceType.FCM.toBridgeType()) + } + + companion object { + private fun preference(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE) + } + } +} diff --git a/mobile/android/android-components/components/feature/push/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/push/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/feature/push/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/mobile/android/android-components/components/feature/push/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/push/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/feature/push/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 |