For #9505: Adds possibility to sort saved logins

Currently we support sorting by name and by last used. Also, the selected
option is saved in shared preferences so that the last option chosen by
the user is properly displayed even after the app was restarted.
fennec/production
ValentinTimisica 4 years ago committed by Emily Kager
parent 2e45244b6c
commit 24ba9f2fc8

@ -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
}
}

@ -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<Toolbar>(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"
}
}

@ -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<SavedLoginsItem>) : 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<SavedLoginsItem>,
val filteredItems: List<SavedLoginsItem>
val filteredItems: List<SavedLoginsItem>,
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
}
}
}

@ -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)
}
}

@ -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 }
}
}
}

@ -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()

@ -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<SavedLoginsItem>): List<SavedLoginsItem>
abstract val appContext: Context
data class Alphabetically(override val appContext: Context) : SortingStrategy() {
override fun invoke(logins: List<SavedLoginsItem>): List<SavedLoginsItem> {
return logins.sortedBy { it.url.urlToTrimmedHost(appContext) }
}
}
data class LastUsed(override val appContext: Context) : SortingStrategy() {
override fun invoke(logins: List<SavedLoginsItem>): List<SavedLoginsItem> {
return logins.sortedByDescending { it.timeLastUsed }
}
}
}

@ -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
}
}
}

@ -13,4 +13,11 @@
app:titleTextAppearance="@style/ToolbarTitleTextStyle"
android:background="?foundation"
android:elevation="8dp">
</androidx.appcompat.widget.Toolbar>
<FrameLayout
android:id="@+id/toolbar_child_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"/>
</androidx.appcompat.widget.Toolbar>

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/sort_logins_menu_root"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackgroundBorderless">
<TextView
android:id="@+id/toolbar_title"
style="@style/ToolbarTitleTextStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/preferences_passwords_saved_logins"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/toolbar_chevron_icon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<View
android:id="@+id/drop_down_menu_anchor_view"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_below="@id/toolbar_title"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar_title" />
<ImageView
android:id="@+id/toolbar_chevron_icon"
android:layout_width="@dimen/saved_logins_sort_menu_dropdown_chevron_icon_size"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_marginStart="@dimen/saved_logins_sort_menu_dropdown_chevron_icon_margin_start"
android:layout_toEndOf="@+id/toolbar_title"
android:adjustViewBounds="true"
android:contentDescription="@string/saved_logins_menu_dropdown_chevron_icon_content_description"
app:srcCompat="@drawable/ic_chevron"
app:layout_constraintBottom_toBottomOf="@+id/toolbar_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/toolbar_title"
app:layout_constraintTop_toTopOf="@+id/toolbar_title" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -147,4 +147,8 @@
<dimen name="tab_tray_divider_margin_start">124dp</dimen>
<dimen name="tab_tray_divider_margin_end">0dp</dimen>
<!-- Saved Logins Fragment -->
<dimen name="saved_logins_sort_menu_dropdown_chevron_icon_margin_start">10dp</dimen>
<dimen name="saved_logins_sort_menu_dropdown_chevron_icon_size">12dp</dimen>
</resources>

@ -76,6 +76,7 @@
<string name="pref_key_fxa_signed_in" translatable="false">pref_key_fxa_signed_in</string>
<string name="pref_key_fxa_has_synced_items" translatable="false">pref_key_fxa_has_synced_items</string>
<string name="pref_key_search_widget_installed" translatable="false">pref_key_search_widget_installed</string>
<string name="pref_key_saved_logins_sorting_strategy" translatable="false">pref_key_saved_logins_sorting_strategy</string>
<!-- Search Settings -->
<string name="pref_key_search_engine_list" translatable="false">pref_key_search_engine_list</string>

@ -1239,6 +1239,12 @@
<string name="preference_accessibility_force_enable_zoom">Zoom on all websites</string>
<!-- Summary for Accessibility Force Enable Zoom Preference -->
<string name="preference_accessibility_force_enable_zoom_summary">Enable to allow pinch and zoom, even on websites that prevent this gesture.</string>
<!-- Saved logins sorting strategy menu item -by name- (if selected, it will sort saved logins alphabetically) -->
<string name="saved_logins_sort_strategy_alphabetically">Name (A-Z)</string>
<!-- Saved logins sorting strategy menu item -by last used- (if selected, it will sort saved logins by last used) -->
<string name="saved_logins_sort_strategy_last_used">Last used</string>
<!-- Content description (not visible, for screen readers etc.): Sort saved logins dropdown menu chevron icon -->
<string name="saved_logins_menu_dropdown_chevron_icon_content_description">Sort logins menu</string>
<!-- Title of the Add search engine screen -->
<string name="search_engine_add_custom_search_engine_title">Add search engine</string>

Loading…
Cancel
Save