Merge branch 'main' into pm-23278/kdf-upgrade-minimum

This commit is contained in:
André Bispo committed 2025-09-12 17:02:12 +01:00
commit d07e8f9ae2
144 files changed
+3701 -2302

No files matched your search

+1 -1
View File
@@ -249,7 +249,7 @@
android:name="com.x8bit.bitwarden.AutofillTileService"
android:exported="true"
android:icon="@drawable/ic_notification"
android:label="@string/autofill"
android:label="@string/autofill_title"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import timber.log.Timber
import java.time.Clock
import java.time.ZoneOffset
import java.time.ZonedDateTime
@@ -129,8 +130,8 @@ class PushManagerImpl @Inject constructor(
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun onMessageReceived(notification: BitwardenNotification) {
if (authDiskSource.uniqueAppId == notification.contextId) return
val userId = activeUserId ?: return
Timber.d("Push Notification Received: ${notification.notificationType}")
when (val type = notification.notificationType) {
NotificationType.AUTH_REQUEST,
@@ -189,9 +190,14 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncCipherNotification>(
string = notification.payload,
)
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.cipherId
?.let { mutableSyncCipherDeleteSharedFlow.tryEmit(SyncCipherDeleteData(it)) }
.takeIf { it.userId != null && it.cipherId != null }
?.let {
SyncCipherDeleteData(
userId = requireNotNull(it.userId),
cipherId = requireNotNull(it.cipherId),
)
}
?.let { mutableSyncCipherDeleteSharedFlow.tryEmit(it) }
}
NotificationType.SYNC_CIPHERS,
@@ -226,9 +232,14 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncFolderNotification>(
string = notification.payload,
)
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.folderId
?.let { mutableSyncFolderDeleteSharedFlow.tryEmit(SyncFolderDeleteData(it)) }
.takeIf { it.userId != null && it.folderId != null }
?.let {
SyncFolderDeleteData(
userId = requireNotNull(it.userId),
folderId = requireNotNull(it.folderId),
)
}
?.let { mutableSyncFolderDeleteSharedFlow.tryEmit(it) }
}
NotificationType.SYNC_ORG_KEYS -> {
@@ -266,9 +277,14 @@ class PushManagerImpl @Inject constructor(
.decodeFromString<NotificationPayload.SyncSendNotification>(
string = notification.payload,
)
.takeIf { isLoggedIn(userId) && it.userMatchesNotification(userId) }
?.sendId
?.let { mutableSyncSendDeleteSharedFlow.tryEmit(SyncSendDeleteData(it)) }
.takeIf { it.userId != null && it.sendId != null }
?.let {
SyncSendDeleteData(
userId = requireNotNull(it.userId),
sendId = requireNotNull(it.sendId),
)
}
?.let { mutableSyncSendDeleteSharedFlow.tryEmit(it) }
}
}
}
@@ -2,9 +2,8 @@ package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for sync cipher delete operations.
*
* @property cipherId The cipher ID.
*/
data class SyncCipherDeleteData(
val userId: String,
val cipherId: String,
)
@@ -2,9 +2,8 @@ package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for sync folder delete operations.
*
* @property folderId The folder ID.
*/
data class SyncFolderDeleteData(
val userId: String,
val folderId: String,
)
@@ -2,9 +2,8 @@ package com.x8bit.bitwarden.data.platform.manager.model
/**
* Required data for sync send delete operations.
*
* @property sendId The send ID.
*/
data class SyncSendDeleteData(
val userId: String,
val sendId: String,
)
@@ -24,6 +24,14 @@ interface VaultDiskSource {
*/
suspend fun getCiphers(userId: String): List<SyncResponseJson.Cipher>
/**
* Retrieves all ciphers with the given [cipherIds] from the data source for a given [userId].
*/
suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<SyncResponseJson.Cipher>
/**
* Retrieves all ciphers from the data source for a given [userId] that contain TOTP codes.
*/
@@ -97,6 +97,24 @@ class VaultDiskSourceImpl(
}
}
override suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getSelectedCiphers(userId = userId, cipherIds = cipherIds)
return withContext(context = dispatcherManager.default) {
entities
.map { entity ->
async {
json.decodeFromStringWithErrorCallback<SyncResponseJson.Cipher>(
string = entity.cipherJson,
) { Timber.e(it, "Failed to deserialize Cipher in Vault") }
}
}
.awaitAll()
}
}
override suspend fun getTotpCiphers(userId: String): List<SyncResponseJson.Cipher> {
val entities = ciphersDao.getAllTotpCiphers(userId = userId)
return withContext(context = dispatcherManager.default) {
@@ -37,6 +37,15 @@ interface CiphersDao {
userId: String,
): List<CipherEntity>
/**
* Retrieves all ciphers from the database with the given [cipherIds] for a given [userId].
*/
@Query("SELECT * FROM ciphers WHERE user_id = :userId AND id IN (:cipherIds)")
suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<CipherEntity>
/**
* Retrieves all ciphers from the database for a given [userId].
*/
@@ -10,6 +10,7 @@ import com.bitwarden.core.UpdateKdfResponse
import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.crypto.Kdf
import com.bitwarden.crypto.TrustDeviceResponse
import com.bitwarden.exporters.Account
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
@@ -429,6 +430,23 @@ interface VaultSdkSource {
format: ExportFormat,
): Result<String>
/**
* Exports the users vault data to a CXF formatted string.
*/
suspend fun exportVaultDataToCxf(
userId: String,
account: Account,
ciphers: List<Cipher>,
): Result<String>
/**
* Imports the given CXF formatted [payload] into the users vault.
*
* @return Result of the import. If successful, a list of [Cipher]s deciphered from the CXF
* payload.
*/
suspend fun importCxf(userId: String, payload: String): Result<List<Cipher>>
/**
* Register a new FIDO 2 credential to a cipher.
*
@@ -11,6 +11,7 @@ import com.bitwarden.core.UpdatePasswordResponse
import com.bitwarden.crypto.Kdf
import com.bitwarden.crypto.TrustDeviceResponse
import com.bitwarden.data.manager.DispatcherManager
import com.bitwarden.exporters.Account
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.fido.PublicKeyCredentialAuthenticatorAssertionResponse
@@ -492,6 +493,28 @@ class VaultSdkSourceImpl(
)
}
override suspend fun exportVaultDataToCxf(
userId: String,
account: Account,
ciphers: List<Cipher>,
): Result<String> = runCatchingWithLogs {
getClient(userId = userId)
.exporters()
.exportCxf(
account = account,
ciphers = ciphers,
)
}
override suspend fun importCxf(
userId: String,
payload: String,
): Result<List<Cipher>> = runCatchingWithLogs {
getClient(userId = userId)
.exporters()
.importCxf(payload = payload)
}
override suspend fun registerFido2Credential(
request: RegisterFido2CredentialRequest,
fido2CredentialStore: Fido2CredentialStore,
@@ -24,6 +24,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.ImportCxfPayloadResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
@@ -258,6 +259,20 @@ interface VaultRepository : CipherManager, VaultLockManager {
restrictedTypes: List<CipherType>,
): ExportVaultDataResult
/**
* Attempt to import a CXF payload.
*
* @param payload The CXF payload to import.
*/
suspend fun importCxfPayload(payload: String): ImportCxfPayloadResult
/**
* Attempt to export the vault data to a CXF file.
*
* @param ciphers Ciphers selected for export.
*/
suspend fun exportVaultDataToCxf(ciphers: List<CipherListView>): Result<String>
/**
* Flow that represents the data for a specific vault list item as found by ID. This may emit
* `null` if the item cannot be found.
@@ -73,6 +73,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.ImportCxfPayloadResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
@@ -82,7 +83,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabetically
import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabeticallyByType
import com.x8bit.bitwarden.data.vault.repository.util.sortAlphabeticallyByTypeAndOrganization
import com.x8bit.bitwarden.data.vault.repository.util.toDomainsData
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedNetworkSend
@@ -93,6 +94,7 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSend
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList
import kotlinx.coroutines.CancellationException
@@ -114,7 +116,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
@@ -929,11 +930,9 @@ class VaultRepositoryImpl(
}
private suspend fun clearFolderIdFromCiphers(folderId: String, userId: String) {
vaultDiskSource.getCiphersFlow(userId).firstOrNull()?.forEach {
vaultDiskSource.getCiphers(userId = userId).forEach {
if (it.folderId == folderId) {
vaultDiskSource.saveCipher(
userId, it.copy(folderId = null),
)
vaultDiskSource.saveCipher(userId = userId, cipher = it.copy(folderId = null))
}
}
}
@@ -974,6 +973,42 @@ class VaultRepositoryImpl(
)
}
override suspend fun importCxfPayload(payload: String): ImportCxfPayloadResult {
val userId = activeUserId
?: return ImportCxfPayloadResult.Error(error = NoActiveUserException())
return vaultSdkSource
.importCxf(
userId = userId,
payload = payload,
)
.fold(
onSuccess = { ImportCxfPayloadResult.Success(it) },
onFailure = { ImportCxfPayloadResult.Error(error = it) },
)
}
override suspend fun exportVaultDataToCxf(
ciphers: List<CipherListView>,
): Result<String> {
val userId = activeUserId
?: return NoActiveUserException().asFailure()
val account = authDiskSource.userState
?.activeAccount
?.toSdkAccount()
?: return NoActiveUserException().asFailure()
val ciphers = vaultDiskSource
.getSelectedCiphers(userId = userId, cipherIds = ciphers.mapNotNull { it.id })
.map { it.toEncryptedSdkCipher() }
return vaultSdkSource
.exportVaultDataToCxf(
userId = userId,
account = account,
ciphers = ciphers,
)
}
/**
* Checks if the given [userId] has an associated encrypted PIN key but not a pin-protected user
* key. This indicates a scenario in which a user has requested PIN unlocking but requires
@@ -1168,7 +1203,11 @@ class VaultRepositoryImpl(
.fold(
onSuccess = { collections ->
DataState.Loaded(
collections.sortAlphabeticallyByType(),
collections.sortAlphabeticallyByTypeAndOrganization(
userOrganizations = authDiskSource
.getOrganizations(userId = userId)
.orEmpty(),
),
)
},
onFailure = { throwable -> DataState.Error(throwable) },
@@ -1246,12 +1285,9 @@ class VaultRepositoryImpl(
* Deletes the cipher specified by [syncCipherDeleteData] from disk.
*/
private suspend fun deleteCipher(syncCipherDeleteData: SyncCipherDeleteData) {
val userId = activeUserId ?: return
val cipherId = syncCipherDeleteData.cipherId
vaultDiskSource.deleteCipher(
userId = userId,
cipherId = cipherId,
userId = syncCipherDeleteData.userId,
cipherId = syncCipherDeleteData.cipherId,
)
}
@@ -1381,12 +1417,9 @@ class VaultRepositoryImpl(
* Deletes the send specified by [syncSendDeleteData] from disk.
*/
private suspend fun deleteSend(syncSendDeleteData: SyncSendDeleteData) {
val userId = activeUserId ?: return
val sendId = syncSendDeleteData.sendId
vaultDiskSource.deleteSend(
userId = userId,
sendId = sendId,
userId = syncSendDeleteData.userId,
sendId = syncSendDeleteData.sendId,
)
}
@@ -1401,15 +1434,14 @@ class VaultRepositoryImpl(
val isUpdate = syncSendUpsertData.isUpdate
val revisionDate = syncSendUpsertData.revisionDate
val localSend = sendDataStateFlow
.mapNotNull { it.data }
val localSend = vaultDiskSource
.getSends(userId = userId)
.first()
.sendViewList
.find { it.id == sendId }
val isValidCreate = !isUpdate && localSend == null
val isValidUpdate = isUpdate &&
localSend != null &&
localSend.revisionDate.epochSecond < revisionDate.toEpochSecond()
localSend.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
@@ -1432,16 +1464,13 @@ class VaultRepositoryImpl(
* Deletes the folder specified by [syncFolderDeleteData] from disk.
*/
private suspend fun deleteFolder(syncFolderDeleteData: SyncFolderDeleteData) {
val userId = activeUserId ?: return
val folderId = syncFolderDeleteData.folderId
clearFolderIdFromCiphers(
folderId = folderId,
userId = userId,
folderId = syncFolderDeleteData.folderId,
userId = syncFolderDeleteData.userId,
)
vaultDiskSource.deleteFolder(
folderId = folderId,
userId = userId,
folderId = syncFolderDeleteData.folderId,
userId = syncFolderDeleteData.userId,
)
}
@@ -1454,21 +1483,20 @@ class VaultRepositoryImpl(
val folderId = syncFolderUpsertData.folderId
val isUpdate = syncFolderUpsertData.isUpdate
val revisionDate = syncFolderUpsertData.revisionDate
val localFolder = foldersStateFlow
.mapNotNull { it.data }
val localFolder = vaultDiskSource
.getFolders(userId = userId)
.first()
.find { it.id == folderId }
val isValidCreate = !isUpdate && localFolder == null
val isValidUpdate = isUpdate &&
localFolder != null &&
localFolder.revisionDate.epochSecond < revisionDate.toEpochSecond()
localFolder.revisionDate.toEpochSecond() < revisionDate.toEpochSecond()
if (!isValidCreate && !isValidUpdate) return
folderService
.getFolder(folderId)
.onSuccess { vaultDiskSource.saveFolder(userId, it) }
.getFolder(folderId = folderId)
.onSuccess { vaultDiskSource.saveFolder(userId = userId, folder = it) }
}
//endregion Push Notification helpers
@@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.vault.Cipher
/**
* Models result of the vault data being imported from a CXF payload.
*/
sealed class ImportCxfPayloadResult {
/**
* The vault data has been successfully imported.
*/
data class Success(val ciphers: List<Cipher>) : ImportCxfPayloadResult()
/**
* There was an error importing the vault data.
*
* @param error The error that occurred during import.
*/
data class Error(val error: Throwable) : ImportCxfPayloadResult()
}
@@ -0,0 +1,13 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.exporters.Account
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
/**
* Converts a [AccountJson] to a [Account] for use in the SDK.
*/
fun AccountJson.toSdkAccount(): Account = Account(
id = profile.userId,
email = profile.email,
name = profile.name,
)
@@ -32,21 +32,45 @@ fun List<SyncResponseJson.Collection>.toEncryptedSdkCollectionList(): List<Colle
map { it.toEncryptedSdkCollection() }
/**
* Sorts the collections, grouping them by type, with `DEFAULT_USER_COLLECTION` types displayed
* first. Within each group, collections are sorted alphabetically by name.
* Sorts a list of [CollectionView] objects based on a multi-level sorting logic.
*
* The sorting criteria are as follows, in order of precedence:
* 1. Collections of type `DEFAULT_USER_COLLECTION` are placed first.
* 2. All other collections are grouped by their `CollectionType`.
* 3. Within each group, collections are sorted alphabetically by name. For collections of
* type `DEFAULT_USER_COLLECTION`, the corresponding organization's name is used for sorting
* instead of the collection's own name.
*
* This function uses a [SpecialCharWithPrecedenceComparator] for the alphabetical sort.
*
* @param userOrganizations A list of the user's organizations, used to find the name for
* `DEFAULT_USER_COLLECTION` types.
* @return A new list containing the sorted [CollectionView] objects.
*/
@JvmName("toAlphabeticallySortedCollectionList")
fun List<CollectionView>.sortAlphabeticallyByType(): List<CollectionView> {
fun List<CollectionView>.sortAlphabeticallyByTypeAndOrganization(
userOrganizations: List<SyncResponseJson.Profile.Organization>,
): List<CollectionView> {
return this.sortedWith(
// DEFAULT_USER_COLLECTION come first
comparator = compareBy<CollectionView> { it.type != CollectionType.DEFAULT_USER_COLLECTION }
// Then sort by other CollectionType ordinals
.thenBy { it.type }
// Finally, sort by name within each group
.thenComparing(
CollectionView::name,
SpecialCharWithPrecedenceComparator,
),
// Finally, sort within each group. For default collections, use the
// organization's name; for others, use the collection's name.
.thenBy(SpecialCharWithPrecedenceComparator) {
if (it.type == CollectionType.DEFAULT_USER_COLLECTION) {
// For default collections, sort by the organization's name
userOrganizations
.find { org -> org.id == it.organizationId }
?.name
?: it.name
} else {
// For other collections, sort by the collection's name
it.name
}
}
// As a final fallback if names are identical, sort by ID to ensure a stable order
.thenBy { it.id },
)
}
@@ -5,8 +5,10 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.util.isValidEmail
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
@@ -321,8 +323,11 @@ class CompleteRegistrationViewModel @Inject constructor(
it.copy(
dialog = CompleteRegistrationDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
message = BitwardenPlurals.master_password_length_val_message_x
.asPluralsText(
quantity = MIN_PASSWORD_LENGTH,
args = arrayOf(MIN_PASSWORD_LENGTH),
),
),
)
}
@@ -24,11 +24,12 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.asText
@@ -154,7 +155,11 @@ private fun MinimumCharacterCount(
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text = stringResource(BitwardenString.minimum_characters, minimumCharacterCount),
text = pluralStringResource(
id = BitwardenPlurals.minimum_characters,
count = minimumCharacterCount,
formatArgs = arrayOf(minimumCharacterCount),
),
color = BitwardenTheme.colorScheme.text.secondary,
style = BitwardenTheme.typography.labelSmall,
)
@@ -5,8 +5,10 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.util.orNullIfBlank
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
@@ -199,8 +201,11 @@ class ResetPasswordViewModel @Inject constructor(
it.copy(
dialogState = ResetPasswordState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
message = BitwardenPlurals.master_password_length_val_message_x
.asPluralsText(
quantity = MIN_PASSWORD_LENGTH,
args = arrayOf(MIN_PASSWORD_LENGTH),
),
),
)
}
@@ -4,8 +4,10 @@ import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
@@ -111,8 +113,11 @@ class SetPasswordViewModel @Inject constructor(
it.copy(
dialogState = SetPasswordState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
message = BitwardenPlurals.master_password_length_val_message_x
.asPluralsText(
quantity = MIN_PASSWORD_LENGTH,
args = arrayOf(MIN_PASSWORD_LENGTH),
),
),
)
}
@@ -219,7 +219,7 @@ private fun AutofillSelectionDialog(
selectionItems = {
if (AutofillSelectionOption.AUTOFILL in displayItem.autofillSelectionOptions) {
BitwardenBasicDialogRow(
text = stringResource(id = BitwardenString.autofill),
text = stringResource(id = BitwardenString.autofill_title),
onClick = {
selectionCallback(
displayItem,
@@ -241,7 +241,7 @@ enum class Settings(
testTag = "AccountSecuritySettingsButton",
),
AUTO_FILL(
text = BitwardenString.autofill.asText(),
text = BitwardenString.autofill_title.asText(),
vectorIconRes = BitwardenDrawable.ic_check_mark,
testTag = "AutofillSettingsButton",
),
@@ -141,7 +141,7 @@ fun AutoFillScreen(
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.autofill),
title = stringResource(id = BitwardenString.autofill_title),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = BitwardenDrawable.ic_back),
navigationIconContentDescription = stringResource(id = BitwardenString.back),
@@ -187,7 +187,7 @@ private fun AutoFillScreenContent(
)
}
BitwardenListHeaderText(
label = stringResource(id = BitwardenString.autofill),
label = stringResource(id = BitwardenString.autofill_title),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
@@ -24,6 +24,7 @@ import com.bitwarden.ui.platform.components.navigation.model.NavigationItem
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.scaffold.model.ScaffoldNavigationData
import com.bitwarden.ui.platform.theme.RootTransitionProviders
import com.bitwarden.ui.platform.util.toObjectNavigationRoute
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.feature.settings.about.navigateToAbout
@@ -272,14 +273,15 @@ private fun VaultUnlockedNavBarScaffold(
* If direct navigation is required, the [navigate] lambda will be invoked with the appropriate
* [NavOptions].
*/
@Suppress("MaxLineLength")
private fun NavController.navigateToTabOrRoot(
tabToNavigateTo: VaultUnlockedNavBarTab,
navigate: (NavOptions) -> Unit,
) {
if (tabToNavigateTo.startDestinationRoute == currentDestination?.route) {
if (tabToNavigateTo.startDestinationRoute.toObjectNavigationRoute() == currentDestination?.route) {
// We are at the start destination already, so nothing to do.
return
} else if (currentDestination?.parent?.route == tabToNavigateTo.graphRoute) {
} else if (currentDestination?.parent?.route == tabToNavigateTo.graphRoute.toObjectNavigationRoute()) {
// We are not at the start destination but we are in the correct graph,
// so lets pop up to the start destination.
popBackStack(route = tabToNavigateTo.startDestinationRoute, inclusive = false)
@@ -300,8 +302,8 @@ private fun NavController.navigateToTabOrRoot(
/**
* Determine if the current destination is the same as the given tab.
*/
private fun NavBackStackEntry?.isCurrentRoute(route: String): Boolean =
private fun NavBackStackEntry?.isCurrentRoute(route: Any): Boolean =
this
?.destination
?.hierarchy
?.any { it.route == route } == true
?.any { it.route == route.toObjectNavigationRoute() } == true
@@ -4,7 +4,6 @@ import android.os.Parcelable
import com.bitwarden.ui.platform.components.navigation.model.NavigationItem
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.util.toObjectNavigationRoute
import com.x8bit.bitwarden.ui.platform.feature.settings.SettingsGraphRoute
import com.x8bit.bitwarden.ui.platform.feature.settings.SettingsRoute
import com.x8bit.bitwarden.ui.tools.feature.generator.GeneratorGraphRoute
@@ -35,8 +34,8 @@ sealed class VaultUnlockedNavBarTab : NavigationItem, Parcelable {
override val iconRes get() = BitwardenDrawable.ic_generator
override val labelRes get() = BitwardenString.generator
override val contentDescriptionRes get() = BitwardenString.generator
override val graphRoute get() = GeneratorGraphRoute.toObjectNavigationRoute()
override val startDestinationRoute get() = GeneratorRoute.Standard.toObjectNavigationRoute()
override val graphRoute get() = GeneratorGraphRoute
override val startDestinationRoute get() = GeneratorRoute.Standard
override val testTag get() = "GeneratorTab"
override val notificationCount get() = 0
}
@@ -50,8 +49,8 @@ sealed class VaultUnlockedNavBarTab : NavigationItem, Parcelable {
override val iconRes get() = BitwardenDrawable.ic_send
override val labelRes get() = BitwardenString.send
override val contentDescriptionRes get() = BitwardenString.send
override val graphRoute get() = SendGraphRoute.toObjectNavigationRoute()
override val startDestinationRoute get() = SendRoute.toObjectNavigationRoute()
override val graphRoute get() = SendGraphRoute
override val startDestinationRoute get() = SendRoute
override val testTag get() = "SendTab"
override val notificationCount get() = 0
}
@@ -66,8 +65,8 @@ sealed class VaultUnlockedNavBarTab : NavigationItem, Parcelable {
) : VaultUnlockedNavBarTab() {
override val iconResSelected get() = BitwardenDrawable.ic_vault_filled
override val iconRes get() = BitwardenDrawable.ic_vault
override val graphRoute get() = VaultGraphRoute.toObjectNavigationRoute()
override val startDestinationRoute get() = VaultRoute.toObjectNavigationRoute()
override val graphRoute get() = VaultGraphRoute
override val startDestinationRoute get() = VaultRoute
override val testTag get() = "VaultTab"
override val notificationCount get() = 0
}
@@ -83,8 +82,8 @@ sealed class VaultUnlockedNavBarTab : NavigationItem, Parcelable {
override val iconRes get() = BitwardenDrawable.ic_settings
override val labelRes get() = BitwardenString.settings
override val contentDescriptionRes get() = BitwardenString.settings
override val graphRoute get() = SettingsGraphRoute.toObjectNavigationRoute()
override val startDestinationRoute get() = SettingsRoute.Standard.toObjectNavigationRoute()
override val graphRoute get() = SettingsGraphRoute
override val startDestinationRoute get() = SettingsRoute.Standard
override val testTag get() = "SettingsTab"
}
}
@@ -14,8 +14,10 @@ import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.DecryptCipherListResult
@@ -1945,7 +1947,11 @@ class VaultAddEditViewModel @Inject constructor(
is BreachCountResult.Success -> {
VaultAddEditState.DialogState.Generic(
message = if (result.breachCount > 0) {
BitwardenString.password_exposed.asText(result.breachCount)
BitwardenPlurals.password_exposed
.asPluralsText(
quantity = result.breachCount,
args = arrayOf(result.breachCount),
)
} else {
BitwardenString.password_safe.asText()
},
@@ -13,8 +13,10 @@ import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
import com.bitwarden.vault.CipherView
@@ -978,7 +980,10 @@ class VaultItemViewModel @Inject constructor(
is BreachCountResult.Success -> {
VaultItemState.DialogState.Generic(
message = if (result.breachCount > 0) {
BitwardenString.password_exposed.asText(result.breachCount)
BitwardenPlurals.password_exposed.asPluralsText(
quantity = result.breachCount,
args = arrayOf(result.breachCount),
)
} else {
BitwardenString.password_safe.asText()
},
@@ -2513,6 +2513,7 @@ class VaultItemListingViewModel @Inject constructor(
decryptCipherListResult = vaultData.decryptCipherListResult.copy(
successes = vaultData.decryptCipherListResult.successes
.filter { it.type is CipherListViewType.Card },
failures = emptyList(),
),
)
}
@@ -2530,6 +2531,7 @@ class VaultItemListingViewModel @Inject constructor(
cipherListViews = vaultData.decryptCipherListResult.successes,
matchUri = matchUri,
),
failures = emptyList(),
),
)
}
@@ -2553,6 +2555,7 @@ class VaultItemListingViewModel @Inject constructor(
cipherListViews = vaultData.decryptCipherListResult.successes,
matchUri = matchUri,
),
failures = emptyList(),
),
)
}
@@ -2574,6 +2577,7 @@ class VaultItemListingViewModel @Inject constructor(
searchTypeData = SearchTypeData.Vault.Logins,
searchTerm = query,
),
failures = emptyList(),
),
)
}
@@ -913,7 +913,9 @@ class VaultViewModel @Inject constructor(
updateVaultState(
vaultData = vaultData.data,
dialog = if (shouldShowDecryptionAlert) {
dialog = if (shouldShowDecryptionAlert ||
state.dialog is VaultState.DialogState.VaultLoadCipherDecryptionError
) {
VaultState.DialogState.VaultLoadCipherDecryptionError(
title = BitwardenString.decryption_error.asText(),
cipherCount = vaultData.data.decryptCipherListResult.failures.size,
@@ -237,6 +237,7 @@ class PushManagerTest {
pushManager.onMessageReceived(SYNC_CIPHER_DELETE_NOTIFICATION_MAP)
assertEquals(
SyncCipherDeleteData(
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
@@ -285,6 +286,7 @@ class PushManagerTest {
pushManager.onMessageReceived(SYNC_FOLDER_DELETE_NOTIFICATION_MAP)
assertEquals(
SyncFolderDeleteData(
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
folderId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
@@ -315,6 +317,7 @@ class PushManagerTest {
pushManager.onMessageReceived(SYNC_LOGIN_DELETE_NOTIFICATION_MAP)
assertEquals(
SyncCipherDeleteData(
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
@@ -343,6 +346,7 @@ class PushManagerTest {
pushManager.onMessageReceived(SYNC_SEND_DELETE_NOTIFICATION_MAP)
assertEquals(
SyncSendDeleteData(
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
sendId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
@@ -403,7 +407,13 @@ class PushManagerTest {
fun `onMessageReceived with sync cipher delete does nothing`() = runTest {
pushManager.syncCipherDeleteFlow.test {
pushManager.onMessageReceived(SYNC_CIPHER_DELETE_NOTIFICATION_MAP)
expectNoEvents()
assertEquals(
SyncCipherDeleteData(
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
)
}
}
@@ -424,12 +434,19 @@ class PushManagerTest {
}
@Test
fun `onMessageReceived with sync folder delete does nothing`() = runTest {
pushManager.syncFolderDeleteFlow.test {
pushManager.onMessageReceived(SYNC_FOLDER_DELETE_NOTIFICATION_MAP)
expectNoEvents()
fun `onMessageReceived with sync folder delete emits to syncFolderDeleteFlow`() =
runTest {
pushManager.syncFolderDeleteFlow.test {
pushManager.onMessageReceived(SYNC_FOLDER_DELETE_NOTIFICATION_MAP)
assertEquals(
SyncFolderDeleteData(
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
folderId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
)
}
}
}
@Test
fun `onMessageReceived with sync folder update does nothing`() = runTest {
@@ -440,12 +457,19 @@ class PushManagerTest {
}
@Test
fun `onMessageReceived with sync login delete does nothing`() = runTest {
pushManager.syncCipherDeleteFlow.test {
pushManager.onMessageReceived(SYNC_LOGIN_DELETE_NOTIFICATION_MAP)
expectNoEvents()
fun `onMessageReceived with sync login delete emits to syncCipherDeleteFlow`() =
runTest {
pushManager.syncCipherDeleteFlow.test {
pushManager.onMessageReceived(SYNC_LOGIN_DELETE_NOTIFICATION_MAP)
assertEquals(
SyncCipherDeleteData(
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
cipherId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
)
}
}
}
@Test
fun `onMessageReceived with sync send create does nothing`() = runTest {
@@ -456,10 +480,16 @@ class PushManagerTest {
}
@Test
fun `onMessageReceived with sync send delete does nothing`() = runTest {
fun `onMessageReceived with sync send delete emits to syncSendDeleteFlow`() = runTest {
pushManager.syncSendDeleteFlow.test {
pushManager.onMessageReceived(SYNC_SEND_DELETE_NOTIFICATION_MAP)
expectNoEvents()
assertEquals(
SyncSendDeleteData(
userId = "078966a2-93c2-4618-ae2a-0a2394c88d37",
sendId = "aab5cdcc-f4a7-4e65-bf6d-5e0eab052321",
),
awaitItem(),
)
}
}
@@ -105,6 +105,22 @@ class VaultDiskSourceTest {
assertEquals(ciphers, result2)
}
@Test
fun `getSelectedCiphers should return selected CiphersDao ciphers`() = runTest {
val cipherEntities = listOf(
CIPHER_ENTITY,
CIPHER_ENTITY.copy(id = "otherCipherId"),
)
val ciphers = listOf(CIPHER_1)
val cipherIds = listOf("mockId-1")
val result1 = vaultDiskSource.getSelectedCiphers(USER_ID, cipherIds)
assertEquals(emptyList<SyncResponseJson.Cipher>(), result1)
ciphersDao.insertCiphers(cipherEntities)
val result2 = vaultDiskSource.getSelectedCiphers(USER_ID, cipherIds)
assertEquals(ciphers, result2)
}
@Test
fun `getTotpCiphers should return all CiphersDao totp ciphers`() = runTest {
val cipherEntities = listOf(
@@ -41,6 +41,12 @@ class FakeCiphersDao : CiphersDao {
override suspend fun getAllCiphers(userId: String): List<CipherEntity> =
storedCiphers.filter { it.userId == userId }
override suspend fun getSelectedCiphers(
userId: String,
cipherIds: List<String>,
): List<CipherEntity> =
storedCiphers.filter { it.userId == userId && it.id in cipherIds }
override suspend fun getAllTotpCiphers(userId: String): List<CipherEntity> =
storedCiphers.filter { it.userId == userId && it.hasTotp }
@@ -15,6 +15,7 @@ import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.crypto.Kdf
import com.bitwarden.crypto.TrustDeviceResponse
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.bitwarden.exporters.Account
import com.bitwarden.exporters.ExportFormat
import com.bitwarden.fido.ClientData
import com.bitwarden.fido.Fido2CredentialAutofillView
@@ -1135,6 +1136,69 @@ class VaultSdkSourceTest {
)
}
@Test
fun `exportVaultDataToCxf should call SDK and return a Result with the correct data`() =
runTest {
val userId = "userId"
val account = mockk<Account>()
val ciphers = listOf(createMockSdkCipher(1))
val expected = "TestResult"
coEvery {
clientExporters.exportCxf(
account = account,
ciphers = ciphers,
)
} returns expected
val result = vaultSdkSource.exportVaultDataToCxf(
userId = userId,
account = account,
ciphers = ciphers,
)
coVerify {
clientExporters.exportCxf(
account = account,
ciphers = ciphers,
)
}
assertEquals(
expected.asSuccess(),
result,
)
}
@Test
fun `importCxf should call SDK and return a Result with the correct data`() = runTest {
val userId = "userId"
val expected = listOf(createMockSdkCipher(number = 1))
val cxf = "cxf"
coEvery {
clientExporters.importCxf(
payload = cxf,
)
} returns expected
val result = vaultSdkSource.importCxf(
userId = userId,
payload = cxf,
)
coVerify {
clientExporters.importCxf(
payload = cxf,
)
}
assertEquals(
expected.asSuccess(),
result,
)
}
@Suppress("MaxLineLength")
@Test
fun `registerFido2Credential should return attestation response when registration completes`() =
@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.vault.datasource.sdk.model
import com.bitwarden.exporters.Account
/**
* Creates a mock [com.bitwarden.exporters.Account] for testing purposes
*/
fun createMockAccount(
number: Int,
email: String = "mockEmail-$number",
name: String? = "mockName-$number",
): Account = Account(
id = "mockId-$number",
email = email,
name = name,
)
@@ -71,6 +71,8 @@ import com.x8bit.bitwarden.data.platform.manager.model.SyncSendUpsertData
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockAccount
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherListView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockDecryptCipherListResult
@@ -92,6 +94,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.ImportCxfPayloadResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
@@ -2834,46 +2837,26 @@ class VaultRepositoryTest {
fun `DeleteFolder with folderService Delete success should return DeleteFolderResult Success and update ciphers`() =
runTest {
fakeAuthDiskSource.userState = MOCK_USER_STATE
val userId = MOCK_USER_STATE.activeUserId
val folderId = "mockFolderId-1"
val mockCipher = createMockCipher(number = 1)
val ciphers = listOf(mockCipher, createMockCipher(number = 2))
coEvery { folderService.deleteFolder(folderId) } returns Unit.asSuccess()
coEvery {
vaultDiskSource.deleteFolder(
MOCK_USER_STATE.activeUserId,
folderId,
)
} just runs
val mockCipher = createMockCipher(1)
val mutableCiphersStateFlow =
MutableStateFlow(
listOf(
mockCipher,
createMockCipher(2),
),
)
coEvery {
vaultDiskSource.getCiphersFlow(MOCK_USER_STATE.activeUserId)
} returns mutableCiphersStateFlow
coEvery { vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) } just runs
coEvery { vaultDiskSource.getCiphers(userId = userId) } returns ciphers
coEvery {
vaultDiskSource.saveCipher(
MOCK_USER_STATE.activeUserId,
mockCipher.copy(
folderId = null,
),
userId = userId,
cipher = mockCipher.copy(folderId = null),
)
} just runs
val result = vaultRepository.deleteFolder(folderId)
val result = vaultRepository.deleteFolder(folderId = folderId)
coVerify(exactly = 1) {
vaultDiskSource.saveCipher(
MOCK_USER_STATE.activeUserId,
mockCipher.copy(
folderId = null,
),
userId = userId,
cipher = mockCipher.copy(folderId = null),
)
}
@@ -3337,11 +3320,10 @@ class VaultRepositoryTest {
val userId = "mockId-1"
val cipherId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) } just runs
mutableSyncCipherDeleteFlow.tryEmit(
SyncCipherDeleteData(cipherId = cipherId),
SyncCipherDeleteData(userId = userId, cipherId = cipherId),
)
coVerify { vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId) }
@@ -3679,13 +3661,9 @@ class VaultRepositoryTest {
fun `syncSendDeleteFlow should delete send from disk`() {
val userId = "mockId-1"
val sendId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { vaultDiskSource.deleteSend(userId = userId, sendId = sendId) } just runs
mutableSyncSendDeleteFlow.tryEmit(
SyncSendDeleteData(sendId = sendId),
)
mutableSyncSendDeleteFlow.tryEmit(SyncSendDeleteData(userId = userId, sendId = sendId))
coVerify { vaultDiskSource.deleteSend(userId = userId, sendId = sendId) }
}
@@ -3813,53 +3791,37 @@ class VaultRepositoryTest {
fun `syncSendUpsertFlow update failure with 404 code should make a request for a send and then delete it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery { sendsService.getSend(sendId = sendId) } returns response.asFailure()
coEvery {
sendsService.getSend(sendId)
} returns response.asFailure()
coEvery {
vaultDiskSource.deleteSend(any(), any())
vaultDiskSource.deleteSend(userId = userId, sendId = sendId)
} just runs
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId)
val sendView = createMockSendView(number = number)
val sendView = createMockSend(
number = number,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
)
coEvery {
vaultSdkSource.decryptSendList(
userId = MOCK_USER_STATE.activeUserId,
sendList = listOf(createMockSdkSend(number = number)),
)
} returns listOf(sendView).asSuccess()
vaultDiskSource.getSends(userId = userId)
} returns MutableStateFlow(listOf(sendView))
val sendsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Send>>()
setupVaultDiskSourceFlows(sendsFlow = sendsFlow)
vaultRepository.sendDataStateFlow.test {
// Populate and consume items related to the sends flow
awaitItem()
sendsFlow.tryEmit(listOf(createMockSend(number = number)))
awaitItem()
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(),
isUpdate = true,
),
)
}
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId)
vaultDiskSource.deleteSend(
userId = MOCK_USER_STATE.activeUserId,
sendId = sendId,
)
sendsService.getSend(sendId = sendId)
vaultDiskSource.deleteSend(userId = userId, sendId = sendId)
}
}
@@ -3867,51 +3829,31 @@ class VaultRepositoryTest {
@Test
fun `syncSendUpsertFlow create failure with 404 code should make a request for a send and do nothing`() =
runTest {
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-1"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId)
val response: HttpException = mockk {
every { code() } returns 404
}
coEvery { sendsService.getSend(sendId = sendId) } returns response.asFailure()
coEvery {
sendsService.getSend(sendId)
} returns response.asFailure()
vaultDiskSource.getSends(userId = userId)
} returns MutableStateFlow(emptyList())
coEvery {
vaultSdkSource.decryptSendList(
userId = MOCK_USER_STATE.activeUserId,
sendList = listOf(),
)
} returns emptyList<SendView>().asSuccess()
val sendsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Send>>()
setupVaultDiskSourceFlows(sendsFlow = sendsFlow)
vaultRepository.sendDataStateFlow.test {
// Populate and consume items related to the sends flow
awaitItem()
sendsFlow.tryEmit(emptyList())
awaitItem()
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
),
)
}
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId)
sendsService.getSend(sendId = sendId)
}
coVerify(exactly = 0) {
vaultDiskSource.deleteSend(
userId = MOCK_USER_STATE.activeUserId,
sendId = sendId,
)
vaultDiskSource.deleteSend(userId = userId, sendId = sendId)
}
}
@@ -3920,50 +3862,28 @@ class VaultRepositoryTest {
fun `syncSendUpsertFlow valid create success should make a request for a send and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId)
coEvery {
vaultSdkSource.decryptSendList(
userId = MOCK_USER_STATE.activeUserId,
sendList = listOf(),
)
} returns listOf<SendView>().asSuccess()
vaultDiskSource.getSends(userId = userId)
} returns MutableStateFlow(emptyList())
val send = mockk<SyncResponseJson.Send>()
coEvery { sendsService.getSend(sendId = sendId) } returns send.asSuccess()
coEvery { vaultDiskSource.saveSend(userId = userId, send = send) } just runs
val sendsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Send>>()
setupVaultDiskSourceFlows(sendsFlow = sendsFlow)
val send: SyncResponseJson.Send = mockk()
coEvery {
sendsService.getSend(sendId)
} returns send.asSuccess()
coEvery {
vaultDiskSource.saveSend(any(), any())
} just runs
vaultRepository.sendDataStateFlow.test {
// Populate and consume items related to the sends flow
awaitItem()
sendsFlow.tryEmit(listOf())
awaitItem()
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
),
)
}
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId)
vaultDiskSource.saveSend(
userId = MOCK_USER_STATE.activeUserId,
send = send,
)
sendsService.getSend(sendId = sendId)
vaultDiskSource.saveSend(userId = userId, send = send)
}
}
@@ -3972,51 +3892,33 @@ class VaultRepositoryTest {
fun `syncSendUpsertFlow valid update success should make a request for a send and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val sendId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId)
val sendView = createMockSendView(number = number)
val sendView = createMockSend(
number = number,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
)
coEvery {
vaultSdkSource.decryptSendList(
userId = MOCK_USER_STATE.activeUserId,
sendList = listOf(createMockSdkSend(number = number)),
)
} returns listOf(sendView).asSuccess()
vaultDiskSource.getSends(userId = userId)
} returns MutableStateFlow(listOf(sendView))
val sendsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Send>>()
setupVaultDiskSourceFlows(sendsFlow = sendsFlow)
val send = mockk<SyncResponseJson.Send>()
coEvery { sendsService.getSend(sendId = sendId) } returns send.asSuccess()
coEvery { vaultDiskSource.saveSend(userId = userId, send = send) } just runs
val send: SyncResponseJson.Send = mockk()
coEvery {
sendsService.getSend(sendId)
} returns send.asSuccess()
coEvery {
vaultDiskSource.saveSend(any(), any())
} just runs
vaultRepository.sendDataStateFlow.test {
// Populate and consume items related to the sends flow
awaitItem()
sendsFlow.tryEmit(listOf(createMockSend(number = number)))
awaitItem()
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(),
isUpdate = true,
),
)
}
mutableSyncSendUpsertFlow.tryEmit(
SyncSendUpsertData(
sendId = sendId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
),
)
coVerify(exactly = 1) {
sendsService.getSend(sendId)
vaultDiskSource.saveSend(
userId = MOCK_USER_STATE.activeUserId,
send = send,
)
sendsService.getSend(sendId = sendId)
vaultDiskSource.saveSend(userId = userId, send = send)
}
}
@@ -4024,20 +3926,21 @@ class VaultRepositoryTest {
fun `syncFolderDeleteFlow should delete folder from disk and update ciphers`() {
val userId = "mockId-1"
val folderId = "mockId-1"
val cipher = createMockCipher(number = 1, folderId = folderId)
val updatedCipher = createMockCipher(number = 1, folderId = null)
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery { vaultDiskSource.deleteFolder(userId = userId, folderId = folderId) } just runs
coEvery {
vaultDiskSource.getCiphersFlow(userId)
} returns flowOf()
coEvery { vaultDiskSource.getCiphers(userId = userId) } returns listOf(cipher)
coEvery { vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher) } just runs
mutableSyncFolderDeleteFlow.tryEmit(
SyncFolderDeleteData(folderId = folderId),
SyncFolderDeleteData(userId = userId, folderId = folderId),
)
coVerify {
coVerify(exactly = 1) {
vaultDiskSource.deleteFolder(userId = userId, folderId = folderId)
vaultDiskSource.getCiphersFlow(userId)
vaultDiskSource.getCiphers(userId = userId)
vaultDiskSource.saveCipher(userId = userId, cipher = updatedCipher)
}
}
@@ -4164,50 +4067,28 @@ class VaultRepositoryTest {
fun `syncFolderUpsertFlow valid create success should make a request for a folder and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val folderId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId)
coEvery {
vaultSdkSource.decryptFolderList(
userId = MOCK_USER_STATE.activeUserId,
folderList = listOf(),
)
} returns listOf<FolderView>().asSuccess()
vaultDiskSource.getFolders(userId = userId)
} returns MutableStateFlow(emptyList())
val folder = mockk<SyncResponseJson.Folder>()
coEvery { folderService.getFolder(folderId = folderId) } returns folder.asSuccess()
coEvery { vaultDiskSource.saveFolder(userId = userId, folder = folder) } just runs
val foldersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Folder>>()
setupVaultDiskSourceFlows(foldersFlow = foldersFlow)
val folder: SyncResponseJson.Folder = mockk()
coEvery {
folderService.getFolder(folderId)
} returns folder.asSuccess()
coEvery {
vaultDiskSource.saveFolder(any(), any())
} just runs
vaultRepository.foldersStateFlow.test {
// Populate and consume items related to the folders flow
awaitItem()
foldersFlow.tryEmit(listOf())
awaitItem()
mutableSyncFolderUpsertFlow.tryEmit(
SyncFolderUpsertData(
folderId = folderId,
revisionDate = ZonedDateTime.now(),
isUpdate = false,
),
)
}
mutableSyncFolderUpsertFlow.tryEmit(
SyncFolderUpsertData(
folderId = folderId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = false,
),
)
coVerify(exactly = 1) {
folderService.getFolder(folderId)
vaultDiskSource.saveFolder(
userId = MOCK_USER_STATE.activeUserId,
folder = folder,
)
folderService.getFolder(folderId = folderId)
vaultDiskSource.saveFolder(userId = userId, folder = folder)
}
}
@@ -4216,51 +4097,32 @@ class VaultRepositoryTest {
fun `syncFolderUpsertFlow valid update success should make a request for a folder and then store it`() =
runTest {
val number = 1
val userId = MOCK_USER_STATE.activeUserId
val folderId = "mockId-$number"
fakeAuthDiskSource.userState = MOCK_USER_STATE
setVaultToUnlocked(userId = MOCK_USER_STATE.activeUserId)
val folderView = createMockFolderView(number = number)
val folderView = createMockFolder(
number = number,
revisionDate = ZonedDateTime.now(clock).minus(5, ChronoUnit.MINUTES),
)
coEvery {
vaultSdkSource.decryptFolderList(
userId = MOCK_USER_STATE.activeUserId,
folderList = listOf(createMockSdkFolder(number = number)),
)
} returns listOf(folderView).asSuccess()
vaultDiskSource.getFolders(userId = userId)
} returns MutableStateFlow(listOf(folderView))
val folder = mockk<SyncResponseJson.Folder>()
coEvery { folderService.getFolder(folderId = folderId) } returns folder.asSuccess()
coEvery { vaultDiskSource.saveFolder(userId = userId, folder = folder) } just runs
val foldersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Folder>>()
setupVaultDiskSourceFlows(foldersFlow = foldersFlow)
val folder: SyncResponseJson.Folder = mockk()
coEvery {
folderService.getFolder(folderId)
} returns folder.asSuccess()
coEvery {
vaultDiskSource.saveFolder(any(), any())
} just runs
vaultRepository.foldersStateFlow.test {
// Populate and consume items related to the folders flow
awaitItem()
foldersFlow.tryEmit(listOf(createMockFolder(number = number)))
awaitItem()
mutableSyncFolderUpsertFlow.tryEmit(
SyncFolderUpsertData(
folderId = folderId,
revisionDate = ZonedDateTime.now(),
isUpdate = true,
),
)
}
mutableSyncFolderUpsertFlow.tryEmit(
SyncFolderUpsertData(
folderId = folderId,
revisionDate = ZonedDateTime.now(clock),
isUpdate = true,
),
)
coVerify(exactly = 1) {
folderService.getFolder(folderId)
vaultDiskSource.saveFolder(
userId = MOCK_USER_STATE.activeUserId,
folder = folder,
)
vaultDiskSource.saveFolder(userId = userId, folder = folder)
}
}
@@ -4396,6 +4258,104 @@ class VaultRepositoryTest {
)
}
@Test
fun `importCxfPayload should return success result`() = runTest {
val userId = "mockId-1"
val payload = "payload"
val ciphers = listOf(createMockSdkCipher(number = 1))
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.importCxf(
userId = userId,
payload = payload,
)
} returns ciphers.asSuccess()
val result = vaultRepository.importCxfPayload(payload)
assertEquals(
ImportCxfPayloadResult.Success(ciphers),
result,
)
}
@Test
fun `importCxfPayload should return error result`() = runTest {
val userId = "mockId-1"
val payload = "payload"
val expected = Throwable()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.importCxf(
userId = userId,
payload = payload,
)
} returns expected.asFailure()
val result = vaultRepository.importCxfPayload(payload)
assertEquals(
ImportCxfPayloadResult.Error(expected),
result,
)
}
@Test
fun `exportVaultDataToCxf should return success result`() = runTest {
val userId = "mockId-1"
val account = createMockAccount(number = 1, email = "email", name = null)
val cipherListViews = listOf(createMockCipherListView(number = 1))
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.exportVaultDataToCxf(
userId = userId,
account = account,
ciphers = any(),
)
} returns "TestResult".asSuccess()
coEvery {
vaultDiskSource.getSelectedCiphers(
userId = userId,
cipherIds = cipherListViews.mapNotNull { it.id },
)
} returns listOf(createMockCipher(number = 1))
val result = vaultRepository.exportVaultDataToCxf(ciphers = cipherListViews)
assertEquals("TestResult".asSuccess(), result)
}
@Test
fun `exportVaultDataToCxf should return error result when exportVaultDataToCxf fails`() =
runTest {
val userId = "mockId-1"
val account = createMockAccount(number = 1, email = "email", name = null)
val cipherListViews = listOf(createMockCipherListView(number = 1))
val throwable = Throwable()
fakeAuthDiskSource.userState = MOCK_USER_STATE
coEvery {
vaultSdkSource.exportVaultDataToCxf(
userId = userId,
account = account,
ciphers = any(),
)
} returns throwable.asFailure()
coEvery {
vaultDiskSource.getSelectedCiphers(
userId = userId,
cipherIds = cipherListViews.mapNotNull { it.id },
)
} returns listOf(createMockCipher(number = 1))
val result = vaultRepository.exportVaultDataToCxf(ciphers = cipherListViews)
assertEquals(throwable.asFailure(), result)
}
@Test
fun `silentlyDiscoverCredentials should return result`() = runTest {
val userId = "userId"
@@ -0,0 +1,68 @@
package com.x8bit.bitwarden.data.vault.repository.model
import com.bitwarden.network.model.KdfTypeJson
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
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import java.time.ZonedDateTime
/**
* Creates a mock [AccountJson.Profile] for testing purposes.
*/
@Suppress("LongParameterList")
fun createMockAccountJsonProfile(
number: Int,
userId: String = "mockId-$number",
email: String = "mockEmail-$number",
isEmailVerified: Boolean = true,
name: String? = "mockName-$number",
stamp: String = "mockSecurityStamp-$number",
organizationId: String? = "mockOrganizationId-$number",
avatarColorHex: String? = "mockAvatarColorHex-$number",
hasPremium: Boolean = false,
forcePasswordResetReason: ForcePasswordResetReason? = null,
kdfType: KdfTypeJson? = null,
kdfIterations: Int? = null,
kdfMemory: Int? = null,
kdfParallelism: Int? = null,
userDecryptionOptions: UserDecryptionOptionsJson? = null,
isTwoFactorEnabled: Boolean = false,
creationDate: ZonedDateTime = ZonedDateTime.parse("2024-09-13T01:00:00.00Z"),
): AccountJson.Profile = AccountJson.Profile(
userId = userId,
email = email,
isEmailVerified = isEmailVerified,
name = name,
stamp = stamp,
organizationId = organizationId,
avatarColorHex = avatarColorHex,
hasPremium = hasPremium,
forcePasswordResetReason = forcePasswordResetReason,
kdfType = kdfType,
kdfIterations = kdfIterations,
kdfMemory = kdfMemory,
kdfParallelism = kdfParallelism,
userDecryptionOptions = userDecryptionOptions,
isTwoFactorEnabled = isTwoFactorEnabled,
creationDate = creationDate,
)
/**
* Creates a mock [AccountJson] for testing purposes.
*/
fun createMockAccountJson(
number: Int,
profile: AccountJson.Profile = createMockAccountJsonProfile(number),
tokens: AccountTokensJson = AccountTokensJson(
accessToken = "accessToken-$number",
refreshToken = "refreshToken-$number",
),
settings: AccountJson.Settings = AccountJson.Settings(
environmentUrlData = null,
),
): AccountJson = AccountJson(
profile = profile,
tokens = tokens,
settings = settings,
)
@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.exporters.Account
import com.x8bit.bitwarden.data.vault.repository.model.createMockAccountJson
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class AccountJsonExtensionsTest {
@Test
fun `toAccountData returns populated AccountData when account is non-null`() {
val account = createMockAccountJson(number = 1)
assertEquals(
Account(
id = "mockId-1",
email = "mockEmail-1",
name = "mockName-1",
),
account.toSdkAccount(),
)
}
}
@@ -4,6 +4,7 @@ import com.bitwarden.collections.Collection
import com.bitwarden.collections.CollectionType
import com.bitwarden.network.model.CollectionTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.createMockOrganization
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
@@ -103,7 +104,7 @@ class VaultSdkCollectionExtensionsTest {
@Suppress("MaxLineLength")
@Test
fun `toSortAlphabetically should sort collections by type and name`() {
fun `toSortAlphabeticallyByTypeAndOrganization should sort collections by type and name`() {
val list = listOf(
createMockCollectionView(1).copy(name = "c"),
createMockCollectionView(1).copy(name = "B"),
@@ -111,16 +112,28 @@ class VaultSdkCollectionExtensionsTest {
createMockCollectionView(1).copy(name = "4"),
createMockCollectionView(1).copy(name = "A"),
createMockCollectionView(1).copy(name = "#"),
createMockCollectionView(1).copy(
name = "D",
createMockCollectionView(2).copy(
name = "Org2 items",
type = CollectionType.DEFAULT_USER_COLLECTION,
organizationId = "mockId-2",
),
createMockCollectionView(1).copy(
name = "Org1 items",
type = CollectionType.DEFAULT_USER_COLLECTION,
organizationId = "mockId-1",
),
)
val expected = listOf(
createMockCollectionView(1).copy(
name = "D",
name = "Org1 items",
type = CollectionType.DEFAULT_USER_COLLECTION,
organizationId = "mockId-1",
),
createMockCollectionView(2).copy(
name = "Org2 items",
type = CollectionType.DEFAULT_USER_COLLECTION,
organizationId = "mockId-2",
),
createMockCollectionView(1).copy(name = "#"),
createMockCollectionView(1).copy(name = "4"),
@@ -132,7 +145,12 @@ class VaultSdkCollectionExtensionsTest {
assertEquals(
expected,
list.sortAlphabeticallyByType(),
list.sortAlphabeticallyByTypeAndOrganization(
userOrganizations = listOf(
createMockOrganization(number = 1),
createMockOrganization(number = 2),
),
),
)
}
@@ -5,7 +5,9 @@ import app.cash.turbine.test
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength.LEVEL_0
@@ -590,7 +592,10 @@ class CompleteRegistrationViewModelTest : BaseViewModelTest() {
passwordInput = input,
dialog = CompleteRegistrationDialog.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_length_val_message_x.asText(12),
message = BitwardenPlurals.master_password_length_val_message_x.asPluralsText(
quantity = 12,
args = arrayOf(12),
),
),
)
viewModel.trySendAction(CompleteRegistrationAction.CallToActionClick)
@@ -3,7 +3,9 @@ package com.x8bit.bitwarden.ui.auth.feature.resetPassword
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
@@ -133,8 +135,10 @@ class ResetPasswordViewModelTest : BaseViewModelTest() {
resetReason = ForcePasswordResetReason.ADMIN_FORCE_PASSWORD_RESET,
dialogState = ResetPasswordState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
message = BitwardenPlurals.master_password_length_val_message_x.asPluralsText(
quantity = MIN_PASSWORD_LENGTH,
args = arrayOf(MIN_PASSWORD_LENGTH),
),
),
passwordInput = password,
passwordStrengthState = PasswordStrengthState.WEAK_1,
@@ -3,7 +3,9 @@ package com.x8bit.bitwarden.ui.auth.feature.setpassword
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
@@ -87,8 +89,10 @@ class SetPasswordViewModelTest : BaseViewModelTest() {
DEFAULT_STATE.copy(
dialogState = SetPasswordState.DialogState.Error(
title = BitwardenString.an_error_has_occurred.asText(),
message = BitwardenString.master_password_length_val_message_x
.asText(MIN_PASSWORD_LENGTH),
message = BitwardenPlurals.master_password_length_val_message_x.asPluralsText(
quantity = MIN_PASSWORD_LENGTH,
args = arrayOf(MIN_PASSWORD_LENGTH),
),
),
passwordInput = password,
retypePasswordInput = password,
@@ -46,7 +46,7 @@ class FlightRecorderDataSetExtensionsTest {
),
)
val result = dataset.getStopsLoggingStringForActiveLog(clock = FIXED_CLOCK)
assertEquals(BitwardenString.stops_logging_on.asText("1/21/70", "4:27 AM"), result)
assertEquals(BitwardenString.stops_logging_on.asText("1/21/70", "4:27\u202FAM"), result)
}
}
@@ -195,7 +195,7 @@ class LoginApprovalViewModelTest : BaseViewModelTest() {
email = EMAIL,
fingerprint = AUTH_REQUEST.fingerprint,
ipAddress = AUTH_REQUEST.ipAddress,
time = "9/13/24, 12:00 AM",
time = "9/13/24, 12:00\u202FAM",
),
)
val viewModel = createViewModel()
@@ -124,7 +124,7 @@ class FlightRecorderDataSetExtensionsTest {
id = "51",
title = "2015-10-10T02:55:52 – 2015-10-10T03:55:52".asText(),
subtextStart = "1.00 KB".asText(),
subtextEnd = BitwardenString.expires_at.asText("10:15 PM"),
subtextEnd = BitwardenString.expires_at.asText("10:15\u202FPM"),
isDeletedEnabled = true,
),
RecordedLogsState.DisplayItem(
@@ -157,7 +157,7 @@ class OtherViewModelTest : BaseViewModelTest() {
mutableVaultLastSyncStateFlow.tryEmit(newSyncTime)
assertEquals(
DEFAULT_STATE.copy(
lastSyncTime = "Oct 27, 2023, 12:00 PM",
lastSyncTime = "Oct 27, 2023, 12:00\u202FPM",
dialogState = null,
),
awaitItem(),
@@ -236,6 +236,6 @@ private val DEFAULT_STATE = OtherState(
allowScreenCapture = false,
allowSyncOnRefresh = false,
clearClipboardFrequency = ClearClipboardFrequency.NEVER,
lastSyncTime = "Oct 26, 2023, 12:00 PM",
lastSyncTime = "Oct 26, 2023, 12:00\u202FPM",
dialogState = null,
)
@@ -172,7 +172,7 @@ class PasswordHistoryViewModelTest : BaseViewModelTest() {
passwords = listOf(
PasswordHistoryState.GeneratedPassword(
password = "mockPassword-1",
date = "10/27/23, 12:00 PM",
date = "10/27/23, 12:00\u202FPM",
),
),
),
@@ -78,7 +78,7 @@ class SendDataExtensionsTest {
SendState.ViewState.Content.SendItem(
id = "mockId-1",
name = "mockName-1",
deletionDate = "Oct 27, 2023, 12:00 PM",
deletionDate = "Oct 27, 2023, 12:00\u202FPM",
type = SendState.ViewState.Content.SendItem.Type.FILE,
iconList = DEFAULT_SEND_STATUS_ICONS,
shareUrl = "www.test.com/#/send/mockAccessId-1/mockKey-1",
@@ -87,7 +87,7 @@ class SendDataExtensionsTest {
SendState.ViewState.Content.SendItem(
id = "mockId-2",
name = "mockName-2",
deletionDate = "Oct 27, 2023, 12:00 PM",
deletionDate = "Oct 27, 2023, 12:00\u202FPM",
type = SendState.ViewState.Content.SendItem.Type.TEXT,
iconList = DEFAULT_SEND_STATUS_ICONS,
shareUrl = "www.test.com/#/send/mockAccessId-2/mockKey-2",
@@ -48,7 +48,7 @@ class SendViewExtensionsTest {
),
shareLink = sendUrl,
sendName = "mockName-1",
deletionDate = "Oct 27, 2023, 12:00 PM",
deletionDate = "Oct 27, 2023, 12:00\u202FPM",
maxAccessCount = 1,
currentAccessCount = 1,
notes = "mockNotes-1",
@@ -76,7 +76,7 @@ class SendViewExtensionsTest {
),
shareLink = sendUrl,
sendName = "mockName-2",
deletionDate = "Oct 27, 2023, 12:00 PM",
deletionDate = "Oct 27, 2023, 12:00\u202FPM",
maxAccessCount = 1,
currentAccessCount = 1,
notes = "mockNotes-2",
@@ -20,8 +20,10 @@ import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.send.SendView
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherView
@@ -2439,7 +2441,10 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
assertEquals(
loginState.copy(
dialog = VaultAddEditState.DialogState.Generic(
message = BitwardenString.password_exposed.asText(breachCount),
message = BitwardenPlurals.password_exposed.asPluralsText(
quantity = breachCount,
args = arrayOf(breachCount),
),
),
),
awaitItem(),
@@ -217,7 +217,7 @@ class CipherViewExtensionsTest {
totp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example",
canViewPassword = false,
fido2CredentialCreationDateTime = BitwardenString.created_x.asText(
"Oct 27, 2023, 12:00 PM",
"Oct 27, 2023, 12:00\u202FPM",
),
),
),
@@ -278,7 +278,7 @@ class CipherViewExtensionsTest {
totp = totp,
canViewPassword = false,
fido2CredentialCreationDateTime = BitwardenString.created_x.asText(
"Oct 27, 2023, 12:00 PM",
"Oct 27, 2023, 12:00\u202FPM",
),
),
),
@@ -353,7 +353,7 @@ class ImportLoginsScreenTest : BitwardenComposeTest() {
it.copy(dialogState = ImportLoginsState.DialogState.Syncing)
}
composeTestRule
.onNodeWithText(text = "Syncing logins...")
.onNodeWithText(text = "Syncing logins…")
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
@@ -431,7 +431,7 @@ class VaultItemScreenTest : BitwardenComposeTest() {
// Verify only the first collection name is shown
composeTestRule
.onNodeWithText("My collection...")
.onNodeWithText("My collection…")
.assertIsDisplayed()
// Verify other collection names are not shown by default.
@@ -13,8 +13,10 @@ import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asPluralsText
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.concat
import com.bitwarden.vault.CipherView
@@ -1289,7 +1291,10 @@ class VaultItemViewModelTest : BaseViewModelTest() {
assertEquals(
loginState.copy(
dialog = VaultItemState.DialogState.Generic(
message = BitwardenString.password_exposed.asText(breachCount),
message = BitwardenPlurals.password_exposed.asPluralsText(
quantity = breachCount,
args = arrayOf(breachCount),
),
),
),
awaitItem(),
@@ -170,8 +170,8 @@ fun createCommonContent(
if (isEmpty) {
VaultItemState.ViewState.Content.Common(
name = "mockName",
created = BitwardenString.created.asText("Jan 1, 1970, 12:16 AM"),
lastUpdated = BitwardenString.last_edited.asText("Jan 1, 1970, 12:16 AM"),
created = BitwardenString.created.asText("Jan 1, 1970, 12:16\u202FAM"),
lastUpdated = BitwardenString.last_edited.asText("Jan 1, 1970, 12:16\u202FAM"),
notes = null,
customFields = emptyList(),
requiresCloneConfirmation = false,
@@ -188,8 +188,8 @@ fun createCommonContent(
} else {
VaultItemState.ViewState.Content.Common(
name = "mockName",
created = BitwardenString.created.asText("Jan 1, 1970, 12:16 AM"),
lastUpdated = BitwardenString.last_edited.asText("Jan 1, 1970, 12:16 AM"),
created = BitwardenString.created.asText("Jan 1, 1970, 12:16\u202FAM"),
lastUpdated = BitwardenString.last_edited.asText("Jan 1, 1970, 12:16\u202FAM"),
notes = "Lots of notes",
customFields = listOf(
FieldView(
@@ -271,7 +271,7 @@ fun createLoginContent(isEmpty: Boolean): VaultItemState.ViewState.Content.ItemT
)
},
passwordRevisionDate = BitwardenString.password_last_updated
.asText("Jan 1, 1970, 12:16 AM")
.asText("Jan 1, 1970, 12:16\u202FAM")
.takeUnless { isEmpty },
isPremiumUser = true,
totpCodeItemData = TotpCodeItemData(
@@ -281,7 +281,7 @@ fun createLoginContent(isEmpty: Boolean): VaultItemState.ViewState.Content.ItemT
)
.takeUnless { isEmpty },
fido2CredentialCreationDateText = BitwardenString.created_x
.asText("Oct 27, 2023, 12:00 PM")
.asText("Oct 27, 2023, 12:00\u202FPM")
.takeUnless { isEmpty },
canViewTotpCode = true,
)
@@ -878,7 +878,7 @@ class VaultScreenTest : BitwardenComposeTest() {
}
composeTestRule
.onNodeWithText("Syncing...")
.onNodeWithText("Syncing…")
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
}
@@ -69,7 +69,7 @@ class FlightRecorderDataSetExtensionsTest {
assertEquals(
BitwardenSnackbarData(
message = BitwardenString.flight_recorder_banner_message
.asText("4/12/25", "9:15 AM"),
.asText("4/12/25", "9:15\u202FAM"),
messageHeader = BitwardenString.flight_recorder_banner_title.asText(),
actionLabel = BitwardenString.go_to_settings.asText(),
withDismissAction = true,