mirror of
https://github.com/bitwarden/android.git
synced 2026-08-29 10:17:56 -05:00
[PM-26095] Add account selection screen for Credential Exchange (#5932)
This commit is contained in:
+6
@@ -15,6 +15,9 @@ import com.bitwarden.core.data.manager.BuildInfoManager
|
||||
import com.bitwarden.core.util.isBuildVersionAtLeast
|
||||
import com.bitwarden.cxf.importer.CredentialExchangeImporter
|
||||
import com.bitwarden.cxf.importer.dsl.credentialExchangeImporter
|
||||
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
|
||||
import com.bitwarden.cxf.manager.dsl.credentialExchangeCompletionManager
|
||||
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
|
||||
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter
|
||||
import com.bitwarden.ui.platform.composition.LocalIntentManager
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
@@ -64,6 +67,8 @@ fun LocalManagerProvider(
|
||||
permissionsManager: PermissionsManager = PermissionsManagerImpl(activity = activity),
|
||||
credentialExchangeImporter: CredentialExchangeImporter =
|
||||
credentialExchangeImporter(activity = activity),
|
||||
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
|
||||
credentialExchangeCompletionManager(activity = activity),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
@@ -79,6 +84,7 @@ fun LocalManagerProvider(
|
||||
LocalNfcManager provides nfcManager,
|
||||
LocalPermissionsManager provides permissionsManager,
|
||||
LocalCredentialExchangeImporter provides credentialExchangeImporter,
|
||||
LocalCredentialExchangeCompletionManager provides credentialExchangeCompletionManager,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.component
|
||||
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitwarden.ui.R
|
||||
import com.bitwarden.ui.platform.base.util.cardStyle
|
||||
import com.bitwarden.ui.platform.base.util.hexToColor
|
||||
import com.bitwarden.ui.platform.base.util.toSafeOverlayColor
|
||||
import com.bitwarden.ui.platform.base.util.toUnscaledTextUnit
|
||||
import com.bitwarden.ui.platform.components.model.CardStyle
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
|
||||
|
||||
/**
|
||||
* A composable that displays an account summary list item.
|
||||
*
|
||||
* @param item The account selection list item to display.
|
||||
* @param cardStyle The card style to apply to the list item.
|
||||
* @param modifier The modifier to apply to the list item.
|
||||
* @param clickable Whether the list item should be clickable.
|
||||
*/
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun AccountSummaryListItem(
|
||||
item: AccountSelectionListItem,
|
||||
cardStyle: CardStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
clickable: Boolean,
|
||||
onClick: (userId: String) -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.testTag("AccountSummaryListItem")
|
||||
.defaultMinSize(minHeight = 60.dp)
|
||||
.clickable(
|
||||
onClick = { onClick(item.userId) },
|
||||
enabled = clickable,
|
||||
)
|
||||
.cardStyle(
|
||||
cardStyle = cardStyle,
|
||||
paddingStart = 16.dp,
|
||||
paddingEnd = 4.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = rememberVectorPainter(
|
||||
id = BitwardenDrawable.ic_account_initials_container,
|
||||
),
|
||||
contentDescription = null,
|
||||
tint = item.avatarColorHex.hexToColor(),
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Text(
|
||||
text = item.initials,
|
||||
style = TextStyle(
|
||||
fontSize = 11.dp.toUnscaledTextUnit(),
|
||||
lineHeight = 13.dp.toUnscaledTextUnit(),
|
||||
fontFamily = FontFamily(Font(R.font.dm_sans_bold)),
|
||||
fontWeight = FontWeight.W600,
|
||||
),
|
||||
color = item.avatarColorHex.hexToColor().toSafeOverlayColor(),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = item.email,
|
||||
style = BitwardenTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.testTag("AccountEmailLabel"),
|
||||
)
|
||||
|
||||
if (item.isItemRestricted) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
BitwardenString.import_restricted_unable_to_import_credit_cards,
|
||||
),
|
||||
style = BitwardenTheme.typography.bodyMedium,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
modifier = Modifier.testTag("AccountRestrictedLabel"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.component
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
|
||||
/**
|
||||
* A reusable scaffold for the export items screen.
|
||||
*
|
||||
* @param navIcon The navigation icon to be displayed in the top app bar.
|
||||
* @param onNavigationIconClick The action to be performed when the navigation icon is clicked.
|
||||
* @param navigationIconContentDescription The content description for the navigation icon.
|
||||
* @param scrollBehavior The scroll behavior to be used for the top app bar.
|
||||
* @param modifier The modifier to be applied to the scaffold.
|
||||
* @param content The content to be displayed inside the scaffold.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ExportItemsScaffold(
|
||||
navIcon: Painter,
|
||||
onNavigationIconClick: () -> Unit,
|
||||
navigationIconContentDescription: String,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit = {},
|
||||
) {
|
||||
BitwardenScaffold(
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(BitwardenString.import_from_bitwarden),
|
||||
onNavigationIconClick = onNavigationIconClick,
|
||||
navigationIconContentDescription = navigationIconContentDescription,
|
||||
navigationIcon = navIcon,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Represents a list item for selecting an account to export items from.
|
||||
*/
|
||||
@Serializable
|
||||
@Parcelize
|
||||
data class AccountSelectionListItem(
|
||||
val userId: String,
|
||||
val isItemRestricted: Boolean,
|
||||
val avatarColorHex: String,
|
||||
val initials: String,
|
||||
val email: String,
|
||||
) : Parcelable
|
||||
+154
-2
@@ -1,7 +1,42 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
|
||||
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
|
||||
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
|
||||
import com.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
|
||||
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
import com.bitwarden.ui.platform.resource.BitwardenString
|
||||
import com.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.AccountSummaryListItem
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.ExportItemsScaffold
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.handlers.rememberSelectAccountHandlers
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Top level screen for selecting an account to export items from.
|
||||
@@ -9,7 +44,124 @@ import androidx.compose.runtime.Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SelectAccountScreen(
|
||||
onAccountSelected: (id: String) -> Unit,
|
||||
onAccountSelected: (userId: String) -> Unit,
|
||||
viewModel: SelectAccountViewModel = hiltViewModel(),
|
||||
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
|
||||
LocalCredentialExchangeCompletionManager.current,
|
||||
) {
|
||||
// TODO: [PM-26095] Implement select account screen.
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val handlers = rememberSelectAccountHandlers(viewModel)
|
||||
EventsEffect(viewModel) { event ->
|
||||
when (event) {
|
||||
SelectAccountEvent.CancelExport -> {
|
||||
credentialExchangeCompletionManager
|
||||
.completeCredentialExport(
|
||||
exportResult = ExportCredentialsResult.Failure(
|
||||
// TODO: [PM-26094] Use ImportCredentialsCancellationException once
|
||||
// public.
|
||||
error = ImportCredentialsUnknownErrorException(
|
||||
errorMessage = "User cancelled import.",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
is SelectAccountEvent.NavigateToPasswordVerification -> {
|
||||
onAccountSelected(event.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
ExportItemsScaffold(
|
||||
navIcon = rememberVectorPainter(BitwardenDrawable.ic_close),
|
||||
navigationIconContentDescription = stringResource(BitwardenString.close),
|
||||
onNavigationIconClick = handlers.onCloseClick,
|
||||
scrollBehavior = scrollBehavior,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) {
|
||||
SelectAccountContent(
|
||||
state = state,
|
||||
onAccountClick = handlers.onAccountClick,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.standardHorizontalMargin(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectAccountContent(
|
||||
state: SelectAccountState,
|
||||
onAccountClick: (userId: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(modifier = modifier) {
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(BitwardenString.select_account),
|
||||
textAlign = TextAlign.Center,
|
||||
style = BitwardenTheme.typography.titleMedium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
|
||||
itemsIndexed(
|
||||
items = state.accountSelectionListItems,
|
||||
key = { _, item -> "AccountSummaryItem_${item.userId}" },
|
||||
) { index, item ->
|
||||
AccountSummaryListItem(
|
||||
item = item,
|
||||
cardStyle = state.accountSelectionListItems.toListItemCardStyle(index),
|
||||
clickable = true,
|
||||
onClick = onAccountClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(16.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SelectAccountContentPreview() {
|
||||
val state = SelectAccountState(
|
||||
accountSelectionListItems = persistentListOf(
|
||||
AccountSelectionListItem(
|
||||
userId = "1",
|
||||
email = "john.doe@example.com",
|
||||
initials = "JD",
|
||||
avatarColorHex = "#FFFF0000",
|
||||
isItemRestricted = false,
|
||||
),
|
||||
AccountSelectionListItem(
|
||||
userId = "2",
|
||||
email = "jane.smith@example.com",
|
||||
initials = "JS",
|
||||
avatarColorHex = "#FF00FF00",
|
||||
isItemRestricted = true,
|
||||
),
|
||||
AccountSelectionListItem(
|
||||
userId = "3",
|
||||
email = "another.user@example.com",
|
||||
initials = "AU",
|
||||
avatarColorHex = "#FF0000FF",
|
||||
isItemRestricted = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
BitwardenScaffold {
|
||||
SelectAccountContent(
|
||||
state = state,
|
||||
onAccountClick = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Manages application state for the select account screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class SelectAccountViewModel @Inject constructor(
|
||||
authRepository: AuthRepository,
|
||||
policyManager: PolicyManager,
|
||||
) : BaseViewModel<SelectAccountState, SelectAccountEvent, SelectAccountAction>(
|
||||
initialState = SelectAccountState(
|
||||
accountSelectionListItems = persistentListOf(),
|
||||
),
|
||||
) {
|
||||
|
||||
init {
|
||||
combine(
|
||||
authRepository.userStateFlow,
|
||||
policyManager.getActivePoliciesFlow(PolicyTypeJson.RESTRICT_ITEM_TYPES),
|
||||
policyManager.getActivePoliciesFlow(PolicyTypeJson.PERSONAL_OWNERSHIP),
|
||||
) { userState, itemRestrictedOrgs, personalOwnershipOrgs ->
|
||||
SelectAccountAction.Internal.SelectionDataReceive(
|
||||
userState,
|
||||
itemRestrictedOrgs,
|
||||
personalOwnershipOrgs,
|
||||
)
|
||||
}
|
||||
.onEach(::handleAction)
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: SelectAccountAction) {
|
||||
when (action) {
|
||||
SelectAccountAction.CloseClick -> {
|
||||
handleCloseClick()
|
||||
}
|
||||
|
||||
is SelectAccountAction.AccountClick -> {
|
||||
handleAccountClick(action)
|
||||
}
|
||||
|
||||
is SelectAccountAction.Internal -> {
|
||||
handleInternalAction(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleCloseClick() {
|
||||
sendEvent(SelectAccountEvent.CancelExport)
|
||||
}
|
||||
|
||||
private fun handleAccountClick(action: SelectAccountAction.AccountClick) {
|
||||
sendEvent(
|
||||
SelectAccountEvent.NavigateToPasswordVerification(
|
||||
action.userId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleInternalAction(action: SelectAccountAction.Internal) {
|
||||
when (action) {
|
||||
is SelectAccountAction.Internal.SelectionDataReceive -> {
|
||||
handleSelectionDataReceive(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSelectionDataReceive(
|
||||
action: SelectAccountAction.Internal.SelectionDataReceive,
|
||||
) {
|
||||
val itemRestrictedOrgIds = action.itemRestrictedOrgs
|
||||
.filter { it.isEnabled }
|
||||
.map { it.organizationId }
|
||||
val personalOwnershipRestrictedOrgIds = action
|
||||
.personalOwnershipOrgs
|
||||
.filter { it.isEnabled }
|
||||
.map { it.organizationId }
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
accountSelectionListItems = action.userState
|
||||
?.accounts
|
||||
.orEmpty()
|
||||
// We only want accounts that do not restrict personal vault ownership
|
||||
.filter { account ->
|
||||
account
|
||||
.organizations
|
||||
.none { org -> org.id in personalOwnershipRestrictedOrgIds }
|
||||
}
|
||||
.map { account ->
|
||||
AccountSelectionListItem(
|
||||
userId = account.userId,
|
||||
email = account.email,
|
||||
initials = account.initials,
|
||||
avatarColorHex = account.avatarColorHex,
|
||||
// Indicate which accounts have item restrictions applied.
|
||||
isItemRestricted = account
|
||||
.organizations
|
||||
.any { org -> org.id in itemRestrictedOrgIds },
|
||||
)
|
||||
}
|
||||
.toImmutableList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the state for the select account screen.
|
||||
*
|
||||
* @param accountSelectionListItems The list of account summaries to be displayed for selection.
|
||||
*/
|
||||
data class SelectAccountState(
|
||||
val accountSelectionListItems: ImmutableList<AccountSelectionListItem>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents the actions that can be performed on the select account screen.
|
||||
*/
|
||||
sealed class SelectAccountAction {
|
||||
|
||||
/**
|
||||
* Indicates the top-bar close button was clicked.
|
||||
*/
|
||||
data object CloseClick : SelectAccountAction()
|
||||
|
||||
/**
|
||||
* Indicates the user selected an account summary.
|
||||
*/
|
||||
data class AccountClick(
|
||||
val userId: String,
|
||||
) : SelectAccountAction()
|
||||
|
||||
/**
|
||||
* Represents internal actions for the select account screen.
|
||||
*/
|
||||
sealed class Internal : SelectAccountAction() {
|
||||
|
||||
/**
|
||||
* Indicates the selection data was received.
|
||||
*/
|
||||
data class SelectionDataReceive(
|
||||
val userState: UserState?,
|
||||
val itemRestrictedOrgs: List<SyncResponseJson.Policy>,
|
||||
val personalOwnershipOrgs: List<SyncResponseJson.Policy>,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the select account screen.
|
||||
*/
|
||||
sealed class SelectAccountEvent {
|
||||
|
||||
/**
|
||||
* Navigates back to the previous screen.
|
||||
*/
|
||||
data object CancelExport : SelectAccountEvent()
|
||||
|
||||
/**
|
||||
* Navigate to the password verification screen.
|
||||
*/
|
||||
data class NavigateToPasswordVerification(
|
||||
val userId: String,
|
||||
) : SelectAccountEvent()
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.handlers
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountViewModel
|
||||
|
||||
/**
|
||||
* Responsible for handling user interactions for the select account screen.
|
||||
*/
|
||||
data class SelectAccountHandlers(
|
||||
val onCloseClick: () -> Unit,
|
||||
val onAccountClick: (userId: String) -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Creates an instance of [SelectAccountHandlers] by binding actions to the provided
|
||||
* [SelectAccountViewModel].
|
||||
*/
|
||||
fun create(viewModel: SelectAccountViewModel): SelectAccountHandlers =
|
||||
SelectAccountHandlers(
|
||||
onCloseClick = {
|
||||
viewModel.trySendAction(SelectAccountAction.CloseClick)
|
||||
},
|
||||
onAccountClick = {
|
||||
viewModel.trySendAction(SelectAccountAction.AccountClick(userId = it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to remember a [SelectAccountHandlers] instance in a [Composable] scope.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSelectAccountHandlers(viewModel: SelectAccountViewModel): SelectAccountHandlers =
|
||||
remember(viewModel) { SelectAccountHandlers.create(viewModel) }
|
||||
+24
@@ -4,6 +4,7 @@ import com.bitwarden.ui.platform.components.account.model.AccountSummary
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Converts the given [UserState] to a list of [AccountSummary].
|
||||
@@ -71,3 +72,26 @@ fun UserState.Account.toVaultFilterData(
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the [UserState.Account], returns the first two "initials" found when looking at the
|
||||
* [UserState.Account.name].
|
||||
*
|
||||
* Ex:
|
||||
* - "First Last" -> "FL"
|
||||
* - "First Second Last" -> "FS"
|
||||
* - "First" -> "FI"
|
||||
* - name is `null`, email is "test@bitwarden.com" -> "TE"
|
||||
*/
|
||||
val UserState.Account.initials: String
|
||||
get() {
|
||||
val names = this.name.orEmpty().split(" ").filter { it.isNotBlank() }
|
||||
return if (names.size >= 2) {
|
||||
names
|
||||
.take(2)
|
||||
.joinToString(separator = "") { it.first().toString() }
|
||||
} else {
|
||||
(this.name ?: this.email).take(2)
|
||||
}
|
||||
.uppercase(Locale.getDefault())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.base
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.bitwarden.cxf.importer.CredentialExchangeImporter
|
||||
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
|
||||
import com.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
|
||||
import com.bitwarden.ui.platform.manager.IntentManager
|
||||
@@ -42,6 +43,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() {
|
||||
nfcManager: NfcManager = mockk(),
|
||||
permissionsManager: PermissionsManager = mockk(),
|
||||
credentialExchangeImporter: CredentialExchangeImporter = mockk(),
|
||||
credentialExchangeCompletionManager: CredentialExchangeCompletionManager = mockk(),
|
||||
test: @Composable () -> Unit,
|
||||
) {
|
||||
setTestContent {
|
||||
@@ -58,6 +60,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() {
|
||||
nfcManager = nfcManager,
|
||||
permissionsManager = permissionsManager,
|
||||
credentialExchangeImporter = credentialExchangeImporter,
|
||||
credentialExchangeCompletionManager = credentialExchangeCompletionManager,
|
||||
) {
|
||||
BitwardenTheme(
|
||||
theme = theme,
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems
|
||||
|
||||
import androidx.compose.ui.test.isDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
|
||||
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
|
||||
import com.bitwarden.ui.platform.components.account.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountEvent
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountScreen
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountState
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountViewModel
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.runs
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class SelectAccountScreenTest : BitwardenComposeTest() {
|
||||
private var onAccountSelectedCalled: Boolean = false
|
||||
private val mockkStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val mockEventFlow = bufferedMutableSharedFlow<SelectAccountEvent>()
|
||||
|
||||
private val credentialExchangeCompletionManager = mockk<CredentialExchangeCompletionManager>()
|
||||
private val viewModel = mockk<SelectAccountViewModel> {
|
||||
every { eventFlow } returns mockEventFlow
|
||||
every { stateFlow } returns mockkStateFlow
|
||||
every { trySendAction(any()) } just runs
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setContent(
|
||||
credentialExchangeCompletionManager = credentialExchangeCompletionManager,
|
||||
) {
|
||||
SelectAccountScreen(
|
||||
onAccountSelected = { onAccountSelectedCalled = true },
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct`() = runTest {
|
||||
composeTestRule
|
||||
.onNodeWithText("Select account")
|
||||
.isDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(ACTIVE_ACCOUNT_SUMMARY.email)
|
||||
.isDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close click should send CloseClick action`() = runTest {
|
||||
composeTestRule
|
||||
.onNodeWithContentDescription("Close")
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(SelectAccountAction.CloseClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelExport event should complete credential exchange with cancellation error`() =
|
||||
runTest {
|
||||
val exportResultSlot = slot<ExportCredentialsResult>()
|
||||
every {
|
||||
credentialExchangeCompletionManager.completeCredentialExport(
|
||||
exportResult = capture(exportResultSlot),
|
||||
)
|
||||
} just runs
|
||||
|
||||
mockEventFlow.emit(SelectAccountEvent.CancelExport)
|
||||
|
||||
verify {
|
||||
credentialExchangeCompletionManager.completeCredentialExport(
|
||||
exportResult = exportResultSlot.captured,
|
||||
)
|
||||
}
|
||||
assertTrue(exportResultSlot.captured is ExportCredentialsResult.Failure)
|
||||
// TODO: [PM-26094] Uncomment check to verify error is
|
||||
// ImportCredentialsCancellationException when it is public.
|
||||
// val result = exportResultSlot.captured as ExportCredentialsResult.Failure
|
||||
// assertTrue(result.error is ImportCredentialsCancellationException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `account list item click should send onAccountClick action`() = runTest {
|
||||
composeTestRule
|
||||
.onNodeWithText(ACTIVE_ACCOUNT_SUMMARY.email)
|
||||
.performClick()
|
||||
|
||||
verify {
|
||||
viewModel.trySendAction(
|
||||
SelectAccountAction.AccountClick(
|
||||
userId = ACTIVE_ACCOUNT_SUMMARY.userId,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToPasswordVerification event should navigate to password verification`() =
|
||||
runTest {
|
||||
mockEventFlow.emit(
|
||||
SelectAccountEvent.NavigateToPasswordVerification(
|
||||
userId = ACTIVE_ACCOUNT_SUMMARY.userId,
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(onAccountSelectedCalled)
|
||||
}
|
||||
}
|
||||
|
||||
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
|
||||
userId = "activeUserId",
|
||||
name = "Active User",
|
||||
email = "active@bitwarden.com",
|
||||
avatarColorHex = "#aa00aa",
|
||||
environmentLabel = "bitwarden.com",
|
||||
isActive = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
)
|
||||
private val LOCKED_ACCOUNT_SUMMARY = AccountSummary(
|
||||
userId = "lockedUserId",
|
||||
name = "Locked User",
|
||||
email = "locked@bitwarden.com",
|
||||
avatarColorHex = "#00aaaa",
|
||||
environmentLabel = "bitwarden.com",
|
||||
isActive = false,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE = SelectAccountState(
|
||||
accountSelectionListItems = persistentListOf(
|
||||
AccountSelectionListItem(
|
||||
userId = ACTIVE_ACCOUNT_SUMMARY.userId,
|
||||
email = ACTIVE_ACCOUNT_SUMMARY.email,
|
||||
initials = "AA",
|
||||
avatarColorHex = "#FFFF0000",
|
||||
isItemRestricted = false,
|
||||
),
|
||||
AccountSelectionListItem(
|
||||
userId = LOCKED_ACCOUNT_SUMMARY.userId,
|
||||
email = LOCKED_ACCOUNT_SUMMARY.email,
|
||||
initials = "LU",
|
||||
avatarColorHex = "#FF00FF00",
|
||||
isItemRestricted = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package com.x8bit.bitwarden.ui.vault.feature.exportitems
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.network.model.OrganizationType
|
||||
import com.bitwarden.network.model.PolicyTypeJson
|
||||
import com.bitwarden.network.model.SyncResponseJson
|
||||
import com.bitwarden.network.model.createMockPolicy
|
||||
import com.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
|
||||
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.Organization
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountAction
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountEvent
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountState
|
||||
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.SelectAccountViewModel
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SelectAccountViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mutableUserUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE)
|
||||
private val mutableRestrictItemTypesFlow =
|
||||
bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
|
||||
private val mutablePersonalOwnershipPolicyFlow =
|
||||
bufferedMutableSharedFlow<List<SyncResponseJson.Policy>>()
|
||||
|
||||
private val authRepository = mockk<AuthRepository> {
|
||||
every { userStateFlow } returns mutableUserUserStateFlow
|
||||
}
|
||||
private val policyManager = mockk<PolicyManager> {
|
||||
every {
|
||||
getActivePoliciesFlow(PolicyTypeJson.RESTRICT_ITEM_TYPES)
|
||||
} returns mutableRestrictItemTypesFlow
|
||||
every {
|
||||
getActivePoliciesFlow(PolicyTypeJson.PERSONAL_OWNERSHIP)
|
||||
} returns mutablePersonalOwnershipPolicyFlow
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertEquals(
|
||||
SelectAccountState(
|
||||
accountSelectionListItems = persistentListOf(),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state is updated when single account with no organization is emitted`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val expectedItem = AccountSelectionListItem(
|
||||
userId = DEFAULT_ACCOUNT.userId,
|
||||
email = DEFAULT_ACCOUNT.email,
|
||||
initials = "AU",
|
||||
avatarColorHex = "#FF00FF00",
|
||||
isItemRestricted = false,
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
SelectAccountAction.Internal.SelectionDataReceive(
|
||||
userState = DEFAULT_USER_STATE,
|
||||
itemRestrictedOrgs = emptyList(),
|
||||
personalOwnershipOrgs = emptyList(),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
SelectAccountState(accountSelectionListItems = persistentListOf(expectedItem)),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accounts in organization with PERSONAL_VAULT policy are filtered out`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val organizationId = "mockOrganizationId-1"
|
||||
val accountInOrg = DEFAULT_ACCOUNT.copy(
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = organizationId,
|
||||
name = "organizationName",
|
||||
shouldManageResetPassword = false,
|
||||
shouldUseKeyConnector = false,
|
||||
role = OrganizationType.ADMIN,
|
||||
keyConnectorUrl = null,
|
||||
userIsClaimedByOrganization = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
SelectAccountAction.Internal.SelectionDataReceive(
|
||||
userState = DEFAULT_USER_STATE.copy(accounts = listOf(accountInOrg)),
|
||||
itemRestrictedOrgs = emptyList(),
|
||||
personalOwnershipOrgs = listOf(
|
||||
createMockPolicy(
|
||||
number = 1,
|
||||
id = organizationId,
|
||||
isEnabled = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
SelectAccountState(
|
||||
accountSelectionListItems = persistentListOf(),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accounts in organization with RESTRICT_ITEM_TYPE policy are marked as restricted`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
val organizationId = "mockOrganizationId-1"
|
||||
val accountInOrg = DEFAULT_ACCOUNT.copy(
|
||||
organizations = listOf(
|
||||
Organization(
|
||||
id = organizationId,
|
||||
name = "organizationName",
|
||||
shouldManageResetPassword = false,
|
||||
shouldUseKeyConnector = false,
|
||||
role = OrganizationType.ADMIN,
|
||||
keyConnectorUrl = null,
|
||||
userIsClaimedByOrganization = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
val expectedItem = AccountSelectionListItem(
|
||||
userId = accountInOrg.userId,
|
||||
email = accountInOrg.email,
|
||||
initials = "AU",
|
||||
avatarColorHex = "#FF00FF00",
|
||||
isItemRestricted = true,
|
||||
)
|
||||
|
||||
viewModel.trySendAction(
|
||||
SelectAccountAction.Internal.SelectionDataReceive(
|
||||
userState = DEFAULT_USER_STATE.copy(accounts = listOf(accountInOrg)),
|
||||
itemRestrictedOrgs = listOf(
|
||||
createMockPolicy(
|
||||
number = 1,
|
||||
id = organizationId,
|
||||
isEnabled = true,
|
||||
),
|
||||
),
|
||||
personalOwnershipOrgs = emptyList(),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
SelectAccountState(accountSelectionListItems = persistentListOf(expectedItem)),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when CloseClick action is sent, CancelExport event is emitted`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(SelectAccountAction.CloseClick)
|
||||
assertEquals(SelectAccountEvent.CancelExport, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when AccountClick action is sent, NavigateToPasswordVerification event is emitted`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(
|
||||
SelectAccountAction.AccountClick(
|
||||
DEFAULT_ACCOUNT.userId,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
SelectAccountEvent.NavigateToPasswordVerification(
|
||||
userId = DEFAULT_ACCOUNT.userId,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(): SelectAccountViewModel = SelectAccountViewModel(
|
||||
authRepository = authRepository,
|
||||
policyManager = policyManager,
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_ACCOUNT = UserState.Account(
|
||||
userId = "activeUserId",
|
||||
name = "Active User",
|
||||
email = "active@bitwarden.com",
|
||||
environment = Environment.Us,
|
||||
avatarColorHex = "#FF00FF00",
|
||||
isPremium = true,
|
||||
isLoggedIn = true,
|
||||
isVaultUnlocked = true,
|
||||
needsPasswordReset = false,
|
||||
isBiometricsEnabled = false,
|
||||
organizations = emptyList(),
|
||||
needsMasterPassword = false,
|
||||
trustedDevice = null,
|
||||
hasMasterPassword = true,
|
||||
isUsingKeyConnector = false,
|
||||
onboardingStatus = OnboardingStatus.COMPLETE,
|
||||
firstTimeState = FirstTimeState(showImportLoginsCard = true),
|
||||
)
|
||||
|
||||
private val DEFAULT_USER_STATE = UserState(
|
||||
activeUserId = "activeUserId",
|
||||
accounts = listOf(DEFAULT_ACCOUNT),
|
||||
)
|
||||
@@ -1107,4 +1107,7 @@ Do you want to switch to this account?</string>
|
||||
<string name="enable_browser_autofill_to_keep_filling_passwords">Enable browser Autofill to keep filling passwords</string>
|
||||
<string name="your_browser_recently_updated_how_autofill_works">Your browser recently updated how Autofill works. To continue filling your passwords with Bitwarden, enable browser Autofill in autofill settings.</string>
|
||||
<string name="not_now">Not now</string>
|
||||
<string name="import_from_bitwarden">Import from Bitwarden</string>
|
||||
<string name="select_account">Select account</string>
|
||||
<string name="import_restricted_unable_to_import_credit_cards">Import restricted, unable to import cards from this account.</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user