diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b638ec29f7..c141555945 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -162,6 +162,7 @@ koverReport { "com.x8bit.bitwarden.ui.platform.feature.splash.SplashScreenKt", // Databases "*.database.*Database", + "*.dao.*Dao", ) packages( // Dependency injection diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt new file mode 100644 index 0000000000..d8b0ab1098 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSource.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk + +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import kotlinx.coroutines.flow.Flow + +/** + * Primary access point for disk information related to vault data. + */ +interface VaultDiskSource { + + /** + * Retrieves all ciphers from the data source for a given [userId]. + */ + fun getCiphers(userId: String): Flow> + + /** + * Replaces all [vault] data for a given [userId] with the new `vault`. + */ + suspend fun replaceVaultData(userId: String, vault: SyncResponseJson) + + /** + * Deletes all stored vault data from the data source for a given [userId]. + */ + suspend fun deleteVaultData(userId: String) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt new file mode 100644 index 0000000000..4008c996c5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceImpl.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk + +import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Default implementation of [VaultDiskSource]. + */ +class VaultDiskSourceImpl( + private val ciphersDao: CiphersDao, + private val json: Json, +) : VaultDiskSource { + + override fun getCiphers( + userId: String, + ): Flow> = + ciphersDao + .getAllCiphers(userId = userId) + .map { entities -> + entities.map { entity -> + json.decodeFromString(entity.cipherJson) + } + } + + override suspend fun replaceVaultData(userId: String, vault: SyncResponseJson) { + ciphersDao.replaceAllCiphers( + userId = userId, + ciphers = vault.ciphers.orEmpty().map { cipher -> + CipherEntity( + id = cipher.id, + userId = userId, + cipherType = json.encodeToString(cipher.type), + cipherJson = json.encodeToString(cipher), + ) + }, + ) + } + + override suspend fun deleteVaultData(userId: String) { + ciphersDao.deleteAllCiphers(userId = userId) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt new file mode 100644 index 0000000000..70b663e5e7 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CiphersDao.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity +import kotlinx.coroutines.flow.Flow + +/** + * Provides methods for inserting, retrieving, and deleting ciphers from the database using the + * [CipherEntity]. + */ +@Dao +interface CiphersDao { + + /** + * Inserts multiple ciphers into the database. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCiphers(ciphers: List) + + /** + * Retrieves all ciphers from the database for a given [userId]. + */ + @Query("SELECT * FROM ciphers WHERE user_id IS :userId") + fun getAllCiphers( + userId: String, + ): Flow> + + /** + * Deletes all the stored ciphers associated with the given [userId]. + */ + @Query("DELETE FROM ciphers WHERE user_id = :userId") + suspend fun deleteAllCiphers(userId: String) + + /** + * Deletes all the stored ciphers associated with the given [userId] and then add all new + * [ciphers] to the database. + */ + @Transaction + suspend fun replaceAllCiphers(userId: String, ciphers: List) { + deleteAllCiphers(userId) + insertCiphers(ciphers) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/database/VaultDatabase.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/database/VaultDatabase.kt new file mode 100644 index 0000000000..5bdaf84bd2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/database/VaultDatabase.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity + +/** + * Room database for storing any persisted data from the vault sync. + */ +@Database( + entities = [ + CipherEntity::class, + ], + version = 1, +) +abstract class VaultDatabase : RoomDatabase() { + + /** + * Provides the DAO for accessing cipher data. + */ + abstract fun cipherDao(): CiphersDao +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/di/VaultDiskModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/di/VaultDiskModule.kt new file mode 100644 index 0000000000..4c5017e376 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/di/VaultDiskModule.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.di + +import android.app.Application +import androidx.room.Room +import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource +import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSourceImpl +import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao +import com.x8bit.bitwarden.data.vault.datasource.disk.database.VaultDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import javax.inject.Singleton + +/** + * Provides database dependencies in the vault package. + */ +@Module +@InstallIn(SingletonComponent::class) +class VaultDiskModule { + + @Provides + @Singleton + fun provideVaultDatabase(app: Application): VaultDatabase = + Room + .databaseBuilder( + context = app, + klass = VaultDatabase::class.java, + name = "vault_database", + ) + .build() + + @Provides + @Singleton + fun provideCipherDao(database: VaultDatabase): CiphersDao = database.cipherDao() + + @Provides + @Singleton + fun provideVaultDiskSource( + ciphersDao: CiphersDao, + json: Json, + ): VaultDiskSource = VaultDiskSourceImpl( + ciphersDao = ciphersDao, + json = json, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CipherEntity.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CipherEntity.kt new file mode 100644 index 0000000000..bf9933d198 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CipherEntity.kt @@ -0,0 +1,24 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing a cipher in the database. + */ +@Entity(tableName = "ciphers") +data class CipherEntity( + @PrimaryKey(autoGenerate = false) + @ColumnInfo(name = "id") + val id: String, + + @ColumnInfo(name = "user_id", index = true) + val userId: String, + + @ColumnInfo(name = "cipher_type") + val cipherType: String, + + @ColumnInfo(name = "cipher_json") + val cipherJson: String, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt b/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt new file mode 100644 index 0000000000..c99ea6a57b --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/util/TestHelpers.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.util + +import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Assertions.assertEquals + +/** + * Helper method for comparing JSON string and ignoring the formatting. + */ +fun assertJsonEquals( + expected: String, + actual: String, + json: Json = PlatformNetworkModule.providesJson(), +) { + assertEquals( + json.parseToJsonElement(expected), + json.parseToJsonElement(actual), + ) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt new file mode 100644 index 0000000000..0289e41671 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/VaultDiskSourceTest.kt @@ -0,0 +1,187 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk + +import app.cash.turbine.test +import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule +import com.x8bit.bitwarden.data.util.assertJsonEquals +import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FakeCiphersDao +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity +import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class VaultDiskSourceTest { + + private val json = PlatformNetworkModule.providesJson() + private lateinit var ciphersDao: FakeCiphersDao + + private lateinit var vaultDiskSource: VaultDiskSource + + @BeforeEach + fun setup() { + ciphersDao = FakeCiphersDao() + vaultDiskSource = VaultDiskSourceImpl( + ciphersDao = ciphersDao, + json = json, + ) + } + + @Test + fun `getCiphers should emit all dao updates`() = runTest { + val cipherEntities = listOf(CIPHER_ENTITY) + val ciphers = listOf(CIPHER_1) + + vaultDiskSource + .getCiphers(USER_ID) + .test { + assertEquals(emptyList(), awaitItem()) + ciphersDao.insertCiphers(cipherEntities) + assertEquals(ciphers, awaitItem()) + } + } + + @Test + fun `replaceVaultData should clear the dao and insert the encoded ciphers`() = runTest { + assertEquals(ciphersDao.storedCiphers, emptyList()) + + vaultDiskSource.replaceVaultData(USER_ID, VAULT_DATA) + + assertEquals(1, ciphersDao.storedCiphers.size) + val storedEntity = ciphersDao.storedCiphers.first() + // We cannot compare the JSON strings directly because of formatting differences + // So we split that off into its own assertion. + assertEquals(CIPHER_ENTITY.copy(cipherJson = ""), storedEntity.copy(cipherJson = "")) + assertJsonEquals(CIPHER_ENTITY.cipherJson, storedEntity.cipherJson) + } + + @Test + fun `deleteVaultData should remove all ciphers matching the user ID`() = runTest { + assertFalse(ciphersDao.deleteCiphersCalled) + vaultDiskSource.deleteVaultData(USER_ID) + assertTrue(ciphersDao.deleteCiphersCalled) + } +} + +private const val USER_ID: String = "test_user_id" + +private val CIPHER_1: SyncResponseJson.Cipher = createMockCipher(1) + +private val VAULT_DATA: SyncResponseJson = SyncResponseJson( + folders = null, + collections = null, + profile = mockk { + every { id } returns USER_ID + }, + ciphers = listOf(CIPHER_1), + policies = null, + domains = SyncResponseJson.Domains( + globalEquivalentDomains = null, + equivalentDomains = null, + ), + sends = null, +) + +private const val CIPHER_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.000Z" + } + ], + "revisionDate": "2023-10-27T12:00:00.000Z", + "type": 1, + "login": { + "uris": [ + { + "match": 1, + "uri": "mockUri-1" + } + ], + "totp": "mockTotp-1", + "password": "mockPassword-1", + "passwordRevisionDate": "2023-10-27T12:00:00.000Z", + "autofillOnPageLoad": false, + "uri": "mockUri-1", + "username": "mockUsername-1" + }, + "creationDate": "2023-10-27T12:00:00.000Z", + "secureNote": { + "type": 0 + }, + "folderId": "mockFolderId-1", + "organizationId": "mockOrganizationId-1", + "deletedDate": "2023-10-27T12:00:00.000Z", + "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" +} +""" + +private val CIPHER_ENTITY = CipherEntity( + id = "mockId-1", + userId = USER_ID, + cipherType = "1", + cipherJson = CIPHER_JSON, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt new file mode 100644 index 0000000000..222016c488 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCiphersDao.kt @@ -0,0 +1,42 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.dao + +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map + +class FakeCiphersDao : CiphersDao { + + val storedCiphers = mutableListOf() + + var deleteCiphersCalled: Boolean = false + + private val ciphersFlow = MutableSharedFlow>( + replay = 1, + extraBufferCapacity = Int.MAX_VALUE, + ) + + init { + ciphersFlow.tryEmit(emptyList()) + } + + override suspend fun deleteAllCiphers(userId: String) { + deleteCiphersCalled = true + storedCiphers.removeAll { it.userId == userId } + ciphersFlow.tryEmit(storedCiphers.toList()) + } + + override fun getAllCiphers(userId: String): Flow> = + ciphersFlow.map { ciphers -> ciphers.filter { it.userId == userId } } + + override suspend fun insertCiphers(ciphers: List) { + storedCiphers.addAll(ciphers) + ciphersFlow.tryEmit(ciphers.toList()) + } + + override suspend fun replaceAllCiphers(userId: String, ciphers: List) { + storedCiphers.removeAll { it.userId == userId } + storedCiphers.addAll(ciphers) + ciphersFlow.tryEmit(ciphers.toList()) + } +}