* For #22534 - Update homescreen section name to "Recently visited" * For #22534 - Show both history highlights and groups in Recently visited For now the metadata groups don't support scoring so as an interim solution we will show up to 9 items, evenly distributes, first favoring groups sorted by date then history highlights pre-sorted by default. Tapping a history highlight will switch to it's already open tab if available or create a new one in which to load it if needed. A "Remove" option will also be available for history highlights to remove it from the screen and also from history. Currently removing a group / highlight will not query new ones to again show up to 9 items, this will be implemented separately. * For #22534 - Rename and refactor historymetadata to recentvisits The updated feature supports more than history metadata so updating the overall naming scheme seems needed. To signal that this is a homescreen feature the entire package is moved to home * For #22534 - Update UI tests to account for the new items space on the screen Saw failures about not finding the collection section on screen. This is probably happening because w are now adding the recent visits to homescreen above the collections section pushing it off screen. Since the collections might be obstructed by the toolbar shown on top as a quick solution we'll scroll to the next homescreen section so that the collections will be shown above in their entirety. * Update app/src/main/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeature.kt Co-authored-by: Christian Sadilek <christian.sadilek@gmail.com> * Update app/src/main/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeature.kt Co-authored-by: Christian Sadilek <christian.sadilek@gmail.com> Co-authored-by: Gabriel Luong <gabriel.luong@gmail.com> Co-authored-by: Christian Sadilek <christian.sadilek@gmail.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>upstream-sync
parent
de1c6b0dae
commit
5c3fedd707
@ -0,0 +1,75 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
package org.mozilla.fenix.compose
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import mozilla.components.browser.icons.IconRequest
|
||||
import mozilla.components.browser.icons.compose.Loader
|
||||
import mozilla.components.browser.icons.compose.Placeholder
|
||||
import mozilla.components.browser.icons.compose.WithIcon
|
||||
import mozilla.components.ui.colors.PhotonColors
|
||||
import org.mozilla.fenix.components.components
|
||||
|
||||
/**
|
||||
* Load and display the favicon of a particular website.
|
||||
*
|
||||
* @param url Website [URL] for which the favicon will be shown.
|
||||
* @param size [Dp] height and width of the image to be loaded.
|
||||
* @param isPrivate Whether or not a private request (like in private browsing) should be used to
|
||||
* download the icon (if needed).
|
||||
*/
|
||||
@Composable
|
||||
fun Favicon(
|
||||
url: String,
|
||||
size: Dp,
|
||||
isPrivate: Boolean = false
|
||||
) {
|
||||
components.core.icons.Loader(
|
||||
url = url,
|
||||
isPrivate = isPrivate,
|
||||
size = size.toIconRequestSize()
|
||||
) {
|
||||
Placeholder {
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
color = when (isSystemInDarkTheme()) {
|
||||
true -> PhotonColors.DarkGrey30
|
||||
false -> PhotonColors.LightGrey30
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
WithIcon { icon ->
|
||||
Image(
|
||||
painter = icon.painter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dp.toIconRequestSize() = when {
|
||||
value <= dimensionResource(IconRequest.Size.DEFAULT.dimen).value -> IconRequest.Size.DEFAULT
|
||||
value <= dimensionResource(IconRequest.Size.LAUNCHER.dimen).value -> IconRequest.Size.LAUNCHER
|
||||
else -> IconRequest.Size.LAUNCHER_ADAPTIVE
|
||||
}
|
@ -1,81 +0,0 @@
|
||||
/* 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.historymetadata
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import mozilla.components.concept.storage.HistoryMetadata
|
||||
import mozilla.components.concept.storage.HistoryMetadataStorage
|
||||
import mozilla.components.support.base.feature.LifecycleAwareFeature
|
||||
import org.mozilla.fenix.home.HomeFragment
|
||||
import org.mozilla.fenix.home.HomeFragmentAction
|
||||
import org.mozilla.fenix.home.HomeFragmentStore
|
||||
import kotlin.math.max
|
||||
|
||||
private const val DEFAULT_MAX_RESULTS = 9
|
||||
|
||||
/**
|
||||
* View-bound feature that retrieves a list of history metadata and dispatches updates to the
|
||||
* [HomeFragmentStore].
|
||||
*
|
||||
* @param homeStore The [HomeFragmentStore] that holds the state of the [HomeFragment].
|
||||
* @param historyMetadataStorage The storage manages [HistoryMetadata].
|
||||
* @param scope The [CoroutineScope] used to retrieve a list of history metadata.
|
||||
* @param ioDispatcher The [CoroutineDispatcher] for performing read/write operations.
|
||||
* @param maxResults The maximum number of metadata groups that should be added to
|
||||
* the store and displayed on the [HomeFragment].
|
||||
*/
|
||||
class HistoryMetadataFeature(
|
||||
private val homeStore: HomeFragmentStore,
|
||||
private val historyMetadataStorage: HistoryMetadataStorage,
|
||||
private val scope: CoroutineScope,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
private val maxResults: Int = DEFAULT_MAX_RESULTS
|
||||
) : LifecycleAwareFeature {
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
override fun start() {
|
||||
job = scope.launch(ioDispatcher) {
|
||||
// For now, group the queried list of [HistoryMetadata] according to their search term.
|
||||
// This feature will later be used to generate different groups and highlights.
|
||||
val historyMetadata = historyMetadataStorage.getHistoryMetadataSince(Long.MIN_VALUE)
|
||||
.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 { (title, data) ->
|
||||
HistoryMetadataGroup(
|
||||
title = title,
|
||||
historyMetadata = data
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.lastUpdated() }
|
||||
.take(maxResults)
|
||||
|
||||
homeStore.dispatch(HomeFragmentAction.HistoryMetadataChange(historyMetadata))
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
/* 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.historymetadata
|
||||
|
||||
import mozilla.components.concept.storage.HistoryMetadata
|
||||
|
||||
/**
|
||||
* A history metadata group.
|
||||
*
|
||||
* @property title The title of the group.
|
||||
* @property historyMetadata A list of [HistoryMetadata] records that matches the title.
|
||||
*/
|
||||
data class HistoryMetadataGroup(
|
||||
val title: String,
|
||||
val historyMetadata: List<HistoryMetadata> = emptyList()
|
||||
)
|
||||
|
||||
// The last updated time of the group is based on the most recently updated item in the group
|
||||
fun HistoryMetadataGroup.lastUpdated(): Long = historyMetadata.maxOf { it.updatedAt }
|
@ -1,94 +0,0 @@
|
||||
/* 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.historymetadata.controller
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
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 org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.components.metrics.Event
|
||||
import org.mozilla.fenix.components.metrics.MetricController
|
||||
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
|
||||
import org.mozilla.fenix.historymetadata.interactor.HistoryMetadataInteractor
|
||||
import org.mozilla.fenix.home.HomeFragmentAction
|
||||
import org.mozilla.fenix.home.HomeFragmentDirections
|
||||
import org.mozilla.fenix.home.HomeFragmentStore
|
||||
import org.mozilla.fenix.library.history.toHistoryMetadata
|
||||
|
||||
/**
|
||||
* An interface that handles the view manipulation of the history metadata in the Home screen.
|
||||
*/
|
||||
interface HistoryMetadataController {
|
||||
|
||||
/**
|
||||
* @see [HistoryMetadataInteractor.onHistoryMetadataShowAllClicked]
|
||||
*/
|
||||
fun handleHistoryShowAllClicked()
|
||||
|
||||
/**
|
||||
* @see [HistoryMetadataInteractor.onHistoryMetadataGroupClicked]
|
||||
*/
|
||||
fun handleHistoryMetadataGroupClicked(historyMetadataGroup: HistoryMetadataGroup)
|
||||
|
||||
/**
|
||||
* @see [HistoryMetadataInteractor.onRemoveGroup]
|
||||
*/
|
||||
fun handleRemoveGroup(searchTerm: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* The default implementation of [HistoryMetadataController].
|
||||
*/
|
||||
class DefaultHistoryMetadataController(
|
||||
private val store: BrowserStore,
|
||||
private val homeStore: HomeFragmentStore,
|
||||
private val navController: NavController,
|
||||
private val storage: HistoryMetadataStorage,
|
||||
private val scope: CoroutineScope,
|
||||
private val metrics: MetricController
|
||||
) : HistoryMetadataController {
|
||||
|
||||
override fun handleHistoryShowAllClicked() {
|
||||
dismissSearchDialogIfDisplayed()
|
||||
navController.navigate(
|
||||
HomeFragmentDirections.actionGlobalHistoryFragment()
|
||||
)
|
||||
}
|
||||
|
||||
override fun handleHistoryMetadataGroupClicked(historyMetadataGroup: HistoryMetadataGroup) {
|
||||
navController.navigate(
|
||||
HomeFragmentDirections.actionGlobalHistoryMetadataGroup(
|
||||
title = historyMetadataGroup.title,
|
||||
historyMetadataItems = historyMetadataGroup.historyMetadata
|
||||
.map { it.toHistoryMetadata() }.toTypedArray()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun handleRemoveGroup(searchTerm: 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 = searchTerm))
|
||||
homeStore.dispatch(HomeFragmentAction.DisbandSearchGroupAction(searchTerm = searchTerm))
|
||||
// Then, perform the expensive IO work of removing search groups from storage.
|
||||
scope.launch {
|
||||
storage.deleteHistoryMetadata(searchTerm)
|
||||
}
|
||||
metrics.track(Event.RecentSearchesGroupDeleted)
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = PRIVATE)
|
||||
fun dismissSearchDialogIfDisplayed() {
|
||||
if (navController.currentDestination?.id == R.id.searchDialogFragment) {
|
||||
navController.navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
/* 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.historymetadata.interactor
|
||||
|
||||
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
|
||||
|
||||
/**
|
||||
* Interface for history metadata related actions in the Home screen.
|
||||
*/
|
||||
interface HistoryMetadataInteractor {
|
||||
|
||||
/**
|
||||
* Shows the history fragment. Called when a user clicks on the "Show all" button besides the
|
||||
* history metadata header.
|
||||
*/
|
||||
fun onHistoryMetadataShowAllClicked()
|
||||
|
||||
/**
|
||||
* Navigates to the history metadata group fragment to display the group. Called when a user
|
||||
* clicks on a history metadata group.
|
||||
*
|
||||
* @param historyMetadataGroup The [HistoryMetadataGroup] to toggle its expanded state.
|
||||
*/
|
||||
fun onHistoryMetadataGroupClicked(historyMetadataGroup: HistoryMetadataGroup)
|
||||
|
||||
/**
|
||||
* Removes a history metadata group with the given search term from the homescreen.
|
||||
*
|
||||
* @param searchTerm The search term to be removed.
|
||||
*/
|
||||
fun onRemoveGroup(searchTerm: String)
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
/* 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.historymetadata.view
|
||||
|
||||
import android.view.View
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.databinding.HistoryMetadataHeaderBinding
|
||||
import org.mozilla.fenix.historymetadata.interactor.HistoryMetadataInteractor
|
||||
import org.mozilla.fenix.utils.view.ViewHolder
|
||||
|
||||
/**
|
||||
* View holder for the history metadata header and "Show all" button.
|
||||
*
|
||||
* @property interactor [HistoryMetadataInteractor] which will have delegated to all user
|
||||
* interactions.
|
||||
*/
|
||||
class HistoryMetadataHeaderViewHolder(
|
||||
view: View,
|
||||
private val interactor: HistoryMetadataInteractor
|
||||
) : ViewHolder(view) {
|
||||
|
||||
init {
|
||||
val binding = HistoryMetadataHeaderBinding.bind(view)
|
||||
binding.showAllButton.setOnClickListener {
|
||||
interactor.onHistoryMetadataShowAllClicked()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LAYOUT_ID = R.layout.history_metadata_header
|
||||
}
|
||||
}
|
@ -1,231 +0,0 @@
|
||||
/* 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.historymetadata.view
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.scrollable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.historymetadata.HistoryMetadataGroup
|
||||
import org.mozilla.fenix.theme.FirefoxTheme
|
||||
|
||||
// Number of recently visited items per column.
|
||||
private const val VISITS_PER_COLUMN = 3
|
||||
|
||||
/**
|
||||
* A list of recently visited items.
|
||||
*
|
||||
* @param recentVisits List of [HistoryMetadataGroup] to display.
|
||||
* @param menuItems List of [RecentVisitMenuItem] to display in a recent visit dropdown menu.
|
||||
* @param onRecentVisitClick Invoked when the user clicks on a recent visit.
|
||||
*/
|
||||
@Composable
|
||||
fun RecentlyVisited(
|
||||
recentVisits: List<HistoryMetadataGroup>,
|
||||
menuItems: List<RecentVisitMenuItem>,
|
||||
onRecentVisitClick: (HistoryMetadataGroup, Int) -> Unit = { _, _ -> }
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
backgroundColor = FirefoxTheme.colors.surface,
|
||||
elevation = 6.dp
|
||||
) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp)
|
||||
) {
|
||||
val itemsList = recentVisits.chunked(VISITS_PER_COLUMN)
|
||||
|
||||
itemsIndexed(itemsList) { pageIndex, items ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items.forEachIndexed { index, recentVisit ->
|
||||
RecentVisitItem(
|
||||
recentVisit = recentVisit,
|
||||
menuItems = menuItems,
|
||||
showDividerLine = index < items.size - 1,
|
||||
onRecentVisitClick = onRecentVisitClick,
|
||||
pageNumber = pageIndex + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A recent visit item.
|
||||
*
|
||||
* @param recentVisit The [HistoryMetadataGroup] to display.
|
||||
* @param menuItems List of [RecentVisitMenuItem] to display in a recent visit dropdown menu.
|
||||
* @param onRecentVisitClick Invoked when the user clicks on a recent visit.
|
||||
* @param pageNumber which page is the item on.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun RecentVisitItem(
|
||||
recentVisit: HistoryMetadataGroup,
|
||||
menuItems: List<RecentVisitMenuItem>,
|
||||
showDividerLine: Boolean,
|
||||
onRecentVisitClick: (HistoryMetadataGroup, Int) -> Unit = { _, _ -> },
|
||||
pageNumber: Int
|
||||
) {
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = { onRecentVisitClick(recentVisit, pageNumber) },
|
||||
onLongClick = { menuExpanded = 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()
|
||||
) {
|
||||
Text(
|
||||
text = recentVisit.title,
|
||||
modifier = Modifier.padding(top = 7.dp, bottom = 2.dp),
|
||||
color = FirefoxTheme.colors.textPrimary,
|
||||
fontSize = 16.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
RecentlyVisitedCaption(recentVisit.historyMetadata.size)
|
||||
|
||||
if (showDividerLine) {
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 9.dp),
|
||||
color = FirefoxTheme.colors.dividerLine,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismissRequest = { menuExpanded = false },
|
||||
modifier = Modifier.background(color = FirefoxTheme.colors.surface)
|
||||
.height(52.dp)
|
||||
.scrollable(
|
||||
state = ScrollState(0),
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = false
|
||||
)
|
||||
) {
|
||||
for (item in menuItems) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
item.onClick(recentVisit)
|
||||
},
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
color = FirefoxTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.align(Alignment.Top)
|
||||
.padding(top = 6.dp)
|
||||
.scrollable(
|
||||
state = ScrollState(0),
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = false
|
||||
).fillMaxHeight()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The caption text for a recent visit.
|
||||
*
|
||||
* @param count Number of recently visited items to display in the caption.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecentlyVisitedCaption(count: Int) {
|
||||
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),
|
||||
color = FirefoxTheme.colors.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
@Preview
|
||||
private fun RecentlyVisitedPreview() {
|
||||
FirefoxTheme {
|
||||
RecentlyVisited(
|
||||
recentVisits = listOf(
|
||||
HistoryMetadataGroup(title = "running shoes"),
|
||||
HistoryMetadataGroup(title = "mozilla"),
|
||||
HistoryMetadataGroup(title = "firefox"),
|
||||
HistoryMetadataGroup(title = "pocket")
|
||||
),
|
||||
menuItems = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,283 @@
|
||||
/* 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.home.HomeFragment
|
||||
import org.mozilla.fenix.home.HomeFragmentAction
|
||||
import org.mozilla.fenix.home.HomeFragmentStore
|
||||
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.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 [HomeFragmentStore]
|
||||
* to be displayed on the homescreen.
|
||||
*
|
||||
* @param homeStore The [HomeFragmentStore] that holds the state of the [HomeFragment].
|
||||
* @param historyMetadataStorage The storage that manages [HistoryMetadata].
|
||||
* @param historyHighlightsStorage The storage that manages [PlacesHistoryStorage].
|
||||
* @param scope The [CoroutineScope] used for IO operations related to querying history
|
||||
* and then for dispatching updates.
|
||||
* @param ioDispatcher The [CoroutineDispatcher] for performing read/write operations.
|
||||
*/
|
||||
class RecentVisitsFeature(
|
||||
private val homeStore: HomeFragmentStore,
|
||||
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>
|
||||
) {
|
||||
homeStore.dispatch(
|
||||
HomeFragmentAction.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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
/* 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 mozilla.components.concept.storage.HistoryMetadata
|
||||
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
|
||||
|
||||
/**
|
||||
* History items as individual or groups of previously accessed webpages.
|
||||
*/
|
||||
sealed class RecentlyVisitedItem {
|
||||
/**
|
||||
* A history highlight - previously accessed webpage of particular importance.
|
||||
*
|
||||
* @param title The title of the webpage. May be [url] if the title is unavailable.
|
||||
* @param url The URL of the webpage.
|
||||
*/
|
||||
data class RecentHistoryHighlight(
|
||||
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 }
|
@ -0,0 +1,151 @@
|
||||
/* 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.controller
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
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 org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.components.metrics.Event
|
||||
import org.mozilla.fenix.components.metrics.MetricController
|
||||
import org.mozilla.fenix.home.HomeFragmentAction
|
||||
import org.mozilla.fenix.home.HomeFragmentAction.RemoveRecentHistoryHighlight
|
||||
import org.mozilla.fenix.home.HomeFragmentDirections
|
||||
import org.mozilla.fenix.home.HomeFragmentStore
|
||||
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.
|
||||
*/
|
||||
interface RecentVisitsController {
|
||||
|
||||
/**
|
||||
* Callback for when the "Show all" link is clicked.
|
||||
*/
|
||||
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].
|
||||
*
|
||||
* @param recentHistoryHighlight The just clicked [RecentHistoryHighlight].
|
||||
*/
|
||||
fun handleRecentHistoryHighlightClicked(recentHistoryHighlight: RecentHistoryHighlight)
|
||||
|
||||
/**
|
||||
* Callback for when the user removes a certain [RecentHistoryHighlight].
|
||||
*
|
||||
* @param highlightUrl Url of the [RecentHistoryHighlight] to remove.
|
||||
*/
|
||||
fun handleRemoveRecentHistoryHighlight(highlightUrl: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* The default implementation of [RecentVisitsController].
|
||||
*/
|
||||
class DefaultRecentVisitsController(
|
||||
private val store: BrowserStore,
|
||||
private val homeStore: HomeFragmentStore,
|
||||
private val selectOrAddTabUseCase: SelectOrAddUseCase,
|
||||
private val navController: NavController,
|
||||
private val storage: HistoryMetadataStorage,
|
||||
private val scope: CoroutineScope,
|
||||
private val metrics: MetricController
|
||||
) : RecentVisitsController {
|
||||
|
||||
/**
|
||||
* Shows the history fragment.
|
||||
*/
|
||||
override fun handleHistoryShowAllClicked() {
|
||||
dismissSearchDialogIfDisplayed()
|
||||
navController.navigate(
|
||||
HomeFragmentDirections.actionGlobalHistoryFragment()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
.map { it.toHistoryMetadata() }.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))
|
||||
homeStore.dispatch(HomeFragmentAction.DisbandSearchGroupAction(searchTerm = groupTitle))
|
||||
// Then, perform the expensive IO work of removing search groups from storage.
|
||||
scope.launch {
|
||||
storage.deleteHistoryMetadata(groupTitle)
|
||||
}
|
||||
metrics.track(Event.RecentSearchesGroupDeleted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to an already open tab for [recentHistoryHighlight] if one exists or
|
||||
* create a new tab in which to load this item's URL.
|
||||
*
|
||||
* @param recentHistoryHighlight the just clicked [RecentHistoryHighlight] to open in browser.
|
||||
*/
|
||||
override fun handleRecentHistoryHighlightClicked(recentHistoryHighlight: RecentHistoryHighlight) {
|
||||
selectOrAddTabUseCase.invoke(recentHistoryHighlight.url)
|
||||
navController.navigate(R.id.browserFragment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a [RecentHistoryHighlight] with the given title from the homescreen.
|
||||
*
|
||||
* @param highlightUrl The title of the [RecentHistoryHighlight] to be removed.
|
||||
*/
|
||||
override fun handleRemoveRecentHistoryHighlight(highlightUrl: String) {
|
||||
homeStore.dispatch(RemoveRecentHistoryHighlight(highlightUrl))
|
||||
scope.launch {
|
||||
storage.deleteHistoryMetadataForUrl(highlightUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting(otherwise = PRIVATE)
|
||||
fun dismissSearchDialogIfDisplayed() {
|
||||
if (navController.currentDestination?.id == R.id.searchDialogFragment) {
|
||||
navController.navigateUp()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
/* 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.interactor
|
||||
|
||||
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
|
||||
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
|
||||
|
||||
/**
|
||||
* All possible user interactions with the "Recent visits" section.
|
||||
*/
|
||||
interface RecentVisitsInteractor {
|
||||
|
||||
/**
|
||||
* Callback for when the user clicks on the "Show all" button besides the recent visits header.
|
||||
*/
|
||||
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].
|
||||
*
|
||||
* @param recentHistoryHighlight The just clicked [RecentHistoryHighlight].
|
||||
*/
|
||||
fun onRecentHistoryHighlightClicked(recentHistoryHighlight: RecentHistoryHighlight)
|
||||
|
||||
/**
|
||||
* Callback for when the user selected an option to remove a [RecentHistoryHighlight].
|
||||
*
|
||||
* @param highlightUrl [RecentHistoryHighlight.url] of the item to remove.
|
||||
*/
|
||||
fun onRemoveRecentHistoryHighlight(highlightUrl: String)
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
/* 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.view
|
||||
|
||||
import android.view.View
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.databinding.RecentVisitsHeaderBinding
|
||||
import org.mozilla.fenix.home.recentvisits.interactor.RecentVisitsInteractor
|
||||
import org.mozilla.fenix.utils.view.ViewHolder
|
||||
|
||||
/**
|
||||
* View holder for the "Recent visits" section header with the "Show all" button.
|
||||
*
|
||||
* @property interactor [RecentVisitsInteractor] which will have delegated to all user
|
||||
* interactions.
|
||||
*/
|
||||
class RecentVisitsHeaderViewHolder(
|
||||
view: View,
|
||||
private val interactor: RecentVisitsInteractor
|
||||
) : ViewHolder(view) {
|
||||
|
||||
init {
|
||||
val binding = RecentVisitsHeaderBinding.bind(view)
|
||||
binding.showAllButton.setOnClickListener {
|
||||
interactor.onHistoryShowAllClicked()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LAYOUT_ID = R.layout.recent_visits_header
|
||||
}
|
||||
}
|
@ -0,0 +1,355 @@
|
||||
/* 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.view
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.scrollable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.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
|
||||
|
||||
// Number of recently visited items per column.
|
||||
private const val VISITS_PER_COLUMN = 3
|
||||
|
||||
/**
|
||||
* A list of recently visited items.
|
||||
*
|
||||
* @param recentVisits List of [RecentHistoryGroup] to display.
|
||||
* @param menuItems List of [RecentVisitMenuItem] for [RecentHistoryGroup]s.
|
||||
* Currently [RecentHistoryHighlight]s do not support a menu -
|
||||
* https://mozilla-hub.atlassian.net/browse/FXMUX-187
|
||||
* @param onRecentVisitClick Invoked when the user clicks on a recent visit.
|
||||
*/
|
||||
@Composable
|
||||
fun RecentlyVisited(
|
||||
recentVisits: List<RecentlyVisitedItem>,
|
||||
menuItems: List<RecentVisitMenuItem>,
|
||||
onRecentVisitClick: (RecentlyVisitedItem, Int) -> Unit = { _, _ -> }
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
backgroundColor = FirefoxTheme.colors.surface,
|
||||
elevation = 6.dp
|
||||
) {
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp)
|
||||
) {
|
||||
val itemsList = recentVisits.chunked(VISITS_PER_COLUMN)
|
||||
|
||||
itemsIndexed(itemsList) { pageIndex, items ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items.forEachIndexed { index, recentVisit ->
|
||||
when (recentVisit) {
|
||||
is RecentHistoryHighlight -> RecentlyVisitedHistoryHighlight(
|
||||
recentVisit = recentVisit,
|
||||
menuItems = menuItems,
|
||||
showDividerLine = index < items.size - 1,
|
||||
onRecentVisitClick = {
|
||||
onRecentVisitClick(it, pageIndex + 1)
|
||||
}
|
||||
)
|
||||
is RecentHistoryGroup -> RecentlyVisitedHistoryGroup(
|
||||
recentVisit = recentVisit,
|
||||
menuItems = menuItems,
|
||||
showDividerLine = index < items.size - 1,
|
||||
onRecentVisitClick = {
|
||||
onRecentVisitClick(it, pageIndex + 1)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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>,
|
||||
showDividerLine: Boolean,
|
||||
onRecentVisitClick: (RecentHistoryGroup) -> Unit = { _ -> },
|
||||
) {
|
||||
var isMenuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
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)
|
||||
)
|
||||
|
||||
RecentlyVisitedCaption(recentVisit.historyMetadata.size)
|
||||
|
||||
if (showDividerLine) {
|
||||
RecentlyVisitedDivider(modifier = Modifier.padding(top = 9.dp))
|
||||
}
|
||||
}
|
||||
|
||||
RecentlyVisitedMenu(
|
||||
showMenu = isMenuExpanded,
|
||||
menuItems = menuItems,
|
||||
recentVisit = recentVisit,
|
||||
onDismissRequest = { isMenuExpanded = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A recently visited history item.
|
||||
*
|
||||
* @param recentVisit The [RecentHistoryHighlight] to display.
|
||||
* @param menuItems List of [RecentVisitMenuItem] to display in a recent visit dropdown menu.
|
||||
* @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 RecentlyVisitedHistoryHighlight(
|
||||
recentVisit: RecentHistoryHighlight,
|
||||
menuItems: List<RecentVisitMenuItem>,
|
||||
showDividerLine: Boolean,
|
||||
onRecentVisitClick: (RecentHistoryHighlight) -> Unit = { _ -> },
|
||||
) {
|
||||
var isMenuExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = { onRecentVisitClick(recentVisit) },
|
||||
onLongClick = { isMenuExpanded = true }
|
||||
)
|
||||
.size(268.dp, 56.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Favicon(url = recentVisit.url, size = 24.dp)
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
RecentlyVisitedTitle(
|
||||
text = recentVisit.title,
|
||||
modifier = Modifier.align(Alignment.CenterStart)
|
||||
)
|
||||
|
||||
if (showDividerLine) {
|
||||
RecentlyVisitedDivider(modifier = Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
}
|
||||
|
||||
RecentlyVisitedMenu(
|
||||
showMenu = isMenuExpanded,
|
||||
menuItems = menuItems,
|
||||
recentVisit = recentVisit,
|
||||
onDismissRequest = { isMenuExpanded = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The title of a recent visit.
|
||||
*
|
||||
* @param text [String] that will be display. Will be ellipsized if cannot fit on one line.
|
||||
* @param modifier [Modifier] allowing to perfectly place this.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecentlyVisitedTitle(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = modifier,
|
||||
color = FirefoxTheme.colors.textPrimary,
|
||||
fontSize = 16.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The caption text for a recent visit.
|
||||
*
|
||||
* @param count Number of recently visited items to display in the caption.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecentlyVisitedCaption(count: Int) {
|
||||
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),
|
||||
color = FirefoxTheme.colors.textSecondary,
|
||||
fontSize = 12.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu shown for a [RecentlyVisitedItem].
|
||||
*
|
||||
* @see [DropdownMenu]
|
||||
*
|
||||
* @param showMenu Whether this is currently open and visible to the user.
|
||||
* @param menuItems List of options shown.
|
||||
* @param recentVisit The [RecentlyVisitedItem] for which this menu is shown.
|
||||
* @param onDismissRequest Called when the user chooses a menu option or requests to dismiss the menu.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecentlyVisitedMenu(
|
||||
showMenu: Boolean,
|
||||
menuItems: List<RecentVisitMenuItem>,
|
||||
recentVisit: RecentlyVisitedItem,
|
||||
onDismissRequest: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
modifier = Modifier
|
||||
.background(color = FirefoxTheme.colors.surface)
|
||||
.height(52.dp)
|
||||
.scrollable(
|
||||
state = ScrollState(0),
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = false
|
||||
)
|
||||
) {
|
||||
for (item in menuItems) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
item.onClick(recentVisit)
|
||||
},
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
) {
|
||||
Text(
|
||||
text = item.title,
|
||||
color = FirefoxTheme.colors.textPrimary,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Top)
|
||||
.padding(top = 6.dp)
|
||||
.scrollable(
|
||||
state = ScrollState(0),
|
||||
orientation = Orientation.Vertical,
|
||||
enabled = false
|
||||
)
|
||||
.fillMaxHeight()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A recent item divider.
|
||||
*
|
||||
* @param modifier [Modifier] allowing to perfectly place this.
|
||||
*/
|
||||
@Composable
|
||||
private fun RecentlyVisitedDivider(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Divider(
|
||||
modifier = modifier,
|
||||
color = FirefoxTheme.colors.dividerLine,
|
||||
thickness = 0.5.dp
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
@Preview
|
||||
private fun RecentlyVisitedPreview() {
|
||||
FirefoxTheme {
|
||||
RecentlyVisited(
|
||||
recentVisits = listOf(
|
||||
RecentHistoryGroup(title = "running shoes"),
|
||||
RecentHistoryGroup(title = "mozilla"),
|
||||
RecentHistoryGroup(title = "firefox"),
|
||||
RecentHistoryGroup(title = "pocket")
|
||||
),
|
||||
menuItems = emptyList()
|
||||
)
|
||||
}
|
||||
}
|
@ -1,326 +0,0 @@
|
||||
/* 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.historymetadata
|
||||
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
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.support.test.libstate.ext.waitUntilIdle
|
||||
import mozilla.components.support.test.middleware.CaptureActionsMiddleware
|
||||
import mozilla.components.support.test.rule.MainCoroutineRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.mozilla.fenix.home.HomeFragmentAction
|
||||
import org.mozilla.fenix.home.HomeFragmentState
|
||||
import org.mozilla.fenix.home.HomeFragmentStore
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class HistoryMetadataFeatureTest {
|
||||
|
||||
private lateinit var historyMetadataStorage: HistoryMetadataStorage
|
||||
|
||||
private val middleware = CaptureActionsMiddleware<HomeFragmentState, HomeFragmentAction>()
|
||||
private val homeStore = HomeFragmentStore(middlewares = listOf(middleware))
|
||||
private val testDispatcher = TestCoroutineDispatcher()
|
||||
|
||||
@get:Rule
|
||||
val coroutinesTestRule = MainCoroutineRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
historyMetadataStorage = mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no history metadata WHEN feature starts THEN fetch history metadata and notify store`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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 expectedHistoryGroup = HistoryMetadataGroup(
|
||||
title = "mozilla",
|
||||
historyMetadata = listOf(historyEntry)
|
||||
)
|
||||
|
||||
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
|
||||
listOf(
|
||||
historyEntry
|
||||
)
|
||||
}
|
||||
|
||||
startHistoryMetadataFeature()
|
||||
|
||||
middleware.assertLastAction(HomeFragmentAction.HistoryMetadataChange::class) {
|
||||
assertEquals(listOf(expectedHistoryGroup), it.historyMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN history metadata WHEN group contains multiple entries with same url THEN entries are deduped`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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 = HistoryMetadataGroup(
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
startHistoryMetadataFeature()
|
||||
|
||||
middleware.assertLastAction(HomeFragmentAction.HistoryMetadataChange::class) {
|
||||
assertEquals(listOf(expectedHistoryGroup), it.historyMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN history metadata WHEN different groups contain entries with same url THEN entries are not deduped`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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 = HistoryMetadataGroup(
|
||||
title = "mozilla",
|
||||
historyMetadata = listOf(historyEntry1, historyEntry2)
|
||||
)
|
||||
|
||||
val expectedHistoryGroup2 = HistoryMetadataGroup(
|
||||
title = "firefox",
|
||||
historyMetadata = listOf(historyEntry3)
|
||||
)
|
||||
|
||||
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
|
||||
listOf(
|
||||
historyEntry1, historyEntry2, historyEntry3
|
||||
)
|
||||
}
|
||||
|
||||
startHistoryMetadataFeature()
|
||||
|
||||
middleware.assertLastAction(HomeFragmentAction.HistoryMetadataChange::class) {
|
||||
assertEquals(listOf(expectedHistoryGroup1, expectedHistoryGroup2), it.historyMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN history metadata WHEN multiple groups exist THEN groups are sorted descending by last updated timestamp`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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 = HistoryMetadataGroup(
|
||||
title = "mozilla",
|
||||
historyMetadata = listOf(historyEntry1, historyEntry2)
|
||||
)
|
||||
|
||||
val expectedHistoryGroup2 = HistoryMetadataGroup(
|
||||
title = "firefox",
|
||||
historyMetadata = listOf(historyEntry3)
|
||||
)
|
||||
|
||||
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
|
||||
listOf(
|
||||
historyEntry1, historyEntry2, historyEntry3
|
||||
)
|
||||
}
|
||||
|
||||
startHistoryMetadataFeature()
|
||||
|
||||
middleware.assertLastAction(HomeFragmentAction.HistoryMetadataChange::class) {
|
||||
assertEquals(listOf(expectedHistoryGroup2, expectedHistoryGroup1), it.historyMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN history metadata WHEN multiple groups exist THEN no more than the configured maximum number of results are added to the store`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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", "firefox", null),
|
||||
title = "firefox",
|
||||
createdAt = now,
|
||||
updatedAt = now + 2,
|
||||
totalViewTime = 20,
|
||||
documentType = DocumentType.Regular,
|
||||
previewImageUrl = null
|
||||
)
|
||||
|
||||
val historyEntry3 = HistoryMetadata(
|
||||
key = HistoryMetadataKey("http://getpocket.com", "pocket", null),
|
||||
title = "pocket",
|
||||
createdAt = now,
|
||||
updatedAt = now + 3,
|
||||
totalViewTime = 30,
|
||||
documentType = DocumentType.Regular,
|
||||
previewImageUrl = null
|
||||
)
|
||||
|
||||
val expectedHistoryGroup1 = HistoryMetadataGroup(
|
||||
title = "firefox",
|
||||
historyMetadata = listOf(historyEntry2)
|
||||
)
|
||||
|
||||
val expectedHistoryGroup2 = HistoryMetadataGroup(
|
||||
title = "pocket",
|
||||
historyMetadata = listOf(historyEntry3)
|
||||
)
|
||||
|
||||
coEvery { historyMetadataStorage.getHistoryMetadataSince(any()) }.coAnswers {
|
||||
listOf(
|
||||
historyEntry1, historyEntry2, historyEntry3
|
||||
)
|
||||
}
|
||||
|
||||
startHistoryMetadataFeature(maxResults = 2)
|
||||
|
||||
// Should not get more than maxResults number of groups back
|
||||
middleware.assertLastAction(HomeFragmentAction.HistoryMetadataChange::class) {
|
||||
assertEquals(listOf(expectedHistoryGroup2, expectedHistoryGroup1), it.historyMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHistoryMetadataFeature(maxResults: Int = 10) {
|
||||
val feature = HistoryMetadataFeature(
|
||||
homeStore,
|
||||
historyMetadataStorage,
|
||||
CoroutineScope(testDispatcher),
|
||||
testDispatcher,
|
||||
maxResults
|
||||
)
|
||||
|
||||
assertEquals(emptyList<HistoryMetadataGroup>(), homeStore.state.historyMetadata)
|
||||
|
||||
feature.start()
|
||||
|
||||
testDispatcher.advanceUntilIdle()
|
||||
homeStore.waitUntilIdle()
|
||||
|
||||
coVerify {
|
||||
historyMetadataStorage.getHistoryMetadataSince(any())
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,792 @@
|
||||
/* 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 io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.spyk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestCoroutineDispatcher
|
||||
import kotlinx.coroutines.test.runBlockingTest
|
||||
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
|
||||
import mozilla.components.concept.storage.DocumentType
|
||||
import mozilla.components.concept.storage.HistoryHighlight
|
||||
import mozilla.components.concept.storage.HistoryHighlightWeights
|
||||
import mozilla.components.concept.storage.HistoryMetadata
|
||||
import mozilla.components.concept.storage.HistoryMetadataKey
|
||||
import mozilla.components.concept.storage.HistoryMetadataStorage
|
||||
import mozilla.components.support.test.libstate.ext.waitUntilIdle
|
||||
import mozilla.components.support.test.middleware.CaptureActionsMiddleware
|
||||
import mozilla.components.support.test.rule.MainCoroutineRule
|
||||
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.home.HomeFragmentAction
|
||||
import org.mozilla.fenix.home.HomeFragmentState
|
||||
import org.mozilla.fenix.home.HomeFragmentStore
|
||||
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
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RecentVisitsFeatureTest {
|
||||
|
||||
private lateinit var historyHightlightsStorage: PlacesHistoryStorage
|
||||
private lateinit var historyMetadataStorage: HistoryMetadataStorage
|
||||
|
||||
private val middleware = CaptureActionsMiddleware<HomeFragmentState, HomeFragmentAction>()
|
||||
private val homeStore = HomeFragmentStore(middlewares = listOf(middleware))
|
||||
private val testDispatcher = TestCoroutineDispatcher()
|
||||
|
||||
@get:Rule
|
||||
val coroutinesTestRule = MainCoroutineRule(testDispatcher)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
historyHightlightsStorage = mockk(relaxed = true)
|
||||
historyMetadataStorage = mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no recent visits WHEN feature starts THEN fetch history metadata and highlights then notify store`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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 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 {
|
||||
listOf(
|
||||
historyEntry
|
||||
)
|
||||
}
|
||||
coEvery { historyHightlightsStorage.getHistoryHighlights(any(), any()) }.coAnswers {
|
||||
listOf(highlightEntry)
|
||||
}
|
||||
|
||||
startRecentVisitsFeature()
|
||||
|
||||
middleware.assertLastAction(HomeFragmentAction.RecentHistoryChange::class) {
|
||||
assertEquals(listOf(recentHistoryGroup, recentHistoryHighlight), it.recentHistory)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `WHEN asking for history highlights THEN use a specific query`() {
|
||||
testDispatcher.runBlockingTest {
|
||||
val highlightWeights = slot<HistoryHighlightWeights>()
|
||||
val highlightsAskedForNumber = slot<Int>()
|
||||
|
||||
startRecentVisitsFeature()
|
||||
|
||||
coVerify {
|
||||
historyHightlightsStorage.getHistoryHighlights(
|
||||
capture(highlightWeights),
|
||||
capture(highlightsAskedForNumber)
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(MIN_VIEW_TIME_OF_HIGHLIGHT, highlightWeights.captured.viewTime, 0.0)
|
||||
assertEquals(MIN_FREQUENCY_OF_HIGHLIGHT, highlightWeights.captured.frequency, 0.0)
|
||||
assertEquals(MAX_RESULTS_TOTAL, highlightsAskedForNumber.captured)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN groups containing history metadata items with the same url WHEN they are added to store THEN entries are deduped`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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(HomeFragmentAction.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`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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(HomeFragmentAction.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`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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(HomeFragmentAction.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`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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(HomeFragmentAction.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`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
val highlights = getHistoryHighlightsItems(10)
|
||||
val expectedRecentHighlights = highlights
|
||||
// Expect to only have 9 highlights
|
||||
.subList(0, 9)
|
||||
.toRecentHistoryHighlights()
|
||||
coEvery { historyHightlightsStorage.getHistoryHighlights(any(), any()) }.coAnswers { highlights }
|
||||
|
||||
startRecentVisitsFeature()
|
||||
|
||||
middleware.assertLastAction(HomeFragmentAction.RecentHistoryChange::class) {
|
||||
assertEquals(
|
||||
expectedRecentHighlights,
|
||||
it.recentHistory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN multiple history highlights and history groups WHEN they are added to store THEN only last accessed are added`() =
|
||||
testDispatcher.runBlockingTest {
|
||||
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()
|
||||
}
|
||||
|
||||
startRecentVisitsFeature()
|
||||
|
||||
middleware.assertLastAction(HomeFragmentAction.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(HomeFragmentAction.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(homeStore, mockk(), mockk(), mockk(), mockk()))
|
||||
val expected = List<RecentHistoryHighlight>(1) { mockk() }
|
||||
every { feature.getCombinedHistory(any(), any()) } returns expected
|
||||
|
||||
feature.updateState(emptyList(), emptyList())
|
||||
homeStore.waitUntilIdle()
|
||||
|
||||
middleware.assertLastAction(HomeFragmentAction.RecentHistoryChange::class) {
|
||||
assertEquals(expected, it.recentHistory)
|
||||
}
|
||||
}
|
||||
|
||||
@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())
|
||||
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())
|
||||
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())
|
||||
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())
|
||||
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 visitsFromSearch = getSearchFromHistoryMetadataItems(10)
|
||||
val directVisits = getDirectVisitsHistoryMetadataItems(10)
|
||||
|
||||
val result = feature.getHistoryHighlights(
|
||||
directVisits.toHistoryHighlights(),
|
||||
visitsFromSearch + directVisits
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
directVisits.toHistoryHighlightsInternal(),
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@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 visitsFromSearch = getSearchFromHistoryMetadataItems(10)
|
||||
val directVisits = getDirectVisitsHistoryMetadataItems(10)
|
||||
val highlightsWithUnknownAccessTime = directVisits.toHistoryHighlightsInternal().take(5).map {
|
||||
it.copy(lastAccessedTime = 0)
|
||||
}
|
||||
val highlightsWithInferredAccessTime = directVisits.toHistoryHighlightsInternal().takeLast(5)
|
||||
|
||||
val result = feature.getHistoryHighlights(
|
||||
directVisits.toHistoryHighlights(),
|
||||
visitsFromSearch + directVisits.takeLast(5)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
highlightsWithUnknownAccessTime + highlightsWithInferredAccessTime,
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@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 visitsFromSearch = getSearchFromHistoryMetadataItems(10)
|
||||
val directVisits = getDirectVisitsHistoryMetadataItems(10)
|
||||
val newerDirectVisits = directVisits.mapIndexed { index, item ->
|
||||
item.copy(updatedAt = item.updatedAt * ((index % 2) + 1))
|
||||
}
|
||||
|
||||
val result = feature.getHistoryHighlights(
|
||||
directVisits.toHistoryHighlights(),
|
||||
visitsFromSearch + directVisits + newerDirectVisits
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
directVisits.mapIndexed { index, item ->
|
||||
item.toHistoryHighlightInternal(item.updatedAt * ((index % 2) + 1))
|
||||
},
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
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())
|
||||
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())
|
||||
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())
|
||||
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())
|
||||
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())
|
||||
val visitsFromSearch = getSearchFromHistoryMetadataItems(10)
|
||||
val directVisits = getDirectVisitsHistoryMetadataItems(10).mapIndexed { index, item ->
|
||||
when (index % 3) {
|
||||
0 -> item
|
||||
1 -> item.copy(title = null)
|
||||
else -> item.copy(title = " ".repeat(Random.nextInt(3)))
|
||||
}
|
||||
}
|
||||
val sortedByDateHighlights = directVisits.reversed()
|
||||
|
||||
val result = feature.getSortedHistory(
|
||||
directVisits.toHistoryHighlightsInternal(),
|
||||
visitsFromSearch.toHistoryGroupsInternal()
|
||||
).filterIsInstance<RecentHistoryHighlight>()
|
||||
|
||||
assertEquals(10, result.size)
|
||||
result.forEachIndexed { index, item ->
|
||||
when (index % 3) {
|
||||
0 -> assertEquals(sortedByDateHighlights[index].title, item.title)
|
||||
1 -> assertEquals(sortedByDateHighlights[index].key.url, item.title)
|
||||
2 -> assertEquals(sortedByDateHighlights[index].key.url, item.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
homeStore,
|
||||
historyMetadataStorage,
|
||||
lazy { historyHightlightsStorage },
|
||||
CoroutineScope(testDispatcher),
|
||||
testDispatcher,
|
||||
)
|
||||
|
||||
assertEquals(emptyList<RecentHistoryGroup>(), homeStore.state.recentHistory)
|
||||
|
||||
feature.start()
|
||||
|
||||
testDispatcher.advanceUntilIdle()
|
||||
homeStore.waitUntilIdle()
|
||||
|
||||
coVerify {
|
||||
historyMetadataStorage.getHistoryMetadataSince(any())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of [HistoryMetadata] representing visits following a search with [count] different elements.
|
||||
* The elements will have different `title`, `url`, `searchTerm` and an increasing `updatedAt` property
|
||||
* based on their index in the returned list.
|
||||
*
|
||||
* This items can be mapped to search groups.
|
||||
*/
|
||||
private fun getSearchFromHistoryMetadataItems(count: Int): List<HistoryMetadata> {
|
||||
return if (count > 0) {
|
||||
val historyEntry1 = HistoryMetadata(
|
||||
key = HistoryMetadataKey("https://searchurl1.test", "searchTerm1", null),
|
||||
title = "test1",
|
||||
createdAt = 0,
|
||||
updatedAt = 1,
|
||||
totalViewTime = 1,
|
||||
documentType = DocumentType.Regular,
|
||||
previewImageUrl = null
|
||||
)
|
||||
mutableListOf(historyEntry1) + (2..count).map {
|
||||
historyEntry1.copy(
|
||||
key = HistoryMetadataKey("https://searchurl$it.test", "searchTerm$it", null),
|
||||
title = "test$it",
|
||||
updatedAt = it.toLong()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of [HistoryMetadata] representing directly accessed webpages with [count] different elements.
|
||||
* The elements will have different `title`, `url` and an increasing `updatedAt` property
|
||||
* based on their index in the returned list.
|
||||
*
|
||||
* This items cannot be mapped to search groups since they don't contain a `searchTerm`.
|
||||
*/
|
||||
private fun getDirectVisitsHistoryMetadataItems(count: Int): List<HistoryMetadata> {
|
||||
return if (count > 0) {
|
||||
val historyEntry1 = HistoryMetadata(
|
||||
key = HistoryMetadataKey("https://url1.test", null),
|
||||
title = "test1",
|
||||
createdAt = 0,
|
||||
updatedAt = 1,
|
||||
totalViewTime = 1,
|
||||
documentType = DocumentType.Regular,
|
||||
previewImageUrl = null
|
||||
)
|
||||
mutableListOf(historyEntry1) + (2..count).map {
|
||||
historyEntry1.copy(
|
||||
key = HistoryMetadataKey("https://url$it.test", null),
|
||||
title = "test$it",
|
||||
updatedAt = it.toLong()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of [HistoryHighlight] with [count] different elements.
|
||||
* Each element will have unique value for all properties based on their index in the returned list.
|
||||
*/
|
||||
private fun getHistoryHighlightsItems(count: Int): List<HistoryHighlight> =
|
||||
(1..count).map {
|
||||
HistoryHighlight(
|
||||
score = it.toDouble(),
|
||||
placeId = it,
|
||||
url = "https://url$it.test",
|
||||
title = "test$it",
|
||||
previewImageUrl = "https://previewImage$it.test"
|
||||
)
|
||||
}
|
||||
|
||||
private fun HistoryMetadata.toHistoryHighlight(): HistoryHighlight = HistoryHighlight(
|
||||
score = 3.0,
|
||||
placeId = 2,
|
||||
title = title,
|
||||
url = key.url,
|
||||
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!!,
|
||||
url = key.url
|
||||
)
|
||||
|
||||
private fun List<HistoryMetadata>.toRecentHistoryHighlights(): List<RecentHistoryHighlight> =
|
||||
map { it.toRecentHistoryHighlight() }
|
||||
|
||||
@JvmName("historyHighlightsToRecentHistoryHighlights") // avoid platform declaration clash with the above method
|
||||
private fun List<HistoryHighlight>.toRecentHistoryHighlights(): List<RecentHistoryHighlight> =
|
||||
map {
|
||||
RecentHistoryHighlight(
|
||||
title = it.title!!,
|
||||
url = it.url
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<HistoryMetadata>.toHistoryHighlights() = map { it.toHistoryHighlight() }
|
||||
|
||||
private fun HistoryMetadata.toHistoryHighlightInternal(lastAccessTime: Long) =
|
||||
HistoryHighlightInternal(
|
||||
historyHighlight = this.toHistoryHighlight(),
|
||||
lastAccessedTime = lastAccessTime
|
||||
)
|
||||
|
||||
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() }
|
Loading…
Reference in New Issue