From edc75c3ad0815d64d71f1985e2ab55dcf371823d Mon Sep 17 00:00:00 2001 From: Elise Richards Date: Tue, 12 May 2020 17:32:01 -0500 Subject: [PATCH] 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 --- .../java/org/mozilla/fenix/FeatureFlags.kt | 5 + .../java/org/mozilla/fenix/HomeActivity.kt | 4 +- .../ExceptionsListItemViewHolder.kt | 2 +- .../fenix/settings/SettingsFragment.kt | 2 +- .../settings/logins/EditLoginFragment.kt | 215 ++++++++++++++ .../settings/logins/LoginDetailFragment.kt | 265 ++++++++++++++++++ .../fenix/settings/logins/LoginDetailView.kt | 20 ++ ...SavedLoginsAdapter.kt => LoginsAdapter.kt} | 25 +- .../settings/logins/LoginsFragmentStore.kt | 161 +++++++++++ ...mViewHolder.kt => LoginsListViewHolder.kt} | 30 +- .../logins/SavedLoginSiteInfoFragment.kt | 194 ------------- .../SavedLoginsAuthFragment.kt} | 22 +- .../settings/logins/SavedLoginsController.kt | 22 -- .../settings/logins/SavedLoginsFragment.kt | 26 +- .../logins/SavedLoginsFragmentStore.kt | 146 ---------- .../settings/logins/SavedLoginsInteractor.kt | 26 -- ...gment.kt => SavedLoginsSettingFragment.kt} | 2 +- .../fenix/settings/logins/SavedLoginsView.kt | 60 ++-- .../fenix/settings/logins/SortingStrategy.kt | 8 +- .../main/res/drawable-v26/ic_menu_kebab.xml | 16 ++ app/src/main/res/layout/exception_item.xml | 2 +- .../main/res/layout/fragment_edit_login.xml | 227 +++++++++++++++ .../main/res/layout/fragment_login_detail.xml | 159 +++++++++++ .../layout/fragment_saved_login_site_info.xml | 134 --------- app/src/main/res/layout/logins_item.xml | 16 +- .../menu/{login_edit.xml => login_delete.xml} | 7 +- app/src/main/res/menu/login_options_menu.xml | 35 +++ app/src/main/res/menu/login_save.xml | 14 + app/src/main/res/navigation/nav_graph.xml | 76 +++-- app/src/main/res/values/strings.xml | 18 ++ .../logins/SavedLoginsControllerTest.kt | 6 +- .../logins/SavedLoginsInteractorTest.kt | 4 +- 32 files changed, 1308 insertions(+), 641 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailFragment.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailView.kt rename app/src/main/java/org/mozilla/fenix/settings/logins/{SavedLoginsAdapter.kt => LoginsAdapter.kt} (51%) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt rename app/src/main/java/org/mozilla/fenix/settings/logins/{SavedLoginsListItemViewHolder.kt => LoginsListViewHolder.kt} (61%) delete mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginSiteInfoFragment.kt rename app/src/main/java/org/mozilla/fenix/settings/{LoginsFragment.kt => logins/SavedLoginsAuthFragment.kt} (92%) delete mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsController.kt delete mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragmentStore.kt delete mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractor.kt rename app/src/main/java/org/mozilla/fenix/settings/logins/{SaveLoginSettingFragment.kt => SavedLoginsSettingFragment.kt} (98%) create mode 100644 app/src/main/res/drawable-v26/ic_menu_kebab.xml create mode 100644 app/src/main/res/layout/fragment_edit_login.xml create mode 100644 app/src/main/res/layout/fragment_login_detail.xml delete mode 100644 app/src/main/res/layout/fragment_saved_login_site_info.xml rename app/src/main/res/menu/{login_edit.xml => login_delete.xml} (85%) create mode 100644 app/src/main/res/menu/login_options_menu.xml create mode 100644 app/src/main/res/menu/login_save.xml diff --git a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt index abe4e4439..e02473ace 100644 --- a/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt +++ b/app/src/main/java/org/mozilla/fenix/FeatureFlags.kt @@ -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 */ diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index c98fab2bb..8fd735974 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -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( diff --git a/app/src/main/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsListItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsListItemViewHolder.kt index f4f241a8c..25e51824c 100644 --- a/app/src/main/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsListItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/exceptions/viewholders/ExceptionsListItemViewHolder.kt @@ -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 diff --git a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt index 675803f48..1d21b0871 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -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() diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt new file mode 100644 index 000000000..aa21d4f0b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt @@ -0,0 +1,215 @@ +/* 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() + 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? = 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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailFragment.kt new file mode 100644 index 000000000..ae2f6cfaa --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailFragment.kt @@ -0,0 +1,265 @@ +/* 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.DialogInterface +import android.os.Bundle +import android.text.InputType +import android.view.MenuItem +import android.view.MenuInflater +import android.view.Menu +import android.view.View +import android.view.ViewGroup +import android.view.LayoutInflater +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +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_login_detail.* +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +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.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. + */ +@Suppress("TooManyFunctions", "ForbiddenComment") +@ExperimentalCoroutinesApi +class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { + + private val args by navArgs() + 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) + } + + private fun setUpPasswordReveal() { + passwordText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + revealPasswordButton.setOnClickListener { + togglePasswordReveal() + } + } + + private fun setUpCopyButtons() { + webAddressText.text = login?.origin + copyWebAddress.setOnClickListener( + CopyButtonListener(login?.origin, R.string.logins_site_copied) + ) + + usernameText.text = login?.username + copyUsername.setOnClickListener( + CopyButtonListener(login?.username, R.string.logins_username_copied) + ) + + passwordText.text = login?.password + copyPassword.setOnClickListener( + CopyButtonListener(login?.password, R.string.logins_password_copied) + ) + } + + // TODO: Move interactions with the component's password storage into a separate datastore + private fun fetchLoginDetails() { + var deferredLogin: Deferred>? = 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) { + 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) { + R.id.delete_login_button -> { + 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? = 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. + * @param value Value to be copied + * @param snackbarText Text to display in snackbar after copying. + */ + private inner class CopyButtonListener( + private val value: String?, + @StringRes private val snackbarText: Int + ) : View.OnClickListener { + override fun onClick(view: View) { + val clipboard = view.context.components.clipboardHandler + clipboard.text = value + showCopiedSnackbar(view.context.getString(snackbarText)) + view.context.components.analytics.metrics.track(Event.CopyLogin) + } + + private fun showCopiedSnackbar(copiedItem: String) { + view?.let { + FenixSnackbar.make( + view = it, + duration = Snackbar.LENGTH_SHORT, + isDisplayedWithBrowserToolbar = false + ).setText(copiedItem).show() + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailView.kt new file mode 100644 index 000000000..0eb6300d8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailView.kt @@ -0,0 +1,20 @@ +/* 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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAdapter.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsAdapter.kt similarity index 51% rename from app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAdapter.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/LoginsAdapter.kt index c32de9951..40e1d39ca 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsAdapter.kt @@ -8,33 +8,30 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter +import org.mozilla.fenix.R -private sealed class AdapterItem { - data class Item(val item: SavedLoginsItem) : AdapterItem() -} - -class SavedLoginsAdapter( +class LoginsAdapter( private val interactor: SavedLoginsInteractor -) : ListAdapter(DiffCallback) { +) : ListAdapter(DiffCallback) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int - ): SavedLoginsListItemViewHolder { + ): LoginsListViewHolder { val view = LayoutInflater.from(parent.context) - .inflate(SavedLoginsListItemViewHolder.LAYOUT_ID, parent, false) - return SavedLoginsListItemViewHolder(view, interactor) + .inflate(R.layout.logins_item, parent, false) + return LoginsListViewHolder(view, interactor) } - override fun onBindViewHolder(holder: SavedLoginsListItemViewHolder, position: Int) { + override fun onBindViewHolder(holder: LoginsListViewHolder, position: Int) { holder.bind(getItem(position)) } - private object DiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: SavedLoginsItem, newItem: SavedLoginsItem) = - oldItem.url == newItem.url + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SavedLogin, newItem: SavedLogin) = + oldItem.origin == newItem.origin - override fun areContentsTheSame(oldItem: SavedLoginsItem, newItem: SavedLoginsItem) = + override fun areContentsTheSame(oldItem: SavedLogin, newItem: SavedLogin) = oldItem == newItem } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt new file mode 100644 index 000000000..dff67d90a --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt @@ -0,0 +1,161 @@ +/* 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.Parcelable +import kotlinx.android.parcel.Parcelize +import mozilla.components.concept.storage.Login +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * Class representing a parcelable saved logins item + * @property guid The id of the saved login + * @property origin Site of the saved login + * @property username Username that's saved for this site + * @property password Password that's saved for this site + * @property timeLastUsed Time of last use in milliseconds from the unix epoch. + */ +@Parcelize +data class SavedLogin( + val guid: String, + val origin: String, + val username: String, + val password: String?, + val timeLastUsed: Long +) : Parcelable + +fun Login.mapToSavedLogin(): SavedLogin = + SavedLogin( + guid = this.guid!!, + origin = this.origin, + username = this.username, + password = this.password, + timeLastUsed = this.timeLastUsed + ) + +/** + * The [Store] for holding the [LoginsListState] and applying [LoginsAction]s. + */ +class LoginsFragmentStore(initialState: LoginsListState) : + Store( + initialState, + ::savedLoginsStateReducer + ) + +/** + * Actions to dispatch through the `LoginsFragmentStore` to modify `LoginsListState` through the reducer. + */ +sealed class LoginsAction : Action { + data class FilterLogins(val newText: String?) : LoginsAction() + data class UpdateLoginsList(val list: List) : LoginsAction() + data class UpdateCurrentLogin(val item: SavedLogin) : LoginsAction() + data class SortLogins(val sortingStrategy: SortingStrategy) : LoginsAction() +} + +/** + * 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 currentItem The last item that was opened into the detail view + * @property searchedForText String used by the user to filter logins + * @property sortingStrategy sorting strategy selected by the user (Currently we support + * sorting alphabetically and by last used) + * @property highlightedItem The current selected sorting strategy from the sort menu + */ +data class LoginsListState( + val isLoading: Boolean = false, + val loginList: List, + val filteredItems: List, + val currentItem: SavedLogin? = null, + val searchedForText: String?, + val sortingStrategy: SortingStrategy, + val highlightedItem: SavedLoginsSortingStrategyMenu.Item +) : State + +/** + * Handles changes in the saved logins list, including updates and filtering. + */ +private fun savedLoginsStateReducer( + state: LoginsListState, + action: LoginsAction +): LoginsListState { + return when (action) { + is LoginsAction.UpdateLoginsList -> state.copy( + isLoading = false, + loginList = action.list, + filteredItems = action.list + ) + is LoginsAction.FilterLogins -> { + filterItems( + action.newText, + state.sortingStrategy, + state + ) + } + is LoginsAction.UpdateCurrentLogin -> { + state.copy( + currentItem = action.item + ) + } + is LoginsAction.SortLogins -> { + filterItems( + state.searchedForText, + action.sortingStrategy, + state + ) + } + } +} + +/** + * @return [LoginsListState] containing a new [LoginsListState.filteredItems] + * with filtered [LoginsListState.items] + * + * @param searchedForText based on which [LoginsListState.items] will be filtered. + * @param sortingStrategy based on which [LoginsListState.items] will be sorted. + * @param state previous [LoginsListState] containing all the other properties + * with which a new state will be created + */ +private fun filterItems( + searchedForText: String?, + sortingStrategy: SortingStrategy, + state: LoginsListState +): LoginsListState { + return if (searchedForText.isNullOrBlank()) { + state.copy( + isLoading = false, + sortingStrategy = sortingStrategy, + highlightedItem = sortingStrategyToMenuItem(sortingStrategy), + searchedForText = searchedForText, + filteredItems = sortingStrategy(state.loginList) + ) + } else { + state.copy( + isLoading = false, + sortingStrategy = sortingStrategy, + highlightedItem = sortingStrategyToMenuItem(sortingStrategy), + searchedForText = searchedForText, + filteredItems = sortingStrategy(state.loginList).filter { + it.origin.contains( + searchedForText + ) + } + ) + } +} + +private fun sortingStrategyToMenuItem(sortingStrategy: SortingStrategy): SavedLoginsSortingStrategyMenu.Item { + return when (sortingStrategy) { + is SortingStrategy.Alphabetically -> { + SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort + } + + is SortingStrategy.LastUsed -> { + SavedLoginsSortingStrategyMenu.Item.LastUsedSort + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsListItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsListViewHolder.kt similarity index 61% rename from app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsListItemViewHolder.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/LoginsListViewHolder.kt index e90929965..62183ae71 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsListItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsListViewHolder.kt @@ -7,26 +7,32 @@ package org.mozilla.fenix.settings.logins import android.view.View import androidx.recyclerview.widget.RecyclerView import kotlinx.android.synthetic.main.logins_item.view.* -import org.mozilla.fenix.R import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.loadIntoView -class SavedLoginsListItemViewHolder( +class LoginsListViewHolder( private val view: View, private val interactor: SavedLoginsInteractor ) : RecyclerView.ViewHolder(view) { private val favicon = view.favicon_image - private val url = view.domainView - private val userName = view.userView + private val url = view.webAddressView + private val username = view.usernameView + private var loginItem: SavedLogin? = null - private var item: SavedLoginsItem? = null + fun bind(item: SavedLogin) { + this.loginItem = SavedLogin( + guid = item.guid, + origin = item.origin, + password = item.password, + username = item.username, + timeLastUsed = item.timeLastUsed + ) + url.text = item.origin + username.text = item.username + + updateFavIcon(item.origin) - fun bind(item: SavedLoginsItem) { - this.item = item - url.text = item.url - userName.text = item.userName - updateFavIcon(item.url) view.setOnClickListener { interactor.itemClicked(item) } @@ -35,8 +41,4 @@ class SavedLoginsListItemViewHolder( private fun updateFavIcon(url: String) { favicon.context.components.core.icons.loadIntoView(favicon, url) } - - companion object { - const val LAYOUT_ID = R.layout.logins_item - } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginSiteInfoFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginSiteInfoFragment.kt deleted file mode 100644 index fbcd31e5e..000000000 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginSiteInfoFragment.kt +++ /dev/null @@ -1,194 +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.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.View -import android.view.WindowManager -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.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.mozilla.fenix.R -import org.mozilla.fenix.components.FenixSnackbar -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 - -/** - * Displays saved login information for a single website. - */ -class SavedLoginSiteInfoFragment : Fragment(R.layout.fragment_saved_login_site_info) { - - private val args by navArgs() - - 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) - } - super.onPause() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - siteInfoText.text = args.savedLoginItem.url - copySiteItem.setOnClickListener( - CopyButtonListener(args.savedLoginItem.url, R.string.logins_site_copied) - ) - - usernameInfoText.text = args.savedLoginItem.userName - copyUsernameItem.setOnClickListener( - CopyButtonListener(args.savedLoginItem.userName, R.string.logins_username_copied) - ) - - passwordInfoText.inputType = - InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - passwordInfoText.text = args.savedLoginItem.password - revealPasswordItem.setOnClickListener { - togglePasswordReveal(it.context) - } - 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) - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { - R.id.delete_login_button -> { - displayDeleteLoginDialog() - true - } - else -> false - } - - private fun deleteLogin() { - var deleteLoginJob: Deferred? = null - val deleteJob = viewLifecycleOwner.lifecycleScope.launch(IO) { - deleteLoginJob = async { - requireContext().components.core.passwordsStorage.delete(args.savedLoginItem.id) - } - deleteLoginJob?.await() - withContext(Main) { - findNavController().popBackStack(R.id.savedLoginsFragment, false) - } - } - deleteJob.invokeOnCompletion { - if (it is CancellationException) { - deleteLoginJob?.cancel() - } - } - } - - 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 - ) - ) - revealPasswordItem.contentDescription = - context.getString(R.string.saved_login_hide_password) - } else { - passwordInfoText.inputType = - InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - revealPasswordItem.setImageDrawable( - getDrawable( - context, - R.drawable.mozac_ic_password_reveal - ) - ) - revealPasswordItem.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, _ -> - dialog.cancel() - } - setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ -> - deleteLogin() - dialog.dismiss() - } - create() - }.show() - } - } - - /** - * Click listener for a textview's copy button. - * @param value Value to be copied - * @param snackbarText Text to display in snackbar after copying. - */ - private inner class CopyButtonListener( - private val value: String?, - @StringRes private val snackbarText: Int - ) : View.OnClickListener { - override fun onClick(view: View) { - val clipboard = view.context.components.clipboardHandler - clipboard.text = value - showCopiedSnackbar(view.context.getString(snackbarText)) - view.context.components.analytics.metrics.track(Event.CopyLogin) - } - - private fun showCopiedSnackbar(copiedItem: String) { - view?.let { - FenixSnackbar.make( - view = it, - duration = Snackbar.LENGTH_SHORT, - isDisplayedWithBrowserToolbar = false - ).setText(copiedItem).show() - } - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAuthFragment.kt similarity index 92% rename from app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAuthFragment.kt index 67d85a71f..a21b7c458 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/LoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAuthFragment.kt @@ -2,7 +2,7 @@ * 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 +package org.mozilla.fenix.settings.logins import android.annotation.TargetApi import android.app.Activity.RESULT_OK @@ -38,10 +38,11 @@ import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.secure import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.settings.SharedPreferenceUpdater import java.util.concurrent.Executors @Suppress("TooManyFunctions", "LargeClass") -class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { +class SavedLoginsAuthFragment : PreferenceFragmentCompat(), AccountObserver { @TargetApi(M) private lateinit var biometricPromptCallback: BiometricPrompt.AuthenticationCallback @@ -114,7 +115,8 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { isEnabled = context.settings().shouldPromptToSaveLogins isChecked = context.settings().shouldAutofillLogins && context.settings().shouldPromptToSaveLogins - onPreferenceChangeListener = SharedPreferenceUpdater() + onPreferenceChangeListener = + SharedPreferenceUpdater() } val savedLoginsKey = getPreferenceKey(R.string.pref_key_saved_logins) @@ -253,7 +255,9 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { getString(R.string.logins_biometric_prompt_message_pin), getString(R.string.logins_biometric_prompt_message) ) - startActivityForResult(intent, PIN_REQUEST) + startActivityForResult(intent, + PIN_REQUEST + ) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -266,29 +270,29 @@ class LoginsFragment : PreferenceFragmentCompat(), AccountObserver { private fun navigateToSavedLoginsFragment() { context?.components?.analytics?.metrics?.track(Event.OpenLogins) - val directions = LoginsFragmentDirections.actionLoginsFragmentToSavedLoginsFragment() + val directions = SavedLoginsAuthFragmentDirections.actionSavedLoginsAuthFragmentToLoginsListFragment() findNavController().navigate(directions) } private fun navigateToAccountSettingsFragment() { val directions = - LoginsFragmentDirections.actionGlobalAccountSettingsFragment() + SavedLoginsAuthFragmentDirections.actionGlobalAccountSettingsFragment() findNavController().navigate(directions) } private fun navigateToAccountProblemFragment() { - val directions = LoginsFragmentDirections.actionGlobalAccountProblemFragment() + val directions = SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment() findNavController().navigate(directions) } private fun navigateToTurnOnSyncFragment() { - val directions = LoginsFragmentDirections.actionLoginsFragmentToTurnOnSyncFragment() + val directions = SavedLoginsAuthFragmentDirections.actionSavedLoginsAuthFragmentToTurnOnSyncFragment() findNavController().navigate(directions) } private fun navigateToSaveLoginSettingFragment() { val directions = - LoginsFragmentDirections.actionLoginsFragmentToSaveLoginSettingFragment() + SavedLoginsAuthFragmentDirections.actionSavedLoginsAuthFragmentToSavedLoginsSettingFragment() findNavController().navigate(directions) } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsController.kt deleted file mode 100644 index 224ff6c45..000000000 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsController.kt +++ /dev/null @@ -1,22 +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 org.mozilla.fenix.utils.Settings - -interface SavedLoginsController { - fun handleSort(sortingStrategy: SortingStrategy) -} - -class DefaultSavedLoginsController( - val store: SavedLoginsFragmentStore, - val settings: Settings -) : SavedLoginsController { - - override fun handleSort(sortingStrategy: SortingStrategy) { - store.dispatch(SavedLoginsFragmentAction.SortLogins(sortingStrategy)) - settings.savedLoginsSortingStrategy = sortingStrategy - } -} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt index 1a9d7e769..eebd97824 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt @@ -46,7 +46,7 @@ import org.mozilla.fenix.settings.SupportUtils @SuppressWarnings("TooManyFunctions") class SavedLoginsFragment : Fragment() { - private lateinit var savedLoginsStore: SavedLoginsFragmentStore + private lateinit var savedLoginsStore: LoginsFragmentStore private lateinit var savedLoginsView: SavedLoginsView private lateinit var savedLoginsInteractor: SavedLoginsInteractor private lateinit var dropDownMenuAnchorView: View @@ -76,10 +76,10 @@ class SavedLoginsFragment : Fragment() { ): View? { val view = inflater.inflate(R.layout.fragment_saved_logins, container, false) savedLoginsStore = StoreProvider.get(this) { - SavedLoginsFragmentStore( - SavedLoginsFragmentState( + LoginsFragmentStore( + LoginsListState( isLoading = true, - items = listOf(), + loginList = listOf(), filteredItems = listOf(), searchedForText = null, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, @@ -88,7 +88,7 @@ class SavedLoginsFragment : Fragment() { ) } val savedLoginsController: SavedLoginsController = - DefaultSavedLoginsController(savedLoginsStore, requireContext().settings()) + SavedLoginsController(savedLoginsStore, requireContext().settings()) savedLoginsInteractor = SavedLoginsInteractor(savedLoginsController, ::itemClicked, ::openLearnMore) savedLoginsView = SavedLoginsView(view.savedLoginsLayout, savedLoginsInteractor) @@ -119,7 +119,7 @@ class SavedLoginsFragment : Fragment() { } override fun onQueryTextChange(newText: String?): Boolean { - savedLoginsStore.dispatch(SavedLoginsFragmentAction.FilterLogins(newText)) + savedLoginsStore.dispatch(LoginsAction.FilterLogins(newText)) return false } }) @@ -134,17 +134,17 @@ class SavedLoginsFragment : Fragment() { (activity as HomeActivity).getSupportActionBarAndInflateIfNecessary().setDisplayShowTitleEnabled(true) sortingStrategyPopupMenu.dismiss() - if (findNavController().currentDestination?.id != R.id.savedLoginSiteInfoFragment) { + if (findNavController().currentDestination?.id != R.id.loginDetailFragment) { activity?.let { it.checkAndUpdateScreenshotPermission(it.settings()) } - findNavController().popBackStack(R.id.loginsFragment, false) + findNavController().popBackStack(R.id.savedLoginsAuthFragment, false) } super.onPause() } - private fun itemClicked(item: SavedLoginsItem) { + private fun itemClicked(item: SavedLogin) { context?.components?.analytics?.metrics?.track(Event.OpenOneLogin) val directions = - SavedLoginsFragmentDirections.actionSavedLoginsFragmentToSavedLoginSiteInfoFragment(item) + SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(item.guid) findNavController().navigate(directions) } @@ -166,9 +166,9 @@ class SavedLoginsFragment : Fragment() { val logins = deferredLogins?.await() logins?.let { withContext(Main) { - savedLoginsStore.dispatch(SavedLoginsFragmentAction.UpdateLogins(logins.map { item -> - SavedLoginsItem(item.origin, item.username, item.password, item.guid!!, item.timeLastUsed) - })) + savedLoginsStore.dispatch( + LoginsAction.UpdateLoginsList(logins.map { it.mapToSavedLogin() }) + ) } } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragmentStore.kt deleted file mode 100644 index 864c66257..000000000 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragmentStore.kt +++ /dev/null @@ -1,146 +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.os.Parcelable -import kotlinx.android.parcel.Parcelize -import mozilla.components.lib.state.Action -import mozilla.components.lib.state.State -import mozilla.components.lib.state.Store - -/** - * Class representing an saved logins item - * @property url Site of the saved login - * @property userName Username that's saved for this site - * @property password Password that's saved for this site - * @property id The unique identifier for this login entry - * @property timeLastUsed Time of last use in milliseconds from the unix epoch. - */ -@Parcelize -data class SavedLoginsItem( - val url: String, - val userName: String?, - val password: String?, - val id: String, - val timeLastUsed: Long -) : - Parcelable - -/** - * The [Store] for holding the [SavedLoginsFragmentState] and applying [SavedLoginsFragmentAction]s. - */ -class SavedLoginsFragmentStore(initialState: SavedLoginsFragmentState) : - Store( - initialState, - ::savedLoginsStateReducer - ) - -/** - * Actions to dispatch through the `SavedLoginsStore` to modify `SavedLoginsFragmentState` through the reducer. - */ -sealed class SavedLoginsFragmentAction : Action { - data class FilterLogins(val newText: String?) : SavedLoginsFragmentAction() - data class UpdateLogins(val list: List) : SavedLoginsFragmentAction() - data class SortLogins(val sortingStrategy: SortingStrategy) : SavedLoginsFragmentAction() -} - -/** - * The state for the Saved Logins Screen - * @property isLoading State to know when to show loading - * @property items Source of truth of list of logins - * @property filteredItems Filtered (or not) list of logins to display - * @property searchedForText String used by the user to filter logins - * @property sortingStrategy sorting strategy selected by the user (Currently we support - * sorting alphabetically and by last used) - */ -data class SavedLoginsFragmentState( - val isLoading: Boolean = false, - val items: List, - val filteredItems: List, - val searchedForText: String?, - val sortingStrategy: SortingStrategy, - val highlightedItem: SavedLoginsSortingStrategyMenu.Item -) : State - -/** - * The SavedLoginsState Reducer. - */ -private fun savedLoginsStateReducer( - state: SavedLoginsFragmentState, - action: SavedLoginsFragmentAction -): SavedLoginsFragmentState { - return when (action) { - is SavedLoginsFragmentAction.UpdateLogins -> { - filterItems( - state.searchedForText, state.sortingStrategy, state.copy( - isLoading = false, - items = action.list, - filteredItems = emptyList() - ) - ) - } - is SavedLoginsFragmentAction.FilterLogins -> - filterItems( - action.newText, - state.sortingStrategy, - state - ) - is SavedLoginsFragmentAction.SortLogins -> - filterItems( - state.searchedForText, - action.sortingStrategy, - state - ) - } -} - -/** - * @return [SavedLoginsFragmentState] containing a new [SavedLoginsFragmentState.filteredItems] - * with filtered [SavedLoginsFragmentState.items] - * - * @param searchedForText based on which [SavedLoginsFragmentState.items] will be filtered. - * @param sortingStrategy based on which [SavedLoginsFragmentState.items] will be sorted. - * @param state previous [SavedLoginsFragmentState] containing all the other properties - * with which a new state will be created - */ -private fun filterItems( - searchedForText: String?, - sortingStrategy: SortingStrategy, - state: SavedLoginsFragmentState -): SavedLoginsFragmentState { - return if (searchedForText.isNullOrBlank()) { - state.copy( - isLoading = false, - sortingStrategy = sortingStrategy, - highlightedItem = sortingStrategyToMenuItem(sortingStrategy), - searchedForText = searchedForText, - filteredItems = sortingStrategy(state.items) - ) - } else { - state.copy( - isLoading = false, - sortingStrategy = sortingStrategy, - highlightedItem = sortingStrategyToMenuItem(sortingStrategy), - searchedForText = searchedForText, - filteredItems = sortingStrategy(state.items).filter { - it.url.contains( - searchedForText - ) - } - ) - } -} - -private fun sortingStrategyToMenuItem(sortingStrategy: SortingStrategy): SavedLoginsSortingStrategyMenu.Item { - return when (sortingStrategy) { - is SortingStrategy.Alphabetically -> { - SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort - } - - is SortingStrategy.LastUsed -> { - SavedLoginsSortingStrategyMenu.Item.LastUsedSort - } - } -} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractor.kt deleted file mode 100644 index 962cd692b..000000000 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractor.kt +++ /dev/null @@ -1,26 +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 - -/** - * Interactor for the saved logins screen - * Provides implementations for the SavedLoginsViewInteractor - */ -class SavedLoginsInteractor( - private val savedLoginsController: SavedLoginsController, - private val itemClicked: (SavedLoginsItem) -> Unit, - private val learnMore: () -> Unit -) : SavedLoginsViewInteractor { - override fun itemClicked(item: SavedLoginsItem) { - itemClicked.invoke(item) - } - override fun onLearnMore() { - learnMore.invoke() - } - - override fun sort(sortingStrategy: SortingStrategy) { - savedLoginsController.handleSort(sortingStrategy) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SaveLoginSettingFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSettingFragment.kt similarity index 98% rename from app/src/main/java/org/mozilla/fenix/settings/logins/SaveLoginSettingFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSettingFragment.kt index 99d4a19f9..593c21fa9 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SaveLoginSettingFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSettingFragment.kt @@ -15,7 +15,7 @@ import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.settings.RadioButtonPreference import org.mozilla.fenix.settings.SharedPreferenceUpdater -class SaveLoginSettingFragment : PreferenceFragmentCompat() { +class SavedLoginsSettingFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.save_logins_preferences, rootKey) } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt index e083168be..4223fdbf6 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt @@ -16,21 +16,7 @@ import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.component_saved_logins.view.* import kotlinx.android.synthetic.main.component_saved_logins.view.progress_bar import org.mozilla.fenix.R - -/** - * Interface for the SavedLoginsViewInteractor. This interface is implemented by objects that want - * to respond to user interaction on the SavedLoginsView - */ -interface SavedLoginsViewInteractor { - /** - * Called whenever one item is clicked - */ - fun itemClicked(item: SavedLoginsItem) - - fun onLearnMore() - - fun sort(sortingStrategy: SortingStrategy) -} +import org.mozilla.fenix.utils.Settings /** * View that contains and configures the Saved Logins List @@ -44,7 +30,7 @@ class SavedLoginsView( .inflate(R.layout.component_saved_logins, containerView, true) .findViewById(R.id.saved_logins_wrapper) - private val loginsAdapter = SavedLoginsAdapter(interactor) + private val loginsAdapter = LoginsAdapter(interactor) init { view.saved_logins_list.apply { @@ -65,21 +51,51 @@ class SavedLoginsView( with(view.saved_passwords_empty_message) { val appName = context.getString(R.string.app_name) - text = context.getString( - R.string.preferences_passwords_saved_logins_description_empty_text, - appName + text = String.format( + context.getString( + R.string.preferences_passwords_saved_logins_description_empty_text + ), appName ) } } - fun update(state: SavedLoginsFragmentState) { + fun update(state: LoginsListState) { if (state.isLoading) { view.progress_bar.isVisible = true } else { view.progress_bar.isVisible = false - view.saved_logins_list.isVisible = state.items.isNotEmpty() - view.saved_passwords_empty_view.isVisible = state.items.isEmpty() + 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 + */ +class SavedLoginsInteractor( + private val savedLoginsController: SavedLoginsController, + private val itemClicked: (SavedLogin) -> Unit, + private val learnMore: () -> Unit +) { + fun itemClicked(item: SavedLogin) { + itemClicked.invoke(item) + } + fun onLearnMore() { + learnMore.invoke() + } + fun sort(sortingStrategy: SortingStrategy) { + savedLoginsController.handleSort(sortingStrategy) + } +} + +/** + * Controller for the saved logins screen + */ +class SavedLoginsController(val store: LoginsFragmentStore, val settings: Settings) { + fun handleSort(sortingStrategy: SortingStrategy) { + store.dispatch(LoginsAction.SortLogins(sortingStrategy)) + settings.savedLoginsSortingStrategy = sortingStrategy + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SortingStrategy.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/SortingStrategy.kt index 2b50c4c89..2cecfd973 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SortingStrategy.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/SortingStrategy.kt @@ -8,17 +8,17 @@ import android.content.Context import org.mozilla.fenix.ext.urlToTrimmedHost sealed class SortingStrategy { - abstract operator fun invoke(logins: List): List + abstract operator fun invoke(logins: List): List abstract val appContext: Context data class Alphabetically(override val appContext: Context) : SortingStrategy() { - override fun invoke(logins: List): List { - return logins.sortedBy { it.url.urlToTrimmedHost(appContext) } + override fun invoke(logins: List): List { + return logins.sortedBy { it.origin.urlToTrimmedHost(appContext) } } } data class LastUsed(override val appContext: Context) : SortingStrategy() { - override fun invoke(logins: List): List { + override fun invoke(logins: List): List { return logins.sortedByDescending { it.timeLastUsed } } } diff --git a/app/src/main/res/drawable-v26/ic_menu_kebab.xml b/app/src/main/res/drawable-v26/ic_menu_kebab.xml new file mode 100644 index 000000000..d1cc76820 --- /dev/null +++ b/app/src/main/res/drawable-v26/ic_menu_kebab.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/layout/exception_item.xml b/app/src/main/res/layout/exception_item.xml index 15138c60a..38b2e4cd9 100644 --- a/app/src/main/res/layout/exception_item.xml +++ b/app/src/main/res/layout/exception_item.xml @@ -26,7 +26,7 @@ app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_login_detail.xml b/app/src/main/res/layout/fragment_login_detail.xml new file mode 100644 index 000000000..da67376a8 --- /dev/null +++ b/app/src/main/res/layout/fragment_login_detail.xml @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_saved_login_site_info.xml b/app/src/main/res/layout/fragment_saved_login_site_info.xml deleted file mode 100644 index c89c080cb..000000000 --- a/app/src/main/res/layout/fragment_saved_login_site_info.xml +++ /dev/null @@ -1,134 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/logins_item.xml b/app/src/main/res/layout/logins_item.xml index 9a2f37b3b..14f25e3af 100644 --- a/app/src/main/res/layout/logins_item.xml +++ b/app/src/main/res/layout/logins_item.xml @@ -1,7 +1,11 @@ - - diff --git a/app/src/main/res/menu/login_edit.xml b/app/src/main/res/menu/login_delete.xml similarity index 85% rename from app/src/main/res/menu/login_edit.xml rename to app/src/main/res/menu/login_delete.xml index f4b80b31c..c2866be61 100644 --- a/app/src/main/res/menu/login_edit.xml +++ b/app/src/main/res/menu/login_delete.xml @@ -1,9 +1,12 @@ + + + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/login_delete" > - + \ No newline at end of file diff --git a/app/src/main/res/menu/login_options_menu.xml b/app/src/main/res/menu/login_options_menu.xml new file mode 100644 index 000000000..6e3e576c9 --- /dev/null +++ b/app/src/main/res/menu/login_options_menu.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/app/src/main/res/menu/login_save.xml b/app/src/main/res/menu/login_save.xml new file mode 100644 index 000000000..f8c8d2738 --- /dev/null +++ b/app/src/main/res/menu/login_save.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 5493c5dc9..5b1385830 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -263,25 +263,25 @@ + + + + + + + + + + + + + + + + app:destination="@id/savedLoginsAuthFragment" /> - - - - - - @@ -699,7 +727,7 @@ android:name="org.mozilla.fenix.settings.advanced.LocaleSettingsFragment" /> Verified By: %1$s Delete + + Edit Are you sure you want to delete this login? Delete + + Login options + + The editable text field for the web address of the login. + + The editable text field for the username of the login. + + The editable text field for the password of the login. + + Save changes to login. + + Discard changes + + Edit + + Password required diff --git a/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsControllerTest.kt b/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsControllerTest.kt index 96037d888..62f27e507 100644 --- a/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsControllerTest.kt @@ -14,10 +14,10 @@ import org.mozilla.fenix.utils.Settings @RunWith(FenixRobolectricTestRunner::class) class SavedLoginsControllerTest { - private val store: SavedLoginsFragmentStore = 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 controller = DefaultSavedLoginsController(store, settings) + private val controller = SavedLoginsController(store, settings) @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`() { @@ -25,7 +25,7 @@ class SavedLoginsControllerTest { verify { store.dispatch( - SavedLoginsFragmentAction.SortLogins( + LoginsAction.SortLogins( SortingStrategy.Alphabetically( testContext ) diff --git a/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractorTest.kt index 7ae725486..b9db6f70b 100644 --- a/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsInteractorTest.kt @@ -15,7 +15,7 @@ import kotlin.random.Random @RunWith(FenixRobolectricTestRunner::class) class SavedLoginsInteractorTest { private val controller: SavedLoginsController = mockk(relaxed = true) - private val savedLoginClicked: (SavedLoginsItem) -> Unit = mockk(relaxed = true) + private val savedLoginClicked: (SavedLogin) -> Unit = mockk(relaxed = true) private val learnMore: () -> Unit = mockk(relaxed = true) private val interactor = SavedLoginsInteractor( controller, @@ -25,7 +25,7 @@ class SavedLoginsInteractorTest { @Test fun itemClicked() { - val item = SavedLoginsItem("mozilla.org", "username", "password", "id", Random.nextLong()) + val item = SavedLogin("mozilla.org", "username", "password", "id", Random.nextLong()) interactor.itemClicked(item) verify {