From 34e3fbcc04903472ad59af85331f6f3e735758b2 Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 15 Dec 2023 17:59:27 -0600 Subject: [PATCH] BIT-431: Add a table to the vault database for folders (#403) --- app/build.gradle.kts | 4 +- .../vault/datasource/disk/VaultDiskSource.kt | 5 ++ .../datasource/disk/VaultDiskSourceImpl.kt | 74 ++++++++++++++++--- .../convertor/ZonedDateTimeTypeConverter.kt | 31 ++++++++ .../vault/datasource/disk/dao/FoldersDao.kt | 59 +++++++++++++++ .../datasource/disk/database/VaultDatabase.kt | 11 +++ .../datasource/disk/di/VaultDiskModule.kt | 9 +++ .../datasource/disk/entity/FolderEntity.kt | 25 +++++++ .../datasource/disk/VaultDiskSourceTest.kt | 52 +++++++++++-- .../ZonedDateTimeTypeConverterTest.kt | 47 ++++++++++++ .../datasource/disk/dao/FakeFoldersDao.kt | 54 ++++++++++++++ 11 files changed, 351 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/convertor/ZonedDateTimeTypeConverter.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FoldersDao.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/FolderEntity.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/convertor/ZonedDateTimeTypeConverterTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeFoldersDao.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c141555945..bc8a67ecd5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -161,8 +161,8 @@ koverReport { // Empty Composables "com.x8bit.bitwarden.ui.platform.feature.splash.SplashScreenKt", // Databases - "*.database.*Database", - "*.dao.*Dao", + "*.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 index d8b0ab1098..c4dd5cd38b 100644 --- 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 @@ -13,6 +13,11 @@ interface VaultDiskSource { */ fun getCiphers(userId: String): Flow> + /** + * Retrieves all folders from the data source for a given [userId]. + */ + fun getFolders(userId: String): Flow> + /** * Replaces all [vault] data for a given [userId] with the new `vault`. */ 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 index 4008c996c5..ac6d6c2167 100644 --- 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 @@ -1,8 +1,13 @@ 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.dao.FoldersDao import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.serialization.encodeToString @@ -13,6 +18,7 @@ import kotlinx.serialization.json.Json */ class VaultDiskSourceImpl( private val ciphersDao: CiphersDao, + private val foldersDao: FoldersDao, private val json: Json, ) : VaultDiskSource { @@ -27,21 +33,67 @@ class VaultDiskSourceImpl( } } - override suspend fun replaceVaultData(userId: String, vault: SyncResponseJson) { - ciphersDao.replaceAllCiphers( - userId = userId, - ciphers = vault.ciphers.orEmpty().map { cipher -> - CipherEntity( - id = cipher.id, + override fun getFolders( + userId: String, + ): Flow> = + foldersDao + .getAllFolders(userId = userId) + .map { entities -> + entities.map { entity -> + SyncResponseJson.Folder( + id = entity.id, + name = entity.name, + revisionDate = entity.revisionDate, + ) + } + } + + override suspend fun replaceVaultData( + userId: String, + vault: SyncResponseJson, + ) { + coroutineScope { + val deferredCiphers = async { + ciphersDao.replaceAllCiphers( userId = userId, - cipherType = json.encodeToString(cipher.type), - cipherJson = json.encodeToString(cipher), + ciphers = vault.ciphers.orEmpty().map { cipher -> + CipherEntity( + id = cipher.id, + userId = userId, + cipherType = json.encodeToString(cipher.type), + cipherJson = json.encodeToString(cipher), + ) + }, ) - }, - ) + } + val deferredFolders = async { + foldersDao.replaceAllFolders( + userId = userId, + folders = vault.folders.orEmpty().map { folder -> + FolderEntity( + userId = userId, + id = folder.id, + name = folder.name, + revisionDate = folder.revisionDate, + ) + }, + ) + } + awaitAll( + deferredCiphers, + deferredFolders, + ) + } } override suspend fun deleteVaultData(userId: String) { - ciphersDao.deleteAllCiphers(userId = userId) + coroutineScope { + val deferredCiphers = async { ciphersDao.deleteAllCiphers(userId = userId) } + val deferredFolders = async { foldersDao.deleteAllFolders(userId = userId) } + awaitAll( + deferredCiphers, + deferredFolders, + ) + } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/convertor/ZonedDateTimeTypeConverter.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/convertor/ZonedDateTimeTypeConverter.kt new file mode 100644 index 0000000000..0c1d5b7db9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/convertor/ZonedDateTimeTypeConverter.kt @@ -0,0 +1,31 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.convertor + +import androidx.room.ProvidedTypeConverter +import androidx.room.TypeConverter +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime + +/** + * A [TypeConverter] to convert a [ZonedDateTime] to and from a [Long]. + */ +@ProvidedTypeConverter +object ZonedDateTimeTypeConverter { + /** + * A [TypeConverter] to convert a [Long] to a [ZonedDateTime]. + */ + @TypeConverter + fun fromTimestamp( + value: Long?, + ): ZonedDateTime? = value?.let { + ZonedDateTime.ofInstant(Instant.ofEpochSecond(it), ZoneOffset.UTC) + } + + /** + * A [TypeConverter] to convert a [ZonedDateTime] to a [Long]. + */ + @TypeConverter + fun toTimestamp( + localDateTime: ZonedDateTime?, + ): Long? = localDateTime?.toEpochSecond() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FoldersDao.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FoldersDao.kt new file mode 100644 index 0000000000..fe7ed96d69 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FoldersDao.kt @@ -0,0 +1,59 @@ +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.FolderEntity +import kotlinx.coroutines.flow.Flow + +/** + * Provides methods for inserting, retrieving, and deleting folders from the database using the + * [FolderEntity]. + */ +@Dao +interface FoldersDao { + + /** + * Inserts multiple folders into the database. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertFolders(folders: List) + + /** + * Inserts a folder into the database. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertFolder(folder: FolderEntity) + + /** + * Retrieves all folders from the database for a given [userId]. + */ + @Query("SELECT * FROM folders WHERE user_id = :userId") + fun getAllFolders( + userId: String, + ): Flow> + + /** + * Deletes all the stored folders associated with the given [userId]. + */ + @Query("DELETE FROM folders WHERE user_id = :userId") + suspend fun deleteAllFolders(userId: String) + + /** + * Deletes the stored folder associated with the given [userId] that matches the [folderId]. + */ + @Query("DELETE FROM folders WHERE user_id = :userId AND id = :folderId") + suspend fun deleteFolder(userId: String, folderId: String) + + /** + * Deletes all the stored [folders] associated with the given [userId] and then add all new + * `folders` to the database. + */ + @Transaction + suspend fun replaceAllFolders(userId: String, folders: List) { + deleteAllFolders(userId) + insertFolders(folders) + } +} 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 index 5bdaf84bd2..96b294f1d3 100644 --- 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 @@ -2,8 +2,12 @@ package com.x8bit.bitwarden.data.vault.datasource.disk.database import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao +import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FoldersDao import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity /** * Room database for storing any persisted data from the vault sync. @@ -11,13 +15,20 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity @Database( entities = [ CipherEntity::class, + FolderEntity::class, ], version = 1, ) +@TypeConverters(ZonedDateTimeTypeConverter::class) abstract class VaultDatabase : RoomDatabase() { /** * Provides the DAO for accessing cipher data. */ abstract fun cipherDao(): CiphersDao + + /** + * Provides the DAO for accessing folder data. + */ + abstract fun folderDao(): FoldersDao } 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 index 4c5017e376..3098cd5f6d 100644 --- 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 @@ -4,7 +4,9 @@ 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.convertor.ZonedDateTimeTypeConverter import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao +import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FoldersDao import com.x8bit.bitwarden.data.vault.datasource.disk.database.VaultDatabase import dagger.Module import dagger.Provides @@ -29,19 +31,26 @@ class VaultDiskModule { klass = VaultDatabase::class.java, name = "vault_database", ) + .addTypeConverter(ZonedDateTimeTypeConverter) .build() @Provides @Singleton fun provideCipherDao(database: VaultDatabase): CiphersDao = database.cipherDao() + @Provides + @Singleton + fun provideFolderDao(database: VaultDatabase): FoldersDao = database.folderDao() + @Provides @Singleton fun provideVaultDiskSource( ciphersDao: CiphersDao, + foldersDao: FoldersDao, json: Json, ): VaultDiskSource = VaultDiskSourceImpl( ciphersDao = ciphersDao, + foldersDao = foldersDao, json = json, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/FolderEntity.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/FolderEntity.kt new file mode 100644 index 0000000000..4fe5979322 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/FolderEntity.kt @@ -0,0 +1,25 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.ZonedDateTime + +/** + * Entity representing a folder in the database. + */ +@Entity(tableName = "folders") +data class FolderEntity( + @PrimaryKey(autoGenerate = false) + @ColumnInfo(name = "id") + val id: String, + + @ColumnInfo(name = "user_id", index = true) + val userId: String, + + @ColumnInfo(name = "name") + val name: String?, + + @ColumnInfo(name = "revision_date") + val revisionDate: ZonedDateTime, +) 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 index 0289e41671..cc95b782be 100644 --- 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 @@ -4,9 +4,12 @@ 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.dao.FakeFoldersDao import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CipherEntity +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockCipher +import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockFolder import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -15,25 +18,29 @@ 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 +import java.time.ZonedDateTime class VaultDiskSourceTest { private val json = PlatformNetworkModule.providesJson() private lateinit var ciphersDao: FakeCiphersDao + private lateinit var foldersDao: FakeFoldersDao private lateinit var vaultDiskSource: VaultDiskSource @BeforeEach fun setup() { ciphersDao = FakeCiphersDao() + foldersDao = FakeFoldersDao() vaultDiskSource = VaultDiskSourceImpl( ciphersDao = ciphersDao, + foldersDao = foldersDao, json = json, ) } @Test - fun `getCiphers should emit all dao updates`() = runTest { + fun `getCiphers should emit all CiphersDao updates`() = runTest { val cipherEntities = listOf(CIPHER_ENTITY) val ciphers = listOf(CIPHER_1) @@ -47,33 +54,57 @@ class VaultDiskSourceTest { } @Test - fun `replaceVaultData should clear the dao and insert the encoded ciphers`() = runTest { + fun `getFolders should emit all FoldersDao updates`() = runTest { + val folderEntities = listOf(FOLDER_ENTITY) + val folders = listOf(FOLDER_1) + + vaultDiskSource + .getFolders(USER_ID) + .test { + assertEquals(emptyList(), awaitItem()) + foldersDao.insertFolders(folderEntities) + assertEquals(folders, awaitItem()) + } + } + + @Test + fun `replaceVaultData should clear the daos and insert the new vault data`() = runTest { assertEquals(ciphersDao.storedCiphers, emptyList()) + assertEquals(foldersDao.storedFolders, emptyList()) vaultDiskSource.replaceVaultData(USER_ID, VAULT_DATA) assertEquals(1, ciphersDao.storedCiphers.size) - val storedEntity = ciphersDao.storedCiphers.first() + assertEquals(1, foldersDao.storedFolders.size) + + // Verify the ciphers dao is updated + val storedCipherEntity = 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) + assertEquals(CIPHER_ENTITY.copy(cipherJson = ""), storedCipherEntity.copy(cipherJson = "")) + assertJsonEquals(CIPHER_ENTITY.cipherJson, storedCipherEntity.cipherJson) + + // Verify the folders dao is updated + assertEquals(listOf(FOLDER_ENTITY), foldersDao.storedFolders) } @Test - fun `deleteVaultData should remove all ciphers matching the user ID`() = runTest { + fun `deleteVaultData should remove all vault data matching the user ID`() = runTest { assertFalse(ciphersDao.deleteCiphersCalled) + assertFalse(foldersDao.deleteFoldersCalled) vaultDiskSource.deleteVaultData(USER_ID) assertTrue(ciphersDao.deleteCiphersCalled) + assertTrue(foldersDao.deleteFoldersCalled) } } private const val USER_ID: String = "test_user_id" private val CIPHER_1: SyncResponseJson.Cipher = createMockCipher(1) +private val FOLDER_1: SyncResponseJson.Folder = createMockFolder(2) private val VAULT_DATA: SyncResponseJson = SyncResponseJson( - folders = null, + folders = listOf(FOLDER_1), collections = null, profile = mockk { every { id } returns USER_ID @@ -185,3 +216,10 @@ private val CIPHER_ENTITY = CipherEntity( cipherType = "1", cipherJson = CIPHER_JSON, ) + +private val FOLDER_ENTITY = FolderEntity( + id = "mockId-2", + userId = USER_ID, + name = "mockName-2", + revisionDate = ZonedDateTime.parse("2023-10-27T12:00Z"), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/convertor/ZonedDateTimeTypeConverterTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/convertor/ZonedDateTimeTypeConverterTest.kt new file mode 100644 index 0000000000..8d937709a5 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/convertor/ZonedDateTimeTypeConverterTest.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.convertor + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.ZonedDateTime + +class ZonedDateTimeTypeConverterTest { + + @Test + fun `fromTimestamp should return null when value is null`() { + val value: Long? = null + + val result = ZonedDateTimeTypeConverter.fromTimestamp(value) + + assertNull(result) + } + + @Test + fun `fromTimestamp should return correct ZonedDateTime when value is not null`() { + val expected = ZonedDateTime.parse("2023-12-15T20:38:06Z") + val value = expected.toEpochSecond() + + val result = ZonedDateTimeTypeConverter.fromTimestamp(value) + + assertEquals(expected, result) + } + + @Test + fun `toTimestamp should return null when value is null`() { + val value: ZonedDateTime? = null + + val result = ZonedDateTimeTypeConverter.toTimestamp(value) + + assertNull(result) + } + + @Test + fun `toTimestamp should return correct Long when value is not null`() { + val value = ZonedDateTime.parse("2023-12-15T20:38:06Z") + val expected = value.toEpochSecond() + + val result = ZonedDateTimeTypeConverter.toTimestamp(value) + + assertEquals(expected, result) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeFoldersDao.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeFoldersDao.kt new file mode 100644 index 0000000000..d82cf38463 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeFoldersDao.kt @@ -0,0 +1,54 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.dao + +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map + +class FakeFoldersDao : FoldersDao { + + val storedFolders = mutableListOf() + + var deleteFolderCalled: Boolean = false + var deleteFoldersCalled: Boolean = false + + private val foldersFlow = MutableSharedFlow>( + replay = 1, + extraBufferCapacity = Int.MAX_VALUE, + ) + + init { + foldersFlow.tryEmit(emptyList()) + } + + override suspend fun deleteAllFolders(userId: String) { + deleteFoldersCalled = true + storedFolders.removeAll { it.userId == userId } + foldersFlow.tryEmit(storedFolders.toList()) + } + + override suspend fun deleteFolder(userId: String, folderId: String) { + deleteFolderCalled = true + storedFolders.removeAll { it.userId == userId && it.id == folderId } + foldersFlow.tryEmit(storedFolders.toList()) + } + + override fun getAllFolders(userId: String): Flow> = + foldersFlow.map { ciphers -> ciphers.filter { it.userId == userId } } + + override suspend fun insertFolders(folders: List) { + storedFolders.addAll(folders) + foldersFlow.tryEmit(storedFolders.toList()) + } + + override suspend fun insertFolder(folder: FolderEntity) { + storedFolders.add(folder) + foldersFlow.tryEmit(storedFolders.toList()) + } + + override suspend fun replaceAllFolders(userId: String, folders: List) { + storedFolders.removeAll { it.userId == userId } + storedFolders.addAll(folders) + foldersFlow.tryEmit(storedFolders.toList()) + } +}