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 {