Files
IronFox/patches/a-c-unifiedpush.patch
celenity 301f29a513 IronFox v145.0
ironfox-oss/IronFox!100
____

## Changes

- Added a toggle in settings to control the [media autoplay blocking policy](https://wiki.mozilla.org/Media/block-autoplay#What_strategy_does_Firefox_use_for_blocking_autoplay?) when JIT is otherwise disabled globally *(set to `Transient` by default)*, located at `Settings` -> `IronFox` -> `IronFox settings` -> **`Media autoplay`**.
- Fixed [an issue](https://gitlab.com/ironfox-oss/IronFox/-/issues/199) that prevented certain preferences from persisting across restarts.
- Updated the default version of Rust to [`1.91.0`](https://releases.rs/docs/1.91.0/).
- Updated to Firefox [`145.0`](https://firefox.com/firefox/android/145.0/releasenotes/).
- Updated to Phoenix [`2025.11.07.1`](https://codeberg.org/celenity/Phoenix/releases/tag/2025.11.07.1).
- [Other minor tweaks and adjustments](https://gitlab.com/ironfox-oss/IronFox/-/merge_requests/100/diffs).

MR-author: celenity <celenity@celenity.dev>
Co-authored-by: LucasMZ <git@lucasmz.dev>
Co-authored-by: Akash Yadav <itsaky01@gmail.com>
Approved-by: Akash Yadav <itsaky01@gmail.com>
Merged-by: celenity <celenity@celenity.dev>
2025-11-11 18:17:55 +00:00

2199 lines
85 KiB
Diff

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 52e9a741e9..59a3c78aeb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -69,6 +69,8 @@ semanticdb-javac = "0.11.1"
semanticdb-kotlinc = "0.7.0"
tomlj = "1.1.1"
+unifiedpush = "3.1.2"
+
# Linters
detekt = "1.23.8"
google-java-format = "1.28.0"
@@ -270,6 +272,7 @@ okhttp-urlconnection = { group = "com.squareup.okhttp3", name = "okhttp-urlconne
okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" }
sentry = { group = "io.sentry", name = "sentry-android", version.ref = "sentry" }
zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
+unifiedpush = { group = "org.unifiedpush.android", name = "connector", version.ref = "unifiedpush" }
[plugins]
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
diff --git a/mobile/android/android-components/.buildconfig.yml b/mobile/android/android-components/.buildconfig.yml
index f9711dc715..1421a44a87 100644
--- a/mobile/android/android-components/.buildconfig.yml
+++ b/mobile/android/android-components/.buildconfig.yml
@@ -602,6 +602,7 @@ projects:
- components:support-utils
- components:tooling-lint
- components:ui-icons
+ - components:feature-unifiedpush
components:feature-addons:
description: A feature that provides for managing add-ons.
path: components/feature/addons
@@ -2418,3 +2419,13 @@ projects:
- components:tooling-lint
- components:ui-colors
- components:ui-icons
+ components:feature-unifiedpush:
+ description: Feature that implements push notifications with UnifiedPush.
+ path: components/feature/unifiedpush
+ publish: true
+ upstream_dependencies:
+ - components:concept-base
+ - components:concept-push
+ - components:support-base
+ - components:support-test
+ - components:tooling-lint
diff --git a/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushObserver.kt b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushObserver.kt
new file mode 100644
index 000000000000..85e18ea0d9f0
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushObserver.kt
@@ -0,0 +1,21 @@
+/* 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.concept.push
+
+/**
+ * Observers that want to receive updates for new subscriptions and messages.
+ */
+interface PushObserver {
+
+ /**
+ * 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
+}
diff --git a/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushProcessor.kt b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushProcessor.kt
index 909ee23737..6c5d38b8f1 100644
--- a/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushProcessor.kt
+++ b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushProcessor.kt
@@ -15,12 +15,12 @@ interface PushProcessor {
/**
* Start the push processor and any service associated.
*/
- fun initialize()
+// fun initialize()
/**
* Removes all push subscriptions from the device.
*/
- fun shutdown()
+// fun shutdown()
/**
* A new registration token has been received.
@@ -43,7 +43,7 @@ interface PushProcessor {
/**
* Requests the [PushService] to renew it's registration with it's provider.
*/
- fun renewRegistration()
+// fun renewRegistration()
companion object {
/**
diff --git a/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushSubscription.kt b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushSubscription.kt
new file mode 100644
index 000000000000..470a1145a298
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushSubscription.kt
@@ -0,0 +1,19 @@
+/* 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.concept.push
+
+typealias AppServerKey = String
+typealias PushScope = String
+
+/**
+ * The subscription information from the WebPush server that can be used to send push messages.
+ */
+data class PushSubscription(
+ val scope: PushScope,
+ val endpoint: String,
+ val publicKey: String,
+ val authKey: String,
+ val appServerKey: AppServerKey?,
+)
diff --git a/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushSubscriptionProcessor.kt b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushSubscriptionProcessor.kt
new file mode 100644
index 000000000000..95392ec543b7
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/PushSubscriptionProcessor.kt
@@ -0,0 +1,73 @@
+/* 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.concept.push
+
+/**
+ * A push subscriptions processor that handles (un)subscriptions for a given scope.
+ */
+interface PushSubscriptionProcessor {
+
+ /**
+ * Start the push processor and any service associated.
+ */
+ fun initialize()
+
+ /**
+ * Removes all push subscriptions from the device.
+ */
+ fun shutdown()
+
+ /**
+ * Requests the PushSubscriptionProcessor to renew its registration with its provider.
+ */
+ fun renewRegistration()
+
+ /**
+ * Run [block] with all registered [PushScope] and appServerKey
+ */
+ fun forEachScopes(block: (PushScope, String?) -> Unit)
+
+ /**
+ * 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: PushScope,
+ appServerKey: String? = null,
+ onSubscribeError: (Exception) -> Unit = {},
+ onSubscribe: ((PushSubscription) -> Unit) = {},
+ )
+
+ /**
+ * 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: PushScope,
+ onUnsubscribeError: (Exception) -> Unit = {},
+ onUnsubscribe: (Boolean) -> Unit = {},
+ )
+
+ /**
+ * 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: PushScope,
+ appServerKey: String? = null,
+ block: (PushSubscription?) -> Unit,
+ )
+}
diff --git a/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/Pusher.kt b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/Pusher.kt
new file mode 100644
index 000000000000..802364f0a9ab
--- /dev/null
+++ b/mobile/android/android-components/components/concept/push/src/main/java/mozilla/components/concept/push/Pusher.kt
@@ -0,0 +1,19 @@
+/* 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.concept.push
+
+import mozilla.components.support.base.observer.Observable
+import mozilla.components.support.base.observer.ObserverRegistry
+
+/**
+ * Push features implementing a [Pusher] handle registrations and messages from a Service,
+ * They handle subscriptions for scopes, and are observers for update to these subscriptions.
+ */
+abstract class Pusher : PushSubscriptionProcessor, Observable<PushObserver> by ObserverRegistry() {
+ /**
+ * A flag to disable our rate-limit logic. This is useful when debugging.
+ */
+ abstract val disableRateLimit: Boolean
+}
diff --git a/mobile/android/android-components/components/feature/accounts-push/build.gradle b/mobile/android/android-components/components/feature/accounts-push/build.gradle
index ed3026775c..8d50f4791e 100644
--- a/mobile/android/android-components/components/feature/accounts-push/build.gradle
+++ b/mobile/android/android-components/components/feature/accounts-push/build.gradle
@@ -50,6 +50,7 @@ dependencies {
testRuntimeOnly libs.junit.vintage
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.robolectric
+ implementation project(':components:feature-unifiedpush')
}
apply from: '../../../android-lint.gradle'
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/FxaPushSupportFeature.kt b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/FxaPushSupportFeature.kt
index 7992e782db..f5ead8686e 100644
--- a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/FxaPushSupportFeature.kt
+++ b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/FxaPushSupportFeature.kt
@@ -25,9 +25,6 @@ import mozilla.components.concept.sync.DevicePushSubscription
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.feature.accounts.push.VerificationDelegate.Companion.PERIODIC_INTERVAL_MILLISECONDS
import mozilla.components.feature.accounts.push.cache.PushScopeProperty
-import mozilla.components.feature.push.AutoPushFeature
-import mozilla.components.feature.push.AutoPushSubscription
-import mozilla.components.feature.push.PushScope
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.manager.ext.withConstellationIfExists
import mozilla.components.support.base.log.logger.Logger
@@ -35,6 +32,11 @@ import mozilla.components.support.base.utils.SharedPreferencesCache
import org.json.JSONObject
import kotlin.coroutines.CoroutineContext
import mozilla.components.concept.sync.AccountObserver as SyncAccountObserver
+import mozilla.components.concept.push.PushObserver
+import mozilla.components.concept.push.PushScope
+import mozilla.components.concept.push.PushSubscription
+import mozilla.components.concept.push.PushSubscriptionProcessor
+import mozilla.components.concept.push.Pusher
internal const val PREFERENCE_NAME = "mozac_feature_accounts_push"
internal const val PREF_LAST_VERIFIED = "last_verified_push_subscription"
@@ -58,7 +60,7 @@ internal const val PREF_FXA_SCOPE = "fxa_push_scope"
class FxaPushSupportFeature(
private val context: Context,
private val accountManager: FxaAccountManager,
- private val pushFeature: AutoPushFeature,
+ private val pushFeature: Pusher,
private val crashReporter: CrashReporting? = null,
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO),
private val uiContext: CoroutineContext = Dispatchers.Main,
@@ -80,7 +82,7 @@ class FxaPushSupportFeature(
fun initialize() = coroutineScope.launch {
val scopeValue = pushScope.value()
- val autoPushObserver = AutoPushObserver(accountManager, pushFeature, scopeValue, uiContext)
+ val fxaPushObserver = FxaPushObserver(accountManager, pushFeature, scopeValue, uiContext)
val accountObserver = AccountObserver(
context,
@@ -95,7 +97,7 @@ class FxaPushSupportFeature(
coroutineScope.launch(uiContext) {
accountManager.register(accountObserver)
- pushFeature.register(autoPushObserver, owner, autoPause)
+ pushFeature.register(fxaPushObserver, owner, autoPause)
}
}
@@ -110,7 +112,7 @@ class FxaPushSupportFeature(
*/
internal class AccountObserver(
private val context: Context,
- private val push: AutoPushFeature,
+ private val push: Pusher,
private val fxaPushScope: String,
private val crashReporter: CrashReporting?,
private val lifecycleOwner: LifecycleOwner,
@@ -119,7 +121,7 @@ internal class AccountObserver(
) : SyncAccountObserver {
private val logger = Logger(AccountObserver::class.java.simpleName)
- private val verificationDelegate = VerificationDelegate(context, push.config.disableRateLimit)
+ private val verificationDelegate = VerificationDelegate(context, push.disableRateLimit)
@OptIn(DelicateCoroutinesApi::class) // GlobalScope usage
override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
@@ -158,7 +160,7 @@ internal class AccountObserver(
* it's OK to call this somewhat frequently.
*/
internal fun pushSubscribe(
- push: AutoPushFeature,
+ push: PushSubscriptionProcessor,
account: OAuthAccount,
scope: String,
uiContext: CoroutineContext,
@@ -202,7 +204,7 @@ internal fun pushSubscribe(
*/
internal class ConstellationObserver(
context: Context,
- private val push: AutoPushFeature,
+ private val push: PushSubscriptionProcessor,
private val scope: String,
private val account: OAuthAccount,
private val verifier: VerificationDelegate = VerificationDelegate(context),
@@ -247,13 +249,13 @@ internal class ConstellationObserver(
/**
* An [AutoPushFeature] observer to handle [FxaAccountManager] subscriptions and push events.
*/
-internal class AutoPushObserver(
+internal class FxaPushObserver(
private val accountManager: FxaAccountManager,
- private val pushFeature: AutoPushFeature,
+ private val pushFeature: PushSubscriptionProcessor,
private val fxaPushScope: String,
private val uiContext: CoroutineContext,
-) : AutoPushFeature.Observer {
- private val logger = Logger(AutoPushObserver::class.java.simpleName)
+) : PushObserver {
+ private val logger = Logger(FxaPushObserver::class.java.simpleName)
override fun onMessageReceived(scope: String, message: ByteArray?) {
if (scope != fxaPushScope) {
@@ -392,7 +394,7 @@ internal data class VerificationState(val timestamp: Long, val totalCount: Int)
internal fun preference(context: Context) = context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE)
-internal fun AutoPushSubscription.into() = DevicePushSubscription(
+internal fun PushSubscription.into() = DevicePushSubscription(
endpoint = this.endpoint,
publicKey = this.publicKey,
authKey = this.authKey,
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/cache/PushScopeProperty.kt b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/cache/PushScopeProperty.kt
index cd58a69246..dc35a50c10 100644
--- a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/cache/PushScopeProperty.kt
+++ b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/cache/PushScopeProperty.kt
@@ -12,7 +12,7 @@ import kotlinx.coroutines.withContext
import mozilla.components.feature.accounts.push.FxaPushSupportFeature
import mozilla.components.feature.accounts.push.PREF_FXA_SCOPE
import mozilla.components.feature.accounts.push.preference
-import mozilla.components.feature.push.PushScope
+import mozilla.components.concept.push.PushScope
import java.util.UUID
/**
diff --git a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/cache/ScopeProperty.kt b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/cache/ScopeProperty.kt
index 30e4030888..532382a2fb 100644
--- a/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/cache/ScopeProperty.kt
+++ b/mobile/android/android-components/components/feature/accounts-push/src/main/java/mozilla/components/feature/accounts/push/cache/ScopeProperty.kt
@@ -4,7 +4,7 @@
package mozilla.components.feature.accounts.push.cache
-import mozilla.components.feature.push.PushScope
+import mozilla.components.concept.push.PushScope
/**
* A [ScopeProperty] implementation generates and holds the [PushScope].
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
index e4bf8170af..d2a3248c36 100644
--- 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
@@ -27,15 +27,17 @@ 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
+import mozilla.components.concept.push.AppServerKey
+import mozilla.components.concept.push.PushScope
+import mozilla.components.concept.push.PushSubscription
+import mozilla.components.concept.push.Pusher
-typealias PushScope = String
-typealias AppServerKey = String
+// typealias PushScope = String
+// typealias AppServerKey = String
/**
* A implementation of a [PushProcessor] that should live as a singleton by being installed
@@ -80,12 +82,12 @@ typealias AppServerKey = String
class AutoPushFeature(
private val context: Context,
private val service: PushService,
- val config: PushConfig,
+ val config: AutoPushConfig,
coroutineContext: CoroutineContext = Executors.newSingleThreadExecutor(
NamedThreadFactory("AutoPushFeature"),
).asCoroutineDispatcher(),
private val crashReporter: CrashReporting? = null,
-) : PushProcessor, Observable<AutoPushFeature.Observer> by ObserverRegistry() {
+) : PushProcessor, Pusher() {
private val logger = Logger("AutoPushFeature")
@@ -98,6 +100,13 @@ class AutoPushFeature(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal var connection: PushManagerInterface? = null
+ override val disableRateLimit: Boolean
+ get() = config.disableRateLimit
+
+ init {
+ PushProcessor.install(this)
+ }
+
/**
* Starts the push feature and initialization work needed. Also starts the [PushService] to ensure new messages
* come through.
@@ -139,10 +148,27 @@ class AutoPushFeature(
*/
override fun shutdown() {
withConnection {
- it.unsubscribeAll()
+ try {
+ it.unsubscribeAll()
+ } catch (e: PushApiException) {
+ }
}
}
+ override fun forEachScopes(block: (PushScope, String?) -> Unit) {
+ // TODO: uncomment once https://github.com/mozilla/application-services/pull/6671 is merged
+ /*
+ val currentConnection = connection
+ currentConnection ?: run {
+ logger.warn("No current connection")
+ }
+ coroutineScope.launch {
+ currentConnection?.listSubscriptionReq()?.forEach {
+ block(it.scope, it.appServerKey)
+ }
+ }*/
+ }
+
/**
* New registration tokens are received and sent to the AutoPush server which also performs subscriptions for
* each push type and notifies the subscribers.
@@ -187,11 +213,11 @@ class AutoPushFeature(
* @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) = {},
+ override fun subscribe(
+ scope: PushScope,
+ appServerKey: String?,
+ onSubscribeError: (Exception) -> Unit,
+ onSubscribe: ((PushSubscription) -> Unit),
) {
withConnection(errorBlock = { exception -> onSubscribeError(exception) }) {
val sub = it.subscribe(scope, appServerKey ?: "")
@@ -206,10 +232,10 @@ class AutoPushFeature(
* @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 = {},
+ override fun unsubscribe(
+ scope: PushScope,
+ onUnsubscribeError: (Exception) -> Unit,
+ onUnsubscribe: (Boolean) -> Unit,
) {
withConnection(errorBlock = { exception -> onUnsubscribeError(exception) }) {
onUnsubscribe(it.unsubscribe(scope))
@@ -224,10 +250,10 @@ class AutoPushFeature(
* @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,
+ override fun getSubscription(
+ scope: PushScope,
+ appServerKey: String?,
+ block: (PushSubscription?) -> Unit,
) {
withConnection {
block(it.getSubscription(scope)?.toPushSubscription(scope, appServerKey))
@@ -289,18 +315,18 @@ class AutoPushFeature(
/**
* Observers that want to receive updates for new subscriptions and messages.
*/
- interface Observer {
+// interface Observer {
/**
* A subscription for the scope is available.
*/
- fun onSubscriptionChanged(scope: PushScope) = Unit
+// fun onSubscriptionChanged(scope: PushScope) = Unit
/**
* A messaged has been received for the [scope].
*/
- fun onMessageReceived(scope: PushScope, message: ByteArray?) = Unit
- }
+// fun onMessageReceived(scope: PushScope, message: ByteArray?) = Unit
+// }
private fun exceptionHandler(onError: (PushError) -> Unit) = CoroutineExceptionHandler { _, e ->
when (e) {
@@ -352,13 +378,13 @@ enum class Protocol {
/**
* 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?,
-)
+// 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.
@@ -369,7 +395,7 @@ data class AutoPushSubscription(
* @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(
+data class AutoPushConfig(
val senderId: String,
val serverHost: String = "updates.push.services.mozilla.com",
val protocol: Protocol = Protocol.HTTPS,
@@ -402,8 +428,8 @@ private fun Protocol.toRustHttpProtocol(): PushHttpProtocol {
fun SubscriptionResponse.toPushSubscription(
scope: String,
appServerKey: AppServerKey? = null,
-): AutoPushSubscription {
- return AutoPushSubscription(
+): PushSubscription {
+ return PushSubscription(
scope = scope,
endpoint = subscriptionInfo.endpoint,
authKey = subscriptionInfo.keys.auth,
diff --git a/mobile/android/android-components/components/feature/unifiedpush/README.md b/mobile/android/android-components/components/feature/unifiedpush/README.md
new file mode 100644
index 000000000000..c492a3879873
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/README.md
@@ -0,0 +1,64 @@
+# [Android Components](../../../README.md) > Feature > UnifiedPush
+
+A component that implements push notifications with UnifiedPush.
+
+## Usage
+
+We can start the AutoPushFeature to get the subscription info and decrypted push message:
+
+```kotlin
+val feature = UnifiedPushFeature(
+ context = context,
+ disableRateLimit = true,
+ crashReporter = crashReporter
+)
+
+// To start the feature and the service.
+feature.initialize()
+
+// To stop the feature and the service.
+feature.shutdown()
+
+// To select the user default UnifiedPush distributor
+feature.useDefaultDistributor(activity) { success ->
+ if (success) {
+ // register known instances
+ }
+}
+
+// To receive the subscription info for all the subscription changes.
+feature.register(object : PushObserver {
+ 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 : PushObserver {
+ override fun onMessageReceived(scope: String, message: ByteArray?) {
+ // Handle decrypted message here.
+ }
+})
+```
+
+In order to receive push messages, a [UnifiedPush distributor](https://unifiedpush.org/users/distributors/) needs to be installed on the system. The app register subscriptions to the distributor, which sends back endpoint for the scope.
+
+For more information, visit <https://unifiedpush.org>.
+
+### 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-unifiedpush:{latest-version}"
+```
+
+## 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/
+
diff --git a/mobile/android/android-components/components/feature/unifiedpush/build.gradle b/mobile/android/android-components/components/feature/unifiedpush/build.gradle
new file mode 100644
index 0000000000..80b2c95cea
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/build.gradle
@@ -0,0 +1,52 @@
+/* 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.unifiedpush'
+}
+
+
+dependencies {
+ implementation project(':components:concept-push')
+
+ implementation ComponentsDependencies.mozilla_appservices_push
+
+ // Remove when the MessageBus is implemented somewhere else.
+ implementation project(':components:support-base')
+ implementation project(':components:ui-icons')
+
+ implementation libs.androidx.work.runtime
+ implementation libs.androidx.lifecycle.runtime
+ implementation libs.kotlinx.coroutines
+ implementation (libs.unifiedpush) {
+ exclude group: "com.google.protobuf", module: "protobuf-java"
+ }
+
+ testImplementation project(':components:support-test')
+
+ testImplementation libs.androidx.test.core
+ testImplementation libs.androidx.test.junit
+ testImplementation libs.kotlinx.coroutines.test
+ testImplementation libs.robolectric
+}
+
+apply from: '../../../common-config.gradle'
+apply from: '../../../publish.gradle'
+ext.configurePublish(config.componentsGroupId, project.name, project.ext.description)
diff --git a/mobile/android/android-components/components/feature/unifiedpush/proguard-rules.pro b/mobile/android/android-components/components/feature/unifiedpush/proguard-rules.pro
new file mode 100644
index 000000000000..f1b424510da5
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/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/unifiedpush/src/main/AndroidManifest.xml b/mobile/android/android-components/components/feature/unifiedpush/src/main/AndroidManifest.xml
new file mode 100644
index 000000000000..e4fd46229073
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<!-- 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 xmlns:tools="http://schemas.android.com/tools"
+ xmlns:android="http://schemas.android.com/apk/res/android">
+ <application>
+ <receiver android:exported="true" android:enabled="true" android:name=".PushReceiver"
+ tools:ignore="ExportedReceiver">
+ <intent-filter>
+ <action android:name="org.unifiedpush.android.connector.MESSAGE"/>
+ <action android:name="org.unifiedpush.android.connector.UNREGISTERED"/>
+ <action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT"/>
+ <action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED"/>
+ </intent-filter>
+ </receiver>
+ </application>
+</manifest>
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/DefaultDistributorManager.kt b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/DefaultDistributorManager.kt
new file mode 100644
index 000000000000..46609dfd5a2c
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/DefaultDistributorManager.kt
@@ -0,0 +1,76 @@
+/* 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.unifiedpush
+
+import android.app.Activity
+import android.content.Context
+import mozilla.components.concept.push.PushScope
+import org.unifiedpush.android.connector.UnifiedPush
+
+/**
+ * Default implementation of [DistributorManager]
+ */
+class DefaultDistributorManager : DistributorManager {
+
+ /**
+ * Check if the distributor is still installed
+ *
+ * If the distributor has been uninstalled, [UnifiedPushProcessor.onUnregistered] is called for
+ * all known chanel
+ */
+ override fun isAvailable(context: Context): Boolean {
+ return UnifiedPush.getAckDistributor(context) != null
+ }
+
+ /**
+ * Register [scope] with [appServerKey]
+ */
+ override fun registerScope(context: Context, scope: PushScope, appServerKey: String?) {
+ UnifiedPush.register(
+ context,
+ instance = scope,
+ vapid = appServerKey,
+ messageForDistributor = scope
+ )
+ }
+
+ /**
+ * Unregister [scope]
+ */
+ override fun unregisterScope(context: Context, scope: PushScope) {
+ UnifiedPush.unregister(context, instance = scope)
+ }
+
+ /**
+ * Register a default instance, may be useful when we are linked to a distributor
+ * but we don't have any active subscription yet
+ */
+ override fun registerDefaultRegistration(context: Context) {
+ UnifiedPush.register(
+ context,
+ messageForDistributor = "Default registration",
+ )
+ }
+
+ /**
+ * Use user default UnifiedPush distributor, then call [callback].
+ *
+ * The callback is called with `true` if we have successfully
+ * selected a distributor.
+ *
+ * If [UnifiedPushProcessor] is already initialized, the user have toggling off/on
+ * UnifiedPush, we register all known instances to the new distributor
+ */
+ override fun useDefaultDistributor(activity: Activity, callback: (Boolean) -> Unit) {
+ UnifiedPush.tryUseDefaultDistributor(activity, callback)
+ }
+
+ /**
+ * Remove current distributor
+ */
+ override fun removeCurrentDistributor(context: Context) {
+ UnifiedPush.removeDistributor(context)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/DistributorManager.kt b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/DistributorManager.kt
new file mode 100644
index 000000000000..a0302866d83b
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/DistributorManager.kt
@@ -0,0 +1,52 @@
+/* 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.unifiedpush
+
+import android.app.Activity
+import android.content.Context
+import mozilla.components.concept.push.PushScope
+
+/**
+ * Interface with functions to interact with an UnifiedPush distributor
+ */
+interface DistributorManager {
+
+ /**
+ * Check if the distributor is still installed
+ */
+ fun isAvailable(context: Context): Boolean
+
+ /**
+ * Register [scope] with [appServerKey]
+ */
+ fun registerScope(context: Context, scope: PushScope, appServerKey: String?)
+
+ /**
+ * Unregister [scope]
+ */
+ fun unregisterScope(context: Context, scope: PushScope)
+
+ /**
+ * Register a default instance, may be useful when we are linked to a distributor
+ * but we don't have any active subscription yet
+ */
+ fun registerDefaultRegistration(context: Context)
+
+ /**
+ * Use user default UnifiedPush distributor, then call [callback].
+ *
+ * The callback is called with `true` if we have successfully
+ * selected a distributor.
+ *
+ * If [UnifiedPushProcessor] is already initialized, the user have toggling off/on
+ * UnifiedPush, we register all known instances to the new distributor
+ */
+ fun useDefaultDistributor(activity: Activity, callback: (Boolean) -> Unit)
+
+ /**
+ * Remove current distributor
+ */
+ fun removeCurrentDistributor(context: Context)
+}
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/PushReceiver.kt b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/PushReceiver.kt
new file mode 100644
index 000000000000..10fb13d77213
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/PushReceiver.kt
@@ -0,0 +1,54 @@
+/* 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.unifiedpush
+
+import android.content.Context
+import android.content.Intent
+import org.unifiedpush.android.connector.FailedReason
+import org.unifiedpush.android.connector.MessagingReceiver
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.PushMessage
+
+/**
+ * Implementation of UnifiedPush [MessagingReceiver]
+ */
+class PushReceiver : MessagingReceiver() {
+ override fun onMessage(context: Context, message: PushMessage, instance: String) {
+ UnifiedPushProcessor.requireInstance.onMessage(
+ scope = instance,
+ message = message,
+ )
+ }
+
+ override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) {
+ UnifiedPushProcessor.requireInstance.onNewEndpoint(
+ scope = instance,
+ newEndpoint = endpoint,
+ )
+ }
+
+ override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) {
+ UnifiedPushProcessor.requireInstance.onError(reason.toPushError())
+ }
+
+ override fun onUnregistered(context: Context, instance: String) {
+ UnifiedPushProcessor.requireInstance.onUnregistered(scope = instance)
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ UnifiedPushProcessor.requireInstance.withCoroutine {
+ super.onReceive(context, intent)
+ }
+ }
+
+ private fun FailedReason.toPushError(): PushError {
+ return when (this) {
+ FailedReason.NETWORK -> PushError.Network("Push Service needs network to register")
+ FailedReason.INTERNAL_ERROR -> PushError.ServiceUnavailable("Unknown error")
+ FailedReason.ACTION_REQUIRED -> PushError.ServiceUnavailable("Push Service waits for a user action")
+ FailedReason.VAPID_REQUIRED -> PushError.Registration("Push Service requires VAPID")
+ }
+ }
+}
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/SubscriptionsDB.kt b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/SubscriptionsDB.kt
new file mode 100644
index 000000000000..0dfdc35d52b7
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/SubscriptionsDB.kt
@@ -0,0 +1,220 @@
+/* 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.unifiedpush
+
+import android.content.ContentValues
+import android.content.Context
+import android.database.Cursor
+import android.database.sqlite.SQLiteDatabase
+import android.database.sqlite.SQLiteOpenHelper
+
+/**
+ * Database to track known [Subscription]
+ */
+class SubscriptionsDB(context: Context) :
+ SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
+
+ /**
+ * Contains information about a subscription
+ */
+ data class Subscription(
+ val scope: String,
+ val endpoint: String? = null,
+ val publicKey: String? = null,
+ val authKey: String? = null,
+ val appServerKey: String? = null,
+ )
+
+ override fun onCreate(db: SQLiteDatabase) {
+ db.execSQL(CREATE_TABLE_APPS)
+ }
+
+ override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
+ throw IllegalStateException("Upgrade not supported")
+ }
+
+ /**
+ * Add a [Subscription]
+ */
+ fun add(subscription: Subscription) {
+ val db = writableDatabase
+ val values = ContentValues().apply {
+ put(FIELD_SCOPE, subscription.scope)
+ put(FIELD_ENDPOINT, subscription.endpoint)
+ put(FIELD_PUBKEY, subscription.publicKey)
+ put(FIELD_AUTH, subscription.authKey)
+ put(FIELD_VAPID, subscription.appServerKey)
+ }
+ db.insertWithOnConflict(TABLE, null, values, SQLiteDatabase.CONFLICT_REPLACE)
+ }
+
+ /**
+ * Update the endpoint or the webpush keys of a registration
+ *
+ * @return `true` if a row has been updated
+ */
+ fun updateEndpoint(scope: String, endpoint: String, publicKey: String, authKey: String): Boolean {
+ val db = writableDatabase
+ val selection = "$FIELD_SCOPE = ? and (" +
+ "$FIELD_ENDPOINT IS NULL or $FIELD_ENDPOINT != ? " +
+ "or $FIELD_PUBKEY IS NULL or $FIELD_PUBKEY != ? " +
+ "or $FIELD_PUBKEY IS NULL or $FIELD_PUBKEY != ? " +
+ "or $FIELD_AUTH IS NULL or $FIELD_AUTH != ?)"
+ val selectionArgs = arrayOf(scope)
+ val values = ContentValues().apply {
+ put(FIELD_ENDPOINT, endpoint)
+ put(FIELD_PUBKEY, publicKey)
+ put(FIELD_AUTH, authKey)
+ }
+ return db.update(
+ TABLE,
+ values,
+ selection,
+ selectionArgs,
+ ) != 0
+ }
+
+ /**
+ * Remove the endpoint of a registration
+ */
+ fun removeEndpoint(scope: String): Boolean {
+ val db = writableDatabase
+ val selection = "$FIELD_SCOPE = ?"
+ val selectionArgs = arrayOf(scope)
+ val values = ContentValues().apply {
+ putNull(FIELD_ENDPOINT)
+ putNull(FIELD_PUBKEY)
+ putNull(FIELD_AUTH)
+ }
+ return db.update(
+ TABLE,
+ values,
+ selection,
+ selectionArgs,
+ ) != 0
+ }
+
+ /**
+ * Remove a subscription
+ */
+ fun remove(scope: String) {
+ val db = writableDatabase
+ val selection = "$FIELD_SCOPE = ?"
+ val selectionArgs = arrayOf(scope)
+ db.delete(TABLE, selection, selectionArgs)
+ }
+
+ /**
+ * Remove all known subscriptions
+ */
+ fun removeAll() {
+ val db = writableDatabase
+ val selection = null
+ val selectionArgs = null
+ db.delete(TABLE, selection, selectionArgs)
+ }
+
+ /**
+ * List all known subscriptions
+ */
+ fun listSubscriptions(): List<Subscription> {
+ val db = readableDatabase
+ val projection = null
+ return db.query(
+ TABLE,
+ projection,
+ null,
+ null,
+ null,
+ null,
+ null,
+ ).use { cursor ->
+ generateSequence { if (cursor.moveToNext()) cursor else null }
+ .mapNotNull { cursor.subscription() }
+ .toList()
+ }
+ }
+
+ /**
+ * Get a subscription based on its scope and its vapid key
+ */
+ fun getSubscription(scope: String, vapid: String?): Subscription? {
+ val db = readableDatabase
+ val projection = null
+ var selection = "$FIELD_SCOPE = ?"
+ val selectionArgs = mutableListOf(scope)
+ vapid?.let {
+ selection += " and $FIELD_VAPID = ?"
+ selectionArgs.add(vapid)
+ }
+ /*
+ vapid is always null at this moment,
+ [onGetSubscription][org.mozilla.fenix.push.WebPushEngineDelegate.onGetSubscription]
+ doesn't pass appServerKey.
+
+ Once it it supported, we can add:
+ ?: run {
+ selection += "and $FIELD_VAPID IS NULL"
+ }
+ */
+ return db.query(
+ TABLE,
+ projection,
+ selection,
+ selectionArgs.toTypedArray(),
+ null,
+ null,
+ null,
+ ).use { cursor ->
+ if (cursor.moveToNext()) {
+ cursor.subscription()
+ } else {
+ null
+ }
+ }
+ }
+
+ private fun Cursor.subscription(): Subscription? {
+ val scopeColumn = this.getColumnIndex(FIELD_SCOPE)
+ val endpointColumn = this.getColumnIndex(FIELD_ENDPOINT)
+ val pubKeyColumn = this.getColumnIndex(FIELD_PUBKEY)
+ val authColumn = this.getColumnIndex(FIELD_AUTH)
+ val vapidColumn = this.getColumnIndex(FIELD_VAPID)
+
+ val scope = (
+ if (scopeColumn >= 0) this.getString(scopeColumn) else null
+ ) ?: return null
+ val endpoint = if (endpointColumn >= 0) this.getString(endpointColumn) else null
+ val pubKey = if (pubKeyColumn >= 0) this.getString(pubKeyColumn) else null
+ val auth = if (authColumn >= 0) this.getString(authColumn) else null
+ val vapidKey = if (vapidColumn >= 0) this.getString(vapidColumn) else null
+ return Subscription(
+ scope,
+ endpoint,
+ pubKey,
+ auth,
+ vapidKey,
+ )
+ }
+
+ companion object {
+ private const val DB_NAME = "feature_unifiedpush_subscriptions"
+ private const val DB_VERSION = 1
+
+ private const val TABLE = "subscriptions"
+ private const val FIELD_SCOPE = "scope"
+ private const val FIELD_ENDPOINT = "endpoint"
+ private const val FIELD_PUBKEY = "pubkey"
+ private const val FIELD_AUTH = "auth"
+ private const val FIELD_VAPID = "vapid"
+ private const val CREATE_TABLE_APPS = "CREATE TABLE $TABLE (" +
+ "$FIELD_SCOPE TEXT," +
+ "$FIELD_ENDPOINT TEXT," +
+ "$FIELD_PUBKEY TEXT," +
+ "$FIELD_AUTH TEXT," +
+ "$FIELD_VAPID TEXT," +
+ "PRIMARY KEY ($FIELD_SCOPE));"
+ }
+}
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/UnifiedPushFeature.kt b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/UnifiedPushFeature.kt
new file mode 100644
index 000000000000..ee7703307210
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/UnifiedPushFeature.kt
@@ -0,0 +1,372 @@
+/* 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.unifiedpush
+
+import android.app.Activity
+import android.content.Context
+import android.database.SQLException
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import kotlinx.coroutines.withTimeoutOrNull
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.concept.push.PushScope
+import mozilla.components.concept.push.PushSubscription
+import mozilla.components.concept.push.Pusher
+import mozilla.components.support.base.log.logger.Logger
+import mozilla.components.support.base.utils.NamedThreadFactory
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.PushMessage
+import java.util.UUID
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeoutException
+import kotlin.coroutines.CoroutineContext
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Implementation of a [UnifiedPushProcessor] that should live as a singleton by being installed
+ * in the Application's onCreate. It receives messages from UnifiedPush service and forwards them
+ * to the web engine.
+ *
+ * Implementation of a [Pusher], that receives request from web engine and allow push event to be
+ * Observable
+ *
+ * ```kotlin
+ * class Application {
+ * override fun onCreate() {
+ * val feature = UnifiedPushFeature(context)
+ * }
+ * }
+ * ```
+ *
+ * Observe for subscription information changes for each registered scope, and for push messages:
+ *
+ * ```kotlin
+ * feature.register(object: PushObserver {
+ * override fun onSubscriptionChanged(scope: PushScope) { }
+ * override fun onMessageReceived(scope: PushScope, message: ByteArray?) { }
+ * })
+ *
+ * feature.subscribe("push_subscription_scope_id")
+ * ```
+ *
+ * @param context the application [Context].
+ * @param disableRateLimit to disable rate-limit logic.
+ * @param coroutineContext An instance of [CoroutineContext] used for executing async push tasks.
+ * @param crashReporter An optional instance of a [CrashReporting].
+ * @param timeoutSubscriptionCallback Duration until [subscribe] fail if it doesn't receive an endpoint
+ * @param db [SubscriptionsDB] to track known subscription
+ * @param up [DistributorManager] to interact with UnifiedPush distributor, default to [DefaultDistributorManager]
+ */
+
+@Suppress("LargeClass")
+class UnifiedPushFeature(
+ private val context: Context,
+ override val disableRateLimit: Boolean = true,
+ coroutineContext: CoroutineContext = Executors.newSingleThreadExecutor(
+ NamedThreadFactory("UnifiedPushFeature"),
+ ).asCoroutineDispatcher(),
+ private val crashReporter: CrashReporting? = null,
+ private val timeoutSubscriptionCallback: Duration = 5.seconds,
+ private val db: SubscriptionsDB = SubscriptionsDB(context),
+ private val up: DistributorManager = DefaultDistributorManager(),
+) : UnifiedPushProcessor, Pusher() {
+
+ private val logger = Logger("UnifiedPushFeature")
+
+ private val coroutineScope = CoroutineScope(coroutineContext) + SupervisorJob() + exceptionHandler { onError(it) }
+
+ private val subscriptionFlow = MutableSharedFlow<Pair<UUID, PushSubscription>>()
+ private val ackFlow = MutableSharedFlow<UUID>()
+
+ init {
+ UnifiedPushProcessor.install(this)
+ }
+
+ fun isAvailable(context: Context): Boolean {
+ return up.isAvailable(context)
+ }
+
+ /**
+ * Starts the push feature and initialization work needed. Register all known instances
+ */
+ override fun initialize() {
+ coroutineScope.launch {
+ registerKnownInstances(context)
+ }
+ }
+
+ /**
+ * Un-subscribes from all push message channels and reset [UnifiedPushProcessor].
+ *
+ * This should only be done on an account logout or app data deletion.
+ */
+ override fun shutdown() {
+ coroutineScope.launch {
+ db.removeAll()
+ // Removes all instances and the distributor
+ up.removeCurrentDistributor(context)
+ // We can reset UnifiedPushProcessor, because we can't receive any push event anymore
+ UnifiedPushProcessor.reset()
+ }
+ }
+
+ /**
+ * Run [block] with all registered [PushScope] and appServerKey
+ */
+ override fun forEachScopes(block: (PushScope, String?) -> Unit) {
+ coroutineScope.launch {
+ db.listSubscriptions().forEach { block(it.scope, it.appServerKey) }
+ }
+ }
+
+ /**
+ * New endpoint is received for [scope], notify the scope if any subscription parameter is updated
+ */
+ override fun onNewEndpoint(scope: PushScope, newEndpoint: PushEndpoint) {
+ logger.info("Got new endpoint for $scope")
+ val pubKeySet = newEndpoint.pubKeySet ?: run {
+ logger.warn("Received push endpoint without public keys")
+ return
+ }
+ val subscription = PushSubscription(
+ scope = scope,
+ endpoint = newEndpoint.url,
+ publicKey = pubKeySet.pubKey,
+ authKey = pubKeySet.auth,
+ appServerKey = null, // The VAPID key is retrieved by the subscriber if needed
+ )
+ val id = UUID.randomUUID()
+ var waitAck = true
+ // If a registration is updated, we notify observer, if a callback isn't already
+ // registered for this scope
+ if (db.updateEndpoint(
+ scope,
+ subscription.endpoint,
+ subscription.publicKey,
+ subscription.authKey,
+ )
+ ) {
+ coroutineScope.launch {
+ ackFlow
+ .takeWhile { waitAck }
+ .filter { it == id }
+ .firstOrNull()
+ ?: run {
+ logger.info("Notifying about subscription change")
+ notifyObservers { onSubscriptionChanged(scope) }
+ }
+ }
+ }
+ coroutineScope.launch {
+ subscriptionFlow.emit(id to subscription)
+ delay(100)
+ waitAck = false
+ ackFlow.emit(id)
+ }
+ }
+
+ /**
+ * A new push message has been received.
+ * The message contains the decrypted payload as sent by the
+ * application server.
+ */
+ override fun onMessage(scope: PushScope, message: PushMessage) {
+ coroutineScope.launch {
+ if (message.decrypted) {
+ logger.info("New push message decrypted.")
+ notifyObservers { onMessageReceived(scope, message.content) }
+ } else {
+ logger.warn("Couldn't decrypt push message.")
+ }
+ }
+ }
+
+ /**
+ * An error has occurred during the registration.
+ */
+ override fun onError(error: PushError) {
+ logger.error("${error.javaClass.simpleName} error: $error")
+ crashReporter?.submitCaughtException(error)
+ }
+
+ /**
+ * The subscription has been removed from the distributor
+ */
+ override fun onUnregistered(scope: PushScope) {
+ logger.info("Registration for $scope removed")
+ coroutineScope.launch {
+ db.removeEndpoint(scope)
+ }
+ }
+
+ /**
+ * Run [block] within the Processor coroutine scope
+ */
+ override fun withCoroutine(block: () -> Unit) {
+ coroutineScope.launch {
+ block()
+ }
+ }
+
+ /**
+ * 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.
+ */
+ override fun subscribe(
+ scope: PushScope,
+ appServerKey: String?,
+ onSubscribeError: (Exception) -> Unit,
+ onSubscribe: ((PushSubscription) -> Unit),
+ ) {
+ coroutineScope.launch {
+ try {
+ db.add(
+ SubscriptionsDB.Subscription(
+ scope = scope,
+ appServerKey = appServerKey,
+ ),
+ )
+ } catch (e: SQLException) {
+ onSubscribeError(e)
+ return@launch
+ }
+ withTimeoutOrNull(timeoutSubscriptionCallback) {
+ subscriptionFlow.filter { sub ->
+ sub.second.scope == scope
+ }.firstOrNull()
+ }?.let { sub ->
+ ackFlow.emit(sub.first)
+ logger.info("Got a registration for the subscription")
+ onSubscribe(sub.second.copy(appServerKey = appServerKey))
+ } ?: run {
+ logger.info("No registration received for the subscription")
+ onSubscribeError(TimeoutException())
+ }
+ }
+ coroutineScope.launch {
+ up.registerScope(context, 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.
+ */
+ override fun unsubscribe(
+ scope: PushScope,
+ onUnsubscribeError: (Exception) -> Unit,
+ onUnsubscribe: (Boolean) -> Unit,
+ ) {
+ coroutineScope.launch {
+ try {
+ db.remove(scope)
+ onUnsubscribe(true)
+ up.unregisterScope(context, scope)
+ } catch (e: SQLException) {
+ onUnsubscribeError(e)
+ }
+ }
+ }
+
+ /**
+ * 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.
+ */
+ override fun getSubscription(
+ scope: PushScope,
+ appServerKey: String?,
+ block: (PushSubscription?) -> Unit,
+ ) {
+ coroutineScope.launch {
+ block(db.getSubscription(scope, appServerKey)?.toPushSubscription())
+ }
+ }
+
+ /**
+ * Re-subscribe all known registrations
+ */
+ override fun renewRegistration() {
+ logger.warn("Forcing registration renewal.")
+ coroutineScope.launch {
+ registerKnownInstances(context)
+ }
+ }
+
+ private fun exceptionHandler(onError: (PushError) -> Unit) = CoroutineExceptionHandler { _, e ->
+ when (e) {
+ is SQLException,
+ -> onError(PushError.DB(e, e.message.orEmpty()))
+ else -> logger.warn("Internal error occurred in UnifiedPushFeature.", e)
+ }
+ }
+
+ private fun registerKnownInstances(context: Context) {
+ up.registerDefaultRegistration(context)
+ db.listSubscriptions().forEach {
+ up.registerScope(context, it.scope, it.appServerKey)
+ }
+ }
+
+ /**
+ * Use user default UnifiedPush distributor, then call [callback].
+ *
+ * The callback is called with `true` if we have successfully
+ * selected a distributor.
+ *
+ * If [UnifiedPushProcessor] is already initialized, the user have toggling off/on
+ * UnifiedPush, we register all known instances to the new distributor
+ */
+ fun useDefaultDistributor(activity: Activity, callback: (Boolean) -> Unit) {
+ up.useDefaultDistributor(activity) { success ->
+ if (success && UnifiedPushProcessor.isInit) {
+ coroutineScope.launch {
+ registerKnownInstances(context)
+ }
+ }
+ callback(success)
+ }
+ }
+
+ /**
+ * Remove current UnifiedPush distributor
+ */
+ fun removeCurrentDistributor(context: Context) {
+ up.removeCurrentDistributor(context)
+ }
+}
+
+fun SubscriptionsDB.Subscription.toPushSubscription(): PushSubscription? {
+ if (endpoint == null || publicKey == null || authKey == null) {
+ return null
+ }
+ return PushSubscription(
+ scope,
+ endpoint,
+ publicKey,
+ authKey,
+ appServerKey,
+ )
+}
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/UnifiedPushNotification.kt b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/UnifiedPushNotification.kt
new file mode 100644
index 000000000000..0db1d27f6aa2
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/UnifiedPushNotification.kt
@@ -0,0 +1,65 @@
+package mozilla.components.feature.unifiedpush
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.Context
+import android.os.Build
+import android.os.Build.VERSION.SDK_INT
+import androidx.core.app.NotificationCompat
+import androidx.core.content.getSystemService
+import mozilla.components.support.base.ids.SharedIdsHelper
+import mozilla.components.ui.icons.R as iconsR
+
+@Suppress("LargeClass")
+object UnifiedPushNotification {
+ private const val NOTIFICATION_CHANNEL_ID = "mozac.feature.unifiedpush.generic"
+
+ fun getNotificationId(context: Context): Int {
+ return SharedIdsHelper.getIdForTag(context, NOTIFICATION_CHANNEL_ID)
+ }
+
+ /**
+ * Build the notification to be displayed when the push services have been uninstalled.
+ */
+ fun createMissingServiceNotification(
+ context: Context,
+ ): Notification {
+ val channelId = ensureChannelExists(context)
+ val title = context.getString(R.string.mozac_feature_unifiedpush_notification_title)
+ val content = context.getString(R.string.mozac_feature_unifiedpush_notification_text)
+
+ return NotificationCompat.Builder(context, channelId)
+ .setStyle(
+ NotificationCompat.BigTextStyle()
+ .bigText(content)
+ )
+ .setSmallIcon(iconsR.drawable.mozac_ic_warning_24)
+ .setContentTitle(title)
+ .setContentText(content)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .build()
+ }
+
+
+ /**
+ * Make sure a notification channel for download notification exists.
+ *
+ * Returns the channel id to be used for download notifications.
+ */
+ private fun ensureChannelExists(context: Context): String {
+ if (SDK_INT >= Build.VERSION_CODES.O) {
+ val notificationManager: NotificationManager = context.getSystemService()!!
+
+ val channel = NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ context.applicationContext.getString(R.string.mozac_feature_unifiedpush_notification_channel),
+ NotificationManager.IMPORTANCE_DEFAULT,
+ )
+
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ return NOTIFICATION_CHANNEL_ID
+ }
+}
\ No newline at end of file
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/UnifiedPushProcessor.kt b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/UnifiedPushProcessor.kt
new file mode 100644
index 000000000000..bf7074acb27f
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/main/java/mozilla/components/feature/unifiedpush/UnifiedPushProcessor.kt
@@ -0,0 +1,84 @@
+/* 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.unifiedpush
+
+import mozilla.components.concept.push.PushScope
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.PushMessage
+
+/**
+ * A push notification processor that handles subscription and new messages from the UnifiedPush Service.
+ * Starting Push in the Application's onCreate is recommended.
+ */
+interface UnifiedPushProcessor {
+
+ /**
+ * New endpoint is received for [scope], notify the scope if any subscription parameter is updated
+ */
+ fun onNewEndpoint(scope: PushScope, newEndpoint: PushEndpoint)
+
+ /**
+ * A new push message has been received.
+ * The message contains the decrypted payload as sent by the
+ * application server.
+ */
+ fun onMessage(scope: PushScope, message: PushMessage)
+
+ /**
+ * An error has occurred during the registration.
+ */
+ fun onError(error: PushError)
+
+ /**
+ * The subscription has been removed from the distributor
+ */
+ fun onUnregistered(scope: PushScope)
+
+ /**
+ * Run [block] within the Processor coroutine scope
+ */
+ fun withCoroutine(block: ()->Unit)
+
+ companion object {
+ /**
+ * Initialize and installs the UnifiedPushProcessor into the application.
+ * This needs to be called in the application's onCreate before a push service has started.
+ */
+ fun install(processor: UnifiedPushProcessor) {
+ instance = processor
+ }
+
+ val isInit: Boolean
+ get() = instance != null
+
+ @Volatile
+ private var instance: UnifiedPushProcessor? = null
+
+ internal fun reset() {
+ instance = null
+ }
+ val requireInstance: UnifiedPushProcessor
+ get() = instance ?: throw IllegalStateException(
+ "You need to call UnifiedPushProcessor.install() on your Push instance from Application.onCreate().",
+ )
+ }
+}
+
+/**
+ * Various error types.
+ */
+sealed class PushError(override val message: String) : Exception() {
+ data class Registration(override val message: String) : PushError(message)
+ data class Network(override val message: String) : PushError(message)
+
+ /**
+ * @property cause Original exception from DB code.
+ */
+ data class DB(
+ override val cause: Throwable?,
+ override val message: String = cause?.message.orEmpty(),
+ ) : PushError(message)
+ data class ServiceUnavailable(override val message: String) : PushError(message)
+}
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/main/res/values/strings.xml b/mobile/android/android-components/components/feature/unifiedpush/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..b92a246afe
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/main/res/values/strings.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8" standalone="yes"?>
+<!-- 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/. -->
+<resources>
+ <!-- Name of the "notification channel" used for displaying a warning when the user has uninstalled the UnifiedPush service that was in used. This UnifiedPush service is used to get push notification, and has been selected when the user has enabled the feature. See https://developer.android.com/training/notify-user/channels -->
+ <string name="mozac_feature_unifiedpush_notification_channel">UnifiedPush</string>
+ <!-- Title of the notification channel used for displaying a warning when the user has uninstalled the UnifiedPush service that was in used. This UnifiedPush service is used to get push notification, and has been selected when the user has enabled the feature. -->
+ <string name="mozac_feature_unifiedpush_notification_title">UnifiedPush</string>
+ <!-- Text shown on the second row of the notification channel used for displaying a warning when the user has uninstalled the UnifiedPush service that was in used. This UnifiedPush service is used to get push notification, and has been selected when the user has enabled the feature. -->
+ <string name="mozac_feature_unifiedpush_notification_text">The push service application is no longer available. It may have been uninstalled. Please re-enable UnifiedPush to select a new service.</string>
+</resources>
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/test/java/mozilla/components/feature/unifiedpush/UnifiedPushFeatureTest.kt b/mobile/android/android-components/components/feature/unifiedpush/src/test/java/mozilla/components/feature/unifiedpush/UnifiedPushFeatureTest.kt
new file mode 100644
index 000000000000..67904440715e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/test/java/mozilla/components/feature/unifiedpush/UnifiedPushFeatureTest.kt
@@ -0,0 +1,435 @@
+/* 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.unifiedpush
+
+import android.database.SQLException
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import mozilla.components.concept.base.crash.CrashReporting
+import mozilla.components.concept.push.PushObserver
+import mozilla.components.support.test.any
+import mozilla.components.support.test.mock
+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.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.verify
+import org.unifiedpush.android.connector.data.PublicKeySet
+import org.unifiedpush.android.connector.data.PushEndpoint
+import org.unifiedpush.android.connector.data.PushMessage
+import kotlin.time.Duration.Companion.milliseconds
+
+@ExperimentalCoroutinesApi
+@RunWith(AndroidJUnit4::class)
+class UnifiedPushFeatureTest {
+
+ @get:Rule
+ val coroutinesTestRule = MainCoroutineRule()
+
+ private val db: SubscriptionsDB = mock()
+ private val up: DistributorManager = mock()
+
+ @Test
+ fun `initialize registers known scopes`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ up = up,
+ )
+ val scope = "testScope"
+ whenever(db.listSubscriptions()).thenReturn(
+ listOf(
+ SubscriptionsDB.Subscription(
+ scope = scope,
+ endpoint = "endpoint",
+ publicKey = "pub",
+ authKey = "auth",
+ appServerKey = null,
+ ),
+ ),
+ )
+
+ feature.initialize()
+ verify(up).registerScope(testContext, scope, null)
+ }
+
+ @Test
+ fun `shutdown removes and unsubscribes all, reset UnifiedPushProcessor`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ up = up,
+ )
+ feature.shutdown()
+ verify(db).removeAll()
+ verify(up).removeCurrentDistributor(testContext)
+ assertFalse(UnifiedPushProcessor.isInit)
+ }
+
+ @Test
+ fun `onNewEndpoint updates db`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ )
+ feature.onNewEndpoint("scope", PushEndpoint("url", PublicKeySet("pub", "auth")))
+ verify(db).updateEndpoint("scope", "url", "pub", "auth")
+ }
+
+ @Test
+ fun `onMessage notifies observers`() = runTestOnMain {
+ val message: PushMessage = mock()
+ val owner: LifecycleOwner = mock()
+ val lifecycle: Lifecycle = mock()
+ val observer: PushObserver = mock()
+ whenever(owner.lifecycle).thenReturn(lifecycle)
+ whenever(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED)
+ whenever(message.decrypted)
+ .thenReturn(false) // If we can't decrypt, we shouldn't notify observers.
+ .thenReturn(true)
+ val mockContent = "test".toByteArray()
+ val scope = "testScope"
+ whenever(message.content).thenReturn(mockContent)
+
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ )
+
+ feature.register(observer)
+
+ // First we receive a message that can't be decrypted
+ feature.onMessage(scope, message)
+ verify(observer, never()).onMessageReceived("testScope", mockContent)
+
+ // Then we receive a message that is decrypted
+ feature.onMessage(scope, message)
+ verify(observer).onMessageReceived("testScope", mockContent)
+ }
+
+ @Test
+ fun `subscribe calls UnifiedPush register`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ up = up,
+ )
+
+ val scope1 = "testScope1"
+ val scope2 = "testScope2"
+ val vapid = "vapid"
+ feature.subscribe(scope = scope1, appServerKey = vapid)
+ verify(db).add(any())
+ verify(up).registerScope(testContext, scope1, vapid)
+ feature.subscribe(scope = scope2)
+ verify(up).registerScope(testContext, scope2, null)
+ }
+
+ @Test
+ fun `subscribe with new endpoint invokes error callback when it timeouts`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ timeoutSubscriptionCallback = 100.milliseconds,
+ )
+ var invoked = false
+ var errorInvoked = false
+
+ feature.subscribe(
+ scope = "testScope",
+ onSubscribeError = {
+ errorInvoked = true
+ },
+ onSubscribe = {
+ invoked = true
+ },
+ )
+
+ delay(200)
+ verify(db).add(any())
+ assertFalse(invoked)
+ assertTrue(errorInvoked)
+ }
+
+ @Test
+ fun `subscribe invokes error callback on database exception`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ )
+ var invoked = false
+ var errorInvoked = false
+
+ whenever(db.add(any())).thenAnswer { throw SQLException("") }
+
+ feature.subscribe(
+ scope = "testScope",
+ appServerKey = null,
+ onSubscribeError = {
+ errorInvoked = true
+ },
+ onSubscribe = {
+ invoked = true
+ },
+ )
+
+ assertFalse(invoked)
+ assertTrue(errorInvoked)
+ }
+
+ @Test
+ fun `unsubscribe removes scope, unregister, and call onUnsubscribe`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ up = up,
+ )
+ var invoked = false
+ var errorInvoked = false
+ val scope = "testScope"
+
+ feature.unsubscribe(
+ scope = scope,
+ onUnsubscribeError = {
+ errorInvoked = true
+ },
+ onUnsubscribe = {
+ invoked = true
+ },
+ )
+
+ verify(db).remove(anyString())
+ verify(up).unregisterScope(testContext, scope)
+ assertTrue(invoked)
+ assertFalse(errorInvoked)
+ }
+
+ @Test
+ fun `unsubscribe invokes error callback on database exception`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ )
+ var invoked = false
+ var errorInvoked = false
+
+ whenever(db.remove(anyString())).thenAnswer { throw SQLException("") }
+
+ feature.unsubscribe(
+ scope = "testScope",
+ onUnsubscribeError = {
+ errorInvoked = true
+ },
+ onUnsubscribe = {
+ invoked = true
+ },
+ )
+
+ assertFalse(invoked)
+ assertTrue(errorInvoked)
+ }
+
+ @Test
+ fun `getSubscription invokes block with null when there is no subscription`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ )
+ var invoked = false
+
+ val scope = "testScope"
+ whenever(db.getSubscription(scope, null)).thenReturn(null)
+
+ feature.getSubscription(
+ scope = scope,
+ appServerKey = null,
+ ) {
+ invoked = it == null
+ }
+
+ assertTrue(invoked)
+ }
+
+ @Test
+ fun `getSubscription invokes block when there is a subscription`() = runTestOnMain {
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ )
+ var invoked = false
+
+ val scope = "testScope"
+ whenever(db.getSubscription(scope, null)).thenReturn(
+ SubscriptionsDB.Subscription(
+ scope = scope,
+ endpoint = "endpoint",
+ publicKey = "pub",
+ authKey = "auth",
+ appServerKey = null,
+ ),
+ )
+
+ feature.getSubscription(
+ scope = scope,
+ appServerKey = null,
+ ) {
+ invoked = it != null
+ }
+
+ assertTrue(invoked)
+ }
+
+ @Test
+ fun `new endpoint without pending subscribe notifies observers when it updates a subscription`() = runTestOnMain {
+ val owner: LifecycleOwner = mock()
+ val lifecycle: Lifecycle = mock()
+ val observers: PushObserver = mock()
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ )
+ whenever(owner.lifecycle).thenReturn(lifecycle)
+ whenever(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED)
+ // The registration is updated
+ whenever(db.updateEndpoint(any(), any(), any(), any()))
+ .thenReturn(false)
+ .thenReturn(true)
+
+ feature.register(observers)
+
+ // When the new endpoint doesn't update the registration, observers should not be notified
+ feature.onNewEndpoint("scope2", PushEndpoint("url", PublicKeySet("pub", "auth")))
+ delay(400)
+ verify(observers, never()).onSubscriptionChanged(any())
+
+ // When the new endpoint updates the registration, observers should be notified
+ feature.onNewEndpoint("scope2", PushEndpoint("url", PublicKeySet("pub", "auth")))
+ delay(400)
+ verify(observers).onSubscriptionChanged(any())
+ }
+
+ @Test
+ fun `new endpoint with pending subscribe doesn't notify observers`() = runTestOnMain {
+ val owner: LifecycleOwner = mock()
+ val lifecycle: Lifecycle = mock()
+ val observers: PushObserver = mock()
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ db = db,
+ )
+ whenever(owner.lifecycle).thenReturn(lifecycle)
+ whenever(lifecycle.currentState).thenReturn(Lifecycle.State.STARTED)
+ // The registration is updated
+ whenever(db.updateEndpoint(any(), any(), any(), any()))
+ .thenReturn(true)
+
+ feature.register(observers)
+
+ // If there is no pending subscribe, observer is called
+ feature.onNewEndpoint("scope1", PushEndpoint("url", PublicKeySet("pub", "auth")))
+ delay(400)
+ verify(observers).onSubscriptionChanged("scope1")
+
+ // When we receive a new endpoint, with a pending subscribe, observers should not be notified
+ feature.subscribe(
+ scope = "scope2",
+ appServerKey = null,
+ )
+ feature.onNewEndpoint("scope2", PushEndpoint("url", PublicKeySet("pub", "auth")))
+ delay(400)
+ verify(observers, never()).onSubscriptionChanged("scope2")
+ }
+
+ @Test
+ fun `crash reporter is notified of errors`() = runTestOnMain {
+ val crashReporter: CrashReporting = mock()
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ crashReporter = crashReporter,
+ )
+
+ feature.onError(PushError.Network("Bad things happened!"))
+
+ verify(crashReporter).submitCaughtException(any<PushError.Network>())
+ }
+
+ @Test
+ fun `DB errors are submitted to crash reporter`() = runTestOnMain {
+ val crashReporter: CrashReporting = mock()
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ crashReporter = crashReporter,
+ up = up,
+ )
+
+ whenever(up.registerScope(any(), any(), any())).thenAnswer { throw SQLException("test") }
+ feature.subscribe("123") {}
+ verify(crashReporter).submitCaughtException(any<PushError.DB>())
+ }
+
+ @Test
+ fun `Other errors are not reported`() = runTestOnMain {
+ val crashReporter: CrashReporting = mock()
+ val feature = UnifiedPushFeature(
+ context = testContext,
+ coroutineContext = coroutineContext,
+ crashReporter = crashReporter,
+ up = up,
+ )
+
+ whenever(up.registerScope(any(), any(), any())).thenAnswer { throw PushError.Network("test") }
+ feature.subscribe("123") {}
+ verify(crashReporter, never()).submitCaughtException(any<PushError.Network>())
+ }
+
+ @Test
+ fun `transform db subscription to PushSubscription`() {
+ val response = SubscriptionsDB.Subscription(
+ "992a0f0542383f1ea5ef51b7cf4ae6c4",
+ "https://mozilla.com",
+ "123",
+ "456",
+ null,
+ )
+ val sub = response.toPushSubscription()
+
+ assertEquals(response.endpoint, sub?.endpoint)
+ assertEquals(response.authKey, sub?.authKey)
+ assertEquals(response.publicKey, sub?.publicKey)
+ assertEquals(response.scope, sub?.scope)
+ assertNull(sub?.appServerKey)
+
+ val sub2 = response.copy(appServerKey = "key").toPushSubscription()
+
+ assertEquals("key", sub2?.appServerKey)
+ }
+}
diff --git a/mobile/android/android-components/components/feature/unifiedpush/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/feature/unifiedpush/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 000000000000..cf1c399ea81e
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/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/unifiedpush/src/test/resources/robolectric.properties b/mobile/android/android-components/components/feature/unifiedpush/src/test/resources/robolectric.properties
new file mode 100644
index 000000000000..3f67ea5ac1bf
--- /dev/null
+++ b/mobile/android/android-components/components/feature/unifiedpush/src/test/resources/robolectric.properties
@@ -0,0 +1 @@
+sdk=35