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:
committed by
amarc@mozilla.com
parent
03605a6b93
commit
0a48fc8bca
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user