diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsController.kt new file mode 100644 index 000000000..224ff6c45 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsController.kt @@ -0,0 +1,22 @@ +/* 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.settings.logins + +import org.mozilla.fenix.utils.Settings + +interface SavedLoginsController { + fun handleSort(sortingStrategy: SortingStrategy) +} + +class DefaultSavedLoginsController( + val store: SavedLoginsFragmentStore, + val settings: Settings +) : SavedLoginsController { + + override fun handleSort(sortingStrategy: SortingStrategy) { + store.dispatch(SavedLoginsFragmentAction.SortLogins(sortingStrategy)) + settings.savedLoginsSortingStrategy = sortingStrategy + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt index b121ddbb3..1a9d7e769 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt @@ -6,26 +6,31 @@ package org.mozilla.fenix.settings.logins import android.os.Bundle import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.view.inputmethod.EditorInfo +import android.view.Menu +import android.view.MenuInflater +import android.widget.FrameLayout +import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.Toolbar +import androidx.constraintlayout.widget.ConstraintLayout import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import kotlinx.android.synthetic.main.fragment_saved_logins.view.* -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.ObsoleteCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ObsoleteCoroutinesApi +import mozilla.components.browser.menu.BrowserMenu import mozilla.components.concept.storage.Login import mozilla.components.lib.state.ext.consumeFrom import org.mozilla.fenix.BrowserDirection @@ -39,10 +44,16 @@ import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.settings.SupportUtils +@SuppressWarnings("TooManyFunctions") class SavedLoginsFragment : Fragment() { private lateinit var savedLoginsStore: SavedLoginsFragmentStore private lateinit var savedLoginsView: SavedLoginsView private lateinit var savedLoginsInteractor: SavedLoginsInteractor + private lateinit var dropDownMenuAnchorView: View + private lateinit var sortingStrategyMenu: SavedLoginsSortingStrategyMenu + private lateinit var sortingStrategyPopupMenu: BrowserMenu + private lateinit var toolbarChildContainer: FrameLayout + private lateinit var sortLoginsMenuRoot: ConstraintLayout override fun onResume() { super.onResume() @@ -50,7 +61,7 @@ class SavedLoginsFragment : Fragment() { WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE ) - showToolbar(getString(R.string.preferences_passwords_saved_logins)) + initToolbar() } override fun onCreate(savedInstanceState: Bundle?) { @@ -69,11 +80,17 @@ class SavedLoginsFragment : Fragment() { SavedLoginsFragmentState( isLoading = true, items = listOf(), - filteredItems = listOf() + filteredItems = listOf(), + searchedForText = null, + sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, + highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem ) ) } - savedLoginsInteractor = SavedLoginsInteractor(::itemClicked, ::openLearnMore) + val savedLoginsController: SavedLoginsController = + DefaultSavedLoginsController(savedLoginsStore, requireContext().settings()) + savedLoginsInteractor = + SavedLoginsInteractor(savedLoginsController, ::itemClicked, ::openLearnMore) savedLoginsView = SavedLoginsView(view.savedLoginsLayout, savedLoginsInteractor) loadAndMapLogins() return view @@ -84,6 +101,7 @@ class SavedLoginsFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) consumeFrom(savedLoginsStore) { + sortingStrategyMenu.updateMenu(savedLoginsStore.state.highlightedItem) savedLoginsView.update(it) } } @@ -111,6 +129,11 @@ class SavedLoginsFragment : Fragment() { * If we pause this fragment, we want to pop users back to reauth */ override fun onPause() { + toolbarChildContainer.removeAllViews() + toolbarChildContainer.visibility = View.GONE + (activity as HomeActivity).getSupportActionBarAndInflateIfNecessary().setDisplayShowTitleEnabled(true) + sortingStrategyPopupMenu.dismiss() + if (findNavController().currentDestination?.id != R.id.savedLoginSiteInfoFragment) { activity?.let { it.checkAndUpdateScreenshotPermission(it.settings()) } findNavController().popBackStack(R.id.loginsFragment, false) @@ -144,7 +167,7 @@ class SavedLoginsFragment : Fragment() { logins?.let { withContext(Main) { savedLoginsStore.dispatch(SavedLoginsFragmentAction.UpdateLogins(logins.map { item -> - SavedLoginsItem(item.origin, item.username, item.password, item.guid!!) + SavedLoginsItem(item.origin, item.username, item.password, item.guid!!, item.timeLastUsed) })) } } @@ -155,4 +178,67 @@ class SavedLoginsFragment : Fragment() { } } } + + private fun initToolbar() { + showToolbar(getString(R.string.preferences_passwords_saved_logins)) + (activity as HomeActivity).getSupportActionBarAndInflateIfNecessary() + .setDisplayShowTitleEnabled(false) + toolbarChildContainer = initChildContainerFromToolbar() + sortLoginsMenuRoot = inflateSortLoginsMenuRoot() + dropDownMenuAnchorView = sortLoginsMenuRoot.findViewById(R.id.drop_down_menu_anchor_view) + when (requireContext().settings().savedLoginsSortingStrategy) { + is SortingStrategy.Alphabetically -> setupMenu(SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort) + is SortingStrategy.LastUsed -> setupMenu(SavedLoginsSortingStrategyMenu.Item.LastUsedSort) + } + } + + private fun initChildContainerFromToolbar(): FrameLayout { + val activity = activity as? AppCompatActivity + val toolbar = (activity as HomeActivity).findViewById(R.id.navigationToolbar) + + return (toolbar.findViewById(R.id.toolbar_child_container) as FrameLayout).apply { + visibility = View.VISIBLE + } + } + + private fun inflateSortLoginsMenuRoot(): ConstraintLayout { + return LayoutInflater.from(context) + .inflate(R.layout.saved_logins_sort_items_toolbar_child, toolbarChildContainer, true) + .findViewById(R.id.sort_logins_menu_root) + } + + private fun attachMenu() { + sortingStrategyPopupMenu = sortingStrategyMenu.menuBuilder.build(requireContext()) + + sortLoginsMenuRoot.setOnClickListener { + sortLoginsMenuRoot.isActivated = true + sortingStrategyPopupMenu.show( + anchor = dropDownMenuAnchorView, + orientation = BrowserMenu.Orientation.DOWN + ) { + sortLoginsMenuRoot.isActivated = false + } + } + } + + private fun setupMenu(itemToHighlight: SavedLoginsSortingStrategyMenu.Item) { + sortingStrategyMenu = SavedLoginsSortingStrategyMenu(requireContext(), itemToHighlight) { + when (it) { + SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort -> { + savedLoginsInteractor.sort(SortingStrategy.Alphabetically(requireContext().applicationContext)) + } + + SavedLoginsSortingStrategyMenu.Item.LastUsedSort -> { + savedLoginsInteractor.sort(SortingStrategy.LastUsed(requireContext().applicationContext)) + } + } + } + + attachMenu() + } + + companion object { + const val SORTING_STRATEGY_ALPHABETICALLY = "ALPHABETICALLY" + const val SORTING_STRATEGY_LAST_USED = "LAST_USED" + } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragmentStore.kt index 68898bf39..864c66257 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragmentStore.kt @@ -15,13 +15,16 @@ import mozilla.components.lib.state.Store * @property url Site of the saved login * @property userName Username that's saved for this site * @property password Password that's saved for this site + * @property id The unique identifier for this login entry + * @property timeLastUsed Time of last use in milliseconds from the unix epoch. */ @Parcelize data class SavedLoginsItem( val url: String, val userName: String?, val password: String?, - val id: String + val id: String, + val timeLastUsed: Long ) : Parcelable @@ -40,17 +43,25 @@ class SavedLoginsFragmentStore(initialState: SavedLoginsFragmentState) : sealed class SavedLoginsFragmentAction : Action { data class FilterLogins(val newText: String?) : SavedLoginsFragmentAction() data class UpdateLogins(val list: List) : SavedLoginsFragmentAction() + data class SortLogins(val sortingStrategy: SortingStrategy) : SavedLoginsFragmentAction() } /** * The state for the Saved Logins Screen + * @property isLoading State to know when to show loading * @property items Source of truth of list of logins - * @property items Filtered (or not) list of logins to display + * @property filteredItems Filtered (or not) list of logins to display + * @property searchedForText String used by the user to filter logins + * @property sortingStrategy sorting strategy selected by the user (Currently we support + * sorting alphabetically and by last used) */ data class SavedLoginsFragmentState( val isLoading: Boolean = false, val items: List, - val filteredItems: List + val filteredItems: List, + val searchedForText: String?, + val sortingStrategy: SortingStrategy, + val highlightedItem: SavedLoginsSortingStrategyMenu.Item ) : State /** @@ -61,21 +72,75 @@ private fun savedLoginsStateReducer( action: SavedLoginsFragmentAction ): SavedLoginsFragmentState { return when (action) { - is SavedLoginsFragmentAction.UpdateLogins -> state.copy( + is SavedLoginsFragmentAction.UpdateLogins -> { + filterItems( + state.searchedForText, state.sortingStrategy, state.copy( + isLoading = false, + items = action.list, + filteredItems = emptyList() + ) + ) + } + is SavedLoginsFragmentAction.FilterLogins -> + filterItems( + action.newText, + state.sortingStrategy, + state + ) + is SavedLoginsFragmentAction.SortLogins -> + filterItems( + state.searchedForText, + action.sortingStrategy, + state + ) + } +} + +/** + * @return [SavedLoginsFragmentState] containing a new [SavedLoginsFragmentState.filteredItems] + * with filtered [SavedLoginsFragmentState.items] + * + * @param searchedForText based on which [SavedLoginsFragmentState.items] will be filtered. + * @param sortingStrategy based on which [SavedLoginsFragmentState.items] will be sorted. + * @param state previous [SavedLoginsFragmentState] containing all the other properties + * with which a new state will be created + */ +private fun filterItems( + searchedForText: String?, + sortingStrategy: SortingStrategy, + state: SavedLoginsFragmentState +): SavedLoginsFragmentState { + return if (searchedForText.isNullOrBlank()) { + state.copy( isLoading = false, - items = action.list, - filteredItems = action.list + sortingStrategy = sortingStrategy, + highlightedItem = sortingStrategyToMenuItem(sortingStrategy), + searchedForText = searchedForText, + filteredItems = sortingStrategy(state.items) ) - is SavedLoginsFragmentAction.FilterLogins -> { - if (action.newText.isNullOrBlank()) { - state.copy( - isLoading = false, - filteredItems = state.items) - } else { - state.copy( - isLoading = false, - filteredItems = state.items.filter { it.url.contains(action.newText) }) + } else { + state.copy( + isLoading = false, + sortingStrategy = sortingStrategy, + highlightedItem = sortingStrategyToMenuItem(sortingStrategy), + searchedForText = searchedForText, + filteredItems = sortingStrategy(state.items).filter { + it.url.contains( + searchedForText + ) } + ) + } +} + +private fun sortingStrategyToMenuItem(sortingStrategy: SortingStrategy): SavedLoginsSortingStrategyMenu.Item { + return when (sortingStrategy) { + is SortingStrategy.Alphabetically -> { + SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort + } + + is SortingStrategy.LastUsed -> { + SavedLoginsSortingStrategyMenu.Item.LastUsedSort } } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractor.kt index 5aa7ae1c5..962cd692b 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractor.kt @@ -9,6 +9,7 @@ package org.mozilla.fenix.settings.logins * Provides implementations for the SavedLoginsViewInteractor */ class SavedLoginsInteractor( + private val savedLoginsController: SavedLoginsController, private val itemClicked: (SavedLoginsItem) -> Unit, private val learnMore: () -> Unit ) : SavedLoginsViewInteractor { @@ -18,4 +19,8 @@ class SavedLoginsInteractor( override fun onLearnMore() { learnMore.invoke() } + + override fun sort(sortingStrategy: SortingStrategy) { + savedLoginsController.handleSort(sortingStrategy) + } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSortingStrategyMenu.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSortingStrategyMenu.kt new file mode 100644 index 000000000..8ac54dc81 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSortingStrategyMenu.kt @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins + +import android.content.Context +import mozilla.components.browser.menu.BrowserMenuBuilder +import mozilla.components.browser.menu.item.SimpleBrowserMenuHighlightableItem +import mozilla.components.support.ktx.android.content.getColorFromAttr +import org.mozilla.fenix.R +import org.mozilla.fenix.theme.ThemeManager + +class SavedLoginsSortingStrategyMenu( + private val context: Context, + private val itemToHighlight: Item, + private val onItemTapped: (Item) -> Unit = {} +) { + sealed class Item { + object AlphabeticallySort : Item() + object LastUsedSort : Item() + } + + val menuBuilder by lazy { BrowserMenuBuilder(menuItems) } + + private val menuItems by lazy { + listOfNotNull( + SimpleBrowserMenuHighlightableItem( + label = context.getString(R.string.saved_logins_sort_strategy_alphabetically), + textColorResource = ThemeManager.resolveAttribute(R.attr.primaryText, context), + itemType = Item.AlphabeticallySort, + backgroundTint = context.getColorFromAttr(R.attr.colorControlHighlight), + isHighlighted = { itemToHighlight == Item.AlphabeticallySort } + ) { + onItemTapped.invoke(Item.AlphabeticallySort) + }, + + SimpleBrowserMenuHighlightableItem( + label = context.getString(R.string.saved_logins_sort_strategy_last_used), + textColorResource = ThemeManager.resolveAttribute(R.attr.primaryText, context), + itemType = Item.LastUsedSort, + backgroundTint = context.getColorFromAttr(R.attr.colorControlHighlight), + isHighlighted = { itemToHighlight == Item.LastUsedSort } + ) { + onItemTapped.invoke(Item.LastUsedSort) + } + ) + } + + internal fun updateMenu(itemToHighlight: Item) { + menuItems.forEach { + it.isHighlighted = { itemToHighlight == it.itemType } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt index 42170ae5d..e083168be 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt @@ -28,6 +28,8 @@ interface SavedLoginsViewInteractor { fun itemClicked(item: SavedLoginsItem) fun onLearnMore() + + fun sort(sortingStrategy: SortingStrategy) } /** @@ -48,6 +50,7 @@ class SavedLoginsView( view.saved_logins_list.apply { adapter = loginsAdapter layoutManager = LinearLayoutManager(containerView.context) + itemAnimator = null } val learnMoreText = view.saved_passwords_empty_learn_more.text.toString() diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SortingStrategy.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SortingStrategy.kt new file mode 100644 index 000000000..2b50c4c89 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SortingStrategy.kt @@ -0,0 +1,25 @@ +/* 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.settings.logins + +import android.content.Context +import org.mozilla.fenix.ext.urlToTrimmedHost + +sealed class SortingStrategy { + abstract operator fun invoke(logins: List): List + abstract val appContext: Context + + data class Alphabetically(override val appContext: Context) : SortingStrategy() { + override fun invoke(logins: List): List { + return logins.sortedBy { it.url.urlToTrimmedHost(appContext) } + } + } + + data class LastUsed(override val appContext: Context) : SortingStrategy() { + override fun invoke(logins: List): List { + return logins.sortedByDescending { it.timeLastUsed } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index 6e26340e5..d2558ec46 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -31,6 +31,9 @@ import org.mozilla.fenix.components.metrics.MozillaProductDetector import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.settings.PhoneFeature import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType +import org.mozilla.fenix.settings.logins.SavedLoginsFragment +import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu +import org.mozilla.fenix.settings.logins.SortingStrategy import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener import java.security.InvalidParameterException @@ -647,4 +650,37 @@ class Settings private constructor( .putBoolean(appContext.getPreferenceKey(R.string.pref_key_enable_new_tab_tray), value) .apply() } + + private var savedLoginsSortingStrategyString by stringPreference( + appContext.getPreferenceKey(R.string.pref_key_saved_logins_sorting_strategy), + default = SavedLoginsFragment.SORTING_STRATEGY_ALPHABETICALLY + ) + + val savedLoginsMenuHighlightedItem: SavedLoginsSortingStrategyMenu.Item + get() { + return when (savedLoginsSortingStrategyString) { + SavedLoginsFragment.SORTING_STRATEGY_ALPHABETICALLY -> { + SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort + } + SavedLoginsFragment.SORTING_STRATEGY_LAST_USED -> { + SavedLoginsSortingStrategyMenu.Item.LastUsedSort + } + else -> SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort + } + } + + var savedLoginsSortingStrategy: SortingStrategy + get() { + return when (savedLoginsSortingStrategyString) { + SavedLoginsFragment.SORTING_STRATEGY_ALPHABETICALLY -> SortingStrategy.Alphabetically(appContext) + SavedLoginsFragment.SORTING_STRATEGY_LAST_USED -> SortingStrategy.LastUsed(appContext) + else -> SortingStrategy.Alphabetically(appContext) + } + } + set(value) { + savedLoginsSortingStrategyString = when (value) { + is SortingStrategy.Alphabetically -> SavedLoginsFragment.SORTING_STRATEGY_ALPHABETICALLY + is SortingStrategy.LastUsed -> SavedLoginsFragment.SORTING_STRATEGY_LAST_USED + } + } } diff --git a/app/src/main/res/layout/navigation_toolbar.xml b/app/src/main/res/layout/navigation_toolbar.xml index 7bf884739..6ec00ed81 100644 --- a/app/src/main/res/layout/navigation_toolbar.xml +++ b/app/src/main/res/layout/navigation_toolbar.xml @@ -13,4 +13,11 @@ app:titleTextAppearance="@style/ToolbarTitleTextStyle" android:background="?foundation" android:elevation="8dp"> - \ No newline at end of file + + + + diff --git a/app/src/main/res/layout/saved_logins_sort_items_toolbar_child.xml b/app/src/main/res/layout/saved_logins_sort_items_toolbar_child.xml new file mode 100644 index 000000000..95ac73643 --- /dev/null +++ b/app/src/main/res/layout/saved_logins_sort_items_toolbar_child.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index b9b063c89..d9de93160 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -147,4 +147,8 @@ 124dp 0dp + + 10dp + 12dp + diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 0388b54b9..378287639 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -76,6 +76,7 @@ pref_key_fxa_signed_in pref_key_fxa_has_synced_items pref_key_search_widget_installed + pref_key_saved_logins_sorting_strategy pref_key_search_engine_list diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d0ab524a0..0d4d37c76 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1239,6 +1239,12 @@ Zoom on all websites Enable to allow pinch and zoom, even on websites that prevent this gesture. + + Name (A-Z) + + Last used + + Sort logins menu Add search engine