From 7d481a7836d3dcdb1cf354e6c7565be5cd575df7 Mon Sep 17 00:00:00 2001 From: Elise Richards Date: Thu, 9 Sep 2021 09:30:33 -0700 Subject: [PATCH] For #19947: manually add login (#21199) * [WIP] New Layout for adding login and 'add login' button in 'SavedLoginsListView' to launch it. Fixed bindings. * [WIP] Removed "reveal password" button * [WIP] Added interactor for the add login screen * [WIP] Trying to check for duplicates * [WIP] Renaming "addNew..." with "add..." * [WIP] Check for duplicates * [WIP] Fixes after merge * Cleaning up the layout and making edit text for hostname selectable * Error handling on add login screen. Tests for interactors and controllers Co-authored-by: Vitaly V. Pinchuk Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .../logins/controller/LoginsListController.kt | 6 + .../SavedLoginsStorageController.kt | 78 ++++ .../logins/fragment/AddLoginFragment.kt | 354 ++++++++++++++++++ .../logins/fragment/EditLoginFragment.kt | 2 +- .../logins/fragment/SavedLoginsFragment.kt | 2 +- .../logins/interactor/AddLoginInteractor.kt | 24 ++ .../interactor/SavedLoginsInteractor.kt | 4 + .../logins/view/SavedLoginsListView.kt | 2 + .../res/layout/component_saved_logins.xml | 74 ++-- .../main/res/layout/fragment_add_login.xml | 232 ++++++++++++ .../main/res/layout/fragment_edit_login.xml | 4 +- .../main/res/layout/fragment_login_detail.xml | 5 +- app/src/main/res/layout/layout_add_login.xml | 39 ++ app/src/main/res/navigation/nav_graph.xml | 17 + app/src/main/res/values/strings.xml | 18 +- .../settings/logins/AddLoginInteractorTest.kt | 51 +++ .../logins/LoginsListControllerTest.kt | 17 +- .../logins/SavedLoginsInteractorTest.kt | 6 + 18 files changed, 896 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/interactor/AddLoginInteractor.kt create mode 100644 app/src/main/res/layout/fragment_add_login.xml create mode 100644 app/src/main/res/layout/layout_add_login.xml create mode 100644 app/src/test/java/org/mozilla/fenix/settings/logins/AddLoginInteractorTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt index 8d4c5629fd..5645321044 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt @@ -45,6 +45,12 @@ class LoginsListController( ) } + fun handleAddLoginClicked() { + navController.navigate( + SavedLoginsFragmentDirections.actionSavedLoginsFragmentToAddLoginFragment() + ) + } + fun handleLearnMoreClicked() { browserNavigator.invoke( SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP), diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt index e8e52e216a..69efa7eede 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt @@ -23,11 +23,13 @@ import org.mozilla.fenix.R import org.mozilla.fenix.settings.logins.LoginsAction import org.mozilla.fenix.settings.logins.LoginsFragmentStore import org.mozilla.fenix.settings.logins.fragment.EditLoginFragmentDirections +import org.mozilla.fenix.settings.logins.fragment.AddLoginFragmentDirections import org.mozilla.fenix.settings.logins.mapToSavedLogin /** * Controller for all saved logins interactions with the password storage component */ +@Suppress("TooManyFunctions", "LargeClass") open class SavedLoginsStorageController( private val passwordsStorage: SyncableLoginsStorage, private val lifecycleScope: CoroutineScope, @@ -56,6 +58,50 @@ open class SavedLoginsStorageController( } } + fun add(hostnameText: String, usernameText: String, passwordText: String) { + var saveLoginJob: Deferred? = null + lifecycleScope.launch(ioDispatcher) { + saveLoginJob = async { + val loginToSave = Login( + guid = null, + origin = hostnameText, + username = usernameText, + password = passwordText, + httpRealm = hostnameText + ) + val newLoginId = add(loginToSave) + if (newLoginId.isNotEmpty()) { + val newLogin = passwordsStorage.get(newLoginId) + syncAndUpdateList(newLogin!!) + } + } + saveLoginJob?.await() + withContext(Dispatchers.Main) { + val directions = + AddLoginFragmentDirections.actionAddLoginFragmentToSavedLoginsFragment() + navController.navigate(directions) + } + } + saveLoginJob?.invokeOnCompletion { + if (it is CancellationException) { + saveLoginJob?.cancel() + } + } + } + + private suspend fun add(loginToSave: Login): String { + var newLoginId = "" + try { + newLoginId = passwordsStorage.add(loginToSave) + } catch (loginException: LoginsStorageException) { + Log.e( + "Add new login", + "Failed to add new login.", loginException + ) + } + return newLoginId + } + fun save(loginId: String, usernameText: String, passwordText: String) { var saveLoginJob: Deferred? = null lifecycleScope.launch(ioDispatcher) { @@ -148,6 +194,38 @@ open class SavedLoginsStorageController( } } + fun findPotentialDuplicates(hostnameText: String, usernameText: String, passwordText: String) { + var deferredLogin: Deferred>? = null + val fetchLoginJob = lifecycleScope.launch(ioDispatcher) { + deferredLogin = async { + val login = Login( + guid = null, + origin = hostnameText, + username = usernameText, + password = passwordText, + httpRealm = hostnameText + ) + passwordsStorage.getPotentialDupesIgnoringUsername(login) + } + val fetchedDuplicatesList = deferredLogin?.await() + fetchedDuplicatesList?.let { list -> + withContext(Dispatchers.Main) { + val savedLoginList = list.map { it.mapToSavedLogin() } + loginsFragmentStore.dispatch( + LoginsAction.ListOfDupes( + savedLoginList + ) + ) + } + } + } + fetchLoginJob.invokeOnCompletion { + if (it is CancellationException) { + deferredLogin?.cancel() + } + } + } + fun fetchLoginDetails(loginId: String) { var deferredLogin: Deferred>? = null val fetchLoginJob = lifecycleScope.launch(ioDispatcher) { diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt new file mode 100644 index 0000000000..510f7c8ea2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/AddLoginFragment.kt @@ -0,0 +1,354 @@ +/* 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.fragment + +import android.content.Context +import android.content.res.ColorStateList +import android.os.Bundle +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.webkit.URLUtil +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.ExperimentalCoroutinesApi +import mozilla.components.lib.state.ext.consumeFrom +import mozilla.components.support.ktx.android.view.hideKeyboard +import org.mozilla.fenix.R +import org.mozilla.fenix.components.StoreProvider +import org.mozilla.fenix.databinding.FragmentAddLoginBinding +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.redirectToReAuth +import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.ext.toEditable +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController +import org.mozilla.fenix.settings.logins.interactor.AddLoginInteractor +import org.mozilla.fenix.settings.logins.LoginsFragmentStore +import org.mozilla.fenix.settings.logins.SavedLogin +import org.mozilla.fenix.settings.logins.createInitialLoginsListState + +/** + * Displays the editable new login information for a single website + */ +@ExperimentalCoroutinesApi +@Suppress("TooManyFunctions", "NestedBlockDepth", "ForbiddenComment") +class AddLoginFragment : Fragment(R.layout.fragment_add_login) { + + private lateinit var loginsFragmentStore: LoginsFragmentStore + private lateinit var interactor: AddLoginInteractor + + private var listOfPossibleDupes: List? = null + + private var validPassword = true + private var validUsername = true + private var validHostname = false + + private var _binding: FragmentAddLoginBinding? = null + private val binding get() = _binding!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setHasOptionsMenu(true) + + _binding = FragmentAddLoginBinding.bind(view) + + loginsFragmentStore = StoreProvider.get(this) { + LoginsFragmentStore( + createInitialLoginsListState(requireContext().settings()) + ) + } + + interactor = AddLoginInteractor( + SavedLoginsStorageController( + passwordsStorage = requireContext().components.core.passwordsStorage, + lifecycleScope = lifecycleScope, + navController = findNavController(), + loginsFragmentStore = loginsFragmentStore + ) + ) + + initEditableValues() + + setUpClickListeners() + setUpTextListeners() + + consumeFrom(loginsFragmentStore) { + listOfPossibleDupes = loginsFragmentStore.state.duplicateLogins + } + } + + private fun initEditableValues() { + binding.hostnameText.text = "".toEditable() + binding.usernameText.text = "".toEditable() + binding.passwordText.text = "".toEditable() + + binding.hostnameText.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + binding.usernameText.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + + // TODO: extend PasswordTransformationMethod() to change bullets to asterisks + binding.passwordText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + + binding.passwordText.compoundDrawablePadding = + requireContext().resources + .getDimensionPixelOffset(R.dimen.saved_logins_end_icon_drawable_padding) + } + + private fun setUpClickListeners() { + binding.hostnameText.requestFocus() + val imm = + requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0) + + binding.clearHostnameTextButton.setOnClickListener { + binding.hostnameText.text?.clear() + binding.hostnameText.isCursorVisible = true + binding.hostnameText.hasFocus() + binding.inputLayoutHostname.hasFocus() + it.isEnabled = false + } + + binding.clearUsernameTextButton.setOnClickListener { + binding.usernameText.text?.clear() + binding.usernameText.isCursorVisible = true + binding.usernameText.hasFocus() + binding.inputLayoutUsername.hasFocus() + it.isEnabled = false + } + + binding.clearPasswordTextButton.setOnClickListener { + binding.passwordText.text?.clear() + binding.passwordText.isCursorVisible = true + binding.passwordText.hasFocus() + binding.inputLayoutPassword.hasFocus() + it.isEnabled = false + } + } + + private fun setUpTextListeners() { + val frag = view?.findViewById(R.id.addLoginFragment) + + frag?.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + view?.hideKeyboard() + } + } + + binding.addLoginLayout.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + if (!hasFocus) { + view?.hideKeyboard() + } + } + + binding.hostnameText.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(h: Editable?) { + val hostnameText = h.toString() + + when { + hostnameText.isEmpty() -> { + setHostnameError() + binding.clearHostnameTextButton.isEnabled = false + } + !URLUtil.isHttpUrl(hostnameText) && !URLUtil.isHttpsUrl(hostnameText) -> { + setHostnameError() + binding.clearHostnameTextButton.isEnabled = true + } + else -> { + validHostname = true + + binding.clearHostnameTextButton.isEnabled = true + binding.inputLayoutHostname.error = null + binding.inputLayoutHostname.errorIconDrawable = null + + interactor.findPotentialDuplicates( + hostnameText = h.toString(), + binding.usernameText.text.toString(), + binding.passwordText.text.toString() + ) + } + } + setSaveButtonState() + } + + override fun beforeTextChanged(u: CharSequence?, start: Int, count: Int, after: Int) { + // NOOP + } + + override fun onTextChanged(u: CharSequence?, start: Int, before: Int, count: Int) { + // NOOP + } + }) + + binding.usernameText.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(u: Editable?) { + when { + u.toString().isEmpty() -> { + binding.clearUsernameTextButton.isVisible = false + setUsernameError() + } + else -> { + setDupeError() + binding.inputLayoutUsername.error = null + binding.inputLayoutUsername.errorIconDrawable = null + } + } + binding.clearUsernameTextButton.isEnabled = u.toString().isNotEmpty() + setSaveButtonState() + } + + override fun beforeTextChanged(u: CharSequence?, start: Int, count: Int, after: Int) { + // NOOP + } + + override fun onTextChanged(u: CharSequence?, start: Int, before: Int, count: Int) { + // NOOP + } + }) + + binding.passwordText.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(p: Editable?) { + when { + p.toString().isEmpty() -> { + binding.clearPasswordTextButton.isVisible = false + setPasswordError() + } + else -> { + validPassword = true + binding.inputLayoutPassword.error = null + binding.inputLayoutPassword.errorIconDrawable = null + binding.clearPasswordTextButton.isVisible = true + } + } + setSaveButtonState() + } + + override fun beforeTextChanged(p: CharSequence?, start: Int, count: Int, after: Int) { + // NOOP + } + + override fun onTextChanged(p: CharSequence?, start: Int, before: Int, count: Int) { + // NOOP + } + }) + } + + private fun isDupe(username: String): Boolean = + loginsFragmentStore.state.duplicateLogins.filter { it.username == username }.any() + + private fun setDupeError() { + if (isDupe(binding.usernameText.text.toString())) { + binding.inputLayoutUsername.let { + validUsername = false + it.error = context?.getString(R.string.saved_login_duplicate) + it.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) + it.setErrorIconTintList( + ColorStateList.valueOf( + ContextCompat.getColor(requireContext(), R.color.design_error) + ) + ) + binding.clearUsernameTextButton.isVisible = false + } + } else { + validUsername = true + binding.inputLayoutUsername.error = null + binding.inputLayoutUsername.errorIconDrawable = null + binding.clearUsernameTextButton.isVisible = true + } + } + + private fun setPasswordError() { + binding.inputLayoutPassword.let { layout -> + validPassword = false + layout.error = context?.getString(R.string.saved_login_password_required) + layout.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) + layout.setErrorIconTintList( + ColorStateList.valueOf( + ContextCompat.getColor(requireContext(), R.color.design_error) + ) + ) + } + } + + private fun setUsernameError() { + binding.inputLayoutUsername.let { layout -> + validUsername = false + layout.error = context?.getString(R.string.saved_login_username_required) + layout.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) + layout.setErrorIconTintList( + ColorStateList.valueOf( + ContextCompat.getColor(requireContext(), R.color.design_error) + ) + ) + } + } + + private fun setHostnameError() { + binding.inputLayoutHostname.let { layout -> + validHostname = false + layout.error = context?.getString(R.string.add_login_hostname_invalid_text_2) + layout.setErrorIconDrawable(R.drawable.mozac_ic_warning_with_bottom_padding) + layout.setErrorIconTintList( + ColorStateList.valueOf( + ContextCompat.getColor(requireContext(), R.color.design_error) + ) + ) + } + } + + private fun setSaveButtonState() { + activity?.invalidateOptionsMenu() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.login_save, menu) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + val saveButton = menu.findItem(R.id.save_login_button) + val changesMadeWithNoErrors = validHostname && validUsername && validPassword + saveButton.isEnabled = changesMadeWithNoErrors + } + + override fun onPause() { + redirectToReAuth( + listOf(R.id.loginDetailFragment, R.id.savedLoginsFragment), + findNavController().currentDestination?.id, + R.id.editLoginFragment + ) + super.onPause() + } + + override fun onResume() { + super.onResume() + showToolbar(getString(R.string.add_login)) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { + R.id.save_login_button -> { + view?.hideKeyboard() + interactor.onAddLogin( + binding.hostnameText.text.toString(), + binding.usernameText.text.toString(), + binding.passwordText.text.toString() + ) + true + } + else -> false + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt index 38bd7a66fb..cadb1f17f0 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt @@ -271,7 +271,7 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { override fun onPause() { redirectToReAuth( - listOf(R.id.loginDetailFragment), + listOf(R.id.loginDetailFragment, R.id.savedLoginsFragment), findNavController().currentDestination?.id, R.id.editLoginFragment ) diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt index a7a4cac1f7..ab47c93fa6 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt @@ -151,7 +151,7 @@ class SavedLoginsFragment : Fragment() { setHasOptionsMenu(false) redirectToReAuth( - listOf(R.id.loginDetailFragment), + listOf(R.id.loginDetailFragment, R.id.addLoginFragment), findNavController().currentDestination?.id, R.id.savedLoginsFragment ) diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/AddLoginInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/AddLoginInteractor.kt new file mode 100644 index 0000000000..8b1421d6f7 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/AddLoginInteractor.kt @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.interactor + +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController + +/** + * Interactor for the add login screen + * + * @property savedLoginsController controller for the saved logins storage + */ +class AddLoginInteractor( + private val savedLoginsController: SavedLoginsStorageController +) { + fun findPotentialDuplicates(hostnameText: String, usernameText: String, passwordText: String) { + savedLoginsController.findPotentialDuplicates(hostnameText, usernameText, passwordText) + } + + fun onAddLogin(hostnameText: String, usernameText: String, passwordText: String) { + savedLoginsController.add(hostnameText, usernameText, passwordText) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt index 5bf69a99b8..662c3a7ffb 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt @@ -36,4 +36,8 @@ class SavedLoginsInteractor( fun loadAndMapLogins() { savedLoginsStorageController.handleLoadAndMapLogins() } + + fun onAddLoginClick() { + loginsListController.handleAddLoginClicked() + } } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt index 362261aa95..69f73265f5 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt @@ -50,6 +50,8 @@ class SavedLoginsListView( appName ) } + + binding.addLoginButton.addLoginLayout.setOnClickListener { interactor.onAddLoginClick() } } fun update(state: LoginsListState) { diff --git a/app/src/main/res/layout/component_saved_logins.xml b/app/src/main/res/layout/component_saved_logins.xml index 9fdc87e133..861866ff9b 100644 --- a/app/src/main/res/layout/component_saved_logins.xml +++ b/app/src/main/res/layout/component_saved_logins.xml @@ -19,39 +19,55 @@ app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent" /> - + android:orientation="vertical"> - - - - - + android:visibility="gone" + android:layout_margin="@dimen/exceptions_description_margin"> + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_add_login.xml b/app/src/main/res/layout/fragment_add_login.xml new file mode 100644 index 0000000000..cf7dfb9ee6 --- /dev/null +++ b/app/src/main/res/layout/fragment_add_login.xml @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_edit_login.xml b/app/src/main/res/layout/fragment_edit_login.xml index e9689689d4..d88ed95fd9 100644 --- a/app/src/main/res/layout/fragment_edit_login.xml +++ b/app/src/main/res/layout/fragment_edit_login.xml @@ -10,9 +10,9 @@ android:id="@+id/editLoginLayout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="72dp" + android:layout_marginStart="20dp" android:layout_marginEnd="20dp" - android:layout_marginTop="12dp" + android:layout_marginTop="16dp" android:clickable="true" android:focusable="true" > diff --git a/app/src/main/res/layout/fragment_login_detail.xml b/app/src/main/res/layout/fragment_login_detail.xml index cfb6bb0daa..107d0e5abd 100644 --- a/app/src/main/res/layout/fragment_login_detail.xml +++ b/app/src/main/res/layout/fragment_login_detail.xml @@ -8,8 +8,9 @@ android:id="@+id/loginDetailLayout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="73dp" - android:layout_marginTop="12dp"> + android:layout_marginStart="20dp" + android:layout_marginEnd="20dp" + android:layout_marginTop="16dp"> + + + + + + + + diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index d29b594d0c..a123a0fc32 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -344,6 +344,11 @@ + + + + + Autofill in other apps Fill usernames and passwords in other apps on your device. + + Add login Sync logins @@ -1555,6 +1557,8 @@ Copy username Clear username + + Clear hostname Copy site @@ -1762,14 +1766,26 @@ Discard changes Edit - + + Add new login + Password required + + Username required + + Hostname required Voice search Speak now A login with that username already exists + + https://www.example.com + + Web address must contain \“https://\“ or \“http://\“ + + Valid hostname required diff --git a/app/src/test/java/org/mozilla/fenix/settings/logins/AddLoginInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/settings/logins/AddLoginInteractorTest.kt new file mode 100644 index 0000000000..7fbf38656d --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/logins/AddLoginInteractorTest.kt @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins + +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController +import org.mozilla.fenix.settings.logins.interactor.AddLoginInteractor + +class AddLoginInteractorTest { + + private val loginsController: SavedLoginsStorageController = mockk(relaxed = true) + private val interactor = AddLoginInteractor(loginsController) + + private val hostname = "https://www.cats.com" + private val username = "myFunUsername111" + private val password = "superDuperSecure123!" + + @Test + fun findPotentialDupesTest() { + interactor.findPotentialDuplicates( + hostname, + username, + password + ) + + verify { + loginsController.findPotentialDuplicates( + hostname, + username, + password + ) + } + } + + @Test + fun addNewLoginTest() { + interactor.onAddLogin(hostname, username, password) + + verify { + loginsController.add( + hostname, + username, + password + ) + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListControllerTest.kt b/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListControllerTest.kt index 8d6cac06e4..d5ac6ab9da 100644 --- a/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListControllerTest.kt @@ -39,7 +39,7 @@ class LoginsListControllerTest { ) @Test - fun `GIVEN a sorting strategy, WHEN handleSort is called on the controller, THEN the correct action should be dispatched and the strategy saved in sharedPref`() { + fun `handle selecting the sorting strategy and save pref`() { controller.handleSort(sortingStrategy) verifyAll { @@ -53,7 +53,7 @@ class LoginsListControllerTest { } @Test - fun `GIVEN a SavedLogin, WHEN handleItemClicked is called for it, THEN LoginsAction$LoginSelected should be emitted`() { + fun `handle login item clicked`() { val login: SavedLogin = mockk(relaxed = true) controller.handleItemClicked(login) @@ -68,7 +68,7 @@ class LoginsListControllerTest { } @Test - fun `GIVEN the learn more option, WHEN handleLearnMoreClicked is called for it, then we should open the right support webpage`() { + fun `Open the correct support webpage when Learn More is clicked`() { controller.handleLearnMoreClicked() verifyAll { @@ -79,4 +79,15 @@ class LoginsListControllerTest { ) } } + + @Test + fun `handle add login clicked`() { + controller.handleAddLoginClicked() + + verifyAll { + navController.navigate( + SavedLoginsFragmentDirections.actionSavedLoginsFragmentToAddLoginFragment() + ) + } + } } 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 9838abb29e..9322bdbdb0 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 @@ -63,4 +63,10 @@ class SavedLoginsInteractorTest { interactor.loadAndMapLogins() verifyAll { savedLoginsStorageController.handleLoadAndMapLogins() } } + + @Test + fun `Handle add login button click`() { + interactor.onAddLoginClick() + verifyAll { listController.handleAddLoginClicked() } + } }