diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt index 47f39f3bcec..e944e2b5f92 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt @@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonReq import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonResponse import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest @@ -149,4 +150,9 @@ interface CiphersApi { */ @GET("ciphers/has-unassigned-ciphers") suspend fun hasUnassignedCiphers(): NetworkResult + + @POST("ciphers/import") + suspend fun importCiphers( + @Body body: ImportCiphersJsonRequest, + ): NetworkResult } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/ImportCiphersJsonRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/ImportCiphersJsonRequest.kt new file mode 100644 index 00000000000..509ba21d3dd --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/ImportCiphersJsonRequest.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents an import ciphers request. + * + * @property folders A list of folders to import. + * @property ciphers A list of ciphers to import. + * @property folderRelationships A map of cipher folder relationships to import. Key correlates to + * the index of the cipher in the ciphers list. Value correlates to the index of the folder in the + * folders list. + */ +@Serializable +data class ImportCiphersJsonRequest( + @SerialName("folders") + val folders: List, + @SerialName("ciphers") + val ciphers: List, + @SerialName("folderRelationships") + val folderRelationships: Map, +) { + /** + * Represents a folder request with an optional [id] if the folder already exists. + * + * @property name The name of the folder. + * @property id The ID of the folder, if it already exists. Null otherwise. + **/ + @Serializable + data class FolderWithIdJsonRequest( + @SerialName("name") + val name: String?, + @SerialName("id") + val id: String?, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/ImportCiphersResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/ImportCiphersResponseJson.kt new file mode 100644 index 00000000000..d498c8565e4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/ImportCiphersResponseJson.kt @@ -0,0 +1,41 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The response body for importing ciphers. + */ +@Serializable +sealed class ImportCiphersResponseJson { + + /** + * Models a successful json response. + */ + @Serializable + object Success : ImportCiphersResponseJson() + + /** + * Represents the json body of an invalid request. + * + * @param validationErrors a map where each value is a list of error messages for each key. + * The values in the array should be used for display to the user, since the keys tend to come + * back as nonsense. (eg: empty string key) + */ + @Serializable + data class Invalid( + @SerialName("message") + private val invalidMessage: String? = null, + + @SerialName("Message") + private val errorMessage: String? = null, + + @SerialName("validationErrors") + val validationErrors: Map>?, + ) : ImportCiphersResponseJson() { + /** + * A generic error message. + */ + val message: String? get() = invalidMessage ?: errorMessage + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt index bedb1286ee9..4c89eb748cf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt @@ -5,6 +5,8 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonReq import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonResponse import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest @@ -118,4 +120,9 @@ interface CiphersService { * Returns a boolean indicating if the active user has unassigned ciphers. */ suspend fun hasUnassignedCiphers(): Result + + /** + * Attempt to import ciphers. + */ + suspend fun importCiphers(request: ImportCiphersJsonRequest): Result } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt index b4a61c39e9a..3e4fc2d836a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt @@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.AttachmentJsonRes import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType +import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest @@ -216,6 +218,23 @@ class CiphersServiceImpl( .hasUnassignedCiphers() .toResult() + override suspend fun importCiphers( + request: ImportCiphersJsonRequest, + ): Result = + ciphersApi + .importCiphers(body = request) + .toResult() + .map { ImportCiphersResponseJson.Success } + .recoverCatching { throwable -> + throwable + .toBitwardenError() + .parseErrorBodyOrNull( + code = 400, + json = json, + ) + ?: throw throwable + } + private fun createMultipartBodyBuilder( encryptedFile: File, filename: String?, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt index d8036ce4489..5c03f89818b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt @@ -6,6 +6,8 @@ import com.x8bit.bitwarden.data.vault.datasource.network.api.AzureApi import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi import com.x8bit.bitwarden.data.vault.datasource.network.model.CreateCipherInOrganizationJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.FileUploadType +import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.ImportCiphersResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.ShareCipherJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherCollectionsJsonRequest import com.x8bit.bitwarden.data.vault.datasource.network.model.UpdateCipherResponseJson @@ -321,6 +323,32 @@ class CiphersServiceTest : BaseServiceTest() { val result = ciphersService.hasUnassignedCiphers() assertTrue(result.getOrThrow()) } + + @Test + fun `importCiphers should return the correct response`() = runTest { + server.enqueue(MockResponse().setResponseCode(200)) + val result = ciphersService.importCiphers( + request = ImportCiphersJsonRequest( + ciphers = listOf(createMockCipherJsonRequest(number = 1)), + folders = emptyList(), + folderRelationships = emptyMap(), + ), + ) + assertEquals(ImportCiphersResponseJson.Success, result.getOrThrow()) + } + + @Test + fun `importCiphers should return an error when the response is an error`() = runTest { + server.enqueue(MockResponse().setResponseCode(400)) + val result = ciphersService.importCiphers( + request = ImportCiphersJsonRequest( + ciphers = listOf(createMockCipherJsonRequest(number = 1)), + folders = emptyList(), + folderRelationships = emptyMap(), + ), + ) + assertTrue(result.isFailure) + } } private fun setupMockUri(