diff --git a/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/button/IconButton.kt b/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/button/IconButton.kt index 6c8b75288fe8..75b666350aeb 100644 --- a/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/button/IconButton.kt +++ b/mobile/android/android-components/components/compose/base/src/main/java/mozilla/components/compose/base/button/IconButton.kt @@ -58,7 +58,7 @@ private val RippleRadius = 24.dp @Composable fun IconButton( onClick: () -> Unit, - contentDescription: String, + contentDescription: String?, modifier: Modifier = Modifier, onClickLabel: String? = null, enabled: Boolean = true, @@ -68,7 +68,11 @@ fun IconButton( val view = LocalView.current Box( modifier = modifier - .semantics { this.contentDescription = contentDescription } + .semantics { + if (contentDescription != null) { + this.contentDescription = contentDescription + } + } .minimumInteractiveComponentSize() .clickable( interactionSource = interactionSource, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/LifecycleHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/LifecycleHolder.kt new file mode 100644 index 000000000000..dffcfb1c0bd4 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/lifecycle/LifecycleHolder.kt @@ -0,0 +1,25 @@ +/* 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.lifecycle + +import android.content.Context +import androidx.navigation.NavController +import org.mozilla.fenix.HomeActivity + +/** + * A helper class to be able to change the reference to objects that get replaced when the activity + * gets recreated. + * + * @property context the android [Context] + * @property navController A [NavController] for interacting with the androidx navigation library. + * @property composeNavController A [NavController] for navigating within the local Composable nav graph. + * @property homeActivity so that we can reference openToBrowserAndLoad and browsingMode :( + */ +class LifecycleHolder( + var context: Context, + var navController: NavController, + var composeNavController: NavController, + var homeActivity: HomeActivity, +) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt index 3c81d9f1afa5..7f23eb7abafb 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/fragment/SavedLoginsFragment.kt @@ -19,6 +19,8 @@ import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.os.bundleOf import androidx.core.view.MenuProvider @@ -27,7 +29,9 @@ import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavHostController import androidx.navigation.fragment.findNavController +import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.menu.MenuController import mozilla.components.concept.menu.Orientation import mozilla.components.lib.state.ext.consumeFrom @@ -40,9 +44,11 @@ import org.mozilla.fenix.biometricauthentication.BiometricAuthenticationManager import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.databinding.FragmentSavedLoginsBinding import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.registerForActivityResult import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.lifecycle.LifecycleHolder import org.mozilla.fenix.settings.biometric.DefaultBiometricUtils import org.mozilla.fenix.settings.logins.LoginsAction import org.mozilla.fenix.settings.logins.LoginsFragmentStore @@ -53,7 +59,14 @@ import org.mozilla.fenix.settings.logins.controller.LoginsListController import org.mozilla.fenix.settings.logins.controller.SavedLoginsStorageController import org.mozilla.fenix.settings.logins.createInitialLoginsListState import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor +import org.mozilla.fenix.settings.logins.ui.DefaultSavedLoginsStorage +import org.mozilla.fenix.settings.logins.ui.LoginsMiddleware +import org.mozilla.fenix.settings.logins.ui.LoginsSortOrder +import org.mozilla.fenix.settings.logins.ui.LoginsState +import org.mozilla.fenix.settings.logins.ui.LoginsStore +import org.mozilla.fenix.settings.logins.ui.SavedLoginsScreen import org.mozilla.fenix.settings.logins.view.SavedLoginsListView +import org.mozilla.fenix.theme.FirefoxTheme @SuppressWarnings("TooManyFunctions") class SavedLoginsFragment : SecureFragment(), MenuProvider { @@ -78,6 +91,9 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + if (requireContext().settings().enableComposeLogins) { return } + startForResult = registerForActivityResult { BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt = false @@ -89,6 +105,12 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { override fun onResume() { super.onResume() + + if (requireContext().settings().enableComposeLogins) { + hideToolbar() + return + } + if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt) { BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt = false @@ -119,11 +141,72 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { initToolbar() } + @Suppress("LongMethod") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { + if (requireContext().settings().enableComposeLogins) { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + val buildStore = { navController: NavHostController -> + val store = StoreProvider.get(this@SavedLoginsFragment) { + val lifecycleHolder = LifecycleHolder( + context = requireContext(), + navController = this@SavedLoginsFragment.findNavController(), + composeNavController = navController, + homeActivity = (requireActivity() as HomeActivity), + ) + + LoginsStore( + initialState = LoginsState().copy( + sortOrder = LoginsSortOrder.fromString( + value = requireContext().settings().loginsListSortOrder, + default = LoginsSortOrder.Alphabetical, + ), + ), + middleware = listOf( + LoginsMiddleware( + loginsStorage = requireContext().components.core.passwordsStorage, + getNavController = { lifecycleHolder.composeNavController }, + exitLogins = { lifecycleHolder.navController.popBackStack() }, + persistLoginsSortOrder = { + DefaultSavedLoginsStorage(requireContext().settings()).savedLoginsSortOrder = + it + }, + openTab = { url, openInNewTab -> + lifecycleHolder.homeActivity.openToBrowserAndLoad( + searchTermOrURL = url, + newTab = openInNewTab, + from = BrowserDirection.FromSavedLoginsFragment, + flags = EngineSession.LoadUrlFlags.select( + EngineSession.LoadUrlFlags.ALLOW_JAVASCRIPT_URL, + ), + ) + }, + ), + ), + lifecycleHolder = lifecycleHolder, + ) + } + + store.lifecycleHolder?.apply { + this.navController = this@SavedLoginsFragment.findNavController() + this.composeNavController = navController + this.homeActivity = (requireActivity() as HomeActivity) + this.context = requireContext() + } + + store + } + setContent { + FirefoxTheme { + SavedLoginsScreen(buildStore = buildStore) + } + } + } + } val view = inflater.inflate(R.layout.fragment_saved_logins, container, false) _binding = FragmentSavedLoginsBinding.bind(view) @@ -169,6 +252,10 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (requireContext().settings().enableComposeLogins) { + return + } + setFragmentResultListener(LoginDetailFragment.LOGIN_REQUEST_KEY) { _, bundle -> removedLoginGuid = bundle.getString(LoginDetailFragment.LOGIN_BUNDLE_ARGS) deletedGuid.add(removedLoginGuid.toString()) @@ -251,6 +338,11 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider { * If we pause this fragment, we want to pop users back to reauth */ override fun onPause() { + if (requireContext().settings().enableComposeLogins) { + super.onPause() + return + } + toolbarChildContainer.removeAllViews() toolbarChildContainer.visibility = View.GONE (activity as HomeActivity).getSupportActionBarAndInflateIfNecessary() diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/DefaultSavedLoginsStorage.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/DefaultSavedLoginsStorage.kt new file mode 100644 index 000000000000..f7a96fc06bd6 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/DefaultSavedLoginsStorage.kt @@ -0,0 +1,32 @@ +/* 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.ui + +import org.mozilla.fenix.utils.Settings + +/** + * An interface to persist the state of the saved logins screen. + */ +interface SavedLoginsStorage { + /** + * Indicates the sort order of the saved logins list. + */ + var savedLoginsSortOrder: LoginsSortOrder +} + +/** + * A default implementation of `SavedLoginsStorage`. + * + * @property settings The settings object used to persist the saved logins screen state. + */ +class DefaultSavedLoginsStorage( + val settings: Settings, +) : SavedLoginsStorage { + override var savedLoginsSortOrder + get() = LoginsSortOrder.fromString(settings.loginsListSortOrder) + set(value) { + settings.loginsListSortOrder = value.asString + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginItem.kt new file mode 100644 index 000000000000..a6d76e6e0482 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginItem.kt @@ -0,0 +1,22 @@ +/* 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.ui + +/** + * An item representing a saved login + * + * @property guid The id of the login. + * @property url The site where the login is created. + * @property username The username of the login. + * @property password The password of the login. + * @property timeLastUsed The time in milliseconds when the login was last used. + */ +data class LoginItem( + val guid: String, + val url: String, + val username: String, + val password: String, + val timeLastUsed: Long = 0L, +) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsAction.kt new file mode 100644 index 000000000000..f0884cfdc82d --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsAction.kt @@ -0,0 +1,85 @@ +/* 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.ui + +import mozilla.components.lib.state.Action + +/** + * Actions relating to the Logins list screen and its various subscreens. + */ +internal sealed interface LoginsAction : Action + +/** + * The Store is initializing. + */ +internal data object Init : LoginsAction +internal data class InitDetails(val guid: String) : LoginsAction +internal data class InitEdit(val guid: String) : LoginsAction +internal data class InitEditLoaded( + val login: LoginItem, +) : LoginsAction + +internal data object InitAdd : LoginsAction +internal data class InitAddLoaded( + val login: LoginItem, +) : LoginsAction + +internal data object ViewDisposed : LoginsAction +internal data object LoginsListBackClicked : LoginsAction + +/** + * Logins have been loaded from the storage layer. + * + * @property loginItems The login items loaded, transformed into a displayable type. + */ +internal data class LoginsLoaded( + val loginItems: List, +) : LoginsAction + +internal sealed class LoginsListSortMenuAction : LoginsAction { + data object OrderByNameClicked : LoginsListSortMenuAction() + data object OrderByLastUsedClicked : LoginsListSortMenuAction() +} + +internal sealed class DetailLoginMenuAction : LoginsAction { + data class EditLoginMenuItemClicked(val item: LoginItem) : DetailLoginMenuAction() + data class DeleteLoginMenuItemClicked(val item: LoginItem) : DetailLoginMenuAction() +} + +internal data class SearchLogins(val searchText: String, val loginItems: List) : + LoginsAction + +internal data class LoginClicked(val item: LoginItem) : LoginsAction +internal data object LearnMoreAboutSync : LoginsAction + +internal sealed class EditLoginAction : LoginsAction { + data class UsernameChanged(val usernameChanged: String) : EditLoginAction() + data class PasswordChanged(val passwordChanged: String) : EditLoginAction() + data class PasswordVisible(val visible: Boolean) : EditLoginAction() + data object UsernameClearClicked : EditLoginAction() + data object PasswordClearClicked : EditLoginAction() + data class SaveEditClicked(val login: LoginItem) : EditLoginAction() + data object BackEditClicked : EditLoginAction() +} + +internal sealed class AddLoginAction : LoginsAction { + data class UrlChanged(val urlChanged: String) : AddLoginAction() + data class UsernameChanged(val usernameChanged: String) : AddLoginAction() + data class PasswordChanged(val passwordChanged: String) : AddLoginAction() + data object UrlClearClicked : AddLoginAction() + data object UsernameClearClicked : AddLoginAction() + data object PasswordClearClicked : AddLoginAction() + data object SaveAddClicked : AddLoginAction() + data object BackAddClicked : AddLoginAction() +} + +internal sealed class DetailLoginAction : LoginsAction { + data object OptionsMenuClicked : DetailLoginAction() + data class GoToSiteClicked(val url: String) : DetailLoginAction() + data class CopyUsernameClicked(val username: String) : DetailLoginAction() + data class CopyPasswordClicked(val password: String) : DetailLoginAction() + data class PasswordVisibleClicked(val visible: Boolean) : DetailLoginAction() + data object BackDetailClicked : DetailLoginAction() +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsMiddleware.kt new file mode 100644 index 000000000000..6b9980d140c6 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsMiddleware.kt @@ -0,0 +1,144 @@ +/* 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.ui + +import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.concept.storage.LoginsStorage +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.Store +import org.mozilla.fenix.settings.SupportUtils + +/** + * A middleware for handling side-effects in response to [LoginsAction]s. + * + * @param loginsStorage Storage layer for reading and writing logins. + * @param getNavController Fetch the NavController for navigating within the local Composable nav graph. + * @param exitLogins Invoked when back is clicked while the navController's backstack is empty. + * @param persistLoginsSortOrder Invoked to persist the new sorting order for logins. + * @param openTab Invoked when opening a tab when a login url is clicked. + * @param ioDispatcher Coroutine dispatcher for IO operations. + */ +@Suppress("LongParameterList") +internal class LoginsMiddleware( + private val loginsStorage: LoginsStorage, + private val getNavController: () -> NavController, + private val exitLogins: () -> Unit, + private val persistLoginsSortOrder: suspend (LoginsSortOrder) -> Unit, + private val openTab: (url: String, openInNewTab: Boolean) -> Unit, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : Middleware { + + private val scope = CoroutineScope(ioDispatcher) + + @Suppress("LongMethod", "ComplexMethod") + override fun invoke( + context: MiddlewareContext, + next: (LoginsAction) -> Unit, + action: LoginsAction, + ) { + next(action) + + when (action) { + Init -> { + context.store.loadLoginsList() + } + is InitEdit -> scope.launch { + Result.runCatching { + val login = loginsStorage.get(action.guid) + val loginItem = login?.let { + LoginItem( + guid = it.guid, + url = it.formActionOrigin ?: "", + username = it.username, + password = it.password, + ) + } + InitEditLoaded(login = loginItem!!) + }.getOrNull()?.also { + context.store.dispatch(it) + } + } + is InitAdd -> { + getNavController().navigate(LoginsDestinations.ADD_LOGIN) + } + is LoginClicked -> { + getNavController().navigate(LoginsDestinations.LOGIN_DETAILS) + } + is SearchLogins -> { + context.store.loadLoginsList() + } + is LoginsListBackClicked -> exitLogins() + is DetailLoginMenuAction.EditLoginMenuItemClicked -> getNavController().navigate( + LoginsDestinations.EDIT_LOGIN, + ) + is DetailLoginMenuAction.DeleteLoginMenuItemClicked -> { + scope.launch { + loginsStorage.delete(action.item.guid) + } + } + is LoginsListSortMenuAction -> scope.launch { + persistLoginsSortOrder(context.store.state.sortOrder) + } + is LearnMoreAboutSync -> { + openTab( + SupportUtils.getGenericSumoURLForTopic(SupportUtils.SumoTopic.SYNC_SETUP), + true, + ) + } + is DetailLoginAction.GoToSiteClicked -> { + openTab(action.url, true) + } + is InitEditLoaded, + is EditLoginAction.UsernameChanged, + is AddLoginAction.BackAddClicked, + is DetailLoginAction.BackDetailClicked, + is EditLoginAction.BackEditClicked, + is DetailLoginAction.CopyPasswordClicked, + is DetailLoginAction.CopyUsernameClicked, + is InitAddLoaded, + is InitDetails, + is LoginsLoaded, + is DetailLoginAction.OptionsMenuClicked, + is EditLoginAction.PasswordChanged, + is AddLoginAction.PasswordChanged, + is EditLoginAction.PasswordClearClicked, + is AddLoginAction.PasswordClearClicked, + is EditLoginAction.PasswordVisible, + is DetailLoginAction.PasswordVisibleClicked, + is AddLoginAction.SaveAddClicked, + is EditLoginAction.SaveEditClicked, + is AddLoginAction.UrlChanged, + is AddLoginAction.UrlClearClicked, + is AddLoginAction.UsernameChanged, + is EditLoginAction.UsernameClearClicked, + is AddLoginAction.UsernameClearClicked, + is ViewDisposed, + -> Unit + } + } + + private fun Store.loadLoginsList() = scope.launch { + val loginItems = arrayListOf() + val items = loginsStorage.list() + items.forEach { login -> + loginItems.add( + LoginItem( + guid = login.guid, + url = login.origin, + username = login.username, + password = login.password, + timeLastUsed = login.timeLastUsed, + ), + ) + } + + dispatch(LoginsLoaded(loginItems)) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsReducer.kt new file mode 100644 index 000000000000..0fd45f22e283 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsReducer.kt @@ -0,0 +1,126 @@ +/* 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.ui + +/** + * Function for reducing a new logins state based on the received action. + */ +internal fun loginsReducer(state: LoginsState, action: LoginsAction) = when (action) { + is InitEditLoaded -> state.copy( + loginsEditLoginState = LoginsEditLoginState( + login = action.login, + ), + ) + is LoginsLoaded -> { + state.handleLoginsLoadedAction(action) + } + is LoginsListSortMenuAction -> { + state.handleSortMenuAction(action) + } + is SearchLogins -> { + state.handleSearchLogins(action) + } + is LoginClicked -> if (state.loginItems.isNotEmpty()) { + state.toggleSelectionOf(action.item) + } else { + state + } + is EditLoginAction -> state.loginsEditLoginState?.let { + state.copy(loginsEditLoginState = it.handleEditLoginAction(action)) + } ?: state + is AddLoginAction -> state.loginsAddLoginState?.let { + state.copy(loginsAddLoginState = handleAddLoginAction(action)) + } ?: state + is DetailLoginAction -> state.loginsLoginDetailState?.let { + state.copy(loginsLoginDetailState = handleDetailLoginAction(action)) + } ?: state + is DetailLoginMenuAction -> state + is LoginsListBackClicked -> state.respondToLoginsListBackClick() + ViewDisposed, + is InitEdit, Init, InitAdd, LearnMoreAboutSync, is InitDetails, is InitAddLoaded, + -> state +} + +private fun LoginsState.handleSearchLogins(action: SearchLogins): LoginsState = copy( + searchText = action.searchText, + loginItems = action.loginItems.filter { + it.url.contains( + action.searchText, + ignoreCase = true, + ) + }, +) + +private fun LoginsState.handleLoginsLoadedAction(action: LoginsLoaded): LoginsState = + copy( + loginItems = if (searchText.isNullOrEmpty()) { + action.loginItems.sortedWith(sortOrder.comparator) + } else { + action.loginItems.sortedWith(sortOrder.comparator) + .filter { it.url.contains(searchText, ignoreCase = true) } + }, + ) + +private fun LoginsState.toggleSelectionOf(item: LoginItem): LoginsState = + if (loginItems.any { it.guid == item.guid }) { + copy(loginItems = loginItems - item) + } else { + copy(loginItems = loginItems + item) + } + +private fun LoginsState.respondToLoginsListBackClick(): LoginsState = when { + loginsListState != null -> copy(loginsListState = null) + else -> this +} + +private fun LoginsState.handleSortMenuAction(action: LoginsListSortMenuAction): LoginsState = + when (action) { + LoginsListSortMenuAction.OrderByLastUsedClicked -> copy(sortOrder = LoginsSortOrder.LastUsed) + LoginsListSortMenuAction.OrderByNameClicked -> copy(sortOrder = LoginsSortOrder.Alphabetical) + }.let { + it.copy( + loginItems = it.loginItems.sortedWith(it.sortOrder.comparator), + ) + } + +private fun LoginsEditLoginState.handleEditLoginAction(action: EditLoginAction): LoginsEditLoginState? = + when (action) { + is EditLoginAction.UsernameChanged -> copy( + login = login.copy(password = action.usernameChanged), + ) + is EditLoginAction.PasswordChanged -> copy( + login = login.copy(password = action.passwordChanged), + ) + is EditLoginAction.UsernameClearClicked, + is EditLoginAction.PasswordClearClicked, + is EditLoginAction.PasswordVisible, + is EditLoginAction.SaveEditClicked, + is EditLoginAction.BackEditClicked, + -> null + } + +private fun handleAddLoginAction(action: AddLoginAction): LoginsAddLoginState? = + when (action) { + is AddLoginAction.UrlChanged, + is AddLoginAction.PasswordChanged, + is AddLoginAction.UsernameChanged, + is AddLoginAction.UrlClearClicked, + is AddLoginAction.PasswordClearClicked, + is AddLoginAction.UsernameClearClicked, + is AddLoginAction.SaveAddClicked, + is AddLoginAction.BackAddClicked, + -> null + } + +private fun handleDetailLoginAction(action: DetailLoginAction): LoginsLoginDetailState? = + when (action) { + is DetailLoginAction.OptionsMenuClicked, + is DetailLoginAction.GoToSiteClicked, + is DetailLoginAction.CopyUsernameClicked, + is DetailLoginAction.CopyPasswordClicked, + is DetailLoginAction.PasswordVisibleClicked, + is DetailLoginAction.BackDetailClicked, + -> null + } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsState.kt new file mode 100644 index 000000000000..6ddb3012e569 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsState.kt @@ -0,0 +1,112 @@ +/* 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.ui + +import mozilla.components.lib.state.State + +/** + * Represents the state of the Logins list screen and its various subscreens. + * + * @property loginItems Login items to be displayed in the current list screen. + * @property searchText The text to filter login items. + * @property sortOrder The order to display the login items. + * @property biometricAuthenticationDialogState State representing the biometric authentication state. + * @property loginsListState State representing the list login subscreen, if visible. + * @property loginsAddLoginState State representing the add login subscreen, if visible. + * @property loginsEditLoginState State representing the edit login subscreen, if visible. + * @property loginsLoginDetailState State representing the login detail subscreen, if visible. + * @property loginsDeletionDialogState State representing the deletion dialog state. + */ +internal data class LoginsState( + val loginItems: List = listOf(), + val searchText: String? = null, + val sortOrder: LoginsSortOrder = LoginsSortOrder.default, + val biometricAuthenticationDialogState: BiometricAuthenticationDialogState? = + BiometricAuthenticationDialogState.None, + val loginsListState: LoginsListState? = null, + val loginsAddLoginState: LoginsAddLoginState? = null, + val loginsEditLoginState: LoginsEditLoginState? = null, + val loginsLoginDetailState: LoginsLoginDetailState? = null, + val loginsDeletionDialogState: DeletionDialogState? = null, +) : State + +internal sealed class BiometricAuthenticationDialogState { + data object None : BiometricAuthenticationDialogState() + data object Authorized : BiometricAuthenticationDialogState() + data object NonAuthorized : BiometricAuthenticationDialogState() +} + +internal sealed class DeletionDialogState { + data object None : DeletionDialogState() + data class Presenting( + val guidToDelete: String, + ) : DeletionDialogState() +} + +internal data class LoginsListState( + val logins: List, +) + +internal data class LoginsEditLoginState( + val login: LoginItem, +) + +internal sealed class LoginsAddLoginState { + data object None : LoginsAddLoginState() + data class Presenting(val login: LoginItem) : LoginsAddLoginState() +} + +internal data class LoginsLoginDetailState( + val login: LoginItem, +) + +/** + * Represents the order of the Logins list items. + */ +sealed class LoginsSortOrder { + abstract val asString: String + abstract val comparator: Comparator + + /** + * Represents the ordering of the logins list when sorted alphabetically. + */ + data object Alphabetical : LoginsSortOrder() { + override val asString: String + get() = "alphabetical" + + override val comparator: Comparator + get() = compareBy { it.url } + } + + /** + * Represents the ordering of the logins list when sorted by the last used date. + */ + data object LastUsed : LoginsSortOrder() { + override val asString: String + get() = "last-used" + + override val comparator: Comparator + get() = compareByDescending { it.timeLastUsed } + } + + /** + * Represents the [LoginsSortOrder] object. + */ + companion object { + val default: LoginsSortOrder + get() = Alphabetical + + /** + * Converts a string into a [LoginsSortOrder] object. + */ + fun fromString(value: String, default: LoginsSortOrder = Alphabetical): LoginsSortOrder { + return when (value) { + "alphabetical" -> Alphabetical + "last-used" -> LastUsed + else -> default + } + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsStore.kt new file mode 100644 index 000000000000..16e64c222350 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/LoginsStore.kt @@ -0,0 +1,36 @@ +/* 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.ui + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.Reducer +import mozilla.components.lib.state.UiStore +import org.mozilla.fenix.lifecycle.LifecycleHolder + +/** + * A Store for handling [LoginsState] and dispatching [LoginsAction]. + * + * @param initialState The initial state for the Store. + * @param reducer Reducer to handle state updates based on dispatched actions. + * @param middleware Middleware to handle side-effects in response to dispatched actions. + * @property lifecycleHolder a hack to box the references to objects that get recreated with the activity. + * @param loginToLoad The guid of a login to load when landing on the edit/details screen. + */ +internal class LoginsStore( + initialState: LoginsState = LoginsState(), + reducer: Reducer = ::loginsReducer, + middleware: List> = listOf(), + val lifecycleHolder: LifecycleHolder? = null, + loginToLoad: String? = null, +) : UiStore( + initialState = initialState, + reducer = reducer, + middleware = middleware, +) { + init { + val action = loginToLoad?.let { InitEdit(loginToLoad) } ?: Init + dispatch(action) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/SavedLoginsScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/SavedLoginsScreen.kt new file mode 100644 index 000000000000..96278cf2323b --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/logins/ui/SavedLoginsScreen.kt @@ -0,0 +1,426 @@ +/* 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.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Icon +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CollectionInfo +import androidx.compose.ui.semantics.collectionInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import mozilla.components.compose.base.annotation.FlexibleWindowLightDarkPreview +import mozilla.components.compose.base.button.IconButton +import mozilla.components.compose.base.menu.DropdownMenu +import mozilla.components.compose.base.menu.MenuItem +import mozilla.components.compose.base.textfield.TextField +import mozilla.components.compose.base.textfield.TextFieldColors +import mozilla.components.lib.state.ext.observeAsState +import mozilla.components.support.ktx.kotlin.trimmed +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.LinkText +import org.mozilla.fenix.compose.LinkTextState +import org.mozilla.fenix.compose.list.IconListItem +import org.mozilla.fenix.compose.list.SelectableFaviconListItem +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * The UI host for the Saved Logins list screen and related sub screens. + * + * @param buildStore A builder function to construct a [LoginsStore] using the NavController that's local + * to the nav graph for the Logins view hierarchy. + * @param startDestination the screen on which to initialize [SavedLoginsScreen] with. + */ +@Composable +internal fun SavedLoginsScreen( + buildStore: (NavHostController) -> LoginsStore, + startDestination: String = LoginsDestinations.LIST, +) { + val navController = rememberNavController() + val store = buildStore(navController) + + DisposableEffect(LocalLifecycleOwner.current) { + onDispose { + store.dispatch(ViewDisposed) + } + } + + NavHost( + navController = navController, + startDestination = startDestination, + ) { + composable(route = LoginsDestinations.LIST) { + BackHandler { store.dispatch(LoginsListBackClicked) } + LoginsList(store = store) + } + composable(route = LoginsDestinations.ADD_LOGIN) { + BackHandler { store.dispatch(AddLoginAction.BackAddClicked) } + } + composable(route = LoginsDestinations.EDIT_LOGIN) { + BackHandler { store.dispatch(EditLoginAction.BackEditClicked) } + } + composable(route = LoginsDestinations.LOGIN_DETAILS) { + BackHandler { store.dispatch(DetailLoginAction.BackDetailClicked) } + } + } +} + +internal object LoginsDestinations { + const val LIST = "list" + const val ADD_LOGIN = "add login" + const val EDIT_LOGIN = "edit login" + const val LOGIN_DETAILS = "login details" +} + +@Composable +private fun LoginsList(store: LoginsStore) { + val state by store.observeAsState(store.state) { it } + + Scaffold( + topBar = { + LoginsListTopBar( + store = store, + text = state.searchText ?: "", + ) + }, + backgroundColor = FirefoxTheme.colors.layer1, + ) { paddingValues -> + + if (state.searchText.isNullOrEmpty() && state.loginItems.isEmpty()) { + EmptyList(dispatcher = store::dispatch) + return@Scaffold + } + + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .padding(vertical = 16.dp) + .semantics { + collectionInfo = + CollectionInfo(rowCount = state.loginItems.size, columnCount = 1) + }, + ) { + itemsIndexed(state.loginItems) { _, item -> + + SelectableFaviconListItem( + label = item.url.trimmed(), + url = item.url, + isSelected = false, + onClick = { store.dispatch(LoginClicked(item)) }, + description = item.username.trimmed(), + ) + } + + item { + AddPasswordItem( + onAddPasswordClicked = { store.dispatch(InitAdd) }, + ) + } + } + } +} + +@Composable +private fun AddPasswordItem( + onAddPasswordClicked: () -> Unit, +) { + IconListItem( + label = stringResource(R.string.preferences_logins_add_login_2), + beforeIconPainter = painterResource(R.drawable.ic_new), + onClick = { onAddPasswordClicked() }, + ) +} + +@Composable +@Suppress("MaxLineLength") +private fun EmptyList( + dispatcher: (LoginsAction) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxSize(), + contentAlignment = Alignment.TopStart, + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier.padding(16.dp), + ) { + Text( + text = String.format( + stringResource(R.string.preferences_passwords_saved_logins_description_empty_text_2), + stringResource(R.string.app_name), + ), + style = FirefoxTheme.typography.body2, + color = FirefoxTheme.colors.textPrimary, + ) + + LinkText( + text = stringResource(R.string.preferences_passwords_saved_logins_description_empty_learn_more_link_2), + linkTextStates = listOf( + LinkTextState( + text = stringResource(R.string.preferences_passwords_saved_logins_description_empty_learn_more_link_2), + url = "", + onClick = { dispatcher(LearnMoreAboutSync) }, + ), + ), + style = FirefoxTheme.typography.body2.copy( + color = FirefoxTheme.colors.textPrimary, + ), + linkTextColor = FirefoxTheme.colors.textPrimary, + linkTextDecoration = TextDecoration.Underline, + ) + + AddPasswordItem( + onAddPasswordClicked = { dispatcher(InitAdd) }, + ) + } + } +} + +@Composable +@Suppress("LongMethod") +private fun LoginsListTopBar( + store: LoginsStore, + text: String, +) { + var showMenu by remember { mutableStateOf(false) } + var searchActive by remember { mutableStateOf(false) } + + val iconColor = FirefoxTheme.colors.iconPrimary + + Box { + TopAppBar( + backgroundColor = FirefoxTheme.colors.layer1, + title = { + if (!searchActive) { + Text( + color = FirefoxTheme.colors.textPrimary, + style = FirefoxTheme.typography.headline6, + text = stringResource(R.string.preferences_passwords_saved_logins_2), + ) + } + }, + navigationIcon = { + IconButton( + onClick = { + if (!searchActive) { + store.dispatch(LoginsListBackClicked) + } else { + searchActive = false + } + }, + contentDescription = null, + ) { + Icon( + painter = painterResource(R.drawable.mozac_ic_back_24), + contentDescription = stringResource(R.string.logins_navigate_back_button_content_description), + tint = iconColor, + ) + } + }, + actions = { + if (!searchActive) { + Box { + Icon( + modifier = Modifier + .clickable { + showMenu = true + }, + painter = if (showMenu) { + painterResource(R.drawable.ic_chevron_up) + } else { + painterResource(R.drawable.ic_chevron_down) + }, + contentDescription = stringResource( + R.string.saved_logins_menu_dropdown_chevron_icon_content_description_2, + ), + tint = iconColor, + ) + + LoginListSortMenu( + showMenu = showMenu, + onDismissRequest = { + showMenu = false + }, + store = store, + ) + } + IconButton(onClick = { searchActive = true }, contentDescription = null) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = stringResource(R.string.preferences_passwords_saved_logins_search_2), + tint = iconColor, + ) + } + } else { + Box { + TextField( + value = text, + placeholder = stringResource(R.string.preferences_passwords_saved_logins_search_2), + onValueChange = { + store.dispatch(SearchLogins(it, store.state.loginItems)) + }, + errorText = "", + modifier = Modifier + .background(color = FirefoxTheme.colors.layer1) + .fillMaxWidth(), + trailingIcons = { + if (text.isNotBlank()) { + IconButton( + onClick = { + store.dispatch( + SearchLogins( + "", + store.state.loginItems, + ), + ) + }, + contentDescription = null, + ) { + Icon( + painter = painterResource(R.drawable.mozac_ic_cross_24), + contentDescription = null, + tint = iconColor, + ) + } + } + }, + colors = TextFieldColors.default( + placeholderColor = FirefoxTheme.colors.textPrimary, + cursorColor = Color.DarkGray, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + ) + } + } + }, + ) + } +} + +@Composable +private fun LoginListSortMenu( + showMenu: Boolean, + onDismissRequest: () -> Unit, + store: LoginsStore, +) { + val sortOrder by store.observeAsState(store.state.sortOrder) { store.state.sortOrder } + DropdownMenu( + menuItems = listOf( + MenuItem.CheckableItem( + text = mozilla.components.compose.base.text.Text.Resource( + R.string.saved_logins_sort_strategy_alphabetically, + ), + onClick = { store.dispatch(LoginsListSortMenuAction.OrderByNameClicked) }, + isChecked = sortOrder == LoginsSortOrder.Alphabetical, + ), + MenuItem.CheckableItem( + text = mozilla.components.compose.base.text.Text.Resource( + R.string.saved_logins_sort_strategy_last_used, + ), + onClick = { store.dispatch(LoginsListSortMenuAction.OrderByLastUsedClicked) }, + isChecked = sortOrder == LoginsSortOrder.LastUsed, + ), + ), + expanded = showMenu, + onDismissRequest = onDismissRequest, + ) +} + +private const val LOGINS_LIST_SIZE = 15 + +@Composable +@FlexibleWindowLightDarkPreview +private fun LoginsListScreenPreview() { + val loginItems = List(LOGINS_LIST_SIZE) { + LoginItem( + guid = "$it", + url = "https://www.justanothersite$it.com", + username = "username $it", + password = "password $it", + ) + } + + val store = { _: NavHostController -> + LoginsStore( + initialState = LoginsState( + loginItems = loginItems, + searchText = "", + sortOrder = LoginsSortOrder.default, + biometricAuthenticationDialogState = null, + loginsListState = null, + loginsAddLoginState = null, + loginsEditLoginState = null, + loginsLoginDetailState = null, + loginsDeletionDialogState = null, + ), + ) + } + + FirefoxTheme { + Box(modifier = Modifier.background(color = FirefoxTheme.colors.layer1)) { + SavedLoginsScreen(store) + } + } +} + +@Composable +@FlexibleWindowLightDarkPreview +private fun EmptyLoginsListScreenPreview() { + val store = { _: NavHostController -> + LoginsStore( + initialState = LoginsState( + loginItems = listOf(), + searchText = "", + sortOrder = LoginsSortOrder.default, + biometricAuthenticationDialogState = null, + loginsListState = null, + loginsAddLoginState = null, + loginsEditLoginState = null, + loginsLoginDetailState = null, + loginsDeletionDialogState = null, + ), + ) + } + + FirefoxTheme { + Box(modifier = Modifier.background(color = FirefoxTheme.colors.layer1)) { + SavedLoginsScreen(store) + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index c24eeb56c0c1..09ac34db9892 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -2356,6 +2356,11 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = false, ) + var loginsListSortOrder by stringPreference( + key = appContext.getPreferenceKey(R.string.pref_key_logins_list_sort_order), + default = "", + ) + /** * Indicates whether or not to show the entry point for the DNS over HTTPS settings */ diff --git a/mobile/android/fenix/app/src/main/res/values/preference_keys.xml b/mobile/android/fenix/app/src/main/res/values/preference_keys.xml index 97c268676ff6..ee13e450db44 100644 --- a/mobile/android/fenix/app/src/main/res/values/preference_keys.xml +++ b/mobile/android/fenix/app/src/main/res/values/preference_keys.xml @@ -407,7 +407,6 @@ pref_key_enable_compose_top_sites pref_key_enable_compose_homepage pref_key_enable_homepage_searchbar - pref_key_enable_compose_logins pref_key_enable_menu_redesign pref_key_enable_homepage_as_new_tab pref_key_enable_unified_trust_panel @@ -416,6 +415,10 @@ pref_key_enable_shortcuts_suggestions pref_key_enable_composable_toolbar" + + pref_key_enable_compose_logins + pref_key_logins_list_sort_order + pref_key_growth_set_as_default pref_key_growth_first_week_series_sent diff --git a/mobile/android/fenix/app/src/main/res/values/strings.xml b/mobile/android/fenix/app/src/main/res/values/strings.xml index 6e87ab428a3d..f9745758d6ad 100644 --- a/mobile/android/fenix/app/src/main/res/values/strings.xml +++ b/mobile/android/fenix/app/src/main/res/values/strings.xml @@ -2299,6 +2299,8 @@ Delete all exceptions Search passwords + + Back Site diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/ui/LoginsMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/ui/LoginsMiddlewareTest.kt new file mode 100644 index 000000000000..03ee3f9a3090 --- /dev/null +++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/ui/LoginsMiddlewareTest.kt @@ -0,0 +1,123 @@ +/* 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.ui + +import android.content.ClipboardManager +import androidx.navigation.NavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.storage.Login +import mozilla.components.concept.storage.LoginsStorage +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.mock +import mozilla.components.support.test.rule.MainCoroutineRule +import mozilla.components.support.test.rule.runTestOnMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` + +@RunWith(AndroidJUnit4::class) +class LoginsMiddlewareTest { + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private lateinit var loginsStorage: LoginsStorage + private lateinit var clipboardManager: ClipboardManager + private lateinit var navController: NavController + private lateinit var exitLogins: () -> Unit + private lateinit var openTab: (String, Boolean) -> Unit + private lateinit var persistLoginsSortOrder: suspend (LoginsSortOrder) -> Unit + + private val loginList = List(5) { + Login(guid = "guid$it", origin = "origin$it", username = "username$it", password = "password$it") + } + + @Before + fun setup() { + loginsStorage = mock() + clipboardManager = mock() + navController = mock() + exitLogins = { } + openTab = { _, _ -> } + persistLoginsSortOrder = { } + } + + @Test + fun `GIVEN no logins in storage WHEN store is initialized THEN list of logins will be empty`() = runTestOnMain { + `when`(loginsStorage.list()).thenReturn(listOf()) + val middleware = buildMiddleware() + val store = middleware.makeStore() + store.waitUntilIdle() + + assertEquals(0, store.state.loginItems.size) + } + + @Test + fun `GIVEN current screen is list logins WHEN add password is clicked THEN navigate to add login screen`() { + val middleware = buildMiddleware() + val store = middleware.makeStore() + store.dispatch(InitAdd) + store.waitUntilIdle() + verify(navController).navigate(LoginsDestinations.ADD_LOGIN) + } + + @Test + fun `GIVEN current screen is list logins WHEN a login is clicked THEN navigate to edit login screen`() { + val middleware = buildMiddleware() + val store = middleware.makeStore() + + store.dispatch( + DetailLoginMenuAction.EditLoginMenuItemClicked( + LoginItem( + guid = "guid1", + url = "url1", + username = "u1", + password = "p1", + timeLastUsed = 0L, + ), + ), + ) + store.waitUntilIdle() + + verify(navController).navigate(LoginsDestinations.EDIT_LOGIN) + } + + @Test + fun `GIVEN current screen is list and the top-level is loaded WHEN back is clicked THEN exit logins`() = runTestOnMain { + `when`(loginsStorage.list()).thenReturn(loginList) + var exited = false + exitLogins = { exited = true } + val middleware = buildMiddleware() + val store = middleware.makeStore() + + store.dispatch(LoginsListBackClicked) + store.waitUntilIdle() + + assertTrue(exited) + } + + private fun buildMiddleware() = LoginsMiddleware( + loginsStorage = loginsStorage, + getNavController = { navController }, + exitLogins = exitLogins, + openTab = openTab, + ioDispatcher = coroutineRule.testDispatcher, + persistLoginsSortOrder = persistLoginsSortOrder, + ) + + private fun LoginsMiddleware.makeStore( + initialState: LoginsState = LoginsState(), + ) = LoginsStore( + initialState = initialState, + middleware = listOf(this), + ).also { + it.waitUntilIdle() + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/ui/LoginsReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/ui/LoginsReducerTest.kt new file mode 100644 index 000000000000..c6ef5f7563bb --- /dev/null +++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/logins/ui/LoginsReducerTest.kt @@ -0,0 +1,117 @@ +/* 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.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class LoginsReducerTest { + @Test + fun `WHEN store initializes THEN no changes to state`() { + val state = LoginsState() + + assertEquals(state, loginsReducer(state, Init)) + } + + @Test + fun `WHEN logins are loaded THEN they are added to state`() { + val state = LoginsState() + val items = List(5) { + LoginItem( + guid = "$it", + url = "url", + username = "user$it", + password = "pass$it", + timeLastUsed = System.currentTimeMillis(), + ) + } + + val result = loginsReducer( + state, + LoginsLoaded( + loginItems = items, + ), + ) + + val expected = state.copy( + loginItems = items, + ) + assertEquals(expected, result) + } + + @Test + fun `GIVEN we are on the list logins screen WHEN add login is clicked THEN initialize the add login state`() { + val state = LoginsState().copy(loginsAddLoginState = LoginsAddLoginState.None) + + val result = loginsReducer(state, InitAdd) + + assertEquals(LoginsAddLoginState.None, result.loginsAddLoginState) + } + + @Test + fun `GIVEN there is no substate screen present WHEN back is clicked THEN state is unchanged`() { + val state = LoginsState() + + val result = loginsReducer(state, LoginsListBackClicked) + + assertEquals(LoginsState(), result) + } + + @Test + fun `GIVEN a logins list WHEN the alphabetical sort menu item is clicked THEN sort the logins list`() { + val items = List(3) { + LoginItem( + guid = "$it", + url = "$it url", + username = "user$it", + password = "pass$it", + timeLastUsed = 0L + it, + ) + } + + val state = LoginsState().copy(loginItems = items) + + val alphabetical = loginsReducer(state, LoginsListSortMenuAction.OrderByNameClicked) + assertEquals(listOf(items[0], items[1], items[2]), alphabetical.loginItems) + } + + @Test + fun `GIVEN a logins list WHEN the last used sort menu item is clicked THEN sort the logins list`() { + val items = List(3) { + LoginItem( + guid = "$it", + url = "$it url", + username = "user$it", + password = "pass$it", + timeLastUsed = 0L + it, + ) + } + + val state = LoginsState().copy(loginItems = items) + + val newest = loginsReducer(state, LoginsListSortMenuAction.OrderByLastUsedClicked) + assertEquals(listOf(items[2], items[1], items[0]), newest.loginItems) + } + + @Test + fun `GIVEN a logins list WHEN the search is used THEN filter the logins list`() { + val items = List(7) { + LoginItem( + guid = "$it", + url = if (it % 2 == 0) "$it url" else "$it uri", + username = "user$it", + password = "pass$it", + timeLastUsed = System.currentTimeMillis(), + ) + } + + val state = LoginsState().copy(loginItems = items) + + val filterUrl = loginsReducer(state, SearchLogins("url", items)) + assertEquals("url", filterUrl.searchText) + assertEquals(4, filterUrl.loginItems.size) + assertEquals(listOf(items[0], items[2], items[4], items[6]), filterUrl.loginItems) + } +}