sync initial

This commit is contained in:
Fabian Freund
2026-02-21 08:29:05 +01:00
parent 03f9dfe9f5
commit 5bdc3c6027
62 changed files with 5724 additions and 519 deletions
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2024-2025 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package eu.weblibre.flutter_mozilla_components
import android.content.Context
import java.io.File
object ActiveProfile {
@Volatile
var prefix: String? = null
/** SharedPreference names used by mozilla-components FxA/sync that need profile isolation */
val FXA_SHARED_PREFERENCE_NAMES = setOf(
"fxaAppState", // FxA account state (SharedPrefAccountStorage)
"fxaStatePrefAC", // Sentinel flag for SecureAbove22 account state presence
"fxaStateAC_kp_pre_m", // SecureAbove22 encrypted account state (API < 23 fallback)
"fxaStateAC_kp_post_m", // SecureAbove22 encrypted account state (API >= 23)
"fxa_abnormalities", // Tracks FxA account abnormalities
"mozac_feature_accounts_push", // Push subscription scope + verification state
"SyncAuthInfoCache", // Cached sync auth tokens
"FxaDeviceSettingsCache", // Cached device settings (ID, name, type)
"syncEngines", // Per-engine enabled/disabled state
"syncPrefs", // Last-synced timestamp + persisted sync state
)
/**
* Resolve the active profile prefix from disk.
* Called in Application.onCreate() to handle cold-start WorkManager scenarios.
*/
fun resolveFromDisk(context: Context) {
val profileFile = File(context.filesDir, PwaConstants.CURRENT_PROFILE_FILE)
if (!profileFile.exists()) return
val uuid = profileFile.readText().trim().ifEmpty { return }
val relativePath = "${PwaConstants.PROFILES_DIR_NAME}/${PwaConstants.PROFILE_DIR_PREFIX}$uuid"
prefix = File(relativePath).name
}
}
@@ -37,6 +37,8 @@ import io.flutter.Log
import mozilla.components.browser.state.state.WebExtensionState
import mozilla.components.browser.thumbnails.BrowserThumbnails
import mozilla.components.concept.engine.EngineView
import mozilla.components.feature.accounts.FxaCapability
import mozilla.components.feature.accounts.FxaWebChannelFeature
import mozilla.components.feature.app.links.AppLinksFeature
import mozilla.components.feature.downloads.DownloadsFeature
import mozilla.components.feature.downloads.manager.FetchDownloadManager
@@ -85,6 +87,7 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
ViewBoundFeatureWrapper<MediaSessionFullscreenFeature>()
private val webAuthnFeature = ViewBoundFeatureWrapper<WebAuthnFeature>()
private val fxaWebChannelFeature = ViewBoundFeatureWrapper<FxaWebChannelFeature>()
private var pictureInPictureFeature: PictureInPictureFeature? = null
@@ -450,6 +453,19 @@ abstract class BaseBrowserFragment : Fragment(), UserInteractionHandler, Activit
view = view
)
fxaWebChannelFeature.set(
feature = FxaWebChannelFeature(
customTabSessionId = sessionId,
runtime = components.core.engine,
store = components.core.store,
accountManager = components.backgroundServices.accountManager,
serverConfig = components.backgroundServices.serverConfig,
fxaCapabilities = setOf(FxaCapability.CHOOSE_WHAT_TO_SYNC),
),
owner = this,
view = view,
)
readerViewFeature.set(
feature = ReaderViewIntegration(
profileContext,
@@ -9,6 +9,7 @@ package eu.weblibre.flutter_mozilla_components
import android.content.Context
import androidx.core.app.NotificationManagerCompat
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.Search
@@ -19,6 +20,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.ContentBlocking
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.weblibre.flutter_mozilla_components.pigeons.ReaderViewController
import mozilla.components.concept.engine.EngineView
@@ -38,14 +40,38 @@ class Components(val profileApplicationContext: ProfileContext,
val logLevel: Log.Priority,
val contentBlocking: ContentBlocking,
val addonCollection: AddonCollection?,
val fxaServerOverride: String?,
val syncTokenServerOverride: String?,
val addonEvents: GeckoAddonEvents,
private val tabContentEvents: GeckoTabContentEvents,
private val extensionEvents: BrowserExtensionEvents
private val extensionEvents: BrowserExtensionEvents,
private val syncStateEvents: GeckoSyncStateEvents?,
) {
val core by lazy { Core(profileApplicationContext, this, flutterEvents, extensionEvents) }
val backgroundServices by lazy {
BackgroundServices(
context = profileApplicationContext,
browserStore = lazy { core.store },
historyStorage = core.lazyHistoryStorage,
bookmarkStorage = core.lazyBookmarksStorage,
remoteTabsStorage = core.lazyRemoteTabsStorage,
fxaServerOverride = fxaServerOverride,
syncTokenServerOverride = syncTokenServerOverride,
syncStateEvents = syncStateEvents,
)
}
val events by lazy { Events(flutterEvents) }
val useCases by lazy { UseCases(profileApplicationContext, core.engine, core.store, core.webAppShortcutManager) }
val services by lazy { Services(profileApplicationContext, core.store, useCases.tabsUseCases) }
val services by lazy {
Services(
profileApplicationContext,
core.store,
useCases.tabsUseCases,
backgroundServices.accountManager,
core.engine,
backgroundServices.serverConfig,
)
}
val features by lazy { Features(core.engine, core.store, addonEvents, tabContentEvents) }
val search by lazy { Search(profileApplicationContext, core, useCases) }
@@ -68,4 +94,4 @@ class Components(val profileApplicationContext: ProfileContext,
val dateTimeProvider: DateTimeProvider by lazy { DefaultDateTimeProvider() }
val downloadEstimator: DownloadEstimator by lazy { DownloadEstimator(dateTimeProvider = dateTimeProvider) }
}
}
@@ -15,6 +15,7 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoAddonEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSelectionActionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import eu.weblibre.flutter_mozilla_components.pigeons.QueryParameterStripping
@@ -26,6 +27,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.storage.sync.GlobalPlacesDependencyProvider
import mozilla.components.browser.state.action.CustomTabListAction
import mozilla.components.browser.state.selector.findCustomTab
@@ -114,9 +116,12 @@ object GlobalComponents {
addonEvents: GeckoAddonEvents,
tabContentEvents: GeckoTabContentEvents,
extensionEvents: BrowserExtensionEvents,
syncStateEvents: GeckoSyncStateEvents?,
logLevel: Log.Priority,
contentBlocking: ContentBlocking,
addonCollection: AddonCollection?,
fxaServerOverride: String?,
syncTokenServerOverride: String?,
mode: ComponentsMode = ComponentsMode.FULL,
) {
Logger.debug("Creating new components")
@@ -137,18 +142,30 @@ object GlobalComponents {
logLevel,
contentBlocking,
addonCollection,
fxaServerOverride,
syncTokenServerOverride,
addonEvents,
tabContentEvents,
extensionEvents,
syncStateEvents,
)
_components = newComponents
currentMode = mode
previousComponents?.let {
runCatching {
it.backgroundServices.accountManager.close()
}
}
//newComponents.crashReporter.install(applicationContext)
//Facts.registerProcessor(LogFactProcessor())
//RustHttpConfig.setClient(lazy { newComponents.core.client })
val megazordNetworkSetup = MegazordSetup.setupMegazordNetwork(
context = newComponents.profileApplicationContext,
client = lazy { newComponents.core.client },
)
if (mode == ComponentsMode.FULL) {
newComponents.core.engine.warmUp()
@@ -167,6 +184,12 @@ object GlobalComponents {
}
if (mode == ComponentsMode.FULL) {
if (!megazordNetworkSetup.isCompleted) {
runBlocking {
megazordNetworkSetup.await()
}
}
val restoreJob = restoreBrowserState(newComponents)
if (previousCustomTabs.isNotEmpty()) {
restoreJob.invokeOnCompletion {
@@ -215,6 +238,12 @@ object GlobalComponents {
GlobalScope.launch(Dispatchers.IO) {
newComponents.core.fileUploadsDirCleaner.cleanUploadsDirectory()
}
// Eagerly initialize account manager so sync starts
newComponents.backgroundServices.accountManager
// Start FxA web channel feature for OAuth redirect handling
newComponents.services.fxaWebChannelFeature.start()
} else {
restorePreviousCustomTabs()
}
@@ -256,9 +285,12 @@ object GlobalComponents {
addonEvents = GeckoAddonEvents(messenger),
tabContentEvents = GeckoTabContentEvents(messenger),
extensionEvents = BrowserExtensionEvents(messenger),
syncStateEvents = null,
logLevel = logLevel,
contentBlocking = contentBlocking,
addonCollection = null,
fxaServerOverride = null,
syncTokenServerOverride = null,
mode = ComponentsMode.EXTERNAL,
)
@@ -0,0 +1,32 @@
package eu.weblibre.flutter_mozilla_components
import android.content.Context
import android.content.pm.ApplicationInfo
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import mozilla.components.concept.fetch.Client
import mozilla.components.support.AppServicesInitializer
import mozilla.components.support.AppServicesInitializer.Config as AppServicesConfig
import mozilla.components.support.rusthttp.RustHttpConfig
object MegazordSetup {
fun setupEarlyMainProcess() {
AppServicesInitializer.init(AppServicesConfig(null))
}
@DelicateCoroutinesApi
fun setupMegazordNetwork(context: Context, client: Lazy<Client>): Deferred<Unit> =
GlobalScope.async(Dispatchers.IO) {
val isDebuggable =
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
if (isDebuggable) {
RustHttpConfig.allowEmulatorLoopback()
}
RustHttpConfig.setClient(client)
}
}
@@ -43,6 +43,7 @@ class ProfileContext(private val base: Context, val relativePath: String) :
}
init {
ActiveProfile.prefix = profilePrefix
customFilesDir.mkdirs()
customNoBackupFilesDir.mkdirs()
customObbDir.mkdirs()
@@ -0,0 +1,30 @@
package eu.weblibre.flutter_mozilla_components.activities
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount
import eu.weblibre.flutter_mozilla_components.GlobalComponents
class AuthCustomTabActivity : ExternalAppBrowserActivity() {
private val accountStateObserver = object : AccountObserver {
override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
finish()
}
}
override fun onResume() {
super.onResume()
GlobalComponents.components
?.backgroundServices
?.accountManager
?.register(accountStateObserver, this, true)
}
override fun onDestroy() {
GlobalComponents.components
?.backgroundServices
?.accountManager
?.unregister(accountStateObserver)
super.onDestroy()
}
}
@@ -0,0 +1,54 @@
package eu.weblibre.flutter_mozilla_components.activities
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.util.Log
import mozilla.components.feature.customtabs.CustomTabIntentProcessor
import mozilla.components.feature.intent.ext.getSessionId
import eu.weblibre.flutter_mozilla_components.GlobalComponents
class AuthIntentReceiverActivity : Activity() {
companion object {
private const val TAG = "AuthIntentReceiver"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sourceIntent = intent?.let { Intent(it) } ?: Intent()
if (GlobalComponents.components == null && !GlobalComponents.ensureExternalComponents(applicationContext)) {
finish()
return
}
val components = GlobalComponents.components
if (components == null) {
finish()
return
}
val processed = CustomTabIntentProcessor(
components.useCases.customTabsUseCases.add,
resources,
isPrivate = false,
).process(sourceIntent)
if (processed) {
val sessionId = sourceIntent.getSessionId() ?: components.core.store.state.customTabs.lastOrNull()?.id
if (sessionId != null) {
val authIntent = ExternalAppBrowserActivity
.createIntent(this, sessionId)
.setClassName(this, AuthCustomTabActivity::class.java.name)
startActivity(authIntent)
} else {
Log.w(TAG, "Auth intent processed but no custom tab session id found")
}
} else {
Log.w(TAG, "Auth custom tab intent was not processed")
}
finish()
}
}
@@ -34,7 +34,7 @@ import mozilla.components.support.base.log.logger.Logger
*
* Uses an empty taskAffinity so Custom Tabs appear as a separate task from the main app.
*/
class ExternalAppBrowserActivity : AppCompatActivity() {
open class ExternalAppBrowserActivity : AppCompatActivity() {
companion object {
private const val TAG = "ExternalAppBrowserActivity"
@@ -47,6 +47,8 @@ import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSessionApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSuggestionEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabContentEvents
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoTabsApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportApi
@@ -155,7 +157,9 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
profileFolder: String,
logLevel: LogLevel,
contentBlocking: ContentBlocking,
addonCollection: AddonCollection?
addonCollection: AddonCollection?,
fxaServerOverride: String?,
syncTokenServerOverride: String?,
) {
synchronized(this) {
if (!isGeckoInitialized) {
@@ -170,7 +174,14 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
Log.addSink(PriorityAwareLogSink(level, geckoLogging))
setupGeckoEngine(profileFolder, level, contentBlocking, addonCollection)
setupGeckoEngine(
profileFolder,
level,
contentBlocking,
addonCollection,
fxaServerOverride,
syncTokenServerOverride,
)
isGeckoInitialized = true
}
}
@@ -190,7 +201,9 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
profileFolder: String,
logLevel: Log.Priority,
contentBlocking: ContentBlocking,
addonCollection: AddonCollection?
addonCollection: AddonCollection?,
fxaServerOverride: String?,
syncTokenServerOverride: String?,
) {
val profileApplicationContext = ProfileContext(_flutterPluginBinding.applicationContext, profileFolder)
@@ -220,6 +233,8 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
GeckoSuggestionApiImpl(suggestionEvents)
)
val syncStateEvents = GeckoSyncStateEvents(_flutterPluginBinding.binaryMessenger)
GlobalComponents.setUp(
profileApplicationContext,
_flutterEvents,
@@ -228,9 +243,12 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
addonEvents,
tabContentEvents,
extensionEvents,
syncStateEvents,
logLevel,
contentBlocking,
addonCollection
addonCollection,
fxaServerOverride,
syncTokenServerOverride,
)
val engineSettingsApiImpl = GeckoEngineSettingsApiImpl()
@@ -275,6 +293,7 @@ class GeckoBrowserApiImpl : GeckoBrowserApi {
GeckoPublicSuffixListApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPublicSuffixListApiImpl(profileApplicationContext))
GeckoTrackingProtectionApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoTrackingProtectionApiImpl())
GeckoAppLinksApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoAppLinksApiImpl(profileApplicationContext))
GeckoSyncApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoSyncApiImpl())
// PWA API for web app installation and management
GeckoPwaApi.setUp(_flutterPluginBinding.binaryMessenger, GeckoPwaApiImpl(profileApplicationContext))
@@ -0,0 +1,346 @@
package eu.weblibre.flutter_mozilla_components.api
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.components.WebLibreFxAEntryPoint
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncApi
import eu.weblibre.flutter_mozilla_components.pigeons.SyncAccountInfo
import eu.weblibre.flutter_mozilla_components.pigeons.SyncDevice
import eu.weblibre.flutter_mozilla_components.pigeons.SyncDeviceTabs
import eu.weblibre.flutter_mozilla_components.pigeons.SyncEngineStatus
import eu.weblibre.flutter_mozilla_components.pigeons.SyncEngineValue
import eu.weblibre.flutter_mozilla_components.pigeons.SyncIncomingTab
import eu.weblibre.flutter_mozilla_components.pigeons.SyncRemoteTab
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import mozilla.components.concept.sync.DeviceCapability
import mozilla.components.concept.sync.DeviceCommandOutgoing
import mozilla.components.concept.sync.TabData
import mozilla.components.service.fxa.SyncEngine
import mozilla.components.service.fxa.manager.SCOPE_PROFILE
import mozilla.components.service.fxa.manager.SCOPE_SYNC
import mozilla.components.service.fxa.manager.SyncEnginesStorage
import mozilla.components.service.fxa.sync.SyncReason
import mozilla.components.service.fxa.sync.getLastSynced
class GeckoSyncApiImpl : GeckoSyncApi {
companion object {
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
}
private val components by lazy {
requireNotNull(GlobalComponents.components) { "Components not initialized" }
}
override fun getAccountInfo(callback: (Result<SyncAccountInfo>) -> Unit) {
coroutineScope.launch {
try {
val accountManager = components.backgroundServices.accountManager
val account = accountManager.authenticatedAccount()
val needsReauth = accountManager.accountNeedsReauth()
val profile = account?.getProfile()
val engineStorage = SyncEnginesStorage(components.profileApplicationContext)
val engineStatus = engineStorage.getStatus()
callback(
Result.success(
SyncAccountInfo(
authenticated = account != null && !needsReauth,
syncing = accountManager.isSyncActive(),
needsReauth = needsReauth,
email = profile?.email,
displayName = profile?.displayName,
lastSyncedAt = getLastSynced(components.profileApplicationContext)
.takeIf { it > 0L },
engines = listOf(
SyncEngineStatus(
engine = SyncEngineValue.HISTORY,
enabled = engineStatus[SyncEngine.History] ?: true,
),
SyncEngineStatus(
engine = SyncEngineValue.BOOKMARKS,
enabled = engineStatus[SyncEngine.Bookmarks] ?: true,
),
SyncEngineStatus(
engine = SyncEngineValue.TABS,
enabled = engineStatus[SyncEngine.Tabs] ?: true,
),
),
),
),
)
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
override fun beginAuthentication(callback: (Result<Unit>) -> Unit) {
coroutineScope.launch {
runCatching {
components.backgroundServices.awaitStarted()
components.services.accountsAuthFeature.beginAuthentication(
context = components.profileApplicationContext,
entrypoint = WebLibreFxAEntryPoint.Settings,
scopes = setOf(SCOPE_PROFILE, SCOPE_SYNC),
)
}.fold(
onSuccess = { callback(Result.success(Unit)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun beginPairingAuthentication(
pairingUrl: String,
callback: (Result<Unit>) -> Unit,
) {
coroutineScope.launch {
runCatching {
components.backgroundServices.awaitStarted()
components.services.accountsAuthFeature.beginPairingAuthentication(
context = components.profileApplicationContext,
pairingUrl = pairingUrl,
entrypoint = WebLibreFxAEntryPoint.Settings,
scopes = setOf(SCOPE_PROFILE, SCOPE_SYNC),
)
}.fold(
onSuccess = { callback(Result.success(Unit)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun logout(callback: (Result<Unit>) -> Unit) {
coroutineScope.launch {
runCatching {
components.backgroundServices.accountManager.logout()
}.fold(
onSuccess = { callback(Result.success(Unit)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun syncNow(callback: (Result<Unit>) -> Unit) {
coroutineScope.launch {
runCatching {
components.backgroundServices.accountManager.syncNow(SyncReason.User)
}.fold(
onSuccess = { callback(Result.success(Unit)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun setEngineEnabled(
engine: SyncEngineValue,
enabled: Boolean,
callback: (Result<Unit>) -> Unit,
) {
coroutineScope.launch {
runCatching {
val storage = SyncEnginesStorage(components.profileApplicationContext)
val mapped = when (engine) {
SyncEngineValue.HISTORY -> SyncEngine.History
SyncEngineValue.BOOKMARKS -> SyncEngine.Bookmarks
SyncEngineValue.TABS -> SyncEngine.Tabs
}
storage.setStatus(mapped, enabled)
components.backgroundServices.accountManager.syncNow(SyncReason.EngineChange)
}.fold(
onSuccess = { callback(Result.success(Unit)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun getSyncedTabs(callback: (Result<List<SyncDeviceTabs>>) -> Unit) {
coroutineScope.launch {
runCatching {
val deviceNames = loadDeviceDisplayNames()
components.core.remoteTabsStorage.getAll().entries.mapNotNull { (client, tabs) ->
val deviceName = deviceNames[client.id] ?: return@mapNotNull null
SyncDeviceTabs(
deviceId = client.id,
deviceName = deviceName,
tabs = tabs.map { tab ->
val active = tab.active()
SyncRemoteTab(
title = active.title,
url = active.url,
iconUrl = active.iconUrl,
lastUsed = tab.lastUsed,
inactive = tab.inactive,
)
}.sortedByDescending { it.lastUsed },
)
}.sortedBy { it.deviceName.lowercase() }
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun getDevices(callback: (Result<List<SyncDevice>>) -> Unit) {
coroutineScope.launch {
runCatching {
components.backgroundServices.awaitStarted()
val account = components.backgroundServices.accountManager.authenticatedAccount()
?: return@runCatching emptyList()
val constellation = account.deviceConstellation()
constellation.refreshDevices()
val state = constellation.state()
val currentDevice = state?.currentDevice
val otherDevices = state?.otherDevices.orEmpty()
(listOfNotNull(currentDevice) + otherDevices).map { device ->
SyncDevice(
deviceId = device.id,
displayName = device.displayName,
isCurrentDevice = device.isCurrentDevice,
canSendTab = device.capabilities.contains(DeviceCapability.SEND_TAB),
)
}.sortedBy { it.displayName.lowercase() }
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun sendTabToDevice(
deviceId: String,
title: String,
url: String,
callback: (Result<Boolean>) -> Unit,
) {
coroutineScope.launch {
runCatching {
components.backgroundServices.awaitStarted()
val account = components.backgroundServices.accountManager.authenticatedAccount()
?: return@runCatching false
val constellation = account.deviceConstellation()
constellation.refreshDevices()
val state = constellation.state()
val target = state?.otherDevices?.firstOrNull {
it.id == deviceId && it.capabilities.contains(DeviceCapability.SEND_TAB)
} ?: return@runCatching false
constellation.sendCommandToDevice(
target.id,
DeviceCommandOutgoing.SendTab(
title = title,
url = url,
),
)
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun refreshDevices(callback: (Result<Unit>) -> Unit) {
coroutineScope.launch {
runCatching {
components.backgroundServices.awaitStarted()
val account = components.backgroundServices.accountManager.authenticatedAccount()
?: return@runCatching
account.deviceConstellation().refreshDevices()
}.fold(
onSuccess = { callback(Result.success(Unit)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun pollDeviceCommands(callback: (Result<Unit>) -> Unit) {
coroutineScope.launch {
runCatching {
components.backgroundServices.awaitStarted()
val account = components.backgroundServices.accountManager.authenticatedAccount()
?: return@runCatching
account.deviceConstellation().pollForCommands()
}.fold(
onSuccess = { callback(Result.success(Unit)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun drainIncomingTabs(callback: (Result<List<SyncIncomingTab>>) -> Unit) {
coroutineScope.launch {
runCatching {
components.backgroundServices.drainIncomingTabs().map {
SyncIncomingTab(
title = it.title,
url = it.url,
fromDeviceId = it.fromDeviceId,
fromDeviceName = it.fromDeviceName,
)
}
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun getDeviceName(callback: (Result<String?>) -> Unit) {
coroutineScope.launch {
runCatching {
components.backgroundServices.awaitStarted()
val account = components.backgroundServices.accountManager.authenticatedAccount()
?: return@runCatching null
val constellation = account.deviceConstellation()
constellation.refreshDevices()
constellation.state()?.currentDevice?.displayName
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
override fun setDeviceName(newName: String, callback: (Result<Boolean>) -> Unit) {
coroutineScope.launch {
runCatching {
val trimmed = newName.trim()
if (trimmed.isEmpty()) {
return@runCatching false
}
components.backgroundServices.awaitStarted()
val account = components.backgroundServices.accountManager.authenticatedAccount()
?: return@runCatching false
account.deviceConstellation()
.setDeviceName(trimmed, components.profileApplicationContext)
}.fold(
onSuccess = { callback(Result.success(it)) },
onFailure = { callback(Result.failure(it)) },
)
}
}
private suspend fun loadDeviceDisplayNames(): Map<String, String> {
components.backgroundServices.awaitStarted()
val account = components.backgroundServices.accountManager.authenticatedAccount()
?: return emptyMap()
val constellation = account.deviceConstellation()
constellation.refreshDevices()
val state = constellation.state() ?: return emptyMap()
val devices = listOfNotNull(state.currentDevice) + state.otherDevices
return devices.associate { it.id to it.displayName }
}
}
@@ -0,0 +1,315 @@
package eu.weblibre.flutter_mozilla_components.components
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Build
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.Device
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
import mozilla.components.concept.sync.TabData
import mozilla.components.browser.storage.sync.PlacesBookmarksStorage
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
import mozilla.components.browser.storage.sync.RemoteTabsStorage
import mozilla.components.concept.sync.DeviceConfig
import mozilla.components.concept.sync.DeviceCapability
import mozilla.components.concept.sync.DeviceType
import mozilla.components.feature.accounts.push.SendTabFeature
import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
import mozilla.components.service.fxa.PeriodicSyncConfig
import mozilla.components.service.fxa.ServerConfig
import mozilla.components.service.fxa.SyncConfig
import mozilla.components.service.fxa.SyncEngine
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.manager.SCOPE_SESSION
import mozilla.components.service.fxa.manager.SCOPE_SYNC
import mozilla.components.service.fxa.manager.SyncEnginesStorage
import mozilla.components.service.fxa.sync.GlobalSyncableStoreProvider
import mozilla.components.service.fxa.sync.SyncReason
import mozilla.components.service.fxa.sync.SyncStatusObserver
import mozilla.components.service.fxa.sync.getLastSynced
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoSyncStateEvents
import eu.weblibre.flutter_mozilla_components.pigeons.SyncAccountInfo
import eu.weblibre.flutter_mozilla_components.pigeons.SyncEngineStatus
import eu.weblibre.flutter_mozilla_components.pigeons.SyncEngineValue
import eu.weblibre.flutter_mozilla_components.sync.SyncedTabsIntegration
import mozilla.components.browser.state.store.BrowserStore
import androidx.lifecycle.ProcessLifecycleOwner
import org.mozilla.gecko.util.ThreadUtils.runOnUiThread
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.TimeUnit
class BackgroundServices(
private val context: Context,
private val browserStore: Lazy<BrowserStore>,
private val historyStorage: Lazy<PlacesHistoryStorage>,
private val bookmarkStorage: Lazy<PlacesBookmarksStorage>,
private val remoteTabsStorage: Lazy<RemoteTabsStorage>,
private val fxaServerOverride: String?,
private val syncTokenServerOverride: String?,
private val syncStateEvents: GeckoSyncStateEvents?,
) {
companion object {
private const val MIN_STARTUP_SYNC_INTERVAL_MS = 15 * 60 * 1000L
private val MAX_ACTIVE_TIME_MS = TimeUnit.DAYS.toMillis(14L)
}
data class IncomingTab(
val title: String,
val url: String,
val fromDeviceId: String?,
val fromDeviceName: String?,
)
private val incomingTabsLock = Any()
private val incomingTabsQueue = ArrayDeque<IncomingTab>()
private val startedSignal = CompletableDeferred<Unit>()
private val authStateScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val authStateLock = Any()
private var lastAuthState: SyncAccountInfo? = null
private val isDebuggable =
(context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
private val supportedEngines = setOf(
SyncEngine.History,
SyncEngine.Bookmarks,
SyncEngine.Tabs,
)
private val syncConfig = SyncConfig(
supportedEngines,
periodicSyncConfig = PeriodicSyncConfig(periodMinutes = 240),
)
val syncedTabsStorage by lazy {
SyncedTabsStorage(
accountManager,
browserStore.value,
remoteTabsStorage.value,
MAX_ACTIVE_TIME_MS,
)
}
val serverConfig: ServerConfig = FxaServer.config(
context = context,
serverOverride = fxaServerOverride,
tokenServerOverride = syncTokenServerOverride,
)
private val deviceConfig = DeviceConfig(
name = "WebLibre ${Build.MANUFACTURER} ${Build.MODEL}",
type = DeviceType.MOBILE,
capabilities = setOf(DeviceCapability.SEND_TAB, DeviceCapability.CLOSE_TABS),
secureStateAtRest = true,
)
init {
GlobalSyncableStoreProvider.configureStore(SyncEngine.History to historyStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Bookmarks to bookmarkStorage)
GlobalSyncableStoreProvider.configureStore(SyncEngine.Tabs to remoteTabsStorage)
}
private val sequenceCounter = AtomicLong(0)
private val syncedTabsIntegrationLaunched = AtomicBoolean(false)
private fun dispatchAuthState(account: OAuthAccount?, needsReauth: Boolean = false) {
val events = syncStateEvents ?: return
authStateScope.launch {
val profile = account?.getProfile()
val engineStorage = SyncEnginesStorage(context)
val engineStatus = engineStorage.getStatus()
val info = SyncAccountInfo(
authenticated = account != null && !needsReauth,
syncing = accountManager.isSyncActive(),
needsReauth = needsReauth,
email = profile?.email,
displayName = profile?.displayName,
lastSyncedAt = getLastSynced(context).takeIf { it > 0L },
engines = listOf(
SyncEngineStatus(
engine = SyncEngineValue.HISTORY,
enabled = engineStatus[SyncEngine.History] ?: true,
),
SyncEngineStatus(
engine = SyncEngineValue.BOOKMARKS,
enabled = engineStatus[SyncEngine.Bookmarks] ?: true,
),
SyncEngineStatus(
engine = SyncEngineValue.TABS,
enabled = engineStatus[SyncEngine.Tabs] ?: true,
),
),
)
val shouldEmit = synchronized(authStateLock) {
if (lastAuthState == info) {
false
} else {
lastAuthState = info
true
}
}
if (!shouldEmit) {
return@launch
}
runOnUiThread {
events.onAuthStateChanged(sequenceCounter.incrementAndGet(), info) { _ -> }
}
}
}
val accountManager: FxaAccountManager by lazy {
FxaAccountManager(
context = context,
serverConfig = serverConfig,
deviceConfig = deviceConfig,
syncConfig = syncConfig,
applicationScopes = setOf(SCOPE_SYNC, SCOPE_SESSION),
crashReporter = null,
).also { accountManager ->
SendTabFeature(accountManager) { device: Device?, tabs: List<TabData> ->
synchronized(incomingTabsLock) {
tabs.forEach { tab ->
incomingTabsQueue.addLast(
IncomingTab(
title = tab.title,
url = tab.url,
fromDeviceId = device?.id,
fromDeviceName = device?.displayName,
),
)
}
}
}
accountManager.register(object : AccountObserver {
override fun onReady(authenticatedAccount: OAuthAccount?) {
if (!startedSignal.isCompleted) {
startedSignal.complete(Unit)
}
}
override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
dispatchAuthState(account)
}
override fun onAuthenticationProblems() {
dispatchAuthState(accountManager.authenticatedAccount(), needsReauth = true)
}
override fun onLoggedOut() {
dispatchAuthState(null)
}
override fun onProfileUpdated(profile: Profile) {
dispatchAuthState(accountManager.authenticatedAccount())
}
override fun onFlowError(error: mozilla.components.concept.sync.AuthFlowError) {
dispatchAuthState(accountManager.authenticatedAccount(), needsReauth = true)
}
})
accountManager.registerForSyncEvents(object : SyncStatusObserver {
override fun onStarted() {
val events = syncStateEvents ?: return
dispatchAuthState(accountManager.authenticatedAccount())
runOnUiThread {
events.onSyncStarted(sequenceCounter.incrementAndGet()) { _ -> }
}
}
override fun onIdle() {
val events = syncStateEvents ?: return
dispatchAuthState(accountManager.authenticatedAccount())
runOnUiThread {
events.onSyncCompleted(sequenceCounter.incrementAndGet()) { _ -> }
}
}
override fun onError(error: Exception?) {
val events = syncStateEvents ?: return
dispatchAuthState(accountManager.authenticatedAccount())
runOnUiThread {
events.onSyncError(
sequenceCounter.incrementAndGet(),
error?.message,
) { _ -> }
}
}
}, owner = ProcessLifecycleOwner.get(), autoPause = false)
MainScope().launch {
runCatching {
accountManager.start()
}.fold(
onSuccess = {},
onFailure = { error ->
val isDuplicateInitialize = error.message
?.contains("Initialize already sent", ignoreCase = true)
?: false
if (isDuplicateInitialize) {
return@fold
}
if (!startedSignal.isCompleted) {
startedSignal.completeExceptionally(error)
}
throw error
},
)
if (accountManager.authenticatedAccount() != null && shouldSyncOnStartup()) {
accountManager.syncNow(SyncReason.Startup)
}
}
}
}
suspend fun awaitStarted() {
accountManager
launchSyncedTabsIntegrationIfNeeded()
withTimeout(10_000) {
startedSignal.await()
}
}
private fun launchSyncedTabsIntegrationIfNeeded() {
if (syncedTabsIntegrationLaunched.compareAndSet(false, true)) {
SyncedTabsIntegration(accountManager, syncedTabsStorage).launch()
}
}
private fun shouldSyncOnStartup(): Boolean {
val lastSynced = getLastSynced(context)
if (lastSynced <= 0L) {
return true
}
return (System.currentTimeMillis() - lastSynced) >= MIN_STARTUP_SYNC_INTERVAL_MS
}
fun drainIncomingTabs(): List<IncomingTab> {
synchronized(incomingTabsLock) {
if (incomingTabsQueue.isEmpty()) {
return emptyList()
}
val values = incomingTabsQueue.toList()
incomingTabsQueue.clear()
return values
}
}
}
@@ -33,6 +33,7 @@ import mozilla.components.browser.state.engine.middleware.SessionPrioritizationM
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.storage.sync.PlacesBookmarksStorage
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
import mozilla.components.browser.storage.sync.RemoteTabsStorage
import mozilla.components.browser.thumbnails.ThumbnailsMiddleware
import mozilla.components.browser.thumbnails.storage.ThumbnailStorage
import mozilla.components.concept.engine.DefaultSettings
@@ -62,8 +63,11 @@ import mozilla.components.feature.session.middleware.LastAccessMiddleware
import mozilla.components.feature.session.middleware.undo.UndoMiddleware
import mozilla.components.feature.sitepermissions.OnDiskSitePermissionsStorage
import mozilla.components.feature.webnotifications.WebNotificationFeature
import mozilla.components.concept.base.crash.Breadcrumb
import mozilla.components.concept.base.crash.CrashReporting
import mozilla.components.support.base.worker.Frequency
import org.mozilla.geckoview.GeckoRuntime
import kotlinx.coroutines.Job
import java.util.concurrent.TimeUnit
private const val AMO_COLLECTION_MAX_CACHE_AGE = 24 * 60L
@@ -74,6 +78,12 @@ class Core(
private val flutterEvents: GeckoStateEvents,
private val extensionEvents: BrowserExtensionEvents
) {
private val noOpCrashReporter = object : CrashReporting {
override fun submitCaughtException(throwable: Throwable): Job = Job()
override fun recordCrashBreadcrumb(breadcrumb: Breadcrumb) = Unit
}
val prefs by lazy {
PreferenceManager.getDefaultSharedPreferences(context)
}
@@ -251,12 +261,14 @@ class Core(
*/
val lazyHistoryStorage = lazy { PlacesHistoryStorage(context) }
val lazyBookmarksStorage = lazy { PlacesBookmarksStorage(context) }
val lazyRemoteTabsStorage = lazy { RemoteTabsStorage(context, noOpCrashReporter) }
/**
* A convenience accessor to the [PlacesHistoryStorage].
*/
val historyStorage by lazy { lazyHistoryStorage.value }
val bookmarksStorage by lazy { lazyBookmarksStorage.value }
val remoteTabsStorage by lazy { lazyRemoteTabsStorage.value }
val permissionStorage by lazy { PermissionStorage(geckoSitePermissionsStorage) }
@@ -0,0 +1,30 @@
package eu.weblibre.flutter_mozilla_components.components
import android.content.Context
import mozilla.appservices.fxaclient.FxaServer as AppServicesFxaServer
import mozilla.components.service.fxa.ServerConfig
object FxaServer {
private const val CLIENT_ID = "a2270f727f45f648"
const val REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel"
fun config(
context: Context,
serverOverride: String?,
tokenServerOverride: String?
): ServerConfig {
val effectiveServerOverride = serverOverride?.trim().orEmpty()
val effectiveTokenOverride = tokenServerOverride?.trim().takeUnless { it.isNullOrEmpty() }
return if (effectiveServerOverride.isEmpty()) {
ServerConfig(AppServicesFxaServer.Release, CLIENT_ID, REDIRECT_URL, effectiveTokenOverride)
} else {
ServerConfig(
AppServicesFxaServer.Custom(effectiveServerOverride),
CLIENT_ID,
REDIRECT_URL,
effectiveTokenOverride,
)
}
}
}
@@ -5,13 +5,26 @@
package eu.weblibre.flutter_mozilla_components.components
import android.content.Context
import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.activities.AuthIntentReceiverActivity
import eu.weblibre.flutter_mozilla_components.ext.getPreferenceKey
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.Engine
import mozilla.components.feature.accounts.FirefoxAccountsAuthFeature
import mozilla.components.feature.accounts.FxaCapability
import mozilla.components.feature.accounts.FxaWebChannelFeature
import mozilla.components.feature.app.links.AppLinksInterceptor
import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.service.fxa.ServerConfig
import mozilla.components.service.fxa.manager.FxaAccountManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Component group which encapsulates foreground-friendly services.
@@ -20,9 +33,39 @@ class Services(
private val context: Context,
private val store: BrowserStore,
private val tabsUseCases: TabsUseCases,
accountManager: FxaAccountManager,
private val engine: Engine,
private val serverConfig: ServerConfig,
) {
private val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val accountsAuthFeature by lazy {
FirefoxAccountsAuthFeature(accountManager, FxaServer.REDIRECT_URL) { _, authUrl ->
CoroutineScope(Dispatchers.Main).launch {
val intent = CustomTabsIntent.Builder()
.setInstantAppsEnabled(false)
.build()
.intent
.setData(authUrl.toUri())
.setClassName(context, AuthIntentReceiverActivity::class.java.name)
.setPackage(context.packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
}
}
val fxaWebChannelFeature by lazy {
FxaWebChannelFeature(
customTabSessionId = null,
runtime = engine,
store = store,
accountManager = accountManager,
serverConfig = serverConfig,
fxaCapabilities = setOf(FxaCapability.CHOOSE_WHAT_TO_SYNC),
)
}
val appLinksInterceptor by lazy {
AppLinksInterceptor(
context = context,
@@ -0,0 +1,7 @@
package eu.weblibre.flutter_mozilla_components.components
import mozilla.components.concept.sync.FxAEntryPoint
enum class WebLibreFxAEntryPoint(override val entryName: String) : FxAEntryPoint {
Settings("settings"),
}
@@ -42,6 +42,19 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor {
return null
}
components.services.accountsAuthFeature.interceptor.onLoadRequest(
engineSession,
uri,
lastUri,
hasUserGesture,
isSameDomain,
isRedirect,
isDirectNavigation,
isSubframeRequest,
)?.let {
return it
}
return components.services.appLinksInterceptor.onLoadRequest(
engineSession,
uri,
@@ -64,4 +77,4 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor {
}
override fun interceptsAppInitiatedRequests() = true
}
}
@@ -0,0 +1,35 @@
package eu.weblibre.flutter_mozilla_components.sync
import mozilla.components.concept.sync.AccountObserver
import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
import mozilla.components.service.fxa.manager.FxaAccountManager
/**
* Starts and stops SyncedTabsStorage based on the authentication state.
*/
class SyncedTabsIntegration(
private val accountManager: FxaAccountManager,
private val syncedTabsStorage: SyncedTabsStorage,
) {
fun launch() {
accountManager.register(SyncedTabsAccountObserver(syncedTabsStorage))
if (accountManager.authenticatedAccount() != null) {
syncedTabsStorage.start()
}
}
}
internal class SyncedTabsAccountObserver(
private val syncedTabsStorage: SyncedTabsStorage,
) : AccountObserver {
override fun onAuthenticated(account: OAuthAccount, authType: AuthType) {
syncedTabsStorage.start()
}
override fun onLoggedOut() {
syncedTabsStorage.stop()
}
}