You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iceraven-browser/app/src/main/java/org/mozilla/fenix/home/recentsyncedtabs/RecentSyncedTabFeature.kt

252 lines
9.3 KiB
Kotlin

/* 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 org.mozilla.fenix.home.recentsyncedtabs
import android.content.Context
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import mozilla.components.browser.storage.sync.Tab
import mozilla.components.concept.storage.HistoryStorage
import mozilla.components.concept.sync.Device
import mozilla.components.concept.sync.DeviceType
import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage
import mozilla.components.lib.state.ext.flow
import mozilla.components.service.fxa.SyncEngine
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.fxa.manager.SyncEnginesStorage
import mozilla.components.service.fxa.manager.ext.withConstellation
import mozilla.components.service.fxa.store.SyncStatus
import mozilla.components.service.fxa.store.SyncStore
import mozilla.components.service.fxa.sync.SyncReason
import mozilla.components.support.base.feature.LifecycleAwareFeature
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
import mozilla.telemetry.glean.GleanTimerId
import org.mozilla.fenix.GleanMetrics.RecentSyncedTabs
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import java.util.concurrent.TimeUnit
/**
* Delegate to handle layout updates and dispatch actions related to the recent synced tab.
*
* @property context An Android [Context].
* @property appStore Store to dispatch actions to when synced tabs are updated or errors encountered.
* @property syncStore Store to observe for changes to Sync and account status.
* @property storage Storage layer for synced tabs.
* @property accountManager Account manager to initiate Syncs and refresh devices.
* @property historyStorage Storage for searching history for preview image URLs matching synced tab.
* @property coroutineScope The scope to collect Sync state Flow updates in.
*/
@Suppress("LongParameterList")
class RecentSyncedTabFeature(
private val context: Context,
private val appStore: AppStore,
private val syncStore: SyncStore,
private val storage: SyncedTabsStorage,
private val accountManager: FxaAccountManager,
private val historyStorage: HistoryStorage,
private val coroutineScope: CoroutineScope,
) : LifecycleAwareFeature {
private var syncStartId: GleanTimerId? = null
private var lastSyncedTabs: List<RecentSyncedTab>? = null
override fun start() {
collectAccountUpdates()
collectStatusUpdates()
}
override fun stop() = Unit
private fun collectAccountUpdates() {
syncStore.flow()
.distinctUntilChangedBy { state ->
state.account != null
}.onEach { state ->
if (state.account != null) {
dispatchLoading()
// Sync tabs storage will fail to retrieve tabs aren't refreshed, as that action
// is what populates the device constellation state
accountManager.withConstellation { refreshDevices() }
accountManager.syncNow(
reason = SyncReason.User,
debounce = true,
customEngineSubset = listOf(SyncEngine.Tabs),
)
}
}.launchIn(coroutineScope)
}
private fun collectStatusUpdates() {
syncStore.flow()
.distinctUntilChangedBy { state ->
state.status
}.onEach { state ->
when (state.status) {
SyncStatus.Idle -> dispatchSyncedTabs()
SyncStatus.Error -> onError()
SyncStatus.LoggedOut -> appStore.dispatch(
AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None),
)
else -> Unit
}
}.launchIn(coroutineScope)
}
private fun dispatchLoading() {
syncStartId?.let { RecentSyncedTabs.recentSyncedTabTimeToLoad.cancel(it) }
syncStartId = RecentSyncedTabs.recentSyncedTabTimeToLoad.start()
if (appStore.state.recentSyncedTabState == RecentSyncedTabState.None) {
appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Loading))
}
}
private suspend fun dispatchSyncedTabs() {
if (!isSyncedTabsEngineEnabled()) {
appStore.dispatch(
AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None),
)
return
}
val syncedTabs = storage.getSyncedDeviceTabs()
.filterNot { it.device.isCurrentDevice || it.tabs.isEmpty() }
.flatMap {
it.tabs.map { tab ->
SyncedDeviceTab(it.device, tab)
}
}
.ifEmpty { return }
// We want to get the last device used based on the most recent accessed tab,
// as described here: https://github.com/mozilla-mobile/fenix/issues/26398
.sortedByDescending { deviceTab -> deviceTab.tab.lastUsed }
.take(MAX_RECENT_SYNCED_TABS)
.map { deviceTab ->
val activeTabEntry = deviceTab.tab.active()
val currentTime = System.currentTimeMillis()
val maxAgeInMs = TimeUnit.DAYS.toMillis(DAYS_HISTORY_FOR_PREVIEW_IMAGE)
val history = historyStorage.getDetailedVisits(
start = currentTime - maxAgeInMs,
end = currentTime,
)
// Searching history entries for any that share a top level domain and have a
// preview image URL available casts a wider net for finding a suitable image.
val previewImageUrl = history.find { entry ->
entry.url.contains(activeTabEntry.url.tryGetHostFromUrl()) && entry.previewImageUrl != null
}?.previewImageUrl
RecentSyncedTab(
deviceDisplayName = deviceTab.device.displayName,
deviceType = deviceTab.device.deviceType,
title = activeTabEntry.title,
url = activeTabEntry.url,
previewImageUrl = previewImageUrl,
)
}
if (syncedTabs.isEmpty()) {
appStore.dispatch(
AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None),
)
} else {
recordMetrics(syncedTabs.first(), lastSyncedTabs?.first())
appStore.dispatch(
AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.Success(syncedTabs)),
)
lastSyncedTabs = syncedTabs
}
}
private fun onError() {
if (appStore.state.recentSyncedTabState == RecentSyncedTabState.Loading) {
appStore.dispatch(AppAction.RecentSyncedTabStateChange(RecentSyncedTabState.None))
}
}
private fun recordMetrics(
tab: RecentSyncedTab,
lastSyncedTab: RecentSyncedTab?,
) {
RecentSyncedTabs.recentSyncedTabShown[tab.deviceType.name.lowercase()].add()
syncStartId?.let {
RecentSyncedTabs.recentSyncedTabTimeToLoad.stopAndAccumulate(it)
syncStartId = null
}
if (tab == lastSyncedTab) {
RecentSyncedTabs.latestSyncedTabIsStale.add()
}
}
private fun isSyncedTabsEngineEnabled(): Boolean {
return SyncEnginesStorage(context).getStatus()[SyncEngine.Tabs] ?: true
}
companion object {
/**
* The number of days to search history for a preview image URL to display for a synced
* tab.
*/
const val DAYS_HISTORY_FOR_PREVIEW_IMAGE = 3L
/**
* Number of recent synced tabs we want to keep in the success state.
*/
const val MAX_RECENT_SYNCED_TABS = 8
}
}
/**
* The state of the recent synced tab.
*/
sealed class RecentSyncedTabState {
/**
* There is no synced tab, or a user is not authenticated.
*/
object None : RecentSyncedTabState()
/**
* A user is authenticated and the sync is running.
*/
object Loading : RecentSyncedTabState()
/**
* A user is authenticated and most recent synced tabs have been found.
*/
data class Success(val tabs: List<RecentSyncedTab>) : RecentSyncedTabState()
}
/**
* A tab that was recently viewed on a synced device.
*
* @property deviceDisplayName The device the tab was viewed on.
* @property deviceType The type of a device the tab was viewed on - mobile, desktop.
* @property title The title of the tab.
* @property url The url of the tab.
* @property previewImageUrl The url used to retrieve the preview image of the tab.
*/
data class RecentSyncedTab(
val deviceDisplayName: String,
val deviceType: DeviceType,
val title: String,
val url: String,
val previewImageUrl: String?,
)
/**
* Class representing a tab from a synced device.
*
* @property device The synced [Device].
* @property tab The tab from the synced device.
*/
private data class SyncedDeviceTab(
val device: Device,
val tab: Tab,
)