diff --git a/patches/fenix-overlay/app/src/main/res/values/ironfox_strings.xml b/patches/fenix-overlay/app/src/main/res/values/ironfox_strings.xml
index c71e448..81d714d 100644
--- a/patches/fenix-overlay/app/src/main/res/values/ironfox_strings.xml
+++ b/patches/fenix-overlay/app/src/main/res/values/ironfox_strings.xml
@@ -29,6 +29,14 @@
Enable WebAssembly (WASM)
+
+
+ Enable UnifiedPush
+
+ Use UnifiedPush
+
+ UnifiedPush setting modified. Quitting the application to apply changes…
+
Customize IronFox
Customize IronFox to suit your liking
diff --git a/patches/unifiedpush.patch b/patches/unifiedpush.patch
new file mode 100644
index 0000000..c7982f9
--- /dev/null
+++ b/patches/unifiedpush.patch
@@ -0,0 +1,2749 @@
+diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
+index f012e43dc0..fc3b947485 100644
+--- a/gradle/libs.versions.toml
++++ b/gradle/libs.versions.toml
+@@ -107,6 +107,8 @@ sentry = "8.9.0"
+ commons-exec = "1.3"
+ tomlj = "1.1.0"
+
++unifiedpush = "3.0.7"
++
+ [libraries]
+
+ kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+@@ -230,6 +232,7 @@ thirdparty-sentry = { group = "io.sentry", name = "sentry-android", version.ref
+ thirdparty-zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
+ thirdparty-disklrucache = { group = "com.jakewharton", name = "disklrucache", version.ref = "disklrucache" }
+ thirdparty-androidsvg = { group = "com.caverock", name = "androidsvg-aar", version.ref = "androidsvg" }
++thirdparty-unifiedpush = { group = "org.unifiedpush.android", name = "connector", version.ref = "unifiedpush" }
+
+ desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar-jdk-libs" }
+ firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging", version.ref = "firebase-messaging" }
+diff --git a/mobile/android/android-components/.buildconfig.yml b/mobile/android/android-components/.buildconfig.yml
+index 26e76a18d2..1dcd401764 100644
+--- a/mobile/android/android-components/.buildconfig.yml
++++ b/mobile/android/android-components/.buildconfig.yml
+@@ -594,6 +594,7 @@ projects:
+ - support-utils
+ - tooling-lint
+ - ui-icons
++ - feature-unifiedpush
+ feature-addons:
+ description: A feature that provides for managing add-ons.
+ path: components/feature/addons
+@@ -2409,3 +2410,13 @@ projects:
+ - tooling-lint
+ - ui-colors
+ - ui-icons
++ feature-unifiedpush:
++ description: Feature that implements push notifications with UnifiedPush.
++ path: components/feature/unifiedpush
++ publish: true
++ upstream_dependencies:
++ - concept-base
++ - concept-push
++ - support-base
++ - support-test
++ - 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 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 613e5aa1fdd4..4322461f50a2 100644
+--- a/mobile/android/android-components/components/feature/accounts-push/build.gradle
++++ b/mobile/android/android-components/components/feature/accounts-push/build.gradle
+@@ -33,6 +33,7 @@ dependencies {
+ implementation project(':support-base')
+ implementation project(':concept-push')
+ implementation project(':feature-push')
++ implementation project(':feature-unifiedpush')
+
+ implementation libs.androidx.work.runtime
+ implementation libs.androidx.lifecycle.process
+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 92634857c5..92d0a0962a 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,15 +25,17 @@ import mozilla.components.concept.sync.DeviceConstellationObserver
+ import mozilla.components.concept.sync.DevicePushSubscription
+ import mozilla.components.concept.sync.OAuthAccount
+ 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.withConstellation
+ import mozilla.components.support.base.log.logger.Logger
+ import mozilla.components.support.base.utils.SharedPreferencesCache
+ import org.json.JSONObject
+ 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"
+@@ -56,7 +58,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 owner: LifecycleOwner = ProcessLifecycleOwner.get(),
+@@ -77,7 +79,7 @@ class FxaPushSupportFeature(
+ fun initialize() = coroutineScope.launch {
+ val scopeValue = pushScope.value()
+
+- val autoPushObserver = AutoPushObserver(accountManager, pushFeature, scopeValue)
++ val fxaPushObserver = FxaPushObserver(accountManager, pushFeature, scopeValue)
+
+ val accountObserver = AccountObserver(
+ context,
+@@ -91,7 +93,7 @@ class FxaPushSupportFeature(
+ coroutineScope.launch(Main) {
+ accountManager.register(accountObserver)
+
+- pushFeature.register(autoPushObserver, owner, autoPause)
++ pushFeature.register(fxaPushObserver, owner, autoPause)
+ }
+ }
+
+@@ -106,7 +108,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,
+@@ -114,7 +116,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) {
+@@ -153,7 +155,7 @@ internal class AccountObserver(
+ * it's OK to call this somewhat frequently.
+ */
+ internal fun pushSubscribe(
+- push: AutoPushFeature,
++ push: PushSubscriptionProcessor,
+ account: OAuthAccount,
+ scope: String,
+ crashReporter: CrashReporting?,
+@@ -196,7 +198,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),
+@@ -240,12 +242,12 @@ 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,
+-) : 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) {
+@@ -384,7 +386,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 30e403088831..532382a2fbbb 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..f22cd6f168 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 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 .
++
++### 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 000000000000..7358ed93a2a0
+--- /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(':concept-push')
++
++ implementation ComponentsDependencies.mozilla_appservices_push
++
++ // Remove when the MessageBus is implemented somewhere else.
++ implementation project(':support-base')
++ implementation project(':ui-icons')
++
++ implementation libs.kotlin.coroutines
++ implementation libs.androidx.work.runtime
++ implementation libs.androidx.lifecycle.runtime
++ implementation (libs.thirdparty.unifiedpush) {
++ exclude group: "com.google.protobuf", module: "protobuf-java"
++ }
++
++ testImplementation project(':support-test')
++
++ testImplementation libs.androidx.test.core
++ testImplementation libs.androidx.test.junit
++ testImplementation libs.testing.robolectric
++ testImplementation libs.testing.coroutines
++}
++
++apply from: '../../../android-lint.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 @@
++
++
++
++
++
++
++
++
++
++
++
++
++
+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 {
++ 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>()
++ private val ackFlow = MutableSharedFlow()
++
++ 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 @@
++
++
++
++
++ UnifiedPush
++
++ UnifiedPush
++
++ The push service application is no longer available. It may have been uninstalled. Please re-enable UnifiedPush to select a new service.
++
+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())
++ }
++
++ @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())
++ }
++
++ @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())
++ }
++
++ @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
+diff --git a/mobile/android/fenix/.buildconfig.yml b/mobile/android/fenix/.buildconfig.yml
+index b5aa2ce3a4..d863ee1bbf 100644
+--- a/mobile/android/fenix/.buildconfig.yml
++++ b/mobile/android/fenix/.buildconfig.yml
+@@ -95,6 +95,7 @@ projects:
+ - ui-icons
+ - ui-tabcounter
+ - ui-widgets
++ - feature-unifiedpush
+ variants:
+ - apks:
+ - abi: arm64-v8a
+diff --git a/mobile/android/fenix/app/build.gradle b/mobile/android/fenix/app/build.gradle
+index d9a6045423..c8351a14bd 100644
+--- a/mobile/android/fenix/app/build.gradle
++++ b/mobile/android/fenix/app/build.gradle
+@@ -654,6 +654,8 @@ dependencies {
+ implementation project(':lib-dataprotect')
+ testImplementation project(':support-test-fakes')
+
++ implementation project(':feature-unifiedpush')
++
+ debugImplementation libs.leakcanary
+ debugImplementation libs.androidx.compose.ui.tooling
+
+diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/FenixApplication.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/FenixApplication.kt
+index 13db9f3c83..526e8f1bf4 100644
+--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/FenixApplication.kt
++++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/FenixApplication.kt
+@@ -39,7 +39,7 @@ import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider
+ import mozilla.components.concept.base.crash.Breadcrumb
+ import mozilla.components.concept.engine.webextension.WebExtension
+ import mozilla.components.concept.engine.webextension.isUnsupported
+-import mozilla.components.concept.push.PushProcessor
++//import mozilla.components.concept.push.PushProcessor
+ import mozilla.components.concept.storage.FrecencyThresholdOption
+ import mozilla.components.feature.addons.migration.DefaultSupportedAddonsChecker
+ import mozilla.components.feature.addons.update.GlobalAddonDependencyProvider
+@@ -491,7 +491,7 @@ open class FenixApplication : LocaleAwareApplication(), Provider {
+ logger.info("AutoPushFeature is configured, initializing it...")
+
+ // Install the AutoPush singleton to receive messages.
+- PushProcessor.install(it)
++// PushProcessor.install(it)
+
+ WebPushEngineIntegration(components.core.engine, it).start()
+
+diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt
+index 1e64545a1220..689a28c1888e 100644
+--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt
++++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/BackgroundServices.kt
+@@ -219,8 +219,8 @@ class BackgroundServices(
+ accountManager.register(AccountManagerReadyObserver(accountManagerAvailableQueue))
+
+ // Enable push if it's configured.
+- push.feature?.let { autoPushFeature ->
+- FxaPushSupportFeature(context, accountManager, autoPushFeature, crashReporter)
++ push.feature?.let { pushFeature ->
++ FxaPushSupportFeature(context, accountManager, pushFeature, crashReporter)
+ .initialize()
+ }
+
+diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Push.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Push.kt
+index f99ef999cf..7c59fe8d6d 100644
+--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Push.kt
++++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/Push.kt
+@@ -8,31 +8,92 @@ import android.content.Context
+ import androidx.core.net.toUri
+ import mozilla.components.feature.push.AutoPushFeature
+ import mozilla.components.feature.push.Protocol
+-import mozilla.components.feature.push.PushConfig
+ import mozilla.components.lib.crash.CrashReporter
+ import mozilla.components.support.base.log.logger.Logger
+ import org.mozilla.fenix.R
+ import org.mozilla.fenix.ext.settings
+ import org.mozilla.fenix.perf.lazyMonitored
+ import org.mozilla.fenix.push.FirebasePushService
++import android.app.Activity
++import android.os.StrictMode
++import mozilla.components.concept.push.Pusher
++import mozilla.components.feature.push.AutoPushConfig
++import mozilla.components.feature.unifiedpush.UnifiedPushFeature
++import mozilla.components.feature.unifiedpush.UnifiedPushNotification
++import mozilla.components.support.base.android.NotificationsDelegate
++import org.mozilla.fenix.ext.components
++import org.mozilla.fenix.perf.StrictModeManager
++import org.mozilla.fenix.utils.Settings
+
+ /**
+ * Component group for push services. These components use services that strongly depend on
+ * push messaging (e.g. WebPush, SendTab).
+ */
+-class Push(val context: Context, crashReporter: CrashReporter) {
+- val feature by lazyMonitored {
+- pushConfig?.let { config ->
++class Push(val context: Context, crashReporter: CrashReporter, val settings: Settings = context.settings(), val strictMode: StrictModeManager = context.components.strictMode, val notificationsDelegate: NotificationsDelegate = context.components.notificationsDelegate, val iAutoPushFeature: AutoPushFeature? = null, val iUnifiedPushFeature: UnifiedPushFeature? = null) {
++ private val logger = Logger("Push")
++
++ val feature: Pusher?
++ get() = strictMode.resetAfter(StrictMode.allowThreadDiskReads()) {
++ if (settings.useUnifiedPush) {
++ unifiedPushFeature.also { feature ->
++ if (feature.isAvailable(context)) {
++ logger.info("Using UnifiedPush")
++ autoPushFeature?.let {
++ it.initialize()
++ feature.migrateFrom(it)
++ }
++ } else {
++ logger.warn(
++ "UnifiedPush distributor has been uninstalled, " +
++ "the feature is disabled for the moment."
++ )
++ val notificationId = UnifiedPushNotification.getNotificationId(context)
++ val notification =
++ UnifiedPushNotification.createMissingServiceNotification(context)
++ notificationsDelegate.notify(
++ notificationId = notificationId,
++ notification = notification
++ )
++ settings.useUnifiedPush = false
++ }
++ }
++ } else {
++ logger.info("Using AutoPush")
++ autoPushFeature?.also { feature ->
++ feature.migrateFrom(unifiedPushFeature)
++ }
++ }
++ }
++
++ private val autoPushFeature by lazyMonitored {
++ iAutoPushFeature
++ ?: autoPushConfig?.let { config ->
+ AutoPushFeature(
+ context = context,
+- service = pushService,
++ service = firebasePushService,
+ config = config,
+ crashReporter = crashReporter,
+ )
+ }
+ }
+
+- private val pushConfig: PushConfig? by lazyMonitored {
++ private val unifiedPushFeature by lazyMonitored {
++ iUnifiedPushFeature
++ ?: UnifiedPushFeature(
++ context = context,
++ disableRateLimit = true,
++ crashReporter = crashReporter,
++ )
++ }
++
++ private fun Pusher.migrateFrom(old: Pusher) {
++ old.forEachScopes { scope, vapid ->
++ this.subscribe(scope, vapid)
++ }
++ old.shutdown()
++ }
++
++ private val autoPushConfig: AutoPushConfig? by lazyMonitored {
+ val logger = Logger("PushConfig")
+ val projectIdKey = context.getString(R.string.pref_key_push_project_id)
+ val resId = context.resources.getIdentifier(projectIdKey, "string", context.packageName)
+@@ -43,12 +104,12 @@ class Push(val context: Context, crashReporter: CrashReporter) {
+
+ logger.debug("Creating push configuration for autopush.")
+ val projectId = context.resources.getString(resId)
+- val serverOverride = context.settings().overridePushServer
++ val serverOverride = settings.overridePushServer
+ if (serverOverride.isEmpty()) {
+- PushConfig(projectId)
++ AutoPushConfig(projectId)
+ } else {
+ val uri = serverOverride.toUri()
+- PushConfig(
++ AutoPushConfig(
+ projectId,
+ serverHost = uri.getHost() ?: "",
+ protocol = if (uri.getScheme() == "http") {
+@@ -61,5 +122,13 @@ class Push(val context: Context, crashReporter: CrashReporter) {
+ }
+ }
+
+- private val pushService by lazyMonitored { FirebasePushService() }
++ private val firebasePushService by lazyMonitored { FirebasePushService() }
++
++ fun switchToUnifiedPush(activity: Activity, callback: (Boolean) -> Unit) {
++ unifiedPushFeature.useDefaultDistributor(activity, callback)
++ }
++
++ fun switchToAutoPush(context: Context) {
++ unifiedPushFeature.removeCurrentDistributor(context)
++ }
+ }
+diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/push/PushFxaIntegration.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/push/PushFxaIntegration.kt
+index 3df203e6e7..27221ce39b 100644
+--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/push/PushFxaIntegration.kt
++++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/push/PushFxaIntegration.kt
+@@ -13,12 +13,13 @@ import mozilla.components.concept.sync.AuthType
+ import mozilla.components.concept.sync.OAuthAccount
+ import mozilla.components.feature.accounts.push.FxaPushSupportFeature
+ import mozilla.components.feature.accounts.push.SendTabFeature
+-import mozilla.components.feature.push.AutoPushFeature
+-import mozilla.components.feature.push.PushScope
+ import mozilla.components.service.fxa.manager.FxaAccountManager
+ import mozilla.components.service.fxa.manager.ext.withConstellation
+ import org.mozilla.fenix.components.BackgroundServices
+ import org.mozilla.fenix.components.Push
++import mozilla.components.concept.push.PushObserver
++import mozilla.components.concept.push.PushScope
++import mozilla.components.concept.push.Pusher
+
+ /**
+ * A lazy initializer for FxaAccountManager if it isn't already initialized.
+@@ -54,7 +55,7 @@ import org.mozilla.fenix.components.Push
+ * assurances, and most importantly, maintainable.
+ */
+ class PushFxaIntegration(
+- private val pushFeature: AutoPushFeature,
++ private val pushFeature: Pusher,
+ lazyAccountManager: Lazy,
+ ) {
+ private val observer =
+@@ -79,8 +80,8 @@ class PushFxaIntegration(
+ */
+ internal class OneTimePushMessageObserver(
+ private val lazyAccountManager: Lazy,
+- private val pushFeature: AutoPushFeature,
+-) : AutoPushFeature.Observer {
++ private val pushFeature: Pusher,
++) : PushObserver {
+ override fun onMessageReceived(scope: PushScope, message: ByteArray?) {
+ // Ignore empty push messages.
+ val rawBytes = message ?: return
+diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/push/WebPushEngineIntegration.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/push/WebPushEngineIntegration.kt
+index 6681840f10..35aa2dab6c 100644
+--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/push/WebPushEngineIntegration.kt
++++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/push/WebPushEngineIntegration.kt
+@@ -12,19 +12,21 @@ import mozilla.components.concept.engine.Engine
+ import mozilla.components.concept.engine.webpush.WebPushDelegate
+ import mozilla.components.concept.engine.webpush.WebPushHandler
+ import mozilla.components.concept.engine.webpush.WebPushSubscription
+-import mozilla.components.feature.push.AutoPushFeature
+-import mozilla.components.feature.push.AutoPushSubscription
+-import mozilla.components.feature.push.PushScope
+ import mozilla.components.support.base.log.logger.Logger
++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
+
+ /**
+ * Engine integration with the push feature to enable WebPush support.
+ */
+ class WebPushEngineIntegration(
+ private val engine: Engine,
+- private val pushFeature: AutoPushFeature,
++ private val pushFeature: Pusher,
+ private val coroutineScope: CoroutineScope = MainScope(),
+-) : AutoPushFeature.Observer {
++) : PushObserver {
+
+ private var handler: WebPushHandler? = null
+ private val delegate = WebPushEngineDelegate(pushFeature)
+@@ -53,7 +55,7 @@ class WebPushEngineIntegration(
+ }
+
+ internal class WebPushEngineDelegate(
+- private val pushFeature: AutoPushFeature,
++ private val pushFeature: PushSubscriptionProcessor,
+ ) : WebPushDelegate {
+ private val logger = Logger("WebPushEngineDelegate")
+
+@@ -95,7 +97,7 @@ internal class WebPushEngineDelegate(
+ }
+ }
+
+-internal fun AutoPushSubscription.toEnginePushSubscription() = WebPushSubscription(
++internal fun PushSubscription.toEnginePushSubscription() = WebPushSubscription(
+ scope = this.scope,
+ publicKey = this.publicKey.toDecodedByteArray(),
+ endpoint = this.endpoint,
+diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt
+index 43bea16286..1bbc5054b5 100644
+--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt
++++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SecretSettingsFragment.kt
+@@ -256,6 +256,12 @@ class SecretSettingsFragment : PreferenceFragmentCompat() {
+ )
+ onPreferenceChangeListener = SharedPreferenceUpdater()
+ }
++
++ requirePreference(R.string.pref_key_enable_unifiedpush).apply {
++ isVisible = true
++ isChecked = context.settings().enableUnifiedPush
++ onPreferenceChangeListener = SharedPreferenceUpdater()
++ }
+ }
+
+ override fun onPreferenceTreeClick(preference: Preference): Boolean {
+diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt
+index 325e5d2141..44a6263c5b 100644
+--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt
++++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt
+@@ -73,6 +73,7 @@ import org.mozilla.fenix.snackbar.SnackbarBinding
+ import org.mozilla.fenix.utils.Settings
+ import kotlin.system.exitProcess
+ import org.mozilla.fenix.GleanMetrics.Settings as SettingsMetrics
++import org.mozilla.fenix.components.Push
+
+ @Suppress("LargeClass", "TooManyFunctions")
+ class SettingsFragment : PreferenceFragmentCompat() {
+@@ -409,6 +410,47 @@ class SettingsFragment : PreferenceFragmentCompat() {
+ null
+ }
+
++ resources.getString(R.string.pref_key_use_unifiedpush) -> {
++ val context = requireActivity()
++ context.settings().apply { useUnifiedPush = !useUnifiedPush }
++ val alert = AlertDialog.Builder(context).apply {
++ setTitle(context.getString(R.string.preferences_unifiedpush))
++ setMessage(context.getString(R.string.quit_application))
++ setNegativeButton(android.R.string.cancel) { dialog: DialogInterface, _ ->
++ dialog.cancel()
++ }
++
++ setPositiveButton(android.R.string.ok) { _, _ ->
++ Toast.makeText(
++ context,
++ getString(R.string.toast_change_unifiedpush_done),
++ Toast.LENGTH_LONG,
++ ).show()
++
++ Handler(Looper.getMainLooper()).postDelayed(
++ {
++ exitProcess(0)
++ },
++ DEFAULT_EXIT_DELAY,
++ )
++ }
++ create().withCenterAlignedButtons()
++ }
++ if (context.settings().useUnifiedPush) {
++ requireComponents.push.switchToUnifiedPush(context) { success ->
++ if (!success) {
++ context.settings().useUnifiedPush = false
++ } else {
++ alert.show()
++ }
++ }
++ } else {
++ requireComponents.push.switchToAutoPush(context)
++ alert.show()
++ }
++ null
++ }
++
+ // Only displayed when secret settings are enabled
+ resources.getString(R.string.pref_key_override_amo_collection) -> {
+ val context = requireContext()
+@@ -547,6 +589,12 @@ class SettingsFragment : PreferenceFragmentCompat() {
+ findPreference(getPreferenceKey(R.string.pref_key_start_profiler))
+
+ with(requireContext().settings()) {
++ findPreference(
++ getPreferenceKey(R.string.pref_key_use_unifiedpush),
++ )?.apply {
++ isVisible = enableUnifiedPush
++ isChecked = useUnifiedPush
++ }
+ findPreference(
+ getPreferenceKey(R.string.pref_key_nimbus_experiments),
+ )?.isVisible = showSecretDebugMenuThisSession
+@@ -787,5 +835,6 @@ class SettingsFragment : PreferenceFragmentCompat() {
+ private const val SCROLL_INDICATOR_DELAY = 10L
+ private const val FXA_SYNC_OVERRIDE_EXIT_DELAY = 2000L
+ private const val AMO_COLLECTION_OVERRIDE_EXIT_DELAY = 3000L
++ private const val DEFAULT_EXIT_DELAY = 2000L
+ }
+ }
+diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
+index 9197b274db..420da7a4ab 100644
+--- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
++++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt
+@@ -1986,6 +1986,16 @@ class Settings(private val appContext: Context) : PreferencesHolder {
+ default = 0,
+ )
+
++ var enableUnifiedPush by booleanPreference(
++ key = appContext.getPreferenceKey(R.string.pref_key_enable_unifiedpush),
++ default = true,
++ )
++
++ var useUnifiedPush by booleanPreference(
++ key = appContext.getPreferenceKey(R.string.pref_key_use_unifiedpush),
++ default = false,
++ )
++
+ /**
+ * Indicates if the Compose Homepage is enabled.
+ */
+diff --git a/mobile/android/fenix/app/src/main/res/values/preference_keys.xml b/mobile/android/fenix/app/src/main/res/values/preference_keys.xml
+index a55bf0589b17..0e3add9a420e 100644
+--- a/mobile/android/fenix/app/src/main/res/values/preference_keys.xml
++++ b/mobile/android/fenix/app/src/main/res/values/preference_keys.xml
+@@ -414,6 +414,8 @@
+ pref_key_enable_recent_searches
+ pref_key_enable_shortcuts_suggestions
+ pref_key_enable_composable_toolbar"
++ pref_key_enable_unifiedpush
++ pref_key_use_unifiedpush
+
+
+ pref_key_enable_compose_logins
+diff --git a/mobile/android/fenix/app/src/main/res/xml/preferences.xml b/mobile/android/fenix/app/src/main/res/xml/preferences.xml
+index 4c6b14bf87..204c648354 100644
+--- a/mobile/android/fenix/app/src/main/res/xml/preferences.xml
++++ b/mobile/android/fenix/app/src/main/res/xml/preferences.xml
+@@ -184,6 +184,13 @@
+ app:iconSpaceReserved="false"
+ android:title="@string/preferences_external_download_manager" />
+
++
++
+
++
+ ()
++ val unifiedPushFeature = mock()
++ // The distributor isn't available anymore
++ whenever(unifiedPushFeature.isAvailable(any())).thenReturn(false)
++ val settings = Settings(testContext)
++ settings.useUnifiedPush = true
++ assertTrue(settings.useUnifiedPush)
++ Push(
++ testContext,
++ crashReporter = mock(),
++ settings = settings,
++ strictMode = TestStrictModeManager(),
++ notificationsDelegate = notificationsDelegate,
++ iUnifiedPushFeature = unifiedPushFeature
++ ).feature
++ assertFalse(settings.useUnifiedPush)
++ verify(notificationsDelegate).notify(any(), anyInt(), any(), any(), any(), eq(false))
++ }
++
++ @Test
++ fun `AutoPush subscriptions are migrated to UnifiedPush when UnifiedPush is enabled`() = runTest {
++ val unifiedPushFeature = mock()
++ val autoPushFeature = mock()
++ whenever(unifiedPushFeature.isAvailable(any())).thenReturn(true)
++ whenever(autoPushFeature.forEachScopes(any())).then { i ->
++ i.getArgument<(PushScope, String?) -> Unit>(0)("test", "test")
++ }
++ val settings = Settings(testContext)
++ // UnifiedPush is enabled
++ settings.useUnifiedPush = true
++ assertTrue(settings.useUnifiedPush)
++ Push(
++ testContext,
++ crashReporter = mock(),
++ settings = settings,
++ strictMode = TestStrictModeManager(),
++ notificationsDelegate = mock(),
++ iUnifiedPushFeature = unifiedPushFeature,
++ iAutoPushFeature = autoPushFeature
++ ).feature
++ verify(autoPushFeature).forEachScopes(any())
++ verify(autoPushFeature).shutdown()
++ verify(unifiedPushFeature).subscribe(eq("test"), eq("test"), any(), any())
++ }
++
++ @Test
++ fun `UnifiedPush subscriptions are migrated to AutoPush when UnifiedPush is disabled`() = runTest {
++ val unifiedPushFeature = mock()
++ val autoPushFeature = mock()
++ whenever(unifiedPushFeature.forEachScopes(any())).then { i ->
++ i.getArgument<(PushScope, String?) -> Unit>(0)("test", "test")
++ }
++ val settings = Settings(testContext)
++ // UnifiedPush is disabled
++ settings.useUnifiedPush = false
++ assertFalse(settings.useUnifiedPush)
++ Push(
++ testContext,
++ crashReporter = mock(),
++ settings = settings,
++ strictMode = TestStrictModeManager(),
++ notificationsDelegate = mock(),
++ iUnifiedPushFeature = unifiedPushFeature,
++ iAutoPushFeature = autoPushFeature
++ ).feature
++ verify(unifiedPushFeature).forEachScopes(any())
++ verify(unifiedPushFeature).shutdown()
++ verify(autoPushFeature).subscribe(eq("test"), eq("test"), any(), any())
++ }
++}
diff --git a/scripts/patches.yaml b/scripts/patches.yaml
index 0c484c0..f908812 100644
--- a/scripts/patches.yaml
+++ b/scripts/patches.yaml
@@ -85,6 +85,13 @@ patches:
reason: "To remove unnecessary tracking libraries and dependencies on proprietary services."
effect: "Improves privacy and protects the freedom of users."
category: "Dependency"
+
+ - file: "unifiedpush.patch"
+ name: "UnifiedPush"
+ description: "Adds support for UnifiedPush."
+ reason: "To allow users to receive notifications without the use of a proprietary Google service."
+ effect: "Improves usability for users, by allowing them to receive push notifications if desired, and improves freedom, by allowing users to decide which push server/implementation they would like to use."
+ category: "Dependency"
# Privacy
- file: "fenix-disable-telemetry.patch"