BIT-431: Add a table to the vault database for folders (#403)

This commit is contained in:
David Perez 2023-12-15 17:59:27 -06:00 committed by Álison Fernandes
parent 34101245dd
commit 34e3fbcc04
11 changed files with 351 additions and 20 deletions

View file

@ -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

View file

@ -13,6 +13,11 @@ interface VaultDiskSource {
*/
fun getCiphers(userId: String): Flow<List<SyncResponseJson.Cipher>>
/**
* Retrieves all folders from the data source for a given [userId].
*/
fun getFolders(userId: String): Flow<List<SyncResponseJson.Folder>>
/**
* Replaces all [vault] data for a given [userId] with the new `vault`.
*/

View file

@ -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<List<SyncResponseJson.Folder>> =
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,
)
}
}
}

View file

@ -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()
}

View file

@ -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<FolderEntity>)
/**
* 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<List<FolderEntity>>
/**
* 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<FolderEntity>) {
deleteAllFolders(userId)
insertFolders(folders)
}
}

View file

@ -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
}

View file

@ -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,
)
}

View file

@ -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,
)

View file

@ -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<SyncResponseJson.Folder>(), 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<CipherEntity>())
assertEquals(foldersDao.storedFolders, emptyList<FolderEntity>())
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<SyncResponseJson.Profile> {
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"),
)

View file

@ -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)
}
}

View file

@ -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<FolderEntity>()
var deleteFolderCalled: Boolean = false
var deleteFoldersCalled: Boolean = false
private val foldersFlow = MutableSharedFlow<List<FolderEntity>>(
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<List<FolderEntity>> =
foldersFlow.map { ciphers -> ciphers.filter { it.userId == userId } }
override suspend fun insertFolders(folders: List<FolderEntity>) {
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<FolderEntity>) {
storedFolders.removeAll { it.userId == userId }
storedFolders.addAll(folders)
foldersFlow.tryEmit(storedFolders.toList())
}
}