From 0ae268914bcb25b6a9d736e5a017b680995f0390 Mon Sep 17 00:00:00 2001 From: Kate Glazko Date: Wed, 1 Jul 2020 16:10:44 -0700 Subject: [PATCH] For #352: Delete a download --- .../library/downloads/DownloadAdapter.kt | 28 +++- .../library/downloads/DownloadController.kt | 30 +++- .../library/downloads/DownloadFragment.kt | 157 +++++++++++++++++- .../downloads/DownloadFragmentStore.kt | 35 +++- .../library/downloads/DownloadInteractor.kt | 20 ++- .../library/downloads/DownloadItemMenu.kt | 44 +++++ .../fenix/library/downloads/DownloadView.kt | 49 +++++- .../DownloadsListItemViewHolder.kt | 66 +++++++- .../main/res/layout/download_list_item.xml | 7 + .../main/res/menu/download_select_multi.xml | 11 ++ app/src/main/res/values/strings.xml | 11 ++ .../library/downloads/DownloadAdapterTest.kt | 2 +- .../downloads/DownloadControllerTest.kt | 59 ++++++- .../downloads/DownloadFragmentStoreTest.kt | 70 ++++++++ .../downloads/DownloadInteractorTest.kt | 46 +++++ 15 files changed, 612 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/library/downloads/DownloadItemMenu.kt create mode 100644 app/src/main/res/menu/download_select_multi.xml create mode 100644 app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentStoreTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadAdapter.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadAdapter.kt index 71fc6d0f48..747512daab 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadAdapter.kt @@ -6,6 +6,7 @@ package org.mozilla.fenix.library.downloads import android.view.LayoutInflater import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import org.mozilla.fenix.library.SelectionHolder import org.mozilla.fenix.library.downloads.viewholders.DownloadsListItemViewHolder @@ -16,6 +17,7 @@ class DownloadAdapter( private var downloads: List = listOf() private var mode: DownloadFragmentState.Mode = DownloadFragmentState.Mode.Normal override val selectedItems get() = mode.selectedItems + var pendingDeletionIds = emptySet() override fun getItemCount(): Int = downloads.size override fun getItemViewType(position: Int): Int = DownloadsListItemViewHolder.LAYOUT_ID @@ -27,14 +29,38 @@ class DownloadAdapter( fun updateMode(mode: DownloadFragmentState.Mode) { this.mode = mode + // Update the delete button alpha that the first item holds + if (itemCount > 0) notifyItemChanged(0) } override fun onBindViewHolder(holder: DownloadsListItemViewHolder, position: Int) { - holder.bind(downloads[position]) + val current = downloads[position] + val isPendingDeletion = pendingDeletionIds.contains(current.id) + holder.bind(downloads[position], position == 0, mode, isPendingDeletion) } fun updateDownloads(downloads: List) { this.downloads = downloads notifyDataSetChanged() } + + fun updatePendingDeletionIds(pendingDeletionIds: Set) { + this.pendingDeletionIds = pendingDeletionIds + } + + companion object { + private val downloadDiffCallback = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: DownloadItem, newItem: DownloadItem): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: DownloadItem, newItem: DownloadItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: DownloadItem, newItem: DownloadItem): Any? { + return newItem + } + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadController.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadController.kt index ccd30c1362..8b6d2df9ac 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadController.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadController.kt @@ -8,17 +8,33 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode interface DownloadController { fun handleOpen(item: DownloadItem, mode: BrowsingMode? = null) + fun handleSelect(item: DownloadItem) + fun handleDeselect(item: DownloadItem) fun handleBackPressed(): Boolean + fun handleModeSwitched() + fun handleDeleteSome(items: Set) + fun handleDeleteAll() } class DefaultDownloadController( private val store: DownloadFragmentStore, - private val openToFileManager: (item: DownloadItem, mode: BrowsingMode?) -> Unit + private val openToFileManager: (item: DownloadItem, mode: BrowsingMode?) -> Unit, + private val displayDeleteAll: () -> Unit, + private val invalidateOptionsMenu: () -> Unit, + private val deleteDownloadItems: (Set) -> Unit ) : DownloadController { override fun handleOpen(item: DownloadItem, mode: BrowsingMode?) { openToFileManager(item, mode) } + override fun handleSelect(item: DownloadItem) { + store.dispatch(DownloadFragmentAction.AddItemForRemoval(item)) + } + + override fun handleDeselect(item: DownloadItem) { + store.dispatch(DownloadFragmentAction.RemoveItemForRemoval(item)) + } + override fun handleBackPressed(): Boolean { return if (store.state.mode is DownloadFragmentState.Mode.Editing) { store.dispatch(DownloadFragmentAction.ExitEditMode) @@ -27,4 +43,16 @@ class DefaultDownloadController( false } } + + override fun handleModeSwitched() { + invalidateOptionsMenu.invoke() + } + + override fun handleDeleteAll() { + displayDeleteAll.invoke() + } + + override fun handleDeleteSome(items: Set) { + deleteDownloadItems.invoke(items) + } } diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragment.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragment.kt index 89b64c88df..36fc7821ae 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragment.kt @@ -4,33 +4,49 @@ package org.mozilla.fenix.library.downloads +import android.content.DialogInterface 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.annotation.VisibleForTesting +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.lifecycleScope import kotlinx.android.synthetic.main.fragment_downloads.view.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi import mozilla.components.browser.state.state.BrowserState +import kotlinx.coroutines.launch import mozilla.components.browser.state.state.content.DownloadState import mozilla.components.feature.downloads.AbstractFetchDownloadService 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.addons.showSnackBar import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.StoreProvider -import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.filterNotExistsOnDisk import org.mozilla.fenix.ext.requireComponents +import org.mozilla.fenix.ext.setTextColor import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.library.LibraryPageFragment +import org.mozilla.fenix.utils.allowUndo @SuppressWarnings("TooManyFunctions", "LargeClass") class DownloadFragment : LibraryPageFragment(), UserInteractionHandler { private lateinit var downloadStore: DownloadFragmentStore private lateinit var downloadView: DownloadView private lateinit var downloadInteractor: DownloadInteractor + private var undoScope: CoroutineScope? = null + private var pendingDownloadDeletionJob: (suspend () -> Unit)? = null override fun onCreateView( inflater: LayoutInflater, @@ -45,14 +61,18 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan DownloadFragmentStore( DownloadFragmentState( items = items, - mode = DownloadFragmentState.Mode.Normal + mode = DownloadFragmentState.Mode.Normal, + pendingDeletionIds = emptySet(), + isDeletingItems = false ) ) } - val downloadController: DownloadController = DefaultDownloadController( downloadStore, - ::openItem + ::openItem, + ::displayDeleteAll, + ::invalidateOptionsMenu, + ::deleteDownloadItems ) downloadInteractor = DownloadInteractor( downloadController @@ -82,12 +102,55 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan override val selectedItems get() = downloadStore.state.mode.selectedItems + private fun invalidateOptionsMenu() { + activity?.invalidateOptionsMenu() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } - requireComponents.analytics.metrics.track(Event.HistoryOpened) + private fun displayDeleteAll() { + activity?.let { activity -> + AlertDialog.Builder(activity).apply { + setMessage(R.string.download_delete_all_dialog) + setNegativeButton(R.string.delete_browsing_data_prompt_cancel) { dialog: DialogInterface, _ -> + dialog.cancel() + } + setPositiveButton(R.string.delete_browsing_data_prompt_allow) { dialog: DialogInterface, _ -> + // Use fragment's lifecycle; the view may be gone by the time dialog is interacted with. + lifecycleScope.launch(IO) { + context.let { + it.components.useCases.downloadUseCases.removeAllDownloads() + } + updatePendingDownloadToDelete(downloadStore.state.items.toSet()) + launch(Dispatchers.Main) { + showSnackBar( + requireView(), + getString(R.string.download_delete_multiple_items_snackbar) + ) + } + } + dialog.dismiss() + } + create() + }.show() + } + } - setHasOptionsMenu(false) + private fun deleteDownloadItems(items: Set) { + updatePendingDownloadToDelete(items) + undoScope = CoroutineScope(IO) + undoScope?.allowUndo( + requireView(), + getMultiSelectSnackBarMessage(items), + getString(R.string.bookmark_undo_deletion), + { + undoPendingDeletion(items) + }, + getDeleteDownloadItemsOperation(items) + ) } @ExperimentalCoroutinesApi @@ -104,7 +167,52 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan showToolbar(getString(R.string.library_downloads)) } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + val menuRes = when (downloadStore.state.mode) { + is DownloadFragmentState.Mode.Normal -> R.menu.library_menu + is DownloadFragmentState.Mode.Editing -> R.menu.download_select_multi + } + inflater.inflate(menuRes, menu) + + menu.findItem(R.id.delete_downloads_multi_select)?.title = + SpannableString(getString(R.string.bookmark_menu_delete_button)).apply { + setTextColor(requireContext(), R.attr.destructive) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.close_history -> { + close() + true + } + + R.id.delete_downloads_multi_select -> { + deleteDownloadItems(downloadStore.state.mode.selectedItems) + downloadStore.dispatch(DownloadFragmentAction.ExitEditMode) + true + } + else -> super.onOptionsItemSelected(item) + } + + private fun getMultiSelectSnackBarMessage(downloadItems: Set): String { + return if (downloadItems.size > 1) { + getString(R.string.download_delete_multiple_items_snackbar) + } else { + String.format( + requireContext().getString( + R.string.history_delete_single_item_snackbar + ), downloadItems.first().fileName + ) + } + } + + override fun onPause() { + invokePendingDeletion() + super.onPause() + } + override fun onBackPressed(): Boolean { + invokePendingDeletion() return downloadView.onBackPressed() } @@ -119,4 +227,41 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan ) } } + + private fun getDeleteDownloadItemsOperation(items: Set): (suspend () -> Unit) { + return { + CoroutineScope(IO).launch { + downloadStore.dispatch(DownloadFragmentAction.EnterDeletionMode) + context?.let { + for (item in items) { + it.components.useCases.downloadUseCases.removeDownload(item.id) + } + } + downloadStore.dispatch(DownloadFragmentAction.ExitDeletionMode) + pendingDownloadDeletionJob = null + } + } + } + + private fun updatePendingDownloadToDelete(items: Set) { + pendingDownloadDeletionJob = getDeleteDownloadItemsOperation(items) + val ids = items.map { item -> item.id }.toSet() + downloadStore.dispatch(DownloadFragmentAction.AddPendingDeletionSet(ids)) + } + + private fun undoPendingDeletion(items: Set) { + pendingDownloadDeletionJob = null + val ids = items.map { item -> item.id }.toSet() + downloadStore.dispatch(DownloadFragmentAction.UndoPendingDeletionSet(ids)) + } + + private fun invokePendingDeletion() { + pendingDownloadDeletionJob?.let { + viewLifecycleOwner.lifecycleScope.launch { + it.invoke() + }.invokeOnCompletion { + pendingDownloadDeletionJob = null + } + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragmentStore.kt index 8f4915e33e..a122262de4 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadFragmentStore.kt @@ -10,7 +10,7 @@ import mozilla.components.lib.state.State import mozilla.components.lib.state.Store /** - * Class representing a history entry + * Class representing a downloads entry * @property id Unique id of the download item * @property fileName File name of the download item * @property filePath Full path of the download item @@ -35,8 +35,15 @@ class DownloadFragmentStore(initialState: DownloadFragmentState) : /** * Actions to dispatch through the `DownloadStore` to modify `DownloadState` through the reducer. */ + sealed class DownloadFragmentAction : Action { object ExitEditMode : DownloadFragmentAction() + data class AddItemForRemoval(val item: DownloadItem) : DownloadFragmentAction() + data class RemoveItemForRemoval(val item: DownloadItem) : DownloadFragmentAction() + data class AddPendingDeletionSet(val itemIds: Set) : DownloadFragmentAction() + data class UndoPendingDeletionSet(val itemIds: Set) : DownloadFragmentAction() + object EnterDeletionMode : DownloadFragmentAction() + object ExitDeletionMode : DownloadFragmentAction() } /** @@ -46,7 +53,9 @@ sealed class DownloadFragmentAction : Action { */ data class DownloadFragmentState( val items: List, - val mode: Mode + val mode: Mode, + val pendingDeletionIds: Set, + val isDeletingItems: Boolean ) : State { sealed class Mode { open val selectedItems = emptySet() @@ -64,6 +73,28 @@ private fun downloadStateReducer( action: DownloadFragmentAction ): DownloadFragmentState { return when (action) { + is DownloadFragmentAction.AddItemForRemoval -> + state.copy(mode = DownloadFragmentState.Mode.Editing(state.mode.selectedItems + action.item)) + is DownloadFragmentAction.RemoveItemForRemoval -> { + val selected = state.mode.selectedItems - action.item + state.copy( + mode = if (selected.isEmpty()) { + DownloadFragmentState.Mode.Normal + } else { + DownloadFragmentState.Mode.Editing(selected) + } + ) + } is DownloadFragmentAction.ExitEditMode -> state.copy(mode = DownloadFragmentState.Mode.Normal) + is DownloadFragmentAction.EnterDeletionMode -> state.copy(isDeletingItems = true) + is DownloadFragmentAction.ExitDeletionMode -> state.copy(isDeletingItems = false) + is DownloadFragmentAction.AddPendingDeletionSet -> + state.copy( + pendingDeletionIds = state.pendingDeletionIds + action.itemIds + ) + is DownloadFragmentAction.UndoPendingDeletionSet -> + state.copy( + pendingDeletionIds = state.pendingDeletionIds - action.itemIds + ) } } diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadInteractor.kt index ea55bd2eb6..6e65692ce5 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadInteractor.kt @@ -15,11 +15,27 @@ class DownloadInteractor( downloadController.handleOpen(item) } - override fun select(item: DownloadItem) { /* noop */ } + override fun select(item: DownloadItem) { + downloadController.handleSelect(item) + } - override fun deselect(item: DownloadItem) { /* noop */ } + override fun deselect(item: DownloadItem) { + downloadController.handleDeselect(item) + } override fun onBackPressed(): Boolean { return downloadController.handleBackPressed() } + + override fun onModeSwitched() { + downloadController.handleModeSwitched() + } + + override fun onDeleteSome(items: Set) { + downloadController.handleDeleteSome(items) + } + + override fun onDeleteAll() { + downloadController.handleDeleteAll() + } } diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadItemMenu.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadItemMenu.kt new file mode 100644 index 0000000000..5a2aab6b56 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadItemMenu.kt @@ -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.downloads + +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 DownloadItemMenu( + private val context: Context, + private val onItemTapped: (Item) -> Unit +) { + + enum class Item { + Delete + } + + val menuController: MenuController by lazy { + BrowserMenuController().apply { + submitList(menuItems()) + } + } + + @VisibleForTesting + internal fun menuItems(): List { + return listOf( + TextMenuCandidate( + text = context.getString(R.string.history_delete_item), + textStyle = TextStyle( + color = context.getColorFromAttr(R.attr.destructive) + ) + ) { + onItemTapped.invoke(Item.Delete) + } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadView.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadView.kt index 76989458dd..03f50c2a50 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadView.kt @@ -12,6 +12,8 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import kotlinx.android.synthetic.main.component_downloads.* import kotlinx.android.synthetic.main.component_downloads.view.* +import kotlinx.android.synthetic.main.component_history.view.progress_bar +import kotlinx.android.synthetic.main.component_history.view.swipe_refresh import mozilla.components.support.base.feature.UserInteractionHandler import org.mozilla.fenix.R import org.mozilla.fenix.library.LibraryPageView @@ -27,6 +29,22 @@ interface DownloadViewInteractor : SelectionInteractor { * Called on backpressed to exit edit mode */ fun onBackPressed(): Boolean + + /** + * Called when the mode is switched so we can invalidate the menu + */ + fun onModeSwitched() + + /** + * Called when multiple downloads items are deleted + * @param items the downloads items to delete + */ + fun onDeleteSome(items: Set) + + /** + * Called when all downloads items are deleted + */ + fun onDeleteAll() } /** @@ -55,18 +73,41 @@ class DownloadView( } fun update(state: DownloadFragmentState) { + val oldMode = mode + view.progress_bar.isVisible = state.isDeletingItems view.swipe_refresh.isEnabled = false mode = state.mode - updateEmptyState(state.items.isNotEmpty()) + downloadAdapter.updatePendingDeletionIds(state.pendingDeletionIds) + + updateEmptyState(state.pendingDeletionIds.size != state.items.size) downloadAdapter.updateMode(state.mode) downloadAdapter.updateDownloads(state.items) - setUiForNormalMode( - context.getString(R.string.library_downloads) - ) + if (state.mode::class != oldMode::class) { + interactor.onModeSwitched() + } + + when (val mode = state.mode) { + is DownloadFragmentState.Mode.Normal -> { + setUiForNormalMode( + context.getString(R.string.library_downloads) + ) + } + is DownloadFragmentState.Mode.Editing -> { + val unselectedItems = oldMode.selectedItems - state.mode.selectedItems + + state.mode.selectedItems.union(unselectedItems).forEach { item -> + val index = state.items.indexOf(item) + downloadAdapter.notifyItemChanged(index) + } + setUiForSelectingMode( + context.getString(R.string.download_multi_select_title, mode.selectedItems.size) + ) + } + } } fun updateEmptyState(userHasDownloads: Boolean) { diff --git a/app/src/main/java/org/mozilla/fenix/library/downloads/viewholders/DownloadsListItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/downloads/viewholders/DownloadsListItemViewHolder.kt index c5f6bbbbdc..0a305e82f5 100644 --- a/app/src/main/java/org/mozilla/fenix/library/downloads/viewholders/DownloadsListItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/viewholders/DownloadsListItemViewHolder.kt @@ -5,16 +5,19 @@ package org.mozilla.fenix.library.downloads.viewholders import android.view.View +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import kotlinx.android.synthetic.main.download_list_item.view.* import kotlinx.android.synthetic.main.library_site_item.view.* import mozilla.components.feature.downloads.toMegabyteOrKilobyteString import org.mozilla.fenix.R -import org.mozilla.fenix.ext.hideAndDisable import org.mozilla.fenix.library.SelectionHolder import org.mozilla.fenix.library.downloads.DownloadInteractor import org.mozilla.fenix.library.downloads.DownloadItem import org.mozilla.fenix.ext.getIcon +import org.mozilla.fenix.ext.showAndEnable +import org.mozilla.fenix.library.downloads.DownloadFragmentState +import org.mozilla.fenix.library.downloads.DownloadItemMenu class DownloadsListItemViewHolder( view: View, @@ -24,24 +27,77 @@ class DownloadsListItemViewHolder( private var item: DownloadItem? = null + init { + setupMenu() + + itemView.delete_downloads_button.setOnClickListener { + val selected = selectionHolder.selectedItems + if (selected.isEmpty()) { + downloadInteractor.onDeleteAll() + } else { + downloadInteractor.onDeleteSome(selected) + } + } + } + fun bind( - item: DownloadItem + item: DownloadItem, + showDeleteButton: Boolean, + mode: DownloadFragmentState.Mode, + isPendingDeletion: Boolean = false ) { - itemView.download_layout.visibility = View.VISIBLE + itemView.download_layout.visibility = if (isPendingDeletion) { + View.GONE + } else { + View.VISIBLE + } itemView.download_layout.titleView.text = item.fileName itemView.download_layout.urlView.text = item.size.toLong().toMegabyteOrKilobyteString() + toggleTopContent(showDeleteButton, mode == DownloadFragmentState.Mode.Normal) + itemView.download_layout.setSelectionInteractor(item, selectionHolder, downloadInteractor) itemView.download_layout.changeSelected(item in selectionHolder.selectedItems) - itemView.overflow_menu.hideAndDisable() itemView.favicon.setImageResource(item.getIcon()) - itemView.favicon.isClickable = false + + itemView.overflow_menu.showAndEnable() this.item = item } + private fun toggleTopContent( + showTopContent: Boolean, + isNormalMode: Boolean + ) { + itemView.delete_downloads_button.isVisible = showTopContent + + if (showTopContent) { + itemView.delete_downloads_button.run { + if (isNormalMode) { + isEnabled = true + alpha = 1f + } else { + isEnabled = false + alpha = DELETE_BUTTON_DISABLED_ALPHA + } + } + } + } + + private fun setupMenu() { + val downloadMenu = DownloadItemMenu(itemView.context) { + val item = this.item ?: return@DownloadItemMenu + + if (it == DownloadItemMenu.Item.Delete) { + downloadInteractor.onDeleteSome(setOf(item)) + } + } + itemView.download_layout.attachMenu(downloadMenu.menuController) + } + companion object { + const val DELETE_BUTTON_DISABLED_ALPHA = 0.4f const val LAYOUT_ID = R.layout.download_list_item } } diff --git a/app/src/main/res/layout/download_list_item.xml b/app/src/main/res/layout/download_list_item.xml index 7e9ddafd42..89c5da55c0 100644 --- a/app/src/main/res/layout/download_list_item.xml +++ b/app/src/main/res/layout/download_list_item.xml @@ -9,6 +9,13 @@ android:layout_width="match_parent" xmlns:tools="http://schemas.android.com/tools" android:orientation="vertical"> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd34228133..70cc95069f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -665,11 +665,22 @@ No history here + + Delete downloads + + Are you sure you want to clear your downloads? + + Downloads Deleted No downloads here %1$d selected + + Open + + Delete + diff --git a/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadAdapterTest.kt b/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadAdapterTest.kt index 79ba1a8abe..e50a00ad7f 100644 --- a/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadAdapterTest.kt +++ b/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadAdapterTest.kt @@ -31,7 +31,7 @@ class DownloadAdapterTest { } @Test - fun `getItemCount should return the number of tab collections`() { + fun `getItemCount should return the number of downloads`() { val download = mockk() assertEquals(0, adapter.itemCount) diff --git a/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadControllerTest.kt b/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadControllerTest.kt index 66109bf554..1cdf21a44e 100644 --- a/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadControllerTest.kt @@ -26,9 +26,15 @@ class DownloadControllerTest { private val store: DownloadFragmentStore = mockk(relaxed = true) private val state: DownloadFragmentState = mockk(relaxed = true) private val openToFileManager: (DownloadItem, BrowsingMode?) -> Unit = mockk(relaxed = true) + private val displayDeleteAll: () -> Unit = mockk(relaxed = true) + private val invalidateOptionsMenu: () -> Unit = mockk(relaxed = true) + private val deleteDownloadItems: (Set) -> Unit = mockk(relaxed = true) private val controller = DefaultDownloadController( store, - openToFileManager + openToFileManager, + displayDeleteAll, + invalidateOptionsMenu, + deleteDownloadItems ) @Before @@ -65,4 +71,55 @@ class DownloadControllerTest { assertFalse(controller.handleBackPressed()) } + + @Test + fun onPressDownloadItemInEditMode() { + every { state.mode } returns DownloadFragmentState.Mode.Editing(setOf()) + + controller.handleSelect(downloadItem) + + verify { + store.dispatch(DownloadFragmentAction.AddItemForRemoval(downloadItem)) + } + } + + @Test + fun onPressSelectedDownloadItemInEditMode() { + every { state.mode } returns DownloadFragmentState.Mode.Editing(setOf(downloadItem)) + + controller.handleDeselect(downloadItem) + + verify { + store.dispatch(DownloadFragmentAction.RemoveItemForRemoval(downloadItem)) + } + } + + @Test + fun onModeSwitched() { + controller.handleModeSwitched() + + verify { + invalidateOptionsMenu.invoke() + } + } + + @Test + fun onDeleteAll() { + controller.handleDeleteAll() + + verify { + displayDeleteAll.invoke() + } + } + + @Test + fun onDeleteSome() { + val itemsToDelete = setOf(downloadItem) + + controller.handleDeleteSome(itemsToDelete) + + verify { + deleteDownloadItems(itemsToDelete) + } + } } diff --git a/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentStoreTest.kt b/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentStoreTest.kt new file mode 100644 index 0000000000..be7889ac47 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadFragmentStoreTest.kt @@ -0,0 +1,70 @@ +/* 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.downloads + +import kotlinx.coroutines.runBlocking +import mozilla.components.browser.state.state.content.DownloadState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Test + +class DownloadFragmentStoreTest { + private val downloadItem = DownloadItem("0", "title", "url", "77", "jpg", DownloadState.Status.COMPLETED) + private val newDownloadItem = DownloadItem("1", "title", "url", "77", "jpg", DownloadState.Status.COMPLETED) + + @Test + fun exitEditMode() = runBlocking { + val initialState = oneItemEditState() + val store = DownloadFragmentStore(initialState) + + store.dispatch(DownloadFragmentAction.ExitEditMode).join() + assertNotSame(initialState, store.state) + assertEquals(store.state.mode, DownloadFragmentState.Mode.Normal) + } + + @Test + fun itemAddedForRemoval() = runBlocking { + val initialState = emptyDefaultState() + val store = DownloadFragmentStore(initialState) + + store.dispatch(DownloadFragmentAction.AddItemForRemoval(newDownloadItem)).join() + assertNotSame(initialState, store.state) + assertEquals( + store.state.mode, + DownloadFragmentState.Mode.Editing(setOf(newDownloadItem)) + ) + } + + @Test + fun removeItemForRemoval() = runBlocking { + val initialState = twoItemEditState() + val store = DownloadFragmentStore(initialState) + + store.dispatch(DownloadFragmentAction.RemoveItemForRemoval(newDownloadItem)).join() + assertNotSame(initialState, store.state) + assertEquals(store.state.mode, DownloadFragmentState.Mode.Editing(setOf(downloadItem))) + } + + private fun emptyDefaultState(): DownloadFragmentState = DownloadFragmentState( + items = listOf(), + mode = DownloadFragmentState.Mode.Normal, + pendingDeletionIds = emptySet(), + isDeletingItems = false + ) + + private fun oneItemEditState(): DownloadFragmentState = DownloadFragmentState( + items = listOf(), + mode = DownloadFragmentState.Mode.Editing(setOf(downloadItem)), + pendingDeletionIds = emptySet(), + isDeletingItems = false + ) + + private fun twoItemEditState(): DownloadFragmentState = DownloadFragmentState( + items = listOf(), + mode = DownloadFragmentState.Mode.Editing(setOf(downloadItem, newDownloadItem)), + pendingDeletionIds = emptySet(), + isDeletingItems = false + ) +} diff --git a/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadInteractorTest.kt index 74a80f888f..b7ace6a3e6 100644 --- a/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/library/downloads/DownloadInteractorTest.kt @@ -25,6 +25,24 @@ class DownloadInteractorTest { } } + @Test + fun onSelect() { + interactor.select(downloadItem) + + verifyAll { + controller.handleSelect(downloadItem) + } + } + + @Test + fun onDeselect() { + interactor.deselect(downloadItem) + + verifyAll { + controller.handleDeselect(downloadItem) + } + } + @Test fun onBackPressed() { every { @@ -38,4 +56,32 @@ class DownloadInteractorTest { } assertTrue(backpressHandled) } + + @Test + fun onModeSwitched() { + interactor.onModeSwitched() + + verifyAll { + controller.handleModeSwitched() + } + } + + @Test + fun onDeleteAll() { + interactor.onDeleteAll() + + verifyAll { + controller.handleDeleteAll() + } + } + + @Test + fun onDeleteSome() { + val items = setOf(downloadItem) + + interactor.onDeleteSome(items) + verifyAll { + controller.handleDeleteSome(items) + } + } }