1
0
Fork 0

For #10173: login duplicates and save (#11208)

* Extract controller into it's own class. Implement find dupes and filter based on username.

Create edit login controller. Add text watchers and check for duplicates.

Edit controller test

* Find duplicates and save to store

* Retrieve duplicates from AC and check list on username text changed

Move duplicates logic into the controller

* Add glean pings for delete and edit. Move logic for login manipulation into the datastore.

* Use correct threads in controller. Enable save button when applicable.

Save enabled in datastore.

Move login data to datastore

Rebase with password error states

Update metrics to be more specific for edit

* Create logins controller for AC calls

* Interactor and controller methods for edit login. Add edit view to separate out some layout manipulation.

Inflate view in edit fragment. Double layout showing up.

Edit view

Controller tests

Controller tests passing

Interactor tests

Lint and detekt cleanup

* Remove datastore and use storage controller for all logins calls to password storage.

Addressed comments

Lint
:

Rebase - 1
master
Elise Richards 2020-07-16 17:08:04 -05:00 committed by GitHub
parent 4dd0c0f224
commit 023a4983fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1140 additions and 490 deletions

View File

@ -2444,6 +2444,39 @@ logins:
notification_emails: notification_emails:
- fenix-core@mozilla.com - fenix-core@mozilla.com
expires: "2020-10-01" expires: "2020-10-01"
open_login_editor:
type: event
description: |
A user entered the edit screen for an individual saved login
bugs:
- https://github.com/mozilla-mobile/fenix/issues/10173
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/11208
notification_emails:
- fenix-core@mozilla.com
expires: "2020-10-01"
delete_saved_login:
type: event
description: |
A user confirms delete of a saved login
bugs:
- https://github.com/mozilla-mobile/fenix/issues/10173
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/11208
notification_emails:
- fenix-core@mozilla.com
expires: "2020-10-01"
save_edited_login:
type: event
description: |
A user saves changes made to an individual login
bugs:
- https://github.com/mozilla-mobile/fenix/issues/10173
data_reviews:
- https://github.com/mozilla-mobile/fenix/issues/11208
notification_emails:
- fenix-core@mozilla.com
expires: "2020-10-01"
download_notification: download_notification:
resume: resume:

View File

@ -26,6 +26,7 @@ import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.NavigationUI import androidx.navigation.ui.NavigationUI
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.gms.tasks.Tasks.call
import kotlinx.android.synthetic.main.activity_home.* import kotlinx.android.synthetic.main.activity_home.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -86,8 +87,8 @@ import org.mozilla.fenix.session.NotificationSessionObserver
import org.mozilla.fenix.settings.SettingsFragmentDirections import org.mozilla.fenix.settings.SettingsFragmentDirections
import org.mozilla.fenix.settings.TrackingProtectionFragmentDirections import org.mozilla.fenix.settings.TrackingProtectionFragmentDirections
import org.mozilla.fenix.settings.about.AboutFragmentDirections import org.mozilla.fenix.settings.about.AboutFragmentDirections
import org.mozilla.fenix.settings.logins.LoginDetailFragmentDirections import org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragmentDirections
import org.mozilla.fenix.settings.logins.SavedLoginsAuthFragmentDirections import org.mozilla.fenix.settings.logins.fragment.LoginDetailFragmentDirections
import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections
import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections
import org.mozilla.fenix.share.AddNewDeviceFragmentDirections import org.mozilla.fenix.share.AddNewDeviceFragmentDirections

View File

@ -466,6 +466,15 @@ private val Event.wrapper: EventWrapper<*>?
is Event.ViewLoginPassword -> EventWrapper<NoExtraKeys>( is Event.ViewLoginPassword -> EventWrapper<NoExtraKeys>(
{ Logins.viewPasswordLogin.record(it) } { Logins.viewPasswordLogin.record(it) }
) )
is Event.DeleteLogin -> EventWrapper<NoExtraKeys>(
{ Logins.deleteSavedLogin.record(it) }
)
is Event.EditLogin -> EventWrapper<NoExtraKeys>(
{ Logins.openLoginEditor.record(it) }
)
is Event.EditLoginSave -> EventWrapper<NoExtraKeys>(
{ Logins.saveEditedLogin.record(it) }
)
is Event.PrivateBrowsingShowSearchSuggestions -> EventWrapper<NoExtraKeys>( is Event.PrivateBrowsingShowSearchSuggestions -> EventWrapper<NoExtraKeys>(
{ SearchSuggestions.enableInPrivate.record(it) } { SearchSuggestions.enableInPrivate.record(it) }
) )

View File

@ -154,6 +154,9 @@ sealed class Event {
object OpenLogins : Event() object OpenLogins : Event()
object OpenOneLogin : Event() object OpenOneLogin : Event()
object CopyLogin : Event() object CopyLogin : Event()
object DeleteLogin : Event()
object EditLogin : Event()
object EditLoginSave : Event()
object ViewLoginPassword : Event() object ViewLoginPassword : Event()
object CustomEngineAdded : Event() object CustomEngineAdded : Event()
object CustomEngineDeleted : Event() object CustomEngineDeleted : Event()

View File

@ -210,9 +210,10 @@ class HistoryFragment : LibraryPageFragment<HistoryItem>(), UserInteractionHandl
return if (historyItems.size > 1) { return if (historyItems.size > 1) {
getString(R.string.history_delete_multiple_items_snackbar) getString(R.string.history_delete_multiple_items_snackbar)
} else { } else {
getString( String.format(
R.string.history_delete_single_item_snackbar, requireContext().getString(
historyItems.first().url.toShortUrl(requireComponents.publicSuffixList) R.string.history_delete_single_item_snackbar
), historyItems.first().url.toShortUrl(requireComponents.publicSuffixList)
) )
} }
} }

View File

@ -1,20 +0,0 @@
/* 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.view.ViewGroup
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_login_detail.*
/**
* View that contains and configures the Login Details
*/
class LoginDetailView(override val containerView: ViewGroup?) : LayoutContainer {
fun update(login: LoginsListState) {
webAddressText.text = login.currentItem?.origin
usernameText.text = login.currentItem?.username
passwordText.text = login.currentItem?.password
}
}

View File

@ -54,18 +54,20 @@ sealed class LoginsAction : Action {
data class UpdateLoginsList(val list: List<SavedLogin>) : LoginsAction() data class UpdateLoginsList(val list: List<SavedLogin>) : LoginsAction()
data class UpdateCurrentLogin(val item: SavedLogin) : LoginsAction() data class UpdateCurrentLogin(val item: SavedLogin) : LoginsAction()
data class SortLogins(val sortingStrategy: SortingStrategy) : LoginsAction() data class SortLogins(val sortingStrategy: SortingStrategy) : LoginsAction()
data class ListOfDupes(val dupeList: List<SavedLogin>) : LoginsAction()
data class LoginSelected(val item: SavedLogin) : LoginsAction() data class LoginSelected(val item: SavedLogin) : LoginsAction()
} }
/** /**
* The state for the Saved Logins Screen * The state for the Saved Logins Screen
* @property loginList Source of truth for local list of logins
* @property loginList Filterable list of logins to display * @property loginList Filterable list of logins to display
* @property currentItem The last item that was opened into the detail view * @property currentItem The last item that was opened into the detail view
* @property searchedForText String used by the user to filter logins * @property searchedForText String used by the user to filter logins
* @property sortingStrategy sorting strategy selected by the user (Currently we support * @property sortingStrategy sorting strategy selected by the user (Currently we support
* sorting alphabetically and by last used) * sorting alphabetically and by last used)
* @property highlightedItem The current selected sorting strategy from the sort menu * @property highlightedItem The current selected sorting strategy from the sort menu
* @property duplicateLogins The current list of possible duplicates for a selected login origin,
* httpRealm, and formActionOrigin
*/ */
data class LoginsListState( data class LoginsListState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@ -74,7 +76,8 @@ data class LoginsListState(
val currentItem: SavedLogin? = null, val currentItem: SavedLogin? = null,
val searchedForText: String?, val searchedForText: String?,
val sortingStrategy: SortingStrategy, val sortingStrategy: SortingStrategy,
val highlightedItem: SavedLoginsSortingStrategyMenu.Item val highlightedItem: SavedLoginsSortingStrategyMenu.Item,
val duplicateLogins: List<SavedLogin>
) : State ) : State
/** /**
@ -113,9 +116,14 @@ private fun savedLoginsStateReducer(
} }
is LoginsAction.LoginSelected -> { is LoginsAction.LoginSelected -> {
state.copy( state.copy(
isLoading = true, isLoading = true,
loginList = emptyList(), loginList = emptyList(),
filteredItems = emptyList() filteredItems = emptyList()
)
}
is LoginsAction.ListOfDupes -> {
state.copy(
duplicateLogins = action.dupeList
) )
} }
} }

View File

@ -1,135 +0,0 @@
/* 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.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.isVisible
import androidx.navigation.NavController
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_saved_logins.view.*
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.addUnderline
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.utils.Settings
/**
* View that contains and configures the Saved Logins List
*/
class SavedLoginsView(
override val containerView: ViewGroup,
val interactor: SavedLoginsInteractor
) : LayoutContainer {
val view: FrameLayout = LayoutInflater.from(containerView.context)
.inflate(R.layout.component_saved_logins, containerView, true)
.findViewById(R.id.saved_logins_wrapper)
private val loginsAdapter = LoginsAdapter(interactor)
init {
view.saved_logins_list.apply {
adapter = loginsAdapter
layoutManager = LinearLayoutManager(containerView.context)
itemAnimator = null
}
with(view.saved_passwords_empty_learn_more) {
movementMethod = LinkMovementMethod.getInstance()
addUnderline()
setOnClickListener { interactor.onLearnMoreClicked() }
}
with(view.saved_passwords_empty_message) {
val appName = context.getString(R.string.app_name)
text = String.format(
context.getString(
R.string.preferences_passwords_saved_logins_description_empty_text
), appName
)
}
}
fun update(state: LoginsListState) {
// todo MVI views should not have logic. Needs refactoring.
if (state.isLoading) {
view.progress_bar.isVisible = true
} else {
view.progress_bar.isVisible = false
view.saved_logins_list.isVisible = state.loginList.isNotEmpty()
view.saved_passwords_empty_view.isVisible = state.loginList.isEmpty()
}
loginsAdapter.submitList(state.filteredItems)
}
}
/**
* Interactor for the saved logins screen
*
* @param savedLoginsController [SavedLoginsController] which will be delegated for all users interactions.
*/
class SavedLoginsInteractor(
private val savedLoginsController: SavedLoginsController
) {
fun onItemClicked(item: SavedLogin) {
savedLoginsController.handleItemClicked(item)
}
fun onLearnMoreClicked() {
savedLoginsController.handleLearnMoreClicked()
}
fun onSortingStrategyChanged(sortingStrategy: SortingStrategy) {
savedLoginsController.handleSort(sortingStrategy)
}
}
/**
* Controller for the saved logins screen
*
* @param store Store used to hold in-memory collection state.
* @param navController NavController manages app navigation within a NavHost.
* @param browserNavigator Controller allowing browser navigation to any Uri.
* @param settings SharedPreferences wrapper for easier usage.
* @param metrics Controller that handles telemetry events.
*/
class SavedLoginsController(
private val store: LoginsFragmentStore,
private val navController: NavController,
private val browserNavigator: (
searchTermOrURL: String,
newTab: Boolean,
from: BrowserDirection
) -> Unit,
private val settings: Settings,
private val metrics: MetricController
) {
fun handleSort(sortingStrategy: SortingStrategy) {
store.dispatch(LoginsAction.SortLogins(sortingStrategy))
settings.savedLoginsSortingStrategy = sortingStrategy
}
fun handleItemClicked(item: SavedLogin) {
store.dispatch(LoginsAction.LoginSelected(item))
metrics.track(Event.OpenOneLogin)
navController.navigate(
SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(item.guid)
)
}
fun handleLearnMoreClicked() {
browserNavigator.invoke(
SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP),
true,
BrowserDirection.FromSavedLoginsFragment
)
}
}

View File

@ -0,0 +1,64 @@
/* 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.controller
import androidx.navigation.NavController
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.logins.LoginsAction
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragmentDirections
import org.mozilla.fenix.utils.Settings
/**
* Controller for the saved logins list
*
* @param loginsFragmentStore Store used to hold in-memory collection state.
* @param navController NavController manages app navigation within a NavHost.
* @param browserNavigator Controller allowing browser navigation to any Uri.
* @param settings SharedPreferences wrapper for easier usage.
* @param metrics Controller that handles telemetry events.
*/
class LoginsListController(
private val loginsFragmentStore: LoginsFragmentStore,
private val navController: NavController,
private val browserNavigator: (
searchTermOrURL: String,
newTab: Boolean,
from: BrowserDirection
) -> Unit,
private val settings: Settings,
private val metrics: MetricController
) {
fun handleItemClicked(item: SavedLogin) {
loginsFragmentStore.dispatch(LoginsAction.LoginSelected(item))
metrics.track(Event.OpenOneLogin)
navController.navigate(
SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(item.guid)
)
}
fun handleLearnMoreClicked() {
browserNavigator.invoke(
SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP),
true,
BrowserDirection.FromSavedLoginsFragment
)
}
fun handleSort(sortingStrategy: SortingStrategy) {
loginsFragmentStore.dispatch(
LoginsAction.SortLogins(
sortingStrategy
)
)
settings.savedLoginsSortingStrategy = sortingStrategy
}
}

View File

@ -0,0 +1,198 @@
/* 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.controller
import android.content.Context
import android.util.Log
import androidx.navigation.NavController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.Login
import mozilla.components.service.sync.logins.InvalidRecordException
import mozilla.components.service.sync.logins.LoginsStorageException
import mozilla.components.service.sync.logins.NoSuchRecordException
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.settings.logins.LoginsAction
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.fragment.EditLoginFragmentDirections
import org.mozilla.fenix.settings.logins.mapToSavedLogin
/**
* Controller for all saved logins interactions with the password storage component
*/
open class SavedLoginsStorageController(
private val context: Context,
private val viewLifecycleScope: CoroutineScope,
private val navController: NavController,
private val loginsFragmentStore: LoginsFragmentStore
) {
private suspend fun getLogin(loginId: String): Login? =
context.components.core.passwordsStorage.get(loginId)
fun delete(loginId: String) {
var deleteLoginJob: Deferred<Boolean>? = null
val deleteJob = viewLifecycleScope.launch(Dispatchers.IO) {
deleteLoginJob = async {
context.components.core.passwordsStorage.delete(loginId)
}
deleteLoginJob?.await()
withContext(Dispatchers.Main) {
navController.popBackStack(R.id.savedLoginsFragment, false)
}
}
deleteJob.invokeOnCompletion {
if (it is CancellationException) {
deleteLoginJob?.cancel()
}
}
}
fun save(loginId: String, usernameText: String, passwordText: String) {
var saveLoginJob: Deferred<Unit>? = null
viewLifecycleScope.launch(Dispatchers.IO) {
saveLoginJob = async {
// must retrieve from storage to get the httpsRealm and formActionOrigin
val oldLogin = context.components.core.passwordsStorage.get(loginId)
// Update requires a Login type, which needs at least one of
// httpRealm or formActionOrigin
val loginToSave = Login(
guid = loginId,
origin = oldLogin?.origin!!,
username = usernameText, // new value
password = passwordText, // new value
httpRealm = oldLogin.httpRealm,
formActionOrigin = oldLogin.formActionOrigin
)
save(loginToSave)
syncAndUpdateList(loginToSave)
}
saveLoginJob?.await()
withContext(Dispatchers.Main) {
val directions =
EditLoginFragmentDirections.actionEditLoginFragmentToLoginDetailFragment(
loginId
)
navController.navigate(directions)
}
}
saveLoginJob?.invokeOnCompletion {
if (it is CancellationException) {
saveLoginJob?.cancel()
}
}
}
private suspend fun save(loginToSave: Login) {
try {
context.components.core.passwordsStorage.update(loginToSave)
} catch (loginException: LoginsStorageException) {
when (loginException) {
is NoSuchRecordException,
is InvalidRecordException -> {
Log.e("Edit login",
"Failed to save edited login.", loginException)
}
else -> Log.e("Edit login",
"Failed to save edited login.", loginException)
}
}
}
private fun syncAndUpdateList(updatedLogin: Login) {
val login = updatedLogin.mapToSavedLogin()
loginsFragmentStore.dispatch(
LoginsAction.UpdateLoginsList(
listOf(login)
)
)
}
fun findPotentialDuplicates(loginId: String) {
var deferredLogin: Deferred<List<Login>>? = null
// What scope should be used here?
val fetchLoginJob = viewLifecycleScope.launch(Dispatchers.IO) {
deferredLogin = async {
val login = getLogin(loginId)
context.components.core.passwordsStorage.getPotentialDupesIgnoringUsername(login!!)
}
val fetchedDuplicatesList = deferredLogin?.await()
fetchedDuplicatesList?.let { list ->
withContext(Dispatchers.Main) {
val savedLoginList = list.map { it.mapToSavedLogin() }
loginsFragmentStore.dispatch(
LoginsAction.ListOfDupes(
savedLoginList
)
)
}
}
}
fetchLoginJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogin?.cancel()
}
}
}
fun fetchLoginDetails(loginId: String) {
var deferredLogin: Deferred<List<Login>>? = null
val fetchLoginJob = viewLifecycleScope.launch(Dispatchers.IO) {
deferredLogin = async {
context.components.core.passwordsStorage.list()
}
val fetchedLoginList = deferredLogin?.await()
fetchedLoginList?.let {
withContext(Dispatchers.Main) {
val login = fetchedLoginList.filter {
it.guid == loginId
}.first()
loginsFragmentStore.dispatch(
LoginsAction.UpdateCurrentLogin(
login.mapToSavedLogin()
)
)
}
}
}
fetchLoginJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogin?.cancel()
}
}
}
fun handleLoadAndMapLogins() {
var deferredLogins: Deferred<List<Login>>? = null
val fetchLoginsJob = viewLifecycleScope.launch(Dispatchers.IO) {
deferredLogins = async {
context.components.core.passwordsStorage.list()
}
val logins = deferredLogins?.await()
logins?.let {
withContext(Dispatchers.Main) {
loginsFragmentStore.dispatch(
LoginsAction.UpdateLoginsList(
logins.map { it.mapToSavedLogin() })
)
}
}
}
fetchLoginsJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogins?.cancel()
}
}
}
}

View File

@ -2,59 +2,72 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.InputType import android.text.InputType
import android.text.TextWatcher import android.text.TextWatcher
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.core.content.ContextCompat import androidx.appcompat.view.menu.ActionMenuItemView
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import kotlinx.android.synthetic.main.fragment_edit_login.* import kotlinx.android.synthetic.main.fragment_edit_login.*
import kotlinx.coroutines.CancellationException import kotlinx.android.synthetic.main.fragment_edit_login.view.*
import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Dispatchers.IO import mozilla.components.lib.state.ext.consumeFrom
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.Login
import mozilla.components.service.sync.logins.InvalidRecordException
import mozilla.components.service.sync.logins.LoginsStorageException
import mozilla.components.service.sync.logins.NoSuchRecordException
import mozilla.components.support.ktx.android.view.hideKeyboard import mozilla.components.support.ktx.android.view.hideKeyboard
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.redirectToReAuth import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.settings.logins.LoginsAction
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.LoginsListState
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.EditLoginInteractor
import org.mozilla.fenix.settings.logins.view.EditLoginView
/** /**
* Displays the editable saved login information for a single website. * Displays the editable saved login information for a single website
*/ */
@ExperimentalCoroutinesApi
@Suppress("TooManyFunctions", "NestedBlockDepth", "ForbiddenComment") @Suppress("TooManyFunctions", "NestedBlockDepth", "ForbiddenComment")
class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
private val args by navArgs<EditLoginFragmentArgs>()
private lateinit var savedLoginsStore: LoginsFragmentStore
fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this) fun String.toEditable(): Editable = Editable.Factory.getInstance().newEditable(this)
private val args by navArgs<EditLoginFragmentArgs>()
private lateinit var loginsFragmentStore: LoginsFragmentStore
private lateinit var interactor: EditLoginInteractor
private lateinit var editLoginView: EditLoginView
private lateinit var oldLogin: SavedLogin private lateinit var oldLogin: SavedLogin
override fun onCreate(savedInstanceState: Bundle?) { private var listOfPossibleDupes: List<SavedLogin>? = null
super.onCreate(savedInstanceState)
private var usernameChanged = false
private var passwordChanged = false
private var saveEnabled = false
private var showPassword = true
private var validPassword = true
private var validUsername = true
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
oldLogin = args.savedLoginItem oldLogin = args.savedLoginItem
savedLoginsStore = StoreProvider.get(this) { editLoginView = EditLoginView(view.editLoginLayout)
loginsFragmentStore = StoreProvider.get(this) {
LoginsFragmentStore( LoginsFragmentStore(
LoginsListState( LoginsListState(
isLoading = true, isLoading = true,
@ -62,31 +75,59 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
filteredItems = listOf(), filteredItems = listOf(),
searchedForText = null, searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem,
duplicateLogins = listOf()
) )
) )
} }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { interactor = EditLoginInteractor(
super.onViewCreated(view, savedInstanceState) SavedLoginsStorageController(
context = requireContext(),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
navController = findNavController(),
loginsFragmentStore = loginsFragmentStore
)
)
// ensure hostname isn't editable loginsFragmentStore.dispatch(LoginsAction.UpdateCurrentLogin(args.savedLoginItem))
interactor.findPotentialDuplicates(args.savedLoginItem.guid)
// initialize editable values
hostnameText.text = args.savedLoginItem.origin.toEditable() hostnameText.text = args.savedLoginItem.origin.toEditable()
hostnameText.isClickable = false
hostnameText.isFocusable = false
usernameText.text = args.savedLoginItem.username.toEditable() usernameText.text = args.savedLoginItem.username.toEditable()
passwordText.text = args.savedLoginItem.password.toEditable() passwordText.text = args.savedLoginItem.password.toEditable()
formatEditableValues()
initSaveState()
setUpClickListeners()
setUpTextListeners()
editLoginView.showPassword()
consumeFrom(loginsFragmentStore) {
listOfPossibleDupes = loginsFragmentStore.state.duplicateLogins
}
}
private fun initSaveState() {
saveEnabled = false // don't enable saving until something has been changed
val saveButton =
activity?.findViewById<ActionMenuItemView>(R.id.save_login_button)
saveButton?.isEnabled = saveEnabled
usernameChanged = false
passwordChanged = false
}
private fun formatEditableValues() {
hostnameText.isClickable = false
hostnameText.isFocusable = false
usernameText.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
// TODO: extend PasswordTransformationMethod() to change bullets to asterisks // TODO: extend PasswordTransformationMethod() to change bullets to asterisks
passwordText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD passwordText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
passwordText.compoundDrawablePadding = passwordText.compoundDrawablePadding =
requireContext().resources requireContext().resources
.getDimensionPixelOffset(R.dimen.saved_logins_end_icon_drawable_padding) .getDimensionPixelOffset(R.dimen.saved_logins_end_icon_drawable_padding)
setUpClickListeners()
setUpTextListeners()
} }
private fun setUpClickListeners() { private fun setUpClickListeners() {
@ -105,14 +146,11 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
it.isEnabled = false it.isEnabled = false
} }
revealPasswordButton.setOnClickListener { revealPasswordButton.setOnClickListener {
togglePasswordReveal() showPassword = !showPassword
} if (showPassword) {
editLoginView.showPassword()
var firstClick = true } else {
passwordText.setOnClickListener { editLoginView.hidePassword()
if (firstClick) {
togglePasswordReveal()
firstClick = false
} }
} }
} }
@ -124,7 +162,6 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
view?.hideKeyboard() view?.hideKeyboard()
} }
} }
editLoginLayout.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> editLoginLayout.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (!hasFocus) { if (!hasFocus) {
view?.hideKeyboard() view?.hideKeyboard()
@ -133,13 +170,20 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
usernameText.addTextChangedListener(object : TextWatcher { usernameText.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(u: Editable?) { override fun afterTextChanged(u: Editable?) {
if (u.toString() == oldLogin.username) { when {
inputLayoutUsername.error = null u.toString() == oldLogin.username -> {
inputLayoutUsername.errorIconDrawable = null usernameChanged = false
} else { validUsername = true
clearUsernameTextButton.isEnabled = true inputLayoutUsername.error = null
// setDupeError() TODO in #10173 inputLayoutUsername.errorIconDrawable = null
}
else -> {
usernameChanged = true
clearUsernameTextButton.isEnabled = true
setDupeError()
}
} }
setSaveButtonState()
} }
override fun beforeTextChanged(u: CharSequence?, start: Int, count: Int, after: Int) { override fun beforeTextChanged(u: CharSequence?, start: Int, count: Int, after: Int) {
@ -155,20 +199,26 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
override fun afterTextChanged(p: Editable?) { override fun afterTextChanged(p: Editable?) {
when { when {
p.toString().isEmpty() -> { p.toString().isEmpty() -> {
passwordChanged = true
clearPasswordTextButton.isEnabled = false clearPasswordTextButton.isEnabled = false
setPasswordError() setPasswordError()
} }
p.toString() == oldLogin.password -> { p.toString() == oldLogin.password -> {
passwordChanged = false
validPassword = true
inputLayoutPassword.error = null inputLayoutPassword.error = null
inputLayoutPassword.errorIconDrawable = null inputLayoutPassword.errorIconDrawable = null
clearPasswordTextButton.isEnabled = true clearPasswordTextButton.isEnabled = true
} }
else -> { else -> {
passwordChanged = true
validPassword = true
inputLayoutPassword.error = null inputLayoutPassword.error = null
inputLayoutPassword.errorIconDrawable = null inputLayoutPassword.errorIconDrawable = null
clearPasswordTextButton.isEnabled = true clearPasswordTextButton.isEnabled = true
} }
} }
setSaveButtonState()
} }
override fun beforeTextChanged(p: CharSequence?, start: Int, count: Int, after: Int) { override fun beforeTextChanged(p: CharSequence?, start: Int, count: Int, after: Int) {
@ -181,14 +231,40 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
}) })
} }
private fun isDupe(username: String): Boolean =
loginsFragmentStore.state.duplicateLogins.filter { it.username == username }.any()
private fun setDupeError() {
if (isDupe(usernameText.text.toString())) {
inputLayoutUsername?.let {
usernameChanged = true
validUsername = false
it.setErrorIconDrawable(R.drawable.mozac_ic_warning)
it.error = context?.getString(R.string.saved_login_duplicate)
}
} else {
usernameChanged = true
validUsername = true
inputLayoutUsername.error = null
}
}
private fun setPasswordError() { private fun setPasswordError() {
inputLayoutPassword?.let { layout -> inputLayoutPassword?.let { layout ->
validPassword = false
layout.error = context?.getString(R.string.saved_login_password_required) layout.error = context?.getString(R.string.saved_login_password_required)
layout.setErrorIconDrawable(R.drawable.mozac_ic_warning) layout.setErrorIconDrawable(R.drawable.mozac_ic_warning)
}
}
layout.errorIconDrawable?.setTint( private fun setSaveButtonState() {
ContextCompat.getColor(requireContext(), R.color.design_default_color_error) val saveButton = activity?.findViewById<ActionMenuItemView>(R.id.save_login_button)
) val changesMadeWithNoErrors =
validUsername && validPassword && (usernameChanged || passwordChanged)
changesMadeWithNoErrors.let {
saveButton?.isEnabled = it
saveEnabled = it
} }
} }
@ -207,101 +283,16 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.save_login_button -> { R.id.save_login_button -> {
view?.hideKeyboard() view?.hideKeyboard()
if (!passwordText.text.isNullOrBlank()) { if (saveEnabled) {
try { interactor.onSaveLogin(
attemptSaveAndExit() args.savedLoginItem.guid,
} catch (loginException: LoginsStorageException) { usernameText.text.toString(),
when (loginException) { passwordText.text.toString()
is NoSuchRecordException, )
is InvalidRecordException -> { requireComponents.analytics.metrics.track(Event.EditLoginSave)
Log.e(
"Edit login",
"Failed to save edited login.",
loginException
)
}
else -> Log.e(
"Edit login",
"Failed to save edited login.",
loginException
)
}
}
} }
true true
} }
else -> false else -> false
} }
// TODO: Move interactions with the component's password storage into a separate datastore
// This includes Delete, Update/Edit, Create
private fun attemptSaveAndExit() {
var saveLoginJob: Deferred<Unit>? = null
viewLifecycleOwner.lifecycleScope.launch(IO) {
saveLoginJob = async {
val oldLogin =
requireContext().components.core.passwordsStorage.get(args.savedLoginItem.guid)
// Update requires a Login type, which needs at least one of
// httpRealm or formActionOrigin
val loginToSave = Login(
guid = oldLogin?.guid,
origin = oldLogin?.origin!!,
username = usernameText.text.toString(), // new value
password = passwordText.text.toString(), // new value
httpRealm = oldLogin.httpRealm,
formActionOrigin = oldLogin.formActionOrigin
)
save(loginToSave)
syncAndUpdateList(loginToSave)
}
saveLoginJob?.await()
withContext(Main) {
val directions =
EditLoginFragmentDirections
.actionEditLoginFragmentToLoginDetailFragment(args.savedLoginItem.guid)
findNavController().navigate(directions)
}
}
saveLoginJob?.invokeOnCompletion {
if (it is CancellationException) {
saveLoginJob?.cancel()
}
}
}
private suspend fun save(loginToSave: Login) =
requireContext().components.core.passwordsStorage.update(loginToSave)
private fun syncAndUpdateList(updatedLogin: Login) {
val login = updatedLogin.mapToSavedLogin()
savedLoginsStore.dispatch(LoginsAction.UpdateLoginsList(listOf(login)))
}
// TODO: create helper class for toggling passwords. Used in login info and edit fragments.
private fun togglePasswordReveal() {
val currText = passwordText.text
if (passwordText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD
or InputType.TYPE_CLASS_TEXT
) {
context?.components?.analytics?.metrics?.track(Event.ViewLoginPassword)
passwordText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
revealPasswordButton.setImageDrawable(
resources.getDrawable(R.drawable.mozac_ic_password_hide, null)
)
revealPasswordButton.contentDescription =
resources.getString(R.string.saved_login_hide_password)
} else {
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.setImageDrawable(
resources.getDrawable(R.drawable.mozac_ic_password_reveal, null)
)
revealPasswordButton.contentDescription =
context?.getString(R.string.saved_login_reveal_password)
}
// For the new type to take effect you need to reset the text to it's current edited version
passwordText?.text = currText
}
} }

View File

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.content.DialogInterface import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
@ -21,16 +21,8 @@ import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_login_detail.* import kotlinx.android.synthetic.main.fragment_login_detail.*
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.ExperimentalCoroutinesApi
import kotlinx.coroutines.ObsoleteCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.Login
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.FeatureFlags import org.mozilla.fenix.FeatureFlags
@ -42,9 +34,16 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.increaseTapArea import org.mozilla.fenix.ext.increaseTapArea
import org.mozilla.fenix.ext.redirectToReAuth import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.urlToTrimmedHost import org.mozilla.fenix.ext.simplifiedUrl
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.LoginsListState
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.LoginDetailInteractor
import org.mozilla.fenix.settings.logins.view.LoginDetailView
/** /**
* Displays saved login information for a single website. * Displays saved login information for a single website.
@ -57,8 +56,10 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
private var login: SavedLogin? = null private var login: SavedLogin? = null
private lateinit var savedLoginsStore: LoginsFragmentStore private lateinit var savedLoginsStore: LoginsFragmentStore
private lateinit var loginDetailView: LoginDetailView private lateinit var loginDetailView: LoginDetailView
private lateinit var interactor: LoginDetailInteractor
private lateinit var menu: Menu private lateinit var menu: Menu
private var deleteDialog: AlertDialog? = null private var deleteDialog: AlertDialog? = null
private var showPassword = true
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -74,12 +75,14 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
filteredItems = listOf(), filteredItems = listOf(),
searchedForText = null, searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem,
duplicateLogins = listOf() // assume on load there are no dupes
) )
) )
} }
loginDetailView = LoginDetailView(view?.findViewById(R.id.loginDetailLayout)) loginDetailView = LoginDetailView(
fetchLoginDetails() view.findViewById(R.id.loginDetailLayout)
)
return view return view
} }
@ -87,16 +90,29 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
@ObsoleteCoroutinesApi @ObsoleteCoroutinesApi
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
interactor = LoginDetailInteractor(
SavedLoginsStorageController(
context = requireContext(),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
navController = findNavController(),
loginsFragmentStore = savedLoginsStore
)
)
interactor.onFetchLoginList(args.savedLoginId)
consumeFrom(savedLoginsStore) { consumeFrom(savedLoginsStore) {
loginDetailView.update(it) loginDetailView.update(it)
login = savedLoginsStore.state.currentItem login = savedLoginsStore.state.currentItem
setUpCopyButtons() setUpCopyButtons()
showToolbar( showToolbar(
savedLoginsStore.state.currentItem?.origin?.urlToTrimmedHost(requireContext()) savedLoginsStore.state.currentItem?.origin?.simplifiedUrl()
?: "" ?: ""
) )
setUpPasswordReveal() setUpPasswordReveal()
} }
loginDetailView.togglePasswordReveal(showPassword)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -124,7 +140,11 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.increaseTapArea(BUTTON_INCREASE_DPS) revealPasswordButton.increaseTapArea(BUTTON_INCREASE_DPS)
revealPasswordButton.setOnClickListener { revealPasswordButton.setOnClickListener {
togglePasswordReveal() showPassword = !showPassword
loginDetailView.togglePasswordReveal(!showPassword)
}
passwordText.setOnClickListener {
loginDetailView.togglePasswordReveal(!showPassword)
} }
} }
@ -149,33 +169,6 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
) )
} }
// TODO: Move interactions with the component's password storage into a separate datastore
private fun fetchLoginDetails() {
var deferredLogin: Deferred<List<Login>>? = null
val fetchLoginJob = viewLifecycleOwner.lifecycleScope.launch(IO) {
deferredLogin = async {
requireContext().components.core.passwordsStorage.list()
}
val fetchedLoginList = deferredLogin?.await()
fetchedLoginList?.let {
withContext(Main) {
val login = fetchedLoginList.filter {
it.guid == args.savedLoginId
}.first()
savedLoginsStore.dispatch(
LoginsAction.UpdateCurrentLogin(login.mapToSavedLogin())
)
}
}
}
fetchLoginJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogin?.cancel()
}
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (FeatureFlags.loginsEdit) { if (FeatureFlags.loginsEdit) {
inflater.inflate(R.menu.login_options_menu, menu) inflater.inflate(R.menu.login_options_menu, menu)
@ -206,9 +199,11 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
} }
private fun editLogin() { private fun editLogin() {
requireComponents.analytics.metrics.track(Event.EditLogin)
val directions = val directions =
LoginDetailFragmentDirections LoginDetailFragmentDirections.actionLoginDetailFragmentToEditLoginFragment(
.actionLoginDetailFragmentToEditLoginFragment(login!!) login!!
)
findNavController().navigate(directions) findNavController().navigate(directions)
} }
@ -220,7 +215,8 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
dialog.cancel() dialog.cancel()
} }
setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ -> setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ ->
deleteLogin() requireComponents.analytics.metrics.track(Event.DeleteLogin)
interactor.onDeleteLogin(args.savedLoginId)
dialog.dismiss() dialog.dismiss()
} }
create() create()
@ -228,49 +224,6 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
} }
} }
// TODO: Move interactions with the component's password storage into a separate datastore
// This includes Delete, Update/Edit, Create
private fun deleteLogin() {
var deleteLoginJob: Deferred<Boolean>? = null
val deleteJob = viewLifecycleOwner.lifecycleScope.launch(IO) {
deleteLoginJob = async {
requireContext().components.core.passwordsStorage.delete(args.savedLoginId)
}
deleteLoginJob?.await()
withContext(Main) {
findNavController().popBackStack(R.id.savedLoginsFragment, false)
}
}
deleteJob.invokeOnCompletion {
if (it is CancellationException) {
deleteLoginJob?.cancel()
}
}
}
// TODO: create helper class for toggling passwords. Used in login info and edit fragments.
private fun togglePasswordReveal() {
if (passwordText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT) {
context?.components?.analytics?.metrics?.track(Event.ViewLoginPassword)
passwordText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
revealPasswordButton.setImageDrawable(
resources.getDrawable(R.drawable.mozac_ic_password_hide, null)
)
revealPasswordButton.contentDescription =
resources.getString(R.string.saved_login_hide_password)
} else {
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.setImageDrawable(
resources.getDrawable(R.drawable.mozac_ic_password_reveal, null)
)
revealPasswordButton.contentDescription =
context?.getString(R.string.saved_login_reveal_password)
}
// For the new type to take effect you need to reset the text
passwordText.text = login?.password
}
/** /**
* Click listener for a textview's copy button. * Click listener for a textview's copy button.
* @param value Value to be copied * @param value Value to be copied

View File

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.annotation.TargetApi import android.annotation.TargetApi
import android.app.Activity.RESULT_OK import android.app.Activity.RESULT_OK
@ -313,7 +313,8 @@ class SavedLoginsAuthFragment : PreferenceFragmentCompat(), AccountObserver {
} }
private fun navigateToAccountProblemFragment() { private fun navigateToAccountProblemFragment() {
val directions = SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment() val directions =
SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment()
findNavController().navigate(directions) findNavController().navigate(directions)
} }

View File

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -21,17 +21,9 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import kotlinx.android.synthetic.main.fragment_saved_logins.view.* import kotlinx.android.synthetic.main.fragment_saved_logins.view.*
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.ObsoleteCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi
import mozilla.components.browser.menu.BrowserMenu import mozilla.components.browser.menu.BrowserMenu
import mozilla.components.concept.storage.Login
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
@ -41,17 +33,28 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.redirectToReAuth import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.logins.LoginsAction
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
import org.mozilla.fenix.settings.logins.controller.LoginsListController
import org.mozilla.fenix.settings.logins.LoginsListState
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu
import org.mozilla.fenix.settings.logins.view.SavedLoginsListView
import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
@SuppressWarnings("TooManyFunctions") @SuppressWarnings("TooManyFunctions")
class SavedLoginsFragment : Fragment() { class SavedLoginsFragment : Fragment() {
private lateinit var savedLoginsStore: LoginsFragmentStore private lateinit var savedLoginsStore: LoginsFragmentStore
private lateinit var savedLoginsView: SavedLoginsView private lateinit var savedLoginsListView: SavedLoginsListView
private lateinit var savedLoginsInteractor: SavedLoginsInteractor private lateinit var savedLoginsInteractor: SavedLoginsInteractor
private lateinit var dropDownMenuAnchorView: View private lateinit var dropDownMenuAnchorView: View
private lateinit var sortingStrategyMenu: SavedLoginsSortingStrategyMenu private lateinit var sortingStrategyMenu: SavedLoginsSortingStrategyMenu
private lateinit var sortingStrategyPopupMenu: BrowserMenu private lateinit var sortingStrategyPopupMenu: BrowserMenu
private lateinit var toolbarChildContainer: FrameLayout private lateinit var toolbarChildContainer: FrameLayout
private lateinit var sortLoginsMenuRoot: ConstraintLayout private lateinit var sortLoginsMenuRoot: ConstraintLayout
private lateinit var loginsListController: LoginsListController
private lateinit var savedLoginsStorageController: SavedLoginsStorageController
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
@ -81,21 +84,39 @@ class SavedLoginsFragment : Fragment() {
filteredItems = listOf(), filteredItems = listOf(),
searchedForText = null, searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem,
duplicateLogins = listOf() // assume on load there are no dupes
) )
) )
} }
val savedLoginsController: SavedLoginsController =
SavedLoginsController( loginsListController =
store = savedLoginsStore, LoginsListController(
navController = findNavController(), loginsFragmentStore = savedLoginsStore,
browserNavigator = ::openToBrowserAndLoad, navController = findNavController(),
settings = requireContext().settings(), browserNavigator = ::openToBrowserAndLoad,
metrics = requireContext().components.analytics.metrics settings = requireContext().settings(),
metrics = requireContext().components.analytics.metrics
) )
savedLoginsInteractor = SavedLoginsInteractor(savedLoginsController) savedLoginsStorageController =
savedLoginsView = SavedLoginsView(view.savedLoginsLayout, savedLoginsInteractor) SavedLoginsStorageController(
loadAndMapLogins() context = requireContext(),
viewLifecycleScope = viewLifecycleOwner.lifecycleScope,
navController = findNavController(),
loginsFragmentStore = savedLoginsStore
)
savedLoginsInteractor =
SavedLoginsInteractor(
loginsListController,
savedLoginsStorageController
)
savedLoginsListView = SavedLoginsListView(
view.savedLoginsLayout,
savedLoginsInteractor
)
savedLoginsInteractor.loadAndMapLogins()
return view return view
} }
@ -105,7 +126,7 @@ class SavedLoginsFragment : Fragment() {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
consumeFrom(savedLoginsStore) { consumeFrom(savedLoginsStore) {
sortingStrategyMenu.updateMenu(savedLoginsStore.state.highlightedItem) sortingStrategyMenu.updateMenu(savedLoginsStore.state.highlightedItem)
savedLoginsView.update(it) savedLoginsListView.update(it)
} }
} }
@ -122,7 +143,11 @@ class SavedLoginsFragment : Fragment() {
} }
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
savedLoginsStore.dispatch(LoginsAction.FilterLogins(newText)) savedLoginsStore.dispatch(
LoginsAction.FilterLogins(
newText
)
)
return false return false
} }
}) })
@ -141,31 +166,11 @@ class SavedLoginsFragment : Fragment() {
super.onPause() super.onPause()
} }
private fun openToBrowserAndLoad(searchTermOrURL: String, newTab: Boolean, from: BrowserDirection) { private fun openToBrowserAndLoad(
(activity as HomeActivity).openToBrowserAndLoad(searchTermOrURL, newTab, from) searchTermOrURL: String,
} newTab: Boolean,
from: BrowserDirection
private fun loadAndMapLogins() { ) = (activity as HomeActivity).openToBrowserAndLoad(searchTermOrURL, newTab, from)
var deferredLogins: Deferred<List<Login>>? = null
val fetchLoginsJob = viewLifecycleOwner.lifecycleScope.launch(IO) {
deferredLogins = async {
requireContext().components.core.passwordsStorage.list()
}
val logins = deferredLogins?.await()
logins?.let {
withContext(Main) {
savedLoginsStore.dispatch(
LoginsAction.UpdateLoginsList(logins.map { it.mapToSavedLogin() })
)
}
}
}
fetchLoginsJob.invokeOnCompletion {
if (it is CancellationException) {
deferredLogins?.cancel()
}
}
}
private fun initToolbar() { private fun initToolbar() {
showToolbar(getString(R.string.preferences_passwords_saved_logins)) showToolbar(getString(R.string.preferences_passwords_saved_logins))
@ -175,8 +180,12 @@ class SavedLoginsFragment : Fragment() {
sortLoginsMenuRoot = inflateSortLoginsMenuRoot() sortLoginsMenuRoot = inflateSortLoginsMenuRoot()
dropDownMenuAnchorView = sortLoginsMenuRoot.findViewById(R.id.drop_down_menu_anchor_view) dropDownMenuAnchorView = sortLoginsMenuRoot.findViewById(R.id.drop_down_menu_anchor_view)
when (requireContext().settings().savedLoginsSortingStrategy) { when (requireContext().settings().savedLoginsSortingStrategy) {
is SortingStrategy.Alphabetically -> setupMenu(SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort) is SortingStrategy.Alphabetically -> setupMenu(
is SortingStrategy.LastUsed -> setupMenu(SavedLoginsSortingStrategyMenu.Item.LastUsedSort) SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort
)
is SortingStrategy.LastUsed -> setupMenu(
SavedLoginsSortingStrategyMenu.Item.LastUsedSort
)
} }
} }
@ -210,21 +219,29 @@ class SavedLoginsFragment : Fragment() {
} }
private fun setupMenu(itemToHighlight: SavedLoginsSortingStrategyMenu.Item) { private fun setupMenu(itemToHighlight: SavedLoginsSortingStrategyMenu.Item) {
sortingStrategyMenu = SavedLoginsSortingStrategyMenu(requireContext(), itemToHighlight) { sortingStrategyMenu =
when (it) { SavedLoginsSortingStrategyMenu(
SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort -> { requireContext(),
savedLoginsInteractor.onSortingStrategyChanged( itemToHighlight
SortingStrategy.Alphabetically(requireContext().applicationContext) ) {
) when (it) {
} SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort -> {
savedLoginsInteractor.onSortingStrategyChanged(
SortingStrategy.Alphabetically(
requireContext().applicationContext
)
)
}
SavedLoginsSortingStrategyMenu.Item.LastUsedSort -> { SavedLoginsSortingStrategyMenu.Item.LastUsedSort -> {
savedLoginsInteractor.onSortingStrategyChanged( savedLoginsInteractor.onSortingStrategyChanged(
SortingStrategy.LastUsed(requireContext().applicationContext) SortingStrategy.LastUsed(
) requireContext().applicationContext
)
)
}
} }
} }
}
attachMenu() attachMenu()
} }

View File

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.fragment
import android.os.Bundle import android.os.Bundle
import androidx.preference.Preference import androidx.preference.Preference

View File

@ -0,0 +1,24 @@
/* 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.interactor
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
/**
* Interactor for the edit login screen
*
* @property savedLoginsController controller for the saved logins storage
*/
class EditLoginInteractor(
private val savedLoginsController: SavedLoginsStorageController
) {
fun findPotentialDuplicates(loginId: String) {
savedLoginsController.findPotentialDuplicates(loginId)
}
fun onSaveLogin(loginId: String, usernameText: String, passwordText: String) {
savedLoginsController.save(loginId, usernameText, passwordText)
}
}

View File

@ -0,0 +1,24 @@
/* 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.interactor
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
/**
* Interactor for the login detail screen
*
* @property savedLoginsController controller for the saved logins storage
*/
class LoginDetailInteractor(
private val savedLoginsController: SavedLoginsStorageController
) {
fun onFetchLoginList(loginId: String) {
savedLoginsController.fetchLoginDetails(loginId)
}
fun onDeleteLogin(loginId: String) {
savedLoginsController.delete(loginId)
}
}

View File

@ -0,0 +1,39 @@
/* 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.interactor
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.logins.controller.LoginsListController
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
/**
* Interactor for the saved logins screen
*
* @param loginsListController [LoginsListController] which will be delegated for all
* user interactions.
* @param savedLoginsStorageController [SavedLoginsStorageController] which will be delegated
* for all calls to the password storage component
*/
class SavedLoginsInteractor(
private val loginsListController: LoginsListController,
private val savedLoginsStorageController: SavedLoginsStorageController
) {
fun onItemClicked(item: SavedLogin) {
loginsListController.handleItemClicked(item)
}
fun onLearnMoreClicked() {
loginsListController.handleLearnMoreClicked()
}
fun onSortingStrategyChanged(sortingStrategy: SortingStrategy) {
loginsListController.handleSort(sortingStrategy)
}
fun loadAndMapLogins() {
savedLoginsStorageController.handleLoadAndMapLogins()
}
}

View File

@ -0,0 +1,54 @@
/* 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.view
import android.text.InputType
import android.view.ViewGroup
import androidx.appcompat.content.res.AppCompatResources
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_edit_login.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
/**
* View that contains and configures the Edit Login screen
*/
@Suppress("ForbiddenComment")
class EditLoginView(
override val containerView: ViewGroup
) : LayoutContainer {
private val context = containerView.context
// TODO: create helper class for toggling passwords. https://github.com/mozilla-mobile/fenix/issues/12554
fun showPassword() {
val currText = containerView.passwordText?.text
context.components.analytics.metrics.track(Event.ViewLoginPassword)
containerView.passwordText?.inputType =
InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
containerView.revealPasswordButton?.setImageDrawable(
AppCompatResources.getDrawable(context, R.drawable.mozac_ic_password_hide)
)
containerView.revealPasswordButton?.contentDescription =
context.resources.getString(R.string.saved_login_hide_password)
// For the new type to take effect you need to reset the text to it's current edited version
containerView.passwordText?.text = currText
}
fun hidePassword() {
val currText = containerView.passwordText?.text
containerView.passwordText?.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
containerView.revealPasswordButton?.setImageDrawable(
AppCompatResources.getDrawable(context, R.drawable.mozac_ic_password_reveal)
)
containerView.revealPasswordButton?.contentDescription =
context.getString(R.string.saved_login_reveal_password)
// For the new type to take effect you need to reset the text to it's current edited version
containerView.passwordText?.text = currText
}
}

View File

@ -0,0 +1,65 @@
/* 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.view
import android.text.InputType
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.fragment_login_detail.*
import kotlinx.android.synthetic.main.fragment_login_detail.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.settings.logins.LoginsListState
/**
* View that contains and configures the Login Details
*/
@Suppress("ForbiddenComment")
class LoginDetailView(override val containerView: ViewGroup) : LayoutContainer {
private val context = containerView.context
fun update(login: LoginsListState) {
webAddressText.text = login.currentItem?.origin
usernameText.text = login.currentItem?.username
passwordText.text = login.currentItem?.password
}
fun togglePasswordReveal(show: Boolean) {
if (show) showPassword() else { hidePassword() }
}
// TODO: create helper class for toggling passwords. https://github.com/mozilla-mobile/fenix/issues/12554
fun showPassword() {
if (passwordText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT) {
context?.components?.analytics?.metrics?.track(Event.ViewLoginPassword)
passwordText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
revealPasswordButton.setImageDrawable(
ResourcesCompat.getDrawable(
context.resources,
R.drawable.mozac_ic_password_hide, null
)
)
revealPasswordButton.contentDescription =
context.resources.getString(R.string.saved_login_hide_password)
}
// For the new type to take effect you need to reset the text
passwordText.text = containerView.passwordText.editableText
}
fun hidePassword() {
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.setImageDrawable(
ResourcesCompat.getDrawable(context.resources,
R.drawable.mozac_ic_password_reveal, null)
)
revealPasswordButton.contentDescription =
context.getString(R.string.saved_login_reveal_password)
// For the new type to take effect you need to reset the text
passwordText.text = containerView.passwordText.editableText
}
}

View File

@ -2,13 +2,15 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.view
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
class LoginsAdapter( class LoginsAdapter(
private val interactor: SavedLoginsInteractor private val interactor: SavedLoginsInteractor

View File

@ -2,13 +2,15 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins package org.mozilla.fenix.settings.logins.view
import android.view.View import android.view.View
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.logins_item.view.* import kotlinx.android.synthetic.main.logins_item.view.*
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.settings.logins.SavedLogin
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
class LoginsListViewHolder( class LoginsListViewHolder(
private val view: View, private val view: View,

View File

@ -0,0 +1,67 @@
/* 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.view
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_saved_logins.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.settings.logins.LoginsListState
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
import org.mozilla.fenix.ext.addUnderline
/**
* View that contains and configures the Saved Logins List
*/
class SavedLoginsListView(
override val containerView: ViewGroup,
val interactor: SavedLoginsInteractor
) : LayoutContainer {
val view: FrameLayout = LayoutInflater.from(containerView.context)
.inflate(R.layout.component_saved_logins, containerView, true)
.findViewById(R.id.saved_logins_wrapper)
private val loginsAdapter = LoginsAdapter(interactor)
init {
view.saved_logins_list.apply {
adapter = loginsAdapter
layoutManager = LinearLayoutManager(containerView.context)
itemAnimator = null
}
with(view.saved_passwords_empty_learn_more) {
movementMethod = LinkMovementMethod.getInstance()
addUnderline()
setOnClickListener { interactor.onLearnMoreClicked() }
}
with(view.saved_passwords_empty_message) {
val appName = context.getString(R.string.app_name)
text = String.format(
context.getString(
R.string.preferences_passwords_saved_logins_description_empty_text
), appName
)
}
}
fun update(state: LoginsListState) {
if (state.isLoading) {
view.progress_bar.isVisible = true
} else {
view.progress_bar.isVisible = false
view.saved_logins_list.isVisible = state.loginList.isNotEmpty()
view.saved_passwords_empty_view.isVisible = state.loginList.isEmpty()
}
loginsAdapter.submitList(state.filteredItems)
}
}

View File

@ -33,7 +33,7 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.ext.getPreferenceKey
import org.mozilla.fenix.settings.PhoneFeature import org.mozilla.fenix.settings.PhoneFeature
import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType
import org.mozilla.fenix.settings.logins.SavedLoginsFragment import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragment
import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu
import org.mozilla.fenix.settings.logins.SortingStrategy import org.mozilla.fenix.settings.logins.SortingStrategy
import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener

View File

@ -0,0 +1,8 @@
<?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/. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_enabled="true" android:color="?primaryText"/>
<item android:state_enabled="false" android:color="?disabled" />
</selector>

View File

@ -6,5 +6,5 @@
<item android:state_enabled="true" <item android:state_enabled="true"
android:color="?primaryText" /> android:color="?primaryText" />
<item android:state_enabled="false" <item android:state_enabled="false"
android:color="@android:color/transparent" /> android:color="?disabled" />
</selector> </selector>

View File

@ -134,12 +134,13 @@
android:id="@+id/clearUsernameTextButton" android:id="@+id/clearUsernameTextButton"
android:layout_width="48dp" android:layout_width="48dp"
android:layout_height="30dp" android:layout_height="30dp"
android:layout_marginTop="3dp"
android:layout_marginBottom="10dp" android:layout_marginBottom="10dp"
android:background="@null" android:background="@null"
android:contentDescription="@string/saved_login_copy_username" android:contentDescription="@string/saved_login_copy_username"
app:tint="@color/saved_login_clear_edit_text_tint" app:tint="@color/saved_login_clear_edit_text_tint"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="@id/inputLayoutUsername" app:layout_constraintTop_toTopOf="@id/inputLayoutUsername"
app:srcCompat="@drawable/ic_clear" /> app:srcCompat="@drawable/ic_clear" />
<TextView <TextView

View File

@ -7,4 +7,4 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
tools:context="org.mozilla.fenix.settings.logins.SavedLoginsFragment" /> tools:context="org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragment" />

View File

@ -8,7 +8,7 @@
<item <item
android:id="@+id/save_login_button" android:id="@+id/save_login_button"
android:icon="@drawable/mozac_ic_check" android:icon="@drawable/mozac_ic_check"
app:iconTint="?primaryText" app:iconTint="@color/save_enabled_ic_color"
android:title="@string/save_changes_to_login" android:title="@string/save_changes_to_login"
app:showAsAction="always" /> app:showAsAction="always" />
</menu> </menu>

View File

@ -321,7 +321,7 @@
<fragment <fragment
android:id="@+id/savedLoginsAuthFragment" android:id="@+id/savedLoginsAuthFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsAuthFragment" android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragment"
android:label="@string/preferences_passwords_logins_and_passwords"> android:label="@string/preferences_passwords_logins_and_passwords">
<action <action
android:id="@+id/action_savedLoginsAuthFragment_to_loginsListFragment" android:id="@+id/action_savedLoginsAuthFragment_to_loginsListFragment"
@ -355,7 +355,7 @@
<fragment <fragment
android:id="@+id/savedLoginsFragment" android:id="@+id/savedLoginsFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsFragment" android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragment"
tools:layout="@layout/fragment_saved_logins"> tools:layout="@layout/fragment_saved_logins">
<action <action
android:id="@+id/action_savedLoginsFragment_to_loginDetailFragment" android:id="@+id/action_savedLoginsFragment_to_loginDetailFragment"
@ -381,7 +381,7 @@
<fragment <fragment
android:id="@+id/loginDetailFragment" android:id="@+id/loginDetailFragment"
android:name="org.mozilla.fenix.settings.logins.LoginDetailFragment" android:name="org.mozilla.fenix.settings.logins.fragment.LoginDetailFragment"
tools:layout="@layout/fragment_login_detail"> tools:layout="@layout/fragment_login_detail">
<argument <argument
android:name="savedLoginId" android:name="savedLoginId"
@ -396,7 +396,7 @@
<fragment <fragment
android:id="@+id/editLoginFragment" android:id="@+id/editLoginFragment"
android:name="org.mozilla.fenix.settings.logins.EditLoginFragment" android:name="org.mozilla.fenix.settings.logins.fragment.EditLoginFragment"
android:label="@string/edit"> android:label="@string/edit">
<argument <argument
android:name="savedLoginItem" android:name="savedLoginItem"
@ -790,7 +790,7 @@
android:name="org.mozilla.fenix.settings.advanced.LocaleSettingsFragment" /> android:name="org.mozilla.fenix.settings.advanced.LocaleSettingsFragment" />
<fragment <fragment
android:id="@+id/saveLoginSettingFragment" android:id="@+id/saveLoginSettingFragment"
android:name="org.mozilla.fenix.settings.logins.SavedLoginsSettingFragment" android:name="org.mozilla.fenix.settings.logins.fragment.SavedLoginsSettingFragment"
android:label="SaveLoginSettingFragment" /> android:label="SaveLoginSettingFragment" />
<fragment <fragment
android:id="@+id/addonsManagementFragment" android:id="@+id/addonsManagementFragment"

View File

@ -0,0 +1,34 @@
/* 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 io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.EditLoginInteractor
class EditLoginInteractorTest {
private val loginsController: SavedLoginsStorageController = mockk(relaxed = true)
private val interactor = EditLoginInteractor(loginsController)
@Test
fun findPotentialDupesTest() {
val id = "anyId"
interactor.findPotentialDuplicates(id)
verify { loginsController.findPotentialDuplicates(id) }
}
@Test
fun saveLoginTest() {
val id = "anyId"
val username = "usernameText"
val password = "passwordText"
interactor.onSaveLogin(id, username, password)
verify { loginsController.save(id, username, password) }
}
}

View File

@ -0,0 +1,30 @@
/* 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 io.mockk.mockk
import io.mockk.verifyAll
import org.junit.Test
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.LoginDetailInteractor
class LoginDetailInteractorTest {
private val loginsController: SavedLoginsStorageController = mockk(relaxed = true)
private val interactor = LoginDetailInteractor(loginsController)
@Test
fun fetchLoginListTest() {
val id = "anyId"
interactor.onFetchLoginList(id)
verifyAll { loginsController.fetchLoginDetails(id) }
}
@Test
fun deleteLoginTest() {
val id = "anyId"
interactor.onDeleteLogin(id)
verifyAll { loginsController.delete(id) }
}
}

View File

@ -15,6 +15,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.logins.view.LoginDetailView
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class LoginDetailViewTest { class LoginDetailViewTest {
@ -31,7 +32,8 @@ class LoginDetailViewTest {
), ),
searchedForText = null, searchedForText = null,
sortingStrategy = SortingStrategy.LastUsed(mockk()), sortingStrategy = SortingStrategy.LastUsed(mockk()),
highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort,
duplicateLogins = listOf()
) )
private lateinit var view: ViewGroup private lateinit var view: ViewGroup

View File

@ -30,7 +30,8 @@ class LoginsFragmentStoreTest {
filteredItems = emptyList(), filteredItems = emptyList(),
searchedForText = null, searchedForText = null,
sortingStrategy = SortingStrategy.LastUsed(mockk()), sortingStrategy = SortingStrategy.LastUsed(mockk()),
highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort highlightedItem = SavedLoginsSortingStrategyMenu.Item.LastUsedSort,
duplicateLogins = listOf()
) )
@Test @Test

View File

@ -6,7 +6,6 @@ package org.mozilla.fenix.settings.logins
import androidx.navigation.NavController import androidx.navigation.NavController
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifyAll import io.mockk.verifyAll
import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.robolectric.testContext
import org.junit.Test import org.junit.Test
@ -16,24 +15,32 @@ import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.logins.controller.LoginsListController
import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragmentDirections
import org.mozilla.fenix.utils.Settings import org.mozilla.fenix.utils.Settings
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsControllerTest { class LoginsListControllerTest {
private val store: LoginsFragmentStore = mockk(relaxed = true) private val store: LoginsFragmentStore = mockk(relaxed = true)
private val settings: Settings = mockk(relaxed = true)
private val sortingStrategy: SortingStrategy = SortingStrategy.Alphabetically(testContext)
private val navController: NavController = mockk(relaxed = true) private val navController: NavController = mockk(relaxed = true)
private val browserNavigator: (String, Boolean, BrowserDirection) -> Unit = mockk(relaxed = true) private val browserNavigator: (String, Boolean, BrowserDirection) -> Unit = mockk(relaxed = true)
private val settings: Settings = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true) private val metrics: MetricController = mockk(relaxed = true)
private val sortingStrategy: SortingStrategy = SortingStrategy.Alphabetically(testContext) private val controller =
private val controller = SavedLoginsController(store, navController, browserNavigator, settings, metrics) LoginsListController(
loginsFragmentStore = store,
navController = navController,
browserNavigator = browserNavigator,
settings = settings,
metrics = metrics
)
@Test @Test
fun `GIVEN a sorting strategy, WHEN handleSort is called on the controller, THEN the correct action should be dispatched and the strategy saved in sharedPref`() { fun `GIVEN a sorting strategy, WHEN handleSort is called on the controller, THEN the correct action should be dispatched and the strategy saved in sharedPref`() {
controller.handleSort(sortingStrategy) controller.handleSort(sortingStrategy)
verify { verifyAll {
store.dispatch( store.dispatch(
LoginsAction.SortLogins( LoginsAction.SortLogins(
SortingStrategy.Alphabetically( SortingStrategy.Alphabetically(
@ -55,7 +62,7 @@ class SavedLoginsControllerTest {
store.dispatch(LoginsAction.LoginSelected(login)) store.dispatch(LoginsAction.LoginSelected(login))
metrics.track(Event.OpenOneLogin) metrics.track(Event.OpenOneLogin)
navController.navigate( navController.navigate(
SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(login.guid) SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(login.guid)
) )
} }
} }
@ -64,7 +71,7 @@ class SavedLoginsControllerTest {
fun `GIVEN the learn more option, WHEN handleLearnMoreClicked is called for it, then we should open the right support webpage`() { fun `GIVEN the learn more option, WHEN handleLearnMoreClicked is called for it, then we should open the right support webpage`() {
controller.handleLearnMoreClicked() controller.handleLearnMoreClicked()
verify { verifyAll {
browserNavigator.invoke( browserNavigator.invoke(
SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP), SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP),
true, true,

View File

@ -16,6 +16,8 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
import org.mozilla.fenix.settings.logins.view.LoginsListViewHolder
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class LoginsListViewHolderTest { class LoginsListViewHolderTest {
@ -39,7 +41,10 @@ class LoginsListViewHolderTest {
@Test @Test
fun `bind url and username`() { fun `bind url and username`() {
val holder = LoginsListViewHolder(view, interactor) val holder = LoginsListViewHolder(
view,
interactor
)
holder.bind(baseLogin) holder.bind(baseLogin)
assertEquals("mozilla.org", view.webAddressView.text) assertEquals("mozilla.org", view.webAddressView.text)
@ -48,7 +53,10 @@ class LoginsListViewHolderTest {
@Test @Test
fun `call interactor on click`() { fun `call interactor on click`() {
val holder = LoginsListViewHolder(view, interactor) val holder = LoginsListViewHolder(
view,
interactor
)
holder.bind(baseLogin) holder.bind(baseLogin)
view.performClick() view.performClick()

View File

@ -7,15 +7,25 @@ package org.mozilla.fenix.settings.logins
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verifyAll import io.mockk.verifyAll
import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.robolectric.testContext
import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.logins.controller.LoginsListController
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
import kotlin.random.Random import kotlin.random.Random
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsInteractorTest { class SavedLoginsInteractorTest {
private val controller: SavedLoginsController = mockk(relaxed = true) private val listController: LoginsListController = mockk(relaxed = true)
private val interactor = SavedLoginsInteractor(controller) private val savedLoginsStorageController: SavedLoginsStorageController = mockk(relaxed = true)
private lateinit var interactor: SavedLoginsInteractor
@Before
fun setup() {
interactor = SavedLoginsInteractor(listController, savedLoginsStorageController)
}
@Test @Test
fun `GIVEN a SavedLogin being clicked, WHEN the interactor is called for it, THEN it should just delegate the controller`() { fun `GIVEN a SavedLogin being clicked, WHEN the interactor is called for it, THEN it should just delegate the controller`() {
@ -23,7 +33,7 @@ class SavedLoginsInteractorTest {
interactor.onItemClicked(item) interactor.onItemClicked(item)
verifyAll { verifyAll {
controller.handleItemClicked(item) listController.handleItemClicked(item)
} }
} }
@ -34,7 +44,7 @@ class SavedLoginsInteractorTest {
interactor.onSortingStrategyChanged(sortingStrategy) interactor.onSortingStrategyChanged(sortingStrategy)
verifyAll { verifyAll {
controller.handleSort(sortingStrategy) listController.handleSort(sortingStrategy)
} }
} }
@ -43,7 +53,13 @@ class SavedLoginsInteractorTest {
interactor.onLearnMoreClicked() interactor.onLearnMoreClicked()
verifyAll { verifyAll {
controller.handleLearnMoreClicked() listController.handleLearnMoreClicked()
} }
} }
@Test
fun loadAndMapLoginsTest() {
interactor.loadAndMapLogins()
verifyAll { savedLoginsStorageController.handleLoadAndMapLogins() }
}
} }

View File

@ -0,0 +1,139 @@
/* 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 android.os.Looper
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestCoroutineScope
import mozilla.components.concept.storage.Login
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.LooperMode
@ExperimentalCoroutinesApi
@LooperMode(LooperMode.Mode.PAUSED)
@RunWith(FenixRobolectricTestRunner::class)
class SavedLoginsStorageControllerTest {
private lateinit var components: Components
private val context: Context = mockk(relaxed = true)
private lateinit var controller: SavedLoginsStorageController
private val navController: NavController = mockk(relaxed = true)
private val loginsFragmentStore: LoginsFragmentStore = mockk(relaxed = true)
private val scope = TestCoroutineScope()
private val loginMock: Login = mockk(relaxed = true)
@Before
fun setup() {
every { navController.currentDestination } returns NavDestination("").apply {
id = R.id.loginDetailFragment
}
coEvery { context.components.core.passwordsStorage.get(any()) } returns loginMock
every { loginsFragmentStore.dispatch(any()) } returns mockk()
coEvery { context.components.core.passwordsStorage } returns mockk(relaxed = true)
components = mockk(relaxed = true)
controller = SavedLoginsStorageController(
context = context,
viewLifecycleScope = MainScope(),
navController = navController,
loginsFragmentStore = loginsFragmentStore
)
}
@After
fun cleanUp() {
scope.cleanupTestCoroutines()
}
@Test
fun `WHEN a login is deleted, THEN navigate back to the previous page`() = runBlocking {
val loginId = "id"
// mock for deleteLoginJob: Deferred<Boolean>?
coEvery { context.components.core.passwordsStorage.delete(any()) } returns true
controller.delete(loginId)
shadow()
coVerify { context.components.core.passwordsStorage.delete(loginId) }
}
private fun shadow() {
// solves issue with Roboelectric v4.3 and SDK 28
// https://github.com/robolectric/robolectric/issues/5356
shadowOf(Looper.getMainLooper()).idle()
}
@Test
fun `WHEN fetching the login list, THEN update the state in the store`() {
val loginId = "id"
// for deferredLogin: Deferred<List<Login>>?
coEvery { context.components.core.passwordsStorage.list() } returns listOf()
controller.fetchLoginDetails(loginId)
coVerify { context.components.core.passwordsStorage.list() }
}
@Test
fun `WHEN saving an update to an item, THEN navigate to login detail view`() {
val login = Login(
guid = "id",
origin = "https://www.test.co.gov.org",
username = "user123",
password = "securePassword1",
httpRealm = "httpRealm",
formActionOrigin = ""
)
coEvery { context.components.core.passwordsStorage.get(any()) } returns loginMock
controller.save(login.guid!!, login.username, login.password)
coVerify { context.components.core.passwordsStorage.get(any()) }
}
@Test
fun `WHEN finding login dupes, THEN update duplicates in the store`() {
val login = Login(
guid = "id",
origin = "https://www.test.co.gov.org",
username = "user123",
password = "securePassword1",
httpRealm = "httpRealm",
formActionOrigin = ""
)
coEvery { context.components.core.passwordsStorage.get(any()) } returns login
// for deferredLogin: Deferred<List<Login>>?
coEvery {
context.components.core.passwordsStorage.getPotentialDupesIgnoringUsername(any())
} returns listOf()
controller.findPotentialDuplicates(login.guid!!)
shadow()
coVerify {
context.components.core.passwordsStorage.getPotentialDupesIgnoringUsername(login)
}
}
}

View File

@ -118,8 +118,11 @@ The following metrics are added to the ping:
| history.removed_all |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user removed all history items |[1](https://github.com/mozilla-mobile/fenix/pull/3940)||2020-10-01 | | history.removed_all |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user removed all history items |[1](https://github.com/mozilla-mobile/fenix/pull/3940)||2020-10-01 |
| history.shared |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user shared a history item |[1](https://github.com/mozilla-mobile/fenix/pull/3940)||2020-10-01 | | history.shared |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user shared a history item |[1](https://github.com/mozilla-mobile/fenix/pull/3940)||2020-10-01 |
| logins.copy_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user copied a piece of a login in saved logins |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 | | logins.copy_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user copied a piece of a login in saved logins |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 |
| logins.delete_saved_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user confirms delete of a saved login |[1](https://github.com/mozilla-mobile/fenix/issues/11208)||2020-10-01 |
| logins.open_individual_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user accessed an individual login in saved logins |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 | | logins.open_individual_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user accessed an individual login in saved logins |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 |
| logins.open_login_editor |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user entered the edit screen for an individual saved login |[1](https://github.com/mozilla-mobile/fenix/issues/11208)||2020-10-01 |
| logins.open_logins |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user accessed Logins in Settings |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 | | logins.open_logins |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user accessed Logins in Settings |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 |
| logins.save_edited_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user saves changes made to an individual login |[1](https://github.com/mozilla-mobile/fenix/issues/11208)||2020-10-01 |
| logins.save_logins_setting_changed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user changed their setting for asking to save logins |[1](https://github.com/mozilla-mobile/fenix/pull/7767)|<ul><li>setting: The new setting for saving logins the user selected. Either `ask_to_save` or `never_save` </li></ul>|2020-10-01 | | logins.save_logins_setting_changed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user changed their setting for asking to save logins |[1](https://github.com/mozilla-mobile/fenix/pull/7767)|<ul><li>setting: The new setting for saving logins the user selected. Either `ask_to_save` or `never_save` </li></ul>|2020-10-01 |
| logins.view_password_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user viewed a password in an individual saved login |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 | | logins.view_password_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user viewed a password in an individual saved login |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 |
| media_notification.pause |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user pressed the pause icon on the media notification |[1](https://github.com/mozilla-mobile/fenix/pull/5520)||2020-10-01 | | media_notification.pause |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user pressed the pause icon on the media notification |[1](https://github.com/mozilla-mobile/fenix/pull/5520)||2020-10-01 |