For #20893 - Search term groups in history

upstream-sync
Gabriel Luong 3 years ago committed by mergify[bot]
parent 391ff6b5fd
commit 2ae7d5d593

@ -95,87 +95,6 @@ class HistoryTest {
}
}
@Test
fun copyHistoryItemURLTest() {
val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1)
navigationToolbar {
}.enterURLAndEnterToBrowser(firstWebPage.url) {
mDevice.waitForIdle()
}.openThreeDotMenu {
}.openHistory {
verifyHistoryListExists()
historyListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1)
IdlingRegistry.getInstance().register(historyListIdlingResource!!)
}.openThreeDotMenu {
}.clickCopy {
verifyCopySnackBarText()
}
}
@Test
fun shareHistoryItemTest() {
val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1)
navigationToolbar {
}.enterURLAndEnterToBrowser(firstWebPage.url) {
mDevice.waitForIdle()
}.openThreeDotMenu {
}.openHistory {
verifyHistoryListExists()
historyListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1)
IdlingRegistry.getInstance().register(historyListIdlingResource!!)
}.openThreeDotMenu {
}.clickShare {
verifyShareOverlay()
verifyShareTabFavicon()
verifyShareTabTitle()
verifyShareTabUrl()
}
}
@Test
fun openHistoryItemInNewTabTest() {
val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1)
navigationToolbar {
}.enterURLAndEnterToBrowser(firstWebPage.url) {
mDevice.waitForIdle()
}.openThreeDotMenu {
}.openHistory {
verifyHistoryListExists()
historyListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1)
IdlingRegistry.getInstance().register(historyListIdlingResource!!)
}.openThreeDotMenu {
}.clickOpenInNormalTab {
verifyTabTrayIsOpened()
verifyNormalModeSelected()
}
}
@Test
fun openHistoryItemInNewPrivateTabTest() {
val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1)
navigationToolbar {
}.enterURLAndEnterToBrowser(firstWebPage.url) {
mDevice.waitForIdle()
}.openThreeDotMenu {
}.openHistory {
verifyHistoryListExists()
historyListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1)
IdlingRegistry.getInstance().register(historyListIdlingResource!!)
}.openThreeDotMenu {
}.clickOpenInPrivateTab {
verifyTabTrayIsOpened()
verifyPrivateModeSelected()
}
}
@Test
fun deleteHistoryItemTest() {
val firstWebPage = TestAssetHelper.getGenericAsset(mockWebServer, 1)
@ -189,9 +108,8 @@ class HistoryTest {
historyListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1)
IdlingRegistry.getInstance().register(historyListIdlingResource!!)
}.openThreeDotMenu {
clickDeleteHistoryButton()
IdlingRegistry.getInstance().unregister(historyListIdlingResource!!)
}.clickDelete {
verifyDeleteSnackbarText("Deleted")
verifyEmptyHistoryView()
}
@ -210,7 +128,7 @@ class HistoryTest {
historyListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.history_list), 1)
IdlingRegistry.getInstance().register(historyListIdlingResource!!)
clickDeleteHistoryButton()
clickDeleteAllHistoryButton()
IdlingRegistry.getInstance().unregister(historyListIdlingResource!!)
verifyDeleteConfirmationMessage()
confirmDeleteAllHistory()

@ -708,89 +708,6 @@ class SmokeTest {
}
}
@Test
// Verifies the items from the overflow menu of Recently Closed Tabs
fun recentlyClosedTabsMenuItemsTest() {
val website = TestAssetHelper.getGenericAsset(mockWebServer, 1)
homeScreen {
}.openNavigationToolbar {
}.enterURLAndEnterToBrowser(website.url) {
mDevice.waitForIdle()
}.openTabDrawer {
closeTab()
}.openTabDrawer {
}.openRecentlyClosedTabs {
waitForListToExist()
recentlyClosedTabsListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.recently_closed_list), 1)
IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!)
verifyRecentlyClosedTabsMenuView()
IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!)
openRecentlyClosedTabsThreeDotMenu()
verifyRecentlyClosedTabsMenuCopy()
verifyRecentlyClosedTabsMenuShare()
verifyRecentlyClosedTabsMenuNewTab()
verifyRecentlyClosedTabsMenuPrivateTab()
verifyRecentlyClosedTabsMenuDelete()
}
}
@Test
// Verifies the Copy option from the Recently Closed Tabs overflow menu
fun copyRecentlyClosedTabsItemTest() {
val website = TestAssetHelper.getGenericAsset(mockWebServer, 1)
homeScreen {
}.openNavigationToolbar {
}.enterURLAndEnterToBrowser(website.url) {
mDevice.waitForIdle()
}.openTabDrawer {
closeTab()
}.openTabDrawer {
}.openRecentlyClosedTabs {
waitForListToExist()
recentlyClosedTabsListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.recently_closed_list), 1)
IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!)
verifyRecentlyClosedTabsMenuView()
IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!)
openRecentlyClosedTabsThreeDotMenu()
verifyRecentlyClosedTabsMenuCopy()
clickCopyRecentlyClosedTabs()
verifyCopyRecentlyClosedTabsSnackBarText()
}
}
@Test
// Verifies the Share option from the Recently Closed Tabs overflow menu
fun shareRecentlyClosedTabsItemTest() {
val website = TestAssetHelper.getGenericAsset(mockWebServer, 1)
homeScreen {
}.openNavigationToolbar {
}.enterURLAndEnterToBrowser(website.url) {
mDevice.waitForIdle()
}.openTabDrawer {
closeTab()
}.openTabDrawer {
}.openRecentlyClosedTabs {
waitForListToExist()
recentlyClosedTabsListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.recently_closed_list), 1)
IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!)
verifyRecentlyClosedTabsMenuView()
IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!)
openRecentlyClosedTabsThreeDotMenu()
verifyRecentlyClosedTabsMenuShare()
clickShareRecentlyClosedTabs()
verifyShareOverlay()
verifyShareTabTitle("Test_Page_1")
verifyShareTabUrl(website.url)
verifyShareTabFavicon()
}
}
@Test
// Verifies the Open in a new tab option from the Recently Closed Tabs overflow menu
fun openRecentlyClosedTabsInNewTabTest() {
@ -810,8 +727,6 @@ class SmokeTest {
IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!)
verifyRecentlyClosedTabsMenuView()
IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!)
openRecentlyClosedTabsThreeDotMenu()
verifyRecentlyClosedTabsMenuNewTab()
}.clickOpenInNewTab {
verifyUrl(website.url.toString())
}.openTabDrawer {
@ -820,35 +735,7 @@ class SmokeTest {
}
@Test
// Verifies the Open in a private tab option from the Recently Closed Tabs overflow menu
fun openRecentlyClosedTabsInNewPrivateTabTest() {
val website = TestAssetHelper.getGenericAsset(mockWebServer, 1)
homeScreen {
}.openNavigationToolbar {
}.enterURLAndEnterToBrowser(website.url) {
mDevice.waitForIdle()
}.openTabDrawer {
closeTab()
}.openTabDrawer {
}.openRecentlyClosedTabs {
waitForListToExist()
recentlyClosedTabsListIdlingResource =
RecyclerViewIdlingResource(activityTestRule.activity.findViewById(R.id.recently_closed_list), 1)
IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!)
verifyRecentlyClosedTabsMenuView()
IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!)
openRecentlyClosedTabsThreeDotMenu()
verifyRecentlyClosedTabsMenuPrivateTab()
}.clickOpenInPrivateTab {
verifyUrl(website.url.toString())
}.openTabDrawer {
verifyPrivateModeSelected()
}
}
@Test
// Verifies the delete option from the Recently Closed Tabs overflow menu
// Verifies the delete button from the Recently Closed Tabs
fun deleteRecentlyClosedTabsItemTest() {
val website = TestAssetHelper.getGenericAsset(mockWebServer, 1)
@ -866,9 +753,7 @@ class SmokeTest {
IdlingRegistry.getInstance().register(recentlyClosedTabsListIdlingResource!!)
verifyRecentlyClosedTabsMenuView()
IdlingRegistry.getInstance().unregister(recentlyClosedTabsListIdlingResource!!)
openRecentlyClosedTabsThreeDotMenu()
verifyRecentlyClosedTabsMenuDelete()
clickDeleteCopyRecentlyClosedTabs()
clickDeleteRecentlyClosedTabs()
verifyEmptyRecentlyClosedTabsList()
}
}

@ -61,20 +61,12 @@ class HistoryRobot {
fun verifyHomeScreen() = HomeScreenRobot().verifyHomeScreen()
fun openOverflowMenu() {
mDevice.waitNotNull(
Until.findObject(
By.res("org.mozilla.fenix.debug:id/overflow_menu")
),
waitingTime
)
threeDotMenu().click()
}
fun clickDeleteHistoryButton() {
deleteAllHistoryButton().click()
deleteButton().click()
}
fun clickDeleteAllHistoryButton() = deleteAllButton().click()
fun confirmDeleteAllHistory() {
onView(withText("Delete"))
.inRoot(isDialog())
@ -91,15 +83,6 @@ class HistoryRobot {
BrowserRobot().interact()
return BrowserRobot.Transition()
}
fun openThreeDotMenu(interact: ThreeDotMenuHistoryItemRobot.() -> Unit):
ThreeDotMenuHistoryItemRobot.Transition {
threeDotMenu().click()
ThreeDotMenuHistoryItemRobot().interact()
return ThreeDotMenuHistoryItemRobot.Transition()
}
}
}
@ -112,11 +95,11 @@ private fun testPageTitle() = onView(allOf(withId(R.id.title), withText("Test_Pa
private fun pageUrl() = onView(withId(R.id.url))
private fun threeDotMenu() = onView(withId(R.id.overflow_menu))
private fun deleteButton() = onView(withId(R.id.overflow_menu))
private fun snackBarText() = onView(withId(R.id.snackbar_text))
private fun deleteAllButton() = onView(withId(R.id.history_delete_all))
private fun deleteAllHistoryButton() = onView(withId(R.id.history_delete_all))
private fun snackBarText() = onView(withId(R.id.snackbar_text))
private fun assertHistoryMenuView() {
onView(

@ -41,44 +41,11 @@ class RecentlyClosedTabsRobot {
fun verifyRecentlyClosedTabsUrl(expectedUrl: Uri) = assertPageUrl(expectedUrl)
fun openRecentlyClosedTabsThreeDotMenu() = recentlyClosedTabsThreeDotButton().click()
fun verifyRecentlyClosedTabsMenuCopy() = assertRecentlyClosedTabsMenuCopy()
fun verifyRecentlyClosedTabsMenuShare() = assertRecentlyClosedTabsMenuShare()
fun verifyRecentlyClosedTabsMenuNewTab() = assertRecentlyClosedTabsOverlayNewTab()
fun verifyRecentlyClosedTabsMenuPrivateTab() = assertRecentlyClosedTabsMenuPrivateTab()
fun verifyRecentlyClosedTabsMenuDelete() = assertRecentlyClosedTabsMenuDelete()
fun clickCopyRecentlyClosedTabs() = recentlyClosedTabsCopyButton().click()
fun clickShareRecentlyClosedTabs() = recentlyClosedTabsShareButton().click()
fun clickDeleteCopyRecentlyClosedTabs() = recentlyClosedTabsDeleteButton().click()
fun verifyCopyRecentlyClosedTabsSnackBarText() = assertCopySnackBarText()
fun verifyShareOverlay() = assertRecentlyClosedShareOverlay()
fun verifyShareTabFavicon() = assertRecentlyClosedShareFavicon()
fun verifyShareTabTitle(title: String) = assetRecentlyClosedShareTitle(title)
fun verifyShareTabUrl(expectedUrl: Uri) = assertRecentlyClosedShareUrl(expectedUrl)
fun clickDeleteRecentlyClosedTabs() = recentlyClosedTabsDeleteButton().click()
class Transition {
fun clickOpenInNewTab(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
recentlyClosedTabsNewTabButton().click()
BrowserRobot().interact()
return BrowserRobot.Transition()
}
fun clickOpenInPrivateTab(interact: BrowserRobot.() -> Unit): BrowserRobot.Transition {
recentlyClosedTabsNewPrivateTabButton().click()
recentlyClosedTabsPageTitle().click()
BrowserRobot().interact()
return BrowserRobot.Transition()
@ -138,7 +105,7 @@ private fun assertRecentlyClosedTabsPageTitle(title: String) {
)
}
private fun recentlyClosedTabsThreeDotButton() =
private fun recentlyClosedTabsDeleteButton() =
onView(
allOf(
withId(R.id.overflow_menu),
@ -147,93 +114,3 @@ private fun recentlyClosedTabsThreeDotButton() =
)
)
)
private fun assertRecentlyClosedTabsMenuCopy() =
onView(withText("Copy"))
.check(
matches(
withEffectiveVisibility(Visibility.VISIBLE)
)
)
private fun assertRecentlyClosedTabsMenuShare() =
onView(withText("Share"))
.check(
matches(
withEffectiveVisibility(Visibility.VISIBLE)
)
)
private fun assertRecentlyClosedTabsOverlayNewTab() =
onView(withText("Open in new tab"))
.check(
matches(
withEffectiveVisibility(Visibility.VISIBLE)
)
)
private fun assertRecentlyClosedTabsMenuPrivateTab() =
onView(withText("Open in private tab"))
.check(
matches(
withEffectiveVisibility(Visibility.VISIBLE)
)
)
private fun assertRecentlyClosedTabsMenuDelete() =
onView(withText("Delete"))
.check(
matches(
withEffectiveVisibility(Visibility.VISIBLE)
)
)
private fun recentlyClosedTabsCopyButton() = onView(withText("Copy"))
private fun copySnackBarText() = onView(withId(R.id.snackbar_text))
private fun assertCopySnackBarText() = copySnackBarText()
.check(
matches
(withText("URL copied"))
)
private fun recentlyClosedTabsShareButton() = onView(withText("Share"))
private fun assertRecentlyClosedShareOverlay() =
onView(withId(R.id.shareWrapper))
.check(
matches(ViewMatchers.isDisplayed())
)
private fun assetRecentlyClosedShareTitle(title: String) =
onView(withId(R.id.share_tab_title))
.check(
matches(ViewMatchers.isDisplayed())
)
.check(
matches(withText(title))
)
private fun assertRecentlyClosedShareFavicon() =
onView(withId(R.id.share_tab_favicon))
.check(
matches(ViewMatchers.isDisplayed())
)
private fun assertRecentlyClosedShareUrl(expectedUrl: Uri) =
onView(
allOf(
withId(R.id.share_tab_url),
withEffectiveVisibility(Visibility.VISIBLE)
)
)
.check(
matches(withText(Matchers.containsString(expectedUrl.toString())))
)
private fun recentlyClosedTabsNewTabButton() = onView(withText("Open in new tab"))
private fun recentlyClosedTabsNewPrivateTabButton() = onView(withText("Open in private tab"))
private fun recentlyClosedTabsDeleteButton() = onView(withText("Delete"))

@ -20,6 +20,7 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) {
FromSettings(R.id.settingsFragment),
FromBookmarks(R.id.bookmarkFragment),
FromHistory(R.id.historyFragment),
FromHistoryMetadataGroup(R.id.historyMetadataGroupFragment),
FromTrackingProtectionExceptions(R.id.trackingProtectionExceptionsFragment),
FromAbout(R.id.aboutFragment),
FromTrackingProtection(R.id.trackingProtectionFragment),

@ -68,4 +68,9 @@ object FeatureFlags {
* Identifies and separates the tabs list with a group containing search term tabs.
*/
val tabGroupFeature = Config.channel.isNightlyOrDebug
/**
* Enables showing search groupings in the History.
*/
val showHistorySearchGroups = Config.channel.isNightlyOrDebug
}

@ -15,11 +15,11 @@ import android.os.StrictMode
import android.os.SystemClock
import android.text.format.DateUtils
import android.util.AttributeSet
import android.view.ActionMode
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ActionMode
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.WindowManager.LayoutParams.FLAG_SECURE
import androidx.annotation.CallSuper
@ -35,12 +35,12 @@ import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.NavigationUI
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers.IO
import mozilla.appservices.places.BookmarkRoot
import mozilla.components.browser.state.action.ContentAction
import mozilla.components.browser.state.search.SearchEngine
@ -94,6 +94,7 @@ import org.mozilla.fenix.home.intent.StartSearchIntentProcessor
import org.mozilla.fenix.library.bookmarks.BookmarkFragmentDirections
import org.mozilla.fenix.library.bookmarks.DesktopFolders
import org.mozilla.fenix.library.history.HistoryFragmentDirections
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections
import org.mozilla.fenix.library.recentlyclosed.RecentlyClosedFragmentDirections
import org.mozilla.fenix.onboarding.DefaultBrowserNotificationWorker
import org.mozilla.fenix.perf.MarkersLifecycleCallbacks
@ -758,6 +759,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity {
BookmarkFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromHistory ->
HistoryFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromHistoryMetadataGroup ->
HistoryMetadataGroupFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromTrackingProtectionExceptions ->
TrackingProtectionExceptionsFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromAbout ->

@ -4,51 +4,164 @@
package org.mozilla.fenix.components.history
import mozilla.components.concept.storage.HistoryStorage
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
import mozilla.components.concept.storage.VisitInfo
import mozilla.components.concept.storage.VisitType
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.perf.runBlockingIncrement
/**
* An Interface for providing a paginated list of [VisitInfo]
* An Interface for providing a paginated list of [History].
*/
interface PagedHistoryProvider {
/**
* Gets a list of [VisitInfo]
* Gets a list of [History].
*
* @param offset How much to offset the list by
* @param numberOfItems How many items to fetch
* @param onComplete A callback that returns the list of [VisitInfo]
* @param onComplete A callback that returns the list of [History]
*/
fun getHistory(offset: Long, numberOfItems: Long, onComplete: (List<VisitInfo>) -> Unit)
fun getHistory(offset: Long, numberOfItems: Long, onComplete: (List<History>) -> Unit)
}
// A PagedList DataSource runs on a background thread automatically.
// If we run this in our own coroutineScope it breaks the PagedList
fun HistoryStorage.createSynchronousPagedHistoryProvider(): PagedHistoryProvider {
return object : PagedHistoryProvider {
override fun getHistory(
offset: Long,
numberOfItems: Long,
onComplete: (List<VisitInfo>) -> Unit
) {
runBlockingIncrement {
val history = getVisitsPaginated(
offset,
numberOfItems,
excludeTypes = listOf(
VisitType.NOT_A_VISIT,
VisitType.DOWNLOAD,
VisitType.REDIRECT_TEMPORARY,
VisitType.RELOAD,
VisitType.EMBED,
VisitType.FRAMED_LINK,
VisitType.REDIRECT_PERMANENT
/**
* @param historyStorage
*/
class DefaultPagedHistoryProvider(
private val historyStorage: PlacesHistoryStorage,
private val showHistorySearchGroups: Boolean = FeatureFlags.showHistorySearchGroups,
) : PagedHistoryProvider {
private var historyGroups: List<History.Group>? = null
@Suppress("LongMethod")
override fun getHistory(
offset: Long,
numberOfItems: Long,
onComplete: (List<History>) -> Unit,
) {
// A PagedList DataSource runs on a background thread automatically.
// If we run this in our own coroutineScope it breaks the PagedList
runBlockingIncrement {
val history: List<History>
if (showHistorySearchGroups) {
// We need to refetch all the history metadata if the offset resets back at 0
// in the case of a pull to refresh.
if (historyGroups == null || offset == 0L) {
historyGroups = historyStorage.getHistoryMetadataSince(Long.MIN_VALUE)
.filter { it.key.searchTerm != null }
.groupBy { it.key.searchTerm!! }
.map { (searchTerm, items) ->
History.Group(
id = items.first().createdAt.toInt(),
title = searchTerm,
visitedAt = items.first().updatedAt,
items = items.map {
History.Metadata(
id = it.createdAt.toInt(),
title = it.title?.takeIf(String::isNotEmpty)
?: it.key.url.tryGetHostFromUrl(),
url = it.key.url,
visitedAt = it.createdAt,
totalViewTime = it.totalViewTime
)
}
)
}
}
history = getHistoryAndSearchGroups(offset, numberOfItems)
} else {
history = historyStorage
.getVisitsPaginated(
offset,
numberOfItems,
excludeTypes = listOf(
VisitType.NOT_A_VISIT,
VisitType.DOWNLOAD,
VisitType.REDIRECT_TEMPORARY,
VisitType.RELOAD,
VisitType.EMBED,
VisitType.FRAMED_LINK,
VisitType.REDIRECT_PERMANENT
)
)
.mapIndexed(transformVisitInfoToHistoryItem(offset.toInt()))
}
onComplete(history)
}
}
@Suppress("MagicNumber")
private suspend fun getHistoryAndSearchGroups(
offset: Long,
numberOfItems: Long,
): List<History> {
val result = mutableListOf<History>()
val history: List<History.Regular> = historyStorage
.getVisitsPaginated(
offset,
numberOfItems,
excludeTypes = listOf(
VisitType.NOT_A_VISIT,
VisitType.DOWNLOAD,
VisitType.REDIRECT_TEMPORARY,
VisitType.RELOAD,
VisitType.EMBED,
VisitType.FRAMED_LINK,
VisitType.REDIRECT_PERMANENT
)
)
.mapIndexed(transformVisitInfoToHistoryItem(offset.toInt()))
// History metadata items are recorded after their associated visited info, we add an
// additional buffer time to the most recent visit to account for a history group
// appearing as the most recent item.
val visitedAtBuffer = if (offset == 0L) 15000 else 0 /* 15 seconds in ms */
// Get the history groups that fit within the range of visited times in the current history
// items.
val historyGroupsInOffset = if (history.isNotEmpty()) {
historyGroups?.filter {
history.last().visitedAt <= it.visitedAt &&
it.visitedAt <= (history.first().visitedAt + visitedAtBuffer)
} ?: emptyList()
} else {
emptyList()
}
val historyMetadata = historyGroupsInOffset.flatMap { it.items }
onComplete(history)
// Add all items that are not in a group filtering out any matches with a history metadata
// item.
result.addAll(history.filter { item -> historyMetadata.find { it.url == item.url } == null })
// Filter history metadata items with no view time.
result.addAll(
historyGroupsInOffset.map { group ->
group.copy(items = group.items.filter { it.totalViewTime > 0 })
}
)
return result.sortedByDescending { it.visitedAt }
}
private fun transformVisitInfoToHistoryItem(offset: Int): (id: Int, visit: VisitInfo) -> History.Regular {
return { id, visit ->
val title = visit.title
?.takeIf(String::isNotEmpty)
?: visit.url.tryGetHostFromUrl()
History.Regular(
id = offset + id,
title = title,
url = visit.url,
visitedAt = visit.visitTime
)
}
}
}

@ -30,8 +30,8 @@ enum class HistoryItemTimeGroup {
class HistoryAdapter(
private val historyInteractor: HistoryInteractor,
) : PagedListAdapter<HistoryItem, HistoryListItemViewHolder>(historyDiffCallback),
SelectionHolder<HistoryItem> {
) : PagedListAdapter<History, HistoryListItemViewHolder>(historyDiffCallback),
SelectionHolder<History> {
private var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal
override val selectedItems get() = mode.selectedItems
@ -102,7 +102,7 @@ class HistoryAdapter(
return calendar.time
}
private fun timeGroupForHistoryItem(item: HistoryItem): HistoryItemTimeGroup {
private fun timeGroupForHistoryItem(item: History): HistoryItemTimeGroup {
return when {
DateUtils.isToday(item.visitedAt) -> HistoryItemTimeGroup.Today
yesterdayRange.contains(item.visitedAt) -> HistoryItemTimeGroup.Yesterday
@ -112,16 +112,16 @@ class HistoryAdapter(
}
}
private val historyDiffCallback = object : DiffUtil.ItemCallback<HistoryItem>() {
override fun areItemsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean {
private val historyDiffCallback = object : DiffUtil.ItemCallback<History>() {
override fun areItemsTheSame(oldItem: History, newItem: History): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean {
override fun areContentsTheSame(oldItem: History, newItem: History): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: HistoryItem, newItem: HistoryItem): Any? {
override fun getChangePayload(oldItem: History, newItem: History): Any? {
return newItem
}
}

@ -4,32 +4,23 @@
package org.mozilla.fenix.library.history
import android.content.ClipData
import android.content.ClipboardManager
import android.content.res.Resources
import androidx.navigation.NavController
import androidx.navigation.NavOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mozilla.components.concept.engine.prompt.ShareData
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
@Suppress("TooManyFunctions")
interface HistoryController {
fun handleOpen(item: HistoryItem)
fun handleOpenInNewTab(item: HistoryItem, mode: BrowsingMode)
fun handleSelect(item: HistoryItem)
fun handleDeselect(item: HistoryItem)
fun handleOpen(item: History)
fun handleSelect(item: History)
fun handleDeselect(item: History)
fun handleBackPressed(): Boolean
fun handleModeSwitched()
fun handleDeleteAll()
fun handleDeleteSome(items: Set<HistoryItem>)
fun handleCopyUrl(item: HistoryItem)
fun handleShare(item: HistoryItem)
fun handleDeleteSome(items: Set<History>)
fun handleRequestSync()
fun handleEnterRecentlyClosed()
}
@ -38,35 +29,45 @@ interface HistoryController {
class DefaultHistoryController(
private val store: HistoryFragmentStore,
private val navController: NavController,
private val resources: Resources,
private val snackbar: FenixSnackbar,
private val clipboardManager: ClipboardManager,
private val scope: CoroutineScope,
private val openToBrowser: (item: HistoryItem) -> Unit,
private val openInNewTab: (item: HistoryItem, mode: BrowsingMode) -> Unit,
private val openToBrowser: (item: History.Regular) -> Unit,
private val displayDeleteAll: () -> Unit,
private val invalidateOptionsMenu: () -> Unit,
private val deleteHistoryItems: (Set<HistoryItem>) -> Unit,
private val deleteHistoryItems: (Set<History.Regular>) -> Unit,
private val syncHistory: suspend () -> Unit,
private val metrics: MetricController
) : HistoryController {
override fun handleOpen(item: HistoryItem) {
openToBrowser(item)
}
override fun handleOpenInNewTab(item: HistoryItem, mode: BrowsingMode) {
openInNewTab(item, mode)
override fun handleOpen(item: History) {
when (item) {
is History.Regular -> openToBrowser(item)
is History.Group -> {
navController.navigate(
HistoryFragmentDirections.actionGlobalHistoryMetadataGroup(
title = item.title,
historyMetadataItems = item.items.toTypedArray()
),
NavOptions.Builder().setPopUpTo(R.id.historyMetadataGroupFragment, true).build()
)
}
else -> { /* noop */ }
}
}
override fun handleSelect(item: HistoryItem) {
override fun handleSelect(item: History) {
if (store.state.mode === HistoryFragmentState.Mode.Syncing) {
return
}
store.dispatch(HistoryFragmentAction.AddItemForRemoval(item))
if (item is History.Regular) {
store.dispatch(HistoryFragmentAction.AddItemForRemoval(item))
}
}
override fun handleDeselect(item: HistoryItem) {
store.dispatch(HistoryFragmentAction.RemoveItemForRemoval(item))
override fun handleDeselect(item: History) {
if (item is History.Regular) {
store.dispatch(HistoryFragmentAction.RemoveItemForRemoval(item))
}
}
override fun handleBackPressed(): Boolean {
@ -86,27 +87,12 @@ class DefaultHistoryController(
displayDeleteAll.invoke()
}
override fun handleDeleteSome(items: Set<HistoryItem>) {
deleteHistoryItems.invoke(items)
}
override fun handleCopyUrl(item: HistoryItem) {
val urlClipData = ClipData.newPlainText(item.url, item.url)
clipboardManager.setPrimaryClip(urlClipData)
with(snackbar) {
setText(resources.getString(R.string.url_copied))
show()
override fun handleDeleteSome(items: Set<History>) {
items.filterIsInstance<History.Regular>().let {
deleteHistoryItems.invoke(it.toSet())
}
}
override fun handleShare(item: HistoryItem) {
navController.navigate(
HistoryFragmentDirections.actionGlobalShareFragment(
data = arrayOf(ShareData(url = item.url, title = item.title))
)
)
}
override fun handleRequestSync() {
scope.launch {
store.dispatch(HistoryFragmentAction.StartSync)

@ -5,49 +5,35 @@
package org.mozilla.fenix.library.history
import androidx.paging.ItemKeyedDataSource
import mozilla.components.concept.storage.VisitInfo
import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl
import org.mozilla.fenix.components.history.PagedHistoryProvider
class HistoryDataSource(
private val historyProvider: PagedHistoryProvider
) : ItemKeyedDataSource<Int, HistoryItem>() {
) : ItemKeyedDataSource<Int, History>() {
// Because the pagination is not based off of they key
// Because the pagination is not based off of the key
// we want to start at 1, not 0 to be able to send the correct offset
// to the `historyProvider.getHistory` call.
override fun getKey(item: HistoryItem): Int = item.id + 1
override fun getKey(item: History): Int = item.id + 1
override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<HistoryItem>
callback: LoadInitialCallback<History>
) {
historyProvider.getHistory(INITIAL_OFFSET, params.requestedLoadSize.toLong()) { history ->
val items = history.mapIndexed(transformVisitInfoToHistoryItem(INITIAL_OFFSET.toInt()))
callback.onResult(items)
callback.onResult(history)
}
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<HistoryItem>) {
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<History>) {
historyProvider.getHistory(params.key.toLong(), params.requestedLoadSize.toLong()) { history ->
val items = history.mapIndexed(transformVisitInfoToHistoryItem(params.key))
callback.onResult(items)
callback.onResult(history)
}
}
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<HistoryItem>) { /* noop */ }
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<History>) { /* noop */ }
companion object {
private const val INITIAL_OFFSET = 0L
fun transformVisitInfoToHistoryItem(offset: Int): (id: Int, visit: VisitInfo) -> HistoryItem {
return { id, visit ->
val title = visit.title
?.takeIf(String::isNotEmpty)
?: visit.url.tryGetHostFromUrl()
HistoryItem(offset + id, title, visit.url, visit.visitTime)
}
}
}
}

@ -10,10 +10,10 @@ import org.mozilla.fenix.components.history.PagedHistoryProvider
class HistoryDataSourceFactory(
private val historyProvider: PagedHistoryProvider
) : DataSource.Factory<Int, HistoryItem>() {
) : DataSource.Factory<Int, History>() {
val datasource = MutableLiveData<HistoryDataSource>()
override fun create(): DataSource<Int, HistoryItem> {
override fun create(): DataSource<Int, History> {
val datasource = HistoryDataSource(historyProvider)
this.datasource.postValue(datasource)
return datasource

@ -4,8 +4,6 @@
package org.mozilla.fenix.library.history
import android.content.ClipboardManager
import android.content.Context.CLIPBOARD_SERVICE
import android.content.DialogInterface
import android.os.Bundle
import android.text.SpannableString
@ -38,9 +36,8 @@ import org.mozilla.fenix.NavHostActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.addons.showSnackBar
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.history.createSynchronousPagedHistoryProvider
import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.databinding.FragmentHistoryBinding
import org.mozilla.fenix.ext.components
@ -52,7 +49,7 @@ import org.mozilla.fenix.library.LibraryPageFragment
import org.mozilla.fenix.utils.allowUndo
@SuppressWarnings("TooManyFunctions", "LargeClass")
class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandler {
class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
private lateinit var historyStore: HistoryFragmentStore
private lateinit var historyInteractor: HistoryInteractor
private lateinit var viewModel: HistoryViewModel
@ -85,16 +82,8 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
val historyController: HistoryController = DefaultHistoryController(
store = historyStore,
navController = findNavController(),
resources = resources,
snackbar = FenixSnackbar.make(
view = view,
duration = FenixSnackbar.LENGTH_LONG,
isDisplayedWithBrowserToolbar = false
),
clipboardManager = activity?.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager,
scope = lifecycleScope,
openToBrowser = ::openItem,
openInNewTab = ::openItemAndShowTray,
displayDeleteAll = ::displayDeleteAllDialog,
invalidateOptionsMenu = ::invalidateOptionsMenu,
deleteHistoryItems = ::deleteHistoryItems,
@ -122,7 +111,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
super.onCreate(savedInstanceState)
viewModel = HistoryViewModel(
requireComponents.core.historyStorage.createSynchronousPagedHistoryProvider()
historyProvider = DefaultPagedHistoryProvider(requireComponents.core.historyStorage)
)
viewModel.userHasHistory.observe(
@ -137,7 +126,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
setHasOptionsMenu(true)
}
private fun deleteHistoryItems(items: Set<HistoryItem>) {
private fun deleteHistoryItems(items: Set<History.Regular>) {
updatePendingHistoryToDelete(items)
undoScope = CoroutineScope(IO)
undoScope?.allowUndo(
@ -200,6 +189,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
}
R.id.open_history_in_new_tabs_multi_select -> {
openItemsInNewTab { selectedItem ->
selectedItem as History.Regular
requireComponents.analytics.metrics.track(Event.HistoryOpenedInNewTabs)
selectedItem.url
}
@ -209,6 +199,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
}
R.id.open_history_in_private_tabs_multi_select -> {
openItemsInNewTab(private = true) { selectedItem ->
selectedItem as History.Regular
requireComponents.analytics.metrics.track(Event.HistoryOpenedInPrivateTabs)
selectedItem.url
}
@ -236,7 +227,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
)
}
private fun getMultiSelectSnackBarMessage(historyItems: Set<HistoryItem>): String {
private fun getMultiSelectSnackBarMessage(historyItems: Set<History.Regular>): String {
return if (historyItems.size > 1) {
getString(R.string.history_delete_multiple_items_snackbar)
} else {
@ -265,7 +256,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
_binding = null
}
private fun openItem(item: HistoryItem) {
private fun openItem(item: History.Regular) {
requireComponents.analytics.metrics.track(Event.HistoryItemOpened)
(activity as HomeActivity).openToBrowserAndLoad(
@ -275,21 +266,6 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
)
}
private fun openItemAndShowTray(item: HistoryItem, mode: BrowsingMode) {
when (mode.isPrivate) {
true -> requireComponents.analytics.metrics.track(Event.HistoryOpenedInPrivateTab)
false -> requireComponents.analytics.metrics.track(Event.HistoryOpenedInNewTab)
}
val homeActivity = activity as HomeActivity
homeActivity.browsingModeManager.mode = mode
homeActivity.components.useCases.tabsUseCases.addTab.invoke(
item.url, private = (mode == BrowsingMode.Private)
)
showTabTray()
}
private fun displayDeleteAllDialog() {
activity?.let { activity ->
AlertDialog.Builder(activity).apply {
@ -342,7 +318,7 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
)
}
private fun getDeleteHistoryItemsOperation(items: Set<HistoryItem>): (suspend () -> Unit) {
private fun getDeleteHistoryItemsOperation(items: Set<History.Regular>): (suspend () -> Unit) {
return {
CoroutineScope(IO).launch {
historyStore.dispatch(HistoryFragmentAction.EnterDeletionMode)
@ -358,13 +334,13 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
}
}
private fun updatePendingHistoryToDelete(items: Set<HistoryItem>) {
private fun updatePendingHistoryToDelete(items: Set<History.Regular>) {
pendingHistoryDeletionJob = getDeleteHistoryItemsOperation(items)
val ids = items.map { item -> item.visitedAt }.toSet()
historyStore.dispatch(HistoryFragmentAction.AddPendingDeletionSet(ids))
}
private fun undoPendingDeletion(items: Set<HistoryItem>) {
private fun undoPendingDeletion(items: Set<History.Regular>) {
pendingHistoryDeletionJob = null
val ids = items.map { item -> item.visitedAt }.toSet()
historyStore.dispatch(HistoryFragmentAction.UndoPendingDeletionSet(ids))

@ -4,18 +4,74 @@
package org.mozilla.fenix.library.history
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
/**
* Class representing a history entry
* @property id Unique id of the history item
* @property title Title of the history item
* @property url URL of the history item
* @property visitedAt Timestamp of when this history item was visited
* Class representing a history entry.
*/
data class HistoryItem(val id: Int, val title: String, val url: String, val visitedAt: Long)
sealed class History : Parcelable {
abstract val id: Int
abstract val title: String
abstract val visitedAt: Long
abstract val selected: Boolean
/**
* A regular history item.
*
* @property id Unique id of the history item.
* @property title Title of the history item.
* @property url URL of the history item.
* @property visitedAt Timestamp of when this history item was visited.
* @property selected Whether or not the history item is selected.
*/
@Parcelize data class Regular(
override val id: Int,
override val title: String,
val url: String,
override val visitedAt: Long,
override val selected: Boolean = false
) : History()
/**
* A history metadata item.
*
* @property id Unique id of the history metadata item.
* @property title Title of the history metadata item.
* @property url URL of the history metadata item.
* @property visitedAt Timestamp of when this history metadata item was visited.
* @property totalViewTime Total time the user viewed the page associated with this record.
* @property selected Whether or not the history metadata item is selected.
*/
@Parcelize data class Metadata(
override val id: Int,
override val title: String,
val url: String,
override val visitedAt: Long,
val totalViewTime: Int,
override val selected: Boolean = false
) : History()
/**
* A history metadata group.
*
* @property id Unique id of the history metadata group.
* @property title Title of the history metadata group.
* @property visitedAt Timestamp of when this history metadata group was visited.
* @property items List of history metadata items associated with the group.
* @property selected Whether or not the history group is selected.
*/
@Parcelize data class Group(
override val id: Int,
override val title: String,
override val visitedAt: Long,
val items: List<Metadata>,
override val selected: Boolean = false
) : History()
}
/**
* The [Store] for holding the [HistoryFragmentState] and applying [HistoryFragmentAction]s.
@ -28,8 +84,8 @@ class HistoryFragmentStore(initialState: HistoryFragmentState) :
*/
sealed class HistoryFragmentAction : Action {
object ExitEditMode : HistoryFragmentAction()
data class AddItemForRemoval(val item: HistoryItem) : HistoryFragmentAction()
data class RemoveItemForRemoval(val item: HistoryItem) : HistoryFragmentAction()
data class AddItemForRemoval(val item: History.Regular) : HistoryFragmentAction()
data class RemoveItemForRemoval(val item: History.Regular) : HistoryFragmentAction()
data class AddPendingDeletionSet(val itemIds: Set<Long>) : HistoryFragmentAction()
data class UndoPendingDeletionSet(val itemIds: Set<Long>) : HistoryFragmentAction()
object EnterDeletionMode : HistoryFragmentAction()
@ -40,21 +96,21 @@ sealed class HistoryFragmentAction : Action {
/**
* The state for the History Screen
* @property items List of HistoryItem to display
* @property items List of History to display
* @property mode Current Mode of History
*/
data class HistoryFragmentState(
val items: List<HistoryItem>,
val items: List<History>,
val mode: Mode,
val pendingDeletionIds: Set<Long>,
val isDeletingItems: Boolean
) : State {
sealed class Mode {
open val selectedItems = emptySet<HistoryItem>()
open val selectedItems = emptySet<History.Regular>()
object Normal : Mode()
object Syncing : Mode()
data class Editing(override val selectedItems: Set<HistoryItem>) : Mode()
data class Editing(override val selectedItems: Set<History.Regular>) : Mode()
}
}

@ -4,14 +4,13 @@
package org.mozilla.fenix.library.history
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.selection.SelectionInteractor
/**
* Interface for the HistoryInteractor. This interface is implemented by objects that want
* to respond to user interaction on the HistoryView
*/
interface HistoryInteractor : SelectionInteractor<HistoryItem> {
interface HistoryInteractor : SelectionInteractor<History> {
/**
* Called on backpressed to exit edit mode
@ -23,34 +22,6 @@ interface HistoryInteractor : SelectionInteractor<HistoryItem> {
*/
fun onModeSwitched()
/**
* Copies the URL of a history item to the copy-paste buffer.
*
* @param item the history item to copy the URL from
*/
fun onCopyPressed(item: HistoryItem)
/**
* Opens the share sheet for a history item.
*
* @param item the history item to share
*/
fun onSharePressed(item: HistoryItem)
/**
* Opens a history item in a new tab.
*
* @param item the history item to open in a new tab
*/
fun onOpenInNormalTab(item: HistoryItem)
/**
* Opens a history item in a private tab.
*
* @param item the history item to open in a private tab
*/
fun onOpenInPrivateTab(item: HistoryItem)
/**
* Called when delete all is tapped
*/
@ -60,7 +31,7 @@ interface HistoryInteractor : SelectionInteractor<HistoryItem> {
* Called when multiple history items are deleted
* @param items the history items to delete
*/
fun onDeleteSome(items: Set<HistoryItem>)
fun onDeleteSome(items: Set<History>)
/**
* Called when the user requests a sync of the history
@ -81,15 +52,15 @@ interface HistoryInteractor : SelectionInteractor<HistoryItem> {
class DefaultHistoryInteractor(
private val historyController: HistoryController
) : HistoryInteractor {
override fun open(item: HistoryItem) {
override fun open(item: History) {
historyController.handleOpen(item)
}
override fun select(item: HistoryItem) {
override fun select(item: History) {
historyController.handleSelect(item)
}
override fun deselect(item: HistoryItem) {
override fun deselect(item: History) {
historyController.handleDeselect(item)
}
@ -101,27 +72,11 @@ class DefaultHistoryInteractor(
historyController.handleModeSwitched()
}
override fun onCopyPressed(item: HistoryItem) {
historyController.handleCopyUrl(item)
}
override fun onSharePressed(item: HistoryItem) {
historyController.handleShare(item)
}
override fun onOpenInNormalTab(item: HistoryItem) {
historyController.handleOpenInNewTab(item, BrowsingMode.Normal)
}
override fun onOpenInPrivateTab(item: HistoryItem) {
historyController.handleOpenInNewTab(item, BrowsingMode.Private)
}
override fun onDeleteAll() {
historyController.handleDeleteAll()
}
override fun onDeleteSome(items: Set<HistoryItem>) {
override fun onDeleteSome(items: Set<History>) {
historyController.handleDeleteSome(items)
}

@ -1,68 +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.library.history
import android.content.Context
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.menu2.BrowserMenuController
import mozilla.components.concept.menu.MenuController
import mozilla.components.concept.menu.candidate.TextMenuCandidate
import mozilla.components.concept.menu.candidate.TextStyle
import mozilla.components.support.ktx.android.content.getColorFromAttr
import org.mozilla.fenix.R
class HistoryItemMenu(
private val context: Context,
private val onItemTapped: (Item) -> Unit
) {
enum class Item {
Copy,
Share,
OpenInNewTab,
OpenInPrivateTab,
Delete;
}
val menuController: MenuController by lazy {
BrowserMenuController().apply {
submitList(menuItems())
}
}
@VisibleForTesting
internal fun menuItems(): List<TextMenuCandidate> {
return listOf(
TextMenuCandidate(
text = context.getString(R.string.history_menu_copy_button)
) {
onItemTapped.invoke(Item.Copy)
},
TextMenuCandidate(
text = context.getString(R.string.history_menu_share_button)
) {
onItemTapped.invoke(Item.Share)
},
TextMenuCandidate(
text = context.getString(R.string.history_menu_open_in_new_tab_button)
) {
onItemTapped.invoke(Item.OpenInNewTab)
},
TextMenuCandidate(
text = context.getString(R.string.history_menu_open_in_private_tab_button)
) {
onItemTapped.invoke(Item.OpenInPrivateTab)
},
TextMenuCandidate(
text = context.getString(R.string.history_delete_item),
textStyle = TextStyle(
color = context.getColorFromAttr(R.attr.destructive)
)
) {
onItemTapped.invoke(Item.Delete)
}
)
}
}

@ -12,7 +12,7 @@ import androidx.paging.PagedList
import org.mozilla.fenix.components.history.PagedHistoryProvider
class HistoryViewModel(historyProvider: PagedHistoryProvider) : ViewModel() {
var history: LiveData<PagedList<HistoryItem>>
var history: LiveData<PagedList<History>>
var userHasHistory = MutableLiveData(true)
private val datasource: LiveData<HistoryDataSource>
@ -21,7 +21,7 @@ class HistoryViewModel(historyProvider: PagedHistoryProvider) : ViewModel() {
datasource = historyDataSourceFactory.datasource
history = LivePagedListBuilder(historyDataSourceFactory, PAGE_SIZE)
.setBoundaryCallback(object : PagedList.BoundaryCallback<HistoryItem>() {
.setBoundaryCallback(object : PagedList.BoundaryCallback<History>() {
override fun onZeroItemsLoaded() {
userHasHistory.value = false
}

@ -12,37 +12,40 @@ import org.mozilla.fenix.databinding.HistoryListItemBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.history.HistoryFragmentState
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryItem
import org.mozilla.fenix.library.history.HistoryItemMenu
import org.mozilla.fenix.library.history.HistoryItemTimeGroup
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.utils.Do
class HistoryListItemViewHolder(
view: View,
private val historyInteractor: HistoryInteractor,
private val selectionHolder: SelectionHolder<HistoryItem>
private val selectionHolder: SelectionHolder<History>,
) : RecyclerView.ViewHolder(view) {
private var item: HistoryItem? = null
private var item: History? = null
private val binding = HistoryListItemBinding.bind(view)
init {
setupMenu()
binding.recentlyClosedNavEmpty.recentlyClosedNav.setOnClickListener {
historyInteractor.onRecentlyClosedClicked()
}
binding.historyLayout.overflowView.setImageResource(R.drawable.ic_close)
binding.historyLayout.overflowView.setOnClickListener {
val item = this.item ?: return@setOnClickListener
historyInteractor.onDeleteSome(setOf(item))
}
}
fun bind(
item: HistoryItem,
item: History,
timeGroup: HistoryItemTimeGroup?,
showTopContent: Boolean,
mode: HistoryFragmentState.Mode,
isPendingDeletion: Boolean = false
isPendingDeletion: Boolean = false,
) {
if (isPendingDeletion) {
binding.historyLayout.visibility = View.GONE
@ -50,8 +53,23 @@ class HistoryListItemViewHolder(
binding.historyLayout.visibility = View.VISIBLE
}
binding.historyLayout.overflowView.isVisible = item !is History.Group
binding.historyLayout.titleView.text = item.title
binding.historyLayout.urlView.text = item.url
binding.historyLayout.urlView.text = Do exhaustive when (item) {
is History.Regular -> item.url
is History.Metadata -> item.url
is History.Group -> {
val numChildren = item.items.size
val stringId = if (numChildren == 1) {
R.string.history_search_group_site
} else {
R.string.history_search_group_sites
}
String.format(itemView.context.getString(stringId), numChildren)
}
}
toggleTopContent(showTopContent, mode === HistoryFragmentState.Mode.Normal)
@ -61,8 +79,12 @@ class HistoryListItemViewHolder(
binding.historyLayout.setSelectionInteractor(item, selectionHolder, historyInteractor)
binding.historyLayout.changeSelected(item in selectionHolder.selectedItems)
if (this.item?.url != item.url) {
if (item is History.Regular &&
(this.item as? History.Regular)?.url != item.url
) {
binding.historyLayout.loadFavicon(item.url)
} else if (item is History.Group) {
binding.historyLayout.iconView.setImageResource(R.drawable.ic_multiple_tabs)
}
if (mode is HistoryFragmentState.Mode.Editing) {
@ -85,7 +107,7 @@ class HistoryListItemViewHolder(
private fun toggleTopContent(
showTopContent: Boolean,
isNormalMode: Boolean
isNormalMode: Boolean,
) {
binding.recentlyClosedNavEmpty.recentlyClosedNav.isVisible = showTopContent
@ -110,21 +132,6 @@ class HistoryListItemViewHolder(
}
}
private fun setupMenu() {
val historyMenu = HistoryItemMenu(itemView.context) {
val item = this.item ?: return@HistoryItemMenu
Do exhaustive when (it) {
HistoryItemMenu.Item.Copy -> historyInteractor.onCopyPressed(item)
HistoryItemMenu.Item.Share -> historyInteractor.onSharePressed(item)
HistoryItemMenu.Item.OpenInNewTab -> historyInteractor.onOpenInNormalTab(item)
HistoryItemMenu.Item.OpenInPrivateTab -> historyInteractor.onOpenInPrivateTab(item)
HistoryItemMenu.Item.Delete -> historyInteractor.onDeleteSome(setOf(item))
}
}
binding.historyLayout.attachMenu(historyMenu.menuController)
}
companion object {
const val DISABLED_BUTTON_ALPHA = 0.7f
const val LAYOUT_ID = R.layout.history_list_item

@ -0,0 +1,168 @@
/* 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.library.historymetadata
import android.os.Bundle
import android.text.SpannableString
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.ExperimentalCoroutinesApi
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.UserInteractionHandler
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.databinding.FragmentHistoryMetadataGroupBinding
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.setTextColor
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.library.LibraryPageFragment
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.historymetadata.controller.DefaultHistoryMetadataGroupController
import org.mozilla.fenix.library.historymetadata.interactor.DefaultHistoryMetadataGroupInteractor
import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor
import org.mozilla.fenix.library.historymetadata.view.HistoryMetadataGroupView
/**
* Displays a list of history metadata items for a history metadata search group.
*/
class HistoryMetadataGroupFragment : LibraryPageFragment<History.Metadata>(), UserInteractionHandler {
private lateinit var historyMetadataGroupStore: HistoryMetadataGroupFragmentStore
private lateinit var interactor: HistoryMetadataGroupInteractor
private var _historyMetadataGroupView: HistoryMetadataGroupView? = null
private val historyMetadataGroupView: HistoryMetadataGroupView
get() = _historyMetadataGroupView!!
private val args by navArgs<HistoryMetadataGroupFragmentArgs>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val binding = FragmentHistoryMetadataGroupBinding.inflate(inflater, container, false)
historyMetadataGroupStore = StoreProvider.get(this) {
HistoryMetadataGroupFragmentStore(
HistoryMetadataGroupFragmentState(
items = args.historyMetadataItems.filterIsInstance<History.Metadata>()
)
)
}
interactor = DefaultHistoryMetadataGroupInteractor(
controller = DefaultHistoryMetadataGroupController(
activity = activity as HomeActivity,
store = historyMetadataGroupStore,
navController = findNavController()
)
)
_historyMetadataGroupView = HistoryMetadataGroupView(
container = binding.historyMetadataGroupLayout,
interactor = interactor,
title = args.title
)
return binding.root
}
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
consumeFrom(historyMetadataGroupStore) { state ->
historyMetadataGroupView.update(state)
activity?.invalidateOptionsMenu()
}
}
override fun onResume() {
super.onResume()
showToolbar(args.title)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (selectedItems.isNotEmpty()) {
inflater.inflate(R.menu.history_select_multi, menu)
menu.findItem(R.id.delete_history_multi_select)?.let { deleteItem ->
deleteItem.title = SpannableString(deleteItem.title).apply {
setTextColor(requireContext(), R.attr.destructive)
}
}
} else {
inflater.inflate(R.menu.history_menu, menu)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.share_history_multi_select -> {
interactor.onShareMenuItem(selectedItems)
true
}
R.id.delete_history_multi_select -> {
interactor.onDeleteMenuItem(selectedItems)
true
}
R.id.open_history_in_new_tabs_multi_select -> {
openItemsInNewTab { selectedItem ->
selectedItem.url
}
showTabTray()
true
}
R.id.open_history_in_private_tabs_multi_select -> {
openItemsInNewTab(private = true) { selectedItem ->
selectedItem.url
}
(activity as HomeActivity).apply {
browsingModeManager.mode = BrowsingMode.Private
supportActionBar?.hide()
}
showTabTray()
true
}
R.id.history_delete_all -> {
interactor.onDeleteAllMenuItem()
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onDestroyView() {
super.onDestroyView()
_historyMetadataGroupView = null
}
override val selectedItems: Set<History.Metadata> get() =
historyMetadataGroupStore.state.items.filter { it.selected }.toSet()
override fun onBackPressed(): Boolean = interactor.onBackPressed(selectedItems)
private fun showTabTray() {
findNavController().nav(
R.id.historyMetadataGroupFragment,
HistoryMetadataGroupFragmentDirections.actionGlobalTabsTrayFragment()
)
}
}

@ -0,0 +1,86 @@
/* 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.library.historymetadata
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import org.mozilla.fenix.library.history.History
/**
* The [Store] for holding the [HistoryMetadataGroupFragmentState] and applying
* [HistoryMetadataGroupFragmentAction]s.
*/
class HistoryMetadataGroupFragmentStore(initialState: HistoryMetadataGroupFragmentState) :
Store<HistoryMetadataGroupFragmentState, HistoryMetadataGroupFragmentAction>(
initialState,
::historyStateReducer
)
/**
* Actions to dispatch through the [HistoryMetadataGroupFragmentStore to modify the
* [HistoryMetadataGroupFragmentState] through the [historyStateReducer].
*/
sealed class HistoryMetadataGroupFragmentAction : Action {
data class UpdateHistoryItems(val items: List<History.Metadata>) :
HistoryMetadataGroupFragmentAction()
data class Select(val item: History.Metadata) : HistoryMetadataGroupFragmentAction()
data class Deselect(val item: History.Metadata) : HistoryMetadataGroupFragmentAction()
object DeselectAll : HistoryMetadataGroupFragmentAction()
}
/**
* The state for [HistoryMetadataGroupFragment].
*
* @property items The list of [History.Metadata] to display.
*/
data class HistoryMetadataGroupFragmentState(
val items: List<History.Metadata> = emptyList()
) : State
/**
* Reduces the history metadata state from the current state with the provided [action] to be
* performed.
*
* @param state The current history metadata state.
* @param action The action to be performed on the state.
* @return the new [HistoryMetadataGroupFragmentState] with the [action] executed.
*/
private fun historyStateReducer(
state: HistoryMetadataGroupFragmentState,
action: HistoryMetadataGroupFragmentAction
): HistoryMetadataGroupFragmentState {
return when (action) {
is HistoryMetadataGroupFragmentAction.UpdateHistoryItems ->
state.copy(items = action.items)
is HistoryMetadataGroupFragmentAction.Select ->
state.copy(
items = state.items.toMutableList()
.map {
if (it == action.item) {
it.copy(selected = true)
} else {
it
}
}
)
is HistoryMetadataGroupFragmentAction.Deselect ->
state.copy(
items = state.items.toMutableList()
.map {
if (it == action.item) {
it.copy(selected = false)
} else {
it
}
}
)
is HistoryMetadataGroupFragmentAction.DeselectAll ->
state.copy(
items = state.items.toMutableList()
.map { it.copy(selected = false) }
)
}
}

@ -0,0 +1,99 @@
/* 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.library.historymetadata.controller
import androidx.navigation.NavController
import mozilla.components.concept.engine.prompt.ShareData
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentAction
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentStore
/**
* An interface that handles the view manipulation of the history metadata group in the History
* metadata group screen.
*/
interface HistoryMetadataGroupController {
/**
* Opens the given history [item] in a new tab.
*
* @param item The [History] to open in a new tab.
*/
fun handleOpen(item: History.Metadata)
/**
* Toggles the given history [item] to be selected in multi-select mode.
*
* @param item The [History] to select.
*/
fun handleSelect(item: History.Metadata)
/**
* Toggles the given history [item] to be deselected in multi-select mode.
*
* @param item The [History] to deselect.
*/
fun handleDeselect(item: History.Metadata)
/**
* Called on backpressed to deselect all the given [items].
*
* @param items The set of [History]s to deselect.
*/
fun handleBackPressed(items: Set<History.Metadata>): Boolean
/**
* Opens the share sheet for a set of history [items].
*
* @param items The set of [History]s to share.
*/
fun handleShare(items: Set<History.Metadata>)
}
/**
* The default implementation of [HistoryMetadataGroupController].
*/
class DefaultHistoryMetadataGroupController(
private val activity: HomeActivity,
private val store: HistoryMetadataGroupFragmentStore,
private val navController: NavController,
) : HistoryMetadataGroupController {
override fun handleOpen(item: History.Metadata) {
activity.openToBrowserAndLoad(
searchTermOrURL = item.url,
newTab = true,
from = BrowserDirection.FromHistoryMetadataGroup
)
}
override fun handleSelect(item: History.Metadata) {
store.dispatch(HistoryMetadataGroupFragmentAction.Select(item))
}
override fun handleDeselect(item: History.Metadata) {
store.dispatch(HistoryMetadataGroupFragmentAction.Deselect(item))
}
override fun handleBackPressed(items: Set<History.Metadata>): Boolean {
return if (items.isNotEmpty()) {
store.dispatch(HistoryMetadataGroupFragmentAction.DeselectAll)
true
} else {
false
}
}
override fun handleShare(items: Set<History.Metadata>) {
navController.navigate(
HistoryMetadataGroupFragmentDirections.actionGlobalShareFragment(
data = items.map { ShareData(url = it.url, title = it.title) }.toTypedArray()
)
)
}
}

@ -0,0 +1,80 @@
/* 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.library.historymetadata.interactor
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.historymetadata.controller.HistoryMetadataGroupController
import org.mozilla.fenix.selection.SelectionInteractor
/**
* Interface for history metadata group related actions in the History view.
*/
interface HistoryMetadataGroupInteractor : SelectionInteractor<History.Metadata> {
/**
* Called on backpressed to deselect all the given [items].
*
* @param items The set of [History]s to deselect.
*/
fun onBackPressed(items: Set<History.Metadata>): Boolean
/**
* Deletes the given set of history [items] that are selected. Called when a user clicks on the
* "Delete" menu item.
*
* @param items The set of [History]s to delete.
*/
fun onDeleteMenuItem(items: Set<History.Metadata>)
/**
* Deletes the all the history items in the history metadata group. Called when a user clicks
* on the "Delete history" menu item.
*/
fun onDeleteAllMenuItem()
/**
* Opens the share sheet for a set of history [items]. Called when a user clicks on the
* "Share" menu item.
*
* @param items The set of [History]s to share.
*/
fun onShareMenuItem(items: Set<History.Metadata>)
}
/**
* The default implementation of [HistoryMetadataGroupInteractor].
*/
class DefaultHistoryMetadataGroupInteractor(
private val controller: HistoryMetadataGroupController
) : HistoryMetadataGroupInteractor {
override fun open(item: History.Metadata) {
controller.handleOpen(item)
}
override fun select(item: History.Metadata) {
controller.handleSelect(item)
}
override fun deselect(item: History.Metadata) {
controller.handleDeselect(item)
}
override fun onBackPressed(items: Set<History.Metadata>): Boolean {
return controller.handleBackPressed(items)
}
override fun onDeleteMenuItem(items: Set<History.Metadata>) {
// no-op
}
override fun onDeleteAllMenuItem() {
// no-op
}
override fun onShareMenuItem(items: Set<History.Metadata>) {
controller.handleShare(items)
}
}

@ -0,0 +1,54 @@
/* 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.library.historymetadata.view
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor
import org.mozilla.fenix.selection.SelectionHolder
/**
* Adapter for a list of history metadata items to be displayed.
*/
class HistoryMetadataGroupAdapter(
private val interactor: HistoryMetadataGroupInteractor
) : ListAdapter<History.Metadata, HistoryMetadataGroupItemViewHolder>(DiffCallback),
SelectionHolder<History.Metadata> {
private var selectedHistoryItems: Set<History.Metadata> = emptySet()
override val selectedItems: Set<History.Metadata>
get() = selectedHistoryItems
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): HistoryMetadataGroupItemViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(HistoryMetadataGroupItemViewHolder.LAYOUT_ID, parent, false)
return HistoryMetadataGroupItemViewHolder(view, interactor, this)
}
override fun onBindViewHolder(holder: HistoryMetadataGroupItemViewHolder, position: Int) {
holder.bind(getItem(position))
}
fun updateData(items: List<History.Metadata>) {
this.selectedHistoryItems = items.filter { it.selected }.toSet()
notifyItemRangeChanged(0, items.size)
submitList(items)
}
internal object DiffCallback : DiffUtil.ItemCallback<History.Metadata>() {
override fun areContentsTheSame(oldItem: History.Metadata, newItem: History.Metadata): Boolean =
oldItem.id == newItem.id
override fun areItemsTheSame(oldItem: History.Metadata, newItem: History.Metadata): Boolean =
oldItem == newItem
}
}

@ -0,0 +1,55 @@
/* 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.library.historymetadata.view
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.HistoryMetadataGroupListItemBinding
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor
import org.mozilla.fenix.selection.SelectionHolder
/**
* View holder for a history metadata list item.
*/
class HistoryMetadataGroupItemViewHolder(
view: View,
private val interactor: HistoryMetadataGroupInteractor,
private val selectionHolder: SelectionHolder<History.Metadata>
) : RecyclerView.ViewHolder(view) {
private val binding = HistoryMetadataGroupListItemBinding.bind(view)
private var item: History.Metadata? = null
fun bind(item: History.Metadata) {
binding.historyLayout.titleView.text = item.title
binding.historyLayout.urlView.text = item.url
binding.historyLayout.setSelectionInteractor(item, selectionHolder, interactor)
binding.historyLayout.changeSelected(item in selectionHolder.selectedItems)
if (this.item?.url != item.url) {
binding.historyLayout.loadFavicon(item.url)
}
binding.historyLayout.overflowView.setImageResource(R.drawable.ic_close)
if (selectionHolder.selectedItems.isEmpty()) {
binding.historyLayout.overflowView.showAndEnable()
} else {
binding.historyLayout.overflowView.hideAndDisable()
}
this.item = item
}
companion object {
const val LAYOUT_ID = R.layout.history_metadata_group_list_item
}
}

@ -0,0 +1,59 @@
/* 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.library.historymetadata.view
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.ComponentHistoryMetadataGroupBinding
import org.mozilla.fenix.library.LibraryPageView
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentState
import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor
/**
* Shows a list of history metadata items.
*/
class HistoryMetadataGroupView(
container: ViewGroup,
val interactor: HistoryMetadataGroupInteractor,
val title: String
) : LibraryPageView(container) {
private val binding = ComponentHistoryMetadataGroupBinding.inflate(
LayoutInflater.from(container.context), container, true
)
private val historyMetadataGroupAdapter = HistoryMetadataGroupAdapter(interactor)
init {
binding.historyMetadataGroupList.apply {
layoutManager = LinearLayoutManager(containerView.context)
adapter = historyMetadataGroupAdapter
}
}
/**
* Updates the display of the history metadata items based on the given
* [HistoryMetadataGroupFragmentState].
*/
fun update(state: HistoryMetadataGroupFragmentState) {
binding.historyMetadataGroupList.isVisible = state.items.isNotEmpty()
binding.historyMetadataGroupEmptyView.isVisible = state.items.isEmpty()
historyMetadataGroupAdapter.updateData(state.items)
val selectedItems = state.items.filter { it.selected }
if (selectedItems.isEmpty()) {
setUiForNormalMode(title)
} else {
setUiForSelectingMode(
context.getString(R.string.history_multi_select_title, selectedItems.size)
)
}
}
}

@ -4,9 +4,6 @@
package org.mozilla.fenix.library.recentlyclosed
import android.content.ClipData
import android.content.ClipboardManager
import android.content.res.Resources
import androidx.navigation.NavController
import androidx.navigation.NavOptions
import mozilla.components.browser.state.action.RecentlyClosedAction
@ -18,7 +15,6 @@ import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.FenixSnackbar
@Suppress("TooManyFunctions")
interface RecentlyClosedController {
@ -26,8 +22,6 @@ interface RecentlyClosedController {
fun handleOpen(tabs: Set<RecoverableTab>, mode: BrowsingMode? = null)
fun handleDelete(tab: RecoverableTab)
fun handleDelete(tabs: Set<RecoverableTab>)
fun handleCopyUrl(item: RecoverableTab)
fun handleShare(tab: RecoverableTab)
fun handleShare(tabs: Set<RecoverableTab>)
fun handleNavigateToHistory()
fun handleRestore(item: RecoverableTab)
@ -42,9 +36,6 @@ class DefaultRecentlyClosedController(
private val browserStore: BrowserStore,
private val recentlyClosedStore: RecentlyClosedFragmentStore,
private val tabsUseCases: TabsUseCases,
private val resources: Resources,
private val snackbar: FenixSnackbar,
private val clipboardManager: ClipboardManager,
private val activity: HomeActivity,
private val openToBrowser: (item: RecoverableTab, mode: BrowsingMode?) -> Unit
) : RecentlyClosedController {
@ -81,17 +72,6 @@ class DefaultRecentlyClosedController(
)
}
override fun handleCopyUrl(item: RecoverableTab) {
val urlClipData = ClipData.newPlainText(item.url, item.url)
clipboardManager.setPrimaryClip(urlClipData)
with(snackbar) {
setText(resources.getString(R.string.url_copied))
show()
}
}
override fun handleShare(tab: RecoverableTab) = handleShare(setOf(tab))
override fun handleShare(tabs: Set<RecoverableTab>) {
val shareData = tabs.map { ShareData(url = it.url, title = it.title) }
navController.navigate(

@ -4,8 +4,6 @@
package org.mozilla.fenix.library.recentlyclosed
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.text.SpannableString
import android.view.LayoutInflater
@ -27,10 +25,8 @@ import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.databinding.FragmentRecentlyClosedTabsBinding
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.setTextColor
import org.mozilla.fenix.ext.showToolbar
@ -116,12 +112,6 @@ class RecentlyClosedFragment : LibraryPageFragment<RecoverableTab>(), UserIntera
recentlyClosedStore = recentlyClosedFragmentStore,
activity = activity as HomeActivity,
tabsUseCases = requireComponents.useCases.tabsUseCases,
resources = requireContext().resources,
snackbar = FenixSnackbar.make(
view = requireActivity().getRootView()!!,
isDisplayedWithBrowserToolbar = true
),
clipboardManager = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager,
openToBrowser = ::openItem
)
recentlyClosedInteractor = RecentlyClosedFragmentInteractor(recentlyClosedController)

@ -5,7 +5,6 @@
package org.mozilla.fenix.library.recentlyclosed
import mozilla.components.browser.state.state.recover.RecoverableTab
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
/**
* Interactor for the recently closed screen
@ -14,25 +13,6 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode
class RecentlyClosedFragmentInteractor(
private val recentlyClosedController: RecentlyClosedController
) : RecentlyClosedInteractor {
override fun restore(item: RecoverableTab) {
recentlyClosedController.handleRestore(item)
}
override fun onCopyPressed(item: RecoverableTab) {
recentlyClosedController.handleCopyUrl(item)
}
override fun onSharePressed(item: RecoverableTab) {
recentlyClosedController.handleShare(item)
}
override fun onOpenInNormalTab(item: RecoverableTab) {
recentlyClosedController.handleOpen(item, BrowsingMode.Normal)
}
override fun onOpenInPrivateTab(item: RecoverableTab) {
recentlyClosedController.handleOpen(item, BrowsingMode.Private)
}
override fun onDelete(tab: RecoverableTab) {
recentlyClosedController.handleDelete(tab)

@ -17,46 +17,11 @@ import org.mozilla.fenix.library.LibraryPageView
import org.mozilla.fenix.selection.SelectionInteractor
interface RecentlyClosedInteractor : SelectionInteractor<RecoverableTab> {
/**
* Called when an item is tapped to restore it.
*
* @param item the tapped item to restore.
*/
fun restore(item: RecoverableTab)
/**
* Called when the view more history option is tapped.
*/
fun onNavigateToHistory()
/**
* Copies the URL of a recently closed tab item to the copy-paste buffer.
*
* @param item the recently closed tab item to copy the URL from
*/
fun onCopyPressed(item: RecoverableTab)
/**
* Opens the share sheet for a recently closed tab item.
*
* @param item the recently closed tab item to share
*/
fun onSharePressed(item: RecoverableTab)
/**
* Opens a recently closed tab item in a new tab.
*
* @param item the recently closed tab item to open in a new tab
*/
fun onOpenInNormalTab(item: RecoverableTab)
/**
* Opens a recently closed tab item in a private tab.
*
* @param item the recently closed tab item to open in a private tab
*/
fun onOpenInPrivateTab(item: RecoverableTab)
/**
* Called when recently closed tab is selected for deletion.
*

@ -12,13 +12,11 @@ import org.mozilla.fenix.databinding.HistoryListItemBinding
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.library.history.HistoryItemMenu
import org.mozilla.fenix.utils.Do
class RecentlyClosedItemViewHolder(
view: View,
private val recentlyClosedFragmentInteractor: RecentlyClosedFragmentInteractor,
private val selectionHolder: SelectionHolder<RecoverableTab>
private val selectionHolder: SelectionHolder<RecoverableTab>,
) : RecyclerView.ViewHolder(view) {
private val binding = HistoryListItemBinding.bind(view)
@ -26,17 +24,23 @@ class RecentlyClosedItemViewHolder(
private var item: RecoverableTab? = null
init {
setupMenu()
binding.historyLayout.overflowView.setImageResource(R.drawable.ic_close)
binding.historyLayout.overflowView.setOnClickListener {
val item = this.item ?: return@setOnClickListener
recentlyClosedFragmentInteractor.onDelete(item)
}
}
fun bind(
item: RecoverableTab
) {
fun bind(item: RecoverableTab) {
binding.historyLayout.titleView.text =
if (item.title.isNotEmpty()) item.title else item.url
binding.historyLayout.urlView.text = item.url
binding.historyLayout.setSelectionInteractor(item, selectionHolder, recentlyClosedFragmentInteractor)
binding.historyLayout.setSelectionInteractor(
item,
selectionHolder,
recentlyClosedFragmentInteractor
)
binding.historyLayout.changeSelected(item in selectionHolder.selectedItems)
if (this.item?.url != item.url) {
@ -52,25 +56,6 @@ class RecentlyClosedItemViewHolder(
this.item = item
}
private fun setupMenu() {
val historyMenu = HistoryItemMenu(itemView.context) {
val item = this.item ?: return@HistoryItemMenu
Do exhaustive when (it) {
HistoryItemMenu.Item.Copy -> recentlyClosedFragmentInteractor.onCopyPressed(item)
HistoryItemMenu.Item.Share -> recentlyClosedFragmentInteractor.onSharePressed(item)
HistoryItemMenu.Item.OpenInNewTab -> recentlyClosedFragmentInteractor.onOpenInNormalTab(
item
)
HistoryItemMenu.Item.OpenInPrivateTab -> recentlyClosedFragmentInteractor.onOpenInPrivateTab(
item
)
HistoryItemMenu.Item.Delete -> recentlyClosedFragmentInteractor.onDelete(item)
}
}
binding.historyLayout.attachMenu(historyMenu.menuController)
}
companion object {
const val LAYOUT_ID = R.layout.history_list_item
}

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/history_metadata_group_empty_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:text="@string/history_empty_message"
android:textColor="?secondaryText"
android:textSize="16sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/history_metadata_group_list"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:listitem="@layout/history_list_item" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/historyMetadataGroupLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" />

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAccessibility="no"
android:orientation="vertical">
<org.mozilla.fenix.library.LibrarySiteItemView
android:id="@+id/history_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="@dimen/library_item_height" />
</LinearLayout>

@ -23,9 +23,7 @@
android:id="@+id/favicon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="10dp"
android:background="@drawable/favicon_background"
android:backgroundTint="?neutral"
android:padding="8dp"
android:importantForAccessibility="no"
tools:src="@drawable/ic_folder_icon" />
<ImageView

@ -33,6 +33,10 @@
android:id="@+id/action_global_recently_closed"
app:destination="@id/recentlyClosedFragment" />
<action
android:id="@+id/action_global_history_metadata_group"
app:destination="@id/historyMetadataGroupFragment" />
<action
android:id="@+id/action_global_shareFragment"
app:destination="@id/shareFragment" />
@ -236,6 +240,17 @@
android:label="@string/library_history"
tools:layout="@layout/fragment_history" />
<fragment
android:id="@+id/historyMetadataGroupFragment"
android:name="org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragment">
<argument
android:name="title"
app:argType="string" />
<argument
android:name="historyMetadataItems"
app:argType="org.mozilla.fenix.library.history.History[]" />
</fragment>
<fragment
android:id="@+id/downloadsFragment"
android:name="org.mozilla.fenix.library.downloads.DownloadFragment"

@ -578,6 +578,13 @@
<!-- Content description (not visible, for screen readers etc.): "Close button for library settings" -->
<string name="content_description_close_button">Close</string>
<!-- Text to show users they have one site in the history group section of the History fragment.
%d is a placeholder for the number of sites in the group. -->
<string name="history_search_group_site">%d site</string>
<!-- Text to show users they have multiple sites in the history group section of the History fragment.
%d is a placeholder for the number of sites in the group. -->
<string name="history_search_group_sites">%d sites</string>
<!-- Option in library for Recently Closed Tabs -->
<string name="library_recently_closed_tabs">Recently closed tabs</string>
<!-- Option in library to open Recently Closed Tabs page -->
@ -759,15 +766,15 @@
<!-- Text for positive action to delete history in deleting history dialog -->
<string name="history_clear_dialog">Clear</string>
<!-- History overflow menu copy button -->
<string name="history_menu_copy_button">Copy</string>
<string name="history_menu_copy_button" moz:removedIn="94" tools:ignore="UnusedResources">Copy</string>
<!-- History overflow menu share button -->
<string name="history_menu_share_button">Share</string>
<string name="history_menu_share_button" moz:removedIn="94" tools:ignore="UnusedResources">Share</string>
<!-- History overflow menu open in new tab button -->
<string name="history_menu_open_in_new_tab_button">Open in new tab</string>
<string name="history_menu_open_in_new_tab_button" moz:removedIn="94" tools:ignore="UnusedResources">Open in new tab</string>
<!-- History overflow menu open in private tab button -->
<string name="history_menu_open_in_private_tab_button">Open in private tab</string>
<string name="history_menu_open_in_private_tab_button" moz:removedIn="94" tools:ignore="UnusedResources">Open in private tab</string>
<!-- Text for the button to delete a single history item -->
<string name="history_delete_item">Delete</string>
<string name="history_delete_item" moz:removedIn="94" tools:ignore="UnusedResources">Delete</string>
<!-- History multi select title in app bar
The first parameter is the number of bookmarks selected -->
<string name="history_multi_select_title">%1$d selected</string>

@ -7,16 +7,20 @@ package org.mozilla.fenix.components.history
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import mozilla.components.concept.storage.HistoryStorage
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
import mozilla.components.concept.storage.DocumentType
import mozilla.components.concept.storage.HistoryMetadata
import mozilla.components.concept.storage.HistoryMetadataKey
import mozilla.components.concept.storage.VisitInfo
import mozilla.components.concept.storage.VisitType
import org.junit.Assert.assertSame
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.library.history.History
class PagedHistoryProviderTest {
private lateinit var storage: HistoryStorage
private lateinit var storage: PlacesHistoryStorage
@Before
fun setup() {
@ -25,11 +29,52 @@ class PagedHistoryProviderTest {
@Test
fun `getHistory uses getVisitsPaginated`() {
val provider = storage.createSynchronousPagedHistoryProvider()
val results = listOf<VisitInfo>(mockk(), mockk())
coEvery { storage.getVisitsPaginated(any(), any(), any()) } returns results
val provider = DefaultPagedHistoryProvider(
historyStorage = storage,
showHistorySearchGroups = true
)
var actualResults: List<VisitInfo>? = null
val visitInfo1 = VisitInfo(
url = "http://www.mozilla.com",
title = "mozilla",
visitTime = 5,
visitType = VisitType.LINK
)
val visitInfo2 = VisitInfo(
url = "http://www.firefox.com",
title = "firefox",
visitTime = 2,
visitType = VisitType.LINK
)
val visitInfo3 = VisitInfo(
url = "http://www.wikipedia.com",
title = "wikipedia",
visitTime = 1,
visitType = VisitType.LINK
)
val historyEntry1 = HistoryMetadata(
key = HistoryMetadataKey("http://www.mozilla.com", "mozilla", null),
title = "mozilla",
createdAt = 5,
updatedAt = 5,
totalViewTime = 10,
documentType = DocumentType.Regular,
previewImageUrl = null
)
val historyEntry2 = HistoryMetadata(
key = HistoryMetadataKey("http://www.firefox.com", "mozilla", null),
title = "firefox",
createdAt = 2,
updatedAt = 2,
totalViewTime = 20,
documentType = DocumentType.Regular,
previewImageUrl = null
)
coEvery { storage.getVisitsPaginated(any(), any(), any()) } returns listOf(visitInfo1, visitInfo2, visitInfo3)
coEvery { storage.getHistoryMetadataSince(any()) } returns listOf(historyEntry1, historyEntry2)
var actualResults: List<History>? = null
provider.getHistory(10L, 5) {
actualResults = it
}
@ -50,6 +95,35 @@ class PagedHistoryProviderTest {
)
}
assertSame(results, actualResults)
val results = listOf(
History.Group(
id = historyEntry1.createdAt.toInt(),
title = historyEntry1.key.searchTerm!!,
visitedAt = historyEntry1.createdAt,
items = listOf(
History.Metadata(
id = historyEntry1.createdAt.toInt(),
title = historyEntry1.title!!,
url = historyEntry1.key.url,
visitedAt = historyEntry1.createdAt,
totalViewTime = historyEntry1.totalViewTime
),
History.Metadata(
id = historyEntry2.createdAt.toInt(),
title = historyEntry2.title!!,
url = historyEntry2.key.url,
visitedAt = historyEntry2.createdAt,
totalViewTime = historyEntry2.totalViewTime
)
)
),
History.Regular(
id = 12,
title = visitInfo3.title!!,
url = visitInfo3.url,
visitedAt = visitInfo3.visitTime
)
)
assertEquals(results, actualResults)
}
}

@ -4,18 +4,13 @@
package org.mozilla.fenix.library.history
import android.content.ClipData
import android.content.ClipboardManager
import android.content.res.Resources
import androidx.navigation.NavController
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.concept.engine.prompt.ShareData
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@ -23,24 +18,17 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.directionsEq
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
// Robolectric needed for `onShareItem()`
@ExperimentalCoroutinesApi
@RunWith(FenixRobolectricTestRunner::class)
class HistoryControllerTest {
private val historyItem = HistoryItem(0, "title", "url", 0.toLong())
private val historyItem = History.Regular(0, "title", "url", 0.toLong())
private val scope = TestCoroutineScope()
private val store: HistoryFragmentStore = mockk(relaxed = true)
private val state: HistoryFragmentState = mockk(relaxed = true)
private val navController: NavController = mockk(relaxed = true)
private val resources: Resources = mockk(relaxed = true)
private val snackbar: FenixSnackbar = mockk(relaxed = true)
private val clipboardManager: ClipboardManager = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true)
@Before
@ -55,7 +43,7 @@ class HistoryControllerTest {
@Test
fun onPressHistoryItemInNormalMode() {
var actualHistoryItem: HistoryItem? = null
var actualHistoryItem: History? = null
val controller = createController(
openInBrowser = {
actualHistoryItem = it
@ -65,36 +53,6 @@ class HistoryControllerTest {
assertEquals(historyItem, actualHistoryItem)
}
@Test
fun onOpenItemInNormalMode() {
var actualHistoryItem: HistoryItem? = null
var actualBrowsingMode: BrowsingMode? = null
val controller = createController(
openAndShowTray = { historyItem, browsingMode ->
actualHistoryItem = historyItem
actualBrowsingMode = browsingMode
}
)
controller.handleOpenInNewTab(historyItem, BrowsingMode.Normal)
assertEquals(historyItem, actualHistoryItem)
assertEquals(BrowsingMode.Normal, actualBrowsingMode)
}
@Test
fun onOpenItemInPrivateMode() {
var actualHistoryItem: HistoryItem? = null
var actualBrowsingMode: BrowsingMode? = null
val controller = createController(
openAndShowTray = { historyItem, browsingMode ->
actualHistoryItem = historyItem
actualBrowsingMode = browsingMode
}
)
controller.handleOpenInNewTab(historyItem, BrowsingMode.Private)
assertEquals(historyItem, actualHistoryItem)
assertEquals(BrowsingMode.Private, actualBrowsingMode)
}
@Test
fun onPressHistoryItemInEditMode() {
every { state.mode } returns HistoryFragmentState.Mode.Editing(setOf())
@ -174,7 +132,7 @@ class HistoryControllerTest {
@Test
fun onDeleteSome() {
val itemsToDelete = setOf(historyItem)
var actualItems: Set<HistoryItem>? = null
var actualItems: Set<History>? = null
val controller = createController(
deleteHistoryItems = { items ->
actualItems = items
@ -185,37 +143,6 @@ class HistoryControllerTest {
assertEquals(itemsToDelete, actualItems)
}
@Test
fun onCopyItem() {
val clipdata = slot<ClipData>()
createController().handleCopyUrl(historyItem)
verify {
clipboardManager.setPrimaryClip(capture(clipdata))
snackbar.show()
}
assertEquals(1, clipdata.captured.itemCount)
assertEquals(historyItem.url, clipdata.captured.description.label)
assertEquals(historyItem.url, clipdata.captured.getItemAt(0).text)
}
@Test
@Suppress("UNCHECKED_CAST")
fun onShareItem() {
createController().handleShare(historyItem)
verify {
navController.navigate(
directionsEq(
HistoryFragmentDirections.actionGlobalShareFragment(
data = arrayOf(ShareData(url = historyItem.url, title = historyItem.title))
)
)
)
}
}
@Test
fun onRequestSync() {
var syncHistoryInvoked = false
@ -235,22 +162,17 @@ class HistoryControllerTest {
@Suppress("LongParameterList")
private fun createController(
openInBrowser: (HistoryItem) -> Unit = { _ -> },
openAndShowTray: (HistoryItem, BrowsingMode) -> Unit = { _, _ -> },
displayDeleteAll: () -> Unit = { },
invalidateOptionsMenu: () -> Unit = { },
deleteHistoryItems: (Set<HistoryItem>) -> Unit = { _ -> },
syncHistory: suspend () -> Unit = { }
openInBrowser: (History) -> Unit = { _ -> },
displayDeleteAll: () -> Unit = {},
invalidateOptionsMenu: () -> Unit = {},
deleteHistoryItems: (Set<History>) -> Unit = { _ -> },
syncHistory: suspend () -> Unit = {}
): HistoryController {
return DefaultHistoryController(
store,
navController,
resources,
snackbar,
clipboardManager,
scope,
openInBrowser,
openAndShowTray,
displayDeleteAll,
invalidateOptionsMenu,
deleteHistoryItems,

@ -10,8 +10,8 @@ import org.junit.Assert.assertNotSame
import org.junit.Test
class HistoryFragmentStoreTest {
private val historyItem = HistoryItem(0, "title", "url", 0.toLong())
private val newHistoryItem = HistoryItem(1, "title", "url", 0.toLong())
private val historyItem = History.Regular(0, "title", "url", 0.toLong())
private val newHistoryItem = History.Regular(1, "title", "url", 0.toLong())
@Test
fun exitEditMode() = runBlocking {

@ -9,10 +9,9 @@ import io.mockk.mockk
import io.mockk.verifyAll
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
class HistoryInteractorTest {
private val historyItem = HistoryItem(0, "title", "url", 0.toLong())
private val historyItem = History.Regular(0, "title", "url", 0.toLong())
val controller: HistoryController = mockk(relaxed = true)
val interactor = DefaultHistoryInteractor(controller)
@ -66,42 +65,6 @@ class HistoryInteractorTest {
}
}
@Test
fun onCopyPressed() {
interactor.onCopyPressed(historyItem)
verifyAll {
controller.handleCopyUrl(historyItem)
}
}
@Test
fun onSharePressed() {
interactor.onSharePressed(historyItem)
verifyAll {
controller.handleShare(historyItem)
}
}
@Test
fun onOpenInNormalTab() {
interactor.onOpenInNormalTab(historyItem)
verifyAll {
controller.handleOpenInNewTab(historyItem, BrowsingMode.Normal)
}
}
@Test
fun onOpenInPrivateTab() {
interactor.onOpenInPrivateTab(historyItem)
verifyAll {
controller.handleOpenInNewTab(historyItem, BrowsingMode.Private)
}
}
@Test
fun onDeleteAll() {
interactor.onDeleteAll()
@ -116,6 +79,7 @@ class HistoryInteractorTest {
val items = setOf(historyItem)
interactor.onDeleteSome(items)
verifyAll {
controller.handleDeleteSome(items)
}
@ -124,6 +88,7 @@ class HistoryInteractorTest {
@Test
fun onRequestSync() {
interactor.onRequestSync()
verifyAll {
controller.handleRequestSync()
}

@ -1,76 +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.library.history
import android.content.Context
import androidx.appcompat.view.ContextThemeWrapper
import mozilla.components.concept.menu.candidate.TextStyle
import mozilla.components.support.ktx.android.content.getColorFromAttr
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.library.history.HistoryItemMenu.Item
@RunWith(FenixRobolectricTestRunner::class)
class HistoryItemMenuTest {
private lateinit var context: Context
private lateinit var menu: HistoryItemMenu
private var onItemTappedCaptured: Item? = null
@Before
fun setup() {
context = ContextThemeWrapper(testContext, R.style.NormalTheme)
onItemTappedCaptured = null
menu = HistoryItemMenu(context) {
onItemTappedCaptured = it
}
}
@Test
fun `delete item has special styling`() {
val deleteItem = menu.menuItems().last()
assertEquals("Delete", deleteItem.text)
assertEquals(
TextStyle(color = context.getColorFromAttr(R.attr.destructive)),
deleteItem.textStyle
)
deleteItem.onClick()
assertEquals(Item.Delete, onItemTappedCaptured)
}
@Test
fun `builds menu items`() {
val items = menu.menuItems()
assertEquals(5, items.size)
val (copy, share, openInNewTab, openInPrivateTab, delete) = items
assertEquals("Copy", copy.text)
assertEquals("Share", share.text)
assertEquals("Open in new tab", openInNewTab.text)
assertEquals("Open in private tab", openInPrivateTab.text)
assertEquals("Delete", delete.text)
copy.onClick()
assertEquals(Item.Copy, onItemTappedCaptured)
share.onClick()
assertEquals(Item.Share, onItemTappedCaptured)
openInNewTab.onClick()
assertEquals(Item.OpenInNewTab, onItemTappedCaptured)
openInPrivateTab.onClick()
assertEquals(Item.OpenInPrivateTab, onItemTappedCaptured)
delete.onClick()
assertEquals(Item.Delete, onItemTappedCaptured)
}
}

@ -0,0 +1,82 @@
/* 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.library.historymetadata
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.library.history.History
class HistoryMetadataGroupFragmentStoreTest {
private lateinit var state: HistoryMetadataGroupFragmentState
private lateinit var store: HistoryMetadataGroupFragmentStore
private val mozillaHistoryMetadataItem = History.Metadata(
id = 0,
title = "Mozilla",
url = "mozilla.org",
visitedAt = 0,
totalViewTime = 0
)
private val firefoxHistoryMetadataItem = History.Metadata(
id = 0,
title = "Firefox",
url = "firefox.com",
visitedAt = 0,
totalViewTime = 0
)
@Before
fun setup() {
state = HistoryMetadataGroupFragmentState()
store = HistoryMetadataGroupFragmentStore(state)
}
@Test
fun `Test updating the items in HistoryMetadataGroupFragmentStore`() = runBlocking {
assertEquals(0, store.state.items.size)
val items = listOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem)
store.dispatch(HistoryMetadataGroupFragmentAction.UpdateHistoryItems(items)).join()
assertEquals(items, store.state.items)
}
@Test
fun `Test selecting and deselecting an item in HistoryMetadataGroupFragmentStore`() = runBlocking {
val items = listOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem)
store.dispatch(HistoryMetadataGroupFragmentAction.UpdateHistoryItems(items)).join()
assertFalse(store.state.items[0].selected)
assertFalse(store.state.items[1].selected)
store.dispatch(HistoryMetadataGroupFragmentAction.Select(mozillaHistoryMetadataItem)).join()
assertTrue(store.state.items[0].selected)
assertFalse(store.state.items[1].selected)
store.dispatch(HistoryMetadataGroupFragmentAction.Deselect(store.state.items[0])).join()
assertFalse(store.state.items[0].selected)
assertFalse(store.state.items[1].selected)
}
@Test
fun `Test deselecting all items in HistoryMetadataGroupFragmentStore`() = runBlocking {
val items = listOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem)
store.dispatch(HistoryMetadataGroupFragmentAction.UpdateHistoryItems(items)).join()
store.dispatch(HistoryMetadataGroupFragmentAction.Select(mozillaHistoryMetadataItem)).join()
store.dispatch(HistoryMetadataGroupFragmentAction.DeselectAll).join()
assertFalse(store.state.items[0].selected)
assertFalse(store.state.items[1].selected)
}
}

@ -0,0 +1,132 @@
/* 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.library.historymetadata.controller
import androidx.navigation.NavController
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.ext.directionsEq
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentAction
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentStore
@ExperimentalCoroutinesApi
@RunWith(FenixRobolectricTestRunner::class)
class HistoryMetadataGroupControllerTest {
private val testDispatcher = TestCoroutineDispatcher()
@get:Rule
val coroutinesTestRule = MainCoroutineRule(testDispatcher)
private val activity: HomeActivity = mockk(relaxed = true)
private val store: HistoryMetadataGroupFragmentStore = mockk(relaxed = true)
private val navController: NavController = mockk(relaxed = true)
private val mozillaHistoryMetadataItem = History.Metadata(
id = 0,
title = "Mozilla",
url = "mozilla.org",
visitedAt = 0,
totalViewTime = 1
)
private val firefoxHistoryMetadataItem = History.Metadata(
id = 0,
title = "Firefox",
url = "firefox.com",
visitedAt = 0,
totalViewTime = 1
)
private lateinit var controller: DefaultHistoryMetadataGroupController
@Before
fun setUp() {
controller = DefaultHistoryMetadataGroupController(
activity = activity,
store = store,
navController = navController
)
}
@Test
fun handleOpen() {
controller.handleOpen(mozillaHistoryMetadataItem)
verify {
activity.openToBrowserAndLoad(
searchTermOrURL = mozillaHistoryMetadataItem.url,
newTab = true,
from = BrowserDirection.FromHistoryMetadataGroup,
historyMetadata = mozillaHistoryMetadataItem.historyMetadataKey
)
}
}
@Test
fun handleSelect() {
controller.handleSelect(mozillaHistoryMetadataItem)
verify {
store.dispatch(HistoryMetadataGroupFragmentAction.Select(mozillaHistoryMetadataItem))
}
}
@Test
fun handleDeselect() {
controller.handleDeselect(mozillaHistoryMetadataItem)
verify {
store.dispatch(HistoryMetadataGroupFragmentAction.Deselect(mozillaHistoryMetadataItem))
}
}
@Test
fun handleBackPressed() {
assertTrue(controller.handleBackPressed(setOf(mozillaHistoryMetadataItem)))
verify {
store.dispatch(HistoryMetadataGroupFragmentAction.DeselectAll)
}
assertFalse(controller.handleBackPressed(emptySet()))
}
@Test
fun handleShare() {
controller.handleShare(setOf(mozillaHistoryMetadataItem, firefoxHistoryMetadataItem))
val data = arrayOf(
ShareData(
title = mozillaHistoryMetadataItem.title,
url = mozillaHistoryMetadataItem.url
),
ShareData(
title = firefoxHistoryMetadataItem.title,
url = firefoxHistoryMetadataItem.url
),
)
verify {
navController.navigate(
directionsEq(HistoryMetadataGroupFragmentDirections.actionGlobalShareFragment(data))
)
}
}
}

@ -0,0 +1,50 @@
/* 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.library.historymetadata.view
import android.view.LayoutInflater
import androidx.navigation.Navigation
import io.mockk.mockk
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.databinding.HistoryMetadataGroupListItemBinding
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor
import org.mozilla.fenix.selection.SelectionHolder
@RunWith(FenixRobolectricTestRunner::class)
class HistoryMetadataGroupItemViewHolderTest {
private lateinit var binding: HistoryMetadataGroupListItemBinding
private lateinit var interactor: HistoryMetadataGroupInteractor
private lateinit var selectionHolder: SelectionHolder<History.Metadata>
private val item = History.Metadata(
id = 0,
title = "Mozilla",
url = "mozilla.org",
visitedAt = 0,
totalViewTime = 0
)
@Before
fun setup() {
binding = HistoryMetadataGroupListItemBinding.inflate(LayoutInflater.from(testContext))
Navigation.setViewNavController(binding.root, mockk(relaxed = true))
interactor = mockk(relaxed = true)
selectionHolder = mockk(relaxed = true)
}
@Test
fun `GIVEN a history metadata item on bind THEN set the title and url text`() {
HistoryMetadataGroupItemViewHolder(binding.root, interactor, selectionHolder).bind(item)
assertEquals(item.title, binding.historyLayout.titleView.text)
assertEquals(item.url, binding.historyLayout.urlView.text)
}
}

@ -4,16 +4,12 @@
package org.mozilla.fenix.library.recentlyclosed
import android.content.ClipData
import android.content.ClipboardManager
import android.content.res.Resources
import androidx.navigation.NavController
import androidx.navigation.NavOptions
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestCoroutineDispatcher
@ -30,25 +26,19 @@ import org.junit.runner.RunWith
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.ext.directionsEq
import org.mozilla.fenix.ext.optionsEq
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
// Robolectric needed for `onShareItem()`
@ExperimentalCoroutinesApi
@RunWith(FenixRobolectricTestRunner::class)
class DefaultRecentlyClosedControllerTest {
private val dispatcher = TestCoroutineDispatcher()
private val navController: NavController = mockk(relaxed = true)
private val resources: Resources = mockk(relaxed = true)
private val snackbar: FenixSnackbar = mockk(relaxed = true)
private val clipboardManager: ClipboardManager = mockk(relaxed = true)
private val activity: HomeActivity = mockk(relaxed = true)
private val browserStore: BrowserStore = mockk(relaxed = true)
private val recentlyClosedStore: RecentlyClosedFragmentStore = mockk(relaxed = true)
private val tabsUseCases: TabsUseCases = mockk(relaxed = true)
val mockedTab: RecoverableTab = mockk(relaxed = true)
@Before
fun setUp() {
@ -177,42 +167,6 @@ class DefaultRecentlyClosedControllerTest {
}
}
@Test
fun handleCopyUrl() {
val item = RecoverableTab(id = "tab-id", title = "Mozilla", url = "mozilla.org", lastAccess = 1L)
val clipdata = slot<ClipData>()
createController().handleCopyUrl(item)
verify {
clipboardManager.setPrimaryClip(capture(clipdata))
snackbar.show()
}
assertEquals(1, clipdata.captured.itemCount)
assertEquals("mozilla.org", clipdata.captured.description.label)
assertEquals("mozilla.org", clipdata.captured.getItemAt(0).text)
}
@Test
@Suppress("UNCHECKED_CAST")
fun handleShare() {
val item = RecoverableTab(id = "tab-id", title = "Mozilla", url = "mozilla.org", lastAccess = 1L)
createController().handleShare(item)
verify {
navController.navigate(
directionsEq(
RecentlyClosedFragmentDirections.actionGlobalShareFragment(
data = arrayOf(ShareData(url = item.url, title = item.title))
)
)
)
}
}
@Test
fun `share multiple tabs`() {
val tabs = createFakeTabList(2)
@ -232,11 +186,13 @@ class DefaultRecentlyClosedControllerTest {
@Test
fun handleRestore() {
createController().handleRestore(mockedTab)
val item: RecoverableTab = mockk(relaxed = true)
createController().handleRestore(item)
dispatcher.advanceUntilIdle()
verify { tabsUseCases.restore.invoke(mockedTab, true) }
verify { tabsUseCases.restore.invoke(item, true) }
}
@Test
@ -256,9 +212,6 @@ class DefaultRecentlyClosedControllerTest {
browserStore,
recentlyClosedStore,
tabsUseCases,
resources,
snackbar,
clipboardManager,
activity,
openToBrowser
)

@ -9,7 +9,6 @@ import io.mockk.verify
import mozilla.components.browser.state.state.recover.RecoverableTab
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
class RecentlyClosedFragmentInteractorTest {
@ -25,56 +24,6 @@ class RecentlyClosedFragmentInteractorTest {
)
}
@Test
fun open() {
val tab = RecoverableTab(id = "tab-id", title = "Mozilla", url = "mozilla.org", lastAccess = 1L)
interactor.restore(tab)
verify {
defaultRecentlyClosedController.handleRestore(tab)
}
}
@Test
fun onCopyPressed() {
val tab = RecoverableTab(id = "tab-id", title = "Mozilla", url = "mozilla.org", lastAccess = 1L)
interactor.onCopyPressed(tab)
verify {
defaultRecentlyClosedController.handleCopyUrl(tab)
}
}
@Test
fun onSharePressed() {
val tab = RecoverableTab(id = "tab-id", title = "Mozilla", url = "mozilla.org", lastAccess = 1L)
interactor.onSharePressed(tab)
verify {
defaultRecentlyClosedController.handleShare(tab)
}
}
@Test
fun onOpenInNormalTab() {
val tab = RecoverableTab(id = "tab-id", title = "Mozilla", url = "mozilla.org", lastAccess = 1L)
interactor.onOpenInNormalTab(tab)
verify {
defaultRecentlyClosedController.handleOpen(tab, mode = BrowsingMode.Normal)
}
}
@Test
fun onOpenInPrivateTab() {
val tab = RecoverableTab(id = "tab-id", title = "Mozilla", url = "mozilla.org", lastAccess = 1L)
interactor.onOpenInPrivateTab(tab)
verify {
defaultRecentlyClosedController.handleOpen(tab, mode = BrowsingMode.Private)
}
}
@Test
fun onDelete() {
val tab = RecoverableTab(id = "tab-id", title = "Mozilla", url = "mozilla.org", lastAccess = 1L)

Loading…
Cancel
Save