mirror of
https://github.com/bitwarden/android.git
synced 2026-05-10 16:45:43 -05:00
Compare commits
15 Commits
QA-1126b/a
...
v2025.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc9113f9a | ||
|
|
2eb829a25b | ||
|
|
04a1d4118f | ||
|
|
9f63cede11 | ||
|
|
a93037d63e | ||
|
|
4e57f306d3 | ||
|
|
1638a20bf0 | ||
|
|
874edfad69 | ||
|
|
0469731fba | ||
|
|
0abfa5bb97 | ||
|
|
13e6728d46 | ||
|
|
116bfd6351 | ||
|
|
6ca8a39355 | ||
|
|
24a54ce214 | ||
|
|
8d76ef50d3 |
3
.github/renovate.json
vendored
3
.github/renovate.json
vendored
@@ -27,6 +27,9 @@
|
||||
],
|
||||
"matchManagers": [
|
||||
"gradle"
|
||||
],
|
||||
"excludePackageNames": [
|
||||
"com.github.bumptech.glide:compose"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ fun TrustDeviceResponse.toUserStateJson(
|
||||
hasMasterPassword = false,
|
||||
trustedDeviceUserDecryptionOptions = null,
|
||||
keyConnectorUserDecryptionOptions = null,
|
||||
masterPasswordUnlock = null,
|
||||
)
|
||||
val deviceOptions = decryptionOptions
|
||||
.trustedDeviceUserDecryptionOptions
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,7 +31,7 @@ fun PublicKeyCredentialAuthenticatorAttestationResponse.toAndroidAttestationResp
|
||||
.ClientExtensionResults
|
||||
.CredentialProperties(residentKey = residentKey),
|
||||
)
|
||||
},
|
||||
} ?: Fido2AttestationResponse.ClientExtensionResults(),
|
||||
authenticatorAttachment = authenticatorAttachment,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = { },
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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].
|
||||
*/
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1463,6 +1463,7 @@ private val USER_STATE = UserStateJson(
|
||||
keyConnectorUserDecryptionOptions = KeyConnectorUserDecryptionOptionsJson(
|
||||
keyConnectorUrl = "keyConnectorUrl",
|
||||
),
|
||||
masterPasswordUnlock = null,
|
||||
),
|
||||
),
|
||||
tokens = AccountTokensJson(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -48,6 +48,9 @@ data class SyncResponseJson(
|
||||
|
||||
@SerialName("sends")
|
||||
val sends: List<Send>?,
|
||||
|
||||
@SerialName("UserDecryption")
|
||||
val userDecryption: UserDecryptionJson?,
|
||||
) {
|
||||
/**
|
||||
* Represents domains in the vault response.
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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?,
|
||||
|
||||
@@ -626,6 +626,7 @@ private val LOGIN_SUCCESS = GetTokenResponseJson.Success(
|
||||
keyConnectorUserDecryptionOptions = KeyConnectorUserDecryptionOptionsJson(
|
||||
keyConnectorUrl = "keyConnectorUrl",
|
||||
),
|
||||
masterPasswordUnlock = null,
|
||||
),
|
||||
keyConnectorUrl = "keyConnectorUrl",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 don’t have any accounts you can import from. Your organization’s 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>
|
||||
|
||||
Reference in New Issue
Block a user