mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BIT-598: Create initial vault database (#399)
This commit is contained in:
parent
7fa42e5fb8
commit
191602a867
10 changed files with 462 additions and 0 deletions
|
@ -162,6 +162,7 @@ koverReport {
|
||||||
"com.x8bit.bitwarden.ui.platform.feature.splash.SplashScreenKt",
|
"com.x8bit.bitwarden.ui.platform.feature.splash.SplashScreenKt",
|
||||||
// Databases
|
// Databases
|
||||||
"*.database.*Database",
|
"*.database.*Database",
|
||||||
|
"*.dao.*Dao",
|
||||||
)
|
)
|
||||||
packages(
|
packages(
|
||||||
// Dependency injection
|
// Dependency injection
|
||||||
|
|
|
@ -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<List<SyncResponseJson.Cipher>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
}
|
|
@ -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<List<SyncResponseJson.Cipher>> =
|
||||||
|
ciphersDao
|
||||||
|
.getAllCiphers(userId = userId)
|
||||||
|
.map { entities ->
|
||||||
|
entities.map { entity ->
|
||||||
|
json.decodeFromString<SyncResponseJson.Cipher>(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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<CipherEntity>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all ciphers from the database for a given [userId].
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM ciphers WHERE user_id IS :userId")
|
||||||
|
fun getAllCiphers(
|
||||||
|
userId: String,
|
||||||
|
): Flow<List<CipherEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<CipherEntity>) {
|
||||||
|
deleteAllCiphers(userId)
|
||||||
|
insertCiphers(ciphers)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
|
@ -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),
|
||||||
|
)
|
||||||
|
}
|
|
@ -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<SyncResponseJson.Cipher>(), awaitItem())
|
||||||
|
ciphersDao.insertCiphers(cipherEntities)
|
||||||
|
assertEquals(ciphers, awaitItem())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `replaceVaultData should clear the dao and insert the encoded ciphers`() = runTest {
|
||||||
|
assertEquals(ciphersDao.storedCiphers, emptyList<CipherEntity>())
|
||||||
|
|
||||||
|
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<SyncResponseJson.Profile> {
|
||||||
|
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,
|
||||||
|
)
|
|
@ -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<CipherEntity>()
|
||||||
|
|
||||||
|
var deleteCiphersCalled: Boolean = false
|
||||||
|
|
||||||
|
private val ciphersFlow = MutableSharedFlow<List<CipherEntity>>(
|
||||||
|
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<List<CipherEntity>> =
|
||||||
|
ciphersFlow.map { ciphers -> ciphers.filter { it.userId == userId } }
|
||||||
|
|
||||||
|
override suspend fun insertCiphers(ciphers: List<CipherEntity>) {
|
||||||
|
storedCiphers.addAll(ciphers)
|
||||||
|
ciphersFlow.tryEmit(ciphers.toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun replaceAllCiphers(userId: String, ciphers: List<CipherEntity>) {
|
||||||
|
storedCiphers.removeAll { it.userId == userId }
|
||||||
|
storedCiphers.addAll(ciphers)
|
||||||
|
ciphersFlow.tryEmit(ciphers.toList())
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue