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/recentvisits/RecentVisitsFeature.kt

290 lines
11 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.recentvisits
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
import mozilla.components.concept.storage.HistoryHighlight
import mozilla.components.concept.storage.HistoryHighlightWeights
import mozilla.components.concept.storage.HistoryMetadata
import mozilla.components.concept.storage.HistoryMetadataStorage
import mozilla.components.support.base.feature.LifecycleAwareFeature
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItemInternal.HistoryGroupInternal
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItemInternal.HistoryHighlightInternal
import org.mozilla.fenix.utils.Settings.Companion.SEARCH_GROUP_MINIMUM_SITES
import kotlin.math.max
@VisibleForTesting internal const val MAX_RESULTS_TOTAL = 9
@VisibleForTesting internal const val MIN_VIEW_TIME_OF_HIGHLIGHT = 10.0
@VisibleForTesting internal const val MIN_FREQUENCY_OF_HIGHLIGHT = 4.0
/**
* View-bound feature that retrieves a list of [HistoryHighlight]s and [HistoryMetadata] items
* which will be mapped to [RecentlyVisitedItem]s and then dispatched to [AppStore]
* to be displayed on the home screen.
*
* @property appStore The [AppStore] that holds the state of the [HomeFragment].
* @property historyMetadataStorage The storage that manages [HistoryMetadata].
* @property historyHighlightsStorage The storage that manages [PlacesHistoryStorage].
* @property scope The [CoroutineScope] used for IO operations related to querying history
* and then for dispatching updates.
* @property ioDispatcher The [CoroutineDispatcher] for performing read/write operations.
*/
class RecentVisitsFeature(
private val appStore: AppStore,
private val historyMetadataStorage: HistoryMetadataStorage,
private val historyHighlightsStorage: Lazy<PlacesHistoryStorage>,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : LifecycleAwareFeature {
private var job: Job? = null
override fun start() {
job = scope.launch(ioDispatcher) {
val highlights = async {
historyHighlightsStorage.value.getHistoryHighlights(
HistoryHighlightWeights(MIN_VIEW_TIME_OF_HIGHLIGHT, MIN_FREQUENCY_OF_HIGHLIGHT),
MAX_RESULTS_TOTAL,
)
}
val allHistoryMetadata = async {
historyMetadataStorage.getHistoryMetadataSince(Long.MIN_VALUE)
}
val historyHighlights = getHistoryHighlights(highlights.await(), allHistoryMetadata.await())
val historyGroups = getHistorySearchGroups(allHistoryMetadata.await())
updateState(historyHighlights, historyGroups)
}
}
@VisibleForTesting
internal fun updateState(
historyHighlights: List<HistoryHighlightInternal>,
historyGroups: List<HistoryGroupInternal>,
) {
appStore.dispatch(
AppAction.RecentHistoryChange(
getCombinedHistory(historyHighlights, historyGroups),
),
)
}
/**
* Get up to [MAX_RESULTS_TOTAL] items if available as an even split of history highlights and history groups.
* If more items then needed are available then highlights will be more by one.
*
* @param historyHighlights List of history highlights. Can be empty.
* @param historyGroups List of history groups. Can be empty.
*
* @return [RecentlyVisitedItem] list representing the data expected by clients of this feature.
*/
@VisibleForTesting
internal fun getCombinedHistory(
historyHighlights: List<HistoryHighlightInternal>,
historyGroups: List<HistoryGroupInternal>,
): List<RecentlyVisitedItem> {
// Cleanup highlights now to avoid counting them below and then removing the ones found in groups.
val distinctHighlights = historyHighlights
.removeHighlightsAlreadyInGroups(historyGroups)
val totalItemsCount = distinctHighlights.size + historyGroups.size
return if (totalItemsCount <= MAX_RESULTS_TOTAL) {
getSortedHistory(
distinctHighlights.sortedByDescending { it.lastAccessedTime },
historyGroups.sortedByDescending { it.lastAccessedTime },
)
} else {
var groupsCount = 0
var highlightCount = 0
while ((highlightCount + groupsCount) < MAX_RESULTS_TOTAL) {
if ((highlightCount + groupsCount) < MAX_RESULTS_TOTAL &&
distinctHighlights.getOrNull(highlightCount) != null
) {
highlightCount += 1
}
if ((highlightCount + groupsCount) < MAX_RESULTS_TOTAL &&
historyGroups.getOrNull(groupsCount) != null
) {
groupsCount += 1
}
}
getSortedHistory(
distinctHighlights
.sortedByDescending { it.lastAccessedTime }
.take(highlightCount),
historyGroups
.sortedByDescending { it.lastAccessedTime }
.take(groupsCount),
)
}
}
/**
* Perform an in-memory mapping of a history highlight to metadata records to compute its last access time.
*
* - If a `highlight` cannot be mapped to a corresponding `metadata` record, its lastAccessTime will be set to 0.
* - If a `highlight` maps to multiple metadata records, its lastAccessTime will be set to the most recently
* updated record.
*
* @param highlights [HistoryHighlight] list for which to get the last accessed time.
* @param metadata [HistoryMetadata] list expected to contain the details for all [highlights].
*
* @return The [highlights] with a computed last accessed time.
*/
@VisibleForTesting
internal fun getHistoryHighlights(
highlights: List<HistoryHighlight>,
metadata: List<HistoryMetadata>,
): List<HistoryHighlightInternal> {
val highlightsUrls = highlights.map { it.url }
val highlightsLastUpdatedTime = metadata
.filter { highlightsUrls.contains(it.key.url) }
.groupBy { it.key.url }
.map { (url, data) ->
url to data.maxByOrNull { it.updatedAt }!!
}
return highlights.map {
HistoryHighlightInternal(
historyHighlight = it,
lastAccessedTime = highlightsLastUpdatedTime
.firstOrNull { (url, _) -> url == it.url }?.second?.updatedAt
?: 0,
)
}
}
/**
* Group all urls accessed following a particular search.
* Automatically dedupes identical urls and adds each url's view time to the group's total.
*
* @param metadata List of history visits.
*
* @return List of user searches and all urls accessed from those.
*/
@VisibleForTesting
internal fun getHistorySearchGroups(
metadata: List<HistoryMetadata>,
): List<HistoryGroupInternal> {
return metadata
.filter { it.totalViewTime > 0 && it.key.searchTerm != null }
.groupBy { it.key.searchTerm!! }
.mapValues { group ->
// Within a group, we dedupe entries based on their url so we don't display
// a page multiple times in the same group, and we sum up the total view time
// of deduped entries while making sure to keep the latest updatedAt value.
val metadataInGroup = group.value
val metadataUrlGroups = metadataInGroup.groupBy { metadata -> metadata.key.url }
metadataUrlGroups.map { metadata ->
metadata.value.reduce { acc, elem ->
acc.copy(
totalViewTime = acc.totalViewTime + elem.totalViewTime,
updatedAt = max(acc.updatedAt, elem.updatedAt),
)
}
}
}
.map {
HistoryGroupInternal(
groupName = it.key,
groupItems = it.value,
)
}
.filter {
it.groupItems.size >= SEARCH_GROUP_MINIMUM_SITES
}
}
/**
* Maps the internal highlights and search groups to the final objects to be returned.
* Items will be sorted by their last accessed date so that the most recent will be first.
*/
@VisibleForTesting
internal fun getSortedHistory(
historyHighlights: List<HistoryHighlightInternal>,
historyGroups: List<HistoryGroupInternal>,
): List<RecentlyVisitedItem> {
return (historyHighlights + historyGroups)
.sortedByDescending { it.lastAccessedTime }
.map {
when (it) {
is HistoryHighlightInternal -> RecentHistoryHighlight(
title = if (it.historyHighlight.title.isNullOrBlank()) {
it.historyHighlight.url
} else {
it.historyHighlight.title!!
},
url = it.historyHighlight.url,
)
is HistoryGroupInternal -> RecentHistoryGroup(
title = it.groupName,
historyMetadata = it.groupItems,
)
}
}
}
override fun stop() {
job?.cancel()
}
}
/**
* Filter out highlights that are already part of a history group.
*/
@VisibleForTesting
internal fun List<HistoryHighlightInternal>.removeHighlightsAlreadyInGroups(
historyMetadata: List<HistoryGroupInternal>,
): List<HistoryHighlightInternal> {
return filterNot { highlight ->
historyMetadata.any {
it.groupItems.any {
it.key.url == highlight.historyHighlight.url
}
}
}
}
@VisibleForTesting
internal sealed class RecentlyVisitedItemInternal {
abstract val lastAccessedTime: Long
/**
* Temporary wrapper over a [HistoryHighlight] which adds a [lastAccessedTime] property used for sorting.
*/
data class HistoryHighlightInternal(
val historyHighlight: HistoryHighlight,
override val lastAccessedTime: Long,
) : RecentlyVisitedItemInternal()
/**
* Temporary search group allowing for easier data manipulation.
*/
data class HistoryGroupInternal(
val groupName: String,
val groupItems: List<HistoryMetadata>,
override val lastAccessedTime: Long = groupItems.maxOf { it.updatedAt },
) : RecentlyVisitedItemInternal()
}