diff --git a/app/schemas/com.x8bit.bitwarden.data.platform.datasource.disk.database.PlatformDatabase/1.json b/app/schemas/com.x8bit.bitwarden.data.platform.datasource.disk.database.PlatformDatabase/1.json new file mode 100644 index 000000000..f1d349523 --- /dev/null +++ b/app/schemas/com.x8bit.bitwarden.data.platform.datasource.disk.database.PlatformDatabase/1.json @@ -0,0 +1,68 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "f40d7a933b2f353d8d5b5ca619f28e24", + "entities": [ + { + "tableName": "organization_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `organization_event_type` TEXT NOT NULL, `cipher_id` TEXT, `date` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "organizationEventType", + "columnName": "organization_event_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cipherId", + "columnName": "cipher_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_organization_events_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_organization_events_user_id` ON `${TABLE_NAME}` (`user_id`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f40d7a933b2f353d8d5b5ca619f28e24')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/EventDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/EventDiskSource.kt new file mode 100644 index 000000000..71ba16cbf --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/EventDiskSource.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent + +/** + * Primary access point for disk information related to event data. + */ +interface EventDiskSource { + /** + * Deletes all organization events associated with the given [userId]. + */ + suspend fun deleteOrganizationEvents(userId: String) + + /** + * Adds a new organization event associated with the given [userId]. + */ + suspend fun addOrganizationEvent(userId: String, event: OrganizationEvent) + + /** + * Retrieves all organization events associated with the given [userId]. + */ + suspend fun getOrganizationEvents(userId: String): List +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/EventDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/EventDiskSourceImpl.kt new file mode 100644 index 000000000..724caa03c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/EventDiskSourceImpl.kt @@ -0,0 +1,54 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao +import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity +import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * The default implementation of [EventDiskSource]. + */ +class EventDiskSourceImpl( + private val organizationEventDao: OrganizationEventDao, + private val dispatcherManager: DispatcherManager, + private val json: Json, +) : EventDiskSource { + + override suspend fun deleteOrganizationEvents(userId: String) { + organizationEventDao.deleteOrganizationEvents(userId = userId) + } + + override suspend fun addOrganizationEvent(userId: String, event: OrganizationEvent) { + organizationEventDao.insertOrganizationEvent( + event = OrganizationEventEntity( + userId = userId, + organizationEventType = withContext(context = dispatcherManager.default) { + json.encodeToString(value = event.type) + }, + cipherId = event.cipherId, + date = event.date, + ), + ) + } + + override suspend fun getOrganizationEvents( + userId: String, + ): List = + organizationEventDao + .getOrganizationEvents(userId = userId) + .map { + OrganizationEvent( + type = withContext(context = dispatcherManager.default) { + json.decodeFromString( + string = it.organizationEventType, + ) + }, + cipherId = it.cipherId, + date = it.date, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/dao/OrganizationEventDao.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/dao/OrganizationEventDao.kt new file mode 100644 index 000000000..67524a24b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/dao/OrganizationEventDao.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity + +/** + * Provides methods for inserting, retrieving, and deleting events from the database using the + * [OrganizationEventEntity]. + */ +@Dao +interface OrganizationEventDao { + /** + * Deletes all the stored events associated with the given [userId]. This will return the + * number of rows deleted by this query. + */ + @Query("DELETE FROM organization_events WHERE user_id = :userId") + suspend fun deleteOrganizationEvents(userId: String): Int + + /** + * Retrieves all events from the database for a given [userId]. + */ + @Query("SELECT * FROM organization_events WHERE user_id = :userId") + suspend fun getOrganizationEvents(userId: String): List + + /** + * Inserts an event into the database. + */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrganizationEvent(event: OrganizationEventEntity) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/database/PlatformDatabase.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/database/PlatformDatabase.kt new file mode 100644 index 000000000..712bb578f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/database/PlatformDatabase.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao +import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity +import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter + +/** + * Room database for storing any persisted data for platform data. + */ +@Database( + entities = [ + OrganizationEventEntity::class, + ], + version = 1, + exportSchema = true, +) +@TypeConverters(ZonedDateTimeTypeConverter::class) +abstract class PlatformDatabase : RoomDatabase() { + /** + * Provides the DAO for accessing organization event data. + */ + abstract fun organizationEventDao(): OrganizationEventDao +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt index 2eafefc93..22c8cd176 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt @@ -3,21 +3,28 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.di import android.app.Application import android.content.Context import android.content.SharedPreferences +import androidx.room.Room import com.x8bit.bitwarden.data.platform.datasource.di.EncryptedPreferences import com.x8bit.bitwarden.data.platform.datasource.di.UnencryptedPreferences import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl +import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSourceImpl +import com.x8bit.bitwarden.data.platform.datasource.disk.dao.OrganizationEventDao +import com.x8bit.bitwarden.data.platform.datasource.disk.database.PlatformDatabase import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigrator import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacyAppCenterMigratorImpl import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorage import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageImpl import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigratorImpl +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.vault.datasource.disk.convertor.ZonedDateTimeTypeConverter import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -44,6 +51,38 @@ object PlatformDiskModule { json = json, ) + @Provides + @Singleton + fun provideEventDatabase(app: Application): PlatformDatabase = + Room + .databaseBuilder( + context = app, + klass = PlatformDatabase::class.java, + name = "platform_database", + ) + .fallbackToDestructiveMigration() + .addTypeConverter(ZonedDateTimeTypeConverter()) + .build() + + @Provides + @Singleton + fun provideOrganizationEventDao( + database: PlatformDatabase, + ): OrganizationEventDao = database.organizationEventDao() + + @Provides + @Singleton + fun provideEventDiskSource( + organizationEventDao: OrganizationEventDao, + dispatcherManager: DispatcherManager, + json: Json, + ): EventDiskSource = + EventDiskSourceImpl( + organizationEventDao = organizationEventDao, + dispatcherManager = dispatcherManager, + json = json, + ) + @Provides @Singleton fun provideLegacySecureStorage( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/entity/OrganizationEventEntity.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/entity/OrganizationEventEntity.kt new file mode 100644 index 000000000..af8684369 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/entity/OrganizationEventEntity.kt @@ -0,0 +1,28 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.ZonedDateTime + +/** + * Entity representing an organization event in the database. + */ +@Entity(tableName = "organization_events") +data class OrganizationEventEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Int = 0, + + @ColumnInfo(name = "user_id", index = true) + val userId: String, + + @ColumnInfo(name = "organization_event_type") + val organizationEventType: String, + + @ColumnInfo(name = "cipher_id") + val cipherId: String?, + + @ColumnInfo(name = "date") + val date: ZonedDateTime, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/EventDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/EventDiskSourceTest.kt new file mode 100644 index 000000000..286b36085 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/EventDiskSourceTest.kt @@ -0,0 +1,142 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.datasource.disk.dao.FakeOrganizationEventDao +import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity +import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule +import com.x8bit.bitwarden.data.platform.datasource.network.model.OrganizationEvent +import com.x8bit.bitwarden.data.platform.manager.model.OrganizationEventType +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.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime + +class EventDiskSourceTest { + private val fixedClock: Clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + + private val fakeOrganizationEventDao = FakeOrganizationEventDao() + private val fakeDispatcherManager = FakeDispatcherManager() + private val json = PlatformNetworkModule.providesJson() + + private val eventDiskSource: EventDiskSource = EventDiskSourceImpl( + organizationEventDao = fakeOrganizationEventDao, + dispatcherManager = fakeDispatcherManager, + json = json, + ) + + @Test + fun `addOrganizationEvent should insert a new organization event`() = runTest { + val userId = "userId-1" + val organizationEvent = OrganizationEvent( + type = OrganizationEventType.CIPHER_DELETED, + cipherId = "cipherId-1", + date = ZonedDateTime.now(fixedClock), + ) + + eventDiskSource.addOrganizationEvent( + userId = userId, + event = organizationEvent, + ) + + assertEquals( + listOf( + OrganizationEventEntity( + id = 0, + userId = userId, + organizationEventType = "1102", + cipherId = "cipherId-1", + date = ZonedDateTime.now(fixedClock), + ), + ), + fakeOrganizationEventDao.storedEvents, + ) + assertFalse(fakeOrganizationEventDao.isDeleteCalled) + assertTrue(fakeOrganizationEventDao.isInsertCalled) + } + + @Test + fun `deleteOrganizationEvents should delete all organization events`() = runTest { + val userId = "userId-1" + fakeOrganizationEventDao.storedEvents.addAll( + listOf( + OrganizationEventEntity( + id = 1, + userId = userId, + organizationEventType = "1102", + cipherId = "cipherId-1", + date = ZonedDateTime.now(fixedClock), + ), + OrganizationEventEntity( + id = 2, + userId = "userId-2", + organizationEventType = "1102", + cipherId = "cipherId-2", + date = ZonedDateTime.now(fixedClock), + ), + ), + ) + + eventDiskSource.deleteOrganizationEvents(userId = userId) + + assertEquals( + listOf( + OrganizationEventEntity( + id = 2, + userId = "userId-2", + organizationEventType = "1102", + cipherId = "cipherId-2", + date = ZonedDateTime.now(fixedClock), + ), + ), + fakeOrganizationEventDao.storedEvents, + ) + assertTrue(fakeOrganizationEventDao.isDeleteCalled) + assertFalse(fakeOrganizationEventDao.isInsertCalled) + } + + @Test + fun `getOrganizationEvents should retrieve the correct organization events`() = runTest { + val userId = "userId-1" + fakeOrganizationEventDao.storedEvents.addAll( + listOf( + OrganizationEventEntity( + id = 1, + userId = userId, + organizationEventType = "1102", + cipherId = "cipherId-1", + date = ZonedDateTime.now(fixedClock), + ), + OrganizationEventEntity( + id = 2, + userId = "userId-2", + organizationEventType = "1102", + cipherId = "cipherId-2", + date = ZonedDateTime.now(fixedClock), + ), + ), + ) + + val result = eventDiskSource.getOrganizationEvents(userId = userId) + + assertEquals( + listOf( + OrganizationEvent( + type = OrganizationEventType.CIPHER_DELETED, + cipherId = "cipherId-1", + date = ZonedDateTime.now(fixedClock), + ), + ), + result, + ) + assertFalse(fakeOrganizationEventDao.isDeleteCalled) + assertFalse(fakeOrganizationEventDao.isInsertCalled) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/dao/FakeOrganizationEventDao.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/dao/FakeOrganizationEventDao.kt new file mode 100644 index 000000000..3baeb8c06 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/dao/FakeOrganizationEventDao.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk.dao + +import com.x8bit.bitwarden.data.platform.datasource.disk.entity.OrganizationEventEntity + +class FakeOrganizationEventDao : OrganizationEventDao { + val storedEvents = mutableListOf() + + var isDeleteCalled = false + var isInsertCalled = false + + override suspend fun deleteOrganizationEvents(userId: String): Int { + val count = storedEvents.count { it.userId == userId } + storedEvents.removeAll { it.userId == userId } + isDeleteCalled = true + return count + } + + override suspend fun getOrganizationEvents( + userId: String, + ): List = storedEvents.filter { it.userId == userId } + + override suspend fun insertOrganizationEvent(event: OrganizationEventEntity) { + storedEvents.add(event) + isInsertCalled = true + } +}