push notifications initial

This commit is contained in:
Fabian Freund
2026-04-16 11:09:02 +02:00
parent 2e1b29a463
commit e387008de7
13 changed files with 3272 additions and 2039 deletions
@@ -139,6 +139,10 @@ dependencies {
implementation "org.mozilla.components:lib-publicsuffixlist:$mozillaComponentsVersion"
implementation "org.mozilla.components:service-firefox-accounts:$mozillaComponentsVersion"
implementation "org.mozilla.components:support-appservices:$mozillaComponentsVersion"
implementation "org.ironfoxoss.unifiedpush:unifiedpush:$mozillaComponentsVersion"
implementation ('org.unifiedpush.android:connector:3.3.2') {
exclude group: 'com.google.protobuf', module: 'protobuf-java'
}
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.3.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.2.0'
@@ -12,6 +12,7 @@ import eu.weblibre.flutter_mozilla_components.components.Core
import eu.weblibre.flutter_mozilla_components.components.BackgroundServices
import eu.weblibre.flutter_mozilla_components.components.Events
import eu.weblibre.flutter_mozilla_components.components.Features
import eu.weblibre.flutter_mozilla_components.components.Push
import eu.weblibre.flutter_mozilla_components.components.Search
import eu.weblibre.flutter_mozilla_components.components.Services
import eu.weblibre.flutter_mozilla_components.components.UseCases
@@ -74,6 +75,7 @@ class Components(val profileApplicationContext: ProfileContext,
}
val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) }
val search by lazy { Search(profileApplicationContext, core, useCases) }
val push by lazy { Push(this) }
var mainBrowserEngineView: EngineView? = null
var externalAppEngineView: EngineView? = null
@@ -289,6 +289,8 @@ object GlobalComponents {
} else {
restorePreviousCustomTabs()
}
newComponents.push.initialize()
}
@Synchronized
@@ -456,6 +456,23 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
currentActivity.startActivity(intent)
}
override fun pickUnifiedPushDistributor(callback: (Result<Boolean>) -> Unit) {
val currentActivity = activity
if (currentActivity == null) {
callback(Result.success(false))
return
}
runCatching {
components.push.pickDistributor(currentActivity) { success ->
callback(Result.success(success))
}
}.onFailure { error ->
logger.error("$TAG: Failed to pick UnifiedPush distributor", error)
callback(Result.failure(error))
}
}
override fun shutdown() {
logger.debug("$TAG: Shutting down GeckoView engine")
@@ -0,0 +1,53 @@
/* 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 https://mozilla.org/MPL/2.0/. */
package eu.weblibre.flutter_mozilla_components.components
import android.app.Activity
import eu.weblibre.flutter_mozilla_components.Components
import eu.weblibre.flutter_mozilla_components.push.WebPushEngineIntegration
import java.util.concurrent.atomic.AtomicBoolean
import org.ironfoxoss.unifiedpush.UnifiedPushFeature
import org.unifiedpush.android.connector.UnifiedPush
/**
* Component group for web push services backed by UnifiedPush.
*/
class Push(
private val components: Components,
) {
private val initialized = AtomicBoolean(false)
private val feature by lazy {
UnifiedPushFeature(
context = components.profileApplicationContext,
disableRateLimit = true,
)
}
private val webPushEngineIntegration by lazy {
WebPushEngineIntegration(components.core.engine, feature)
}
fun initialize() {
if (!initialized.compareAndSet(false, true)) {
return
}
// Ensure the store-side WebNotificationFeature is installed before push events arrive.
components.core.store
webPushEngineIntegration.start()
feature.initialize()
}
fun pickDistributor(activity: Activity, callback: (Boolean) -> Unit) {
initialize()
UnifiedPush.tryPickDistributor(activity) { success ->
if (success) {
feature.renewRegistration()
}
callback(success)
}
}
}
@@ -5656,6 +5656,7 @@ interface GeckoBrowserApi {
fun openInCustomTab(url: String, private: Boolean, contextId: String?)
fun isDefaultBrowser(): Boolean
fun requestDefaultBrowser()
fun pickUnifiedPushDistributor(callback: (Result<Boolean>) -> Unit)
fun shutdown()
companion object {
@@ -5790,6 +5791,24 @@ interface GeckoBrowserApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.pickUnifiedPushDistributor$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.pickUnifiedPushDistributor{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeckoPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeckoPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoBrowserApi.shutdown$separatedMessageChannelSuffix", codec)
if (api != null) {
@@ -0,0 +1,113 @@
/* 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 https://mozilla.org/MPL/2.0/. */
package eu.weblibre.flutter_mozilla_components.push
import android.util.Base64
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
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.support.base.log.logger.Logger
import org.ironfoxoss.unifiedpush.AutoPushSubscription
import org.ironfoxoss.unifiedpush.PushObserver
import org.ironfoxoss.unifiedpush.PushScope
import org.ironfoxoss.unifiedpush.PushSubscriptionProcessor
import org.ironfoxoss.unifiedpush.Pusher
/**
* Engine integration with UnifiedPush-backed web push support.
*/
class WebPushEngineIntegration(
private val engine: Engine,
private val pushFeature: Pusher,
private val coroutineScope: CoroutineScope = MainScope(),
stringDecoder: (String) -> ByteArray =
{ s -> Base64.decode(s.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) },
byteArrayEncoder: (ByteArray) -> String =
{ ba -> Base64.encodeToString(ba, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) },
) : PushObserver {
private var handler: WebPushHandler? = null
private val delegate = WebPushEngineDelegate(pushFeature, stringDecoder, byteArrayEncoder)
fun start() {
handler = engine.registerWebPushDelegate(delegate)
pushFeature.register(this)
}
fun stop() {
pushFeature.unregister(this)
}
override fun onMessageReceived(scope: PushScope, message: ByteArray?) {
coroutineScope.launch {
handler?.onPushMessage(scope, message)
}
}
override fun onSubscriptionChanged(scope: PushScope) {
coroutineScope.launch {
handler?.onSubscriptionChanged(scope)
}
}
}
internal class WebPushEngineDelegate(
private val pushFeature: PushSubscriptionProcessor,
private val stringDecoder: (String) -> ByteArray,
private val byteArrayEncoder: (ByteArray) -> String,
) : WebPushDelegate {
private val logger = Logger("WebPushEngineDelegate")
override fun onGetSubscription(scope: String, onSubscription: (WebPushSubscription?) -> Unit) {
pushFeature.getSubscription(scope) {
onSubscription(it?.toEnginePushSubscription(stringDecoder))
}
}
override fun onSubscribe(
scope: String,
serverKey: ByteArray?,
onSubscribe: (WebPushSubscription?) -> Unit,
) {
pushFeature.subscribe(
scope = scope,
appServerKey = serverKey?.let { byteArrayEncoder(it) },
onSubscribeError = {
logger.error("Error on push onSubscribe.")
onSubscribe(null)
},
onSubscribe = { subscription ->
onSubscribe(subscription.toEnginePushSubscription(stringDecoder))
},
)
}
override fun onUnsubscribe(scope: String, onUnsubscribe: (Boolean) -> Unit) {
pushFeature.unsubscribe(
scope = scope,
onUnsubscribeError = {
logger.error("Error on push onUnsubscribe.")
onUnsubscribe(false)
},
onUnsubscribe = {
onUnsubscribe(it)
},
)
}
}
internal fun AutoPushSubscription.toEnginePushSubscription(stringDecoder: (String) -> ByteArray) = WebPushSubscription(
scope = scope,
publicKey = stringDecoder(publicKey),
endpoint = endpoint,
authSecret = stringDecoder(authKey),
// The app server key is only available during subscription creation, so we preserve Gecko's
// previous value by leaving it null for cached subscriptions.
appServerKey = null,
)
@@ -0,0 +1,75 @@
/* 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 https://mozilla.org/MPL/2.0/. */
package eu.weblibre.flutter_mozilla_components.receivers
import android.content.Context
import android.content.Intent
import android.util.Log
import eu.weblibre.flutter_mozilla_components.ActiveProfile
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import org.ironfoxoss.unifiedpush.PushError
import org.ironfoxoss.unifiedpush.UnifiedPushProcessor
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
class UnifiedPushReceiver : MessagingReceiver() {
companion object {
private const val TAG = "UnifiedPushReceiver"
}
override fun onReceive(context: Context, intent: Intent) {
ActiveProfile.resolveFromDisk(context.applicationContext)
if (GlobalComponents.components == null &&
!GlobalComponents.ensureExternalComponents(context.applicationContext)
) {
Log.e(TAG, "Unable to initialize components for UnifiedPush delivery")
return
}
GlobalComponents.components?.push?.initialize()
if (GlobalComponents.components == null) {
Log.e(TAG, "UnifiedPush delivery aborted because components are unavailable")
return
}
super.onReceive(context, intent)
}
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)
}
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")
}
}
}
@@ -65,6 +65,10 @@ class GeckoBrowserService {
return _api.requestDefaultBrowser();
}
Future<bool> pickUnifiedPushDistributor() {
return _api.pickUnifiedPushDistributor();
}
Future<void> shutdown() {
return _api.shutdown();
}
File diff suppressed because it is too large Load Diff
@@ -1140,6 +1140,8 @@ abstract class GeckoBrowserApi {
});
bool isDefaultBrowser();
void requestDefaultBrowser();
@async
bool pickUnifiedPushDistributor();
void shutdown();
}