diff --git a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt index bd50ba9fd1cd..03ac1310658b 100644 --- a/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt +++ b/app/src/main/java/org/mozilla/fenix/BrowserDirection.kt @@ -20,6 +20,7 @@ enum class BrowserDirection(@IdRes val fragmentId: Int) { FromSettings(R.id.settingsFragment), FromBookmarks(R.id.bookmarkFragment), FromHistory(R.id.historyFragment), + FromHistoryMetadataGroup(R.id.historyMetadataGroupFragment), FromTrackingProtectionExceptions(R.id.trackingProtectionExceptionsFragment), FromAbout(R.id.aboutFragment), FromTrackingProtection(R.id.trackingProtectionFragment), diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index 228ae7fd28e8..87907a0f843f 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -58,4 +58,9 @@ object FeatureFlags { * Enables showing the home screen behind the search dialog */ val showHomeBehindSearch = Config.channel.isNightlyOrDebug + + /** + * Enables showing search groupings in the History. + */ + val showHistorySearchGroups = Config.channel.isDebug } diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index 2eafea132492..2fe31b70a673 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -15,10 +15,10 @@ import android.os.StrictMode import android.os.SystemClock import android.text.format.DateUtils import android.util.AttributeSet +import android.view.ActionMode import android.view.KeyEvent import android.view.LayoutInflater import android.view.View -import android.view.ActionMode import android.view.ViewConfiguration import android.view.WindowManager.LayoutParams.FLAG_SECURE import androidx.annotation.CallSuper @@ -34,12 +34,12 @@ import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.NavigationUI import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers.IO import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.state.action.ContentAction import mozilla.components.browser.state.search.SearchEngine @@ -93,6 +93,7 @@ import org.mozilla.fenix.home.intent.StartSearchIntentProcessor import org.mozilla.fenix.library.bookmarks.BookmarkFragmentDirections import org.mozilla.fenix.library.bookmarks.DesktopFolders import org.mozilla.fenix.library.history.HistoryFragmentDirections +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections import org.mozilla.fenix.library.recentlyclosed.RecentlyClosedFragmentDirections import org.mozilla.fenix.onboarding.DefaultBrowserNotificationWorker import org.mozilla.fenix.perf.Performance @@ -740,6 +741,8 @@ open class HomeActivity : LocaleAwareAppCompatActivity(), NavHostActivity { BookmarkFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromHistory -> HistoryFragmentDirections.actionGlobalBrowser(customTabSessionId) + BrowserDirection.FromHistoryMetadataGroup -> + HistoryMetadataGroupFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromTrackingProtectionExceptions -> TrackingProtectionExceptionsFragmentDirections.actionGlobalBrowser(customTabSessionId) BrowserDirection.FromAbout -> diff --git a/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt b/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt index 32f943936ca6..be2d70604c64 100644 --- a/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt +++ b/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt @@ -4,9 +4,14 @@ package org.mozilla.fenix.components.history -import mozilla.components.concept.storage.HistoryStorage +import mozilla.components.browser.storage.sync.PlacesHistoryStorage +import mozilla.components.concept.storage.HistoryMetadata import mozilla.components.concept.storage.VisitInfo import mozilla.components.concept.storage.VisitType +import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl +import org.mozilla.fenix.FeatureFlags +import org.mozilla.fenix.library.history.HistoryItem +import org.mozilla.fenix.library.history.HistoryItemType import org.mozilla.fenix.perf.runBlockingIncrement /** @@ -19,36 +24,138 @@ interface PagedHistoryProvider { * @param numberOfItems How many items to fetch * @param onComplete A callback that returns the list of [VisitInfo] */ - fun getHistory(offset: Long, numberOfItems: Long, onComplete: (List) -> Unit) + fun getHistory(offset: Long, numberOfItems: Long, onComplete: (List) -> Unit) } -// A PagedList DataSource runs on a background thread automatically. -// If we run this in our own coroutineScope it breaks the PagedList -fun HistoryStorage.createSynchronousPagedHistoryProvider(): PagedHistoryProvider { - return object : PagedHistoryProvider { - - override fun getHistory( - offset: Long, - numberOfItems: Long, - onComplete: (List) -> Unit - ) { - runBlockingIncrement { - val history = getVisitsPaginated( - offset, - numberOfItems, - excludeTypes = listOf( - VisitType.NOT_A_VISIT, - VisitType.DOWNLOAD, - VisitType.REDIRECT_TEMPORARY, - VisitType.RELOAD, - VisitType.EMBED, - VisitType.FRAMED_LINK, - VisitType.REDIRECT_PERMANENT +/** + * @param historyStorage + */ +class DefaultPagedHistoryProvider( + private val historyStorage: PlacesHistoryStorage +) : PagedHistoryProvider { + + @Suppress("LongMethod") + override fun getHistory( + offset: Long, + numberOfItems: Long, + onComplete: (List) -> Unit + ) { + // A PagedList DataSource runs on a background thread automatically. + // If we run this in our own coroutineScope it breaks the PagedList + runBlockingIncrement { + if (FeatureFlags.showHistorySearchGroups) { + val historyMetadata: MutableList = + historyStorage.getHistoryMetadataSince(Long.MIN_VALUE) + .filter { it.totalViewTime > 0 && it.key.searchTerm != null } + .toMutableList() + val historyGroups: MutableMap> = historyMetadata + .groupBy { it.key.searchTerm!! } + .toMutableMap() + + val history: List = historyStorage + .getVisitsPaginated( + offset, + numberOfItems, + excludeTypes = listOf( + VisitType.NOT_A_VISIT, + VisitType.DOWNLOAD, + VisitType.REDIRECT_TEMPORARY, + VisitType.RELOAD, + VisitType.EMBED, + VisitType.FRAMED_LINK, + VisitType.REDIRECT_PERMANENT + ) ) - ) + .mapIndexed(transformVisitInfoToHistoryItem(offset.toInt())) + + val result = mutableListOf() + + // Iterate through the existing list of visited history item and filter out items + // that belong to a search group, replacing the first seen item that matches a + // history metadata item with its corresponding search group. + for (item in history) { + val matchingHistoryMetadataItem = + historyMetadata.find { it.key.url == item.url } + + if (matchingHistoryMetadataItem != null) { + // Found a visited history item matching an existing history metadata item + + val searchTerm = matchingHistoryMetadataItem.key.searchTerm!! + val historyMetadataItems = historyGroups[searchTerm] + + historyMetadata.remove(matchingHistoryMetadataItem) + + // Replace the visited history item with a history group along with all the + // matching history metadata item with the same search term. + if (historyMetadataItems != null && historyMetadataItems.isNotEmpty()) { + result.add( + HistoryItem( + id = item.id, + title = searchTerm, + visitedAt = item.visitedAt, + type = HistoryItemType.GROUP, + historyMetadataItems = historyMetadataItems.map { + HistoryItem( + id = 0, + title = it.title?.takeIf(String::isNotEmpty) + ?: it.key.url.tryGetHostFromUrl(), + url = it.key.url, + visitedAt = it.updatedAt, + type = HistoryItemType.HISTORY_METADATA_ITEM + ) + } + ) + ) + + // Since a history group was added for the existing `searchTerm`, + // remove the `searchTerm` from `historyGroups`. Subsequent matches + // of the visited history item and an existing history metadata with + // a search group that has already been added will not be appended + // to the `result`. + historyGroups.remove(searchTerm) + } + } else { + // Visited history item does not belong in a search group. Add to `result`. + result.add(item) + } + } + + onComplete(result) + } else { + val history = historyStorage + .getVisitsPaginated( + offset, + numberOfItems, + excludeTypes = listOf( + VisitType.NOT_A_VISIT, + VisitType.DOWNLOAD, + VisitType.REDIRECT_TEMPORARY, + VisitType.RELOAD, + VisitType.EMBED, + VisitType.FRAMED_LINK, + VisitType.REDIRECT_PERMANENT + ) + ) + .mapIndexed(transformVisitInfoToHistoryItem(offset.toInt())) onComplete(history) } } } + + private fun transformVisitInfoToHistoryItem(offset: Int): (id: Int, visit: VisitInfo) -> HistoryItem { + return { id, visit -> + val title = visit.title + ?.takeIf(String::isNotEmpty) + ?: visit.url.tryGetHostFromUrl() + + HistoryItem( + id = offset + id, + title = title, + url = visit.url, + visitedAt = visit.visitTime, + type = HistoryItemType.ITEM + ) + } + } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt index 1f72c14eb99c..85e4ba9d9e70 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryController.kt @@ -17,6 +17,7 @@ import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.utils.Do @Suppress("TooManyFunctions") interface HistoryController { @@ -50,8 +51,21 @@ class DefaultHistoryController( private val syncHistory: suspend () -> Unit, private val metrics: MetricController ) : HistoryController { + override fun handleOpen(item: HistoryItem) { - openToBrowser(item) + Do exhaustive when (item.type) { + HistoryItemType.HISTORY_METADATA_ITEM, + HistoryItemType.ITEM -> openToBrowser(item) + HistoryItemType.GROUP -> { + navController.navigate( + HistoryFragmentDirections.actionGlobalHistoryMetadataGroup( + title = item.title, + historyMetadataItems = item.historyMetadataItems.toTypedArray() + ), + NavOptions.Builder().setPopUpTo(R.id.historyMetadataGroupFragment, true).build() + ) + } + } } override fun handleOpenInNewTab(item: HistoryItem, mode: BrowsingMode) { diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt index 2c5d52176efe..2231089d823e 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt @@ -5,15 +5,13 @@ package org.mozilla.fenix.library.history import androidx.paging.ItemKeyedDataSource -import mozilla.components.concept.storage.VisitInfo -import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl import org.mozilla.fenix.components.history.PagedHistoryProvider class HistoryDataSource( private val historyProvider: PagedHistoryProvider ) : ItemKeyedDataSource() { - // Because the pagination is not based off of they key + // Because the pagination is not based off of the key // we want to start at 1, not 0 to be able to send the correct offset // to the `historyProvider.getHistory` call. override fun getKey(item: HistoryItem): Int = item.id + 1 @@ -23,15 +21,13 @@ class HistoryDataSource( callback: LoadInitialCallback ) { historyProvider.getHistory(INITIAL_OFFSET, params.requestedLoadSize.toLong()) { history -> - val items = history.mapIndexed(transformVisitInfoToHistoryItem(INITIAL_OFFSET.toInt())) - callback.onResult(items) + callback.onResult(history) } } override fun loadAfter(params: LoadParams, callback: LoadCallback) { historyProvider.getHistory(params.key.toLong(), params.requestedLoadSize.toLong()) { history -> - val items = history.mapIndexed(transformVisitInfoToHistoryItem(params.key)) - callback.onResult(items) + callback.onResult(history) } } @@ -39,15 +35,5 @@ class HistoryDataSource( companion object { private const val INITIAL_OFFSET = 0L - - fun transformVisitInfoToHistoryItem(offset: Int): (id: Int, visit: VisitInfo) -> HistoryItem { - return { id, visit -> - val title = visit.title - ?.takeIf(String::isNotEmpty) - ?: visit.url.tryGetHostFromUrl() - - HistoryItem(offset + id, title, visit.url, visit.visitTime) - } - } } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt index e308c1e83b8d..4818f7b187a6 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt @@ -40,7 +40,7 @@ import org.mozilla.fenix.addons.showSnackBar import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.StoreProvider -import org.mozilla.fenix.components.history.createSynchronousPagedHistoryProvider +import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.databinding.FragmentHistoryBinding import org.mozilla.fenix.ext.components @@ -122,7 +122,7 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl super.onCreate(savedInstanceState) viewModel = HistoryViewModel( - requireComponents.core.historyStorage.createSynchronousPagedHistoryProvider() + historyProvider = DefaultPagedHistoryProvider(requireComponents.core.historyStorage) ) viewModel.userHasHistory.observe( diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt index 08d6f3682008..58915714d50b 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragmentStore.kt @@ -4,18 +4,53 @@ package org.mozilla.fenix.library.history +import android.os.Parcelable +import kotlinx.parcelize.Parcelize import mozilla.components.lib.state.Action import mozilla.components.lib.state.State import mozilla.components.lib.state.Store /** * Class representing a history entry + * * @property id Unique id of the history item * @property title Title of the history item * @property url URL of the history item * @property visitedAt Timestamp of when this history item was visited + * @property type + * @property historyMetadataItems + * @property selected */ -data class HistoryItem(val id: Int, val title: String, val url: String, val visitedAt: Long) +@Parcelize +data class HistoryItem( + val id: Int, + val title: String, + val url: String = "", + val visitedAt: Long, + val type: HistoryItemType = HistoryItemType.ITEM, + val historyMetadataItems: List = emptyList(), + val selected: Boolean = false, +) : Parcelable + +/** + * Enum of the types of items that are displayed in the history view. + */ +enum class HistoryItemType { + /** + * A visited history item. + */ + ITEM, + + /** + * A history metadata item. + */ + HISTORY_METADATA_ITEM, + + /** + * A search group. + */ + GROUP +} /** * The [Store] for holding the [HistoryFragmentState] and applying [HistoryFragmentAction]s. diff --git a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt index d9ee8c1b8fa0..861ddfd473a6 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt @@ -12,12 +12,13 @@ import org.mozilla.fenix.databinding.HistoryListItemBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.hideAndDisable import org.mozilla.fenix.ext.showAndEnable -import org.mozilla.fenix.selection.SelectionHolder import org.mozilla.fenix.library.history.HistoryFragmentState import org.mozilla.fenix.library.history.HistoryInteractor import org.mozilla.fenix.library.history.HistoryItem import org.mozilla.fenix.library.history.HistoryItemMenu import org.mozilla.fenix.library.history.HistoryItemTimeGroup +import org.mozilla.fenix.library.history.HistoryItemType +import org.mozilla.fenix.selection.SelectionHolder import org.mozilla.fenix.utils.Do class HistoryListItemViewHolder( @@ -51,7 +52,19 @@ class HistoryListItemViewHolder( } binding.historyLayout.titleView.text = item.title - binding.historyLayout.urlView.text = item.url + + binding.historyLayout.urlView.text = + if (item.type == HistoryItemType.GROUP) { + val numChildren = item.historyMetadataItems.size + val stringId = if (numChildren == 1) { + R.string.history_search_group_site + } else { + R.string.history_search_group_sites + } + String.format(itemView.context.getString(stringId), numChildren) + } else { + item.url + } toggleTopContent(showTopContent, mode === HistoryFragmentState.Mode.Normal) @@ -61,8 +74,10 @@ class HistoryListItemViewHolder( binding.historyLayout.setSelectionInteractor(item, selectionHolder, historyInteractor) binding.historyLayout.changeSelected(item in selectionHolder.selectedItems) - if (this.item?.url != item.url) { + if (item.type == HistoryItemType.ITEM && this.item?.url != item.url) { binding.historyLayout.loadFavicon(item.url) + } else if (item.type == HistoryItemType.GROUP) { + binding.historyLayout.iconView.setImageResource(R.drawable.ic_multiple_tabs) } if (mode is HistoryFragmentState.Mode.Editing) { diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragment.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragment.kt new file mode 100644 index 000000000000..69a553267c74 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragment.kt @@ -0,0 +1,165 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata + +import android.os.Bundle +import android.text.SpannableString +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.lib.state.ext.consumeFrom +import mozilla.components.support.base.feature.UserInteractionHandler +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.R +import org.mozilla.fenix.browser.browsingmode.BrowsingMode +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.databinding.FragmentHistoryMetadataGroupBinding +import org.mozilla.fenix.ext.nav +import org.mozilla.fenix.ext.setTextColor +import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.library.LibraryPageFragment +import org.mozilla.fenix.library.history.HistoryItem +import org.mozilla.fenix.library.historymetadata.controller.DefaultHistoryMetadataGroupController +import org.mozilla.fenix.library.historymetadata.interactor.DefaultHistoryMetadataGroupInteractor +import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor +import org.mozilla.fenix.library.historymetadata.view.HistoryMetadataGroupView + +class HistoryMetadataGroupFragment : LibraryPageFragment(), UserInteractionHandler { + + private lateinit var historyMetadataGroupStore: HistoryMetadataGroupFragmentStore + private lateinit var interactor: HistoryMetadataGroupInteractor + + private var _historyMetadataGroupView: HistoryMetadataGroupView? = null + protected val historyMetadataGroupView: HistoryMetadataGroupView + get() = _historyMetadataGroupView!! + + private val args by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val binding = FragmentHistoryMetadataGroupBinding.inflate(inflater, container, false) + + historyMetadataGroupStore = StoreProvider.get(this) { + HistoryMetadataGroupFragmentStore( + HistoryMetadataGroupFragmentState( + items = args.historyMetadataItems.toList() + ) + ) + } + + interactor = DefaultHistoryMetadataGroupInteractor( + controller = DefaultHistoryMetadataGroupController( + activity = activity as HomeActivity, + store = historyMetadataGroupStore, + navController = findNavController() + ) + ) + + _historyMetadataGroupView = HistoryMetadataGroupView( + container = binding.historyMetadataGroupLayout, + interactor = interactor, + title = args.title + ) + + return binding.root + } + + @ExperimentalCoroutinesApi + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + consumeFrom(historyMetadataGroupStore) { state -> + historyMetadataGroupView.update(state) + activity?.invalidateOptionsMenu() + } + } + + override fun onResume() { + super.onResume() + showToolbar(args.title) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + if (selectedItems.isNotEmpty()) { + inflater.inflate(R.menu.history_select_multi, menu) + + menu.findItem(R.id.delete_history_multi_select)?.let { deleteItem -> + deleteItem.title = SpannableString(deleteItem.title).apply { + setTextColor(requireContext(), R.attr.destructive) + } + } + } else { + inflater.inflate(R.menu.history_menu, menu) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.share_history_multi_select -> { + interactor.onShareMenuItem(selectedItems) + true + } + R.id.delete_history_multi_select -> { + interactor.onDeleteMenuItem(selectedItems) + true + } + R.id.open_history_in_new_tabs_multi_select -> { + openItemsInNewTab { selectedItem -> + selectedItem.url + } + + showTabTray() + true + } + R.id.open_history_in_private_tabs_multi_select -> { + openItemsInNewTab(private = true) { selectedItem -> + selectedItem.url + } + + (activity as HomeActivity).apply { + browsingModeManager.mode = BrowsingMode.Private + supportActionBar?.hide() + } + + showTabTray() + true + } + R.id.history_delete_all -> { + interactor.onDeleteAllMenuItem() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _historyMetadataGroupView = null + } + + override val selectedItems: Set get() = + historyMetadataGroupStore.state.items.filter { it.selected }.toSet() + + override fun onBackPressed(): Boolean = interactor.onBackPressed(selectedItems) + + private fun showTabTray() { + findNavController().nav( + R.id.historyMetadataGroupFragment, + HistoryMetadataGroupFragmentDirections.actionGlobalTabsTrayFragment() + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStore.kt new file mode 100644 index 000000000000..4d43148a4974 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/HistoryMetadataGroupFragmentStore.kt @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import org.mozilla.fenix.library.history.HistoryItem + +/** + * + */ +class HistoryMetadataGroupFragmentStore(initialState: HistoryMetadataGroupFragmentState) : + Store( + initialState, + ::historyStateReducer + ) + +/** + * + */ +sealed class HistoryMetadataGroupFragmentAction : Action { + data class UpdateHistoryItems(val items: List) : + HistoryMetadataGroupFragmentAction() + data class Select(val item: HistoryItem) : HistoryMetadataGroupFragmentAction() + data class Deselect(val item: HistoryItem) : HistoryMetadataGroupFragmentAction() + object DeselectAll : HistoryMetadataGroupFragmentAction() +} + +/** + * + */ +data class HistoryMetadataGroupFragmentState( + val items: List +) : State + +/** + * + */ +private fun historyStateReducer( + state: HistoryMetadataGroupFragmentState, + action: HistoryMetadataGroupFragmentAction +): HistoryMetadataGroupFragmentState { + return when (action) { + is HistoryMetadataGroupFragmentAction.UpdateHistoryItems -> + state.copy(items = action.items) + is HistoryMetadataGroupFragmentAction.Select -> + state.copy( + items = state.items.toMutableList() + .map { + if (it == action.item) { + it.copy(selected = true) + } else { + it + } + } + ) + is HistoryMetadataGroupFragmentAction.Deselect -> + state.copy( + items = state.items.toMutableList() + .map { + if (it == action.item) { + it.copy(selected = false) + } else { + it + } + } + ) + is HistoryMetadataGroupFragmentAction.DeselectAll -> + state.copy( + items = state.items.toMutableList() + .map { it.copy(selected = false) } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupController.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupController.kt new file mode 100644 index 000000000000..9de1f965f3b1 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/controller/HistoryMetadataGroupController.kt @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.controller + +import androidx.navigation.NavController +import mozilla.components.concept.engine.prompt.ShareData +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.nav +import org.mozilla.fenix.library.history.HistoryItem +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentAction +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentStore + +/** + * + */ +interface HistoryMetadataGroupController { + + /** + * + */ + fun handleOpen(item: HistoryItem) + + /** + * + */ + fun handleSelect(item: HistoryItem) + + /** + * + */ + fun handleDeselect(item: HistoryItem) + + /** + * + */ + fun handleBackPressed(items: Set): Boolean + + fun handleShare(items: Set) +} + +/** + * + */ +class DefaultHistoryMetadataGroupController( + private val activity: HomeActivity, + private val store: HistoryMetadataGroupFragmentStore, + private val navController: NavController +) : HistoryMetadataGroupController { + + override fun handleOpen(item: HistoryItem) { + activity.openToBrowserAndLoad( + searchTermOrURL = item.url, + newTab = true, + from = BrowserDirection.FromHistoryMetadataGroup + ) + } + + override fun handleSelect(item: HistoryItem) { + store.dispatch(HistoryMetadataGroupFragmentAction.Select(item)) + } + + override fun handleDeselect(item: HistoryItem) { + store.dispatch(HistoryMetadataGroupFragmentAction.Deselect(item)) + } + + override fun handleBackPressed(items: Set): Boolean { + return if (items.isNotEmpty()) { + store.dispatch(HistoryMetadataGroupFragmentAction.DeselectAll) + true + } else { + false + } + } + + override fun handleShare(items: Set) { + navController.nav( + R.id.historyMetadataGroupFragment, + HistoryMetadataGroupFragmentDirections.actionGlobalShareFragment( + data = items.map { ShareData(url = it.url, title = it.title) }.toTypedArray() + ) + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/interactor/HistoryMetadataGroupInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/interactor/HistoryMetadataGroupInteractor.kt new file mode 100644 index 000000000000..ce9e8acebf67 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/interactor/HistoryMetadataGroupInteractor.kt @@ -0,0 +1,71 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.interactor + +import org.mozilla.fenix.library.history.HistoryItem +import org.mozilla.fenix.library.historymetadata.controller.HistoryMetadataGroupController +import org.mozilla.fenix.selection.SelectionInteractor + +/** + * + */ +interface HistoryMetadataGroupInteractor : SelectionInteractor { + + /** + * + */ + fun onBackPressed(items: Set): Boolean + + /** + * + */ + fun onDeleteMenuItem(items: Set) + + /** + * + */ + fun onDeleteAllMenuItem() + + /** + * + */ + fun onShareMenuItem(items: Set) +} + +/** + * + */ +class DefaultHistoryMetadataGroupInteractor( + private val controller: HistoryMetadataGroupController +) : HistoryMetadataGroupInteractor { + + override fun open(item: HistoryItem) { + controller.handleOpen(item) + } + + override fun select(item: HistoryItem) { + controller.handleSelect(item) + } + + override fun deselect(item: HistoryItem) { + controller.handleDeselect(item) + } + + override fun onBackPressed(items: Set): Boolean { + return controller.handleBackPressed(items) + } + + override fun onDeleteMenuItem(items: Set) { + TODO("Not yet implemented") + } + + override fun onDeleteAllMenuItem() { + TODO("Not yet implemented") + } + + override fun onShareMenuItem(items: Set) { + controller.handleShare(items) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupAdapter.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupAdapter.kt new file mode 100644 index 000000000000..baf00b847b9c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupAdapter.kt @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.view + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import org.mozilla.fenix.library.history.HistoryItem +import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor +import org.mozilla.fenix.selection.SelectionHolder + +class HistoryMetadataGroupAdapter( + private val interactor: HistoryMetadataGroupInteractor +) : ListAdapter(DiffCallback), + SelectionHolder { + + private var selectedHistoryItems: Set = emptySet() + + override val selectedItems: Set + get() = selectedHistoryItems + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): HistoryMetadataGroupItemViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(HistoryMetadataGroupItemViewHolder.LAYOUT_ID, parent, false) + return HistoryMetadataGroupItemViewHolder(view, interactor, this) + } + + override fun onBindViewHolder(holder: HistoryMetadataGroupItemViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + fun updateData(items: List) { + this.selectedHistoryItems = items.filter { it.selected }.toSet() + notifyItemRangeChanged(0, items.size) + submitList(items) + } + + internal object DiffCallback : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean = + oldItem.id == newItem.id + + override fun areItemsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean = + oldItem == newItem + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolder.kt new file mode 100644 index 000000000000..ed5104417ccd --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupItemViewHolder.kt @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.view + +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.HistoryMetadataGroupListItemBinding +import org.mozilla.fenix.ext.hideAndDisable +import org.mozilla.fenix.ext.showAndEnable +import org.mozilla.fenix.library.history.HistoryItem +import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor +import org.mozilla.fenix.selection.SelectionHolder + +class HistoryMetadataGroupItemViewHolder( + view: View, + private val interactor: HistoryMetadataGroupInteractor, + private val selectionHolder: SelectionHolder +) : RecyclerView.ViewHolder(view) { + + private val binding = HistoryMetadataGroupListItemBinding.bind(view) + + private var item: HistoryItem? = null + + fun bind(item: HistoryItem) { + binding.historyLayout.titleView.text = item.title + binding.historyLayout.urlView.text = item.url + + binding.historyLayout.setSelectionInteractor(item, selectionHolder, interactor) + binding.historyLayout.changeSelected(item in selectionHolder.selectedItems) + + if (this.item?.url != item.url) { + binding.historyLayout.loadFavicon(item.url) + } + + binding.historyLayout.overflowView.setImageResource(R.drawable.mozac_ic_close) + + if (selectionHolder.selectedItems.isEmpty()) { + binding.historyLayout.overflowView.showAndEnable() + } else { + binding.historyLayout.overflowView.hideAndDisable() + } + + this.item = item + } + + companion object { + const val LAYOUT_ID = R.layout.history_metadata_group_list_item + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupView.kt b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupView.kt new file mode 100644 index 000000000000..896b1ca3062f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/historymetadata/view/HistoryMetadataGroupView.kt @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.library.historymetadata.view + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import org.mozilla.fenix.R +import org.mozilla.fenix.databinding.ComponentHistoryMetadataGroupBinding +import org.mozilla.fenix.library.LibraryPageView +import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentState +import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor + +/** + * Shows a list of history metadata items. + */ +class HistoryMetadataGroupView( + container: ViewGroup, + val interactor: HistoryMetadataGroupInteractor, + val title: String +) : LibraryPageView(container) { + + private val binding = ComponentHistoryMetadataGroupBinding.inflate( + LayoutInflater.from(container.context), container, true + ) + + private val historyMetadataGroupAdapter = HistoryMetadataGroupAdapter(interactor) + + init { + binding.historyMetadataGroupList.apply { + layoutManager = LinearLayoutManager(containerView.context) + adapter = historyMetadataGroupAdapter + } + } + + /** + * Updates the display of the history metadata items based on the given + * [HistoryMetadataGroupFragmentState]. + */ + fun update(state: HistoryMetadataGroupFragmentState) { + binding.historyMetadataGroupList.isVisible = state.items.isNotEmpty() + binding.historyMetadataGroupEmptyView.isVisible = state.items.isEmpty() + + historyMetadataGroupAdapter.updateData(state.items) + + val selectedItems = state.items.filter { it.selected } + + if (selectedItems.isEmpty()) { + setUiForNormalMode(title) + } else { + setUiForSelectingMode( + context.getString(R.string.history_multi_select_title, selectedItems.size) + ) + } + } +} diff --git a/app/src/main/res/layout/component_history_metadata_group.xml b/app/src/main/res/layout/component_history_metadata_group.xml new file mode 100644 index 000000000000..25b0f348ee7c --- /dev/null +++ b/app/src/main/res/layout/component_history_metadata_group.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_history_metadata_group.xml b/app/src/main/res/layout/fragment_history_metadata_group.xml new file mode 100644 index 000000000000..cc8af567be84 --- /dev/null +++ b/app/src/main/res/layout/fragment_history_metadata_group.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/history_metadata_group_list_item.xml b/app/src/main/res/layout/history_metadata_group_list_item.xml new file mode 100644 index 000000000000..3fa370519eeb --- /dev/null +++ b/app/src/main/res/layout/history_metadata_group_list_item.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/layout/library_site_item.xml b/app/src/main/res/layout/library_site_item.xml index ca050dafe5ad..530759fed501 100644 --- a/app/src/main/res/layout/library_site_item.xml +++ b/app/src/main/res/layout/library_site_item.xml @@ -23,9 +23,7 @@ android:id="@+id/favicon" android:layout_width="match_parent" android:layout_height="match_parent" - android:padding="10dp" - android:background="@drawable/favicon_background" - android:backgroundTint="?neutral" + android:padding="8dp" android:importantForAccessibility="no" tools:src="@drawable/ic_folder_icon" /> + + @@ -230,6 +234,17 @@ android:label="@string/library_history" tools:layout="@layout/fragment_history" /> + + + + + Close + + %d site + + %d sites + Recently closed tabs