For #22534 - Show history highlights and groups in "Recently visited" (#22535)

* 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
Mugurell 3 years ago committed by GitHub
parent de1c6b0dae
commit 5c3fedd707
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -2641,12 +2641,12 @@ history:
recent_searches_tapped:
type: event
description: |
User has tapped on a recent searches card in home.
User has tapped on an item in the "Recently visited" section on home.
extra_keys:
page_number:
description: |
The page number in the homescreen carousel that the recent searches
card was on.
The page number in the homescreen carousel that the recently visited
item was on.
bugs:
- https://github.com/mozilla-mobile/fenix/issues/22172
data_reviews:
@ -5980,7 +5980,7 @@ recent_searches:
type: event
description: |
A user has deleted a search term group from the
"Recent searches" section on the homescreen using
"Recently visited" section on the homescreen using
the long-press menu "Remove" option. This removes
the item from the homescreen, but does not delete
the item from history.

@ -805,6 +805,7 @@ class SmokeTest {
}.submitQuery(secondWebPage.url.toString()) {
mDevice.waitForIdle()
}.goToHomescreen {
swipeToBottom()
}.clickSaveTabsToCollectionButton {
longClickTab(firstWebPage.title)
selectTab(secondWebPage.title)

@ -10,7 +10,6 @@ import android.graphics.Bitmap
import android.widget.EditText
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
@ -27,7 +26,6 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.By.text
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.UiObject
import androidx.test.uiautomator.UiScrollable
@ -324,13 +322,13 @@ class HomeScreenRobot {
}
fun expandCollection(title: String, interact: CollectionRobot.() -> Unit): CollectionRobot.Transition {
try {
mDevice.waitNotNull(findObject(text(title)), waitingTime)
collectionTitle(title).click()
} catch (e: NoMatchingViewException) {
scrollToElementByText(title)
collectionTitle(title).click()
}
// Depending on the screen dimensions collections might report as visible on screen
// but actually have the bottom toolbar above so interactions with collections might fail.
// As a quick solution we'll try scrolling to the element below collection on the homescreen
// so that they are displayed above in their entirety.
scrollToElementByText(appContext.getString(R.string.pocket_stories_header_1))
collectionTitle(title).click()
CollectionRobot().interact()
return CollectionRobot.Transition()

@ -55,7 +55,7 @@ private fun recentBookmarksButton() =
onView(allOf(withText(R.string.customize_toggle_recent_bookmarks)))
private fun recentSearchesButton() =
onView(allOf(withText(R.string.customize_toggle_recent_searches)))
onView(allOf(withText(R.string.customize_toggle_recently_visited)))
private fun pocketButton() =
onView(allOf(withText(R.string.customize_toggle_pocket)))

@ -74,9 +74,9 @@ import org.mozilla.fenix.downloads.DownloadService
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.gecko.GeckoProvider
import org.mozilla.fenix.historymetadata.DefaultHistoryMetadataService
import org.mozilla.fenix.historymetadata.HistoryMetadataMiddleware
import org.mozilla.fenix.historymetadata.HistoryMetadataService
import org.mozilla.fenix.home.recentvisits.DefaultHistoryMetadataService
import org.mozilla.fenix.home.recentvisits.HistoryMetadataMiddleware
import org.mozilla.fenix.home.recentvisits.HistoryMetadataService
import org.mozilla.fenix.media.MediaSessionService
import org.mozilla.fenix.perf.StrictModeManager
import org.mozilla.fenix.perf.lazyMonitored

@ -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()
)
}
}

@ -103,8 +103,6 @@ import org.mozilla.fenix.ext.recordExposureEvent
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.historymetadata.HistoryMetadataFeature
import org.mozilla.fenix.historymetadata.controller.DefaultHistoryMetadataController
import org.mozilla.fenix.home.mozonline.showPrivacyPopWindow
import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
@ -113,6 +111,8 @@ import org.mozilla.fenix.home.recentbookmarks.controller.DefaultRecentBookmarksC
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recenttabs.RecentTabsListFeature
import org.mozilla.fenix.home.recenttabs.controller.DefaultRecentTabsController
import org.mozilla.fenix.home.recentvisits.RecentVisitsFeature
import org.mozilla.fenix.home.recentvisits.controller.DefaultRecentVisitsController
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
import org.mozilla.fenix.home.sessioncontrol.SessionControlView
@ -179,7 +179,7 @@ class HomeFragment : Fragment() {
private val topSitesFeature = ViewBoundFeatureWrapper<TopSitesFeature>()
private val recentTabsListFeature = ViewBoundFeatureWrapper<RecentTabsListFeature>()
private val recentBookmarksFeature = ViewBoundFeatureWrapper<RecentBookmarksFeature>()
private val historyMetadataFeature = ViewBoundFeatureWrapper<HistoryMetadataFeature>()
private val historyMetadataFeature = ViewBoundFeatureWrapper<RecentVisitsFeature>()
@VisibleForTesting
internal var getMenuButton: () -> MenuButton? = { binding.menuButton }
@ -255,7 +255,7 @@ class HomeFragment : Fragment() {
// This will otherwise cause a visual jump as the section gets rendered from no state
// to some state.
recentTabs = getRecentTabs(components),
historyMetadata = emptyList()
recentHistory = emptyList()
),
listOf(
PocketUpdatesMiddleware(
@ -316,9 +316,10 @@ class HomeFragment : Fragment() {
if (requireContext().settings().historyMetadataUIFeature) {
historyMetadataFeature.set(
feature = HistoryMetadataFeature(
feature = RecentVisitsFeature(
homeStore = homeFragmentStore,
historyMetadataStorage = components.core.historyStorage,
historyHighlightsStorage = components.core.lazyHistoryStorage,
scope = viewLifecycleOwner.lifecycleScope
),
owner = viewLifecycleOwner,
@ -356,9 +357,10 @@ class HomeFragment : Fragment() {
activity = activity,
navController = findNavController()
),
historyMetadataController = DefaultHistoryMetadataController(
recentVisitsController = DefaultRecentVisitsController(
navController = findNavController(),
homeStore = homeFragmentStore,
selectOrAddTabUseCase = components.useCases.tabsUseCases.selectOrAddTab,
storage = components.core.historyStorage,
scope = viewLifecycleOwner.lifecycleScope,
store = components.core.store,
@ -700,7 +702,7 @@ class HomeFragment : Fragment() {
// to some state.
recentTabs = getRecentTabs(components),
recentBookmarks = emptyList(),
historyMetadata = emptyList()
recentHistory = emptyList()
)
)

@ -17,13 +17,15 @@ import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.ext.recentSearchGroup
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
import org.mozilla.fenix.home.pocket.POCKET_STORIES_TO_SHOW_COUNT
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recenttabs.RecentTab.SearchGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
/**
* The [Store] for holding the [HomeFragmentState] and applying [HomeFragmentAction]s.
@ -57,7 +59,7 @@ data class Tab(
* @property showSetAsDefaultBrowserCard If true, shows the default browser card
* @property recentTabs The list of recent [RecentTab] in the [HomeFragment].
* @property recentBookmarks The list of recently saved [BookmarkNode]s to show on the [HomeFragment].
* @property historyMetadata The list of [HistoryMetadataGroup].
* @property recentHistory The list of [RecentlyVisitedItem]s.
* @property pocketStories The list of currently shown [PocketRecommendedStory]s.
* @property pocketStoriesCategories All [PocketRecommendedStory] categories.
* Also serves as an in memory cache of all stories mapped by category allowing for quick stories filtering.
@ -72,7 +74,7 @@ data class HomeFragmentState(
val showSetAsDefaultBrowserCard: Boolean = false,
val recentTabs: List<RecentTab> = emptyList(),
val recentBookmarks: List<RecentBookmark> = emptyList(),
val historyMetadata: List<HistoryMetadataGroup> = emptyList(),
val recentHistory: List<RecentlyVisitedItem> = emptyList(),
val pocketStories: List<PocketRecommendedStory> = emptyList(),
val pocketStoriesCategories: List<PocketRecommendedStoriesCategory> = emptyList(),
val pocketStoriesCategoriesSelections: List<PocketRecommendedStoriesSelectedCategory> = emptyList()
@ -87,7 +89,7 @@ sealed class HomeFragmentAction : Action {
val showCollectionPlaceholder: Boolean,
val recentTabs: List<RecentTab>,
val recentBookmarks: List<RecentBookmark>,
val historyMetadata: List<HistoryMetadataGroup>
val recentHistory: List<RecentlyVisitedItem>
) :
HomeFragmentAction()
@ -100,7 +102,8 @@ sealed class HomeFragmentAction : Action {
data class RemoveTip(val tip: Tip) : HomeFragmentAction()
data class RecentTabsChange(val recentTabs: List<RecentTab>) : HomeFragmentAction()
data class RecentBookmarksChange(val recentBookmarks: List<RecentBookmark>) : HomeFragmentAction()
data class HistoryMetadataChange(val historyMetadata: List<HistoryMetadataGroup>) : HomeFragmentAction()
data class RecentHistoryChange(val recentHistory: List<RecentlyVisitedItem>) : HomeFragmentAction()
data class RemoveRecentHistoryHighlight(val highlightUrl: String) : HomeFragmentAction()
data class DisbandSearchGroupAction(val searchTerm: String) : HomeFragmentAction()
data class SelectPocketStoriesCategory(val categoryName: String) : HomeFragmentAction()
data class DeselectPocketStoriesCategory(val categoryName: String) : HomeFragmentAction()
@ -129,11 +132,11 @@ private fun homeFragmentStateReducer(
tip = action.tip,
recentBookmarks = action.recentBookmarks,
recentTabs = action.recentTabs,
historyMetadata = if (action.historyMetadata.isNotEmpty() && action.recentTabs.isNotEmpty()) {
recentHistory = if (action.recentHistory.isNotEmpty() && action.recentTabs.isNotEmpty()) {
val recentSearchGroup = action.recentTabs.find { it is SearchGroup } as SearchGroup?
action.historyMetadata.filterOut(recentSearchGroup?.searchTerm)
action.recentHistory.filterOut(recentSearchGroup?.searchTerm)
} else {
action.historyMetadata
action.recentHistory
}
)
is HomeFragmentAction.CollectionExpanded -> {
@ -161,19 +164,25 @@ private fun homeFragmentStateReducer(
val recentSearchGroup = action.recentTabs.find { it is SearchGroup } as SearchGroup?
state.copy(
recentTabs = action.recentTabs,
historyMetadata = state.historyMetadata.filterOut(recentSearchGroup?.searchTerm)
recentHistory = state.recentHistory.filterOut(recentSearchGroup?.searchTerm)
)
}
is HomeFragmentAction.RecentBookmarksChange -> state.copy(recentBookmarks = action.recentBookmarks)
is HomeFragmentAction.HistoryMetadataChange -> state.copy(
historyMetadata = action.historyMetadata.filterOut(state.recentSearchGroup?.searchTerm)
is HomeFragmentAction.RecentHistoryChange -> state.copy(
recentHistory = action.recentHistory.filterOut(state.recentSearchGroup?.searchTerm)
)
is HomeFragmentAction.RemoveRecentHistoryHighlight -> state.copy(
recentHistory = state.recentHistory.filterNot {
it is RecentHistoryHighlight && it.url == action.highlightUrl
}
)
is HomeFragmentAction.DisbandSearchGroupAction -> state.copy(
historyMetadata = state.historyMetadata
.filter {
it.title.lowercase() != action.searchTerm.lowercase() &&
it.title.lowercase() != state.recentSearchGroup?.searchTerm?.lowercase()
}
recentHistory = state.recentHistory.filterNot {
it is RecentHistoryGroup && (
it.title.equals(action.searchTerm, true) ||
it.title.equals(state.recentSearchGroup?.searchTerm, true)
)
}
)
is HomeFragmentAction.SelectPocketStoriesCategory -> {
val updatedCategoriesState = state.copy(
@ -244,14 +253,14 @@ private fun homeFragmentStateReducer(
}
/**
* Removes a [HistoryMetadataGroup] identified by [groupTitle] if it exists in the current list.
* Removes a [RecentHistoryGroup] identified by [groupTitle] if it exists in the current list.
*
* @param groupTitle [HistoryMetadataGroup.title] of the item that should be removed.
* @param groupTitle [RecentHistoryGroup.title] of the item that should be removed.
*/
@VisibleForTesting
internal fun List<HistoryMetadataGroup>.filterOut(groupTitle: String?): List<HistoryMetadataGroup> {
internal fun List<RecentlyVisitedItem>.filterOut(groupTitle: String?): List<RecentlyVisitedItem> {
return when (groupTitle != null) {
true -> filterNot { it.title.equals(groupTitle, true) }
true -> filterNot { it is RecentHistoryGroup && it.title.equals(groupTitle, true) }
false -> this
}
}

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.home.recentvisits
import mozilla.components.browser.state.action.BrowserAction
import mozilla.components.browser.state.action.ContentAction

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.home.recentvisits
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.asCoroutineDispatcher

@ -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)
}

@ -2,9 +2,9 @@
* 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
package org.mozilla.fenix.home.recentvisits.view
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
/**
* A menu item in the recent visit dropdown menu.
@ -14,5 +14,5 @@ import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
*/
data class RecentVisitMenuItem(
val title: String,
val onClick: (HistoryMetadataGroup) -> Unit
val onClick: (RecentlyVisitedItem) -> Unit
)

@ -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()
)
}
}

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.home.recentvisits.view
import android.view.View
import androidx.compose.ui.platform.ComposeView
@ -12,23 +12,26 @@ import mozilla.components.lib.state.ext.observeAsComposableState
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.historymetadata.interactor.HistoryMetadataInteractor
import org.mozilla.fenix.home.HomeFragmentStore
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.home.recentvisits.interactor.RecentVisitsInteractor
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.utils.view.ViewHolder
/**
* View holder for a history metadata group item.
* View holder for [RecentlyVisitedItem]s.
*
* @param composeView [ComposeView] which will be populated with Jetpack Compose UI content.
* @param store [HomeFragmentStore] containing the list of history metadata groups to be displayed.
* @property interactor [HistoryMetadataInteractor] which will have delegated to all user interactions.
* @param store [HomeFragmentStore] containing the list of [RecentlyVisitedItem] to be displayed.
* @property interactor [RecentVisitsInteractor] which will have delegated to all user interactions.
* @property metrics [MetricController] that handles telemetry events.
*/
class HistoryMetadataGroupViewHolder(
class RecentlyVisitedViewHolder(
val composeView: ComposeView,
private val store: HomeFragmentStore,
private val interactor: HistoryMetadataInteractor,
private val interactor: RecentVisitsInteractor,
private val metrics: MetricController
) : ViewHolder(composeView) {
@ -40,7 +43,7 @@ class HistoryMetadataGroupViewHolder(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
composeView.setContent {
val recentVisits = store.observeAsComposableState { state -> state.historyMetadata }
val recentVisits = store.observeAsComposableState { state -> state.recentHistory }
FirefoxTheme {
RecentlyVisited(
@ -48,14 +51,24 @@ class HistoryMetadataGroupViewHolder(
menuItems = listOfNotNull(
RecentVisitMenuItem(
title = stringResource(R.string.recently_visited_menu_item_remove),
onClick = { group ->
interactor.onRemoveGroup(group.title)
onClick = { visit ->
when (visit) {
is RecentHistoryGroup -> interactor.onRemoveRecentHistoryGroup(visit.title)
is RecentHistoryHighlight -> interactor.onRemoveRecentHistoryHighlight(visit.url)
}
}
)
),
onRecentVisitClick = { historyMetadataGroup, pageNumber ->
metrics.track(Event.HistoryRecentSearchesTapped(pageNumber.toString()))
interactor.onHistoryMetadataGroupClicked(historyMetadataGroup)
onRecentVisitClick = { recentlyVisitedItem, pageNumber ->
when (recentlyVisitedItem) {
is RecentHistoryHighlight -> {
interactor.onRecentHistoryHighlightClicked(recentlyVisitedItem)
}
is RecentHistoryGroup -> {
metrics.track(Event.HistoryRecentSearchesTapped(pageNumber.toString()))
interactor.onRecentHistoryGroupClicked(recentlyVisitedItem)
}
}
}
)
}

@ -19,8 +19,6 @@ import mozilla.components.feature.top.sites.TopSite.Type.FRECENT
import mozilla.components.ui.widgets.WidgetSiteItemView
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.historymetadata.view.HistoryMetadataGroupViewHolder
import org.mozilla.fenix.historymetadata.view.HistoryMetadataHeaderViewHolder
import org.mozilla.fenix.home.HomeFragmentStore
import org.mozilla.fenix.home.TopPlaceholderViewHolder
import org.mozilla.fenix.home.pocket.PocketStoriesViewHolder
@ -28,6 +26,8 @@ import org.mozilla.fenix.home.recentbookmarks.view.RecentBookmarksHeaderViewHold
import org.mozilla.fenix.home.recentbookmarks.view.RecentBookmarksViewHolder
import org.mozilla.fenix.home.recenttabs.view.RecentTabViewHolder
import org.mozilla.fenix.home.recenttabs.view.RecentTabsHeaderViewHolder
import org.mozilla.fenix.home.recentvisits.view.RecentVisitsHeaderViewHolder
import org.mozilla.fenix.home.recentvisits.view.RecentlyVisitedViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionHeaderViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.CustomizeHomeButtonViewHolder
@ -159,8 +159,8 @@ sealed class AdapterItem(@LayoutRes val viewType: Int) {
object RecentTabsHeader : AdapterItem(RecentTabsHeaderViewHolder.LAYOUT_ID)
object RecentTabItem : AdapterItem(RecentTabViewHolder.LAYOUT_ID)
object HistoryMetadataHeader : AdapterItem(HistoryMetadataHeaderViewHolder.LAYOUT_ID)
object HistoryMetadataGroup : AdapterItem(HistoryMetadataGroupViewHolder.LAYOUT_ID)
object RecentVisitsHeader : AdapterItem(RecentVisitsHeaderViewHolder.LAYOUT_ID)
object RecentVisitsItems : AdapterItem(RecentlyVisitedViewHolder.LAYOUT_ID)
object RecentBookmarksHeader : AdapterItem(RecentBookmarksHeaderViewHolder.LAYOUT_ID)
object RecentBookmarks : AdapterItem(RecentBookmarksViewHolder.LAYOUT_ID)
@ -226,7 +226,7 @@ class SessionControlAdapter(
store = store,
interactor = interactor
)
HistoryMetadataGroupViewHolder.LAYOUT_ID -> return HistoryMetadataGroupViewHolder(
RecentlyVisitedViewHolder.LAYOUT_ID -> return RecentlyVisitedViewHolder(
composeView = ComposeView(parent.context),
store = store,
interactor = interactor,
@ -274,7 +274,7 @@ class SessionControlAdapter(
ExperimentDefaultBrowserCardViewHolder.LAYOUT_ID -> ExperimentDefaultBrowserCardViewHolder(view, interactor)
RecentTabsHeaderViewHolder.LAYOUT_ID -> RecentTabsHeaderViewHolder(view, interactor)
RecentBookmarksHeaderViewHolder.LAYOUT_ID -> RecentBookmarksHeaderViewHolder(view, interactor)
HistoryMetadataHeaderViewHolder.LAYOUT_ID -> HistoryMetadataHeaderViewHolder(
RecentVisitsHeaderViewHolder.LAYOUT_ID -> RecentVisitsHeaderViewHolder(
view,
interactor
)
@ -285,7 +285,7 @@ class SessionControlAdapter(
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
when (holder) {
is CustomizeHomeButtonViewHolder,
is HistoryMetadataGroupViewHolder,
is RecentlyVisitedViewHolder,
is RecentBookmarksViewHolder,
is RecentTabViewHolder,
is PocketStoriesViewHolder -> {
@ -346,7 +346,7 @@ class SessionControlAdapter(
(item as AdapterItem.OnboardingSectionHeader).labelBuilder
)
is OnboardingManualSignInViewHolder -> holder.bind()
is HistoryMetadataGroupViewHolder,
is RecentlyVisitedViewHolder,
is RecentBookmarksViewHolder,
is RecentTabViewHolder,
is PocketStoriesViewHolder -> {

@ -10,9 +10,6 @@ import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
import org.mozilla.fenix.historymetadata.controller.HistoryMetadataController
import org.mozilla.fenix.historymetadata.interactor.HistoryMetadataInteractor
import org.mozilla.fenix.home.HomeFragmentState
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.pocket.PocketStoriesController
@ -22,6 +19,10 @@ import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksControll
import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor
import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.home.recentvisits.controller.RecentVisitsController
import org.mozilla.fenix.home.recentvisits.interactor.RecentVisitsInteractor
/**
* Interface for tab related actions in the [SessionControlInteractor].
@ -242,7 +243,7 @@ class SessionControlInteractor(
private val controller: SessionControlController,
private val recentTabController: RecentTabController,
private val recentBookmarksController: RecentBookmarksController,
private val historyMetadataController: HistoryMetadataController,
private val recentVisitsController: RecentVisitsController,
private val pocketStoriesController: PocketStoriesController
) : CollectionInteractor,
OnboardingInteractor,
@ -253,7 +254,7 @@ class SessionControlInteractor(
ExperimentCardInteractor,
RecentTabInteractor,
RecentBookmarksInteractor,
HistoryMetadataInteractor,
RecentVisitsInteractor,
CustomizeHomeIteractor,
PocketStoriesInteractor {
@ -381,18 +382,26 @@ class SessionControlInteractor(
recentBookmarksController.handleShowAllBookmarksClicked()
}
override fun onHistoryMetadataShowAllClicked() {
historyMetadataController.handleHistoryShowAllClicked()
override fun onHistoryShowAllClicked() {
recentVisitsController.handleHistoryShowAllClicked()
}
override fun onHistoryMetadataGroupClicked(historyMetadataGroup: HistoryMetadataGroup) {
historyMetadataController.handleHistoryMetadataGroupClicked(
historyMetadataGroup
override fun onRecentHistoryGroupClicked(recentHistoryGroup: RecentHistoryGroup) {
recentVisitsController.handleRecentHistoryGroupClicked(
recentHistoryGroup
)
}
override fun onRemoveGroup(searchTerm: String) {
historyMetadataController.handleRemoveGroup(searchTerm)
override fun onRemoveRecentHistoryGroup(groupTitle: String) {
recentVisitsController.handleRemoveRecentHistoryGroup(groupTitle)
}
override fun onRecentHistoryHighlightClicked(recentHistoryHighlight: RecentHistoryHighlight) {
recentVisitsController.handleRecentHistoryHighlightClicked(recentHistoryHighlight)
}
override fun onRemoveRecentHistoryHighlight(highlightUrl: String) {
recentVisitsController.handleRemoveRecentHistoryHighlight(highlightUrl)
}
override fun openCustomizeHomePage() {

@ -16,13 +16,13 @@ import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
import org.mozilla.fenix.home.HomeFragmentState
import org.mozilla.fenix.home.HomeFragmentStore
import org.mozilla.fenix.home.Mode
import org.mozilla.fenix.home.OnboardingState
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.onboarding.JumpBackInCFRDialog
import org.mozilla.fenix.utils.Settings
@ -39,7 +39,7 @@ internal fun normalModeAdapterItems(
showCollectionsPlaceholder: Boolean,
showSetAsDefaultBrowserCard: Boolean,
recentTabs: List<RecentTab>,
historyMetadata: List<HistoryMetadataGroup>,
recentVisits: List<RecentlyVisitedItem>,
pocketStories: List<PocketRecommendedStory>
): List<AdapterItem> {
val items = mutableListOf<AdapterItem>()
@ -70,10 +70,10 @@ internal fun normalModeAdapterItems(
items.add(AdapterItem.RecentBookmarks)
}
if (historyMetadata.isNotEmpty()) {
if (recentVisits.isNotEmpty()) {
shouldShowCustomizeHome = true
items.add(AdapterItem.HistoryMetadataHeader)
items.add(AdapterItem.HistoryMetadataGroup)
items.add(AdapterItem.RecentVisitsHeader)
items.add(AdapterItem.RecentVisitsItems)
}
if (collections.isEmpty()) {
@ -157,7 +157,7 @@ private fun HomeFragmentState.toAdapterList(): List<AdapterItem> = when (mode) {
showCollectionPlaceholder,
showSetAsDefaultBrowserCard,
recentTabs,
historyMetadata,
recentHistory,
pocketStories
)
is Mode.Private -> privateModeAdapterItems()
@ -167,7 +167,7 @@ private fun HomeFragmentState.toAdapterList(): List<AdapterItem> = when (mode) {
@VisibleForTesting
internal fun HomeFragmentState.shouldShowHomeOnboardingDialog(settings: Settings): Boolean {
val isAnySectionsVisible = recentTabs.isNotEmpty() || recentBookmarks.isNotEmpty() ||
historyMetadata.isNotEmpty() || pocketStories.isNotEmpty()
recentHistory.isNotEmpty() || pocketStories.isNotEmpty()
return isAnySectionsVisible && !settings.hasShownHomeOnboardingDialog
}

@ -17,7 +17,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:maxLines="2"
android:text="@string/history_metadata_header_3"
android:text="@string/history_metadata_header_2"
android:gravity="top"
android:paddingTop="1dp"
app:layout_constraintStart_toStartOf="parent"

@ -154,10 +154,10 @@
<string name="history_metadata_header" moz:removedIn="94" tools:ignore="UnusedResources">Past explorations</string>
<!-- Header text for a section on the home screen that displays grouped highlights from the
user's browsing history, such as topics they have researched or explored on the web -->
<string name="history_metadata_header_2" moz:removedIn="94" tools:ignore="UnusedResources">Recently visited</string>
<string name="history_metadata_header_2">Recently visited</string>
<!-- Header text for a section on the home screen that displays grouped highlights from the
user's browsing history, such as topics they have researched or explored on the web -->
<string name="history_metadata_header_3">Recent searches</string>
<string name="history_metadata_header_3" moz:removedIn="96" tools:ignore="UnusedResources">Recent searches</string>
<!-- Text for the menu button to remove a grouped highlight from the user's browsing history
in the Recently visited section -->
<string name="recently_visited_menu_item_remove">Remove</string>
@ -461,10 +461,10 @@
<string name="customize_toggle_recent_bookmarks">Recent bookmarks</string>
<!-- Title for the customize home screen section with recently visited. Recently visited is
a section where users see a list of tabs that they have visited in the past few days -->
<string name="customize_toggle_recently_visited" moz:removedIn="94" tools:ignore="UnusedResources">Recently visited</string>
<string name="customize_toggle_recently_visited">Recently visited</string>
<!-- Title for the customize home screen settings section for recent searches. Recent searches
is a section where users see a list of groups of tabs that they have visited in the past few days -->
<string name="customize_toggle_recent_searches">Recent searches</string>
<string name="customize_toggle_recent_searches" moz:removedIn="96" tools:ignore="UnusedResources">Recent searches</string>
<!-- Title for the customize home screen section with Pocket. -->
<string name="customize_toggle_pocket">Pocket</string>

@ -21,7 +21,7 @@
<androidx.preference.SwitchPreference
android:key="@string/pref_key_history_metadata_feature"
android:title="@string/customize_toggle_recent_searches"
android:title="@string/customize_toggle_recently_visited"
app:isPreferenceVisible="false" />
<androidx.preference.SwitchPreference

@ -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())
}
}
}

@ -24,12 +24,14 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
import org.mozilla.fenix.home.pocket.POCKET_STORIES_TO_SHOW_COUNT
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesSelectedCategory
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.onboarding.FenixOnboarding
class HomeFragmentStoreTest {
@ -109,12 +111,13 @@ class HomeFragmentStoreTest {
@Test
fun `Test changing the recent tabs in HomeFragmentStore`() = runBlocking {
val historyGroup1 = HistoryMetadataGroup(title = "historyGroup1")
val historyGroup2 = HistoryMetadataGroup(title = "historyGroup2")
val historyGroup3 = HistoryMetadataGroup(title = "historyGroup3")
val group1 = RecentHistoryGroup(title = "title1")
val group2 = RecentHistoryGroup(title = "title2")
val group3 = RecentHistoryGroup(title = "title3")
val highlight = RecentHistoryHighlight(title = group2.title, "")
homeFragmentStore = HomeFragmentStore(
HomeFragmentState(
historyMetadata = listOf(historyGroup1, historyGroup2, historyGroup3)
recentHistory = listOf(group1, group2, group3, highlight)
)
)
assertEquals(0, homeFragmentStore.state.recentTabs.size)
@ -122,46 +125,60 @@ class HomeFragmentStoreTest {
// Add 2 RecentTabs to the HomeFragmentStore
// A new SearchGroup already shown in history should hide the HistoryGroup.
val recentTab1: RecentTab.Tab = mockk()
val recentTab2 = RecentTab.SearchGroup(historyGroup2.title, "tabId", "url", null, 2)
val recentTab2 = RecentTab.SearchGroup(group2.title, "tabId", "url", null, 2)
val recentTabs: List<RecentTab> = listOf(recentTab1, recentTab2)
homeFragmentStore.dispatch(HomeFragmentAction.RecentTabsChange(recentTabs)).join()
assertEquals(recentTabs, homeFragmentStore.state.recentTabs)
assertEquals(listOf(historyGroup1, historyGroup3), homeFragmentStore.state.historyMetadata)
assertEquals(listOf(group1, group3, highlight), homeFragmentStore.state.recentHistory)
}
@Test
fun `Test changing the history metadata in HomeFragmentStore`() = runBlocking {
val recentGroup = RecentTab.SearchGroup("testSearchTerm", "id", "url", null, 3)
homeFragmentStore = HomeFragmentStore(
HomeFragmentState(recentTabs = listOf(recentGroup))
assertEquals(0, homeFragmentStore.state.recentHistory.size)
val historyMetadata: List<RecentHistoryGroup> = listOf(mockk(), mockk())
homeFragmentStore.dispatch(HomeFragmentAction.RecentHistoryChange(historyMetadata)).join()
assertEquals(historyMetadata, homeFragmentStore.state.recentHistory)
}
@Test
fun `Test removing a history highlight from HomeFragmentStore`() = runBlocking {
val g1 = RecentHistoryGroup(title = "group One")
val g2 = RecentHistoryGroup(title = "grup two")
val h1 = RecentHistoryHighlight(title = "highlight One", url = "url1")
val h2 = RecentHistoryHighlight(title = "highlight two", url = "url2")
val recentHistoryState = HomeFragmentState(
recentHistory = listOf(g1, g2, h1, h2)
)
assertEquals(0, homeFragmentStore.state.historyMetadata.size)
homeFragmentStore = HomeFragmentStore(recentHistoryState)
homeFragmentStore.dispatch(HomeFragmentAction.RemoveRecentHistoryHighlight("invalid")).join()
assertEquals(recentHistoryState, homeFragmentStore.state)
val historyGroup1 = HistoryMetadataGroup(recentGroup.searchTerm.lowercase())
val historyGroup2 = HistoryMetadataGroup("differentTitle")
val historyMetadata: List<HistoryMetadataGroup> = listOf(historyGroup1, historyGroup2)
homeFragmentStore.dispatch(HomeFragmentAction.HistoryMetadataChange(historyMetadata)).join()
homeFragmentStore.dispatch(HomeFragmentAction.RemoveRecentHistoryHighlight(h1.title)).join()
assertEquals(recentHistoryState, homeFragmentStore.state)
assertEquals(listOf(historyGroup2), homeFragmentStore.state.historyMetadata)
homeFragmentStore.dispatch(HomeFragmentAction.RemoveRecentHistoryHighlight(h1.url)).join()
assertEquals(
recentHistoryState.copy(recentHistory = listOf(g1, g2, h2)),
homeFragmentStore.state
)
}
@Test
fun `Test disbanding search group in HomeFragmentStore`() = runBlocking {
val recentGroup = RecentTab.SearchGroup("testSearchTerm", "id", "url", null, 3)
val g1 = HistoryMetadataGroup(title = "test One")
val g2 = HistoryMetadataGroup(title = "test two")
val g3 = HistoryMetadataGroup(title = recentGroup.searchTerm.lowercase())
homeFragmentStore = HomeFragmentStore(
HomeFragmentState(
recentTabs = listOf(recentGroup),
historyMetadata = listOf(g1, g2, g3)
)
)
val g1 = RecentHistoryGroup(title = "test One")
val g2 = RecentHistoryGroup(title = "test two")
val h1 = RecentHistoryHighlight(title = "highlight One", url = "url1")
val h2 = RecentHistoryHighlight(title = "highlight two", url = "url2")
val recentHistory: List<RecentlyVisitedItem> = listOf(g1, g2, h1, h2)
homeFragmentStore.dispatch(HomeFragmentAction.RecentHistoryChange(recentHistory)).join()
assertEquals(recentHistory, homeFragmentStore.state.recentHistory)
homeFragmentStore.dispatch(HomeFragmentAction.DisbandSearchGroupAction("Test one")).join()
assertEquals(listOf(g2), homeFragmentStore.state.historyMetadata)
assertEquals(listOf(g2, h1, h2), homeFragmentStore.state.recentHistory)
}
@Test
@ -195,7 +212,7 @@ class HomeFragmentStoreTest {
assertEquals(0, homeFragmentStore.state.topSites.size)
assertEquals(0, homeFragmentStore.state.recentTabs.size)
assertEquals(0, homeFragmentStore.state.recentBookmarks.size)
assertEquals(0, homeFragmentStore.state.historyMetadata.size)
assertEquals(0, homeFragmentStore.state.recentHistory.size)
assertEquals(Mode.Normal, homeFragmentStore.state.mode)
val recentGroup = RecentTab.SearchGroup("testSearchTerm", "id", "url", null, 3)
@ -203,10 +220,11 @@ class HomeFragmentStoreTest {
val topSites: List<TopSite> = listOf(mockk(), mockk())
val recentTabs: List<RecentTab> = listOf(mockk(), recentGroup, mockk())
val recentBookmarks: List<RecentBookmark> = listOf(mockk(), mockk())
val g1 = HistoryMetadataGroup(title = "test One")
val g2 = HistoryMetadataGroup(title = recentGroup.searchTerm.lowercase())
val g3 = HistoryMetadataGroup(title = "test two")
val historyMetadata: List<HistoryMetadataGroup> = listOf(g1, g2, g3)
val group1 = RecentHistoryGroup(title = "test One")
val group2 = RecentHistoryGroup(title = recentGroup.searchTerm.lowercase())
val group3 = RecentHistoryGroup(title = "test two")
val highlight = RecentHistoryHighlight(group2.title, "")
val recentHistory: List<RecentlyVisitedItem> = listOf(group1, group2, group3, highlight)
homeFragmentStore.dispatch(
HomeFragmentAction.Change(
@ -216,7 +234,7 @@ class HomeFragmentStoreTest {
showCollectionPlaceholder = true,
recentTabs = recentTabs,
recentBookmarks = recentBookmarks,
historyMetadata = historyMetadata
recentHistory = recentHistory
)
).join()
@ -224,7 +242,7 @@ class HomeFragmentStoreTest {
assertEquals(topSites, homeFragmentStore.state.topSites)
assertEquals(recentTabs, homeFragmentStore.state.recentTabs)
assertEquals(recentBookmarks, homeFragmentStore.state.recentBookmarks)
assertEquals(listOf(g1, g3), homeFragmentStore.state.historyMetadata)
assertEquals(listOf(group1, group3, highlight), homeFragmentStore.state.recentHistory)
assertEquals(Mode.Private, homeFragmentStore.state.mode)
}
@ -367,15 +385,18 @@ class HomeFragmentStoreTest {
@Test
fun `Test filtering out search groups`() {
val group1 = HistoryMetadataGroup("group1")
val group2 = HistoryMetadataGroup("group2")
val group3 = HistoryMetadataGroup("group3")
val groups = listOf(group1, group2, group3)
assertEquals(groups, groups.filterOut(null))
assertEquals(groups, groups.filterOut(""))
assertEquals(groups, groups.filterOut(" "))
assertEquals(groups - group2, groups.filterOut("Group2"))
assertEquals(groups - group3, groups.filterOut("group3"))
val group1 = RecentHistoryGroup("title1")
val group2 = RecentHistoryGroup("title2")
val group3 = RecentHistoryGroup("title3")
val highLight1 = RecentHistoryHighlight("title1", "")
val highLight2 = RecentHistoryHighlight("title2", "")
val highLight3 = RecentHistoryHighlight("title3", "")
val recentHistory = listOf(group1, highLight1, group2, highLight2, group3, highLight3)
assertEquals(recentHistory, recentHistory.filterOut(null))
assertEquals(recentHistory, recentHistory.filterOut(""))
assertEquals(recentHistory, recentHistory.filterOut(" "))
assertEquals(recentHistory - group2, recentHistory.filterOut("Title2"))
assertEquals(recentHistory - group3, recentHistory.filterOut("title3"))
}
}

@ -13,12 +13,12 @@ import mozilla.components.service.pocket.PocketRecommendedStory
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.historymetadata.controller.HistoryMetadataController
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.pocket.PocketStoriesController
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController
import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
import org.mozilla.fenix.home.recentvisits.controller.RecentVisitsController
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
@ -29,8 +29,8 @@ class SessionControlInteractorTest {
private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true)
private val pocketStoriesController: PocketStoriesController = mockk(relaxed = true)
// Note: the historyMetadata tests are handled in [HistoryMetadataInteractorTest] and [HistoryMetadataControllerTest]
private val historyMetadataController: HistoryMetadataController = mockk(relaxed = true)
// Note: the recent visits tests are handled in [RecentVisitsInteractorTest] and [RecentVisitsControllerTest]
private val recentVisitsController: RecentVisitsController = mockk(relaxed = true)
private lateinit var interactor: SessionControlInteractor
@ -40,7 +40,7 @@ class SessionControlInteractorTest {
controller,
recentTabController,
recentBookmarksController,
historyMetadataController,
recentVisitsController,
pocketStoriesController
)
}

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.home.recentvisits
import io.mockk.Called
import io.mockk.every
@ -11,10 +11,10 @@ import io.mockk.slot
import io.mockk.verify
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.action.MediaSessionAction
import mozilla.components.browser.state.action.SearchAction
import mozilla.components.browser.state.action.TabListAction
import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.engine.EngineMiddleware
import mozilla.components.browser.state.search.SearchEngine
import mozilla.components.browser.state.selector.findTab

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.home.recentvisits
import io.mockk.coVerify
import io.mockk.mockk

@ -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() }

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.home.recentvisits.controller
import androidx.navigation.NavController
import androidx.navigation.NavDirections
@ -11,7 +11,9 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.browser.state.action.HistoryMetadataAction
@ -20,6 +22,7 @@ import mozilla.components.concept.storage.DocumentType
import mozilla.components.concept.storage.HistoryMetadata
import mozilla.components.concept.storage.HistoryMetadataKey
import mozilla.components.concept.storage.HistoryMetadataStorage
import mozilla.components.feature.tabs.TabsUseCases.SelectOrAddUseCase
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.After
import org.junit.Before
@ -28,19 +31,22 @@ import org.junit.Test
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.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
@OptIn(ExperimentalCoroutinesApi::class)
class HistoryMetadataControllerTest {
class RecentVisitsControllerTest {
private val testDispatcher = TestCoroutineDispatcher()
@get:Rule
val coroutinesTestRule = MainCoroutineRule(testDispatcher)
private val selectOrAddTabUseCase: SelectOrAddUseCase = mockk(relaxed = true)
private val navController = mockk<NavController>(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true)
@ -49,7 +55,7 @@ class HistoryMetadataControllerTest {
private lateinit var store: BrowserStore
private val scope = TestCoroutineScope()
private lateinit var controller: DefaultHistoryMetadataController
private lateinit var controller: DefaultRecentVisitsController
@Before
fun setup() {
@ -61,9 +67,10 @@ class HistoryMetadataControllerTest {
store = mockk(relaxed = true)
controller = spyk(
DefaultHistoryMetadataController(
DefaultRecentVisitsController(
homeStore = homeFragmentStore,
store = store,
selectOrAddTabUseCase = selectOrAddTabUseCase,
navController = navController,
scope = scope,
storage = storage,
@ -90,7 +97,7 @@ class HistoryMetadataControllerTest {
}
@Test
fun handleToggleHistoryMetadataGroupClicked() {
fun handleRecentHistoryGroupClicked() {
val historyEntry = HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
title = "mozilla",
@ -100,12 +107,12 @@ class HistoryMetadataControllerTest {
documentType = DocumentType.Regular,
previewImageUrl = null
)
val historyGroup = HistoryMetadataGroup(
val historyGroup = RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(historyEntry)
)
controller.handleHistoryMetadataGroupClicked(historyGroup)
controller.handleRecentHistoryGroupClicked(historyGroup)
verify {
navController.navigate(
@ -115,14 +122,14 @@ class HistoryMetadataControllerTest {
}
@Test
fun handleItemRemoved() {
fun handleRemoveGroup() {
val historyMetadataKey = HistoryMetadataKey(
"http://www.mozilla.com",
"mozilla",
null
)
val historyGroup = HistoryMetadataGroup(
val historyGroup = RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(
HistoryMetadata(
@ -137,7 +144,7 @@ class HistoryMetadataControllerTest {
)
)
controller.handleRemoveGroup(historyGroup.title)
controller.handleRemoveRecentHistoryGroup(historyGroup.title)
testDispatcher.advanceUntilIdle()
verify {
@ -150,4 +157,29 @@ class HistoryMetadataControllerTest {
storage.deleteHistoryMetadata(historyGroup.title)
}
}
@Test
fun handleRecentHistoryHighlightClicked() {
val historyHighlight = RecentHistoryHighlight("title", "url")
controller.handleRecentHistoryHighlightClicked(historyHighlight)
verifyOrder {
selectOrAddTabUseCase.invoke(historyHighlight.url)
navController.navigate(R.id.browserFragment)
}
}
@Test
fun handleRemoveRecentHistoryHighlight() {
val highlightUrl = "highlightUrl"
controller.handleRemoveRecentHistoryHighlight(highlightUrl)
verify {
homeFragmentStore.dispatch(RemoveRecentHistoryHighlight(highlightUrl))
scope.launch {
storage.deleteHistoryMetadataForUrl(highlightUrl)
}
}
}
}

@ -2,72 +2,49 @@
* 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
package org.mozilla.fenix.home.recentvisits.interactor
import androidx.navigation.NavController
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import mozilla.components.concept.storage.DocumentType
import mozilla.components.concept.storage.HistoryMetadata
import mozilla.components.concept.storage.HistoryMetadataKey
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.R
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
import org.mozilla.fenix.historymetadata.controller.HistoryMetadataController
import org.mozilla.fenix.home.pocket.PocketStoriesController
import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController
import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryHighlight
import org.mozilla.fenix.home.recentvisits.controller.RecentVisitsController
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
import org.mozilla.fenix.home.pocket.PocketStoriesController
@OptIn(ExperimentalCoroutinesApi::class)
class HistoryMetadataInteractorTest {
private val testDispatcher = TestCoroutineDispatcher()
@get:Rule
val coroutinesTestRule = MainCoroutineRule(testDispatcher)
private val navController = mockk<NavController>(relaxed = true)
class RecentVisitsInteractorTest {
private val defaultSessionControlController: DefaultSessionControlController =
mockk(relaxed = true)
private val recentTabController: RecentTabController = mockk(relaxed = true)
private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true)
private val pocketStoriesController: PocketStoriesController = mockk(relaxed = true)
private val historyMetadataController: HistoryMetadataController = mockk(relaxed = true)
private val recentVisitsController: RecentVisitsController = mockk(relaxed = true)
private lateinit var interactor: SessionControlInteractor
@Before
fun setup() {
every { navController.currentDestination } returns mockk {
every { id } returns R.id.homeFragment
}
interactor = SessionControlInteractor(
defaultSessionControlController,
recentTabController,
recentBookmarksController,
historyMetadataController,
recentVisitsController,
pocketStoriesController
)
}
@After
fun cleanUp() {
testDispatcher.cleanupTestCoroutines()
}
@Test
fun onHistoryMetadataGroupClicked() {
fun handleRecentHistoryGroupClicked() {
val historyGroup =
HistoryMetadataGroup(
RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(
HistoryMetadata(
@ -82,20 +59,20 @@ class HistoryMetadataInteractorTest {
)
)
interactor.onHistoryMetadataGroupClicked(historyGroup)
interactor.onRecentHistoryGroupClicked(historyGroup)
verify {
historyMetadataController.handleHistoryMetadataGroupClicked(historyGroup)
recentVisitsController.handleRecentHistoryGroupClicked(historyGroup)
}
}
@Test
fun onHistoryMetadataShowAllClicked() {
interactor.onHistoryMetadataShowAllClicked()
verify { historyMetadataController.handleHistoryShowAllClicked() }
fun handleHistoryShowAllClicked() {
interactor.onHistoryShowAllClicked()
verify { recentVisitsController.handleHistoryShowAllClicked() }
}
@Test
fun onRemoveItem() {
fun onRemoveRecentHistoryGroup() {
val historyMetadataKey = HistoryMetadataKey(
"http://www.mozilla.com",
"mozilla",
@ -103,7 +80,7 @@ class HistoryMetadataInteractorTest {
)
val historyGroup =
HistoryMetadataGroup(
RecentHistoryGroup(
title = "mozilla",
historyMetadata = listOf(
HistoryMetadata(
@ -118,10 +95,26 @@ class HistoryMetadataInteractorTest {
)
)
interactor.onRemoveGroup(historyGroup.title)
interactor.onRemoveRecentHistoryGroup(historyGroup.title)
verify {
historyMetadataController.handleRemoveGroup(historyGroup.title)
recentVisitsController.handleRemoveRecentHistoryGroup(historyGroup.title)
}
}
@Test
fun onRecentHistoryHighlightClicked() {
val historyHighlight: RecentHistoryHighlight = mockk()
interactor.onRecentHistoryHighlightClicked(historyHighlight)
verify { recentVisitsController.handleRecentHistoryHighlightClicked(historyHighlight) }
}
@Test
fun onRemoveRecentHistoryHighlight() {
interactor.onRemoveRecentHistoryHighlight("url")
verify { recentVisitsController.handleRemoveRecentHistoryHighlight("url") }
}
}

@ -2,11 +2,11 @@
* 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
package org.mozilla.fenix.home.recentvisits.view
// TODO: Needs testImplementation 'androidx.compose.ui:ui-test-junit4:1.0.0-beta04'
@Suppress("ForbiddenComment")
class HistoryMetadataViewHolderTest {
class RecentBookmarksViewHolderTest {
/*
@get:Rule
val composeTestRule = ComposeTestRule()
@ -14,7 +14,7 @@ class HistoryMetadataViewHolderTest {
@Test
fun `WHEN a group is removed via long press menu THEN interactor is called`() {
val historyGroup = HistoryMetadataGroup(
val historyGroup = RecentVisitsItems(
title = "mozilla",
historyMetadata = listOf(
HistoryMetadata(

@ -2,7 +2,7 @@
* 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
package org.mozilla.fenix.home.recentvisits.view
import android.view.LayoutInflater
import io.mockk.mockk
@ -11,28 +11,28 @@ import mozilla.components.support.test.robolectric.testContext
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.databinding.HistoryMetadataHeaderBinding
import org.mozilla.fenix.databinding.RecentVisitsHeaderBinding
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
@RunWith(FenixRobolectricTestRunner::class)
class HistoryMetadataHeaderViewHolderTest {
class RecentVisitsHeaderViewHolderTest {
private lateinit var binding: HistoryMetadataHeaderBinding
private lateinit var binding: RecentVisitsHeaderBinding
private lateinit var interactor: SessionControlInteractor
@Before
fun setup() {
binding = HistoryMetadataHeaderBinding.inflate(LayoutInflater.from(testContext))
binding = RecentVisitsHeaderBinding.inflate(LayoutInflater.from(testContext))
interactor = mockk(relaxed = true)
}
@Test
fun `WHEN show all button is clicked THEN interactor is called`() {
HistoryMetadataHeaderViewHolder(binding.root, interactor)
RecentVisitsHeaderViewHolder(binding.root, interactor)
binding.showAllButton.performClick()
verify { interactor.onHistoryMetadataShowAllClicked() }
verify { interactor.onHistoryShowAllClicked() }
}
}

@ -18,10 +18,10 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
import org.mozilla.fenix.home.HomeFragmentState
import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup
import org.mozilla.fenix.utils.Settings
@RunWith(FenixRobolectricTestRunner::class)
@ -53,12 +53,12 @@ class SessionControlViewTest {
@Test
fun `GIVEN historyMetadata WHEN calling shouldShowHomeOnboardingDialog THEN show the dialog `() {
val historyMetadata = listOf(HistoryMetadataGroup("title", emptyList()))
val historyMetadata = listOf(RecentHistoryGroup("title", emptyList()))
val settings: Settings = mockk()
every { settings.hasShownHomeOnboardingDialog } returns false
val state = HomeFragmentState(historyMetadata = historyMetadata)
val state = HomeFragmentState(recentHistory = historyMetadata)
assertTrue(state.shouldShowHomeOnboardingDialog(settings))
}
@ -135,7 +135,7 @@ class SessionControlViewTest {
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf(RecentBookmark())
val recentTabs = emptyList<RecentTab.Tab>()
val historyMetadata = emptyList<HistoryMetadataGroup>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketArticles = emptyList<PocketRecommendedStory>()
val results = normalModeAdapterItems(
@ -164,7 +164,7 @@ class SessionControlViewTest {
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>()
val recentTabs = listOf<RecentTab.Tab>(mockk())
val historyMetadata = emptyList<HistoryMetadataGroup>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketArticles = emptyList<PocketRecommendedStory>()
val results = normalModeAdapterItems(
@ -193,7 +193,7 @@ class SessionControlViewTest {
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>()
val recentTabs = emptyList<RecentTab.Tab>()
val historyMetadata = listOf(HistoryMetadataGroup("title", emptyList()))
val historyMetadata = listOf(RecentHistoryGroup("title", emptyList()))
val pocketArticles = emptyList<PocketRecommendedStory>()
val results = normalModeAdapterItems(
@ -210,8 +210,8 @@ class SessionControlViewTest {
)
assertTrue(results[0] is AdapterItem.TopPlaceholderItem)
assertTrue(results[1] is AdapterItem.HistoryMetadataHeader)
assertTrue(results[2] is AdapterItem.HistoryMetadataGroup)
assertTrue(results[1] is AdapterItem.RecentVisitsHeader)
assertTrue(results[2] is AdapterItem.RecentVisitsItems)
assertTrue(results[3] is AdapterItem.CustomizeHomeButton)
}
@ -222,7 +222,7 @@ class SessionControlViewTest {
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>()
val recentTabs = emptyList<RecentTab.Tab>()
val historyMetadata = emptyList<HistoryMetadataGroup>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketArticles = listOf(PocketRecommendedStory("", "", "", "", "", 1, 1))
val results = normalModeAdapterItems(
@ -250,7 +250,7 @@ class SessionControlViewTest {
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>()
val recentTabs = emptyList<RecentTab.Tab>()
val historyMetadata = emptyList<HistoryMetadataGroup>()
val historyMetadata = emptyList<RecentHistoryGroup>()
val pocketArticles = emptyList<PocketRecommendedStory>()
val results = normalModeAdapterItems(
@ -279,7 +279,7 @@ class SessionControlViewTest {
val expandedCollections = emptySet<Long>()
val recentBookmarks = listOf<RecentBookmark>(mockk())
val recentTabs = listOf<RecentTab.Tab>(mockk())
val historyMetadata = listOf<HistoryMetadataGroup>(mockk())
val historyMetadata = listOf<RecentHistoryGroup>(mockk())
val pocketArticles = listOf<PocketRecommendedStory>(mockk())
val results = normalModeAdapterItems(

Loading…
Cancel
Save