diff --git a/app/metrics.yaml b/app/metrics.yaml index 21386fce5..ea4cb11b3 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -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. diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt index 02e277866..d8862c412 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/SmokeTest.kt @@ -805,6 +805,7 @@ class SmokeTest { }.submitQuery(secondWebPage.url.toString()) { mDevice.waitForIdle() }.goToHomescreen { + swipeToBottom() }.clickSaveTabsToCollectionButton { longClickTab(firstWebPage.title) selectTab(secondWebPage.title) diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HomeScreenRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HomeScreenRobot.kt index 8e9f44260..776921398 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HomeScreenRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/HomeScreenRobot.kt @@ -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() diff --git a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuHomepageRobot.kt b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuHomepageRobot.kt index c484d4afb..38c62f286 100644 --- a/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuHomepageRobot.kt +++ b/app/src/androidTest/java/org/mozilla/fenix/ui/robots/SettingsSubMenuHomepageRobot.kt @@ -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))) diff --git a/app/src/main/java/org/mozilla/fenix/components/Core.kt b/app/src/main/java/org/mozilla/fenix/components/Core.kt index 5e2a7e1c6..e9963fdb1 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Core.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Core.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/compose/Favicon.kt b/app/src/main/java/org/mozilla/fenix/compose/Favicon.kt new file mode 100644 index 000000000..ff65ea514 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/Favicon.kt @@ -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 +} diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataFeature.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataFeature.kt deleted file mode 100644 index d5655c92f..000000000 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataFeature.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataGroup.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataGroup.kt deleted file mode 100644 index 05ac7cdd0..000000000 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataGroup.kt +++ /dev/null @@ -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 = 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 } diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataController.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataController.kt deleted file mode 100644 index 489be6c6a..000000000 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataController.kt +++ /dev/null @@ -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() - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractor.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractor.kt deleted file mode 100644 index adb8b006a..000000000 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractor.kt +++ /dev/null @@ -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) -} diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolder.kt deleted file mode 100644 index d8186aa61..000000000 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolder.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentlyVisited.kt b/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentlyVisited.kt deleted file mode 100644 index 06da9eea5..000000000 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentlyVisited.kt +++ /dev/null @@ -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, - menuItems: List, - 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, - 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() - ) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index 564edf2bc..47f84d6cb 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -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() private val recentTabsListFeature = ViewBoundFeatureWrapper() private val recentBookmarksFeature = ViewBoundFeatureWrapper() - private val historyMetadataFeature = ViewBoundFeatureWrapper() + private val historyMetadataFeature = ViewBoundFeatureWrapper() @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() ) ) diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt index a76dbcf12..4098bea6b 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt @@ -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 = emptyList(), val recentBookmarks: List = emptyList(), - val historyMetadata: List = emptyList(), + val recentHistory: List = emptyList(), val pocketStories: List = emptyList(), val pocketStoriesCategories: List = emptyList(), val pocketStoriesCategoriesSelections: List = emptyList() @@ -87,7 +89,7 @@ sealed class HomeFragmentAction : Action { val showCollectionPlaceholder: Boolean, val recentTabs: List, val recentBookmarks: List, - val historyMetadata: List + val recentHistory: List ) : HomeFragmentAction() @@ -100,7 +102,8 @@ sealed class HomeFragmentAction : Action { data class RemoveTip(val tip: Tip) : HomeFragmentAction() data class RecentTabsChange(val recentTabs: List) : HomeFragmentAction() data class RecentBookmarksChange(val recentBookmarks: List) : HomeFragmentAction() - data class HistoryMetadataChange(val historyMetadata: List) : HomeFragmentAction() + data class RecentHistoryChange(val recentHistory: List) : 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.filterOut(groupTitle: String?): List { +internal fun List.filterOut(groupTitle: String?): List { return when (groupTitle != null) { - true -> filterNot { it.title.equals(groupTitle, true) } + true -> filterNot { it is RecentHistoryGroup && it.title.equals(groupTitle, true) } false -> this } } diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddleware.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddleware.kt similarity index 99% rename from app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddleware.kt rename to app/src/main/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddleware.kt index 39d0ccdfa..18425a14f 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddleware.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddleware.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataService.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataService.kt similarity index 99% rename from app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataService.kt rename to app/src/main/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataService.kt index 5b708a2b0..ca375d11c 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/HistoryMetadataService.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataService.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeature.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeature.kt new file mode 100644 index 000000000..d87837b2d --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeature.kt @@ -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, + 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, + historyGroups: List + ) { + 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, + historyGroups: List + ): List { + // 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, + metadata: List + ): List { + 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 + ): List { + 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, + historyGroups: List + ): List { + 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.removeHighlightsAlreadyInGroups( + historyMetadata: List +): List { + 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, + override val lastAccessedTime: Long = groupItems.maxOf { it.updatedAt } + ) : RecentlyVisitedItemInternal() +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recentvisits/RecentlyVisitedItem.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/RecentlyVisitedItem.kt new file mode 100644 index 000000000..a3e39577f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/RecentlyVisitedItem.kt @@ -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 = 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 } diff --git a/app/src/main/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsController.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsController.kt new file mode 100644 index 000000000..6b195d6c1 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsController.kt @@ -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() + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractor.kt new file mode 100644 index 000000000..f6f1b10df --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractor.kt @@ -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) +} diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentVisitMenuItem.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentVisitMenuItem.kt similarity index 73% rename from app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentVisitMenuItem.kt rename to app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentVisitMenuItem.kt index d0edcf2df..d56f454ab 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/view/RecentVisitMenuItem.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentVisitMenuItem.kt @@ -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 ) diff --git a/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentVisitsHeaderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentVisitsHeaderViewHolder.kt new file mode 100644 index 000000000..67b536d68 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentVisitsHeaderViewHolder.kt @@ -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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentlyVisited.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentlyVisited.kt new file mode 100644 index 000000000..38c8eb5aa --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentlyVisited.kt @@ -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, + menuItems: List, + 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, + 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, + 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, + 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() + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataGroupViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentlyVisitedViewHolder.kt similarity index 54% rename from app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataGroupViewHolder.kt rename to app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentlyVisitedViewHolder.kt index 398184241..c5e82a237 100644 --- a/app/src/main/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataGroupViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentvisits/view/RecentlyVisitedViewHolder.kt @@ -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) + } + } } ) } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt index bb6647e31..69b7df2f4 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt @@ -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 -> { diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt index 43e344573..bf0ad8caa 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt @@ -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() { diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt index f90ea400e..a47109285 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlView.kt @@ -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, - historyMetadata: List, + recentVisits: List, pocketStories: List ): List { val items = mutableListOf() @@ -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 = when (mode) { showCollectionPlaceholder, showSetAsDefaultBrowserCard, recentTabs, - historyMetadata, + recentHistory, pocketStories ) is Mode.Private -> privateModeAdapterItems() @@ -167,7 +167,7 @@ private fun HomeFragmentState.toAdapterList(): List = 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 } diff --git a/app/src/main/res/layout/history_metadata_header.xml b/app/src/main/res/layout/recent_visits_header.xml similarity index 97% rename from app/src/main/res/layout/history_metadata_header.xml rename to app/src/main/res/layout/recent_visits_header.xml index 138bfd42c..340f7010b 100644 --- a/app/src/main/res/layout/history_metadata_header.xml +++ b/app/src/main/res/layout/recent_visits_header.xml @@ -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" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc6aaa29d..937dc5a23 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -154,10 +154,10 @@ Past explorations - Recently visited + Recently visited - Recent searches + Recent searches Remove @@ -461,10 +461,10 @@ Recent bookmarks - Recently visited + Recently visited - Recent searches + Recent searches Pocket diff --git a/app/src/main/res/xml/home_preferences.xml b/app/src/main/res/xml/home_preferences.xml index f26168ac6..97ce95c85 100644 --- a/app/src/main/res/xml/home_preferences.xml +++ b/app/src/main/res/xml/home_preferences.xml @@ -21,7 +21,7 @@ () - 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(), homeStore.state.historyMetadata) - - feature.start() - - testDispatcher.advanceUntilIdle() - homeStore.waitUntilIdle() - - coVerify { - historyMetadataStorage.getHistoryMetadataSince(any()) - } - } -} diff --git a/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt b/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt index 089c5c35b..3bded96de 100644 --- a/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt @@ -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 = 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 = 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 = 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 = 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 = listOf(mockk(), mockk()) val recentTabs: List = listOf(mockk(), recentGroup, mockk()) val recentBookmarks: List = 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 = 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 = 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")) } } diff --git a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt index 1a8887473..6dca109f7 100644 --- a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt @@ -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 ) } diff --git a/app/src/test/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddlewareTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddlewareTest.kt similarity index 99% rename from app/src/test/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddlewareTest.kt rename to app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddlewareTest.kt index 51e20f2bd..6c0e9e511 100644 --- a/app/src/test/java/org/mozilla/fenix/historymetadata/HistoryMetadataMiddlewareTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataMiddlewareTest.kt @@ -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 diff --git a/app/src/test/java/org/mozilla/fenix/historymetadata/HistoryMetadataServiceTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataServiceTest.kt similarity index 99% rename from app/src/test/java/org/mozilla/fenix/historymetadata/HistoryMetadataServiceTest.kt rename to app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataServiceTest.kt index b587a9950..9664115e2 100644 --- a/app/src/test/java/org/mozilla/fenix/historymetadata/HistoryMetadataServiceTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/recentvisits/HistoryMetadataServiceTest.kt @@ -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 diff --git a/app/src/test/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeatureTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeatureTest.kt new file mode 100644 index 000000000..828eb200e --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/home/recentvisits/RecentVisitsFeatureTest.kt @@ -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() + 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() + val highlightsAskedForNumber = slot() + + 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(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() + + 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(), 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 { + 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 { + 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 = + (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.toIndividualRecentHistoryGroups(): List = + 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.toRecentHistoryHighlights(): List = + map { it.toRecentHistoryHighlight() } + +@JvmName("historyHighlightsToRecentHistoryHighlights") // avoid platform declaration clash with the above method +private fun List.toRecentHistoryHighlights(): List = + map { + RecentHistoryHighlight( + title = it.title!!, + url = it.url + ) + } + +private fun List.toHistoryHighlights() = map { it.toHistoryHighlight() } + +private fun HistoryMetadata.toHistoryHighlightInternal(lastAccessTime: Long) = + HistoryHighlightInternal( + historyHighlight = this.toHistoryHighlight(), + lastAccessedTime = lastAccessTime + ) + +private fun List.toHistoryHighlightsInternal() = mapIndexed { index, item -> + item.toHistoryHighlightInternal(index + 1L) +} + +private fun HistoryMetadata.toHistoryGroupInternal() = HistoryGroupInternal( + groupName = key.searchTerm!!, + groupItems = listOf(this) +) + +private fun List.toHistoryGroupsInternal() = map { it.toHistoryGroupInternal() } diff --git a/app/src/test/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataControllerTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsControllerTest.kt similarity index 72% rename from app/src/test/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataControllerTest.kt rename to app/src/test/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsControllerTest.kt index 226b3be18..61e7b94d6 100644 --- a/app/src/test/java/org/mozilla/fenix/historymetadata/controller/HistoryMetadataControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/recentvisits/controller/RecentVisitsControllerTest.kt @@ -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(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) + } + } + } } diff --git a/app/src/test/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt similarity index 63% rename from app/src/test/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractorTest.kt rename to app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt index 07089a839..ba6dbf664 100644 --- a/app/src/test/java/org/mozilla/fenix/historymetadata/interactor/HistoryMetadataInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/recentvisits/interactor/RecentVisitsInteractorTest.kt @@ -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(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") } + } } diff --git a/app/src/test/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataViewHolderTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentBookmarksViewHolderTest.kt similarity index 89% rename from app/src/test/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataViewHolderTest.kt rename to app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentBookmarksViewHolderTest.kt index f5531ee1a..13d299e30 100644 --- a/app/src/test/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataViewHolderTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentBookmarksViewHolderTest.kt @@ -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( diff --git a/app/src/test/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolderTest.kt b/app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentVisitsHeaderViewHolderTest.kt similarity index 66% rename from app/src/test/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolderTest.kt rename to app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentVisitsHeaderViewHolderTest.kt index f9b52f2aa..669f5658c 100644 --- a/app/src/test/java/org/mozilla/fenix/historymetadata/view/HistoryMetadataHeaderViewHolderTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/recentvisits/view/RecentVisitsHeaderViewHolderTest.kt @@ -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() } } } diff --git a/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt b/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt index 7dfc896f3..4d9a8fb0f 100644 --- a/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/SessionControlViewTest.kt @@ -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() val recentBookmarks = listOf(RecentBookmark()) val recentTabs = emptyList() - val historyMetadata = emptyList() + val historyMetadata = emptyList() val pocketArticles = emptyList() val results = normalModeAdapterItems( @@ -164,7 +164,7 @@ class SessionControlViewTest { val expandedCollections = emptySet() val recentBookmarks = listOf() val recentTabs = listOf(mockk()) - val historyMetadata = emptyList() + val historyMetadata = emptyList() val pocketArticles = emptyList() val results = normalModeAdapterItems( @@ -193,7 +193,7 @@ class SessionControlViewTest { val expandedCollections = emptySet() val recentBookmarks = listOf() val recentTabs = emptyList() - val historyMetadata = listOf(HistoryMetadataGroup("title", emptyList())) + val historyMetadata = listOf(RecentHistoryGroup("title", emptyList())) val pocketArticles = emptyList() 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() val recentBookmarks = listOf() val recentTabs = emptyList() - val historyMetadata = emptyList() + val historyMetadata = emptyList() val pocketArticles = listOf(PocketRecommendedStory("", "", "", "", "", 1, 1)) val results = normalModeAdapterItems( @@ -250,7 +250,7 @@ class SessionControlViewTest { val expandedCollections = emptySet() val recentBookmarks = listOf() val recentTabs = emptyList() - val historyMetadata = emptyList() + val historyMetadata = emptyList() val pocketArticles = emptyList() val results = normalModeAdapterItems( @@ -279,7 +279,7 @@ class SessionControlViewTest { val expandedCollections = emptySet() val recentBookmarks = listOf(mockk()) val recentTabs = listOf(mockk()) - val historyMetadata = listOf(mockk()) + val historyMetadata = listOf(mockk()) val pocketArticles = listOf(mockk()) val results = normalModeAdapterItems(