Close #25954: add a new delete time range confirmation dialog for history screen

This commit is contained in:
mike a 2022-07-20 07:54:36 -07:00 committed by mergify[bot]
parent bfd0eb7306
commit 7982c6b79f
15 changed files with 460 additions and 112 deletions

View File

@ -71,7 +71,7 @@ class HistoryRobot {
deleteButton(item).click() deleteButton(item).click()
} }
fun clickDeleteAllHistoryButton() = deleteAllButton().click() fun clickDeleteAllHistoryButton() = deleteButton().click()
fun confirmDeleteAllHistory() { fun confirmDeleteAllHistory() {
onView(withText("Delete")) onView(withText("Delete"))
@ -104,7 +104,7 @@ private fun pageUrl() = onView(withId(R.id.url))
private fun deleteButton(title: String) = private fun deleteButton(title: String) =
onView(allOf(withId(R.id.overflow_menu), hasSibling(withText(title)))) onView(allOf(withId(R.id.overflow_menu), hasSibling(withText(title))))
private fun deleteAllButton() = onView(withId(R.id.history_delete_all)) private fun deleteButton() = onView(withId(R.id.history_delete))
private fun snackBarText() = onView(withId(R.id.snackbar_text)) private fun snackBarText() = onView(withId(R.id.snackbar_text))
@ -147,7 +147,7 @@ private fun assertPageUrl(expectedUrl: Uri) = pageUrl()
.check(matches(withText(Matchers.containsString(expectedUrl.toString())))) .check(matches(withText(Matchers.containsString(expectedUrl.toString()))))
private fun assertDeleteConfirmationMessage() = private fun assertDeleteConfirmationMessage() =
onView(withText("This will delete all of your browsing data.")) onView(withText("Removes history (including history synced from other devices), cookies and other browsing data."))
.inRoot(isDialog()) .inRoot(isDialog())
.check(matches(isDisplayed())) .check(matches(isDisplayed()))

View File

@ -10,7 +10,11 @@ import androidx.navigation.NavOptions
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.HistoryMetadataAction import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.action.RecentlyClosedAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
import mozilla.components.service.glean.private.NoExtras import mozilla.components.service.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.Events import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.R import org.mozilla.fenix.R
@ -19,6 +23,7 @@ import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.navigateSafe import org.mozilla.fenix.ext.navigateSafe
import org.mozilla.fenix.library.history.HistoryFragment.DeleteConfirmationDialogFragment
import org.mozilla.fenix.utils.Settings import org.mozilla.fenix.utils.Settings
import org.mozilla.fenix.GleanMetrics.History as GleanHistory import org.mozilla.fenix.GleanMetrics.History as GleanHistory
@ -30,10 +35,22 @@ interface HistoryController {
fun handleBackPressed(): Boolean fun handleBackPressed(): Boolean
fun handleModeSwitched() fun handleModeSwitched()
fun handleSearch() fun handleSearch()
fun handleDeleteAll()
/**
* Displays a [DeleteConfirmationDialogFragment].
*/
fun handleDeleteTimeRange()
fun handleDeleteSome(items: Set<History>) fun handleDeleteSome(items: Set<History>)
/**
* Deletes history items inside the time frame.
*
* @param timeFrame Selected time frame by the user. If `null`, removes all history.
*/
fun handleDeleteTimeRangeConfirmed(timeFrame: RemoveTimeFrame?)
fun handleRequestSync() fun handleRequestSync()
fun handleEnterRecentlyClosed() fun handleEnterRecentlyClosed()
/** /**
* Navigates to [org.mozilla.fenix.library.syncedhistory.SyncedHistoryFragment] * Navigates to [org.mozilla.fenix.library.syncedhistory.SyncedHistoryFragment]
*/ */
@ -44,11 +61,14 @@ interface HistoryController {
class DefaultHistoryController( class DefaultHistoryController(
private val store: HistoryFragmentStore, private val store: HistoryFragmentStore,
private val appStore: AppStore, private val appStore: AppStore,
private val browserStore: BrowserStore,
private val historyStorage: PlacesHistoryStorage,
private var historyProvider: DefaultPagedHistoryProvider, private var historyProvider: DefaultPagedHistoryProvider,
private val navController: NavController, private val navController: NavController,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val openToBrowser: (item: History.Regular) -> Unit, private val openToBrowser: (item: History.Regular) -> Unit,
private val displayDeleteAll: () -> Unit, private val displayDeleteTimeRange: () -> Unit,
private val onTimeFrameDeleted: () -> Unit,
private val invalidateOptionsMenu: () -> Unit, private val invalidateOptionsMenu: () -> Unit,
private val deleteSnackbar: ( private val deleteSnackbar: (
items: Set<History>, items: Set<History>,
@ -111,8 +131,8 @@ class DefaultHistoryController(
navController.navigateSafe(R.id.historyFragment, directions) navController.navigateSafe(R.id.historyFragment, directions)
} }
override fun handleDeleteAll() { override fun handleDeleteTimeRange() {
displayDeleteAll.invoke() displayDeleteTimeRange.invoke()
} }
override fun handleDeleteSome(items: Set<History>) { override fun handleDeleteSome(items: Set<History>) {
@ -121,6 +141,31 @@ class DefaultHistoryController(
deleteSnackbar.invoke(items, ::undo, ::delete) deleteSnackbar.invoke(items, ::undo, ::delete)
} }
override fun handleDeleteTimeRangeConfirmed(timeFrame: RemoveTimeFrame?) {
scope.launch {
store.dispatch(HistoryFragmentAction.EnterDeletionMode)
if (timeFrame == null) {
GleanHistory.removedAll.record(mozilla.telemetry.glean.private.NoExtras())
historyStorage.deleteEverything()
} else {
historyStorage.deleteVisitsBetween(
startTime = timeFrame.toLongRange().first,
endTime = timeFrame.toLongRange().last,
)
}
// We introduced more deleting options, but are keeping these actions for all options.
// The approach could be improved: https://github.com/mozilla-mobile/fenix/issues/26102
browserStore.dispatch(RecentlyClosedAction.RemoveAllClosedTabAction)
browserStore.dispatch(EngineAction.PurgeHistoryAction).join()
store.dispatch(HistoryFragmentAction.ExitDeletionMode)
launch(Dispatchers.Main) {
onTimeFrameDeleted.invoke()
}
}
}
private fun undo(items: Set<History>) { private fun undo(items: Set<History>) {
val pendingDeletionItems = items.map { it.toPendingDeletionHistory() }.toSet() val pendingDeletionItems = items.map { it.toPendingDeletionHistory() }.toSet()
appStore.dispatch(AppAction.UndoPendingDeletionSet(pendingDeletionItems)) appStore.dispatch(AppAction.UndoPendingDeletionSet(pendingDeletionItems))

View File

@ -4,6 +4,7 @@
package org.mozilla.fenix.library.history package org.mozilla.fenix.library.history
import android.app.Dialog
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
@ -14,7 +15,9 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.RadioGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@ -23,14 +26,9 @@ import androidx.paging.PagingConfig
import androidx.paging.PagingData import androidx.paging.PagingData
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.RecentlyClosedAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.lib.state.ext.flowScoped import mozilla.components.lib.state.ext.flowScoped
@ -48,11 +46,12 @@ import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider
import org.mozilla.fenix.databinding.FragmentHistoryBinding import org.mozilla.fenix.databinding.FragmentHistoryBinding
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.setTextColor import org.mozilla.fenix.ext.setTextColor
import org.mozilla.fenix.ext.toShortUrl import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.ext.getRootView
import org.mozilla.fenix.library.LibraryPageFragment import org.mozilla.fenix.library.LibraryPageFragment
import org.mozilla.fenix.utils.allowUndo import org.mozilla.fenix.utils.allowUndo
import org.mozilla.fenix.GleanMetrics.History as GleanHistory import org.mozilla.fenix.GleanMetrics.History as GleanHistory
@ -100,13 +99,16 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
val historyController: HistoryController = DefaultHistoryController( val historyController: HistoryController = DefaultHistoryController(
store = historyStore, store = historyStore,
appStore = requireContext().components.appStore, appStore = requireContext().components.appStore,
browserStore = requireComponents.core.store,
historyStorage = requireComponents.core.historyStorage,
historyProvider = historyProvider, historyProvider = historyProvider,
navController = findNavController(), navController = findNavController(),
scope = lifecycleScope, scope = lifecycleScope,
openToBrowser = ::openItem, openToBrowser = ::openItem,
displayDeleteAll = ::displayDeleteAllDialog, displayDeleteTimeRange = ::displayDeleteTimeRange,
invalidateOptionsMenu = ::invalidateOptionsMenu, invalidateOptionsMenu = ::invalidateOptionsMenu,
deleteSnackbar = :: deleteSnackbar, deleteSnackbar = ::deleteSnackbar,
onTimeFrameDeleted = ::onTimeFrameDeleted,
syncHistory = ::syncHistory, syncHistory = ::syncHistory,
settings = requireContext().components.settings, settings = requireContext().components.settings,
) )
@ -173,6 +175,16 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
) )
} }
private fun onTimeFrameDeleted() {
runIfFragmentIsAttached {
historyView.historyAdapter.refresh()
showSnackBar(
binding.root,
getString(R.string.preferences_delete_browsing_data_snackbar)
)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -280,8 +292,8 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
historyInteractor.onSearch() historyInteractor.onSearch()
true true
} }
R.id.history_delete_all -> { R.id.history_delete -> {
historyInteractor.onDeleteAll() historyInteractor.onDeleteTimeRange()
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
@ -331,40 +343,10 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
) )
} }
private fun displayDeleteAllDialog() { private fun displayDeleteTimeRange() {
activity?.let { activity -> DeleteConfirmationDialogFragment(
AlertDialog.Builder(activity).apply { historyInteractor = historyInteractor
setMessage(R.string.delete_browsing_data_prompt_message) ).show(childFragmentManager, null)
setNegativeButton(R.string.delete_browsing_data_prompt_cancel) { dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.delete_browsing_data_prompt_allow) { dialog: DialogInterface, _ ->
historyStore.dispatch(HistoryFragmentAction.EnterDeletionMode)
// Use fragment's lifecycle; the view may be gone by the time dialog is interacted with.
lifecycleScope.launch(IO) {
GleanHistory.removedAll.record(NoExtras())
requireComponents.core.store.dispatch(RecentlyClosedAction.RemoveAllClosedTabAction)
requireComponents.core.historyStorage.deleteEverything()
deleteOpenTabsEngineHistory(requireComponents.core.store)
launch(Main) {
historyView.historyAdapter.refresh()
historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode)
showSnackBar(
requireView(),
getString(R.string.preferences_delete_browsing_data_snackbar)
)
}
}
dialog.dismiss()
}
create()
}.show()
}
}
private suspend fun deleteOpenTabsEngineHistory(store: BrowserStore) {
store.dispatch(EngineAction.PurgeHistoryAction).join()
} }
private fun share(data: List<ShareData>) { private fun share(data: List<ShareData>) {
@ -389,6 +371,33 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
historyView.historyAdapter.refresh() historyView.historyAdapter.refresh()
} }
internal class DeleteConfirmationDialogFragment(
private val historyInteractor: HistoryInteractor
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
AlertDialog.Builder(requireContext()).apply {
val layout = LayoutInflater.from(context)
.inflate(R.layout.delete_history_time_range_dialog, null)
val radioGroup = layout.findViewById<RadioGroup>(R.id.radio_group)
radioGroup.check(R.id.last_hour_button)
setView(layout)
setNegativeButton(R.string.delete_browsing_data_prompt_cancel) { dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.delete_browsing_data_prompt_allow) { dialog: DialogInterface, _ ->
val selectedTimeFrame = when (radioGroup.checkedRadioButtonId) {
R.id.last_hour_button -> RemoveTimeFrame.LastHour
R.id.today_and_yesterday_button -> RemoveTimeFrame.TodayAndYesterday
R.id.everything_button -> null
else -> throw IllegalStateException("Unexpected radioButtonId")
}
historyInteractor.onDeleteTimeRangeConfirmed(selectedTimeFrame)
dialog.dismiss()
}
}.create()
}
@Suppress("UnusedPrivateMember") @Suppress("UnusedPrivateMember")
companion object { companion object {
private const val PAGE_SIZE = 25 private const val PAGE_SIZE = 25

View File

@ -28,9 +28,9 @@ interface HistoryInteractor : SelectionInteractor<History> {
fun onSearch() fun onSearch()
/** /**
* Called when delete all is tapped * Called when the delete menu button is tapped.
*/ */
fun onDeleteAll() fun onDeleteTimeRange()
/** /**
* Called when multiple history items are deleted * Called when multiple history items are deleted
@ -38,6 +38,14 @@ interface HistoryInteractor : SelectionInteractor<History> {
*/ */
fun onDeleteSome(items: Set<History>) fun onDeleteSome(items: Set<History>)
/**
* Called when the user has confirmed deletion of a time range.
*
* @param timeFrame The selected timeframe. `null` means no specific time frame has been
* selected; should remove everything.
*/
fun onDeleteTimeRangeConfirmed(timeFrame: RemoveTimeFrame?)
/** /**
* Called when the user requests a sync of the history * Called when the user requests a sync of the history
*/ */
@ -86,14 +94,18 @@ class DefaultHistoryInteractor(
historyController.handleSearch() historyController.handleSearch()
} }
override fun onDeleteAll() { override fun onDeleteTimeRange() {
historyController.handleDeleteAll() historyController.handleDeleteTimeRange()
} }
override fun onDeleteSome(items: Set<History>) { override fun onDeleteSome(items: Set<History>) {
historyController.handleDeleteSome(items) historyController.handleDeleteSome(items)
} }
override fun onDeleteTimeRangeConfirmed(timeFrame: RemoveTimeFrame?) {
historyController.handleDeleteTimeRangeConfirmed(timeFrame)
}
override fun onRequestSync() { override fun onRequestSync() {
historyController.handleRequestSync() historyController.handleRequestSync()
} }

View File

@ -0,0 +1,44 @@
/* 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 java.util.Calendar
import java.util.Date
/**
* A helper class that provides starting and ending timestamps for a set time frame. Is used by
* [HistoryFragment] to provide timestamps for options inside the delete history dialog.
*/
enum class RemoveTimeFrame {
LastHour,
TodayAndYesterday;
/**
* Provides starting and ending timestamps for a set time frame. Each call is calculated at the
* moment of execution, which is different from [HistoryItemTimeGroup] implementation.
*/
fun toLongRange(): LongRange {
return when (this) {
LastHour -> LongRange(getHourAgo(hoursAgo = 1).time, Long.MAX_VALUE)
TodayAndYesterday -> LongRange(getDaysAgo(daysAgo = 1).time, Long.MAX_VALUE)
}
}
private fun getHourAgo(hoursAgo: Int): Date {
return Calendar.getInstance().apply {
add(Calendar.HOUR_OF_DAY, -hoursAgo)
}.time
}
private fun getDaysAgo(daysAgo: Int): Date {
return Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
add(Calendar.DAY_OF_YEAR, -daysAgo)
}.time
}
}

View File

@ -98,6 +98,7 @@ class HistoryMetadataGroupFragment :
store = historyMetadataGroupStore, store = historyMetadataGroupStore,
selectOrAddUseCase = requireComponents.useCases.tabsUseCases.selectOrAddTab, selectOrAddUseCase = requireComponents.useCases.tabsUseCases.selectOrAddTab,
navController = findNavController(), navController = findNavController(),
scope = CoroutineScope(Dispatchers.IO),
searchTerm = args.title, searchTerm = args.title,
deleteSnackbar = :: deleteSnackbar, deleteSnackbar = :: deleteSnackbar,
promptDeleteAll = :: promptDeleteAll, promptDeleteAll = :: promptDeleteAll,
@ -194,8 +195,8 @@ class HistoryMetadataGroupFragment :
showTabTray() showTabTray()
true true
} }
R.id.history_delete_all -> { R.id.history_delete -> {
interactor.onDeleteAllMenuItem() interactor.onDeleteAll()
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
@ -218,14 +219,14 @@ class HistoryMetadataGroupFragment :
) )
} }
private fun promptDeleteAll(delete: () -> Unit) { private fun promptDeleteAll() {
if (childFragmentManager.findFragmentByTag(DeleteAllConfirmationDialogFragment.TAG) if (childFragmentManager.findFragmentByTag(DeleteAllConfirmationDialogFragment.TAG)
as? DeleteAllConfirmationDialogFragment != null as? DeleteAllConfirmationDialogFragment != null
) { ) {
return return
} }
DeleteAllConfirmationDialogFragment(delete).show( DeleteAllConfirmationDialogFragment(interactor, args.title).show(
childFragmentManager, DeleteAllConfirmationDialogFragment.TAG childFragmentManager, DeleteAllConfirmationDialogFragment.TAG
) )
} }
@ -254,15 +255,23 @@ class HistoryMetadataGroupFragment :
) )
} }
internal class DeleteAllConfirmationDialogFragment(private val delete: () -> Unit) : DialogFragment() { internal class DeleteAllConfirmationDialogFragment(
private val interactor: HistoryMetadataGroupInteractor,
private val groupName: String
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setMessage(R.string.delete_history_group_prompt_message) .setMessage(
String.format(
getString(R.string.delete_all_history_group_prompt_message),
groupName
)
)
.setNegativeButton(R.string.delete_history_group_prompt_cancel) { dialog: DialogInterface, _ -> .setNegativeButton(R.string.delete_history_group_prompt_cancel) { dialog: DialogInterface, _ ->
dialog.cancel() dialog.cancel()
} }
.setPositiveButton(R.string.delete_history_group_prompt_allow) { dialog: DialogInterface, _ -> .setPositiveButton(R.string.delete_history_group_prompt_allow) { dialog: DialogInterface, _ ->
delete.invoke() interactor.onDeleteAllConfirmed()
dialog.dismiss() dialog.dismiss()
} }
.create() .create()

View File

@ -7,7 +7,6 @@ package org.mozilla.fenix.library.historymetadata.controller
import android.content.Context import android.content.Context
import androidx.navigation.NavController import androidx.navigation.NavController
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.state.action.HistoryMetadataAction import mozilla.components.browser.state.action.HistoryMetadataAction
@ -22,6 +21,7 @@ import org.mozilla.fenix.ext.components
import mozilla.components.service.glean.private.NoExtras import mozilla.components.service.glean.private.NoExtras
import org.mozilla.fenix.library.history.History import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.history.toPendingDeletionHistory import org.mozilla.fenix.library.history.toPendingDeletionHistory
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragment.DeleteAllConfirmationDialogFragment
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentAction import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentAction
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentStore import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentStore
@ -74,9 +74,14 @@ interface HistoryMetadataGroupController {
fun handleDelete(items: Set<History.Metadata>) fun handleDelete(items: Set<History.Metadata>)
/** /**
* Deletes all the history metadata items in this group. * Displays a [DeleteAllConfirmationDialogFragment] prompt.
*/ */
fun handleDeleteAll() fun handleDeleteAll()
/**
* Deletes history metadata items in this group.
*/
fun handleDeleteAllConfirmed()
} }
/** /**
@ -90,13 +95,14 @@ class DefaultHistoryMetadataGroupController(
private val store: HistoryMetadataGroupFragmentStore, private val store: HistoryMetadataGroupFragmentStore,
private val selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase, private val selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase,
private val navController: NavController, private val navController: NavController,
private val scope: CoroutineScope,
private val searchTerm: String, private val searchTerm: String,
private val deleteSnackbar: ( private val deleteSnackbar: (
items: Set<History.Metadata>, items: Set<History.Metadata>,
undo: suspend (Set<History.Metadata>) -> Unit, undo: suspend (Set<History.Metadata>) -> Unit,
delete: (Set<History.Metadata>) -> suspend (context: Context) -> Unit delete: (Set<History.Metadata>) -> suspend (context: Context) -> Unit
) -> Unit, ) -> Unit,
private val promptDeleteAll: (() -> Unit) -> Unit, private val promptDeleteAll: () -> Unit,
private val allDeletedSnackbar: () -> Unit private val allDeletedSnackbar: () -> Unit
) : HistoryMetadataGroupController { ) : HistoryMetadataGroupController {
@ -144,7 +150,7 @@ class DefaultHistoryMetadataGroupController(
private fun delete(items: Set<History.Metadata>): suspend (context: Context) -> Unit { private fun delete(items: Set<History.Metadata>): suspend (context: Context) -> Unit {
return { context -> return { context ->
CoroutineScope(IO).launch { scope.launch {
val isDeletingLastItem = items.containsAll(store.state.items) val isDeletingLastItem = items.containsAll(store.state.items)
items.forEach { items.forEach {
store.dispatch(HistoryMetadataGroupFragmentAction.Delete(it)) store.dispatch(HistoryMetadataGroupFragmentAction.Delete(it))
@ -163,11 +169,11 @@ class DefaultHistoryMetadataGroupController(
} }
override fun handleDeleteAll() { override fun handleDeleteAll() {
promptDeleteAll.invoke(::deleteAll) promptDeleteAll.invoke()
} }
private fun deleteAll() { override fun handleDeleteAllConfirmed() {
CoroutineScope(IO).launch { scope.launch {
store.dispatch(HistoryMetadataGroupFragmentAction.DeleteAll) store.dispatch(HistoryMetadataGroupFragmentAction.DeleteAll)
store.state.items.forEach { store.state.items.forEach {
historyStorage.deleteVisitsFor(it.url) historyStorage.deleteVisitsFor(it.url)

View File

@ -29,10 +29,14 @@ interface HistoryMetadataGroupInteractor : SelectionInteractor<History.Metadata>
fun onDelete(items: Set<History.Metadata>) fun onDelete(items: Set<History.Metadata>)
/** /**
* Deletes all the history items in the history metadata group. Called when a user clicks * Called when a user clicks on the "Delete history" menu item.
* on the "Delete history" menu item.
*/ */
fun onDeleteAllMenuItem() fun onDeleteAll()
/**
* Called when a user has confirmed the deletion of the group.
*/
fun onDeleteAllConfirmed()
/** /**
* Opens the share sheet for a set of history [items]. Called when a user clicks on the * Opens the share sheet for a set of history [items]. Called when a user clicks on the
@ -70,10 +74,14 @@ class DefaultHistoryMetadataGroupInteractor(
controller.handleDelete(items) controller.handleDelete(items)
} }
override fun onDeleteAllMenuItem() { override fun onDeleteAll() {
controller.handleDeleteAll() controller.handleDeleteAll()
} }
override fun onDeleteAllConfirmed() {
controller.handleDeleteAllConfirmed()
}
override fun onShareMenuItem(items: Set<History.Metadata>) { override fun onShareMenuItem(items: Set<History.Metadata>) {
controller.handleShare(items) controller.handleShare(items)
} }

View File

@ -0,0 +1,91 @@
<?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"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:ellipsize="end"
android:text="@string/delete_history_prompt_title"
android:textColor="?attr/textPrimary"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="24dp"
android:text="@string/delete_history_prompt_body"
android:textColor="?attr/textPrimary"
android:textSize="14sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/title" />
<RadioGroup
android:id="@+id/radio_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/body">
<RadioButton
android:id="@+id/last_hour_button"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackground"
android:ellipsize="end"
android:buttonTint="?attr/textSecondary"
android:layout_marginStart="18dp"
android:paddingStart="32dp"
android:paddingEnd="8dp"
android:text="@string/delete_history_prompt_button_last_hour"
android:textColor="?attr/textPrimary"
android:textSize="16sp" />
<RadioButton
android:id="@+id/today_and_yesterday_button"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackground"
android:ellipsize="end"
android:buttonTint="?attr/textSecondary"
android:layout_marginStart="18dp"
android:paddingStart="32dp"
android:paddingEnd="8dp"
android:text="@string/delete_history_prompt_button_today_and_yesterday"
android:textColor="?attr/textPrimary"
android:textSize="16sp" />
<RadioButton
android:id="@+id/everything_button"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackground"
android:ellipsize="end"
android:buttonTint="?attr/textSecondary"
android:layout_marginStart="18dp"
android:paddingStart="32dp"
android:paddingEnd="8dp"
android:text="@string/delete_history_prompt_button_everything"
android:textColor="?attr/textPrimary"
android:textSize="16sp" />
</RadioGroup>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -11,7 +11,7 @@
app:iconTint="?attr/textPrimary" app:iconTint="?attr/textPrimary"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item <item
android:id="@+id/history_delete_all" android:id="@+id/history_delete"
android:icon="@drawable/ic_delete" android:icon="@drawable/ic_delete"
android:title="@string/history_delete_all" android:title="@string/history_delete_all"
app:iconTint="?attr/textPrimary" app:iconTint="?attr/textPrimary"

View File

@ -1118,7 +1118,18 @@
<string name="delete_browsing_data_on_quit_action">Quit</string> <string name="delete_browsing_data_on_quit_action">Quit</string>
<!-- Dialog message to the user asking to delete browsing data. --> <!-- Dialog message to the user asking to delete browsing data. -->
<string name="delete_browsing_data_prompt_message">This will delete all of your browsing data.</string> <string moz:removedIn="105" name="delete_browsing_data_prompt_message" tools:ignore="UnusedResources">This will delete all of your browsing data.</string>
<!-- Title text of a delete browsing data dialog. -->
<string name="delete_history_prompt_title">Time range to delete</string>
<!-- Body text of a delete browsing data dialog. -->
<string name="delete_history_prompt_body">Removes history (including history synced from other devices), cookies and other browsing data.</string>
<!-- Radio button of a delete browsing data dialog, to delete history items for the last hour. -->
<string name="delete_history_prompt_button_last_hour">Last hour</string>
<!-- Radio button of a delete browsing data dialog, to delete history items for today and yesterday. -->
<string name="delete_history_prompt_button_today_and_yesterday">Today and yesterday</string>
<!-- Radio button of a delete browsing data dialog, to delete all history. -->
<string name="delete_history_prompt_button_everything">Everything</string>
<!-- Dialog message to the user asking to delete browsing data. Parameter will be replaced by app name. --> <!-- Dialog message to the user asking to delete browsing data. Parameter will be replaced by app name. -->
<string name="delete_browsing_data_prompt_message_3">%s will delete the selected browsing data.</string> <string name="delete_browsing_data_prompt_message_3">%s will delete the selected browsing data.</string>
<!-- Text for the cancel button for the data deletion dialog --> <!-- Text for the cancel button for the data deletion dialog -->
@ -1131,7 +1142,7 @@
<string name="deleting_browsing_data_in_progress">Deleting browsing data&#8230;</string> <string name="deleting_browsing_data_in_progress">Deleting browsing data&#8230;</string>
<!-- Dialog message to the user asking to delete all history items inside the opened group. --> <!-- Dialog message to the user asking to delete all history items inside the opened group. -->
<string name="delete_history_group_prompt_message">This will delete all items.</string> <string name="delete_all_history_group_prompt_message">Delete all sites in \"%s\"</string>
<!-- Text for the cancel button for the history group deletion dialog --> <!-- Text for the cancel button for the history group deletion dialog -->
<string name="delete_history_group_prompt_cancel">Cancel</string> <string name="delete_history_group_prompt_cancel">Cancel</string>
<!-- Text for the allow button for the history group dialog --> <!-- Text for the allow button for the history group dialog -->

View File

@ -9,6 +9,9 @@ import io.mockk.coVerifyOrder
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import mozilla.components.browser.state.action.RecentlyClosedAction
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.storage.sync.PlacesHistoryStorage
import mozilla.components.support.test.rule.MainCoroutineRule import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@ -40,6 +43,8 @@ class HistoryControllerTest {
private val store: HistoryFragmentStore = mockk(relaxed = true) private val store: HistoryFragmentStore = mockk(relaxed = true)
private val appStore: AppStore = mockk(relaxed = true) private val appStore: AppStore = mockk(relaxed = true)
private val browserStore: BrowserStore = mockk(relaxed = true)
private val historyStorage: PlacesHistoryStorage = mockk(relaxed = true)
private val state: HistoryFragmentState = mockk(relaxed = true) private val state: HistoryFragmentState = mockk(relaxed = true)
private val navController: NavController = mockk(relaxed = true) private val navController: NavController = mockk(relaxed = true)
private val historyProvider: DefaultPagedHistoryProvider = mockk(relaxed = true) private val historyProvider: DefaultPagedHistoryProvider = mockk(relaxed = true)
@ -139,16 +144,29 @@ class HistoryControllerTest {
} }
@Test @Test
fun onDeleteAll() { fun onDeleteTimeRange() {
var displayDeleteAllInvoked = false var displayDeleteTimeRangeInvoked = false
val controller = createController( val controller = createController(
displayDeleteAll = { displayDeleteTimeRange = {
displayDeleteAllInvoked = true displayDeleteTimeRangeInvoked = true
} }
) )
controller.handleDeleteAll() controller.handleDeleteTimeRange()
assertTrue(displayDeleteAllInvoked) assertTrue(displayDeleteTimeRangeInvoked)
}
@Test
fun onDeleteTimeRangeConfirmed() {
val controller = createController()
controller.handleDeleteTimeRangeConfirmed(null)
coVerifyOrder {
store.dispatch(HistoryFragmentAction.EnterDeletionMode)
historyStorage.deleteEverything()
browserStore.dispatch(RecentlyClosedAction.RemoveAllClosedTabAction)
store.dispatch(HistoryFragmentAction.ExitDeletionMode)
}
} }
@Test @Test
@ -185,7 +203,8 @@ class HistoryControllerTest {
@Suppress("LongParameterList") @Suppress("LongParameterList")
private fun createController( private fun createController(
openInBrowser: (History) -> Unit = { _ -> }, openInBrowser: (History) -> Unit = { _ -> },
displayDeleteAll: () -> Unit = {}, displayDeleteTimeRange: () -> Unit = {},
onTimeFrameDeleted: () -> Unit = {},
invalidateOptionsMenu: () -> Unit = {}, invalidateOptionsMenu: () -> Unit = {},
deleteHistoryItems: (Set<History>) -> Unit = { _ -> }, deleteHistoryItems: (Set<History>) -> Unit = { _ -> },
syncHistory: suspend () -> Unit = {} syncHistory: suspend () -> Unit = {}
@ -193,11 +212,14 @@ class HistoryControllerTest {
return DefaultHistoryController( return DefaultHistoryController(
store, store,
appStore, appStore,
browserStore,
historyStorage,
historyProvider, historyProvider,
navController, navController,
scope, scope,
openInBrowser, openInBrowser,
displayDeleteAll, displayDeleteTimeRange,
onTimeFrameDeleted,
invalidateOptionsMenu, invalidateOptionsMenu,
{ items, _, _ -> deleteHistoryItems.invoke(items) }, { items, _, _ -> deleteHistoryItems.invoke(items) },
syncHistory, syncHistory,

View File

@ -75,11 +75,20 @@ class HistoryInteractorTest {
} }
@Test @Test
fun onDeleteAll() { fun onDeleteTimeRange() {
interactor.onDeleteAll() interactor.onDeleteTimeRange()
verifyAll { verifyAll {
controller.handleDeleteAll() controller.handleDeleteTimeRange()
}
}
@Test
fun onDeleteTimeRangeConfirmed() {
interactor.onDeleteTimeRangeConfirmed(null)
verifyAll {
controller.handleDeleteTimeRangeConfirmed(null)
} }
} }

View File

@ -0,0 +1,58 @@
/* 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 org.junit.Assert
import org.junit.Test
import java.util.Calendar
import java.util.concurrent.TimeUnit
class RemoveTimeFrameTest {
@Test
fun `WHEN LastHour is calculated THEN first timeStamp is one hour ago`() {
val lastHourRange = RemoveTimeFrame.LastHour.toLongRange()
val nowMillis = Calendar.getInstance().timeInMillis
val millisDif = nowMillis - lastHourRange.first
val hourDif = TimeUnit.HOURS.convert(millisDif, TimeUnit.MILLISECONDS)
Assert.assertEquals(1, hourDif)
}
@Test
fun `WHEN LastHour is calculated THEN second timeStamp is equal or greater than now`() {
val lastHourRange = RemoveTimeFrame.LastHour.toLongRange()
val nowMillis = Calendar.getInstance().timeInMillis
Assert.assertTrue(nowMillis <= lastHourRange.last)
}
@Test
fun `WHEN TodayAndYesterday is calculated THEN first timeStamp is one day ago`() {
val lastHourRange = RemoveTimeFrame.TodayAndYesterday.toLongRange()
val nowMillis = Calendar.getInstance().timeInMillis
val millisDif = nowMillis - lastHourRange.first
val daysDif = TimeUnit.DAYS.convert(millisDif, TimeUnit.MILLISECONDS)
Assert.assertEquals(1, daysDif)
}
@Test
fun `WHEN TodayAndYesterday is calculated THEN first timeStamp is the start of the previous day`() {
val todayAndYesterdayRange = RemoveTimeFrame.TodayAndYesterday.toLongRange()
val yesterdayStartMillis = Calendar.getInstance().apply {
add(Calendar.DAY_OF_YEAR, -1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
Assert.assertEquals(yesterdayStartMillis, todayAndYesterdayRange.first)
}
@Test
fun `WHEN TodayAndYesterday is calculated THEN second timeStamp is equal or greater than now`() {
val lastHourRange = RemoveTimeFrame.TodayAndYesterday.toLongRange()
val nowMillis = Calendar.getInstance().timeInMillis
Assert.assertTrue(nowMillis <= lastHourRange.last)
}
}

View File

@ -23,11 +23,10 @@ import mozilla.components.support.test.rule.MainCoroutineRule
import mozilla.components.support.test.rule.runTestOnMain import mozilla.components.support.test.rule.runTestOnMain
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Ignore
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -90,23 +89,7 @@ class HistoryMetadataGroupControllerTest {
@Before @Before
fun setUp() { fun setUp() {
controller = DefaultHistoryMetadataGroupController( controller = createController()
historyStorage = historyStorage,
browserStore = browserStore,
appStore = appStore,
store = store,
selectOrAddUseCase = selectOrAddUseCase,
navController = navController,
searchTerm = "mozilla",
deleteSnackbar = { items, _, delete ->
scope.launch {
delete(items).invoke(context)
}
},
promptDeleteAll = { deleteAll -> deleteAll.invoke() },
allDeletedSnackbar = {}
)
every { activity.components.core.historyStorage } returns historyStorage every { activity.components.core.historyStorage } returns historyStorage
every { context.components.core.store } returns browserStore every { context.components.core.store } returns browserStore
every { context.components.core.historyStorage } returns historyStorage every { context.components.core.historyStorage } returns historyStorage
@ -189,7 +172,6 @@ class HistoryMetadataGroupControllerTest {
} }
@Test @Test
@Ignore("Intermittent test: https://github.com/mozilla-mobile/fenix/issues/25167")
fun handleDeleteSingle() = runTestOnMain { fun handleDeleteSingle() = runTestOnMain {
assertNull(GleanHistory.searchTermGroupRemoveTab.testGetValue()) assertNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
@ -218,7 +200,6 @@ class HistoryMetadataGroupControllerTest {
} }
@Test @Test
@Ignore("Intermittent test: https://github.com/mozilla-mobile/fenix/issues/25167")
fun handleDeleteMultiple() = runTestOnMain { fun handleDeleteMultiple() = runTestOnMain {
assertNull(GleanHistory.searchTermGroupRemoveTab.testGetValue()) assertNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
controller.handleDelete(getMetadataItemsList().toSet()) controller.handleDelete(getMetadataItemsList().toSet())
@ -244,14 +225,16 @@ class HistoryMetadataGroupControllerTest {
} }
@Test @Test
@Ignore("Intermittent test: https://github.com/mozilla-mobile/fenix/issues/25167")
fun handleDeleteAbnormal() = runTestOnMain { fun handleDeleteAbnormal() = runTestOnMain {
val abnormalList = listOf( val abnormalList = listOf(
mozillaHistoryMetadataItem, mozillaHistoryMetadataItem,
firefoxHistoryMetadataItem, firefoxHistoryMetadataItem,
mozillaHistoryMetadataItem.copy(title = "Pocket", url = "https://getpocket.com"), mozillaHistoryMetadataItem.copy(title = "Pocket", url = "https://getpocket.com"),
mozillaHistoryMetadataItem.copy(title = "BBC", url = "https://www.bbc.com/"), mozillaHistoryMetadataItem.copy(title = "BBC", url = "https://www.bbc.com/"),
mozillaHistoryMetadataItem.copy(title = "Stackoverflow", url = "https://stackoverflow.com/") mozillaHistoryMetadataItem.copy(
title = "Stackoverflow",
url = "https://stackoverflow.com/"
)
) )
assertNull(GleanHistory.searchTermGroupRemoveTab.testGetValue()) assertNull(GleanHistory.searchTermGroupRemoveTab.testGetValue())
@ -290,9 +273,21 @@ class HistoryMetadataGroupControllerTest {
@Test @Test
fun handleDeleteAll() = runTestOnMain { fun handleDeleteAll() = runTestOnMain {
var promptDeleteAllInvoked = false
val controller = createController(
promptDeleteAll = {
promptDeleteAllInvoked = true
}
)
controller.handleDeleteAll()
assertTrue(promptDeleteAllInvoked)
}
@Test
fun handleDeleteAllConfirmed() = runTestOnMain {
assertNull(GleanHistory.searchTermGroupRemoveAll.testGetValue()) assertNull(GleanHistory.searchTermGroupRemoveAll.testGetValue())
controller.handleDeleteAll() controller.handleDeleteAllConfirmed()
coVerify { coVerify {
store.dispatch(HistoryMetadataGroupFragmentAction.DeleteAll) store.dispatch(HistoryMetadataGroupFragmentAction.DeleteAll)
@ -313,4 +308,33 @@ class HistoryMetadataGroupControllerTest {
.single().extra .single().extra
) )
} }
@Suppress("LongParameterList")
private fun createController(
deleteSnackbar: (
items: Set<History.Metadata>,
undo: suspend (Set<History.Metadata>) -> Unit,
delete: (Set<History.Metadata>) -> suspend (context: Context) -> Unit
) -> Unit = { items, _, delete ->
scope.launch {
delete(items).invoke(context)
}
},
promptDeleteAll: () -> Unit = {},
allDeletedSnackbar: () -> Unit = {}
): DefaultHistoryMetadataGroupController {
return DefaultHistoryMetadataGroupController(
historyStorage = historyStorage,
browserStore = browserStore,
appStore = appStore,
store = store,
selectOrAddUseCase = selectOrAddUseCase,
navController = navController,
scope = scope,
searchTerm = searchTerm,
deleteSnackbar = deleteSnackbar,
promptDeleteAll = promptDeleteAll,
allDeletedSnackbar = allDeletedSnackbar
)
}
} }