Unverified Commit edc75c3a authored by Elise Richards's avatar Elise Richards Committed by GitHub
Browse files

Fixes #9504: Edit logins (#9693)

* Create editable view and fragment. Update login info page to display options menu with edit and delete.

* Create feature flag for edit. Check flag in the login detail fragment and default to just delete.

* Add three-dot kebab options menu in login detail fragment. Add title to the login item.

* Nav to and from edit view on save and back pressed.

* Save login through AC login manager. Clear text in editable field on button click.

* Match colors, fonts, dimens to UX specs for edit logins. Enable password reveal/hide and clearing text fields.

* Refactoring logins fragments. Using component Login object for consistency.

Fetch login list when saved logins are opened. Fetch login details when detail view is opened.

Revert "Fetch login list when saved logins are opened. Fetch login details when detail view is opened."

This reverts commit 44fe17166c3332b330229258b2e8982832672e3b.

* Using parcelable login and Login component class to pass ids and items between fragments

* Retrieve login from storage when viewing login details.

Rename login logic for consistency.

Ktlint cleanup

Fix nits and naming consistency.

* UX consistency for login detail and edit login pages

* Rebasing with logins sort - updating logins store.

* Rebasing with logins sort - merging fragments and controllers.

* Lint and removing unused files.

* UX cleanup.

* Update string description
parent f7b4f1c9
......@@ -39,6 +39,11 @@ object FeatureFlags {
*/
val tips = Config.channel.isDebug
/**
* Allows edit of saved logins.
*/
val loginsEdit = Config.channel.isNightlyOrDebug
/**
* Enables new tab tray pref
*/
......
......@@ -69,10 +69,10 @@ import org.mozilla.fenix.perf.Performance
import org.mozilla.fenix.perf.StartupTimeline
import org.mozilla.fenix.search.SearchFragmentDirections
import org.mozilla.fenix.settings.DefaultBrowserSettingsFragmentDirections
import org.mozilla.fenix.settings.logins.SavedLoginsAuthFragmentDirections
import org.mozilla.fenix.settings.SettingsFragmentDirections
import org.mozilla.fenix.settings.TrackingProtectionFragmentDirections
import org.mozilla.fenix.settings.about.AboutFragmentDirections
import org.mozilla.fenix.settings.logins.SavedLoginsFragmentDirections
import org.mozilla.fenix.theme.DefaultThemeManager
import org.mozilla.fenix.theme.ThemeManager
import org.mozilla.fenix.utils.BrowsersCache
......@@ -387,7 +387,7 @@ open class HomeActivity : LocaleAwareAppCompatActivity() {
BrowserDirection.FromDefaultBrowserSettingsFragment ->
DefaultBrowserSettingsFragmentDirections.actionGlobalBrowser(customTabSessionId)
BrowserDirection.FromSavedLoginsFragment ->
SavedLoginsFragmentDirections.actionGlobalBrowser(customTabSessionId)
SavedLoginsAuthFragmentDirections.actionGlobalBrowser(customTabSessionId)
}
private fun load(
......
......@@ -22,7 +22,7 @@ class ExceptionsListItemViewHolder(
) : RecyclerView.ViewHolder(view) {
private val favicon = view.favicon_image
private val url = view.domainView
private val url = view.webAddressView
private val deleteButton = view.delete_exception
private var item: TrackingProtectionException? = null
......
......@@ -240,7 +240,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
null
}
resources.getString(R.string.pref_key_passwords) -> {
SettingsFragmentDirections.actionSettingsFragmentToLoginsFragment()
SettingsFragmentDirections.actionSettingsFragmentToSavedLoginsAuthFragment()
}
resources.getString(R.string.pref_key_about) -> {
SettingsFragmentDirections.actionSettingsFragmentToAboutFragment()
......
/* 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.os.Bundle
import android.text.Editable
import android.text.InputType
import android.util.Log
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_edit_login.inputLayoutPassword
import kotlinx.android.synthetic.main.fragment_edit_login.inputLayoutUsername
import kotlinx.android.synthetic.main.fragment_edit_login.hostnameText
import kotlinx.android.synthetic.main.fragment_edit_login.usernameText
import kotlinx.android.synthetic.main.fragment_edit_login.passwordText
import kotlinx.android.synthetic.main.fragment_edit_login.clearUsernameTextButton
import kotlinx.android.synthetic.main.fragment_edit_login.clearPasswordTextButton
import kotlinx.android.synthetic.main.fragment_edit_login.revealPasswordButton
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
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 org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
/**
* Displays the editable saved login information for a single website.
*/
@Suppress("TooManyFunctions", "NestedBlockDepth", "ForbiddenComment")
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)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
savedLoginsStore = StoreProvider.get(this) {
LoginsFragmentStore(
LoginsListState(
isLoading = true,
loginList = listOf(),
filteredItems = listOf(),
searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem
)
)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// ensure hostname isn't editable
hostnameText.text = args.savedLoginItem.origin.toEditable()
hostnameText.isClickable = false
hostnameText.isFocusable = false
usernameText.text = args.savedLoginItem.username.toEditable()
passwordText.text = args.savedLoginItem.password!!.toEditable()
// TODO: extend PasswordTransformationMethod() to change bullets to asterisks
passwordText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
setUpClickListeners()
}
private fun setUpClickListeners() {
clearUsernameTextButton.setOnClickListener {
usernameText.text?.clear()
usernameText.isCursorVisible = true
usernameText.hasFocus()
inputLayoutUsername.hasFocus()
}
clearPasswordTextButton.setOnClickListener {
passwordText.text?.clear()
passwordText.isCursorVisible = true
passwordText.hasFocus()
inputLayoutPassword.hasFocus()
}
revealPasswordButton.setOnClickListener {
togglePasswordReveal()
}
passwordText.setOnClickListener {
togglePasswordReveal()
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.login_save, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.save_login_button -> {
view?.hideKeyboard()
try {
if (!passwordText.text.isNullOrBlank()) {
attemptSaveAndExit()
} else {
view?.let {
FenixSnackbar.make(
view = it,
duration = Snackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = false
).setText(getString(R.string.saved_login_password_required)).show()
}
}
} 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)
}
}
true
}
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
}
}
......@@ -4,86 +4,158 @@
package org.mozilla.fenix.settings.logins
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.text.InputType
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.MenuInflater
import android.view.Menu
import android.view.View
import android.view.WindowManager
import android.view.ViewGroup
import android.view.LayoutInflater
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.fragment_saved_login_site_info.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.android.synthetic.main.fragment_login_detail.*
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.async
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.Login
import mozilla.components.lib.state.ext.consumeFrom
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.checkAndUpdateScreenshotPermission
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.urlToTrimmedHost
/**
* Displays saved login information for a single website.
*/
class SavedLoginSiteInfoFragment : Fragment(R.layout.fragment_saved_login_site_info) {
@Suppress("TooManyFunctions", "ForbiddenComment")
@ExperimentalCoroutinesApi
class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
private val args by navArgs<SavedLoginSiteInfoFragmentArgs>()
private val args by navArgs<LoginDetailFragmentArgs>()
private var login: SavedLogin? = null
private lateinit var savedLoginsStore: LoginsFragmentStore
private lateinit var loginDetailView: LoginDetailView
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val view = inflater.inflate(R.layout.fragment_login_detail, container, false)
savedLoginsStore = StoreProvider.get(this) {
LoginsFragmentStore(
LoginsListState(
isLoading = true,
loginList = listOf(),
filteredItems = listOf(),
searchedForText = null,
sortingStrategy = requireContext().settings().savedLoginsSortingStrategy,
highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem
)
)
}
loginDetailView = LoginDetailView(view?.findViewById(R.id.loginDetailLayout))
fetchLoginDetails()
return view
}
@ObsoleteCoroutinesApi
@ExperimentalCoroutinesApi
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
consumeFrom(savedLoginsStore) {
loginDetailView.update(it)
login = savedLoginsStore.state.currentItem
setUpCopyButtons()
showToolbar(
savedLoginsStore.state.currentItem?.origin?.urlToTrimmedHost(requireContext())
?: ""
)
setUpPasswordReveal()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onPause() {
// If we pause this fragment, we want to pop users back to reauth
if (findNavController().currentDestination?.id != R.id.savedLoginsFragment) {
activity?.let { it.checkAndUpdateScreenshotPermission(it.settings()) }
findNavController().popBackStack(R.id.loginsFragment, false)
private fun setUpPasswordReveal() {
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordButton.setOnClickListener {
togglePasswordReveal()
}
super.onPause()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
private fun setUpCopyButtons() {
webAddressText.text = login?.origin
copyWebAddress.setOnClickListener(
CopyButtonListener(login?.origin, R.string.logins_site_copied)
)
siteInfoText.text = args.savedLoginItem.url
copySiteItem.setOnClickListener(
CopyButtonListener(args.savedLoginItem.url, R.string.logins_site_copied)
usernameText.text = login?.username
copyUsername.setOnClickListener(
CopyButtonListener(login?.username, R.string.logins_username_copied)
)
usernameInfoText.text = args.savedLoginItem.userName
copyUsernameItem.setOnClickListener(
CopyButtonListener(args.savedLoginItem.userName, R.string.logins_username_copied)
passwordText.text = login?.password
copyPassword.setOnClickListener(
CopyButtonListener(login?.password, R.string.logins_password_copied)
)
}
passwordInfoText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
passwordInfoText.text = args.savedLoginItem.password
revealPasswordItem.setOnClickListener {
togglePasswordReveal(it.context)
// 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()
}
}
copyPasswordItem.setOnClickListener(
CopyButtonListener(args.savedLoginItem.password, R.string.logins_password_copied)
)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.login_edit, menu)
if (FeatureFlags.loginsEdit) {
inflater.inflate(R.menu.login_options_menu, menu)
} else {
inflater.inflate(R.menu.login_delete, menu)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
......@@ -91,14 +163,43 @@ class SavedLoginSiteInfoFragment : Fragment(R.layout.fragment_saved_login_site_i
displayDeleteLoginDialog()
true
}
R.id.edit_login_button -> {
editLogin()
true
}
else -> false
}
private fun editLogin() {
val directions =
LoginDetailFragmentDirections
.actionLoginDetailFragmentToEditLoginFragment(login!!)
findNavController().navigate(directions)
}
private fun displayDeleteLoginDialog() {
activity?.let { activity ->
AlertDialog.Builder(activity).apply {
setMessage(R.string.login_deletion_confirmation)
setNegativeButton(android.R.string.cancel) { dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ ->
deleteLogin()
dialog.dismiss()
}
create()
}.show()
}
}
// 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.savedLoginItem.id)
requireContext().components.core.passwordsStorage.delete(args.savedLoginId)
}
deleteLoginJob?.await()
withContext(Main) {
......@@ -112,57 +213,27 @@ class SavedLoginSiteInfoFragment : Fragment(R.layout.fragment_saved_login_site_i
}
}
private fun togglePasswordReveal(context: Context) {
if (passwordInfoText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT) {
context.components.analytics.metrics.track(Event.ViewLoginPassword)
passwordInfoText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
revealPasswordItem.setImageDrawable(
getDrawable(
context,
R.drawable.mozac_ic_password_hide
)
// 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)
)
revealPasswordItem.contentDescription =
context.getString(R.string.saved_login_hide_password)
revealPasswordButton.contentDescription =
resources.getString(R.string.saved_login_hide_password)
} else {
passwordInfoText.inputType =
passwordText.inputType =
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
revealPasswordItem.setImageDrawable(
getDrawable(
context,
R.drawable.mozac_ic_password_reveal
)
revealPasswordButton.setImageDrawable(
resources.getDrawable(R.drawable.mozac_ic_password_reveal, null)
)
revealPasswordItem.contentDescription =
context.getString(R.string.saved_login_reveal_password)
revealPasswordButton.contentDescription =
context?.getString(R.string.saved_login_reveal_password)
}
// For the new type to take effect you need to reset the text
passwordInfoText.text = args.savedLoginItem.password
}
override fun onResume() {
super.onResume()
activity?.window?.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
showToolbar(args.savedLoginItem.url)
}
private fun displayDeleteLoginDialog() {
activity?.let { activity ->
AlertDialog.Builder(activity).apply {
setMessage(R.string.login_deletion_confirmation)
setNegativeButton(android.R.string.cancel) { dialog: DialogInterface, _ ->