Bug 1952024: composify logins list screen r=android-reviewers,android-l10n-reviewers,sfamisa,delphine
TRY link:: https://treeherder.mozilla.org/jobs?repo=try&revision=2fde9e4d3e9dab5a107b68b0f82d38dd8a57f44c APPROVED patch before git migration:: https://phabricator.services.mozilla.com/D246556 Differential Revision: https://phabricator.services.mozilla.com/D249986
This commit is contained in:
committed by
avirvara@mozilla.com
parent
71756fd1bc
commit
9053b19eaf
@@ -58,7 +58,7 @@ private val RippleRadius = 24.dp
|
|||||||
@Composable
|
@Composable
|
||||||
fun IconButton(
|
fun IconButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
contentDescription: String,
|
contentDescription: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClickLabel: String? = null,
|
onClickLabel: String? = null,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
@@ -68,7 +68,11 @@ fun IconButton(
|
|||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.semantics { this.contentDescription = contentDescription }
|
.semantics {
|
||||||
|
if (contentDescription != null) {
|
||||||
|
this.contentDescription = contentDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
.minimumInteractiveComponentSize()
|
.minimumInteractiveComponentSize()
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -19,6 +19,8 @@ import androidx.activity.result.ActivityResultLauncher
|
|||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.appcompat.widget.SearchView
|
||||||
import androidx.appcompat.widget.Toolbar
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.core.view.MenuProvider
|
import androidx.core.view.MenuProvider
|
||||||
@@ -27,7 +29,9 @@ import androidx.fragment.app.setFragmentResult
|
|||||||
import androidx.fragment.app.setFragmentResultListener
|
import androidx.fragment.app.setFragmentResultListener
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import mozilla.components.concept.engine.EngineSession
|
||||||
import mozilla.components.concept.menu.MenuController
|
import mozilla.components.concept.menu.MenuController
|
||||||
import mozilla.components.concept.menu.Orientation
|
import mozilla.components.concept.menu.Orientation
|
||||||
import mozilla.components.lib.state.ext.consumeFrom
|
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.components.StoreProvider
|
||||||
import org.mozilla.fenix.databinding.FragmentSavedLoginsBinding
|
import org.mozilla.fenix.databinding.FragmentSavedLoginsBinding
|
||||||
import org.mozilla.fenix.ext.components
|
import org.mozilla.fenix.ext.components
|
||||||
|
import org.mozilla.fenix.ext.hideToolbar
|
||||||
import org.mozilla.fenix.ext.registerForActivityResult
|
import org.mozilla.fenix.ext.registerForActivityResult
|
||||||
import org.mozilla.fenix.ext.settings
|
import org.mozilla.fenix.ext.settings
|
||||||
import org.mozilla.fenix.ext.showToolbar
|
import org.mozilla.fenix.ext.showToolbar
|
||||||
|
import org.mozilla.fenix.lifecycle.LifecycleHolder
|
||||||
import org.mozilla.fenix.settings.biometric.DefaultBiometricUtils
|
import org.mozilla.fenix.settings.biometric.DefaultBiometricUtils
|
||||||
import org.mozilla.fenix.settings.logins.LoginsAction
|
import org.mozilla.fenix.settings.logins.LoginsAction
|
||||||
import org.mozilla.fenix.settings.logins.LoginsFragmentStore
|
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.controller.SavedLoginsStorageController
|
||||||
import org.mozilla.fenix.settings.logins.createInitialLoginsListState
|
import org.mozilla.fenix.settings.logins.createInitialLoginsListState
|
||||||
import org.mozilla.fenix.settings.logins.interactor.SavedLoginsInteractor
|
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.settings.logins.view.SavedLoginsListView
|
||||||
|
import org.mozilla.fenix.theme.FirefoxTheme
|
||||||
|
|
||||||
@SuppressWarnings("TooManyFunctions")
|
@SuppressWarnings("TooManyFunctions")
|
||||||
class SavedLoginsFragment : SecureFragment(), MenuProvider {
|
class SavedLoginsFragment : SecureFragment(), MenuProvider {
|
||||||
@@ -78,6 +91,9 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
if (requireContext().settings().enableComposeLogins) { return }
|
||||||
|
|
||||||
startForResult = registerForActivityResult {
|
startForResult = registerForActivityResult {
|
||||||
BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
|
BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
|
||||||
false
|
false
|
||||||
@@ -89,6 +105,12 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|
||||||
|
if (requireContext().settings().enableComposeLogins) {
|
||||||
|
hideToolbar()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt) {
|
if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt) {
|
||||||
BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
|
BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
|
||||||
false
|
false
|
||||||
@@ -119,11 +141,72 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider {
|
|||||||
initToolbar()
|
initToolbar()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongMethod")
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?,
|
savedInstanceState: Bundle?,
|
||||||
): View? {
|
): 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)
|
val view = inflater.inflate(R.layout.fragment_saved_logins, container, false)
|
||||||
|
|
||||||
_binding = FragmentSavedLoginsBinding.bind(view)
|
_binding = FragmentSavedLoginsBinding.bind(view)
|
||||||
@@ -169,6 +252,10 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
if (requireContext().settings().enableComposeLogins) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setFragmentResultListener(LoginDetailFragment.LOGIN_REQUEST_KEY) { _, bundle ->
|
setFragmentResultListener(LoginDetailFragment.LOGIN_REQUEST_KEY) { _, bundle ->
|
||||||
removedLoginGuid = bundle.getString(LoginDetailFragment.LOGIN_BUNDLE_ARGS)
|
removedLoginGuid = bundle.getString(LoginDetailFragment.LOGIN_BUNDLE_ARGS)
|
||||||
deletedGuid.add(removedLoginGuid.toString())
|
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
|
* If we pause this fragment, we want to pop users back to reauth
|
||||||
*/
|
*/
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
if (requireContext().settings().enableComposeLogins) {
|
||||||
|
super.onPause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
toolbarChildContainer.removeAllViews()
|
toolbarChildContainer.removeAllViews()
|
||||||
toolbarChildContainer.visibility = View.GONE
|
toolbarChildContainer.visibility = View.GONE
|
||||||
(activity as HomeActivity).getSupportActionBarAndInflateIfNecessary()
|
(activity as HomeActivity).getSupportActionBarAndInflateIfNecessary()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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<LoginItem>,
|
||||||
|
) : 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<LoginItem>) :
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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<LoginsState, LoginsAction> {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(ioDispatcher)
|
||||||
|
|
||||||
|
@Suppress("LongMethod", "ComplexMethod")
|
||||||
|
override fun invoke(
|
||||||
|
context: MiddlewareContext<LoginsState, LoginsAction>,
|
||||||
|
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<LoginsState, LoginsAction>.loadLoginsList() = scope.launch {
|
||||||
|
val loginItems = arrayListOf<LoginItem>()
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<LoginItem> = 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<LoginItem>,
|
||||||
|
)
|
||||||
|
|
||||||
|
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<LoginItem>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the ordering of the logins list when sorted alphabetically.
|
||||||
|
*/
|
||||||
|
data object Alphabetical : LoginsSortOrder() {
|
||||||
|
override val asString: String
|
||||||
|
get() = "alphabetical"
|
||||||
|
|
||||||
|
override val comparator: Comparator<LoginItem>
|
||||||
|
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<LoginItem>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoginsState, LoginsAction> = ::loginsReducer,
|
||||||
|
middleware: List<Middleware<LoginsState, LoginsAction>> = listOf(),
|
||||||
|
val lifecycleHolder: LifecycleHolder? = null,
|
||||||
|
loginToLoad: String? = null,
|
||||||
|
) : UiStore<LoginsState, LoginsAction>(
|
||||||
|
initialState = initialState,
|
||||||
|
reducer = reducer,
|
||||||
|
middleware = middleware,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
val action = loginToLoad?.let { InitEdit(loginToLoad) } ?: Init
|
||||||
|
dispatch(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2356,6 +2356,11 @@ class Settings(private val appContext: Context) : PreferencesHolder {
|
|||||||
default = false,
|
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
|
* Indicates whether or not to show the entry point for the DNS over HTTPS settings
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -407,7 +407,6 @@
|
|||||||
<string name="pref_key_enable_compose_top_sites" translatable="false">pref_key_enable_compose_top_sites</string>
|
<string name="pref_key_enable_compose_top_sites" translatable="false">pref_key_enable_compose_top_sites</string>
|
||||||
<string name="pref_key_enable_compose_homepage" translatable="false">pref_key_enable_compose_homepage</string>
|
<string name="pref_key_enable_compose_homepage" translatable="false">pref_key_enable_compose_homepage</string>
|
||||||
<string name="pref_key_enable_homepage_searchbar" translatable="false">pref_key_enable_homepage_searchbar</string>
|
<string name="pref_key_enable_homepage_searchbar" translatable="false">pref_key_enable_homepage_searchbar</string>
|
||||||
<string name="pref_key_enable_compose_logins" translatable="false">pref_key_enable_compose_logins</string>
|
|
||||||
<string name="pref_key_enable_menu_redesign" translatable="false">pref_key_enable_menu_redesign</string>
|
<string name="pref_key_enable_menu_redesign" translatable="false">pref_key_enable_menu_redesign</string>
|
||||||
<string name="pref_key_enable_homepage_as_new_tab" translatable="false">pref_key_enable_homepage_as_new_tab</string>
|
<string name="pref_key_enable_homepage_as_new_tab" translatable="false">pref_key_enable_homepage_as_new_tab</string>
|
||||||
<string name="pref_key_enable_unified_trust_panel" translatable="false">pref_key_enable_unified_trust_panel</string>
|
<string name="pref_key_enable_unified_trust_panel" translatable="false">pref_key_enable_unified_trust_panel</string>
|
||||||
@@ -416,6 +415,10 @@
|
|||||||
<string name="pref_key_enable_shortcuts_suggestions" translatable="false">pref_key_enable_shortcuts_suggestions</string>
|
<string name="pref_key_enable_shortcuts_suggestions" translatable="false">pref_key_enable_shortcuts_suggestions</string>
|
||||||
<string name="pref_key_enable_composable_toolbar" translatable="false">pref_key_enable_composable_toolbar</string>"
|
<string name="pref_key_enable_composable_toolbar" translatable="false">pref_key_enable_composable_toolbar</string>"
|
||||||
|
|
||||||
|
<!-- Logins -->
|
||||||
|
<string name="pref_key_enable_compose_logins" translatable="false">pref_key_enable_compose_logins</string>
|
||||||
|
<string name="pref_key_logins_list_sort_order" translatable="false">pref_key_logins_list_sort_order</string>
|
||||||
|
|
||||||
<!-- Growth Data -->
|
<!-- Growth Data -->
|
||||||
<string name="pref_key_growth_set_as_default" translatable="false">pref_key_growth_set_as_default</string>
|
<string name="pref_key_growth_set_as_default" translatable="false">pref_key_growth_set_as_default</string>
|
||||||
<string name="pref_key_growth_first_week_series_sent" translatable="false">pref_key_growth_first_week_series_sent</string>
|
<string name="pref_key_growth_first_week_series_sent" translatable="false">pref_key_growth_first_week_series_sent</string>
|
||||||
|
|||||||
@@ -2299,6 +2299,8 @@
|
|||||||
<string name="preferences_passwords_exceptions_remove_all">Delete all exceptions</string>
|
<string name="preferences_passwords_exceptions_remove_all">Delete all exceptions</string>
|
||||||
<!-- Hint for search box in passwords list -->
|
<!-- Hint for search box in passwords list -->
|
||||||
<string name="preferences_passwords_saved_logins_search_2">Search passwords</string>
|
<string name="preferences_passwords_saved_logins_search_2">Search passwords</string>
|
||||||
|
<!-- Content description for the logins navigation bar back button -->
|
||||||
|
<string name="logins_navigate_back_button_content_description">Back</string>
|
||||||
<!-- The header for the site that a login is for -->
|
<!-- The header for the site that a login is for -->
|
||||||
<string name="preferences_passwords_saved_logins_site">Site</string>
|
<string name="preferences_passwords_saved_logins_site">Site</string>
|
||||||
<!-- The header for the username for a login -->
|
<!-- The header for the username for a login -->
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user