2
0
mirror of https://github.com/fork-maintainers/iceraven-browser synced 2024-11-17 15:26:23 +00:00

[fenix] Revert "For https://github.com/mozilla-mobile/fenix/issues/26227 - Remove search term tab groups from Recently Visited"

This reverts commit e0617080ec0e28b74a2cd8ca1f9b94a521ba17ac.
This commit is contained in:
Christian Sadilek 2022-08-02 21:00:36 -04:00 committed by mergify[bot]
parent 17d8116525
commit aed096a6a0
20 changed files with 1119 additions and 61 deletions

View File

@ -60,6 +60,7 @@ sealed class AppAction : Action {
data class RemoveRecentBookmark(val recentBookmark: RecentBookmark) : AppAction()
data class RecentHistoryChange(val recentHistory: List<RecentlyVisitedItem>) : AppAction()
data class RemoveRecentHistoryHighlight(val highlightUrl: String) : AppAction()
data class DisbandSearchGroupAction(val searchTerm: String) : AppAction()
/**
* Indicates the given [categoryName] was selected by the user.
*/

View File

@ -4,15 +4,18 @@
package org.mozilla.fenix.components.appstate
import androidx.annotation.VisibleForTesting
import mozilla.components.service.pocket.PocketStory.PocketRecommendedStory
import mozilla.components.service.pocket.PocketStory.PocketSponsoredStory
import mozilla.components.service.pocket.ext.recordNewImpression
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.ext.filterOutTab
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.ext.recentSearchGroup
import org.mozilla.fenix.gleanplumb.state.MessagingReducer
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
/**
* Reducer for [AppStore].
@ -87,6 +90,14 @@ internal object AppStoreReducer {
it is RecentlyVisitedItem.RecentHistoryHighlight && it.url == action.highlightUrl
}
)
is AppAction.DisbandSearchGroupAction -> state.copy(
recentHistory = state.recentHistory.filterNot {
it is RecentHistoryGroup && (
it.title.equals(action.searchTerm, true) ||
it.title.equals(state.recentSearchGroup?.searchTerm, true)
)
}
)
is AppAction.SelectPocketStoriesCategory -> {
val updatedCategoriesState = state.copy(
pocketStoriesCategoriesSelections =
@ -194,3 +205,16 @@ internal object AppStoreReducer {
)
}
}
/**
* Removes a [RecentHistoryGroup] identified by [groupTitle] if it exists in the current list.
*
* @param groupTitle [RecentHistoryGroup.title] of the item that should be removed.
*/
@VisibleForTesting
internal fun List<RecentlyVisitedItem>.filterOut(groupTitle: String?): List<RecentlyVisitedItem> {
return when (groupTitle != null) {
true -> filterNot { it is RecentHistoryGroup && it.title.equals(groupTitle, true) }
false -> this
}
}

View File

@ -16,6 +16,7 @@ import org.mozilla.fenix.home.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.pocket.PocketStory
import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
import org.mozilla.fenix.home.recenttabs.RecentTab.SearchGroup
import org.mozilla.fenix.utils.Settings
/**
@ -161,6 +162,13 @@ internal fun getFilteredSponsoredStories(
.toList()
}
/**
* Get the [SearchGroup] shown in the "Jump back in" section.
* May be null if no search group is shown.
*/
internal val AppState.recentSearchGroup: SearchGroup?
get() = recentTabs.find { it is SearchGroup } as SearchGroup?
/**
* Filter a [AppState] by the blocklist.
*

View File

@ -4,6 +4,7 @@
package org.mozilla.fenix.home.recenttabs
import android.graphics.Bitmap
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ -44,4 +45,21 @@ sealed class RecentTab {
* @param state Recently viewed [TabSessionState]
*/
data class Tab(val state: TabSessionState) : RecentTab()
/**
* A search term group that was recently viewed
*
* @param searchTerm The search term that was recently viewed. Forced to start with uppercase.
* @param tabId The id of the tab that was recently viewed
* @param url The url that was recently viewed
* @param thumbnail The thumbnail of the search term that was recently viewed
* @param count The number of tabs in the search term group
*/
data class SearchGroup(
val searchTerm: String,
val tabId: String,
val url: String,
val thumbnail: Bitmap?,
val count: Int
) : RecentTab()
}

View File

@ -81,6 +81,7 @@ fun RecentTabs(
onRecentTabClick = onRecentTabClick
)
}
else -> {}
}
}
}

View File

@ -17,11 +17,16 @@ 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.FeatureFlags
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
@ -45,6 +50,7 @@ class RecentVisitsFeature(
private val historyHighlightsStorage: Lazy<PlacesHistoryStorage>,
private val scope: CoroutineScope,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val historyImprovementFeatures: Boolean = FeatureFlags.historyImprovementFeatures,
) : LifecycleAwareFeature {
private var job: Job? = null
@ -63,48 +69,75 @@ class RecentVisitsFeature(
}
val historyHighlights = getHistoryHighlights(highlights.await(), allHistoryMetadata.await())
val historyGroups = getHistorySearchGroups(allHistoryMetadata.await())
updateState(historyHighlights)
updateState(historyHighlights, historyGroups)
}
}
@VisibleForTesting
internal fun updateState(
historyHighlights: List<HistoryHighlightInternal>,
historyGroups: List<HistoryGroupInternal>
) {
appStore.dispatch(
AppAction.RecentHistoryChange(
getCombinedHistory(historyHighlights)
getCombinedHistory(historyHighlights, historyGroups)
)
)
}
/**
* Get up to [MAX_RESULTS_TOTAL] items if available of history highlights.
* 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.
* 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> {
return historyHighlights
.sortedByDescending { it.lastAccessedTime }
.take(MAX_RESULTS_TOTAL)
.map {
RecentHistoryHighlight(
title = if (it.historyHighlight.title.isNullOrBlank()) {
it.historyHighlight.url
} else {
it.historyHighlight.title!!
},
url = it.historyHighlight.url
)
// 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)
)
}
}
/**
@ -142,11 +175,101 @@ class RecentVisitsFeature(
}
}
/**
* 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 {
if (historyImprovementFeatures) {
it.groupItems.size >= SEARCH_GROUP_MINIMUM_SITES
} else {
true
}
}
}
/**
* 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
@ -158,4 +281,13 @@ internal sealed class RecentlyVisitedItemInternal {
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()
}

View File

@ -4,8 +4,11 @@
package org.mozilla.fenix.home.recentvisits
import mozilla.components.concept.storage.HistoryMetadata
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
/**
* History items of previously accessed webpages.
* History items as individual or groups of previously accessed webpages.
*/
sealed class RecentlyVisitedItem {
/**
@ -18,4 +21,18 @@ sealed class RecentlyVisitedItem {
val title: String,
val url: String
) : RecentlyVisitedItem()
/**
* A group of previously accessed webpages related by their search terms.
*
* @property title The title of the group.
* @property historyMetadata A list of [HistoryMetadata] records that matches the title.
*/
data class RecentHistoryGroup(
val title: String,
val historyMetadata: List<HistoryMetadata> = emptyList()
) : RecentlyVisitedItem()
}
// The last updated time of the group is based on the most recently updated item in the group
fun RecentHistoryGroup.lastUpdated(): Long = historyMetadata.maxOf { it.updatedAt }

View File

@ -9,14 +9,19 @@ import androidx.annotation.VisibleForTesting.PRIVATE
import androidx.navigation.NavController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.storage.HistoryMetadataStorage
import mozilla.components.feature.tabs.TabsUseCases.SelectOrAddUseCase
import mozilla.components.service.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.RecentSearches
import org.mozilla.fenix.R
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.library.history.toHistoryMetadata
/**
* All possible updates following user interactions with the "Recent visits" section from the Home screen.
@ -28,6 +33,20 @@ interface RecentVisitsController {
*/
fun handleHistoryShowAllClicked()
/**
* Callback for when the user clicks on a specific [RecentHistoryGroup].
*
* @param recentHistoryGroup The just clicked [RecentHistoryGroup].
*/
fun handleRecentHistoryGroupClicked(recentHistoryGroup: RecentHistoryGroup)
/**
* Callback for when the user removes a certain [RecentHistoryGroup].
*
* @param groupTitle Title of the [RecentHistoryGroup] to remove.
*/
fun handleRemoveRecentHistoryGroup(groupTitle: String)
/**
* Callback for when the user clicks on a specific [RecentHistoryHighlight].
*
@ -65,6 +84,39 @@ class DefaultRecentVisitsController(
)
}
/**
* Navigates to the history metadata group fragment to display the group.
*
* @param recentHistoryGroup The [RecentHistoryGroup] to which to navigate to.
*/
override fun handleRecentHistoryGroupClicked(recentHistoryGroup: RecentHistoryGroup) {
navController.navigate(
HomeFragmentDirections.actionGlobalHistoryMetadataGroup(
title = recentHistoryGroup.title,
historyMetadataItems = recentHistoryGroup.historyMetadata
.mapIndexed { index, item -> item.toHistoryMetadata(index) }.toTypedArray()
)
)
}
/**
* Removes a [RecentHistoryGroup] with the given title from the homescreen.
*
* @param groupTitle The title of the [RecentHistoryGroup] to be removed.
*/
override fun handleRemoveRecentHistoryGroup(groupTitle: String) {
// We want to update the UI right away in response to user action without waiting for the IO.
// First, dispatch actions that will clean up search groups in the two stores that have
// metadata-related state.
store.dispatch(HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = groupTitle))
appStore.dispatch(AppAction.DisbandSearchGroupAction(searchTerm = groupTitle))
// Then, perform the expensive IO work of removing search groups from storage.
scope.launch {
storage.deleteHistoryMetadata(groupTitle)
}
RecentSearches.groupDeleted.record(NoExtras())
}
/**
* Switch to an already open tab for [recentHistoryHighlight] if one exists or
* create a new tab in which to load this item's URL.

View File

@ -4,6 +4,7 @@
package org.mozilla.fenix.home.recentvisits.interactor
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
/**
@ -16,6 +17,20 @@ interface RecentVisitsInteractor {
*/
fun onHistoryShowAllClicked()
/**
* Callbacks for when the user clicks on a [RecentHistoryGroup].
*
* @param recentHistoryGroup The just clicked [RecentHistoryGroup].
*/
fun onRecentHistoryGroupClicked(recentHistoryGroup: RecentHistoryGroup)
/**
* Callback for when the user selected an option to remove a [RecentHistoryGroup].
*
* @param groupTitle [RecentHistoryGroup.title] of the item to remove.
*/
fun onRemoveRecentHistoryGroup(groupTitle: String)
/**
* Callback for when the user clicks on a [RecentHistoryHighlight].
*

View File

@ -5,8 +5,10 @@
package org.mozilla.fenix.home.recentvisits.view
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
@ -37,13 +40,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.EagerFlingBehavior
import org.mozilla.fenix.compose.Favicon
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.theme.Theme
@ -96,6 +103,15 @@ fun RecentlyVisited(
onRecentVisitClick(it, pageIndex + 1)
}
)
is RecentHistoryGroup -> RecentlyVisitedHistoryGroup(
recentVisit = recentVisit,
menuItems = menuItems,
clickableEnabled = listState.atLeastHalfVisibleItems.contains(pageIndex),
showDividerLine = index < items.size - 1,
onRecentVisitClick = {
onRecentVisitClick(it, pageIndex + 1)
}
)
}
}
}
@ -104,6 +120,73 @@ fun RecentlyVisited(
}
}
/**
* A recently visited history group.
*
* @param recentVisit The [RecentHistoryGroup] to display.
* @param menuItems List of [RecentVisitMenuItem] to display in a recent visit dropdown menu.
* @param clickableEnabled Whether click actions should be invoked or not.
* @param showDividerLine Whether to show a divider line at the bottom.
* @param onRecentVisitClick Invoked when the user clicks on a recent visit.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RecentlyVisitedHistoryGroup(
recentVisit: RecentHistoryGroup,
menuItems: List<RecentVisitMenuItem>,
clickableEnabled: Boolean,
showDividerLine: Boolean,
onRecentVisitClick: (RecentHistoryGroup) -> Unit = { _ -> },
) {
var isMenuExpanded by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.combinedClickable(
enabled = clickableEnabled,
onClick = { onRecentVisitClick(recentVisit) },
onLongClick = { isMenuExpanded = true }
)
.size(268.dp, 56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.ic_multiple_tabs),
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.fillMaxSize()
) {
RecentlyVisitedTitle(
text = recentVisit.title,
modifier = Modifier
.padding(top = 7.dp, bottom = 2.dp)
.weight(1f)
)
RecentlyVisitedCaption(
count = recentVisit.historyMetadata.size,
modifier = Modifier.weight(1f)
)
if (showDividerLine) {
RecentlyVisitedDivider()
}
}
RecentlyVisitedMenu(
showMenu = isMenuExpanded,
menuItems = menuItems,
recentVisit = recentVisit,
onDismissRequest = { isMenuExpanded = false }
)
}
}
/**
* A recently visited history item.
*
@ -179,6 +262,36 @@ private fun RecentlyVisitedTitle(
)
}
/**
* The caption text for a recent visit.
*
* @param count Number of recently visited items to display in the caption.
* @param modifier [Modifier] allowing to perfectly place this.
*/
@Composable
private fun RecentlyVisitedCaption(
count: Int,
modifier: Modifier
) {
val stringId = if (count == 1) {
R.string.history_search_group_site
} else {
R.string.history_search_group_sites
}
Text(
text = String.format(LocalContext.current.getString(stringId), count),
modifier = modifier,
color = when (isSystemInDarkTheme()) {
true -> FirefoxTheme.colors.textPrimary
false -> FirefoxTheme.colors.textSecondary
},
fontSize = 12.sp,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
}
/**
* Menu shown for a [RecentlyVisitedItem].
*
@ -260,14 +373,10 @@ private fun RecentlyVisitedPreview() {
FirefoxTheme(theme = Theme.getTheme()) {
RecentlyVisited(
recentVisits = listOf(
RecentHistoryHighlight(
title = "Google",
url = "www.google.com",
),
RecentHistoryHighlight(
title = "Firefox",
url = "www.firefox.com",
),
RecentHistoryGroup(title = "running shoes"),
RecentHistoryGroup(title = "mozilla"),
RecentHistoryGroup(title = "firefox"),
RecentHistoryGroup(title = "pocket")
),
menuItems = emptyList()
)

View File

@ -11,11 +11,13 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.LifecycleOwner
import mozilla.components.lib.state.ext.observeAsComposableState
import mozilla.components.service.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.History
import org.mozilla.fenix.GleanMetrics.RecentlyVisitedHomepage
import org.mozilla.fenix.R
import org.mozilla.fenix.components.components
import org.mozilla.fenix.compose.ComposeViewHolder
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.home.recentvisits.interactor.RecentVisitsInteractor
@ -49,6 +51,7 @@ class RecentlyVisitedViewHolder(
title = stringResource(R.string.recently_visited_menu_item_remove),
onClick = { visit ->
when (visit) {
is RecentHistoryGroup -> interactor.onRemoveRecentHistoryGroup(visit.title)
is RecentHistoryHighlight -> interactor.onRemoveRecentHistoryHighlight(
visit.url
)
@ -56,12 +59,21 @@ class RecentlyVisitedViewHolder(
}
)
),
onRecentVisitClick = { recentlyVisitedItem, _ ->
onRecentVisitClick = { recentlyVisitedItem, pageNumber ->
when (recentlyVisitedItem) {
is RecentHistoryHighlight -> {
RecentlyVisitedHomepage.historyHighlightOpened.record(NoExtras())
interactor.onRecentHistoryHighlightClicked(recentlyVisitedItem)
}
is RecentHistoryGroup -> {
RecentlyVisitedHomepage.searchGroupOpened.record(NoExtras())
History.recentSearchesTapped.record(
History.RecentSearchesTappedExtra(
pageNumber.toString()
)
)
interactor.onRecentHistoryGroupClicked(recentlyVisitedItem)
}
}
}
)

View File

@ -23,6 +23,7 @@ import org.mozilla.fenix.home.recentsyncedtabs.interactor.RecentSyncedTabInterac
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.home.recentvisits.controller.RecentVisitsController
import org.mozilla.fenix.home.recentvisits.interactor.RecentVisitsInteractor
@ -403,6 +404,16 @@ class SessionControlInteractor(
recentVisitsController.handleHistoryShowAllClicked()
}
override fun onRecentHistoryGroupClicked(recentHistoryGroup: RecentHistoryGroup) {
recentVisitsController.handleRecentHistoryGroupClicked(
recentHistoryGroup
)
}
override fun onRemoveRecentHistoryGroup(groupTitle: String) {
recentVisitsController.handleRemoveRecentHistoryGroup(groupTitle)
}
override fun onRecentHistoryHighlightClicked(recentHistoryHighlight: RecentHistoryHighlight) {
recentVisitsController.handleRecentHistoryHighlightClicked(recentHistoryHighlight)
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
android:pathData="M0,2.5C0,1.119 1.119,0 2.5,0H15.5C16.881,0 18,1.119 18,2.5V10.5C18,11.881 16.881,13 15.5,13H2.5C1.119,13 0,11.881 0,10.5V2.5ZM15.7,11.5L16.5,10.7V2.3L15.7,1.5H2.3L1.5,2.3V10.7L2.3,11.5H15.7ZM1.5,15.7L2.3,16.5H15.7L16.5,15.7V14.75C16.5,14.336 16.836,14 17.25,14C17.664,14 18,14.336 18,14.75V15.5C18,16.881 16.881,18 15.5,18H2.5C1.119,18 0,16.881 0,15.5V14.75C0,14.336 0.336,14 0.75,14C1.164,14 1.5,14.336 1.5,14.75V15.7Z"
android:fillColor="?attr/textSecondary"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="80dp"
android:viewportWidth="108"
android:viewportHeight="80">
<group>
<clip-path
android:pathData="M8,0L100,0A8,8 0,0 1,108 8L108,72A8,8 0,0 1,100 80L8,80A8,8 0,0 1,0 72L0,8A8,8 0,0 1,8 0z"/>
<path
android:pathData="M8,0L100,0A8,8 0,0 1,108 8L108,72A8,8 0,0 1,100 80L8,80A8,8 0,0 1,0 72L0,8A8,8 0,0 1,8 0z">
<aapt:attr name="android:fillColor">
<gradient
android:startY="0"
android:startX="108"
android:endY="103.313"
android:endX="31.4721"
android:type="linear">
<item android:offset="0" android:color="#FF9059FF"/>
<item android:offset="1" android:color="#FF0250BB"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M100,80L8,80A8,8 0,0 1,0 72L0,8A8,8 0,0 1,8 0L100,0A8,8 0,0 1,108 8L108,72A8,8 0,0 1,100 80z"
android:strokeAlpha="0.5"
android:fillAlpha="0.5">
<aapt:attr name="android:fillColor">
<gradient
android:startY="80"
android:startX="54"
android:endY="0"
android:endX="54"
android:type="linear">
<item android:offset="0.0104167" android:color="#FF000000"/>
<item android:offset="0.567708" android:color="#FF81535C"/>
<item android:offset="1" android:color="#00C4C4C4"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M60.55,43.687C62.019,41.645 62.888,39.145 62.888,36.444C62.888,29.584 57.306,24 50.444,24C43.582,24 38,29.584 38,36.444C38,43.305 43.582,48.889 50.444,48.889C53.155,48.889 55.663,48.014 57.709,46.535L58.641,46.524L67.725,55.609C67.985,55.868 68.326,56 68.667,56C69.008,56 69.35,55.87 69.609,55.609C70.13,55.088 70.13,54.244 69.609,53.723L60.532,44.644L60.55,43.687ZM50.444,46.222C45.052,46.222 40.667,41.835 40.667,36.444C40.667,31.054 45.052,26.667 50.444,26.667C55.836,26.667 60.221,31.054 60.221,36.444C60.221,41.835 55.836,46.222 50.444,46.222Z"
android:fillColor="#F9F9FB"/>
</group>
</vector>

View File

@ -117,10 +117,10 @@
<string name="recent_tabs_show_all_content_description_2">Show all recent tabs button</string>
<!-- Title for showing a group item in the 'Jump back in' section of the new tab
The first parameter is the search term that the user used. (for example: your search for "cat")-->
<string name="recent_tabs_search_term" moz:RemovedIn="105" tools:ignore="UnusedResources">Your search for \"%1$s\"</string>
<string name="recent_tabs_search_term">Your search for \"%1$s\"</string>
<!-- Text for the number of tabs in a group in the 'Jump back in' section of the new tab
%d is a placeholder for the number of sites in the group. This number will always be more than one. -->
<string name="recent_tabs_search_term_count_2" moz:RemovedIn="105" tools:ignore="UnusedResources">%d sites</string>
<string name="recent_tabs_search_term_count_2">%d sites</string>
<!-- Text for button in synced tab card that opens synced tabs tray -->
<string name="recent_tabs_see_all_synced_tabs_button_text">See all synced tabs</string>
<!-- Accessibility description for device icon used for recent synced tab -->

View File

@ -31,6 +31,7 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.MessagingAction.UpdateMessageToShow
import org.mozilla.fenix.components.appstate.AppState
import org.mozilla.fenix.components.appstate.filterOut
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.home.CurrentMode
@ -42,6 +43,7 @@ import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTab
import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.onboarding.FenixOnboarding
@ -131,10 +133,13 @@ class AppStoreTest {
@Test
fun `Test changing the recent tabs in AppStore`() = runTest {
val highlight = RecentHistoryHighlight(title = "title", "")
val group1 = RecentHistoryGroup(title = "title1")
val group2 = RecentHistoryGroup(title = "title2")
val group3 = RecentHistoryGroup(title = "title3")
val highlight = RecentHistoryHighlight(title = group2.title, "")
appStore = AppStore(
AppState(
recentHistory = listOf(highlight)
recentHistory = listOf(group1, group2, group3, highlight)
)
)
assertEquals(0, appStore.state.recentTabs.size)
@ -146,7 +151,7 @@ class AppStoreTest {
appStore.dispatch(AppAction.RecentTabsChange(recentTabs)).join()
assertEquals(recentTabs, appStore.state.recentTabs)
assertEquals(listOf(highlight), appStore.state.recentHistory)
assertEquals(listOf(group1, group3, highlight), appStore.state.recentHistory)
}
@Test
@ -172,7 +177,7 @@ class AppStoreTest {
fun `Test changing the history metadata in AppStore`() = runTest {
assertEquals(0, appStore.state.recentHistory.size)
val historyMetadata: List<RecentHistoryHighlight> = listOf(mockk(), mockk())
val historyMetadata: List<RecentHistoryGroup> = listOf(mockk(), mockk())
appStore.dispatch(AppAction.RecentHistoryChange(historyMetadata)).join()
assertEquals(historyMetadata, appStore.state.recentHistory)
@ -180,10 +185,12 @@ class AppStoreTest {
@Test
fun `Test removing a history highlight from AppStore`() = runTest {
val g1 = RecentHistoryGroup(title = "group One")
val g2 = RecentHistoryGroup(title = "grup two")
val h1 = RecentHistoryHighlight(title = "highlight One", url = "url1")
val h2 = RecentHistoryHighlight(title = "highlight two", url = "url2")
val recentHistoryState = AppState(
recentHistory = listOf(h1, h2)
recentHistory = listOf(g1, g2, h1, h2)
)
appStore = AppStore(recentHistoryState)
@ -195,7 +202,7 @@ class AppStoreTest {
appStore.dispatch(AppAction.RemoveRecentHistoryHighlight(h1.url)).join()
assertEquals(
recentHistoryState.copy(recentHistory = listOf(h2)),
recentHistoryState.copy(recentHistory = listOf(g1, g2, h2)),
appStore.state
)
}
@ -234,12 +241,16 @@ class AppStoreTest {
assertEquals(0, appStore.state.recentHistory.size)
assertEquals(Mode.Normal, appStore.state.mode)
val recentGroup = RecentTab.SearchGroup("testSearchTerm", "id", "url", null, 3)
val collections: List<TabCollection> = listOf(mockk())
val topSites: List<TopSite> = listOf(mockk(), mockk())
val recentTabs: List<RecentTab> = listOf(mockk(), mockk())
val recentBookmarks: List<RecentBookmark> = listOf(mockk(), mockk())
val highlight = RecentHistoryHighlight("title", "")
val recentHistory: List<RecentlyVisitedItem> = listOf(highlight)
val group1 = RecentHistoryGroup(title = "test One")
val group2 = RecentHistoryGroup(title = recentGroup.searchTerm.lowercase())
val group3 = RecentHistoryGroup(title = "test two")
val highlight = RecentHistoryHighlight(group2.title, "")
val recentHistory: List<RecentlyVisitedItem> = listOf(group1, group2, group3, highlight)
appStore.dispatch(
AppAction.Change(
@ -257,7 +268,7 @@ class AppStoreTest {
assertEquals(topSites, appStore.state.topSites)
assertEquals(recentTabs, appStore.state.recentTabs)
assertEquals(recentBookmarks, appStore.state.recentBookmarks)
assertEquals(listOf(highlight), appStore.state.recentHistory)
assertEquals(listOf(group1, group3, highlight), appStore.state.recentHistory)
assertEquals(Mode.Private, appStore.state.mode)
}
@ -469,4 +480,21 @@ class AppStoreTest {
assertSame(firstFilteredStories, appStore.state.pocketStories)
}
}
@Test
fun `Test filtering out search groups`() {
val group1 = RecentHistoryGroup("title1")
val group2 = RecentHistoryGroup("title2")
val group3 = RecentHistoryGroup("title3")
val highLight1 = RecentHistoryHighlight("title1", "")
val highLight2 = RecentHistoryHighlight("title2", "")
val highLight3 = RecentHistoryHighlight("title3", "")
val recentHistory = listOf(group1, highLight1, group2, highLight2, group3, highLight3)
assertEquals(recentHistory, recentHistory.filterOut(null))
assertEquals(recentHistory, recentHistory.filterOut(""))
assertEquals(recentHistory, recentHistory.filterOut(" "))
assertEquals(recentHistory - group2, recentHistory.filterOut("Title2"))
assertEquals(recentHistory - group3, recentHistory.filterOut("title3"))
}
}

View File

@ -24,13 +24,16 @@ import mozilla.components.support.test.middleware.CaptureActionsMiddleware
import mozilla.components.support.test.rule.MainCoroutineRule
import mozilla.components.support.test.rule.runTestOnMain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppState
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 kotlin.random.Random
@ -66,6 +69,10 @@ class RecentVisitsFeatureTest {
documentType = DocumentType.Regular,
previewImageUrl = null
)
val recentHistoryGroup = RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(historyEntry)
)
val highlightEntry = HistoryHighlight(1.0, 1, "https://firefox.com", "firefox", null)
val recentHistoryHighlight = RecentHistoryHighlight("firefox", "https://firefox.com")
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
@ -80,7 +87,7 @@ class RecentVisitsFeatureTest {
startRecentVisitsFeature()
middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
assertEquals(listOf(recentHistoryHighlight), it.recentHistory)
assertEquals(listOf(recentHistoryGroup, recentHistoryHighlight), it.recentHistory)
}
}
@ -106,7 +113,200 @@ class RecentVisitsFeatureTest {
}
@Test
fun `GIVEN multiple highlights exist WHEN they are added to store THEN only MAX_RESULTS_TOTAL are sent`() =
fun `GIVEN groups containing history metadata items with the same url WHEN they are added to store THEN entries are deduped`() =
runTestOnMain {
val historyEntry1 = HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
title = "mozilla",
createdAt = System.currentTimeMillis(),
updatedAt = 1,
totalViewTime = 10,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val historyEntry2 = HistoryMetadata(
key = HistoryMetadataKey("http://firefox.com", "mozilla", null),
title = "firefox",
createdAt = System.currentTimeMillis(),
updatedAt = 2,
totalViewTime = 20,
documentType = DocumentType.Regular,
previewImageUrl = "http://firefox.com/image1"
)
val historyEntry3 = HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
title = "mozilla",
createdAt = System.currentTimeMillis(),
updatedAt = 3,
totalViewTime = 30,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val expectedHistoryGroup = RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(
// Expected total view time to be summed up for deduped entries
historyEntry1.copy(
totalViewTime = historyEntry1.totalViewTime + historyEntry3.totalViewTime,
updatedAt = historyEntry3.updatedAt
),
historyEntry2
)
)
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
listOf(
historyEntry1, historyEntry2, historyEntry3
)
}
startRecentVisitsFeature()
middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
assertEquals(listOf(expectedHistoryGroup), it.recentHistory)
}
}
@Test
fun `GIVEN different groups containing history metadata items with the same url WHEN they are added to store THEN entries are not deduped`() =
runTestOnMain {
val now = System.currentTimeMillis()
val historyEntry1 = HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
title = "mozilla",
createdAt = now,
updatedAt = now + 3,
totalViewTime = 10,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val historyEntry2 = HistoryMetadata(
key = HistoryMetadataKey("http://firefox.com", "mozilla", null),
title = "firefox",
createdAt = now,
updatedAt = now + 2,
totalViewTime = 20,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val historyEntry3 = HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", "firefox", null),
title = "mozilla",
createdAt = now,
updatedAt = now + 1,
totalViewTime = 30,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val expectedHistoryGroup1 = RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(historyEntry1, historyEntry2)
)
val expectedHistoryGroup2 = RecentHistoryGroup(
title = "firefox",
historyMetadata = listOf(historyEntry3)
)
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
listOf(
historyEntry1, historyEntry2, historyEntry3
)
}
startRecentVisitsFeature()
middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
assertEquals(listOf(expectedHistoryGroup1, expectedHistoryGroup2), it.recentHistory)
}
}
@Test
fun `GIVEN history groups WHEN they are added to store THEN they are sorted descending by last updated timestamp`() =
runTestOnMain {
val now = System.currentTimeMillis()
val historyEntry1 = HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
title = "mozilla",
createdAt = now,
updatedAt = now + 1,
totalViewTime = 10,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val historyEntry2 = HistoryMetadata(
key = HistoryMetadataKey("http://firefox.com", "mozilla", null),
title = "firefox",
createdAt = now,
updatedAt = now + 2,
totalViewTime = 20,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val historyEntry3 = HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", "firefox", null),
title = "mozilla",
createdAt = now,
updatedAt = now + 3,
totalViewTime = 30,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val expectedHistoryGroup1 = RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(historyEntry1, historyEntry2)
)
val expectedHistoryGroup2 = RecentHistoryGroup(
title = "firefox",
historyMetadata = listOf(historyEntry3)
)
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
listOf(
historyEntry1, historyEntry2, historyEntry3
)
}
startRecentVisitsFeature()
middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
assertEquals(listOf(expectedHistoryGroup2, expectedHistoryGroup1), it.recentHistory)
}
}
@Test
fun `GIVEN multiple groups exist but no highlights WHEN they are added to store THEN only MAX_RESULTS_TOTAL are sent`() =
runTestOnMain {
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val expectedRecentHistoryGroups = visitsFromSearch
// Expect to only have the last accessed 9 groups.
.subList(1, 10)
.toIndividualRecentHistoryGroups()
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers { visitsFromSearch }
startRecentVisitsFeature()
middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
assertEquals(
// The 9 most recent groups.
expectedRecentHistoryGroups,
it.recentHistory
)
}
}
@Test
fun `GIVEN multiple highlights exist but no history groups WHEN they are added to store THEN only MAX_RESULTS_TOTAL are sent`() =
runTestOnMain {
val highlights = getHistoryHighlightsItems(10)
val expectedRecentHighlights = highlights
@ -126,12 +326,70 @@ class RecentVisitsFeatureTest {
}
@Test
fun `GIVEN a list of history highlights WHEN updateState is called THEN emit RecentHistoryChange`() {
val feature = spyk(RecentVisitsFeature(appStore, mockk(), mockk(), mockk(), mockk()))
val expected = List<RecentHistoryHighlight>(1) { mockk() }
every { feature.getCombinedHistory(any()) } returns expected
fun `GIVEN multiple history highlights and history groups WHEN they are added to store THEN only last accessed are added`() =
runTestOnMain {
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val directVisits = getDirectVisitsHistoryMetadataItems(10)
val expectedRecentHistoryGroups = visitsFromSearch
// Expect only 4 groups. Take 5 here for using in the below zip() and be dropped after.
.subList(5, 10)
.toIndividualRecentHistoryGroups()
val expectedRecentHistoryHighlights = directVisits.reversed().toRecentHistoryHighlights()
val expectedItems = expectedRecentHistoryHighlights.zip(expectedRecentHistoryGroups).flatMap {
listOf(it.first, it.second)
}.take(9)
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers { visitsFromSearch + directVisits }
coEvery { historyHightlightsStorage.getHistoryHighlights(any(), any()) }.coAnswers {
directVisits.toHistoryHighlights()
}
feature.updateState(emptyList())
startRecentVisitsFeature()
middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
assertEquals(expectedItems, it.recentHistory)
}
}
@Test
fun `GIVEN history highlights exist as history metadata WHEN they are added to store THEN don't add highlight dupes`() {
// To know if a highlight appears in a search group each visit's url should be checked.
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val directDistinctVisits = getDirectVisitsHistoryMetadataItems(10).takeLast(2)
val directDupeVisits = visitsFromSearch.takeLast(2).map {
// Erase the search term for this to not be mapped to a search group.
// The url remains the same as the item from a group so it should be skipped.
it.copy(key = it.key.copy(searchTerm = null))
}
val expectedRecentHistoryGroups = visitsFromSearch
.subList(3, 10)
.toIndividualRecentHistoryGroups()
val expectedRecentHistoryHighlights = directDistinctVisits.reversed().toRecentHistoryHighlights()
val expectedItems = listOf(
expectedRecentHistoryHighlights.first(),
expectedRecentHistoryGroups.first(),
expectedRecentHistoryHighlights[1]
) + expectedRecentHistoryGroups.subList(1, expectedRecentHistoryGroups.size)
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
visitsFromSearch + directDistinctVisits + directDupeVisits
}
coEvery { historyHightlightsStorage.getHistoryHighlights(any(), any()) }.coAnswers {
directDistinctVisits.toHistoryHighlights() + directDupeVisits.toHistoryHighlights()
}
startRecentVisitsFeature()
middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
assertEquals(expectedItems, it.recentHistory)
}
}
@Test
fun `GIVEN a list of history highlights and groups WHEN updateState is called THEN emit RecentHistoryChange`() {
val feature = spyk(RecentVisitsFeature(appStore, mockk(), mockk(), mockk(), mockk(), false))
val expected = List<RecentHistoryHighlight>(1) { mockk() }
every { feature.getCombinedHistory(any(), any()) } returns expected
feature.updateState(emptyList(), emptyList())
appStore.waitUntilIdle()
middleware.assertLastAction(AppAction.RecentHistoryChange::class) {
@ -139,9 +397,84 @@ class RecentVisitsFeatureTest {
}
}
@Test
fun `GIVEN highlights visits exist in search groups WHEN getCombined is called THEN remove the highlights already in groups`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(4)
val directVisits = getDirectVisitsHistoryMetadataItems(4)
val directDupeVisits = getSearchFromHistoryMetadataItems(2).map {
// Erase the search term for this to not be mapped to a search group.
// The url remains the same as the item from a group so it should be skipped.
it.copy(key = it.key.copy(searchTerm = null))
}
val expected = directVisits.reversed().toRecentHistoryHighlights()
.zip(visitsFromSearch.toIndividualRecentHistoryGroups())
.flatMap {
listOf(it.first, it.second)
}
val result = feature.getCombinedHistory(
(directVisits + directDupeVisits).toHistoryHighlightsInternal(),
visitsFromSearch.toHistoryGroupsInternal()
)
assertEquals(expected, result)
}
@Test
fun `GIVEN fewer than needed highlights and search groups WHEN getCombined is called THEN the result is sorted by date`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(4)
val directVisits = getDirectVisitsHistoryMetadataItems(4)
val expected = directVisits.reversed().toRecentHistoryHighlights()
.zip(visitsFromSearch.toIndividualRecentHistoryGroups())
.flatMap {
listOf(it.first, it.second)
}
val result = feature.getCombinedHistory(
directVisits.toHistoryHighlightsInternal(),
visitsFromSearch.toHistoryGroupsInternal()
)
assertEquals(expected, result)
}
@Test
fun `GIVEN more highlights are newer than search groups WHEN getCombined is called THEN then return an even split then sorted by date`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(5)
val directVisits = getDirectVisitsHistoryMetadataItems(14)
val expected = directVisits.takeLast(5).reversed().toRecentHistoryHighlights() +
visitsFromSearch.takeLast(4).toIndividualRecentHistoryGroups()
val result = feature.getCombinedHistory(
directVisits.toHistoryHighlightsInternal(),
visitsFromSearch.toHistoryGroupsInternal()
)
assertEquals(expected, result)
}
@Test
fun `GIVEN more search groups are newer than highlights WHEN getCombined is called THEN then return an even split then sorted by date`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(14)
val directVisits = getDirectVisitsHistoryMetadataItems(5)
val expected = visitsFromSearch.takeLast(4).toIndividualRecentHistoryGroups() +
directVisits.takeLast(5).reversed().toRecentHistoryHighlights()
val result = feature.getCombinedHistory(
directVisits.toHistoryHighlightsInternal(),
visitsFromSearch.toHistoryGroupsInternal()
)
assertEquals(expected, result)
}
@Test
fun `GIVEN all highlights have metadata WHEN getHistoryHighlights is called THEN return a list of highlights with an inferred last access time`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val directVisits = getDirectVisitsHistoryMetadataItems(10)
@ -158,7 +491,7 @@ class RecentVisitsFeatureTest {
@Test
fun `GIVEN not all highlights have metadata WHEN getHistoryHighlights is called THEN set 0 for the highlights with not found last access time`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val directVisits = getDirectVisitsHistoryMetadataItems(10)
val highlightsWithUnknownAccessTime = directVisits.toHistoryHighlightsInternal().take(5).map {
@ -179,7 +512,7 @@ class RecentVisitsFeatureTest {
@Test
fun `GIVEN multiple metadata records for the same highlight WHEN getHistoryHighlights is called THEN set the latest access time from multiple available`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val directVisits = getDirectVisitsHistoryMetadataItems(10)
val newerDirectVisits = directVisits.mapIndexed { index, item ->
@ -200,8 +533,70 @@ class RecentVisitsFeatureTest {
}
@Test
fun `GIVEN highlights don't have a valid title WHEN getCombinedHistory is called THEN the url is set as title`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk())
fun `GIVEN multiple metadata entries only for direct accessed pages WHEN getHistorySearchGroups is called THEN return an empty list`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val directVisits = getDirectVisitsHistoryMetadataItems(10)
val result = feature.getHistorySearchGroups(directVisits)
assertTrue(result.isEmpty())
}
@Test
fun `GIVEN multiple metadata entries WHEN getHistorySearchGroups is called THEN group all entries by their search term`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val directVisits = getDirectVisitsHistoryMetadataItems(10)
val result = feature.getHistorySearchGroups(visitsFromSearch + directVisits)
assertEquals(10, result.size)
assertEquals(visitsFromSearch.map { it.key.searchTerm }, result.map { it.groupName })
assertEquals(visitsFromSearch.map { listOf(it) }, result.map { it.groupItems })
}
@Test
fun `GIVEN multiple metadata entries for the same url WHEN getHistorySearchGroups is called THEN entries are deduped`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val newerVisitsFromSearch = visitsFromSearch.map { it.copy(updatedAt = it.updatedAt * 2) }
val directVisits = getDirectVisitsHistoryMetadataItems(10)
val result = feature.getHistorySearchGroups(visitsFromSearch + directVisits + newerVisitsFromSearch)
assertEquals(10, result.size)
assertEquals(newerVisitsFromSearch.map { it.key.searchTerm }, result.map { it.groupName })
assertEquals(
newerVisitsFromSearch.map {
listOf(it.copy(totalViewTime = it.totalViewTime * 2,))
},
result.map { it.groupItems }
)
}
@Test
fun `GIVEN highlights and search groups WHEN getSortedHistory is called THEN sort descending all items based on the last access time`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val directVisits = getDirectVisitsHistoryMetadataItems(10)
val expected = directVisits.reversed().toRecentHistoryHighlights()
.zip(visitsFromSearch.toIndividualRecentHistoryGroups())
.flatMap {
listOf(it.first, it.second)
}
val result = feature.getSortedHistory(
directVisits.toHistoryHighlightsInternal(),
visitsFromSearch.toHistoryGroupsInternal()
)
assertEquals(expected, result)
}
@Test
fun `GIVEN highlights don't have a valid title WHEN getSortedHistory is called THEN the url is set as title`() {
val feature = RecentVisitsFeature(mockk(), mockk(), mockk(), mockk(), mockk(), false)
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
val directVisits = getDirectVisitsHistoryMetadataItems(10).mapIndexed { index, item ->
when (index % 3) {
0 -> item
@ -211,11 +606,12 @@ class RecentVisitsFeatureTest {
}
val sortedByDateHighlights = directVisits.reversed()
val result = feature.getCombinedHistory(
val result = feature.getSortedHistory(
directVisits.toHistoryHighlightsInternal(),
visitsFromSearch.toHistoryGroupsInternal()
).filterIsInstance<RecentHistoryHighlight>()
assertEquals(9, result.size)
assertEquals(10, result.size)
result.forEachIndexed { index, item ->
when (index % 3) {
0 -> assertEquals(sortedByDateHighlights[index].title, item.title)
@ -225,6 +621,25 @@ class RecentVisitsFeatureTest {
}
}
@Test
fun `GIVEN highlight visits also exist in search groups WHEN removeHighlightsAlreadyInGroups is called THEN filter out such highlights`() {
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
// To know if a highlight appears in a search group each visit's url should be checked.
// Ensure we have the identical urls with the ones from a search group and also some random others.
val directDupeVisits = visitsFromSearch.mapIndexed { index, item ->
when (index % 2) {
0 -> item
else -> item.copy(key = item.key.copy(url = "https://mozilla.org"))
}
}
val highlights = directDupeVisits.toHistoryHighlightsInternal()
val result = highlights.removeHighlightsAlreadyInGroups(visitsFromSearch.toHistoryGroupsInternal())
assertEquals(5, result.size)
result.forEach { assertEquals("https://mozilla.org", it.historyHighlight.url) }
}
private fun startRecentVisitsFeature() {
val feature = RecentVisitsFeature(
appStore,
@ -232,8 +647,11 @@ class RecentVisitsFeatureTest {
lazy { historyHightlightsStorage },
scope,
testDispatcher,
false
)
assertEquals(emptyList<RecentHistoryGroup>(), appStore.state.recentHistory)
feature.start()
scope.advanceUntilIdle()
@ -328,6 +746,15 @@ private fun HistoryMetadata.toHistoryHighlight(): HistoryHighlight = HistoryHigh
previewImageUrl = null
)
private fun HistoryMetadata.toRecentHistoryGroup(): RecentHistoryGroup = RecentHistoryGroup(
title = key.searchTerm!!,
historyMetadata = listOf(this)
)
private fun List<HistoryMetadata>.toIndividualRecentHistoryGroups(): List<RecentHistoryGroup> =
map { it.toRecentHistoryGroup() }
.sortedByDescending { it.lastUpdated() }
private fun HistoryMetadata.toRecentHistoryHighlight(): RecentHistoryHighlight =
RecentHistoryHighlight(
title = if (title.isNullOrBlank()) key.url else title!!,
@ -357,3 +784,10 @@ private fun HistoryMetadata.toHistoryHighlightInternal(lastAccessTime: Long) =
private fun List<HistoryMetadata>.toHistoryHighlightsInternal() = mapIndexed { index, item ->
item.toHistoryHighlightInternal(index + 1L)
}
private fun HistoryMetadata.toHistoryGroupInternal() = HistoryGroupInternal(
groupName = key.searchTerm!!,
groupItems = listOf(this)
)
private fun List<HistoryMetadata>.toHistoryGroupsInternal() = map { it.toHistoryGroupInternal() }

View File

@ -5,6 +5,8 @@
package org.mozilla.fenix.home.recentvisits.controller
import androidx.navigation.NavController
import androidx.navigation.NavDirections
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
@ -12,22 +14,31 @@ import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.storage.DocumentType
import mozilla.components.concept.storage.HistoryMetadata
import mozilla.components.concept.storage.HistoryMetadataKey
import mozilla.components.concept.storage.HistoryMetadataStorage
import mozilla.components.feature.tabs.TabsUseCases.SelectOrAddUseCase
import mozilla.components.service.glean.testing.GleanTestRule
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.support.test.rule.MainCoroutineRule
import mozilla.components.support.test.rule.runTestOnMain
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.GleanMetrics.RecentSearches
import org.mozilla.fenix.R
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.home.HomeFragmentDirections
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
@OptIn(ExperimentalCoroutinesApi::class)
@ -82,6 +93,69 @@ class RecentVisitsControllerTest {
}
}
@Test
fun handleRecentHistoryGroupClicked() = runTestOnMain {
val historyEntry = HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
title = "mozilla",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
totalViewTime = 10,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val historyGroup = RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(historyEntry)
)
controller.handleRecentHistoryGroupClicked(historyGroup)
verify {
navController.navigate(
match<NavDirections> { it.actionId == R.id.action_global_history_metadata_group }
)
}
}
@Test
fun handleRemoveGroup() = runTestOnMain {
val historyMetadataKey = HistoryMetadataKey(
"http://www.mozilla.com",
"mozilla",
null
)
val historyGroup = RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(
HistoryMetadata(
key = historyMetadataKey,
title = "mozilla",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
totalViewTime = 10,
documentType = DocumentType.Regular,
previewImageUrl = null
)
)
)
assertNull(RecentSearches.groupDeleted.testGetValue())
controller.handleRemoveRecentHistoryGroup(historyGroup.title)
advanceUntilIdle()
verify {
store.dispatch(HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = historyGroup.title))
appStore.dispatch(AppAction.DisbandSearchGroupAction(searchTerm = historyGroup.title))
}
assertNotNull(RecentSearches.groupDeleted.testGetValue())
coVerify {
storage.deleteHistoryMetadata(historyGroup.title)
}
}
@Test
fun handleRecentHistoryHighlightClicked() = runTestOnMain {
val historyHighlight = RecentHistoryHighlight("title", "url")

View File

@ -6,12 +6,16 @@ package org.mozilla.fenix.home.recentvisits.interactor
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.concept.storage.DocumentType
import mozilla.components.concept.storage.HistoryMetadata
import mozilla.components.concept.storage.HistoryMetadataKey
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.home.pocket.PocketStoriesController
import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController
import org.mozilla.fenix.home.recentsyncedtabs.controller.RecentSyncedTabController
import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.home.recentvisits.controller.RecentVisitsController
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
@ -40,12 +44,67 @@ class RecentVisitsInteractorTest {
)
}
@Test
fun handleRecentHistoryGroupClicked() {
val historyGroup =
RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(
HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", null, null),
title = "mozilla",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
totalViewTime = 10,
documentType = DocumentType.Regular,
previewImageUrl = null
)
)
)
interactor.onRecentHistoryGroupClicked(historyGroup)
verify {
recentVisitsController.handleRecentHistoryGroupClicked(historyGroup)
}
}
@Test
fun handleHistoryShowAllClicked() {
interactor.onHistoryShowAllClicked()
verify { recentVisitsController.handleHistoryShowAllClicked() }
}
@Test
fun onRemoveRecentHistoryGroup() {
val historyMetadataKey = HistoryMetadataKey(
"http://www.mozilla.com",
"mozilla",
null
)
val historyGroup =
RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(
HistoryMetadata(
key = historyMetadataKey,
title = "mozilla",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
totalViewTime = 10,
documentType = DocumentType.Regular,
previewImageUrl = null
)
)
)
interactor.onRemoveRecentHistoryGroup(historyGroup.title)
verify {
recentVisitsController.handleRemoveRecentHistoryGroup(historyGroup.title)
}
}
@Test
fun onRecentHistoryHighlightClicked() {
val historyHighlight: RecentHistoryHighlight = mockk()

View File

@ -24,7 +24,7 @@ import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.utils.Settings
@RunWith(FenixRobolectricTestRunner::class)
@ -56,7 +56,7 @@ class SessionControlViewTest {
@Test
fun `GIVEN historyMetadata WHEN calling shouldShowHomeOnboardingDialog THEN show the dialog `() {
val historyMetadata = listOf(RecentlyVisitedItem.RecentHistoryHighlight("title", ""))
val historyMetadata = listOf(RecentHistoryGroup("title", emptyList()))
val settings: Settings = mockk()
every { settings.hasShownHomeOnboardingDialog } returns false
@ -138,7 +138,7 @@ class SessionControlViewTest {
val collections = emptyList<TabCollection>()
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf(RecentBookmark())
val historyMetadata = emptyList<RecentlyVisitedItem>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketStories = emptyList<PocketStory>()
every { settings.showTopSitesFeature } returns true
@ -173,7 +173,7 @@ class SessionControlViewTest {
val collections = emptyList<TabCollection>()
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf(RecentBookmark())
val historyMetadata = emptyList<RecentlyVisitedItem>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketStories = emptyList<PocketStory>()
val nimbusMessageCard: Message = mockk()
@ -206,7 +206,7 @@ class SessionControlViewTest {
val collections = emptyList<TabCollection>()
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>()
val historyMetadata = emptyList<RecentlyVisitedItem>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketStories = emptyList<PocketStory>()
every { settings.showTopSitesFeature } returns true
@ -241,7 +241,7 @@ class SessionControlViewTest {
val collections = emptyList<TabCollection>()
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>()
val historyMetadata = listOf<RecentlyVisitedItem>(mockk())
val historyMetadata = listOf(RecentHistoryGroup("title", emptyList()))
val pocketStories = emptyList<PocketStory>()
every { settings.showTopSitesFeature } returns true
@ -276,7 +276,7 @@ class SessionControlViewTest {
val collections = emptyList<TabCollection>()
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>()
val historyMetadata = emptyList<RecentlyVisitedItem>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketStories = listOf(PocketRecommendedStory("", "", "", "", "", 1, 1))
every { settings.showTopSitesFeature } returns true
@ -312,7 +312,7 @@ class SessionControlViewTest {
val collections = emptyList<TabCollection>()
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>()
val historyMetadata = emptyList<RecentlyVisitedItem>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketStories = emptyList<PocketStory>()
every { settings.showTopSitesFeature } returns true
@ -347,7 +347,7 @@ class SessionControlViewTest {
val collections = listOf(collection)
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>(mockk())
val historyMetadata = listOf<RecentlyVisitedItem>(mockk())
val historyMetadata = listOf<RecentHistoryGroup>(mockk())
val pocketStories = listOf<PocketStory>(mockk())
every { settings.showTopSitesFeature } returns true