mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-1274, BIT-1275, BIT-1276 Add the ability to export user vault data (#1040)
This commit is contained in:
parent
e6883d9599
commit
3fba5d6e9a
20 changed files with 643 additions and 57 deletions
|
@ -10,6 +10,7 @@ import com.bitwarden.core.Collection
|
|||
import com.bitwarden.core.CollectionView
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.core.DerivePinKeyResponse
|
||||
import com.bitwarden.core.ExportFormat
|
||||
import com.bitwarden.core.Folder
|
||||
import com.bitwarden.core.FolderView
|
||||
import com.bitwarden.core.InitOrgCryptoRequest
|
||||
|
@ -344,4 +345,15 @@ interface VaultSdkSource {
|
|||
userId: String,
|
||||
newPassword: String,
|
||||
): Result<UpdatePasswordResponse>
|
||||
|
||||
/**
|
||||
* Exports the users vault data and returns it as a string in the selected format
|
||||
* (JSON, CSV, encrypted JSON).
|
||||
*/
|
||||
suspend fun exportVaultDataToString(
|
||||
userId: String,
|
||||
folders: List<Folder>,
|
||||
ciphers: List<Cipher>,
|
||||
format: ExportFormat,
|
||||
): Result<String>
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.bitwarden.core.Collection
|
|||
import com.bitwarden.core.CollectionView
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.core.DerivePinKeyResponse
|
||||
import com.bitwarden.core.ExportFormat
|
||||
import com.bitwarden.core.Folder
|
||||
import com.bitwarden.core.FolderView
|
||||
import com.bitwarden.core.InitOrgCryptoRequest
|
||||
|
@ -372,6 +373,21 @@ class VaultSdkSourceImpl(
|
|||
.updatePassword(newPassword)
|
||||
}
|
||||
|
||||
override suspend fun exportVaultDataToString(
|
||||
userId: String,
|
||||
folders: List<Folder>,
|
||||
ciphers: List<Cipher>,
|
||||
format: ExportFormat,
|
||||
): Result<String> = runCatching {
|
||||
getClient(userId = userId)
|
||||
.exporters()
|
||||
.exportVault(
|
||||
folders = folders,
|
||||
ciphers = ciphers,
|
||||
format = format,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getClient(
|
||||
userId: String,
|
||||
): Client = sdkClientManager.getOrCreateClient(userId = userId)
|
||||
|
|
|
@ -28,6 +28,12 @@ interface FileManager {
|
|||
*/
|
||||
suspend fun fileToUri(fileUri: Uri, file: File): Boolean
|
||||
|
||||
/**
|
||||
* Writes an [dataString] to a [fileUri]. `true` will be returned if the file was
|
||||
* successfully saved.
|
||||
*/
|
||||
suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean
|
||||
|
||||
/**
|
||||
* Reads the [fileUri] into memory and returns the raw [ByteArray]
|
||||
*/
|
||||
|
|
|
@ -96,6 +96,23 @@ class FileManagerImpl(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
return try {
|
||||
withContext(dispatcherManager.io) {
|
||||
context
|
||||
.contentResolver
|
||||
.openOutputStream(fileUri)
|
||||
?.use { outputStream ->
|
||||
outputStream.write(dataString.toByteArray())
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (exception: RuntimeException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun uriToByteArray(fileUri: Uri): ByteArray =
|
||||
withContext(dispatcherManager.io) {
|
||||
context
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.net.Uri
|
|||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.CollectionView
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.core.ExportFormat
|
||||
import com.bitwarden.core.FolderView
|
||||
import com.bitwarden.core.SendType
|
||||
import com.bitwarden.core.SendView
|
||||
|
@ -22,6 +23,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.DownloadAttachmentResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.SendData
|
||||
|
@ -323,4 +325,9 @@ interface VaultRepository : VaultLockManager {
|
|||
* Attempt to update a folder.
|
||||
*/
|
||||
suspend fun updateFolder(folderId: String, folderView: FolderView): UpdateFolderResult
|
||||
|
||||
/**
|
||||
* Attempt to get the user's vault data for export.
|
||||
*/
|
||||
suspend fun exportVaultDataToString(format: ExportFormat): ExportVaultDataResult
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import com.bitwarden.core.CipherType
|
|||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.CollectionView
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.core.ExportFormat
|
||||
import com.bitwarden.core.FolderView
|
||||
import com.bitwarden.core.InitOrgCryptoRequest
|
||||
import com.bitwarden.core.InitUserCryptoMethod
|
||||
|
@ -64,6 +65,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
|
|||
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.DownloadAttachmentResult
|
||||
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.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
|
@ -1215,6 +1217,35 @@ class VaultRepositoryImpl(
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
override suspend fun exportVaultDataToString(format: ExportFormat): ExportVaultDataResult {
|
||||
val userId = activeUserId ?: return ExportVaultDataResult.Error
|
||||
val folders = vaultDiskSource
|
||||
.getFolders(userId)
|
||||
.firstOrNull()
|
||||
.orEmpty()
|
||||
.map { it.toEncryptedSdkFolder() }
|
||||
|
||||
val ciphers = vaultDiskSource
|
||||
.getCiphers(userId)
|
||||
.firstOrNull()
|
||||
.orEmpty()
|
||||
.map { it.toEncryptedSdkCipher() }
|
||||
.filter { it.collectionIds.isEmpty() }
|
||||
|
||||
return vaultSdkSource
|
||||
.exportVaultDataToString(
|
||||
userId = userId,
|
||||
folders = folders,
|
||||
ciphers = ciphers,
|
||||
format = format,
|
||||
)
|
||||
.fold(
|
||||
onSuccess = { ExportVaultDataResult.Success(it) },
|
||||
onFailure = { ExportVaultDataResult.Error },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
package com.x8bit.bitwarden.data.vault.repository.model
|
||||
|
||||
/**
|
||||
* Models result of the vault data being exported.
|
||||
*/
|
||||
sealed class ExportVaultDataResult {
|
||||
|
||||
/**
|
||||
* The vault data has been successfully converted into the selected format
|
||||
* (JSON, CSV, encrypted JSON).
|
||||
*/
|
||||
data class Success(val vaultData: String) : ExportVaultDataResult()
|
||||
|
||||
/**
|
||||
* There was an error converting the vault data.
|
||||
*/
|
||||
data object Error : ExportVaultDataResult()
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
|
||||
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
@ -45,6 +46,8 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
|||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager
|
||||
import com.x8bit.bitwarden.ui.platform.util.displayLabel
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
|
@ -56,10 +59,21 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
@Composable
|
||||
fun ExportVaultScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
intentManager: IntentManager = LocalIntentManager.current,
|
||||
viewModel: ExportVaultViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
val exportVaultLocationReceived: (Uri) -> Unit = remember {
|
||||
{ viewModel.trySendAction(ExportVaultAction.ExportLocationReceive(it)) }
|
||||
}
|
||||
val fileSaverLauncher = intentManager.getActivityResultLauncher { activityResult ->
|
||||
intentManager.getFileDataFromActivityResult(activityResult)?.let {
|
||||
exportVaultLocationReceived.invoke(it.uri)
|
||||
}
|
||||
}
|
||||
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
ExportVaultEvent.NavigateBack -> onNavigateBack()
|
||||
|
@ -67,6 +81,14 @@ fun ExportVaultScreen(
|
|||
is ExportVaultEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
is ExportVaultEvent.NavigateToSelectExportDataLocation -> {
|
||||
fileSaverLauncher.launch(
|
||||
intentManager.createDocumentIntent(
|
||||
fileName = event.fileName,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
|
@ -8,16 +9,24 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
||||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.toExportFormat
|
||||
import com.x8bit.bitwarden.ui.platform.util.fileExtension
|
||||
import com.x8bit.bitwarden.ui.platform.util.toFormattedPattern
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.time.Clock
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
@ -25,15 +34,20 @@ private const val KEY_STATE = "state"
|
|||
/**
|
||||
* Manages application state for the Export Vault screen.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
@HiltViewModel
|
||||
class ExportVaultViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val policyManager: PolicyManager,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val fileManager: FileManager,
|
||||
private val clock: Clock,
|
||||
) : BaseViewModel<ExportVaultState, ExportVaultEvent, ExportVaultAction>(
|
||||
initialState = savedStateHandle[KEY_STATE]
|
||||
?: ExportVaultState(
|
||||
dialogState = null,
|
||||
exportData = null,
|
||||
exportFormat = ExportVaultFormat.JSON,
|
||||
passwordInput = "",
|
||||
policyPreventsExport = policyManager
|
||||
|
@ -55,10 +69,19 @@ class ExportVaultViewModel @Inject constructor(
|
|||
ExportVaultAction.DialogDismiss -> handleDialogDismiss()
|
||||
is ExportVaultAction.ExportFormatOptionSelect -> handleExportFormatOptionSelect(action)
|
||||
is ExportVaultAction.PasswordInputChanged -> handlePasswordInputChanged(action)
|
||||
is ExportVaultAction.ExportLocationReceive -> handleExportLocationReceive(action)
|
||||
|
||||
is ExportVaultAction.Internal.ReceiveValidatePasswordResult -> {
|
||||
handleReceiveValidatePasswordResult(action)
|
||||
}
|
||||
|
||||
is ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult -> {
|
||||
handleReceivePrepareVaultDataResult(action)
|
||||
}
|
||||
|
||||
is ExportVaultAction.Internal.SaveExportDataToUriResultReceive -> {
|
||||
handleExportDataFinishedSavingToDisk(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -75,16 +98,11 @@ class ExportVaultViewModel @Inject constructor(
|
|||
private fun handleConfirmExportVaultClicked() {
|
||||
// Display an error alert if the user hasn't entered a password.
|
||||
if (mutableStateFlow.value.passwordInput.isBlank()) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExportVaultState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.validation_field_required.asText(
|
||||
R.string.master_password.asText(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
updateStateWithError(
|
||||
R.string.validation_field_required.asText(
|
||||
R.string.master_password.asText(),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -116,6 +134,27 @@ class ExportVaultViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the vault data in the location.
|
||||
*/
|
||||
private fun handleExportLocationReceive(action: ExportVaultAction.ExportLocationReceive) {
|
||||
val exportData = state.exportData
|
||||
if (exportData == null) {
|
||||
updateStateWithError(R.string.export_vault_failure.asText())
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = fileManager
|
||||
.stringToUri(
|
||||
fileUri = action.fileUri,
|
||||
dataString = exportData,
|
||||
)
|
||||
|
||||
sendAction(ExportVaultAction.Internal.SaveExportDataToUriResultReceive(result))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the state with the new password input.
|
||||
*/
|
||||
|
@ -133,34 +172,91 @@ class ExportVaultViewModel @Inject constructor(
|
|||
) {
|
||||
when (action.result) {
|
||||
ValidatePasswordResult.Error -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExportVaultState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
updateStateWithError(R.string.generic_error_message.asText())
|
||||
}
|
||||
|
||||
is ValidatePasswordResult.Success -> {
|
||||
// Display an error dialog if the password is invalid.
|
||||
if (!action.result.isValid) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExportVaultState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.invalid_master_password.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// TODO: BIT-1274, BIT-1275, and BIT-1276
|
||||
sendEvent(ExportVaultEvent.ShowToast("Not yet implemented".asText()))
|
||||
updateStateWithError(R.string.invalid_master_password.asText())
|
||||
return
|
||||
}
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(dialogState = ExportVaultState.DialogState.Loading())
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val result = vaultRepository.exportVaultDataToString(
|
||||
format = state.exportFormat.toExportFormat(state.passwordInput),
|
||||
)
|
||||
|
||||
sendAction(
|
||||
ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult(
|
||||
result = result,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error message or proceed to export the vault after receiving the data.
|
||||
*/
|
||||
private fun handleReceivePrepareVaultDataResult(
|
||||
action: ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult,
|
||||
) {
|
||||
when (val result = action.result) {
|
||||
is ExportVaultDataResult.Error -> {
|
||||
updateStateWithError(
|
||||
message = R.string.export_vault_failure.asText(),
|
||||
)
|
||||
}
|
||||
|
||||
is ExportVaultDataResult.Success -> {
|
||||
val date = clock.instant().toFormattedPattern(
|
||||
pattern = "yyyyMMddHHmmss",
|
||||
)
|
||||
val extension = state.exportFormat.fileExtension
|
||||
val fileName = "bitwarden_export_$date.$extension"
|
||||
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = null,
|
||||
passwordInput = "",
|
||||
exportData = result.vaultData,
|
||||
)
|
||||
}
|
||||
|
||||
sendEvent(
|
||||
ExportVaultEvent.NavigateToSelectExportDataLocation(fileName),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleExportDataFinishedSavingToDisk(
|
||||
action: ExportVaultAction.Internal.SaveExportDataToUriResultReceive,
|
||||
) {
|
||||
if (!action.result) {
|
||||
updateStateWithError(R.string.export_vault_failure.asText())
|
||||
return
|
||||
}
|
||||
|
||||
sendEvent(ExportVaultEvent.ShowToast(R.string.export_vault_success.asText()))
|
||||
}
|
||||
|
||||
private fun updateStateWithError(message: Text) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExportVaultState.DialogState.Error(
|
||||
title = null,
|
||||
message = message,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -168,6 +264,8 @@ class ExportVaultViewModel @Inject constructor(
|
|||
*/
|
||||
@Parcelize
|
||||
data class ExportVaultState(
|
||||
@IgnoredOnParcel
|
||||
val exportData: String? = null,
|
||||
val dialogState: DialogState?,
|
||||
val exportFormat: ExportVaultFormat,
|
||||
val passwordInput: String,
|
||||
|
@ -192,7 +290,7 @@ data class ExportVaultState(
|
|||
*/
|
||||
@Parcelize
|
||||
data class Loading(
|
||||
val message: Text,
|
||||
val message: Text = R.string.loading.asText(),
|
||||
) : DialogState()
|
||||
}
|
||||
}
|
||||
|
@ -210,6 +308,11 @@ sealed class ExportVaultEvent {
|
|||
* Shows a toast with the given [message].
|
||||
*/
|
||||
data class ShowToast(val message: Text) : ExportVaultEvent()
|
||||
|
||||
/**
|
||||
* Navigates to select a location where to save the vault data with the [fileName].
|
||||
*/
|
||||
data class NavigateToSelectExportDataLocation(val fileName: String) : ExportVaultEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -236,6 +339,13 @@ sealed class ExportVaultAction {
|
|||
*/
|
||||
data class ExportFormatOptionSelect(val option: ExportVaultFormat) : ExportVaultAction()
|
||||
|
||||
/**
|
||||
* Indicates the user has selected a location to save the file.
|
||||
*/
|
||||
data class ExportLocationReceive(
|
||||
val fileUri: Uri,
|
||||
) : ExportVaultAction()
|
||||
|
||||
/**
|
||||
* Indicates that the password input has changed.
|
||||
*/
|
||||
|
@ -245,6 +355,21 @@ sealed class ExportVaultAction {
|
|||
* Models actions that the [ExportVaultViewModel] might send itself.
|
||||
*/
|
||||
sealed class Internal : ExportVaultAction() {
|
||||
|
||||
/**
|
||||
* Indicates that the item has finished saving to disk.
|
||||
*/
|
||||
data class SaveExportDataToUriResultReceive(
|
||||
val result: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the result for exporting the vault data has been received.
|
||||
*/
|
||||
data class ReceiveExportVaultDataToStringResult(
|
||||
val result: ExportVaultDataResult,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that a validate password result has been received.
|
||||
*/
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model
|
||||
|
||||
import com.bitwarden.core.ExportFormat
|
||||
|
||||
/**
|
||||
* Represents the file formats a user can select to export the vault.
|
||||
*/
|
||||
|
@ -8,3 +10,13 @@ enum class ExportVaultFormat {
|
|||
CSV,
|
||||
JSON_ENCRYPTED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the [ExportVaultFormat] to [ExportFormat].
|
||||
*/
|
||||
fun ExportVaultFormat.toExportFormat(password: String): ExportFormat =
|
||||
when (this) {
|
||||
ExportVaultFormat.JSON -> ExportFormat.Json
|
||||
ExportVaultFormat.CSV -> ExportFormat.Csv
|
||||
ExportVaultFormat.JSON_ENCRYPTED -> ExportFormat.EncryptedJson(password)
|
||||
}
|
||||
|
|
|
@ -76,9 +76,9 @@ interface IntentManager {
|
|||
fun createFileChooserIntent(withCameraIntents: Boolean): Intent
|
||||
|
||||
/**
|
||||
* Creates an intent to use when selecting to save an attachment with [fileName] to disk.
|
||||
* Creates an intent to use when selecting to save an item with [fileName] to disk.
|
||||
*/
|
||||
fun createAttachmentChooserIntent(fileName: String): Intent
|
||||
fun createDocumentIntent(fileName: String): Intent
|
||||
|
||||
/**
|
||||
* Represents file information.
|
||||
|
|
|
@ -172,7 +172,7 @@ class IntentManagerImpl(
|
|||
return chooserIntent
|
||||
}
|
||||
|
||||
override fun createAttachmentChooserIntent(fileName: String): Intent =
|
||||
override fun createDocumentIntent(fileName: String): Intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
// Attempt to get the MIME type from the file extension
|
||||
val extension = MimeTypeMap.getFileExtensionFromUrl(fileName)
|
||||
|
|
|
@ -2,9 +2,22 @@ package com.x8bit.bitwarden.ui.platform.util
|
|||
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
|
||||
|
||||
/**
|
||||
* Provides a human-readable label for the export format.
|
||||
*/
|
||||
val ExportVaultFormat.displayLabel: String
|
||||
get() = when (this) {
|
||||
ExportVaultFormat.JSON -> ".json"
|
||||
ExportVaultFormat.CSV -> ".csv"
|
||||
ExportVaultFormat.JSON_ENCRYPTED -> ".json (Encrypted)"
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the file extension associated with the export format.
|
||||
*/
|
||||
val ExportVaultFormat.fileExtension: String
|
||||
get() = when (this) {
|
||||
ExportVaultFormat.JSON -> "json"
|
||||
ExportVaultFormat.CSV -> "csv"
|
||||
ExportVaultFormat.JSON_ENCRYPTED -> "json"
|
||||
}
|
||||
|
|
|
@ -114,7 +114,7 @@ fun VaultItemScreen(
|
|||
|
||||
is VaultItemEvent.NavigateToSelectAttachmentSaveLocation -> {
|
||||
fileChooserLauncher.launch(
|
||||
intentManager.createAttachmentChooserIntent(event.fileName),
|
||||
intentManager.createDocumentIntent(event.fileName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.bitwarden.core.Collection
|
|||
import com.bitwarden.core.CollectionView
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.core.DerivePinKeyResponse
|
||||
import com.bitwarden.core.ExportFormat
|
||||
import com.bitwarden.core.Folder
|
||||
import com.bitwarden.core.FolderView
|
||||
import com.bitwarden.core.InitOrgCryptoRequest
|
||||
|
@ -24,6 +25,7 @@ import com.bitwarden.sdk.BitwardenException
|
|||
import com.bitwarden.sdk.Client
|
||||
import com.bitwarden.sdk.ClientAuth
|
||||
import com.bitwarden.sdk.ClientCrypto
|
||||
import com.bitwarden.sdk.ClientExporters
|
||||
import com.bitwarden.sdk.ClientPasswordHistory
|
||||
import com.bitwarden.sdk.ClientPlatform
|
||||
import com.bitwarden.sdk.ClientVault
|
||||
|
@ -31,6 +33,8 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
|
|||
import com.x8bit.bitwarden.data.platform.util.asFailure
|
||||
import com.x8bit.bitwarden.data.platform.util.asSuccess
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkCipher
|
||||
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFolder
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
|
@ -49,14 +53,18 @@ class VaultSdkSourceTest {
|
|||
private val clientCrypto = mockk<ClientCrypto>()
|
||||
private val clientPlatform = mockk<ClientPlatform>()
|
||||
private val clientPasswordHistory = mockk<ClientPasswordHistory>()
|
||||
private val clientVault = mockk<ClientVault>() {
|
||||
private val clientVault = mockk<ClientVault> {
|
||||
every { passwordHistory() } returns clientPasswordHistory
|
||||
}
|
||||
private val client = mockk<Client>() {
|
||||
private val clientExporters = mockk<ClientExporters> {
|
||||
coEvery { exportVault(any(), any(), any()) }
|
||||
}
|
||||
private val client = mockk<Client> {
|
||||
every { auth() } returns clientAuth
|
||||
every { vault() } returns clientVault
|
||||
every { platform() } returns clientPlatform
|
||||
every { crypto() } returns clientCrypto
|
||||
every { exporters() } returns clientExporters
|
||||
}
|
||||
private val sdkClientManager = mockk<SdkClientManager> {
|
||||
every { getOrCreateClient(any()) } returns client
|
||||
|
@ -847,4 +855,43 @@ class VaultSdkSourceTest {
|
|||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportVaultDataToString should call SDK and return a Result with the correct data`() =
|
||||
runTest {
|
||||
val userId = "userId"
|
||||
val expected = "TestResult"
|
||||
|
||||
val format = ExportFormat.Json
|
||||
val ciphers = listOf(createMockSdkCipher(1))
|
||||
val folders = listOf(createMockSdkFolder(1))
|
||||
|
||||
coEvery {
|
||||
clientExporters.exportVault(
|
||||
folders = folders,
|
||||
ciphers = ciphers,
|
||||
format = format,
|
||||
)
|
||||
} returns expected
|
||||
|
||||
val result = vaultSdkSource.exportVaultDataToString(
|
||||
userId = userId,
|
||||
folders = folders,
|
||||
ciphers = ciphers,
|
||||
format = ExportFormat.Json,
|
||||
)
|
||||
|
||||
coVerify {
|
||||
clientExporters.exportVault(
|
||||
folders = folders,
|
||||
ciphers = ciphers,
|
||||
format = format,
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
expected.asSuccess(),
|
||||
result,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import com.bitwarden.core.Cipher
|
|||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.CollectionView
|
||||
import com.bitwarden.core.DateTime
|
||||
import com.bitwarden.core.ExportFormat
|
||||
import com.bitwarden.core.Folder
|
||||
import com.bitwarden.core.FolderView
|
||||
import com.bitwarden.core.InitOrgCryptoRequest
|
||||
|
@ -92,6 +93,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
|
|||
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.DownloadAttachmentResult
|
||||
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.RemovePasswordSendResult
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
|
||||
|
@ -5450,6 +5452,63 @@ class VaultRepositoryTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `exportVaultDataToString should return a success result when data is successfully converted for export`() =
|
||||
runTest {
|
||||
val format = ExportFormat.Json
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val userId = "mockId-1"
|
||||
|
||||
coEvery {
|
||||
vaultDiskSource.getCiphers(userId)
|
||||
} returns flowOf(listOf(createMockCipher(1)))
|
||||
|
||||
coEvery {
|
||||
vaultDiskSource.getFolders(userId)
|
||||
} returns flowOf(listOf(createMockFolder(1)))
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.exportVaultDataToString(userId, any(), any(), format)
|
||||
} returns "TestResult".asSuccess()
|
||||
|
||||
val expected = ExportVaultDataResult.Success(vaultData = "TestResult")
|
||||
val result = vaultRepository.exportVaultDataToString(format = format)
|
||||
|
||||
assertEquals(
|
||||
expected,
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportVaultDataToString should return a failure result when the data conversion fails`() =
|
||||
runTest {
|
||||
val format = ExportFormat.Json
|
||||
fakeAuthDiskSource.userState = MOCK_USER_STATE
|
||||
val userId = "mockId-1"
|
||||
|
||||
coEvery {
|
||||
vaultDiskSource.getCiphers(userId)
|
||||
} returns flowOf(listOf(createMockCipher(1)))
|
||||
|
||||
coEvery {
|
||||
vaultDiskSource.getFolders(userId)
|
||||
} returns flowOf(listOf(createMockFolder(1)))
|
||||
|
||||
coEvery {
|
||||
vaultSdkSource.exportVaultDataToString(userId, any(), any(), format)
|
||||
} returns Throwable("Fail").asFailure()
|
||||
|
||||
val expected = ExportVaultDataResult.Error
|
||||
val result = vaultRepository.exportVaultDataToString(format = format)
|
||||
|
||||
assertEquals(
|
||||
expected,
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
//region Helper functions
|
||||
|
||||
/**
|
||||
|
|
|
@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
|
|||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
@ -36,16 +37,28 @@ class ExportVaultScreenTest : BaseComposeTest() {
|
|||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
private val intentManager = mockk<IntentManager>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
composeTestRule.setContent {
|
||||
ExportVaultScreen(
|
||||
onNavigateBack = { onNavigateBackCalled = true },
|
||||
viewModel = viewModel,
|
||||
intentManager = intentManager,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToSelectExportDataLocation should invoke createDocumentIntent`() {
|
||||
mutableEventFlow.tryEmit(ExportVaultEvent.NavigateToSelectExportDataLocation("test.json"))
|
||||
|
||||
verify(exactly = 1) {
|
||||
intentManager.createDocumentIntent("test.json")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `basicDialog should update according to state`() {
|
||||
composeTestRule.onNodeWithText("Error message").assertDoesNotExist()
|
||||
|
@ -62,6 +75,23 @@ class ExportVaultScreenTest : BaseComposeTest() {
|
|||
composeTestRule.onNodeWithText("Error message").isDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `progress dialog should be displayed according to state`() {
|
||||
val loadingMessage = "loading..."
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = ExportVaultState.DialogState.Loading(loadingMessage.asText()),
|
||||
)
|
||||
}
|
||||
composeTestRule
|
||||
.onNodeWithText(loadingMessage)
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
composeTestRule.onNode(isDialog()).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close button click should send CloseButtonClick action`() {
|
||||
composeTestRule.onNodeWithContentDescription("Close").performClick()
|
||||
|
@ -199,5 +229,6 @@ private val DEFAULT_STATE = ExportVaultState(
|
|||
dialogState = null,
|
||||
exportFormat = ExportVaultFormat.JSON,
|
||||
passwordInput = "",
|
||||
exportData = "",
|
||||
policyPreventsExport = false,
|
||||
)
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.platform.feature.settings.exportvault
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
|
@ -8,15 +9,25 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
|
|||
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
|
||||
import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
|
||||
import com.x8bit.bitwarden.data.vault.manager.FileManager
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.ExportVaultDataResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.feature.settings.exportvault.model.ExportVaultFormat
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.TimeZone
|
||||
|
||||
class ExportVaultViewModelTest : BaseViewModelTest() {
|
||||
private val authRepository: AuthRepository = mockk()
|
||||
|
@ -27,7 +38,25 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
|
|||
} returns emptyList()
|
||||
}
|
||||
|
||||
private val savedStateHandle = SavedStateHandle()
|
||||
private val clock: Clock = Clock.fixed(
|
||||
Instant.parse("2023-10-27T12:00:00Z"),
|
||||
ZoneOffset.UTC,
|
||||
)
|
||||
|
||||
private val vaultRepository: VaultRepository = mockk {
|
||||
coEvery { exportVaultDataToString(any()) } returns mockk()
|
||||
}
|
||||
private val fileManager: FileManager = mockk()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
TimeZone.setDefault(null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct`() = runTest {
|
||||
|
@ -59,25 +88,26 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `ConfirmExportVaultClicked correct password should emit ShowToast`() = runTest {
|
||||
val password = "password"
|
||||
coEvery {
|
||||
authRepository.validatePassword(
|
||||
password = password,
|
||||
)
|
||||
} returns ValidatePasswordResult.Success(isValid = true)
|
||||
fun `ConfirmExportVaultClicked correct password should call exportVaultDataToString`() =
|
||||
runTest {
|
||||
val password = "password"
|
||||
coEvery {
|
||||
authRepository.validatePassword(
|
||||
password = password,
|
||||
)
|
||||
} returns ValidatePasswordResult.Success(isValid = true)
|
||||
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(password))
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ExportVaultAction.PasswordInputChanged(password))
|
||||
|
||||
viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked)
|
||||
assertEquals(
|
||||
ExportVaultEvent.ShowToast("Not yet implemented".asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
viewModel.trySendAction(ExportVaultAction.ConfirmExportVaultClicked)
|
||||
|
||||
coVerify {
|
||||
vaultRepository.exportVaultDataToString(any())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ConfirmExportVaultClicked blank password should show an error`() = runTest {
|
||||
|
@ -191,11 +221,135 @@ class ExportVaultViewModelTest : BaseViewModelTest() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(): ExportVaultViewModel =
|
||||
@Test
|
||||
fun `ReceiveExportVaultDataToStringResult should update state to error if result is error`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.trySendAction(
|
||||
ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult(
|
||||
result = ExportVaultDataResult.Error,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = ExportVaultState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.export_vault_failure.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `ReceiveExportVaultDataToStringResult should emit NavigateToSelectExportDataLocation on result success`() =
|
||||
runTest {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(
|
||||
ExportVaultAction.Internal.ReceiveExportVaultDataToStringResult(
|
||||
result = ExportVaultDataResult.Success(vaultData = "TestVaultData"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
ExportVaultEvent.NavigateToSelectExportDataLocation(
|
||||
fileName = "bitwarden_export_20231027120000.json",
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExportLocationReceive should update state to error if exportData is null`() {
|
||||
val viewModel = createViewModel()
|
||||
val uri = mockk<Uri>()
|
||||
|
||||
viewModel.trySendAction(ExportVaultAction.ExportLocationReceive(fileUri = uri))
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
dialogState = ExportVaultState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.export_vault_failure.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExportLocationReceive should update state to error if saving the data fails`() =
|
||||
runTest {
|
||||
val exportData = "TestExportVaultData"
|
||||
val viewModel = createViewModel(
|
||||
DEFAULT_STATE.copy(
|
||||
exportData = exportData,
|
||||
),
|
||||
)
|
||||
val uri = mockk<Uri>()
|
||||
coEvery {
|
||||
fileManager.stringToUri(fileUri = any(), dataString = exportData)
|
||||
} returns false
|
||||
|
||||
viewModel.trySendAction(ExportVaultAction.ExportLocationReceive(fileUri = uri))
|
||||
|
||||
coVerify {
|
||||
fileManager.stringToUri(fileUri = any(), dataString = exportData)
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(
|
||||
exportData = exportData,
|
||||
dialogState = ExportVaultState.DialogState.Error(
|
||||
title = null,
|
||||
message = R.string.export_vault_failure.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ExportLocationReceive should emit ShowToast on success`() = runTest {
|
||||
val exportData = "TestExportVaultData"
|
||||
val viewModel = createViewModel(
|
||||
DEFAULT_STATE.copy(
|
||||
exportData = exportData,
|
||||
),
|
||||
)
|
||||
val uri = mockk<Uri>()
|
||||
coEvery { fileManager.stringToUri(fileUri = any(), dataString = exportData) } returns true
|
||||
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(ExportVaultAction.ExportLocationReceive(uri))
|
||||
|
||||
coVerify { fileManager.stringToUri(fileUri = any(), dataString = exportData) }
|
||||
|
||||
assertEquals(
|
||||
ExportVaultEvent.ShowToast(R.string.export_vault_success.asText()),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
initialState: ExportVaultState? = null,
|
||||
): ExportVaultViewModel =
|
||||
ExportVaultViewModel(
|
||||
authRepository = authRepository,
|
||||
policyManager = policyManager,
|
||||
savedStateHandle = savedStateHandle,
|
||||
savedStateHandle = SavedStateHandle(
|
||||
initialState = mapOf("state" to initialState),
|
||||
),
|
||||
fileManager = fileManager,
|
||||
vaultRepository = vaultRepository,
|
||||
clock = clock,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -203,5 +357,6 @@ private val DEFAULT_STATE = ExportVaultState(
|
|||
dialogState = null,
|
||||
exportFormat = ExportVaultFormat.JSON,
|
||||
passwordInput = "",
|
||||
exportData = null,
|
||||
policyPreventsExport = false,
|
||||
)
|
||||
|
|
|
@ -19,4 +19,19 @@ class ExportVaultFormatExtensionTest {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fileExtension should return the correct value for each type`() {
|
||||
mapOf(
|
||||
ExportVaultFormat.JSON to "json",
|
||||
ExportVaultFormat.CSV to "csv",
|
||||
ExportVaultFormat.JSON_ENCRYPTED to "json",
|
||||
)
|
||||
.forEach { (type, label) ->
|
||||
assertEquals(
|
||||
label,
|
||||
type.fileExtension,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -145,11 +145,11 @@ class VaultItemScreenTest : BaseComposeTest() {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `NavigateToSelectAttachmentSaveLocation should invoke createAttachmentChooserIntent`() {
|
||||
fun `NavigateToSelectAttachmentSaveLocation should invoke createDocumentIntent`() {
|
||||
mutableEventFlow.tryEmit(VaultItemEvent.NavigateToSelectAttachmentSaveLocation("test.mp4"))
|
||||
|
||||
verify(exactly = 1) {
|
||||
intentManager.createAttachmentChooserIntent("test.mp4")
|
||||
intentManager.createDocumentIntent("test.mp4")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue