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 c4dd5cd38b..5cc5ee2344 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 collections from the data source for a given [userId]. + */ + fun getCollections(userId: String): Flow> + /** * Retrieves all folders from the data source for a given [userId]. */ 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 ac6d6c2167..adb1bc54e3 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,10 @@ 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.CollectionsDao 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.CollectionEntity import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson import kotlinx.coroutines.async @@ -18,6 +20,7 @@ import kotlinx.serialization.json.Json */ class VaultDiskSourceImpl( private val ciphersDao: CiphersDao, + private val collectionsDao: CollectionsDao, private val foldersDao: FoldersDao, private val json: Json, ) : VaultDiskSource { @@ -33,6 +36,24 @@ class VaultDiskSourceImpl( } } + override fun getCollections( + userId: String, + ): Flow> = + collectionsDao + .getAllCollections(userId = userId) + .map { entities -> + entities.map { entity -> + SyncResponseJson.Collection( + id = entity.id, + name = entity.name, + organizationId = entity.organizationId, + shouldHidePasswords = entity.shouldHidePasswords, + externalId = entity.externalId, + isReadOnly = entity.isReadOnly, + ) + } + } + override fun getFolders( userId: String, ): Flow> = @@ -66,6 +87,22 @@ class VaultDiskSourceImpl( }, ) } + val deferredCollections = async { + collectionsDao.replaceAllCollections( + userId = userId, + collections = vault.collections.orEmpty().map { collection -> + CollectionEntity( + userId = userId, + id = collection.id, + name = collection.name, + organizationId = collection.organizationId, + shouldHidePasswords = collection.shouldHidePasswords, + externalId = collection.externalId, + isReadOnly = collection.isReadOnly, + ) + }, + ) + } val deferredFolders = async { foldersDao.replaceAllFolders( userId = userId, @@ -81,6 +118,7 @@ class VaultDiskSourceImpl( } awaitAll( deferredCiphers, + deferredCollections, deferredFolders, ) } @@ -89,9 +127,11 @@ class VaultDiskSourceImpl( override suspend fun deleteVaultData(userId: String) { coroutineScope { val deferredCiphers = async { ciphersDao.deleteAllCiphers(userId = userId) } + val deferredCollections = async { collectionsDao.deleteAllCollections(userId = userId) } val deferredFolders = async { foldersDao.deleteAllFolders(userId = userId) } awaitAll( deferredCiphers, + deferredCollections, deferredFolders, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CollectionsDao.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CollectionsDao.kt new file mode 100644 index 0000000000..6a0f4f569d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/CollectionsDao.kt @@ -0,0 +1,58 @@ +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.CollectionEntity +import kotlinx.coroutines.flow.Flow + +/** + * Provides methods for inserting, retrieving, and deleting collections from the database using the + * [CollectionEntity]. + */ +@Dao +interface CollectionsDao { + + /** + * Inserts multiple collections into the database. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCollections(collections: List) + + /** + * Inserts a collection into the database. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCollection(collection: CollectionEntity) + + /** + * Retrieves all collections from the database for a given [userId]. + */ + @Query("SELECT * FROM collections WHERE user_id = :userId") + fun getAllCollections(userId: String): Flow> + + /** + * Deletes all the stored collections associated with the given [userId]. + */ + @Query("DELETE FROM collections WHERE user_id = :userId") + suspend fun deleteAllCollections(userId: String) + + /** + * Deletes the stored collection associated with the given [userId] that matches the + * [collectionId]. + */ + @Query("DELETE FROM collections WHERE user_id = :userId AND id = :collectionId") + suspend fun deleteCollection(userId: String, collectionId: String) + + /** + * Deletes all the stored [collections] associated with the given [userId] and then add all new + * `collections` to the database. + */ + @Transaction + suspend fun replaceAllCollections(userId: String, collections: List) { + deleteAllCollections(userId) + insertCollections(collections) + } +} 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 96b294f1d3..72130d7ae3 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 @@ -5,8 +5,10 @@ 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.CollectionsDao 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.CollectionEntity import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity /** @@ -15,6 +17,7 @@ import com.x8bit.bitwarden.data.vault.datasource.disk.entity.FolderEntity @Database( entities = [ CipherEntity::class, + CollectionEntity::class, FolderEntity::class, ], version = 1, @@ -27,6 +30,11 @@ abstract class VaultDatabase : RoomDatabase() { */ abstract fun cipherDao(): CiphersDao + /** + * Provides the DAO for accessing collection data. + */ + abstract fun collectionDao(): CollectionsDao + /** * Provides the DAO for accessing folder data. */ 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 3098cd5f6d..10ddc9d069 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 @@ -6,6 +6,7 @@ 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.CollectionsDao import com.x8bit.bitwarden.data.vault.datasource.disk.dao.FoldersDao import com.x8bit.bitwarden.data.vault.datasource.disk.database.VaultDatabase import dagger.Module @@ -38,6 +39,10 @@ class VaultDiskModule { @Singleton fun provideCipherDao(database: VaultDatabase): CiphersDao = database.cipherDao() + @Provides + @Singleton + fun provideCollectionDao(database: VaultDatabase): CollectionsDao = database.collectionDao() + @Provides @Singleton fun provideFolderDao(database: VaultDatabase): FoldersDao = database.folderDao() @@ -46,10 +51,12 @@ class VaultDiskModule { @Singleton fun provideVaultDiskSource( ciphersDao: CiphersDao, + collectionsDao: CollectionsDao, foldersDao: FoldersDao, json: Json, ): VaultDiskSource = VaultDiskSourceImpl( ciphersDao = ciphersDao, + collectionsDao = collectionsDao, foldersDao = foldersDao, json = json, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CollectionEntity.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CollectionEntity.kt new file mode 100644 index 0000000000..f734099c44 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/datasource/disk/entity/CollectionEntity.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Entity representing a collection in the database. + */ +@Entity(tableName = "collections") +data class CollectionEntity( + @PrimaryKey(autoGenerate = false) + @ColumnInfo(name = "id") + val id: String, + + @ColumnInfo(name = "user_id", index = true) + val userId: String, + + @ColumnInfo(name = "organization_id") + val organizationId: String, + + @ColumnInfo(name = "should_hide_passwords") + val shouldHidePasswords: Boolean, + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "external_id") + val externalId: String?, + + @ColumnInfo(name = "read_only") + val isReadOnly: Boolean, +) 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 cc95b782be..f82b4984ce 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,11 +4,14 @@ 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.FakeCollectionsDao 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.CollectionEntity 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.createMockCollection import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockFolder import io.mockk.every import io.mockk.mockk @@ -24,6 +27,7 @@ class VaultDiskSourceTest { private val json = PlatformNetworkModule.providesJson() private lateinit var ciphersDao: FakeCiphersDao + private lateinit var collectionsDao: FakeCollectionsDao private lateinit var foldersDao: FakeFoldersDao private lateinit var vaultDiskSource: VaultDiskSource @@ -31,9 +35,11 @@ class VaultDiskSourceTest { @BeforeEach fun setup() { ciphersDao = FakeCiphersDao() + collectionsDao = FakeCollectionsDao() foldersDao = FakeFoldersDao() vaultDiskSource = VaultDiskSourceImpl( ciphersDao = ciphersDao, + collectionsDao = collectionsDao, foldersDao = foldersDao, json = json, ) @@ -53,6 +59,20 @@ class VaultDiskSourceTest { } } + @Test + fun `getCollections should emit all CollectionsDao updates`() = runTest { + val collectionEntities = listOf(COLLECTION_ENTITY) + val collection = listOf(COLLECTION_1) + + vaultDiskSource + .getCollections(USER_ID) + .test { + assertEquals(emptyList(), awaitItem()) + collectionsDao.insertCollections(collectionEntities) + assertEquals(collection, awaitItem()) + } + } + @Test fun `getFolders should emit all FoldersDao updates`() = runTest { val folderEntities = listOf(FOLDER_ENTITY) @@ -70,6 +90,7 @@ class VaultDiskSourceTest { @Test fun `replaceVaultData should clear the daos and insert the new vault data`() = runTest { assertEquals(ciphersDao.storedCiphers, emptyList()) + assertEquals(collectionsDao.storedCollections, emptyList()) assertEquals(foldersDao.storedFolders, emptyList()) vaultDiskSource.replaceVaultData(USER_ID, VAULT_DATA) @@ -84,6 +105,9 @@ class VaultDiskSourceTest { assertEquals(CIPHER_ENTITY.copy(cipherJson = ""), storedCipherEntity.copy(cipherJson = "")) assertJsonEquals(CIPHER_ENTITY.cipherJson, storedCipherEntity.cipherJson) + // Verify the collections dao is updated + assertEquals(listOf(COLLECTION_ENTITY), collectionsDao.storedCollections) + // Verify the folders dao is updated assertEquals(listOf(FOLDER_ENTITY), foldersDao.storedFolders) } @@ -91,9 +115,11 @@ class VaultDiskSourceTest { @Test fun `deleteVaultData should remove all vault data matching the user ID`() = runTest { assertFalse(ciphersDao.deleteCiphersCalled) + assertFalse(collectionsDao.deleteCollectionsCalled) assertFalse(foldersDao.deleteFoldersCalled) vaultDiskSource.deleteVaultData(USER_ID) assertTrue(ciphersDao.deleteCiphersCalled) + assertTrue(collectionsDao.deleteCollectionsCalled) assertTrue(foldersDao.deleteFoldersCalled) } } @@ -101,11 +127,12 @@ class VaultDiskSourceTest { private const val USER_ID: String = "test_user_id" private val CIPHER_1: SyncResponseJson.Cipher = createMockCipher(1) +private val COLLECTION_1: SyncResponseJson.Collection = createMockCollection(3) private val FOLDER_1: SyncResponseJson.Folder = createMockFolder(2) private val VAULT_DATA: SyncResponseJson = SyncResponseJson( folders = listOf(FOLDER_1), - collections = null, + collections = listOf(COLLECTION_1), profile = mockk { every { id } returns USER_ID }, @@ -217,6 +244,16 @@ private val CIPHER_ENTITY = CipherEntity( cipherJson = CIPHER_JSON, ) +private val COLLECTION_ENTITY = CollectionEntity( + id = "mockId-3", + userId = USER_ID, + organizationId = "mockOrganizationId-3", + shouldHidePasswords = false, + name = "mockName-3", + externalId = "mockExternalId-3", + isReadOnly = false, +) + private val FOLDER_ENTITY = FolderEntity( id = "mockId-2", userId = USER_ID, diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCollectionsDao.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCollectionsDao.kt new file mode 100644 index 0000000000..68ea5788fd --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/disk/dao/FakeCollectionsDao.kt @@ -0,0 +1,57 @@ +package com.x8bit.bitwarden.data.vault.datasource.disk.dao + +import com.x8bit.bitwarden.data.vault.datasource.disk.entity.CollectionEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map + +class FakeCollectionsDao : CollectionsDao { + + val storedCollections = mutableListOf() + + var deleteCollectionCalled: Boolean = false + var deleteCollectionsCalled: Boolean = false + + private val collectionsFlow = MutableSharedFlow>( + replay = 1, + extraBufferCapacity = Int.MAX_VALUE, + ) + + init { + collectionsFlow.tryEmit(emptyList()) + } + + override suspend fun deleteAllCollections(userId: String) { + deleteCollectionsCalled = true + storedCollections.removeAll { it.userId == userId } + collectionsFlow.tryEmit(storedCollections.toList()) + } + + override suspend fun deleteCollection(userId: String, collectionId: String) { + deleteCollectionCalled = true + storedCollections.removeAll { it.userId == userId && it.id == collectionId } + collectionsFlow.tryEmit(storedCollections.toList()) + } + + override fun getAllCollections(userId: String): Flow> = + collectionsFlow.map { ciphers -> ciphers.filter { it.userId == userId } } + + override suspend fun insertCollections(collections: List) { + storedCollections.addAll(collections) + collectionsFlow.tryEmit(storedCollections.toList()) + } + + override suspend fun insertCollection(collection: CollectionEntity) { + storedCollections.add(collection) + collectionsFlow.tryEmit(storedCollections.toList()) + } + + override suspend fun replaceAllCollections( + userId: String, + collections: List, + ) { + storedCollections.removeAll { it.userId == userId } + storedCollections.addAll(collections) + collectionsFlow.tryEmit(storedCollections.toList()) + } +}