Compare commits

...

15 Commits

Author SHA1 Message Date
Patrick Honkonen
acc9113f9a [PM-26355] Improve SelectAccountScreen state handling (#5965) 2025-10-02 21:05:08 +00:00
David Perez
2eb829a25b [deps]: Update org.sonarqube to v6.3.1.5724 (#5973) 2025-10-02 20:51:02 +00:00
Álison Fernandes
04a1d4118f Update renovate.json to exclude com.github.bumptech.glide from gradle-minor group (#5974) 2025-10-02 20:39:47 +00:00
David Perez
9f63cede11 Update UI elements for common use in Authenticator (#5971) 2025-10-02 18:37:17 +00:00
David Perez
a93037d63e PM-26445: Common Debug menu components (#5970) 2025-10-02 17:32:22 +00:00
Patrick Honkonen
4e57f306d3 [PM-26330] Correct owner data when individual vault is disabled (#5968) 2025-10-02 15:56:50 +00:00
André Bispo
1638a20bf0 [PM-23280] Save MasterPasswordUnlockData to local state (#5944) 2025-10-02 14:48:28 +00:00
bw-ghapp[bot]
874edfad69 Update SDK to 1.0.0-3194-9947387b (#5938)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
Co-authored-by: Hinton <hinton@users.noreply.github.com>
Co-authored-by: Carlos Gonçalves <cgoncalves@bitwarden.com>
2025-10-01 17:15:23 +00:00
David Perez
0469731fba Update Kover to v0.9.2 (#5966) 2025-10-01 17:08:54 +00:00
David Perez
0abfa5bb97 Update Androidx Camera to v1.5.0 (#5896) 2025-10-01 17:08:10 +00:00
aj-rosado
13e6728d46 [PM-17870] Always include clientExtensionResults in Fido2AttestationResponse (#5964) 2025-10-01 13:58:57 +00:00
David Perez
116bfd6351 PM-26312: Add browser integration help link (#5963) 2025-09-30 17:47:43 +00:00
David Perez
6ca8a39355 Update Guava to v33.5.0 (#5962) 2025-09-30 17:20:31 +00:00
David Perez
24a54ce214 Update hilt to v2.57.2 (#5961) 2025-09-30 17:20:15 +00:00
David Perez
8d76ef50d3 Firebase BOM update (#5960) 2025-09-30 17:19:59 +00:00
52 changed files with 1302 additions and 161 deletions

View File

@@ -27,6 +27,9 @@
],
"matchManagers": [
"gradle"
],
"excludePackageNames": [
"com.github.bumptech.glide:compose"
]
},
{

View File

@@ -26,6 +26,7 @@ fun TrustDeviceResponse.toUserStateJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
val deviceOptions = decryptionOptions
.trustedDeviceUserDecryptionOptions

View File

@@ -32,6 +32,7 @@ fun UserStateJson.toRemovedPasswordUserStateJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
val updatedProfile = profile.copy(userDecryptionOptions = updatedUserDecryptionOptions)
val updatedAccount = account.copy(profile = updatedProfile)
@@ -54,6 +55,23 @@ fun UserStateJson.toUpdatedUserStateJson(
val userId = syncProfile.id
val account = this.accounts[userId] ?: return this
val profile = account.profile
val userDecryptionOptions = syncResponse
.userDecryption
?.let { syncUserDecryption ->
profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock)
?: UserDecryptionOptionsJson(
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock,
)
}
?: profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = null)
val updatedProfile = profile
.copy(
avatarColorHex = syncProfile.avatarColor,
@@ -61,6 +79,7 @@ fun UserStateJson.toUpdatedUserStateJson(
hasPremium = syncProfile.isPremium || syncProfile.isPremiumFromOrganization,
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this
@@ -90,6 +109,7 @@ fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
)
val updatedAccount = account.copy(profile = updatedProfile)

View File

@@ -20,7 +20,7 @@ data class Fido2AttestationResponse(
@SerialName("response")
val response: RegistrationResponse,
@SerialName("clientExtensionResults")
val clientExtensionResults: ClientExtensionResults?,
val clientExtensionResults: ClientExtensionResults,
@SerialName("authenticatorAttachment")
val authenticatorAttachment: String?,
) {
@@ -50,7 +50,7 @@ data class Fido2AttestationResponse(
@Serializable
data class ClientExtensionResults(
@SerialName("credProps")
val credentialProperties: CredentialProperties,
val credentialProperties: CredentialProperties? = null,
) {
/**
* Represents properties for newly created credential.

View File

@@ -1,11 +1,21 @@
package com.x8bit.bitwarden.data.platform.manager
import android.os.Build
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
/**
* The token provider to pass to the SDK.
*/
class Token : ClientManagedTokens {
override suspend fun getAccessToken(): String? {
return null
}
}
/**
* Primary implementation of [SdkClientManager].
*/
@@ -14,7 +24,7 @@ class SdkClientManagerImpl(
sdkRepoFactory: SdkRepositoryFactory,
private val featureFlagManager: FeatureFlagManager,
private val clientProvider: suspend (userId: String?) -> Client = { userId ->
Client(settings = null).apply {
Client(tokenProvider = Token(), settings = null).apply {
platform().loadFlags(featureFlagManager.sdkFeatureFlags)
userId?.let {
platform().state().apply {

View File

@@ -31,7 +31,7 @@ fun PublicKeyCredentialAuthenticatorAttestationResponse.toAndroidAttestationResp
.ClientExtensionResults
.CredentialProperties(residentKey = residentKey),
)
},
} ?: Fido2AttestationResponse.ClientExtensionResults(),
authenticatorAttachment = authenticatorAttachment,
)

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -14,6 +15,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.pluralStringResource
@@ -21,6 +23,7 @@ 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.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
@@ -31,6 +34,7 @@ import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
@@ -64,6 +68,12 @@ fun SetupBrowserAutofillScreen(
browserPackage = event.browserPackage,
)
}
SetupBrowserAutofillEvent.NavigateToBrowserIntegrationsInfo -> {
intentManager.launchUri(
"https://bitwarden.com/help/auto-fill-android/#browser-integrations/".toUri(),
)
}
}
}
SetupBrowserAutofillDialogs(
@@ -106,6 +116,9 @@ fun SetupBrowserAutofillScreen(
) {
SetupBrowserAutofillContent(
state = state,
onWhyIsThisStepRequiredClick = remember(viewModel) {
{ viewModel.trySendAction(SetupBrowserAutofillAction.WhyIsThisStepRequiredClick) }
},
onBrowserClick = remember(viewModel) {
{ viewModel.trySendAction(SetupBrowserAutofillAction.BrowserIntegrationClick(it)) }
},
@@ -120,9 +133,11 @@ fun SetupBrowserAutofillScreen(
}
}
@Suppress("LongMethod")
@Composable
private fun SetupBrowserAutofillContent(
state: SetupBrowserAutofillState,
onWhyIsThisStepRequiredClick: () -> Unit,
onBrowserClick: (BrowserPackage) -> Unit,
onContinueClick: () -> Unit,
onTurnOnLaterClick: () -> Unit,
@@ -154,7 +169,16 @@ private fun SetupBrowserAutofillContent(
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenClickableText(
label = stringResource(id = BitwardenString.why_is_this_step_required),
style = BitwardenTheme.typography.labelMedium,
onClick = onWhyIsThisStepRequiredClick,
modifier = Modifier
.wrapContentWidth()
.align(alignment = Alignment.CenterHorizontally)
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
BrowserAutofillSettingsCard(
options = state.browserAutofillSettingsOptions,
onOptionClicked = onBrowserClick,
@@ -221,6 +245,7 @@ private fun SetupBrowserAutofillContent_preview() {
BrowserAutofillSettingsOption.ChromeBeta(enabled = true),
),
),
onWhyIsThisStepRequiredClick = { },
onBrowserClick = { },
onContinueClick = { },
onTurnOnLaterClick = { },

View File

@@ -56,6 +56,10 @@ class SetupBrowserAutofillViewModel @Inject constructor(
handleBrowserIntegrationClick(action)
}
SetupBrowserAutofillAction.WhyIsThisStepRequiredClick -> {
handleWhyIsThisStepRequiredClick()
}
SetupBrowserAutofillAction.CloseClick -> handleCloseClick()
SetupBrowserAutofillAction.DismissDialog -> handleDismissDialog()
SetupBrowserAutofillAction.ContinueClick -> handleContinueClick()
@@ -81,6 +85,10 @@ class SetupBrowserAutofillViewModel @Inject constructor(
)
}
private fun handleWhyIsThisStepRequiredClick() {
sendEvent(SetupBrowserAutofillEvent.NavigateToBrowserIntegrationsInfo)
}
private fun handleCloseClick() {
sendEvent(SetupBrowserAutofillEvent.NavigateBack)
}
@@ -167,6 +175,11 @@ sealed class SetupBrowserAutofillEvent {
data class NavigateToBrowserAutofillSettings(
val browserPackage: BrowserPackage,
) : SetupBrowserAutofillEvent()
/**
* Navigates to the browser integrations info page.
*/
data object NavigateToBrowserIntegrationsInfo : SetupBrowserAutofillEvent()
}
/**
@@ -205,6 +218,11 @@ sealed class SetupBrowserAutofillAction {
*/
data object TurnOnLaterConfirmClick : SetupBrowserAutofillAction()
/**
* Indicates that the "Why is this step required?" button was clicked.
*/
data object WhyIsThisStepRequiredClick : SetupBrowserAutofillAction()
/**
* Models actions the [SetupBrowserAutofillViewModel] itself may send.
*/

View File

@@ -31,6 +31,7 @@ import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.debug.ListItemContent
import com.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
@@ -38,7 +39,6 @@ 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.platform.feature.debugmenu.components.ListItemContent
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf

View File

@@ -40,6 +40,7 @@ import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.annotatedStringResource
import com.bitwarden.ui.platform.base.util.spanStyleOf
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toAnnotatedString
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.badge.NotificationBadge
import com.bitwarden.ui.platform.components.card.BitwardenActionCard
@@ -246,9 +247,9 @@ private fun AutoFillScreenContent(
if (state.showPasskeyManagementRow) {
BitwardenExternalLinkRow(
text = stringResource(id = BitwardenString.passkey_management),
description = stringResource(
id = BitwardenString.passkey_management_explanation_long,
),
description = BitwardenString
.passkey_management_explanation_long
.toAnnotatedString(),
onConfirmClick = autoFillHandlers.onPasskeyManagementClick,
dialogTitle = stringResource(id = BitwardenString.continue_to_device_settings),
dialogMessage = stringResource(
@@ -330,9 +331,9 @@ private fun AutoFillScreenContent(
Spacer(modifier = Modifier.height(8.dp))
BitwardenTextRow(
text = stringResource(id = BitwardenString.block_auto_fill),
description = stringResource(
id = BitwardenString.auto_fill_will_not_be_offered_for_these_ur_is,
),
description = BitwardenString
.auto_fill_will_not_be_offered_for_these_ur_is
.toAnnotatedString(),
onClick = autoFillHandlers.onBlockAutoFillClick,
cardStyle = CardStyle.Full,
modifier = Modifier

View File

@@ -26,6 +26,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toAnnotatedString
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
@@ -181,7 +182,7 @@ private fun PrivilegedAppsListContent(
) { index, item ->
BitwardenTextRow(
text = item.label,
description = stringResource(item.trustAuthority.description),
description = item.trustAuthority.description.toAnnotatedString(),
clickable = false,
onClick = {},
cardStyle = state.installedApps

View File

@@ -2,6 +2,7 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit.util
import com.bitwarden.collections.CollectionType
import com.bitwarden.collections.CollectionView
import com.bitwarden.core.data.util.toFormattedDateTimeStyle
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -139,12 +140,22 @@ fun VaultAddEditState.ViewState.appendFolderAndOwnerData(
.toSelectedOwnerId(cipherView = currentContentState.common.originalCipher)
?: collectionViewList
.firstOrNull { it.id == currentContentState.common.selectedCollectionId }
?.organizationId
?: collectionViewList
.getDefaultCollectionViewOrNull(
isIndividualVaultDisabled = isIndividualVaultDisabled,
)
?.organizationId,
availableOwners = activeAccount.toAvailableOwners(
collectionViewList = collectionViewList,
cipherView = currentContentState.common.originalCipher,
isIndividualVaultDisabled = isIndividualVaultDisabled,
selectedCollectionId = currentContentState.common.selectedCollectionId,
selectedCollectionId = currentContentState.common.selectedCollectionId
?: collectionViewList
.getDefaultCollectionViewOrNull(
isIndividualVaultDisabled = isIndividualVaultDisabled,
)
?.id,
),
isUnlockWithPasswordEnabled = activeAccount.hasMasterPassword,
hasOrganizations = activeAccount.organizations.isNotEmpty(),
@@ -153,6 +164,29 @@ fun VaultAddEditState.ViewState.appendFolderAndOwnerData(
} ?: this
}
/**
* Retrieves the default user collection from a list of [CollectionView]s, but only if the
* individual vault is disabled.
*
* This is used to pre-select the default collection for a new item when the user is part of an
* organization and the "Individual Vault" policy is enabled, which prevents them from creating
* items in their personal vault.
*
* @param isIndividualVaultDisabled A boolean indicating if the policy disabling the individual
* vault is active.
*
* @return The [CollectionView] corresponding to the default user collection if the individual vault
* is disabled, otherwise `null`.
*/
fun List<CollectionView>.getDefaultCollectionViewOrNull(
isIndividualVaultDisabled: Boolean,
): CollectionView? =
if (isIndividualVaultDisabled) {
firstOrNull { it.type == CollectionType.DEFAULT_USER_COLLECTION }
} else {
null
}
/**
* Validates a [CipherView] otherwise returning a [VaultAddEditState.ViewState.Error].
*/

View File

@@ -4,6 +4,7 @@ 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.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -18,7 +19,7 @@ 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.credentials.providerevents.exception.ImportCredentialsCancellationException
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
@@ -27,7 +28,8 @@ 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.content.BitwardenEmptyContent
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -36,6 +38,7 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.AccountSummary
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.ImmutableList
import kotlinx.collections.immutable.persistentListOf
/**
@@ -43,6 +46,7 @@ import kotlinx.collections.immutable.persistentListOf
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongMethod")
fun SelectAccountScreen(
onAccountSelected: (userId: String) -> Unit,
viewModel: SelectAccountViewModel = hiltViewModel(),
@@ -57,9 +61,7 @@ fun SelectAccountScreen(
credentialExchangeCompletionManager
.completeCredentialExport(
exportResult = ExportCredentialsResult.Failure(
// TODO: [PM-26094] Use ImportCredentialsCancellationException once
// public.
error = ImportCredentialsUnknownErrorException(
error = ImportCredentialsCancellationException(
errorMessage = "User cancelled import.",
),
),
@@ -82,19 +84,40 @@ fun SelectAccountScreen(
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
) {
SelectAccountContent(
state = state,
onAccountClick = handlers.onAccountClick,
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
when (val viewState = state.viewState) {
is SelectAccountState.ViewState.Content -> {
SelectAccountContent(
accountSelectionListItems = viewState.accountSelectionListItems,
onAccountClick = handlers.onAccountClick,
modifier = Modifier.fillMaxSize(),
)
}
SelectAccountState.ViewState.Loading -> {
BitwardenLoadingContent(
text = stringResource(BitwardenString.loading),
modifier = Modifier.fillMaxSize(),
)
}
SelectAccountState.ViewState.NoItems -> {
BitwardenEmptyContent(
title = stringResource(BitwardenString.no_accounts_available),
titleTestTag = "NoAccountsTitle",
text = stringResource(
BitwardenString.you_dont_have_any_accounts_you_can_import_from,
),
labelTestTag = "NoAccountsText",
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@Composable
private fun SelectAccountContent(
state: SelectAccountState,
accountSelectionListItems: ImmutableList<AccountSelectionListItem>,
onAccountClick: (userId: String) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -106,62 +129,121 @@ private fun SelectAccountContent(
text = stringResource(BitwardenString.select_account),
textAlign = TextAlign.Center,
style = BitwardenTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
item { Spacer(Modifier.height(16.dp)) }
itemsIndexed(
items = state.accountSelectionListItems,
items = accountSelectionListItems,
key = { _, item -> "AccountSummaryItem_${item.userId}" },
) { index, item ->
AccountSummaryListItem(
item = item,
cardStyle = state.accountSelectionListItems.toListItemCardStyle(index),
cardStyle = accountSelectionListItems.toListItemCardStyle(index),
clickable = true,
onClick = onAccountClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.animateItem(),
)
}
item { Spacer(Modifier.height(16.dp)) }
item { Spacer(Modifier.navigationBarsPadding()) }
}
}
@Preview(showBackground = true)
@OptIn(ExperimentalMaterial3Api::class)
@Preview(
showBackground = true,
name = "Select account content",
showSystemUi = 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 {
private fun SelectAccountContent_preview() {
ExportItemsScaffold(
navIcon = rememberVectorPainter(BitwardenDrawable.ic_close),
navigationIconContentDescription = stringResource(BitwardenString.close),
onNavigationIconClick = { },
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
) {
SelectAccountContent(
state = state,
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,
),
),
onAccountClick = { },
modifier = Modifier.fillMaxSize(),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(
showBackground = true,
name = "No accounts content",
showSystemUi = true,
)
@Composable
private fun NoAccountsContent_preview() {
ExportItemsScaffold(
navIcon = rememberVectorPainter(BitwardenDrawable.ic_close),
navigationIconContentDescription = stringResource(BitwardenString.close),
onNavigationIconClick = { },
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
) {
BitwardenEmptyContent(
title = stringResource(BitwardenString.no_accounts_available),
titleTestTag = "NoAccountsTitle",
text = stringResource(
BitwardenString.you_dont_have_any_accounts_you_can_import_from,
),
labelTestTag = "NoAccountsText",
modifier = Modifier.fillMaxSize(),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Preview(
showBackground = true,
name = "Loading content",
showSystemUi = true,
)
@Composable
private fun LoadingContent_preview() {
ExportItemsScaffold(
navIcon = rememberVectorPainter(BitwardenDrawable.ic_close),
navigationIconContentDescription = stringResource(BitwardenString.close),
onNavigationIconClick = { },
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
) {
BitwardenLoadingContent(
text = stringResource(BitwardenString.loading),
modifier = Modifier.fillMaxSize(),
)
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount
import android.os.Parcelable
import androidx.lifecycle.viewModelScope
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
@@ -11,12 +12,13 @@ import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionLi
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 kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import javax.inject.Inject
/**
@@ -28,7 +30,7 @@ class SelectAccountViewModel @Inject constructor(
policyManager: PolicyManager,
) : BaseViewModel<SelectAccountState, SelectAccountEvent, SelectAccountAction>(
initialState = SelectAccountState(
accountSelectionListItems = persistentListOf(),
viewState = SelectAccountState.ViewState.Loading,
),
) {
@@ -95,30 +97,38 @@ class SelectAccountViewModel @Inject constructor(
.filter { it.isEnabled }
.map { it.organizationId }
val 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()
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(),
viewState = if (accountSelectionListItems.isEmpty()) {
SelectAccountState.ViewState.NoItems
} else {
SelectAccountState.ViewState.Content(
accountSelectionListItems = accountSelectionListItems,
)
},
)
}
}
@@ -126,12 +136,40 @@ class SelectAccountViewModel @Inject constructor(
/**
* Represents the state for the select account screen.
*
* @param accountSelectionListItems The list of account summaries to be displayed for selection.
*/
@Parcelize
@Serializable
data class SelectAccountState(
val accountSelectionListItems: ImmutableList<AccountSelectionListItem>,
)
val viewState: ViewState,
) : Parcelable {
/**
* Represents the different states for the select account screen.
*/
@Parcelize
@Serializable
sealed class ViewState : Parcelable {
/**
* Represents the loading state for the select account screen.
*/
data object Loading : ViewState()
/**
* Represents the content state for the select account screen.
*
* @param accountSelectionListItems The list of account summaries to be displayed for
* selection.
*/
data class Content(
val accountSelectionListItems: ImmutableList<AccountSelectionListItem>,
) : ViewState()
/**
* Represents the no items state for the select account screen.
*/
data object NoItems : ViewState()
}
}
/**
* Represents the actions that can be performed on the select account screen.

View File

@@ -1463,6 +1463,7 @@ private val USER_STATE = UserStateJson(
keyConnectorUserDecryptionOptions = KeyConnectorUserDecryptionOptionsJson(
keyConnectorUrl = "keyConnectorUrl",
),
masterPasswordUnlock = null,
),
),
tokens = AccountTokensJson(

View File

@@ -273,12 +273,14 @@ private val DEFAULT_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDec
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = DEFAULT_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
private val UPDATED_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = UPDATED_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
private val DEFAULT_ACCOUNT = AccountJson(

View File

@@ -55,12 +55,14 @@ private val DEFAULT_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDec
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = DEFAULT_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
private val UPDATED_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = UPDATED_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
private val DEFAULT_ACCOUNT = AccountJson(

View File

@@ -2008,6 +2008,7 @@ class AuthRepositoryTest {
hasMasterPassword = false,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
)
coEvery {
@@ -7030,6 +7031,7 @@ class AuthRepositoryTest {
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = TRUSTED_DEVICE_DECRYPTION_OPTIONS,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
@Deprecated(
@@ -7138,6 +7140,7 @@ class AuthRepositoryTest {
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
),
),

View File

@@ -128,6 +128,7 @@ private val USER_DECRYPTION_OPTIONS = UserDecryptionOptionsJson(
hasManageResetPasswordPermission = true,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
private val PROFILE_1 = AccountJson.Profile(
userId = USER_ID_1,

View File

@@ -4,8 +4,11 @@ import com.bitwarden.data.datasource.disk.model.EnvironmentUrlDataJson
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorUserDecryptionOptionsJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.UserDecryptionJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@@ -83,6 +86,7 @@ class UserStateJsonExtensionsTest {
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
),
),
@@ -112,6 +116,7 @@ class UserStateJsonExtensionsTest {
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
@@ -136,6 +141,7 @@ class UserStateJsonExtensionsTest {
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
),
),
@@ -222,6 +228,7 @@ class UserStateJsonExtensionsTest {
every { isTwoFactorEnabled } returns false
every { creationDate } returns ZonedDateTime
.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
),
@@ -266,6 +273,7 @@ class UserStateJsonExtensionsTest {
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
),
),
@@ -309,6 +317,7 @@ class UserStateJsonExtensionsTest {
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
masterPasswordUnlock = null,
),
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
@@ -328,6 +337,7 @@ class UserStateJsonExtensionsTest {
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
masterPasswordUnlock = null,
),
),
),
@@ -396,6 +406,7 @@ class UserStateJsonExtensionsTest {
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
},
tokens = AccountTokensJson(
@@ -509,6 +520,7 @@ class UserStateJsonExtensionsTest {
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
},
tokens = AccountTokensJson(
@@ -629,6 +641,7 @@ class UserStateJsonExtensionsTest {
hasManageResetPasswordPermission = false,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
},
tokens = null,
@@ -746,6 +759,7 @@ class UserStateJsonExtensionsTest {
hasManageResetPasswordPermission = false,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
},
tokens = null,
@@ -863,6 +877,7 @@ class UserStateJsonExtensionsTest {
hasManageResetPasswordPermission = false,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
},
tokens = null,
@@ -984,6 +999,7 @@ class UserStateJsonExtensionsTest {
hasManageResetPasswordPermission = false,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
},
tokens = null,
@@ -1082,6 +1098,7 @@ class UserStateJsonExtensionsTest {
keyConnectorUserDecryptionOptions = KeyConnectorUserDecryptionOptionsJson(
keyConnectorUrl = "keyConnectorUrl",
),
masterPasswordUnlock = null,
)
},
tokens = null,
@@ -1157,6 +1174,7 @@ class UserStateJsonExtensionsTest {
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
},
tokens = null,
@@ -1262,6 +1280,7 @@ class UserStateJsonExtensionsTest {
hasManageResetPasswordPermission = false,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
},
tokens = null,
@@ -1381,6 +1400,7 @@ class UserStateJsonExtensionsTest {
hasManageResetPasswordPermission = false,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
},
tokens = null,
@@ -1432,4 +1452,239 @@ class UserStateJsonExtensionsTest {
),
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should create UserDecryptionOptionsJson when null and syncResponse has masterPasswordUnlock`() {
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremium = true,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = null,
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = null,
settings = AccountJson.Settings(environmentUrlData = null),
)
val originalUserState = UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = mockk<SyncResponseJson>(relaxed = true) {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "avatarColor"
every { securityStamp } returns "securityStamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns true
every { creationDate } returns ZonedDateTime.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
)
}
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
avatarColorHex = "avatarColor",
stamp = "securityStamp",
hasPremium = false,
isTwoFactorEnabled = true,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
),
),
),
),
originalUserState.toUpdatedUserStateJson(syncResponse),
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should update existing UserDecryptionOptionsJson with masterPasswordUnlock`() {
val trustedDeviceOptions = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = false,
hasManageResetPasswordPermission = true,
)
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremium = true,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = trustedDeviceOptions,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = null,
settings = AccountJson.Settings(environmentUrlData = null),
)
val originalUserState = UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "newAvatarColor"
every { securityStamp } returns "newSecurityStamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns true
every { creationDate } returns ZonedDateTime.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
)
}
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
avatarColorHex = "newAvatarColor",
stamp = "newSecurityStamp",
hasPremium = true,
isTwoFactorEnabled = true,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = trustedDeviceOptions,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
),
),
),
),
originalUserState.toUpdatedUserStateJson(syncResponse),
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should update existing UserDecryptionOptionsJson when syncResponse has no userDecryption`() {
val keyConnectorOptions = KeyConnectorUserDecryptionOptionsJson("keyConnectorUrl")
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremium = true,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = keyConnectorOptions,
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = null,
settings = AccountJson.Settings(environmentUrlData = null),
)
val originalUserState = UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "updatedAvatarColor"
every { securityStamp } returns "updatedSecurityStamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns ZonedDateTime.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns null
}
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
avatarColorHex = "updatedAvatarColor",
stamp = "updatedSecurityStamp",
hasPremium = true,
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = keyConnectorOptions,
masterPasswordUnlock = null,
),
),
),
),
),
originalUserState.toUpdatedUserStateJson(syncResponse),
)
}
}
private val MOCK_MASTER_PASSWORD_UNLOCK_DATA = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = mockk(),
masterKeyWrappedUserKey = "masterKeyWrappedUserKeyMock",
)

View File

@@ -421,6 +421,7 @@ private val MOCK_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDecryp
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = MOCK_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
private val MOCK_PROFILE = AccountJson.Profile(

View File

@@ -1292,6 +1292,7 @@ private val MOCK_USER_DECRYPTION_OPTIONS: UserDecryptionOptionsJson = UserDecryp
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = MOCK_TRUSTED_DEVICE_USER_DECRYPTION_OPTIONS,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
private val MOCK_PROFILE = AccountJson.Profile(

View File

@@ -804,6 +804,7 @@ private val USER_STATE = UserStateJson(
keyConnectorUserDecryptionOptions = KeyConnectorUserDecryptionOptionsJson(
keyConnectorUrl = "keyConnectorUrl",
),
masterPasswordUnlock = null,
),
isTwoFactorEnabled = false,
creationDate = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),

View File

@@ -356,6 +356,7 @@ private val VAULT_DATA: SyncResponseJson = SyncResponseJson(
policies = null,
domains = DOMAINS_1,
sends = listOf(SEND_1),
userDecryption = null,
)
private const val CIPHER_JSON = """

View File

@@ -0,0 +1,93 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.util
import android.util.Base64
import com.bitwarden.fido.AuthenticatorAttestationResponse
import com.bitwarden.fido.ClientExtensionResults
import com.bitwarden.fido.CredPropsResult
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAttestationResponse
import com.bitwarden.fido.SelectedCredential
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFido2CredentialView
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class PublicKeyCredentialAuthenticatorAttestationResponseExtensionsTest {
@BeforeEach
fun setUp() {
mockkStatic(Base64::class)
every { Base64.encodeToString(any(), any()) } returns ""
}
@AfterEach
fun tearDown() {
unmockkStatic(Base64::class)
}
@Test
fun `authenticatorAttachment should be null when SDK value is null`() {
val mockSdkResponse = createMockSdkAttestationResponse(number = 1)
val result = mockSdkResponse.toAndroidAttestationResponse()
assertNull(result.authenticatorAttachment)
}
@Test
fun `authenticatorAttachment should be populated when SDK value is non-null`() {
val mockSdkResponse = createMockSdkAttestationResponse(
number = 1,
authenticatorAttachment = "mockAuthenticatorAttachment",
)
val result = mockSdkResponse.toAndroidAttestationResponse()
assertNotNull(result.authenticatorAttachment)
}
@Test
fun `clientExtensionResults should be populated when SDK value is null`() {
val mockSdkResponse = createMockSdkAttestationResponse(number = 1)
val result = mockSdkResponse.toAndroidAttestationResponse()
assertNotNull(result.clientExtensionResults)
}
@Test
fun `residentKey should be populated when SDK value is non-null`() {
val mockSdkResponse = createMockSdkAttestationResponse(
number = 1,
credProps = CredPropsResult(
rk = true,
authenticatorDisplayName = null,
),
)
val result = mockSdkResponse.toAndroidAttestationResponse()
assert(result.clientExtensionResults.credentialProperties?.residentKey ?: false)
}
}
private fun createMockSdkAttestationResponse(
number: Int,
authenticatorAttachment: String? = null,
credProps: CredPropsResult? = null,
) = PublicKeyCredentialAuthenticatorAttestationResponse(
id = "mockId-$number",
rawId = byteArrayOf(0),
ty = "mockTy-$number",
authenticatorAttachment = authenticatorAttachment,
clientExtensionResults = ClientExtensionResults(credProps),
response = AuthenticatorAttestationResponse(
clientDataJson = byteArrayOf(0),
authenticatorData = byteArrayOf(0),
publicKey = byteArrayOf(0),
publicKeyAlgorithm = Long.MIN_VALUE,
attestationObject = byteArrayOf(0),
transports = listOf("internal"),
),
selectedCredential = SelectedCredential(
cipher = createMockCipherView(number = 1),
credential = createMockFido2CredentialView(number = 1),
),
)

View File

@@ -9,6 +9,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.util.assertNoDialogExists
@@ -34,7 +35,9 @@ import org.junit.Test
class SetupBrowserAutofillScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false
private val intentManager = mockk<IntentManager>()
private val intentManager = mockk<IntentManager> {
every { launchUri(uri = any()) } just runs
}
private val mutableEventFlow = bufferedMutableSharedFlow<SetupBrowserAutofillEvent>()
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@@ -68,6 +71,16 @@ class SetupBrowserAutofillScreenTest : BitwardenComposeTest() {
assertTrue(onNavigateBackCalled)
}
@Test
fun `NavigateToBrowserIntegrationsInfo should call onNavigateBack`() {
mutableEventFlow.tryEmit(SetupBrowserAutofillEvent.NavigateToBrowserIntegrationsInfo)
verify(exactly = 1) {
intentManager.launchUri(
uri = "https://bitwarden.com/help/auto-fill-android/#browser-integrations/".toUri(),
)
}
}
@Test
fun `NavigateToBrowserAutofillSettings should start system autofill settings activity`() {
val browserPackage = BrowserPackage.CHROME_STABLE
@@ -112,6 +125,18 @@ class SetupBrowserAutofillScreenTest : BitwardenComposeTest() {
composeTestRule.onNodeWithContentDescription(label = "Close").assertExists()
}
@Test
fun `why is this step required button click should emit WhyIsThisStepRequiredClick`() {
mutableStateFlow.update { it.copy(isInitialSetup = false) }
composeTestRule
.onNodeWithText(text = "Why is this step required?")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(SetupBrowserAutofillAction.WhyIsThisStepRequiredClick)
}
}
@Test
fun `close button click should emit CloseClick`() {
mutableStateFlow.update { it.copy(isInitialSetup = false) }

View File

@@ -101,6 +101,19 @@ class SetupBrowserAutofillViewModelTest {
}
}
@Test
fun `WhyIsThisStepRequiredClick should send NavigateToBrowserIntegrationsInfo event`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SetupBrowserAutofillAction.WhyIsThisStepRequiredClick)
assertEquals(
SetupBrowserAutofillEvent.NavigateToBrowserIntegrationsInfo,
awaitItem(),
)
}
}
@Test
fun `BrowserIntegrationClick should send NavigateToBrowserAutofillSettings event`() = runTest {
val browserPackage = BrowserPackage.BRAVE_RELEASE

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.addedit.util
import com.bitwarden.collections.CollectionType
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -453,7 +454,18 @@ class CipherViewExtensionsTest {
@Test
fun `appendFolderAndOwnerData should append folder and owner data`() {
val viewState = createSecureNoteViewState(withFolderAndOwnerData = false)
val viewState = createSecureNoteViewState(
availableOwners = listOf(
USER_OWNER,
ORGANIZATION_OWNER,
),
availableFolders = listOf(
NO_FOLDER_ITEM,
MOCK_FOLDER_ITEM,
),
selectedFolderId = null,
selectedOwnerId = null,
)
val account = createAccount()
val folderView = listOf(createMockFolderView(number = 1))
val collectionList = listOf(createMockCollectionView(number = 1))
@@ -466,15 +478,74 @@ class CipherViewExtensionsTest {
resourceManager = resourceManager,
)
val expected = createSecureNoteViewState(
availableOwners = listOf(
USER_OWNER,
ORGANIZATION_OWNER,
),
availableFolders = listOf(
NO_FOLDER_ITEM,
MOCK_FOLDER_ITEM,
),
selectedFolderId = MOCK_FOLDER_ITEM.id,
selectedOwnerId = ORGANIZATION_OWNER.id,
)
assertEquals(
createSecureNoteViewState(withFolderAndOwnerData = true),
expected,
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `appendFolderAndOwnerData should append correct owner data if individual vault is disabled`() {
val mockCipherView = createMockCipherView(number = 1, organizationId = null)
val viewState = createSecureNoteViewState(
cipherView = mockCipherView,
availableOwners = listOf(USER_OWNER, ORGANIZATION_OWNER),
availableFolders = emptyList(),
selectedOwnerId = null,
selectedFolderId = null,
)
val account = createAccount()
val collectionList = listOf(
createMockCollectionView(
number = 1,
type = CollectionType.DEFAULT_USER_COLLECTION,
),
)
val result = viewState.appendFolderAndOwnerData(
folderViewList = emptyList(),
collectionViewList = collectionList,
activeAccount = account,
isIndividualVaultDisabled = true,
resourceManager = resourceManager,
)
val expected = createSecureNoteViewState(
cipherView = mockCipherView,
availableOwners = listOf(ORGANIZATION_OWNER),
availableFolders = listOf(NO_FOLDER_ITEM),
)
assertEquals(
expected,
result,
)
}
private fun createSecureNoteViewState(
cipherView: CipherView = createMockCipherView(number = 1),
withFolderAndOwnerData: Boolean,
availableOwners: List<VaultAddEditState.Owner> = listOf(
USER_OWNER,
ORGANIZATION_OWNER,
),
availableFolders: List<VaultAddEditState.Folder> = listOf(
NO_FOLDER_ITEM,
MOCK_FOLDER_ITEM,
),
selectedFolderId: String? = availableFolders.firstOrNull()?.id,
selectedOwnerId: String? = availableOwners.firstOrNull()?.id,
): VaultAddEditState.ViewState.Content =
VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(
@@ -504,39 +575,21 @@ class CipherViewExtensionsTest {
availableOwners = emptyList(),
)
.let {
if (withFolderAndOwnerData) {
if (availableOwners.isNotEmpty()) {
it.copy(
selectedFolderId = "mockId-1",
selectedOwnerId = "mockOrganizationId-1",
availableFolders = listOf(
VaultAddEditState.Folder(
id = null,
name = "No Folder",
),
VaultAddEditState.Folder(
id = "mockId-1",
name = "mockName-1",
),
),
selectedOwnerId = selectedOwnerId,
hasOrganizations = true,
availableOwners = listOf(
VaultAddEditState.Owner(
id = null,
name = "activeEmail",
collections = emptyList(),
),
VaultAddEditState.Owner(
id = "mockOrganizationId-1",
name = "organizationName",
collections = listOf(
VaultCollection(
id = "mockId-1",
name = "mockName-1",
isSelected = true,
),
),
),
),
availableOwners = availableOwners,
)
} else {
it
}
}
.let {
if (availableFolders.isNotEmpty()) {
it.copy(
selectedFolderId = selectedFolderId,
availableFolders = availableFolders,
)
} else {
it
@@ -734,3 +787,27 @@ private val DEFAULT_SSH_KEY_CIPHER_VIEW: CipherView = DEFAULT_BASE_CIPHER_VIEW.c
)
private const val TEST_ID = "testID"
private val NO_FOLDER_ITEM = VaultAddEditState.Folder(
id = null,
name = "No Folder",
)
private val MOCK_FOLDER_ITEM = VaultAddEditState.Folder(
id = "mockId-1",
name = "mockName-1",
)
private val ORGANIZATION_OWNER = VaultAddEditState.Owner(
id = "mockOrganizationId-1",
name = "organizationName",
collections = listOf(
VaultCollection(
id = "mockId-1",
name = "mockName-1",
isSelected = true,
),
),
)
private val USER_OWNER = VaultAddEditState.Owner(
id = null,
name = "activeEmail",
collections = emptyList(),
)

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems
import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.isNotDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
@@ -124,6 +125,42 @@ class SelectAccountScreenTest : BitwardenComposeTest() {
assertTrue(onAccountSelectedCalled)
}
@Test
fun `NoItemsContent should be displayed according to state`() = runTest {
mockkStateFlow.emit(
DEFAULT_STATE.copy(
viewState = SelectAccountState.ViewState.NoItems,
),
)
composeTestRule
.onNodeWithText("No accounts available")
.isDisplayed()
composeTestRule
.onNodeWithText(
text = "You don't have any accounts you can import from.",
substring = true,
)
.isDisplayed()
composeTestRule
.onNodeWithText("Select an account")
.isNotDisplayed()
}
@Test
fun `Loading content should be displayed according to state`() = runTest {
mockkStateFlow.emit(
DEFAULT_STATE.copy(
viewState = SelectAccountState.ViewState.Loading,
),
)
composeTestRule
.onNodeWithText("Loading")
.isDisplayed()
}
}
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
@@ -148,20 +185,22 @@ private val LOCKED_ACCOUNT_SUMMARY = AccountSummary(
)
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,
viewState = SelectAccountState.ViewState.Content(
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,
),
),
),
)

View File

@@ -50,11 +50,8 @@ class SelectAccountViewModelTest : BaseViewModelTest() {
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
assertEquals(
SelectAccountState(
accountSelectionListItems = persistentListOf(),
),
SelectAccountState(viewState = SelectAccountState.ViewState.Loading),
viewModel.stateFlow.value,
)
}
@@ -79,7 +76,11 @@ class SelectAccountViewModelTest : BaseViewModelTest() {
)
assertEquals(
SelectAccountState(accountSelectionListItems = persistentListOf(expectedItem)),
SelectAccountState(
viewState = SelectAccountState.ViewState.Content(
accountSelectionListItems = persistentListOf(expectedItem),
),
),
viewModel.stateFlow.value,
)
}
@@ -116,9 +117,7 @@ class SelectAccountViewModelTest : BaseViewModelTest() {
),
)
assertEquals(
SelectAccountState(
accountSelectionListItems = persistentListOf(),
),
SelectAccountState(viewState = SelectAccountState.ViewState.NoItems),
viewModel.stateFlow.value,
)
}
@@ -163,7 +162,11 @@ class SelectAccountViewModelTest : BaseViewModelTest() {
),
)
assertEquals(
SelectAccountState(accountSelectionListItems = persistentListOf(expectedItem)),
SelectAccountState(
viewState = SelectAccountState.ViewState.Content(
accountSelectionListItems = persistentListOf(expectedItem),
),
),
viewModel.stateFlow.value,
)
}

View File

@@ -1,12 +1,18 @@
package com.bitwarden.authenticator.data.platform.manager
import com.bitwarden.core.ClientManagedTokens
import com.bitwarden.sdk.Client
/**
* Primary implementation of [SdkClientManager].
*/
class SdkClientManagerImpl(
private val clientProvider: suspend () -> Client = { Client(null) },
private val clientProvider: suspend () -> Client = {
Client(
tokenProvider = Token(),
settings = null,
)
},
) : SdkClientManager {
private var client: Client? = null
@@ -15,4 +21,13 @@ class SdkClientManagerImpl(
override fun destroyClient() {
client = null
}
/**
* The token provider to pass to the SDK.
*/
private class Token : ClientManagedTokens {
override suspend fun getAccessToken(): String? {
return null
}
}
}

View File

@@ -18,7 +18,7 @@ androidxAppCompat = "1.7.1"
androdixAutofill = "1.3.0"
androidxBiometrics = "1.2.0-alpha05"
androidxBrowser = "1.9.0"
androidxCamera = "1.4.2"
androidxCamera = "1.5.0"
androidxComposeBom = "2025.09.01"
androidxCore = "1.17.0"
androidxCredentials = "1.6.0-beta01"
@@ -30,17 +30,17 @@ androidxRoom = "2.8.1"
androidxSecurityCrypto = "1.1.0"
androidxSplash = "1.1.0-rc01"
androidxWork = "2.10.5"
bitwardenSdk = "1.0.0-3175-c9758478"
bitwardenSdk = "1.0.0-3194-9947387b"
crashlytics = "3.0.6"
detekt = "1.23.8"
firebaseBom = "34.2.0"
firebaseBom = "34.3.0"
glide = "1.0.0-beta01"
googleGuava = "33.4.8-jre"
googleGuava = "33.5.0-jre"
googleProtoBufJava = "4.32.1"
googleProtoBufPlugin = "0.9.5"
googleServices = "4.4.3"
googleReview = "2.0.2"
hilt = "2.57.1"
hilt = "2.57.2"
junit5 = "5.13.4"
jvmTarget = "21"
# kotlin and ksp **must** use compatible versions, do not update either without the other.
@@ -48,13 +48,13 @@ kotlin = "2.2.20"
kotlinxCollectionsImmutable = "0.4.0"
kotlinxCoroutines = "1.10.2"
kotlinxSerialization = "1.9.0"
kotlinxKover = "0.9.1"
kotlinxKover = "0.9.2"
ksp = "2.2.20-2.0.2"
mockk = "1.14.5"
okhttp = "5.1.0"
retrofitBom = "3.0.0"
robolectric = "4.16"
sonarqube = "6.2.0.5505"
sonarqube = "6.3.1.5724"
testng = "7.11.0"
timber = "5.0.1"
turbine = "1.2.1"

View File

@@ -0,0 +1,22 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the data used to create the kdf settings.
*/
@Serializable
data class KdfJson(
@SerialName("KdfType")
val kdfType: KdfTypeJson,
@SerialName("Iterations")
val iterations: Int,
@SerialName("Memory")
val memory: Int?,
@SerialName("Parallelism")
val parallelism: Int?,
)

View File

@@ -0,0 +1,25 @@
package com.bitwarden.network.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
/**
* Represents the data used to unlock with the master password.
*/
@Serializable
@OptIn(ExperimentalSerializationApi::class)
data class MasterPasswordUnlockDataJson(
@SerialName("Salt")
val salt: String,
@SerialName("Kdf")
val kdf: KdfJson,
// TODO: PM-26397 this was done due to naming inconsistency server side,
// should be cleaned up when server side is updated
@SerialName("MasterKeyWrappedUserKey")
@JsonNames("MasterKeyEncryptedUserKey")
val masterKeyWrappedUserKey: String,
)

View File

@@ -48,6 +48,9 @@ data class SyncResponseJson(
@SerialName("sends")
val sends: List<Send>?,
@SerialName("UserDecryption")
val userDecryption: UserDecryptionJson?,
) {
/**
* Represents domains in the vault response.

View File

@@ -0,0 +1,13 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the user decryption options received on sync.
*/
@Serializable
data class UserDecryptionJson(
@SerialName("MasterPasswordUnlock")
val masterPasswordUnlock: MasterPasswordUnlockDataJson?,
)

View File

@@ -21,6 +21,10 @@ data class UserDecryptionOptionsJson(
@JsonNames("HasMasterPassword")
val hasMasterPassword: Boolean,
@SerialName("masterPasswordUnlock")
@JsonNames("MasterPasswordUnlock")
val masterPasswordUnlock: MasterPasswordUnlockDataJson?,
@SerialName("trustedDeviceOption")
@JsonNames("TrustedDeviceOption")
val trustedDeviceUserDecryptionOptions: TrustedDeviceUserDecryptionOptionsJson?,

View File

@@ -626,6 +626,7 @@ private val LOGIN_SUCCESS = GetTokenResponseJson.Success(
keyConnectorUserDecryptionOptions = KeyConnectorUserDecryptionOptionsJson(
keyConnectorUrl = "keyConnectorUrl",
),
masterPasswordUnlock = null,
),
keyConnectorUrl = "keyConnectorUrl",
)

View File

@@ -13,6 +13,7 @@ fun createMockSyncResponse(
policies: List<SyncResponseJson.Policy> = listOf(createMockPolicy(number = number)),
domains: SyncResponseJson.Domains = createMockDomains(number = number),
sends: List<SyncResponseJson.Send> = listOf(createMockSend(number = number)),
userDecryption: UserDecryptionJson? = null,
): SyncResponseJson =
SyncResponseJson(
folders = folders,
@@ -22,4 +23,5 @@ fun createMockSyncResponse(
policies = policies,
domains = domains,
sends = sends,
userDecryption = userDecryption,
)

View File

@@ -3,6 +3,7 @@ package com.bitwarden.ui.platform.components.content
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.layout.navigationBarsPadding
@@ -12,11 +13,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.nullableTestTag
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.icon.BitwardenIcon
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
@@ -28,6 +32,8 @@ fun BitwardenEmptyContent(
modifier: Modifier = Modifier,
illustrationData: IconData? = null,
labelTestTag: String? = null,
title: String? = null,
titleTestTag: String? = null,
) {
Column(
modifier = modifier,
@@ -41,6 +47,19 @@ fun BitwardenEmptyContent(
)
Spacer(modifier = Modifier.height(height = 24.dp))
}
title?.let {
Text(
text = title,
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.nullableTestTag(tag = titleTestTag),
)
Spacer(modifier = Modifier.height(height = 8.dp))
}
Text(
text = text,
style = BitwardenTheme.typography.bodyMedium,
@@ -54,3 +73,20 @@ fun BitwardenEmptyContent(
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Preview(showBackground = true, name = "Bitwarden empty content")
@Composable
private fun BitwardenEmptyContent_preview() {
BitwardenScaffold {
BitwardenEmptyContent(
title = "Empty content",
titleTestTag = "TitleTestTag",
text = "There is no content to display",
labelTestTag = "EmptyContentLabel",
illustrationData = IconData.Local(BitwardenDrawable.ic_empty_vault),
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}
}

View File

@@ -3,6 +3,7 @@ package com.bitwarden.ui.platform.components.content
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size
@@ -11,8 +12,11 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.indicator.BitwardenCircularProgressIndicator
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
@@ -46,3 +50,16 @@ fun BitwardenLoadingContent(
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Preview(showBackground = true, name = "Bitwarden loading content")
@Composable
private fun BitwardenLoadingContent_preview() {
BitwardenScaffold {
BitwardenLoadingContent(
text = "Loading...",
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}
}

View File

@@ -1,4 +1,4 @@
package com.x8bit.bitwarden.ui.platform.feature.debugmenu.components
package com.bitwarden.ui.platform.components.debug
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier

View File

@@ -36,6 +36,7 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
* Displays a dialog with a title and "Cancel" button.
*
* @param title Title to display.
* @param subTitle The subtitle to display
* @param onDismissRequest Invoked when the user dismisses the dialog.
* @param selectionItems Lambda containing selection items to show to the user. See
* [BitwardenSelectionRow].
@@ -44,6 +45,7 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
@Composable
fun BitwardenSelectionDialog(
title: String,
subTitle: String? = null,
onDismissRequest: () -> Unit,
selectionItems: @Composable ColumnScope.() -> Unit = {},
) {
@@ -83,6 +85,17 @@ fun BitwardenSelectionDialog(
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.headlineSmall,
)
subTitle?.let {
Text(
modifier = Modifier
.testTag("AlertSubTitleText")
.padding(start = 24.dp, end = 24.dp, bottom = 24.dp)
.fillMaxWidth(),
text = it,
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.bodyMedium,
)
}
if (canScrollBackward) {
BitwardenHorizontalDivider()
}

View File

@@ -47,6 +47,7 @@ import kotlinx.collections.immutable.toImmutableList
* @param isEnabled Whether or not the button is enabled.
* @param cardStyle Indicates the type of card style to be applied.
* @param modifier A [Modifier] that you can use to apply custom modifications to the composable.
* @param dialogSubtitle The subtitle to apply to the dialog.
* @param supportingText A optional supporting text that will appear below the text field.
* @param tooltip A nullable [TooltipData], representing the tooltip icon.
* @param insets Inner padding to be applied withing the card.
@@ -64,6 +65,7 @@ fun BitwardenMultiSelectButton(
onOptionSelected: (String) -> Unit,
cardStyle: CardStyle?,
modifier: Modifier = Modifier,
dialogSubtitle: String? = null,
isEnabled: Boolean = true,
supportingText: String? = null,
tooltip: TooltipData? = null,
@@ -74,6 +76,7 @@ fun BitwardenMultiSelectButton(
) {
BitwardenMultiSelectButton(
label = label,
dialogSubtitle = dialogSubtitle,
options = options.map { MultiSelectOption.Row(it) }.toImmutableList(),
selectedOption = selectedOption?.let { MultiSelectOption.Row(it) },
onOptionSelected = { onOptionSelected(it.title) },
@@ -130,6 +133,7 @@ fun BitwardenMultiSelectButton(
onOptionSelected: (MultiSelectOption.Row) -> Unit,
cardStyle: CardStyle?,
modifier: Modifier = Modifier,
dialogSubtitle: String? = null,
isEnabled: Boolean = true,
supportingContent: @Composable (ColumnScope.() -> Unit)?,
tooltip: TooltipData? = null,
@@ -161,6 +165,7 @@ fun BitwardenMultiSelectButton(
if (shouldShowDialog) {
BitwardenSelectionDialog(
title = label,
subTitle = dialogSubtitle,
onDismissRequest = { shouldShowDialog = false },
) {
BitwardenMultiSelectDialogContent(

View File

@@ -0,0 +1,215 @@
package com.bitwarden.ui.platform.components.fab
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.nullableTestTag
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.Text
import kotlinx.collections.immutable.ImmutableList
/**
* A FAB that expands, when clicked, to display a collection of options that can be clicked.
*
* @param expandableFabIcon The icon to display and how to display it.
* @param items [ExpandableFabOption] buttons displayed when the FAB is expanded.
* @param label [Text] displayed when the FAB is expanded.
* @param modifier The modifier for this composable.
* @param expandableFabState [ExpandableFabIcon] displayed in the FAB.
* @param onStateChange Lambda invoked when the FAB expanded state changes.
*/
@Suppress("LongMethod")
@Composable
fun <T : ExpandableFabOption> BitwardenExpandableFloatingActionButton(
expandableFabIcon: ExpandableFabIcon,
items: ImmutableList<T>,
modifier: Modifier = Modifier,
label: Text? = null,
expandableFabState: MutableState<ExpandableFabState> = rememberExpandableFabState(),
onStateChange: (expandableFabState: ExpandableFabState) -> Unit = { },
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.Bottom,
modifier = modifier,
) {
AnimatedVisibility(
visible = expandableFabState.value.isExpanded(),
label = "display_fab_options_animation",
modifier = Modifier.weight(weight = 1f),
) {
LazyColumn(
modifier = Modifier
.clickable(interactionSource = null, indication = null) {
expandableFabState.value = ExpandableFabState.Collapsed
}
.fillMaxSize(),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(
space = 12.dp,
alignment = Alignment.Bottom,
),
contentPadding = PaddingValues(bottom = 16.dp),
) {
items(items) { expandableFabOption ->
ExpandableFabOption(
onFabOptionClick = {
expandableFabState.value = expandableFabState.value.toggleValue()
onStateChange(expandableFabState.value)
expandableFabOption.onFabOptionClick()
},
expandableFabOption = expandableFabOption,
)
}
}
}
val rotation by animateFloatAsState(
targetValue = if (expandableFabState.value.isExpanded()) {
expandableFabIcon.iconRotation ?: 0f
} else {
0f
},
label = "add_item_rotation",
)
ExtendedFloatingActionButton(
onClick = {
expandableFabState.value = expandableFabState.value.toggleValue()
onStateChange(expandableFabState.value)
},
expanded = if (label != null) {
!expandableFabState.value.isExpanded()
} else {
false
},
containerColor = BitwardenTheme.colorScheme.filledButton.background,
contentColor = BitwardenTheme.colorScheme.filledButton.foreground,
shape = BitwardenTheme.shapes.fab,
text = {
label?.let {
Text(
text = it(),
style = BitwardenTheme.typography.labelMedium,
)
}
},
icon = {
Icon(
painter = rememberVectorPainter(id = expandableFabIcon.icon.iconRes),
contentDescription = expandableFabIcon.icon.contentDescription?.invoke(),
modifier = Modifier
.rotate(degrees = rotation)
.nullableTestTag(tag = expandableFabIcon.icon.testTag),
)
},
)
}
}
@Composable
private fun <T : ExpandableFabOption> ExpandableFabOption(
expandableFabOption: T,
onFabOptionClick: (option: T) -> Unit,
modifier: Modifier = Modifier,
) {
SmallFloatingActionButton(
onClick = { onFabOptionClick(expandableFabOption) },
containerColor = BitwardenTheme.colorScheme.filledButton.background,
contentColor = BitwardenTheme.colorScheme.filledButton.foreground,
shape = BitwardenTheme.shapes.fabItem,
modifier = modifier,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(space = 12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.wrapContentSize()
.padding(start = 8.dp, end = 16.dp),
) {
Text(
text = expandableFabOption.label(),
style = BitwardenTheme.typography.labelSmall,
modifier = Modifier.padding(all = 8.dp),
)
Icon(
painter = rememberVectorPainter(id = expandableFabOption.icon.iconRes),
contentDescription = expandableFabOption.icon.contentDescription?.invoke(),
modifier = Modifier.nullableTestTag(tag = expandableFabOption.icon.testTag),
)
}
}
}
@Composable
private fun rememberExpandableFabState(): MutableState<ExpandableFabState> =
remember { mutableStateOf(ExpandableFabState.Collapsed) }
/**
* Represents options displayed when the FAB is expanded.
*/
abstract class ExpandableFabOption(
val label: Text,
val icon: IconData.Local,
val onFabOptionClick: () -> Unit,
)
/**
* Models data for an expandable FAB icon.
*/
data class ExpandableFabIcon(
val icon: IconData.Local,
val iconRotation: Float?,
)
/**
* Models the state of the expandable FAB.
*/
sealed class ExpandableFabState {
/**
* Indicates if the FAB is expanded.
*/
fun isExpanded(): Boolean = this == Expanded
/**
* Invert the state of the FAB.
*/
fun toggleValue(): ExpandableFabState = if (isExpanded()) {
Collapsed
} else {
Expanded
}
/**
* Indicates the FAB is collapsed.
*/
data object Collapsed : ExpandableFabState()
/**
* Indicates the FAB is expanded.
*/
data object Expanded : ExpandableFabState()
}

View File

@@ -22,12 +22,15 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
* @param timeLeftSeconds The seconds left on the timer.
* @param periodSeconds The period for the timer countdown.
* @param modifier A [Modifier] for the composable.
* @param alertThresholdSeconds The threshold at which the progress indicator should change to an
* alert color.
*/
@Composable
fun BitwardenCircularCountdownIndicator(
timeLeftSeconds: Int,
periodSeconds: Int,
modifier: Modifier = Modifier,
alertThresholdSeconds: Int = -1,
) {
val progressAnimate by animateFloatAsState(
targetValue = timeLeftSeconds.toFloat() / periodSeconds,
@@ -46,7 +49,11 @@ fun BitwardenCircularCountdownIndicator(
CircularProgressIndicator(
progress = { progressAnimate },
modifier = Modifier.size(size = 30.dp),
color = BitwardenTheme.colorScheme.icon.secondary,
color = if (timeLeftSeconds > alertThresholdSeconds) {
BitwardenTheme.colorScheme.icon.secondary
} else {
BitwardenTheme.colorScheme.status.error
},
trackColor = Color.Transparent,
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round,
@@ -55,7 +62,11 @@ fun BitwardenCircularCountdownIndicator(
Text(
text = timeLeftSeconds.toString(),
style = BitwardenTheme.typography.bodySmall,
color = BitwardenTheme.colorScheme.text.primary,
color = if (timeLeftSeconds > alertThresholdSeconds) {
BitwardenTheme.colorScheme.text.primary
} else {
BitwardenTheme.colorScheme.status.error
},
)
}
}

View File

@@ -55,7 +55,7 @@ fun BitwardenNavigationRail(
.fillMaxHeight()
.windowInsetsPadding(insets = windowInsets)
.widthIn(min = 80.dp)
.padding(vertical = 4.dp)
.padding(all = 4.dp)
.selectableGroup()
.verticalScroll(state = rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,

View File

@@ -8,6 +8,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import com.bitwarden.ui.platform.base.util.mirrorIfRtl
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
@@ -41,7 +42,7 @@ fun BitwardenExternalLinkRow(
onConfirmClick: () -> Unit,
cardStyle: CardStyle,
modifier: Modifier = Modifier,
description: String? = null,
description: AnnotatedString? = null,
withDivider: Boolean = true,
dialogTitle: String,
dialogMessage: String,

View File

@@ -16,10 +16,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.nullableTestTag
import com.bitwarden.ui.platform.base.util.toAnnotatedString
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
import com.bitwarden.ui.platform.components.model.CardStyle
@@ -53,7 +55,7 @@ fun BitwardenTextRow(
onClick: () -> Unit,
cardStyle: CardStyle,
modifier: Modifier = Modifier,
description: String? = null,
description: AnnotatedString? = null,
textTestTag: String? = null,
isEnabled: Boolean = true,
clickable: Boolean = isEnabled,
@@ -138,7 +140,7 @@ private fun BitwardenTextRowWithTooltipAndContent_Preview() {
text = "Sample Text",
onClick = {},
cardStyle = CardStyle.Full,
description = "This is a sample description.",
description = "This is a sample description.".toAnnotatedString(),
textTestTag = "sampleTestTag",
isEnabled = true,
withDivider = false,
@@ -156,7 +158,7 @@ private fun BitwardenTextRowWithDividerDisabled_Preview() {
text = "Sample Text Disabled",
onClick = {},
cardStyle = CardStyle.Top(),
description = "This is a sample disabled description.",
description = "This is a sample disabled description.".toAnnotatedString(),
textTestTag = "sampleDisabledTestTag",
isEnabled = false,
withDivider = true,

View File

@@ -17,6 +17,7 @@ data class BitwardenShapes(
val contentTop: CornerBasedShape,
val dialog: CornerBasedShape,
val fab: CornerBasedShape,
val fabItem: CornerBasedShape,
val infoCallout: CornerBasedShape,
val menu: CornerBasedShape,
val progressIndicator: CornerBasedShape,

View File

@@ -17,6 +17,7 @@ val bitwardenShapes: BitwardenShapes = BitwardenShapes(
contentTop = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp),
dialog = RoundedCornerShape(size = 28.dp),
fab = CircleShape,
fabItem = RoundedCornerShape(size = 12.dp),
infoCallout = RoundedCornerShape(size = 8.dp),
menu = RoundedCornerShape(size = 4.dp),
progressIndicator = CircleShape,

View File

@@ -1119,6 +1119,8 @@ Do you want to switch to this account?</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="no_accounts_available">No accounts available</string>
<string name="you_dont_have_any_accounts_you_can_import_from">You dont have any accounts you can import from. Your organizations security policy may restrict importing items from Bitwarden to another app.</string>
<string name="import_restricted_unable_to_import_credit_cards">Import restricted, unable to import cards from this account.</string>
<string name="verify_your_master_password">Verify your master password</string>
<string name="having_trouble_with_autofill">Having trouble with autofill?</string>
@@ -1130,4 +1132,5 @@ Do you want to switch to this account?</string>
<string name="passwords">Passwords</string>
<string name="passkeys">Passkeys</string>
<string name="import_verb">Import</string>
<string name="why_is_this_step_required">Why is this step required?</string>
</resources>