From 1635ed9543bf161ebe9c125688b79710329c453c Mon Sep 17 00:00:00 2001 From: Kate Glazko Date: Wed, 1 Jul 2020 16:10:44 -0700 Subject: [PATCH] For #349: View Downloads --- .../java/org/mozilla/fenix/home/HomeMenu.kt | 1 + .../library/downloads/DownloadAdapter.kt | 23 +++ .../library/downloads/DownloadController.kt | 26 +++- .../library/downloads/DownloadFragment.kt | 137 +++++++++++++++++- .../downloads/DownloadFragmentStore.kt | 39 ++++- .../library/downloads/DownloadInteractor.kt | 16 +- .../library/downloads/DownloadItemMenu.kt | 45 ++++++ .../fenix/library/downloads/DownloadView.kt | 50 ++++++- .../DownloadsListItemViewHolder.kt | 42 +++++- .../main/res/menu/download_select_multi.xml | 15 ++ app/src/main/res/values/strings.xml | 5 + 11 files changed, 373 insertions(+), 26 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 diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt b/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt index 3e172e8f81ca..225ff9af4c0a 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeMenu.kt @@ -168,6 +168,7 @@ class HomeMenu( BrowserMenuDivider(), if (settings.syncedTabsInTabsTray) null else syncedTabsItem, bookmarksItem, + downloadsItem, historyItem, downloadsItem, BrowserMenuDivider(), 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 71fc6d0f4861..ac0d79b41027 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,7 +6,9 @@ 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.ext.logDebug import org.mozilla.fenix.library.SelectionHolder import org.mozilla.fenix.library.downloads.viewholders.DownloadsListItemViewHolder @@ -37,4 +39,25 @@ class DownloadAdapter( this.downloads = downloads notifyDataSetChanged() } + + fun updatePendingDeletionIds(pendingDeletionIds: Set) { + logDebug("boek", pendingDeletionIds.toString()) +// 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 ccd30c13620d..b0fe89e1d8e0 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,15 +8,29 @@ 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) } class DefaultDownloadController( private val store: DownloadFragmentStore, - private val openToFileManager: (item: DownloadItem, mode: BrowsingMode?) -> Unit + private val openToFileManager: (item: DownloadItem, mode: BrowsingMode?) -> Unit, + private val invalidateOptionsMenu: () -> Unit, + private val deleteDownloadItems: (Set) -> Unit ) : DownloadController { override fun handleOpen(item: DownloadItem, mode: BrowsingMode?) { - openToFileManager(item, mode) + 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 { @@ -27,4 +41,12 @@ class DefaultDownloadController( false } } + + override fun handleModeSwitched() { + invalidateOptionsMenu.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 95effa5633ce..3cbdfdd28e41 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 @@ -6,10 +6,17 @@ package org.mozilla.fenix.library.downloads import android.os.Bundle 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.lifecycle.lifecycleScope import kotlinx.android.synthetic.main.fragment_downloads.view.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi +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 @@ -19,16 +26,17 @@ import org.mozilla.fenix.R 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.filterNotExistsOnDisk -import org.mozilla.fenix.ext.requireComponents -import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.ext.* 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 pendingHistoryDeletionJob: (suspend () -> Unit)? = null override fun onCreateView( inflater: LayoutInflater, @@ -54,14 +62,17 @@ 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, + ::invalidateOptionsMenu, + ::deleteHistoryItems ) downloadInteractor = DownloadInteractor( downloadController @@ -73,12 +84,31 @@ 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) requireComponents.analytics.metrics.track(Event.HistoryOpened) - setHasOptionsMenu(false) + setHasOptionsMenu(true) + } + + private fun deleteHistoryItems(items: Set) { + + updatePendingHistoryToDelete(items) + undoScope = CoroutineScope(IO) + undoScope?.allowUndo( + requireView(), + getMultiSelectSnackBarMessage(items), + getString(R.string.bookmark_undo_deletion), + { + undoPendingDeletion(items) + }, + getDeleteHistoryItemsOperation(items) + ) } @ExperimentalCoroutinesApi @@ -95,7 +125,60 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan showToolbar(getString(R.string.library_downloads)) } + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + val menuRes = when (downloadStore.state.mode) { + DownloadFragmentState.Mode.Normal -> R.menu.library_menu + is DownloadFragmentState.Mode.Editing -> R.menu.download_select_multi + } + + inflater.inflate(menuRes, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.close_history -> { + close() + true + } + + R.id.delete_downloads -> { + deleteHistoryItems(downloadStore.state.mode.selectedItems) + downloadStore.dispatch(DownloadFragmentAction.ExitEditMode) + true + } + R.id.open_downloads -> { + val selectedDownloads = downloadStore.state.mode.selectedItems + val listOfDownload = selectedDownloads.toList() + context?.let { + AbstractFetchDownloadService.openFile( + context = it, + contentType = listOfDownload[0].contentType, + filePath = listOfDownload[0].filePath + ) + } + true + } + else -> super.onOptionsItemSelected(item) + } + + private fun getMultiSelectSnackBarMessage(downloadItems: Set): String { + return if (downloadItems.size > 1) { + getString(R.string.history_delete_multiple_items_snackbar) + } else { + String.format( + requireContext().getString( + R.string.history_delete_single_item_snackbar + ), downloadItems.first().filePath.toShortUrl(requireComponents.publicSuffixList) + ) + } + } + + override fun onPause() { + invokePendingDeletion() + super.onPause() + } + override fun onBackPressed(): Boolean { + invokePendingDeletion() return downloadView.onBackPressed() } @@ -110,4 +193,44 @@ class DownloadFragment : LibraryPageFragment(), UserInteractionHan ) } } + + private fun getDeleteHistoryItemsOperation(items: Set): (suspend () -> Unit) { + return { + CoroutineScope(IO).launch { + downloadStore.dispatch(DownloadFragmentAction.EnterDeletionMode) + context?.components?.run { + for (item in items) { + analytics.metrics.track(Event.HistoryItemRemoved) + // core.historyStorage.deleteVisit(item.url, item.visitedAt) + } + } + downloadStore.dispatch(DownloadFragmentAction.ExitDeletionMode) + pendingHistoryDeletionJob = null + } + } + } + + private fun updatePendingHistoryToDelete(items: Set) { + logDebug("boek", items.toString()) +// pendingHistoryDeletionJob = getDeleteHistoryItemsOperation(items) +// val ids = items.map { item -> item.visitedAt }.toSet() +// downloadStore.dispatch(DownloadFragmentAction.AddPendingDeletionSet(ids)) + } + + private fun undoPendingDeletion(items: Set) { + logDebug("boek", items.toString()) +// pendingHistoryDeletionJob = null +// val ids = items.map { item -> item.visitedAt }.toSet() +// downloadStore.dispatch(DownloadFragmentAction.UndoPendingDeletionSet(ids)) + } + + private fun invokePendingDeletion() { + pendingHistoryDeletionJob?.let { + viewLifecycleOwner.lifecycleScope.launch { + it.invoke() + }.invokeOnCompletion { + pendingHistoryDeletionJob = 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 8f4915e33e68..68b355bc855c 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 @@ -35,18 +35,27 @@ 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() } /** - * The state for the Download Screen - * @property items List of DownloadItem to display - * @property mode Current Mode of Download + * The state for the History Screen + * @property items List of HistoryItem to display + * @property mode Current Mode of History */ 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 ea55bd2eb62b..b7a6ea0bcda9 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,23 @@ 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) + } } 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 000000000000..6b4bea6f8f47 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/downloads/DownloadItemMenu.kt @@ -0,0 +1,45 @@ + +/* 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 76989458dde2..4c0ee107ad73 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,17 @@ 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) } /** @@ -55,18 +68,45 @@ class DownloadView( } fun update(state: DownloadFragmentState) { + val oldMode = mode - view.swipe_refresh.isEnabled = false + view.progress_bar.isVisible = state.isDeletingItems + view.swipe_refresh.isEnabled = + state.mode === DownloadFragmentState.Mode.Normal 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() + } + + if (state.mode 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) + } + } + + when (val mode = state.mode) { + is DownloadFragmentState.Mode.Normal -> { + setUiForNormalMode( + context.getString(R.string.library_downloads) + ) + } + is DownloadFragmentState.Mode.Editing -> { + 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 c367ca6749e3..a0fd23561a92 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 @@ -14,7 +14,9 @@ import org.mozilla.fenix.library.SelectionHolder import org.mozilla.fenix.library.downloads.DownloadInteractor import org.mozilla.fenix.library.downloads.DownloadItem import mozilla.components.feature.downloads.toMegabyteString -import org.mozilla.fenix.ext.getIcon +import org.mozilla.fenix.ext.showAndEnable +import org.mozilla.fenix.library.downloads.DownloadItemMenu +import org.mozilla.fenix.utils.Do class DownloadsListItemViewHolder( view: View, @@ -24,23 +26,51 @@ class DownloadsListItemViewHolder( private var item: DownloadItem? = null + init { + setupMenu() + } + fun bind( - item: DownloadItem + item: DownloadItem, + isPendingDeletion: Boolean = false ) { - itemView.download_layout.visibility = View.VISIBLE + if (isPendingDeletion) { + itemView.download_layout.visibility = View.GONE + } else { + itemView.download_layout.visibility = View.VISIBLE + } + itemView.download_layout.titleView.text = item.fileName itemView.download_layout.urlView.text = item.size.toLong().toMegabyteString() 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 + if (this.item?.fileName != item.fileName) { + itemView.download_layout.loadFavicon(item.filePath) + } + + if (item !in selectionHolder.selectedItems) { + itemView.overflow_menu.showAndEnable() + } else { + itemView.overflow_menu.hideAndDisable() + } this.item = item } + private fun setupMenu() { + val downloadMenu = DownloadItemMenu(itemView.context) { + val item = this.item ?: return@DownloadItemMenu + + Do exhaustive when (it) { + DownloadItemMenu.Item.Delete -> downloadInteractor.onDeleteSome(setOf(item)) + } + } + + itemView.download_layout.attachMenu(downloadMenu.menuController) + } + companion object { const val LAYOUT_ID = R.layout.download_list_item } diff --git a/app/src/main/res/menu/download_select_multi.xml b/app/src/main/res/menu/download_select_multi.xml new file mode 100644 index 000000000000..a92631fb4f70 --- /dev/null +++ b/app/src/main/res/menu/download_select_multi.xml @@ -0,0 +1,15 @@ + + + + + + \ 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 1465e3623ef8..ebe28bc6b0cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -660,6 +660,11 @@ %1$d selected + + Open + + Delete +