From 023a4983fa769a6fb9eea6600ec7ce8e620618c4 Mon Sep 17 00:00:00 2001 From: Elise Richards Date: Thu, 16 Jul 2020 17:08:04 -0500 Subject: [PATCH] For #10173: login duplicates and save (#11208) * Extract controller into it's own class. Implement find dupes and filter based on username. Create edit login controller. Add text watchers and check for duplicates. Edit controller test * Find duplicates and save to store * Retrieve duplicates from AC and check list on username text changed Move duplicates logic into the controller * Add glean pings for delete and edit. Move logic for login manipulation into the datastore. * Use correct threads in controller. Enable save button when applicable. Save enabled in datastore. Move login data to datastore Rebase with password error states Update metrics to be more specific for edit * Create logins controller for AC calls * Interactor and controller methods for edit login. Add edit view to separate out some layout manipulation. Inflate view in edit fragment. Double layout showing up. Edit view Controller tests Controller tests passing Interactor tests Lint and detekt cleanup * Remove datastore and use storage controller for all logins calls to password storage. Addressed comments Lint : Rebase - 1 --- app/metrics.yaml | 33 +++ .../java/org/mozilla/fenix/HomeActivity.kt | 5 +- .../components/metrics/GleanMetricsService.kt | 9 + .../fenix/components/metrics/Metrics.kt | 3 + .../fenix/library/history/HistoryFragment.kt | 7 +- .../fenix/settings/logins/LoginDetailView.kt | 20 -- .../settings/logins/LoginsFragmentStore.kt | 18 +- .../fenix/settings/logins/SavedLoginsView.kt | 135 --------- .../logins/controller/LoginsListController.kt | 64 ++++ .../SavedLoginsStorageController.kt | 198 +++++++++++++ .../{ => fragment}/EditLoginFragment.kt | 275 +++++++++--------- .../{ => fragment}/LoginDetailFragment.kt | 129 +++----- .../{ => fragment}/SavedLoginsAuthFragment.kt | 5 +- .../{ => fragment}/SavedLoginsFragment.kt | 141 +++++---- .../SavedLoginsSettingFragment.kt | 2 +- .../logins/interactor/EditLoginInteractor.kt | 24 ++ .../interactor/LoginDetailInteractor.kt | 24 ++ .../interactor/SavedLoginsInteractor.kt | 39 +++ .../settings/logins/view/EditLoginView.kt | 54 ++++ .../settings/logins/view/LoginDetailView.kt | 65 +++++ .../logins/{ => view}/LoginsAdapter.kt | 4 +- .../logins/{ => view}/LoginsListViewHolder.kt | 4 +- .../logins/view/SavedLoginsListView.kt | 67 +++++ .../java/org/mozilla/fenix/utils/Settings.kt | 2 +- .../main/res/color/save_enabled_ic_color.xml | 8 + .../saved_login_clear_edit_text_tint.xml | 2 +- .../main/res/layout/fragment_edit_login.xml | 3 +- .../main/res/layout/fragment_saved_logins.xml | 2 +- app/src/main/res/menu/login_save.xml | 2 +- app/src/main/res/navigation/nav_graph.xml | 10 +- .../logins/EditLoginInteractorTest.kt | 34 +++ .../logins/LoginDetailInteractorTest.kt | 30 ++ .../settings/logins/LoginDetailViewTest.kt | 4 +- .../logins/LoginsFragmentStoreTest.kt | 3 +- ...lerTest.kt => LoginsListControllerTest.kt} | 25 +- .../logins/LoginsListViewHolderTest.kt | 12 +- .../logins/SavedLoginsInteractorTest.kt | 26 +- .../SavedLoginsStorageControllerTest.kt | 139 +++++++++ docs/metrics.md | 3 + 39 files changed, 1140 insertions(+), 490 deletions(-) delete mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailView.kt delete mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt rename app/src/main/java/org/mozilla/fenix/settings/logins/{ => fragment}/EditLoginFragment.kt (53%) rename app/src/main/java/org/mozilla/fenix/settings/logins/{ => fragment}/LoginDetailFragment.kt (66%) rename app/src/main/java/org/mozilla/fenix/settings/logins/{ => fragment}/SavedLoginsAuthFragment.kt (98%) rename app/src/main/java/org/mozilla/fenix/settings/logins/{ => fragment}/SavedLoginsFragment.kt (66%) rename app/src/main/java/org/mozilla/fenix/settings/logins/{ => fragment}/SavedLoginsSettingFragment.kt (98%) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/interactor/EditLoginInteractor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/view/EditLoginView.kt create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt rename app/src/main/java/org/mozilla/fenix/settings/logins/{ => view}/LoginsAdapter.kt (88%) rename app/src/main/java/org/mozilla/fenix/settings/logins/{ => view}/LoginsListViewHolder.kt (88%) create mode 100644 app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt create mode 100644 app/src/main/res/color/save_enabled_ic_color.xml create mode 100644 app/src/test/java/org/mozilla/fenix/settings/logins/EditLoginInteractorTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/settings/logins/LoginDetailInteractorTest.kt rename app/src/test/java/org/mozilla/fenix/settings/logins/{SavedLoginsControllerTest.kt => LoginsListControllerTest.kt} (81%) create mode 100644 app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt diff --git a/app/metrics.yaml b/app/metrics.yaml index 8f7e414e7..2c9163ba4 100644 --- a/app/metrics.yaml +++ b/app/metrics.yaml @@ -2444,6 +2444,39 @@ logins: notification_emails: - fenix-core@mozilla.com expires: "2020-10-01" + open_login_editor: + type: event + description: | + A user entered the edit screen for an individual saved login + bugs: + - https://github.com/mozilla-mobile/fenix/issues/10173 + data_reviews: + - https://github.com/mozilla-mobile/fenix/issues/11208 + notification_emails: + - fenix-core@mozilla.com + expires: "2020-10-01" + delete_saved_login: + type: event + description: | + A user confirms delete of a saved login + bugs: + - https://github.com/mozilla-mobile/fenix/issues/10173 + data_reviews: + - https://github.com/mozilla-mobile/fenix/issues/11208 + notification_emails: + - fenix-core@mozilla.com + expires: "2020-10-01" + save_edited_login: + type: event + description: | + A user saves changes made to an individual login + bugs: + - https://github.com/mozilla-mobile/fenix/issues/10173 + data_reviews: + - https://github.com/mozilla-mobile/fenix/issues/11208 + notification_emails: + - fenix-core@mozilla.com + expires: "2020-10-01" download_notification: resume: diff --git a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt index ca06e6964..8acd3d709 100644 --- a/app/src/main/java/org/mozilla/fenix/HomeActivity.kt +++ b/app/src/main/java/org/mozilla/fenix/HomeActivity.kt @@ -26,6 +26,7 @@ import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.AppBarConfiguration import androidx.navigation.ui.NavigationUI import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.gms.tasks.Tasks.call import kotlinx.android.synthetic.main.activity_home.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -86,8 +87,8 @@ import org.mozilla.fenix.session.NotificationSessionObserver import org.mozilla.fenix.settings.SettingsFragmentDirections import org.mozilla.fenix.settings.TrackingProtectionFragmentDirections import org.mozilla.fenix.settings.about.AboutFragmentDirections -import org.mozilla.fenix.settings.logins.LoginDetailFragmentDirections -import org.mozilla.fenix.settings.logins.SavedLoginsAuthFragmentDirections +import org.mozilla.fenix.settings.logins.fragment.SavedLoginsAuthFragmentDirections +import org.mozilla.fenix.settings.logins.fragment.LoginDetailFragmentDirections import org.mozilla.fenix.settings.search.AddSearchEngineFragmentDirections import org.mozilla.fenix.settings.search.EditCustomSearchEngineFragmentDirections import org.mozilla.fenix.share.AddNewDeviceFragmentDirections diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt index bd1c43e7c..677e0aba7 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/GleanMetricsService.kt @@ -466,6 +466,15 @@ private val Event.wrapper: EventWrapper<*>? is Event.ViewLoginPassword -> EventWrapper( { Logins.viewPasswordLogin.record(it) } ) + is Event.DeleteLogin -> EventWrapper( + { Logins.deleteSavedLogin.record(it) } + ) + is Event.EditLogin -> EventWrapper( + { Logins.openLoginEditor.record(it) } + ) + is Event.EditLoginSave -> EventWrapper( + { Logins.saveEditedLogin.record(it) } + ) is Event.PrivateBrowsingShowSearchSuggestions -> EventWrapper( { SearchSuggestions.enableInPrivate.record(it) } ) diff --git a/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt b/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt index 9e8fe2d88..77a08ce20 100644 --- a/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt +++ b/app/src/main/java/org/mozilla/fenix/components/metrics/Metrics.kt @@ -154,6 +154,9 @@ sealed class Event { object OpenLogins : Event() object OpenOneLogin : Event() object CopyLogin : Event() + object DeleteLogin : Event() + object EditLogin : Event() + object EditLoginSave : Event() object ViewLoginPassword : Event() object CustomEngineAdded : Event() object CustomEngineDeleted : Event() diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt index a6796f1d6..868ac3668 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt @@ -210,9 +210,10 @@ class HistoryFragment : LibraryPageFragment(), UserInteractionHandl return if (historyItems.size > 1) { getString(R.string.history_delete_multiple_items_snackbar) } else { - getString( - R.string.history_delete_single_item_snackbar, - historyItems.first().url.toShortUrl(requireComponents.publicSuffixList) + String.format( + requireContext().getString( + R.string.history_delete_single_item_snackbar + ), historyItems.first().url.toShortUrl(requireComponents.publicSuffixList) ) } } 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 deleted file mode 100644 index 0eb6300d8..000000000 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailView.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.settings.logins - -import android.view.ViewGroup -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.fragment_login_detail.* - -/** - * View that contains and configures the Login Details - */ -class LoginDetailView(override val containerView: ViewGroup?) : LayoutContainer { - fun update(login: LoginsListState) { - webAddressText.text = login.currentItem?.origin - usernameText.text = login.currentItem?.username - passwordText.text = login.currentItem?.password - } -} 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 index 9e10508fd..ea130f07e 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsFragmentStore.kt @@ -54,18 +54,20 @@ sealed class LoginsAction : Action { data class UpdateLoginsList(val list: List) : LoginsAction() data class UpdateCurrentLogin(val item: SavedLogin) : LoginsAction() data class SortLogins(val sortingStrategy: SortingStrategy) : LoginsAction() + data class ListOfDupes(val dupeList: List) : LoginsAction() data class LoginSelected(val item: SavedLogin) : 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 + * @property duplicateLogins The current list of possible duplicates for a selected login origin, + * httpRealm, and formActionOrigin */ data class LoginsListState( val isLoading: Boolean = false, @@ -74,7 +76,8 @@ data class LoginsListState( val currentItem: SavedLogin? = null, val searchedForText: String?, val sortingStrategy: SortingStrategy, - val highlightedItem: SavedLoginsSortingStrategyMenu.Item + val highlightedItem: SavedLoginsSortingStrategyMenu.Item, + val duplicateLogins: List ) : State /** @@ -113,9 +116,14 @@ private fun savedLoginsStateReducer( } is LoginsAction.LoginSelected -> { state.copy( - isLoading = true, - loginList = emptyList(), - filteredItems = emptyList() + isLoading = true, + loginList = emptyList(), + filteredItems = emptyList() + ) + } + is LoginsAction.ListOfDupes -> { + state.copy( + duplicateLogins = action.dupeList ) } } 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 deleted file mode 100644 index dd1564df3..000000000 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsView.kt +++ /dev/null @@ -1,135 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.mozilla.fenix.settings.logins - -import android.text.method.LinkMovementMethod -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.core.view.isVisible -import androidx.navigation.NavController -import androidx.recyclerview.widget.LinearLayoutManager -import kotlinx.android.extensions.LayoutContainer -import kotlinx.android.synthetic.main.component_saved_logins.view.* -import org.mozilla.fenix.BrowserDirection -import org.mozilla.fenix.R -import org.mozilla.fenix.components.metrics.Event -import org.mozilla.fenix.components.metrics.MetricController -import org.mozilla.fenix.ext.addUnderline -import org.mozilla.fenix.settings.SupportUtils -import org.mozilla.fenix.utils.Settings - -/** - * View that contains and configures the Saved Logins List - */ -class SavedLoginsView( - override val containerView: ViewGroup, - val interactor: SavedLoginsInteractor -) : LayoutContainer { - - val view: FrameLayout = LayoutInflater.from(containerView.context) - .inflate(R.layout.component_saved_logins, containerView, true) - .findViewById(R.id.saved_logins_wrapper) - - private val loginsAdapter = LoginsAdapter(interactor) - - init { - view.saved_logins_list.apply { - adapter = loginsAdapter - layoutManager = LinearLayoutManager(containerView.context) - itemAnimator = null - } - - with(view.saved_passwords_empty_learn_more) { - movementMethod = LinkMovementMethod.getInstance() - addUnderline() - setOnClickListener { interactor.onLearnMoreClicked() } - } - - with(view.saved_passwords_empty_message) { - val appName = context.getString(R.string.app_name) - text = String.format( - context.getString( - R.string.preferences_passwords_saved_logins_description_empty_text - ), appName - ) - } - } - - fun update(state: LoginsListState) { - // todo MVI views should not have logic. Needs refactoring. - if (state.isLoading) { - view.progress_bar.isVisible = true - } else { - view.progress_bar.isVisible = false - view.saved_logins_list.isVisible = state.loginList.isNotEmpty() - view.saved_passwords_empty_view.isVisible = state.loginList.isEmpty() - } - loginsAdapter.submitList(state.filteredItems) - } -} - -/** - * Interactor for the saved logins screen - * - * @param savedLoginsController [SavedLoginsController] which will be delegated for all users interactions. - */ -class SavedLoginsInteractor( - private val savedLoginsController: SavedLoginsController -) { - fun onItemClicked(item: SavedLogin) { - savedLoginsController.handleItemClicked(item) - } - - fun onLearnMoreClicked() { - savedLoginsController.handleLearnMoreClicked() - } - - fun onSortingStrategyChanged(sortingStrategy: SortingStrategy) { - savedLoginsController.handleSort(sortingStrategy) - } -} - -/** - * Controller for the saved logins screen - * - * @param store Store used to hold in-memory collection state. - * @param navController NavController manages app navigation within a NavHost. - * @param browserNavigator Controller allowing browser navigation to any Uri. - * @param settings SharedPreferences wrapper for easier usage. - * @param metrics Controller that handles telemetry events. - */ -class SavedLoginsController( - private val store: LoginsFragmentStore, - private val navController: NavController, - private val browserNavigator: ( - searchTermOrURL: String, - newTab: Boolean, - from: BrowserDirection - ) -> Unit, - private val settings: Settings, - private val metrics: MetricController -) { - fun handleSort(sortingStrategy: SortingStrategy) { - store.dispatch(LoginsAction.SortLogins(sortingStrategy)) - settings.savedLoginsSortingStrategy = sortingStrategy - } - - fun handleItemClicked(item: SavedLogin) { - store.dispatch(LoginsAction.LoginSelected(item)) - metrics.track(Event.OpenOneLogin) - navController.navigate( - SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(item.guid) - ) - } - - fun handleLearnMoreClicked() { - browserNavigator.invoke( - SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP), - true, - BrowserDirection.FromSavedLoginsFragment - ) - } -} 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 new file mode 100644 index 000000000..8d4c5629f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/LoginsListController.kt @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.controller + +import androidx.navigation.NavController +import org.mozilla.fenix.BrowserDirection +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.settings.SupportUtils +import org.mozilla.fenix.settings.logins.LoginsAction +import org.mozilla.fenix.settings.logins.LoginsFragmentStore +import org.mozilla.fenix.settings.logins.SavedLogin +import org.mozilla.fenix.settings.logins.SortingStrategy +import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragmentDirections +import org.mozilla.fenix.utils.Settings + +/** + * Controller for the saved logins list + * + * @param loginsFragmentStore Store used to hold in-memory collection state. + * @param navController NavController manages app navigation within a NavHost. + * @param browserNavigator Controller allowing browser navigation to any Uri. + * @param settings SharedPreferences wrapper for easier usage. + * @param metrics Controller that handles telemetry events. + */ +class LoginsListController( + private val loginsFragmentStore: LoginsFragmentStore, + private val navController: NavController, + private val browserNavigator: ( + searchTermOrURL: String, + newTab: Boolean, + from: BrowserDirection + ) -> Unit, + private val settings: Settings, + private val metrics: MetricController +) { + + fun handleItemClicked(item: SavedLogin) { + loginsFragmentStore.dispatch(LoginsAction.LoginSelected(item)) + metrics.track(Event.OpenOneLogin) + navController.navigate( + SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(item.guid) + ) + } + + fun handleLearnMoreClicked() { + browserNavigator.invoke( + SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP), + true, + BrowserDirection.FromSavedLoginsFragment + ) + } + + fun handleSort(sortingStrategy: SortingStrategy) { + loginsFragmentStore.dispatch( + LoginsAction.SortLogins( + sortingStrategy + ) + ) + settings.savedLoginsSortingStrategy = sortingStrategy + } +} 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 new file mode 100644 index 000000000..7df5dd741 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/controller/SavedLoginsStorageController.kt @@ -0,0 +1,198 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.controller + +import android.content.Context +import android.util.Log +import androidx.navigation.NavController +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.concept.storage.Login +import mozilla.components.service.sync.logins.InvalidRecordException +import mozilla.components.service.sync.logins.LoginsStorageException +import mozilla.components.service.sync.logins.NoSuchRecordException +import org.mozilla.fenix.R +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.settings.logins.LoginsAction +import org.mozilla.fenix.settings.logins.LoginsFragmentStore +import org.mozilla.fenix.settings.logins.fragment.EditLoginFragmentDirections +import org.mozilla.fenix.settings.logins.mapToSavedLogin + +/** + * Controller for all saved logins interactions with the password storage component + */ +open class SavedLoginsStorageController( + private val context: Context, + private val viewLifecycleScope: CoroutineScope, + private val navController: NavController, + private val loginsFragmentStore: LoginsFragmentStore +) { + + private suspend fun getLogin(loginId: String): Login? = + context.components.core.passwordsStorage.get(loginId) + + fun delete(loginId: String) { + var deleteLoginJob: Deferred? = null + val deleteJob = viewLifecycleScope.launch(Dispatchers.IO) { + deleteLoginJob = async { + context.components.core.passwordsStorage.delete(loginId) + } + deleteLoginJob?.await() + withContext(Dispatchers.Main) { + navController.popBackStack(R.id.savedLoginsFragment, false) + } + } + deleteJob.invokeOnCompletion { + if (it is CancellationException) { + deleteLoginJob?.cancel() + } + } + } + + fun save(loginId: String, usernameText: String, passwordText: String) { + var saveLoginJob: Deferred? = null + viewLifecycleScope.launch(Dispatchers.IO) { + saveLoginJob = async { + // must retrieve from storage to get the httpsRealm and formActionOrigin + val oldLogin = context.components.core.passwordsStorage.get(loginId) + + // Update requires a Login type, which needs at least one of + // httpRealm or formActionOrigin + val loginToSave = Login( + guid = loginId, + origin = oldLogin?.origin!!, + username = usernameText, // new value + password = passwordText, // new value + httpRealm = oldLogin.httpRealm, + formActionOrigin = oldLogin.formActionOrigin + ) + + save(loginToSave) + syncAndUpdateList(loginToSave) + } + saveLoginJob?.await() + withContext(Dispatchers.Main) { + val directions = + EditLoginFragmentDirections.actionEditLoginFragmentToLoginDetailFragment( + loginId + ) + navController.navigate(directions) + } + } + saveLoginJob?.invokeOnCompletion { + if (it is CancellationException) { + saveLoginJob?.cancel() + } + } + } + + private suspend fun save(loginToSave: Login) { + try { + context.components.core.passwordsStorage.update(loginToSave) + } catch (loginException: LoginsStorageException) { + when (loginException) { + is NoSuchRecordException, + is InvalidRecordException -> { + Log.e("Edit login", + "Failed to save edited login.", loginException) + } + else -> Log.e("Edit login", + "Failed to save edited login.", loginException) + } + } + } + + private fun syncAndUpdateList(updatedLogin: Login) { + val login = updatedLogin.mapToSavedLogin() + loginsFragmentStore.dispatch( + LoginsAction.UpdateLoginsList( + listOf(login) + ) + ) + } + + fun findPotentialDuplicates(loginId: String) { + var deferredLogin: Deferred>? = null + // What scope should be used here? + val fetchLoginJob = viewLifecycleScope.launch(Dispatchers.IO) { + deferredLogin = async { + val login = getLogin(loginId) + context.components.core.passwordsStorage.getPotentialDupesIgnoringUsername(login!!) + } + val fetchedDuplicatesList = deferredLogin?.await() + fetchedDuplicatesList?.let { list -> + withContext(Dispatchers.Main) { + val savedLoginList = list.map { it.mapToSavedLogin() } + loginsFragmentStore.dispatch( + LoginsAction.ListOfDupes( + savedLoginList + ) + ) + } + } + } + fetchLoginJob.invokeOnCompletion { + if (it is CancellationException) { + deferredLogin?.cancel() + } + } + } + + fun fetchLoginDetails(loginId: String) { + var deferredLogin: Deferred>? = null + val fetchLoginJob = viewLifecycleScope.launch(Dispatchers.IO) { + deferredLogin = async { + context.components.core.passwordsStorage.list() + } + val fetchedLoginList = deferredLogin?.await() + + fetchedLoginList?.let { + withContext(Dispatchers.Main) { + val login = fetchedLoginList.filter { + it.guid == loginId + }.first() + loginsFragmentStore.dispatch( + LoginsAction.UpdateCurrentLogin( + login.mapToSavedLogin() + ) + ) + } + } + } + fetchLoginJob.invokeOnCompletion { + if (it is CancellationException) { + deferredLogin?.cancel() + } + } + } + + fun handleLoadAndMapLogins() { + var deferredLogins: Deferred>? = null + val fetchLoginsJob = viewLifecycleScope.launch(Dispatchers.IO) { + deferredLogins = async { + context.components.core.passwordsStorage.list() + } + val logins = deferredLogins?.await() + logins?.let { + withContext(Dispatchers.Main) { + loginsFragmentStore.dispatch( + LoginsAction.UpdateLoginsList( + logins.map { it.mapToSavedLogin() }) + ) + } + } + } + fetchLoginsJob.invokeOnCompletion { + if (it is CancellationException) { + deferredLogins?.cancel() + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt similarity index 53% rename from app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt index 80f54d5b6..15827fdf5 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/EditLoginFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/EditLoginFragment.kt @@ -2,59 +2,72 @@ * 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 +package org.mozilla.fenix.settings.logins.fragment import android.os.Bundle import android.text.Editable import android.text.InputType import android.text.TextWatcher -import android.util.Log import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View -import androidx.core.content.ContextCompat +import androidx.appcompat.view.menu.ActionMenuItemView import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import kotlinx.android.synthetic.main.fragment_edit_login.* -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 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 kotlinx.android.synthetic.main.fragment_edit_login.view.* +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.components.metrics.Event -import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.redirectToReAuth +import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.settings.logins.LoginsAction +import org.mozilla.fenix.settings.logins.LoginsFragmentStore +import org.mozilla.fenix.settings.logins.LoginsListState +import org.mozilla.fenix.settings.logins.SavedLogin +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController +import org.mozilla.fenix.settings.logins.interactor.EditLoginInteractor +import org.mozilla.fenix.settings.logins.view.EditLoginView /** - * Displays the editable saved login information for a single website. + * Displays the editable saved login information for a single website */ +@ExperimentalCoroutinesApi @Suppress("TooManyFunctions", "NestedBlockDepth", "ForbiddenComment") 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) + private val args by navArgs() + private lateinit var loginsFragmentStore: LoginsFragmentStore + private lateinit var interactor: EditLoginInteractor + private lateinit var editLoginView: EditLoginView private lateinit var oldLogin: SavedLogin - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) + private var listOfPossibleDupes: List? = null + + private var usernameChanged = false + private var passwordChanged = false + private var saveEnabled = false + private var showPassword = true + + private var validPassword = true + private var validUsername = true + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) setHasOptionsMenu(true) oldLogin = args.savedLoginItem - savedLoginsStore = StoreProvider.get(this) { + editLoginView = EditLoginView(view.editLoginLayout) + + loginsFragmentStore = StoreProvider.get(this) { LoginsFragmentStore( LoginsListState( isLoading = true, @@ -62,31 +75,59 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { filteredItems = listOf(), searchedForText = null, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, - highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem + highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem, + duplicateLogins = listOf() ) ) } - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) + interactor = EditLoginInteractor( + SavedLoginsStorageController( + context = requireContext(), + viewLifecycleScope = viewLifecycleOwner.lifecycleScope, + navController = findNavController(), + loginsFragmentStore = loginsFragmentStore + ) + ) - // ensure hostname isn't editable + loginsFragmentStore.dispatch(LoginsAction.UpdateCurrentLogin(args.savedLoginItem)) + interactor.findPotentialDuplicates(args.savedLoginItem.guid) + + // initialize editable values hostnameText.text = args.savedLoginItem.origin.toEditable() - hostnameText.isClickable = false - hostnameText.isFocusable = false - usernameText.text = args.savedLoginItem.username.toEditable() passwordText.text = args.savedLoginItem.password.toEditable() + formatEditableValues() + initSaveState() + setUpClickListeners() + setUpTextListeners() + editLoginView.showPassword() + + consumeFrom(loginsFragmentStore) { + listOfPossibleDupes = loginsFragmentStore.state.duplicateLogins + } + } + + private fun initSaveState() { + saveEnabled = false // don't enable saving until something has been changed + val saveButton = + activity?.findViewById(R.id.save_login_button) + saveButton?.isEnabled = saveEnabled + + usernameChanged = false + passwordChanged = false + } + + private fun formatEditableValues() { + hostnameText.isClickable = false + hostnameText.isFocusable = false + usernameText.inputType = InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS // TODO: extend PasswordTransformationMethod() to change bullets to asterisks passwordText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD passwordText.compoundDrawablePadding = requireContext().resources .getDimensionPixelOffset(R.dimen.saved_logins_end_icon_drawable_padding) - - setUpClickListeners() - setUpTextListeners() } private fun setUpClickListeners() { @@ -105,14 +146,11 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { it.isEnabled = false } revealPasswordButton.setOnClickListener { - togglePasswordReveal() - } - - var firstClick = true - passwordText.setOnClickListener { - if (firstClick) { - togglePasswordReveal() - firstClick = false + showPassword = !showPassword + if (showPassword) { + editLoginView.showPassword() + } else { + editLoginView.hidePassword() } } } @@ -124,7 +162,6 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { view?.hideKeyboard() } } - editLoginLayout.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> if (!hasFocus) { view?.hideKeyboard() @@ -133,13 +170,20 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { usernameText.addTextChangedListener(object : TextWatcher { override fun afterTextChanged(u: Editable?) { - if (u.toString() == oldLogin.username) { - inputLayoutUsername.error = null - inputLayoutUsername.errorIconDrawable = null - } else { - clearUsernameTextButton.isEnabled = true - // setDupeError() TODO in #10173 + when { + u.toString() == oldLogin.username -> { + usernameChanged = false + validUsername = true + inputLayoutUsername.error = null + inputLayoutUsername.errorIconDrawable = null + } + else -> { + usernameChanged = true + clearUsernameTextButton.isEnabled = true + setDupeError() + } } + setSaveButtonState() } override fun beforeTextChanged(u: CharSequence?, start: Int, count: Int, after: Int) { @@ -155,20 +199,26 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { override fun afterTextChanged(p: Editable?) { when { p.toString().isEmpty() -> { + passwordChanged = true clearPasswordTextButton.isEnabled = false setPasswordError() } p.toString() == oldLogin.password -> { + passwordChanged = false + validPassword = true inputLayoutPassword.error = null inputLayoutPassword.errorIconDrawable = null clearPasswordTextButton.isEnabled = true } else -> { + passwordChanged = true + validPassword = true inputLayoutPassword.error = null inputLayoutPassword.errorIconDrawable = null clearPasswordTextButton.isEnabled = true } } + setSaveButtonState() } override fun beforeTextChanged(p: CharSequence?, start: Int, count: Int, after: Int) { @@ -181,14 +231,40 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { }) } + private fun isDupe(username: String): Boolean = + loginsFragmentStore.state.duplicateLogins.filter { it.username == username }.any() + + private fun setDupeError() { + if (isDupe(usernameText.text.toString())) { + inputLayoutUsername?.let { + usernameChanged = true + validUsername = false + it.setErrorIconDrawable(R.drawable.mozac_ic_warning) + it.error = context?.getString(R.string.saved_login_duplicate) + } + } else { + usernameChanged = true + validUsername = true + inputLayoutUsername.error = null + } + } + private fun setPasswordError() { inputLayoutPassword?.let { layout -> + validPassword = false layout.error = context?.getString(R.string.saved_login_password_required) layout.setErrorIconDrawable(R.drawable.mozac_ic_warning) + } + } - layout.errorIconDrawable?.setTint( - ContextCompat.getColor(requireContext(), R.color.design_default_color_error) - ) + private fun setSaveButtonState() { + val saveButton = activity?.findViewById(R.id.save_login_button) + val changesMadeWithNoErrors = + validUsername && validPassword && (usernameChanged || passwordChanged) + + changesMadeWithNoErrors.let { + saveButton?.isEnabled = it + saveEnabled = it } } @@ -207,101 +283,16 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { R.id.save_login_button -> { view?.hideKeyboard() - if (!passwordText.text.isNullOrBlank()) { - try { - attemptSaveAndExit() - } 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 - ) - } - } + if (saveEnabled) { + interactor.onSaveLogin( + args.savedLoginItem.guid, + usernameText.text.toString(), + passwordText.text.toString() + ) + requireComponents.analytics.metrics.track(Event.EditLoginSave) } 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/fragment/LoginDetailFragment.kt similarity index 66% rename from app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.kt index f7c842978..501e26763 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginDetailFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/LoginDetailFragment.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.logins +package org.mozilla.fenix.settings.logins.fragment import android.content.DialogInterface import android.os.Bundle @@ -21,16 +21,8 @@ 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.CancellationException -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import mozilla.components.concept.storage.Login import mozilla.components.lib.state.ext.consumeFrom import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.FeatureFlags @@ -42,9 +34,16 @@ import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.increaseTapArea import org.mozilla.fenix.ext.redirectToReAuth +import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar -import org.mozilla.fenix.ext.urlToTrimmedHost +import org.mozilla.fenix.ext.simplifiedUrl +import org.mozilla.fenix.settings.logins.LoginsFragmentStore +import org.mozilla.fenix.settings.logins.LoginsListState +import org.mozilla.fenix.settings.logins.SavedLogin +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController +import org.mozilla.fenix.settings.logins.interactor.LoginDetailInteractor +import org.mozilla.fenix.settings.logins.view.LoginDetailView /** * Displays saved login information for a single website. @@ -57,8 +56,10 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { private var login: SavedLogin? = null private lateinit var savedLoginsStore: LoginsFragmentStore private lateinit var loginDetailView: LoginDetailView + private lateinit var interactor: LoginDetailInteractor private lateinit var menu: Menu private var deleteDialog: AlertDialog? = null + private var showPassword = true override fun onCreateView( inflater: LayoutInflater, @@ -74,12 +75,14 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { filteredItems = listOf(), searchedForText = null, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, - highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem + highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem, + duplicateLogins = listOf() // assume on load there are no dupes ) ) } - loginDetailView = LoginDetailView(view?.findViewById(R.id.loginDetailLayout)) - fetchLoginDetails() + loginDetailView = LoginDetailView( + view.findViewById(R.id.loginDetailLayout) + ) return view } @@ -87,16 +90,29 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { @ObsoleteCoroutinesApi @ExperimentalCoroutinesApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + interactor = LoginDetailInteractor( + SavedLoginsStorageController( + context = requireContext(), + viewLifecycleScope = viewLifecycleOwner.lifecycleScope, + navController = findNavController(), + loginsFragmentStore = savedLoginsStore + ) + ) + interactor.onFetchLoginList(args.savedLoginId) + consumeFrom(savedLoginsStore) { loginDetailView.update(it) login = savedLoginsStore.state.currentItem setUpCopyButtons() showToolbar( - savedLoginsStore.state.currentItem?.origin?.urlToTrimmedHost(requireContext()) + savedLoginsStore.state.currentItem?.origin?.simplifiedUrl() ?: "" ) setUpPasswordReveal() } + loginDetailView.togglePasswordReveal(showPassword) } override fun onCreate(savedInstanceState: Bundle?) { @@ -124,7 +140,11 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD revealPasswordButton.increaseTapArea(BUTTON_INCREASE_DPS) revealPasswordButton.setOnClickListener { - togglePasswordReveal() + showPassword = !showPassword + loginDetailView.togglePasswordReveal(!showPassword) + } + passwordText.setOnClickListener { + loginDetailView.togglePasswordReveal(!showPassword) } } @@ -149,33 +169,6 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { ) } - // TODO: Move interactions with the component's password storage into a separate datastore - private fun fetchLoginDetails() { - var deferredLogin: Deferred>? = 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) @@ -206,9 +199,11 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { } private fun editLogin() { + requireComponents.analytics.metrics.track(Event.EditLogin) val directions = - LoginDetailFragmentDirections - .actionLoginDetailFragmentToEditLoginFragment(login!!) + LoginDetailFragmentDirections.actionLoginDetailFragmentToEditLoginFragment( + login!! + ) findNavController().navigate(directions) } @@ -220,7 +215,8 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { dialog.cancel() } setPositiveButton(R.string.dialog_delete_positive) { dialog: DialogInterface, _ -> - deleteLogin() + requireComponents.analytics.metrics.track(Event.DeleteLogin) + interactor.onDeleteLogin(args.savedLoginId) dialog.dismiss() } create() @@ -228,49 +224,6 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) { } } - // TODO: Move interactions with the component's password storage into a separate datastore - // This includes Delete, Update/Edit, Create - private fun deleteLogin() { - var deleteLoginJob: Deferred? = 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 diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAuthFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt similarity index 98% rename from app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAuthFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsAuthFragment.kt index d8051e7f3..63fc052e0 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsAuthFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/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.logins +package org.mozilla.fenix.settings.logins.fragment import android.annotation.TargetApi import android.app.Activity.RESULT_OK @@ -313,7 +313,8 @@ class SavedLoginsAuthFragment : PreferenceFragmentCompat(), AccountObserver { } private fun navigateToAccountProblemFragment() { - val directions = SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment() + val directions = + SavedLoginsAuthFragmentDirections.actionGlobalAccountProblemFragment() findNavController().navigate(directions) } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt similarity index 66% rename from app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt index cec4c1c29..16a4a8974 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.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.logins +package org.mozilla.fenix.settings.logins.fragment import android.os.Bundle import android.view.LayoutInflater @@ -21,17 +21,9 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import kotlinx.android.synthetic.main.fragment_saved_logins.view.* -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Main -import kotlinx.coroutines.async -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ObsoleteCoroutinesApi import mozilla.components.browser.menu.BrowserMenu -import mozilla.components.concept.storage.Login import mozilla.components.lib.state.ext.consumeFrom import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.HomeActivity @@ -41,17 +33,28 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.redirectToReAuth import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.settings.logins.LoginsAction +import org.mozilla.fenix.settings.logins.LoginsFragmentStore +import org.mozilla.fenix.settings.logins.controller.LoginsListController +import org.mozilla.fenix.settings.logins.LoginsListState +import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor +import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu +import org.mozilla.fenix.settings.logins.view.SavedLoginsListView +import org.mozilla.fenix.settings.logins.SortingStrategy +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController @SuppressWarnings("TooManyFunctions") class SavedLoginsFragment : Fragment() { private lateinit var savedLoginsStore: LoginsFragmentStore - private lateinit var savedLoginsView: SavedLoginsView + private lateinit var savedLoginsListView: SavedLoginsListView private lateinit var savedLoginsInteractor: SavedLoginsInteractor private lateinit var dropDownMenuAnchorView: View private lateinit var sortingStrategyMenu: SavedLoginsSortingStrategyMenu private lateinit var sortingStrategyPopupMenu: BrowserMenu private lateinit var toolbarChildContainer: FrameLayout private lateinit var sortLoginsMenuRoot: ConstraintLayout + private lateinit var loginsListController: LoginsListController + private lateinit var savedLoginsStorageController: SavedLoginsStorageController override fun onResume() { super.onResume() @@ -81,21 +84,39 @@ class SavedLoginsFragment : Fragment() { filteredItems = listOf(), searchedForText = null, sortingStrategy = requireContext().settings().savedLoginsSortingStrategy, - highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem + highlightedItem = requireContext().settings().savedLoginsMenuHighlightedItem, + duplicateLogins = listOf() // assume on load there are no dupes ) ) } - val savedLoginsController: SavedLoginsController = - SavedLoginsController( - store = savedLoginsStore, - navController = findNavController(), - browserNavigator = ::openToBrowserAndLoad, - settings = requireContext().settings(), - metrics = requireContext().components.analytics.metrics + + loginsListController = + LoginsListController( + loginsFragmentStore = savedLoginsStore, + navController = findNavController(), + browserNavigator = ::openToBrowserAndLoad, + settings = requireContext().settings(), + metrics = requireContext().components.analytics.metrics ) - savedLoginsInteractor = SavedLoginsInteractor(savedLoginsController) - savedLoginsView = SavedLoginsView(view.savedLoginsLayout, savedLoginsInteractor) - loadAndMapLogins() + savedLoginsStorageController = + SavedLoginsStorageController( + context = requireContext(), + viewLifecycleScope = viewLifecycleOwner.lifecycleScope, + navController = findNavController(), + loginsFragmentStore = savedLoginsStore + ) + + savedLoginsInteractor = + SavedLoginsInteractor( + loginsListController, + savedLoginsStorageController + ) + + savedLoginsListView = SavedLoginsListView( + view.savedLoginsLayout, + savedLoginsInteractor + ) + savedLoginsInteractor.loadAndMapLogins() return view } @@ -105,7 +126,7 @@ class SavedLoginsFragment : Fragment() { super.onViewCreated(view, savedInstanceState) consumeFrom(savedLoginsStore) { sortingStrategyMenu.updateMenu(savedLoginsStore.state.highlightedItem) - savedLoginsView.update(it) + savedLoginsListView.update(it) } } @@ -122,7 +143,11 @@ class SavedLoginsFragment : Fragment() { } override fun onQueryTextChange(newText: String?): Boolean { - savedLoginsStore.dispatch(LoginsAction.FilterLogins(newText)) + savedLoginsStore.dispatch( + LoginsAction.FilterLogins( + newText + ) + ) return false } }) @@ -141,31 +166,11 @@ class SavedLoginsFragment : Fragment() { super.onPause() } - private fun openToBrowserAndLoad(searchTermOrURL: String, newTab: Boolean, from: BrowserDirection) { - (activity as HomeActivity).openToBrowserAndLoad(searchTermOrURL, newTab, from) - } - - private fun loadAndMapLogins() { - var deferredLogins: Deferred>? = null - val fetchLoginsJob = viewLifecycleOwner.lifecycleScope.launch(IO) { - deferredLogins = async { - requireContext().components.core.passwordsStorage.list() - } - val logins = deferredLogins?.await() - logins?.let { - withContext(Main) { - savedLoginsStore.dispatch( - LoginsAction.UpdateLoginsList(logins.map { it.mapToSavedLogin() }) - ) - } - } - } - fetchLoginsJob.invokeOnCompletion { - if (it is CancellationException) { - deferredLogins?.cancel() - } - } - } + private fun openToBrowserAndLoad( + searchTermOrURL: String, + newTab: Boolean, + from: BrowserDirection + ) = (activity as HomeActivity).openToBrowserAndLoad(searchTermOrURL, newTab, from) private fun initToolbar() { showToolbar(getString(R.string.preferences_passwords_saved_logins)) @@ -175,8 +180,12 @@ class SavedLoginsFragment : Fragment() { sortLoginsMenuRoot = inflateSortLoginsMenuRoot() dropDownMenuAnchorView = sortLoginsMenuRoot.findViewById(R.id.drop_down_menu_anchor_view) when (requireContext().settings().savedLoginsSortingStrategy) { - is SortingStrategy.Alphabetically -> setupMenu(SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort) - is SortingStrategy.LastUsed -> setupMenu(SavedLoginsSortingStrategyMenu.Item.LastUsedSort) + is SortingStrategy.Alphabetically -> setupMenu( + SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort + ) + is SortingStrategy.LastUsed -> setupMenu( + SavedLoginsSortingStrategyMenu.Item.LastUsedSort + ) } } @@ -210,21 +219,29 @@ class SavedLoginsFragment : Fragment() { } private fun setupMenu(itemToHighlight: SavedLoginsSortingStrategyMenu.Item) { - sortingStrategyMenu = SavedLoginsSortingStrategyMenu(requireContext(), itemToHighlight) { - when (it) { - SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort -> { - savedLoginsInteractor.onSortingStrategyChanged( - SortingStrategy.Alphabetically(requireContext().applicationContext) - ) - } + sortingStrategyMenu = + SavedLoginsSortingStrategyMenu( + requireContext(), + itemToHighlight + ) { + when (it) { + SavedLoginsSortingStrategyMenu.Item.AlphabeticallySort -> { + savedLoginsInteractor.onSortingStrategyChanged( + SortingStrategy.Alphabetically( + requireContext().applicationContext + ) + ) + } - SavedLoginsSortingStrategyMenu.Item.LastUsedSort -> { - savedLoginsInteractor.onSortingStrategyChanged( - SortingStrategy.LastUsed(requireContext().applicationContext) - ) + SavedLoginsSortingStrategyMenu.Item.LastUsedSort -> { + savedLoginsInteractor.onSortingStrategyChanged( + SortingStrategy.LastUsed( + requireContext().applicationContext + ) + ) + } } } - } attachMenu() } diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSettingFragment.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsSettingFragment.kt similarity index 98% rename from app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSettingFragment.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsSettingFragment.kt index f044da1b4..b781e0ee3 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/SavedLoginsSettingFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsSettingFragment.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.logins +package org.mozilla.fenix.settings.logins.fragment import android.os.Bundle import androidx.preference.Preference diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/EditLoginInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/EditLoginInteractor.kt new file mode 100644 index 000000000..3039dd693 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/EditLoginInteractor.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 edit login screen + * + * @property savedLoginsController controller for the saved logins storage + */ +class EditLoginInteractor( + private val savedLoginsController: SavedLoginsStorageController +) { + fun findPotentialDuplicates(loginId: String) { + savedLoginsController.findPotentialDuplicates(loginId) + } + + fun onSaveLogin(loginId: String, usernameText: String, passwordText: String) { + savedLoginsController.save(loginId, usernameText, passwordText) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.kt new file mode 100644 index 000000000..7d8f6cb9c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/LoginDetailInteractor.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 login detail screen + * + * @property savedLoginsController controller for the saved logins storage + */ +class LoginDetailInteractor( + private val savedLoginsController: SavedLoginsStorageController +) { + fun onFetchLoginList(loginId: String) { + savedLoginsController.fetchLoginDetails(loginId) + } + + fun onDeleteLogin(loginId: String) { + savedLoginsController.delete(loginId) + } +} 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 new file mode 100644 index 000000000..5bf69a99b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/interactor/SavedLoginsInteractor.kt @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.interactor + +import org.mozilla.fenix.settings.logins.SavedLogin +import org.mozilla.fenix.settings.logins.SortingStrategy +import org.mozilla.fenix.settings.logins.controller.LoginsListController +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController + +/** + * Interactor for the saved logins screen + * + * @param loginsListController [LoginsListController] which will be delegated for all + * user interactions. + * @param savedLoginsStorageController [SavedLoginsStorageController] which will be delegated + * for all calls to the password storage component + */ +class SavedLoginsInteractor( + private val loginsListController: LoginsListController, + private val savedLoginsStorageController: SavedLoginsStorageController +) { + fun onItemClicked(item: SavedLogin) { + loginsListController.handleItemClicked(item) + } + + fun onLearnMoreClicked() { + loginsListController.handleLearnMoreClicked() + } + + fun onSortingStrategyChanged(sortingStrategy: SortingStrategy) { + loginsListController.handleSort(sortingStrategy) + } + + fun loadAndMapLogins() { + savedLoginsStorageController.handleLoadAndMapLogins() + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/view/EditLoginView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/EditLoginView.kt new file mode 100644 index 000000000..cdc1c8a9b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/EditLoginView.kt @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.view + +import android.text.InputType +import android.view.ViewGroup +import androidx.appcompat.content.res.AppCompatResources +import kotlinx.android.extensions.LayoutContainer +import kotlinx.android.synthetic.main.fragment_edit_login.view.* +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components + +/** + * View that contains and configures the Edit Login screen + */ +@Suppress("ForbiddenComment") +class EditLoginView( + override val containerView: ViewGroup +) : LayoutContainer { + private val context = containerView.context + + // TODO: create helper class for toggling passwords. https://github.com/mozilla-mobile/fenix/issues/12554 + fun showPassword() { + val currText = containerView.passwordText?.text + context.components.analytics.metrics.track(Event.ViewLoginPassword) + containerView.passwordText?.inputType = + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + containerView.revealPasswordButton?.setImageDrawable( + AppCompatResources.getDrawable(context, R.drawable.mozac_ic_password_hide) + ) + containerView.revealPasswordButton?.contentDescription = + context.resources.getString(R.string.saved_login_hide_password) + + // For the new type to take effect you need to reset the text to it's current edited version + containerView.passwordText?.text = currText + } + + fun hidePassword() { + val currText = containerView.passwordText?.text + containerView.passwordText?.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + containerView.revealPasswordButton?.setImageDrawable( + AppCompatResources.getDrawable(context, R.drawable.mozac_ic_password_reveal) + ) + containerView.revealPasswordButton?.contentDescription = + context.getString(R.string.saved_login_reveal_password) + + // For the new type to take effect you need to reset the text to it's current edited version + containerView.passwordText?.text = currText + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt new file mode 100644 index 000000000..6276772f6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginDetailView.kt @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.view + +import android.text.InputType +import android.view.ViewGroup +import androidx.core.content.res.ResourcesCompat +import kotlinx.android.extensions.LayoutContainer +import kotlinx.android.synthetic.main.fragment_login_detail.* +import kotlinx.android.synthetic.main.fragment_login_detail.view.* +import org.mozilla.fenix.R +import org.mozilla.fenix.components.metrics.Event +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.settings.logins.LoginsListState + +/** + * View that contains and configures the Login Details + */ +@Suppress("ForbiddenComment") +class LoginDetailView(override val containerView: ViewGroup) : LayoutContainer { + private val context = containerView.context + + fun update(login: LoginsListState) { + webAddressText.text = login.currentItem?.origin + usernameText.text = login.currentItem?.username + passwordText.text = login.currentItem?.password + } + + fun togglePasswordReveal(show: Boolean) { + if (show) showPassword() else { hidePassword() } + } + + // TODO: create helper class for toggling passwords. https://github.com/mozilla-mobile/fenix/issues/12554 + fun showPassword() { + if (passwordText.inputType == InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT) { + context?.components?.analytics?.metrics?.track(Event.ViewLoginPassword) + passwordText.inputType = InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + revealPasswordButton.setImageDrawable( + ResourcesCompat.getDrawable( + context.resources, + R.drawable.mozac_ic_password_hide, null + ) + ) + revealPasswordButton.contentDescription = + context.resources.getString(R.string.saved_login_hide_password) + } + // For the new type to take effect you need to reset the text + passwordText.text = containerView.passwordText.editableText + } + + fun hidePassword() { + passwordText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + revealPasswordButton.setImageDrawable( + ResourcesCompat.getDrawable(context.resources, + R.drawable.mozac_ic_password_reveal, null) + ) + revealPasswordButton.contentDescription = + context.getString(R.string.saved_login_reveal_password) + // For the new type to take effect you need to reset the text + passwordText.text = containerView.passwordText.editableText + } +} diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsAdapter.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsAdapter.kt similarity index 88% rename from app/src/main/java/org/mozilla/fenix/settings/logins/LoginsAdapter.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsAdapter.kt index 40e1d39ca..b5801ff9d 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsAdapter.kt @@ -2,13 +2,15 @@ * 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 +package org.mozilla.fenix.settings.logins.view import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import org.mozilla.fenix.R +import org.mozilla.fenix.settings.logins.SavedLogin +import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor class LoginsAdapter( private val interactor: SavedLoginsInteractor diff --git a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsListViewHolder.kt b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsListViewHolder.kt similarity index 88% rename from app/src/main/java/org/mozilla/fenix/settings/logins/LoginsListViewHolder.kt rename to app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsListViewHolder.kt index f7bd68faa..b5cd79943 100644 --- a/app/src/main/java/org/mozilla/fenix/settings/logins/LoginsListViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/LoginsListViewHolder.kt @@ -2,13 +2,15 @@ * 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 +package org.mozilla.fenix.settings.logins.view import android.view.View import androidx.recyclerview.widget.RecyclerView import kotlinx.android.synthetic.main.logins_item.view.* import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.loadIntoView +import org.mozilla.fenix.settings.logins.SavedLogin +import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor class LoginsListViewHolder( private val view: View, 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 new file mode 100644 index 000000000..400d3442f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/settings/logins/view/SavedLoginsListView.kt @@ -0,0 +1,67 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins.view + +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.android.extensions.LayoutContainer +import kotlinx.android.synthetic.main.component_saved_logins.view.* +import org.mozilla.fenix.R +import org.mozilla.fenix.settings.logins.LoginsListState +import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor +import org.mozilla.fenix.ext.addUnderline + +/** + * View that contains and configures the Saved Logins List + */ +class SavedLoginsListView( + override val containerView: ViewGroup, + val interactor: SavedLoginsInteractor +) : LayoutContainer { + + val view: FrameLayout = LayoutInflater.from(containerView.context) + .inflate(R.layout.component_saved_logins, containerView, true) + .findViewById(R.id.saved_logins_wrapper) + + private val loginsAdapter = LoginsAdapter(interactor) + + init { + view.saved_logins_list.apply { + adapter = loginsAdapter + layoutManager = LinearLayoutManager(containerView.context) + itemAnimator = null + } + + with(view.saved_passwords_empty_learn_more) { + movementMethod = LinkMovementMethod.getInstance() + addUnderline() + setOnClickListener { interactor.onLearnMoreClicked() } + } + + with(view.saved_passwords_empty_message) { + val appName = context.getString(R.string.app_name) + text = String.format( + context.getString( + R.string.preferences_passwords_saved_logins_description_empty_text + ), appName + ) + } + } + + fun update(state: LoginsListState) { + if (state.isLoading) { + view.progress_bar.isVisible = true + } else { + view.progress_bar.isVisible = false + view.saved_logins_list.isVisible = state.loginList.isNotEmpty() + view.saved_passwords_empty_view.isVisible = state.loginList.isEmpty() + } + loginsAdapter.submitList(state.filteredItems) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index fbf5b28e3..b6d4530c6 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -33,7 +33,7 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.getPreferenceKey import org.mozilla.fenix.settings.PhoneFeature import org.mozilla.fenix.settings.deletebrowsingdata.DeleteBrowsingDataOnQuitType -import org.mozilla.fenix.settings.logins.SavedLoginsFragment +import org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragment import org.mozilla.fenix.settings.logins.SavedLoginsSortingStrategyMenu import org.mozilla.fenix.settings.logins.SortingStrategy import org.mozilla.fenix.settings.registerOnSharedPreferenceChangeListener diff --git a/app/src/main/res/color/save_enabled_ic_color.xml b/app/src/main/res/color/save_enabled_ic_color.xml new file mode 100644 index 000000000..4c0a682f3 --- /dev/null +++ b/app/src/main/res/color/save_enabled_ic_color.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/color/saved_login_clear_edit_text_tint.xml b/app/src/main/res/color/saved_login_clear_edit_text_tint.xml index acb4e5e7e..ada9ebc97 100644 --- a/app/src/main/res/color/saved_login_clear_edit_text_tint.xml +++ b/app/src/main/res/color/saved_login_clear_edit_text_tint.xml @@ -6,5 +6,5 @@ + android:color="?disabled" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_edit_login.xml b/app/src/main/res/layout/fragment_edit_login.xml index 5881ed11c..9c5a6373f 100644 --- a/app/src/main/res/layout/fragment_edit_login.xml +++ b/app/src/main/res/layout/fragment_edit_login.xml @@ -134,12 +134,13 @@ android:id="@+id/clearUsernameTextButton" android:layout_width="48dp" android:layout_height="30dp" + android:layout_marginTop="3dp" android:layout_marginBottom="10dp" android:background="@null" android:contentDescription="@string/saved_login_copy_username" app:tint="@color/saved_login_clear_edit_text_tint" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintBottom_toBottomOf="@id/inputLayoutUsername" + app:layout_constraintTop_toTopOf="@id/inputLayoutUsername" app:srcCompat="@drawable/ic_clear" /> + tools:context="org.mozilla.fenix.settings.logins.fragment.SavedLoginsFragment" /> diff --git a/app/src/main/res/menu/login_save.xml b/app/src/main/res/menu/login_save.xml index f8c8d2738..fb9682757 100644 --- a/app/src/main/res/menu/login_save.xml +++ b/app/src/main/res/menu/login_save.xml @@ -8,7 +8,7 @@ \ 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 b01087482..33ecf6269 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -321,7 +321,7 @@ Unit = mockk(relaxed = true) - - private val settings: Settings = mockk(relaxed = true) private val metrics: MetricController = mockk(relaxed = true) - private val sortingStrategy: SortingStrategy = SortingStrategy.Alphabetically(testContext) - private val controller = SavedLoginsController(store, navController, browserNavigator, settings, metrics) + private val controller = + LoginsListController( + loginsFragmentStore = store, + navController = navController, + browserNavigator = browserNavigator, + settings = settings, + metrics = metrics + ) @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`() { controller.handleSort(sortingStrategy) - verify { + verifyAll { store.dispatch( LoginsAction.SortLogins( SortingStrategy.Alphabetically( @@ -55,7 +62,7 @@ class SavedLoginsControllerTest { store.dispatch(LoginsAction.LoginSelected(login)) metrics.track(Event.OpenOneLogin) navController.navigate( - SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(login.guid) + SavedLoginsFragmentDirections.actionSavedLoginsFragmentToLoginDetailFragment(login.guid) ) } } @@ -64,7 +71,7 @@ class SavedLoginsControllerTest { fun `GIVEN the learn more option, WHEN handleLearnMoreClicked is called for it, then we should open the right support webpage`() { controller.handleLearnMoreClicked() - verify { + verifyAll { browserNavigator.invoke( SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP), true, diff --git a/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListViewHolderTest.kt b/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListViewHolderTest.kt index 3f15ad9fe..73378590b 100644 --- a/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListViewHolderTest.kt +++ b/app/src/test/java/org/mozilla/fenix/settings/logins/LoginsListViewHolderTest.kt @@ -16,6 +16,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.mozilla.fenix.R import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor +import org.mozilla.fenix.settings.logins.view.LoginsListViewHolder @RunWith(FenixRobolectricTestRunner::class) class LoginsListViewHolderTest { @@ -39,7 +41,10 @@ class LoginsListViewHolderTest { @Test fun `bind url and username`() { - val holder = LoginsListViewHolder(view, interactor) + val holder = LoginsListViewHolder( + view, + interactor + ) holder.bind(baseLogin) assertEquals("mozilla.org", view.webAddressView.text) @@ -48,7 +53,10 @@ class LoginsListViewHolderTest { @Test fun `call interactor on click`() { - val holder = LoginsListViewHolder(view, interactor) + val holder = LoginsListViewHolder( + view, + interactor + ) holder.bind(baseLogin) view.performClick() 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 7e56dd379..d82e265e8 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 @@ -7,15 +7,25 @@ package org.mozilla.fenix.settings.logins import io.mockk.mockk import io.mockk.verifyAll import mozilla.components.support.test.robolectric.testContext +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.settings.logins.controller.LoginsListController +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController +import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor import kotlin.random.Random @RunWith(FenixRobolectricTestRunner::class) class SavedLoginsInteractorTest { - private val controller: SavedLoginsController = mockk(relaxed = true) - private val interactor = SavedLoginsInteractor(controller) + private val listController: LoginsListController = mockk(relaxed = true) + private val savedLoginsStorageController: SavedLoginsStorageController = mockk(relaxed = true) + private lateinit var interactor: SavedLoginsInteractor + + @Before + fun setup() { + interactor = SavedLoginsInteractor(listController, savedLoginsStorageController) + } @Test fun `GIVEN a SavedLogin being clicked, WHEN the interactor is called for it, THEN it should just delegate the controller`() { @@ -23,7 +33,7 @@ class SavedLoginsInteractorTest { interactor.onItemClicked(item) verifyAll { - controller.handleItemClicked(item) + listController.handleItemClicked(item) } } @@ -34,7 +44,7 @@ class SavedLoginsInteractorTest { interactor.onSortingStrategyChanged(sortingStrategy) verifyAll { - controller.handleSort(sortingStrategy) + listController.handleSort(sortingStrategy) } } @@ -43,7 +53,13 @@ class SavedLoginsInteractorTest { interactor.onLearnMoreClicked() verifyAll { - controller.handleLearnMoreClicked() + listController.handleLearnMoreClicked() } } + + @Test + fun loadAndMapLoginsTest() { + interactor.loadAndMapLogins() + verifyAll { savedLoginsStorageController.handleLoadAndMapLogins() } + } } diff --git a/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt b/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt new file mode 100644 index 000000000..f42e1f774 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/settings/logins/SavedLoginsStorageControllerTest.kt @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.logins + +import android.content.Context +import android.os.Looper +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScope +import mozilla.components.concept.storage.Login +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.R +import org.mozilla.fenix.components.Components +import org.mozilla.fenix.ext.components +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.LooperMode + +@ExperimentalCoroutinesApi +@LooperMode(LooperMode.Mode.PAUSED) +@RunWith(FenixRobolectricTestRunner::class) +class SavedLoginsStorageControllerTest { + private lateinit var components: Components + private val context: Context = mockk(relaxed = true) + private lateinit var controller: SavedLoginsStorageController + private val navController: NavController = mockk(relaxed = true) + private val loginsFragmentStore: LoginsFragmentStore = mockk(relaxed = true) + private val scope = TestCoroutineScope() + private val loginMock: Login = mockk(relaxed = true) + + @Before + fun setup() { + every { navController.currentDestination } returns NavDestination("").apply { + id = R.id.loginDetailFragment + } + coEvery { context.components.core.passwordsStorage.get(any()) } returns loginMock + every { loginsFragmentStore.dispatch(any()) } returns mockk() + coEvery { context.components.core.passwordsStorage } returns mockk(relaxed = true) + components = mockk(relaxed = true) + + controller = SavedLoginsStorageController( + context = context, + viewLifecycleScope = MainScope(), + navController = navController, + loginsFragmentStore = loginsFragmentStore + ) + } + + @After + fun cleanUp() { + scope.cleanupTestCoroutines() + } + + @Test + fun `WHEN a login is deleted, THEN navigate back to the previous page`() = runBlocking { + val loginId = "id" + // mock for deleteLoginJob: Deferred? + coEvery { context.components.core.passwordsStorage.delete(any()) } returns true + controller.delete(loginId) + + shadow() + + coVerify { context.components.core.passwordsStorage.delete(loginId) } + } + + private fun shadow() { + // solves issue with Roboelectric v4.3 and SDK 28 + // https://github.com/robolectric/robolectric/issues/5356 + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun `WHEN fetching the login list, THEN update the state in the store`() { + val loginId = "id" + // for deferredLogin: Deferred>? + coEvery { context.components.core.passwordsStorage.list() } returns listOf() + + controller.fetchLoginDetails(loginId) + + coVerify { context.components.core.passwordsStorage.list() } + } + + @Test + fun `WHEN saving an update to an item, THEN navigate to login detail view`() { + val login = Login( + guid = "id", + origin = "https://www.test.co.gov.org", + username = "user123", + password = "securePassword1", + httpRealm = "httpRealm", + formActionOrigin = "" + ) + coEvery { context.components.core.passwordsStorage.get(any()) } returns loginMock + + controller.save(login.guid!!, login.username, login.password) + + coVerify { context.components.core.passwordsStorage.get(any()) } + } + + @Test + fun `WHEN finding login dupes, THEN update duplicates in the store`() { + val login = Login( + guid = "id", + origin = "https://www.test.co.gov.org", + username = "user123", + password = "securePassword1", + httpRealm = "httpRealm", + formActionOrigin = "" + ) + + coEvery { context.components.core.passwordsStorage.get(any()) } returns login + + // for deferredLogin: Deferred>? + coEvery { + context.components.core.passwordsStorage.getPotentialDupesIgnoringUsername(any()) + } returns listOf() + + controller.findPotentialDuplicates(login.guid!!) + + shadow() + + coVerify { + context.components.core.passwordsStorage.getPotentialDupesIgnoringUsername(login) + } + } +} diff --git a/docs/metrics.md b/docs/metrics.md index 912b814ad..f8b59d646 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -118,8 +118,11 @@ The following metrics are added to the ping: | history.removed_all |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user removed all history items |[1](https://github.com/mozilla-mobile/fenix/pull/3940)||2020-10-01 | | history.shared |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user shared a history item |[1](https://github.com/mozilla-mobile/fenix/pull/3940)||2020-10-01 | | logins.copy_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user copied a piece of a login in saved logins |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 | +| logins.delete_saved_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user confirms delete of a saved login |[1](https://github.com/mozilla-mobile/fenix/issues/11208)||2020-10-01 | | logins.open_individual_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user accessed an individual login in saved logins |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 | +| logins.open_login_editor |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user entered the edit screen for an individual saved login |[1](https://github.com/mozilla-mobile/fenix/issues/11208)||2020-10-01 | | logins.open_logins |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user accessed Logins in Settings |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 | +| logins.save_edited_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user saves changes made to an individual login |[1](https://github.com/mozilla-mobile/fenix/issues/11208)||2020-10-01 | | logins.save_logins_setting_changed |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user changed their setting for asking to save logins |[1](https://github.com/mozilla-mobile/fenix/pull/7767)|
  • setting: The new setting for saving logins the user selected. Either `ask_to_save` or `never_save`
|2020-10-01 | | logins.view_password_login |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user viewed a password in an individual saved login |[1](https://github.com/mozilla-mobile/fenix/pull/6352)||2020-10-01 | | media_notification.pause |[event](https://mozilla.github.io/glean/book/user/metrics/event.html) |A user pressed the pause icon on the media notification |[1](https://github.com/mozilla-mobile/fenix/pull/5520)||2020-10-01 |