Revert "Bug 1952024: composify logins list screen r=android-reviewers,android-l10n-reviewers,sfamisa,delphine" for causing fenix failures @ LoginsMiddlewareTest

This reverts commit 9053b19eaf.
This commit is contained in:
Alexandru Marc
2025-05-20 19:04:31 +03:00
committed by amarc@mozilla.com
parent 03605a6b93
commit 0a48fc8bca
16 changed files with 3 additions and 1357 deletions

View File

@@ -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,11 +68,7 @@ fun IconButton(
val view = LocalView.current
Box(
modifier = modifier
.semantics {
if (contentDescription != null) {
this.contentDescription = contentDescription
}
}
.semantics { this.contentDescription = contentDescription }
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,

View File

@@ -1,25 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.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,
)

View File

@@ -19,8 +19,6 @@ 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
@@ -29,9 +27,7 @@ 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
@@ -44,11 +40,9 @@ 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
@@ -59,14 +53,7 @@ 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 {
@@ -91,9 +78,6 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (requireContext().settings().enableComposeLogins) { return }
startForResult = registerForActivityResult {
BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
false
@@ -105,12 +89,6 @@ class SavedLoginsFragment : SecureFragment(), MenuProvider {
override fun onResume() {
super.onResume()
if (requireContext().settings().enableComposeLogins) {
hideToolbar()
return
}
if (BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt) {
BiometricAuthenticationManager.biometricAuthenticationNeededInfo.shouldShowAuthenticationPrompt =
false
@@ -141,72 +119,11 @@ 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)
@@ -252,10 +169,6 @@ 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())
@@ -338,11 +251,6 @@ 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()

View File

@@ -1,32 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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
}
}

View File

@@ -1,22 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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,
)

View File

@@ -1,85 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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()
}

View File

@@ -1,144 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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))
}
}

View File

@@ -1,126 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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
}

View File

@@ -1,112 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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
}
}
}
}

View File

@@ -1,36 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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)
}
}

View File

@@ -1,426 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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)
}
}
}

View File

@@ -2356,11 +2356,6 @@ 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
*/

View File

@@ -407,6 +407,7 @@
<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_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_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>
@@ -415,10 +416,6 @@
<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>"
<!-- 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 -->
<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>

View File

@@ -2299,8 +2299,6 @@
<string name="preferences_passwords_exceptions_remove_all">Delete all exceptions</string>
<!-- Hint for search box in passwords list -->
<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 -->
<string name="preferences_passwords_saved_logins_site">Site</string>
<!-- The header for the username for a login -->

View File

@@ -1,123 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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()
}
}

View File

@@ -1,117 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.logins.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)
}
}