diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt index 68d5c4cef..2c4978d21 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSource.kt @@ -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 + + /** + * 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, + ciphers: List, + format: ExportFormat, + ): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt index 1d6f7b7de..cb32145a4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceImpl.kt @@ -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, + ciphers: List, + format: ExportFormat, + ): Result = runCatching { + getClient(userId = userId) + .exporters() + .exportVault( + folders = folders, + ciphers = ciphers, + format = format, + ) + } + private fun getClient( userId: String, ): Client = sdkClientManager.getOrCreateClient(userId = userId) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt index a72142769..e2a2d8f68 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManager.kt @@ -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] */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt index 64bec4075..4d0d417d6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/FileManagerImpl.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index 53a27a3cf..b4995bad1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -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 } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 0dba9869f..3fd37fb5e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -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 diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/ExportVaultDataResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/ExportVaultDataResult.kt new file mode 100644 index 000000000..b7cf05071 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/ExportVaultDataResult.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt index f8fb6d3e7..68a38f922 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreen.kt @@ -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, + ), + ) + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt index 7b2f8d0f0..086f952bb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModel.kt @@ -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( 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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/model/ExportVaultFormat.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/model/ExportVaultFormat.kt index f870ac0e0..bf02c1813 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/model/ExportVaultFormat.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/model/ExportVaultFormat.kt @@ -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) + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt index 7441a81cd..3f502005a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManager.kt @@ -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. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index f197fa441..684c2a742 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -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) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ExportVaultFormatExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ExportVaultFormatExtensions.kt index 415c1ee41..3a352047c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ExportVaultFormatExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/ExportVaultFormatExtensions.kt @@ -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" + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index 35dee7030..0c28ba0fb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -114,7 +114,7 @@ fun VaultItemScreen( is VaultItemEvent.NavigateToSelectAttachmentSaveLocation -> { fileChooserLauncher.launch( - intentManager.createAttachmentChooserIntent(event.fileName), + intentManager.createDocumentIntent(event.fileName), ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt index 16f56572b..c5aaa3c55 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/VaultSdkSourceTest.kt @@ -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() private val clientPlatform = mockk() private val clientPasswordHistory = mockk() - private val clientVault = mockk() { + private val clientVault = mockk { every { passwordHistory() } returns clientPasswordHistory } - private val client = mockk() { + private val clientExporters = mockk { + coEvery { exportVault(any(), any(), any()) } + } + private val client = mockk { every { auth() } returns clientAuth every { vault() } returns clientVault every { platform() } returns clientPlatform every { crypto() } returns clientCrypto + every { exporters() } returns clientExporters } private val sdkClientManager = mockk { 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, + ) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index a4386b333..57824be3e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -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 /** diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt index bfd4ae39f..b4a5bcd40 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultScreenTest.kt @@ -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(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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt index a1b8f6790..5401ce715 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/exportvault/ExportVaultViewModelTest.kt @@ -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() + + 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() + 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() + 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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ExportVaultFormatExtensionTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ExportVaultFormatExtensionTest.kt index 0ecafd9f7..e5f9875c3 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ExportVaultFormatExtensionTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ExportVaultFormatExtensionTest.kt @@ -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, + ) + } + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index ec2eb34b2..e027592f1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -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") } }