diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializer.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializer.kt index a9c991182f..77da97ca12 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializer.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializer.kt @@ -16,7 +16,7 @@ class LocalDateTimeSerializer : KSerializer { private val dateTimeFormatterDeserialization = DateTimeFormatter .ofPattern("yyyy-MM-dd'T'HH:mm:ss.[SSSSSSS][SSSSSS][SSSSS][SSSS][SSS][SS][S]'Z'") private val dateTimeFormatterSerialization = - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSSS'Z'") + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") override val descriptor: SerialDescriptor get() = PrimitiveSerialDescriptor(serialName = "LocalDateTime", kind = PrimitiveKind.STRING) 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 new file mode 100644 index 0000000000..bf00fcc6f0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/api/CiphersApi.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.api + +import CipherJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Defines raw calls under the /ciphers API with authentication applied. + */ +interface CiphersApi { + + /** + * Create a cipher. + */ + @POST("ciphers") + suspend fun createCipher(@Body body: CipherJsonRequest): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt index 2d47e7f7c4..f2a3175b2d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/di/VaultNetworkModule.kt @@ -3,6 +3,8 @@ package com.x8bit.bitwarden.data.vault.datasource.network.di import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncServiceImpl import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits +import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersService +import com.x8bit.bitwarden.data.vault.datasource.network.service.CiphersServiceImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -17,6 +19,14 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object VaultNetworkModule { + @Provides + @Singleton + fun provideCiphersService( + retrofits: Retrofits, + ): CiphersService = CiphersServiceImpl( + ciphersApi = retrofits.authenticatedApiRetrofit.create(), + ) + @Provides @Singleton fun provideSyncService( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequest.kt new file mode 100644 index 0000000000..a7fedf328a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequest.kt @@ -0,0 +1,71 @@ +import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherRepromptTypeJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.CipherTypeJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +/** + * Represents a cipher request. + * + * @property notes The notes of the cipher (nullable). + * @property reprompt The reprompt of the cipher. + * @property passwordHistory A list of password history objects + * associated with the cipher (nullable). + * @property type The type of cipher. + * @property login The login of the cipher. + * @property secureNote The secure note of the cipher. + * @property folderId The folder ID of the cipher (nullable). + * @property organizationId The organization ID of the cipher (nullable). + * @property identity The identity of the cipher. + * @property name The name of the cipher (nullable). + * @property fields A list of fields associated with the cipher (nullable). + * @property isFavorite If the cipher is a favorite. + * @property card The card of the cipher. + */ +@Serializable +data class CipherJsonRequest( + @SerialName("notes") + val notes: String?, + + @SerialName("reprompt") + val reprompt: CipherRepromptTypeJson, + + @SerialName("passwordHistory") + val passwordHistory: List?, + + @SerialName("lastKnownRevisionDate") + @Contextual + val lastKnownRevisionDate: LocalDateTime?, + + @SerialName("type") + val type: CipherTypeJson, + + @SerialName("login") + val login: SyncResponseJson.Cipher.Login?, + + @SerialName("secureNote") + val secureNote: SyncResponseJson.Cipher.SecureNote?, + + @SerialName("folderId") + val folderId: String?, + + @SerialName("organizationId") + val organizationId: String?, + + @SerialName("identity") + val identity: SyncResponseJson.Cipher.Identity?, + + @SerialName("name") + val name: String?, + + @SerialName("fields") + val fields: List?, + + @SerialName("favorite") + val isFavorite: Boolean, + + @SerialName("card") + val card: SyncResponseJson.Cipher.Card?, +) 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 new file mode 100644 index 0000000000..acb88aaa5a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersService.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.service + +import CipherJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson + +/** + * Provides an API for querying ciphers endpoints. + */ +interface CiphersService { + /** + * Attempt to create a cipher. + */ + suspend fun createCipher(body: CipherJsonRequest): 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 new file mode 100644 index 0000000000..d25f939332 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceImpl.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.service + +import CipherJsonRequest +import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson + +class CiphersServiceImpl constructor( + private val ciphersApi: CiphersApi, +) : CiphersService { + override suspend fun createCipher(body: CipherJsonRequest): Result = + ciphersApi.createCipher(body = body) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index 66316c1d2b..260756081e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -10,21 +10,22 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState * Transforms a [CipherView] into a [VaultState.ViewState.VaultItem]. */ @Suppress("MagicNumber") -private fun CipherView.toVaultItem(): VaultState.ViewState.VaultItem = - when (type) { +private fun CipherView.toVaultItemOrNull(): VaultState.ViewState.VaultItem? { + val id = this.id ?: return null + return when (type) { CipherType.LOGIN -> VaultState.ViewState.VaultItem.Login( - id = id.toString(), + id = id, name = name.asText(), username = login?.username?.asText(), ) CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote( - id = id.toString(), + id = id, name = name.asText(), ) CipherType.CARD -> VaultState.ViewState.VaultItem.Card( - id = id.toString(), + id = id, name = name.asText(), brand = card?.brand?.asText(), lastFourDigits = card?.number @@ -33,11 +34,12 @@ private fun CipherView.toVaultItem(): VaultState.ViewState.VaultItem = ) CipherType.IDENTITY -> VaultState.ViewState.VaultItem.Identity( - id = id.toString(), + id = id, name = name.asText(), firstName = identity?.firstName?.asText(), ) } +} /** * Transforms [VaultData] into [VaultState.ViewState]. @@ -46,24 +48,28 @@ fun VaultData.toViewState(): VaultState.ViewState = if (cipherViewList.isEmpty() && folderViewList.isEmpty()) { VaultState.ViewState.NoItems } else { + // Filter out any items with invalid IDs in the unlikely case they exist + val filteredCipherViewList = cipherViewList.filterNot { it.id.isNullOrBlank() } VaultState.ViewState.Content( - loginItemsCount = cipherViewList.count { it.type == CipherType.LOGIN }, - cardItemsCount = cipherViewList.count { it.type == CipherType.CARD }, - identityItemsCount = cipherViewList.count { it.type == CipherType.IDENTITY }, - secureNoteItemsCount = cipherViewList.count { it.type == CipherType.SECURE_NOTE }, + loginItemsCount = filteredCipherViewList.count { it.type == CipherType.LOGIN }, + cardItemsCount = filteredCipherViewList.count { it.type == CipherType.CARD }, + identityItemsCount = filteredCipherViewList.count { it.type == CipherType.IDENTITY }, + secureNoteItemsCount = filteredCipherViewList + .count { it.type == CipherType.SECURE_NOTE }, favoriteItems = cipherViewList .filter { it.favorite } - .map { it.toVaultItem() }, + .mapNotNull { it.toVaultItemOrNull() }, folderItems = folderViewList.map { folderView -> VaultState.ViewState.FolderItem( id = folderView.id, name = folderView.name.asText(), - itemCount = cipherViewList.count { folderView.id == it.folderId }, + itemCount = cipherViewList + .count { !it.id.isNullOrBlank() && folderView.id == it.folderId }, ) }, noFolderItems = cipherViewList .filter { it.folderId.isNullOrBlank() } - .map { it.toVaultItem() }, + .mapNotNull { it.toVaultItemOrNull() }, // TODO need to populate trash item count in BIT-969 trashItemsCount = 0, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializerTest.kt index 58ccb2540a..f0e3da8988 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializerTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/serializer/LocalDateTimeSerializerTest.kt @@ -69,7 +69,7 @@ class LocalDateTimeSerializerTest { json.parseToJsonElement( """ { - "dataAsLocalDateTime": "2023-10-06T17:22:28.4400000Z" + "dataAsLocalDateTime": "2023-10-06T17:22:28.440Z" } """, ), diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequestUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequestUtil.kt new file mode 100644 index 0000000000..c2e61b197e --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/model/CipherJsonRequestUtil.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.model + +import CipherJsonRequest +import java.time.LocalDateTime + +/** + * Create a mock [CipherJsonRequest] with a given [number]. + */ +fun createMockCipherJsonRequest(number: Int): CipherJsonRequest = + CipherJsonRequest( + organizationId = "mockOrganizationId-$number", + folderId = "mockFolderId-$number", + name = "mockName-$number", + notes = "mockNotes-$number", + type = CipherTypeJson.LOGIN, + login = createMockLogin(number = number), + card = createMockCard(number = number), + fields = listOf(createMockField(number = number)), + identity = createMockIdentity(number = number), + isFavorite = false, + passwordHistory = listOf(createMockPasswordHistory(number = number)), + reprompt = CipherRepromptTypeJson.NONE, + secureNote = createMockSecureNote(), + lastKnownRevisionDate = LocalDateTime.parse("2023-10-27T12:00:00"), + ) 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 new file mode 100644 index 0000000000..819c981a9e --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/network/service/CiphersServiceTest.kt @@ -0,0 +1,123 @@ +package com.x8bit.bitwarden.data.vault.datasource.network.service + +import com.x8bit.bitwarden.data.platform.base.BaseServiceTest +import com.x8bit.bitwarden.data.vault.datasource.network.api.CiphersApi +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipherJsonRequest +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import retrofit2.create + +class CiphersServiceTest : BaseServiceTest() { + private val ciphersApi: CiphersApi = retrofit.create() + + private val ciphersService: CiphersService = CiphersServiceImpl( + ciphersApi = ciphersApi, + ) + + @Test + fun `createCipher should return the correct response`() = runTest { + server.enqueue(MockResponse().setBody(CREATE_CIPHER_SUCCESS_JSON)) + val result = ciphersService.createCipher( + body = createMockCipherJsonRequest(number = 1), + ) + assertEquals( + createMockCipher(number = 1), + result.getOrThrow(), + ) + } +} + +private const val CREATE_CIPHER_SUCCESS_JSON = """ +{ + "notes": "mockNotes-1", + "attachments": [ + { + "fileName": "mockFileName-1", + "size": 1, + "sizeName": "mockSizeName-1", + "id": "mockId-1", + "url": "mockUrl-1", + "key": "mockKey-1" + } + ], + "organizationUseTotp": false, + "reprompt": 0, + "edit": false, + "passwordHistory": [ + { + "password": "mockPassword-1", + "lastUsedDate": "2023-10-27T12:00:00.00Z" + } + ], + "revisionDate": "2023-10-27T12:00:00.00Z", + "type": 1, + "login": { + "uris": [ + { + "match": 1, + "uri": "mockUri-1" + } + ], + "totp": "mockTotp-1", + "password": "mockPassword-1", + "passwordRevisionDate": "2023-10-27T12:00:00.00Z", + "autofillOnPageLoad": false, + "uri": "mockUri-1", + "username": "mockUsername-1" + }, + "creationDate": "2023-10-27T12:00:00.00Z", + "secureNote": { + "type": 0 + }, + "folderId": "mockFolderId-1", + "organizationId": "mockOrganizationId-1", + "deletedDate": "2023-10-27T12:00:00.00Z", + "identity": { + "passportNumber": "mockPassportNumber-1", + "lastName": "mockLastName-1", + "address3": "mockAddress3-1", + "address2": "mockAddress2-1", + "city": "mockCity-1", + "country": "mockCountry-1", + "address1": "mockAddress1-1", + "postalCode": "mockPostalCode-1", + "title": "mockTitle-1", + "ssn": "mockSsn-1", + "firstName": "mockFirstName-1", + "phone": "mockPhone-1", + "middleName": "mockMiddleName-1", + "company": "mockCompany-1", + "licenseNumber": "mockLicenseNumber-1", + "state": "mockState-1", + "email": "mockEmail-1", + "username": "mockUsername-1" + }, + "collectionIds": [ + "mockCollectionId-1" + ], + "name": "mockName-1", + "id": "mockId-1" + "fields": [ + { + "linkedId": 100, + "name": "mockName-1", + "type": 1, + "value": "mockValue-1" + } + ], + "viewPassword": false, + "favorite": false, + "card": { + "number": "mockNumber-1", + "expMonth": "mockExpMonth-1", + "code": "mockCode-1", + "expYear": "mockExpirationYear-1", + "cardholderName": "mockCardholderName-1", + "brand": "mockBrand-1" + }, + "key": "mockKey-1" +} +""" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index 8434a40592..53461fdef8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -54,4 +54,34 @@ class VaultDataExtensionsTest { actual, ) } + + @Test + fun `toViewState should not transform ciphers with no ID into ViewState items`() { + val vaultData = VaultData( + cipherViewList = listOf(createMockCipherView(number = 1).copy(id = null)), + folderViewList = listOf(createMockFolderView(number = 1)), + ) + + val actual = vaultData.toViewState() + + assertEquals( + VaultState.ViewState.Content( + loginItemsCount = 0, + cardItemsCount = 0, + identityItemsCount = 0, + secureNoteItemsCount = 0, + favoriteItems = emptyList(), + folderItems = listOf( + VaultState.ViewState.FolderItem( + id = "mockId-1", + name = "mockName-1".asText(), + itemCount = 0, + ), + ), + noFolderItems = emptyList(), + trashItemsCount = 0, + ), + actual, + ) + } }