Add a CacheStore module.

This commit is contained in:
Benoit Marty 2026-04-29 16:39:07 +02:00 committed by Benoit Marty
parent c1e908a8e6
commit 29e0a08dd9
15 changed files with 374 additions and 0 deletions

View file

@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.cachestore.api"
}

View file

@ -0,0 +1,13 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.cachestore.api
data class CacheData(
val value: String,
val updatedAt: Long,
)

View file

@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.cachestore.api
interface CacheStore {
suspend fun storeData(key: String, data: CacheData)
suspend fun getData(key: String): CacheData?
suspend fun deleteData(key: String)
}

View file

@ -0,0 +1,48 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
alias(libs.plugins.sqldelight)
}
android {
namespace = "io.element.android.libraries.cachestore.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.encryptedDb)
api(projects.libraries.cachestore.api)
implementation(libs.sqldelight.driver.android)
implementation(libs.sqlcipher)
implementation(libs.sqlite)
implementation(projects.libraries.di)
implementation(libs.sqldelight.coroutines)
testCommonDependencies(libs)
testImplementation(libs.sqldelight.driver.jvm)
}
sqldelight {
databases {
create("CacheDatabase") {
// https://sqldelight.github.io/sqldelight/2.1.0/android_sqlite/migrations/
// To generate a .db file from your latest schema, run this task
// ./gradlew generateDebugCacheDatabaseSchema
// Test migration by running
// ./gradlew verifySqlDelightMigration
schemaOutputDirectory = File("src/main/sqldelight/databases")
verifyMigrations = true
}
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.cachestore.impl
import io.element.android.libraries.cachestore.api.CacheData
import java.util.Date
import io.element.android.libraries.cachestore.CacheData as DbCacheData
internal fun CacheData.toDbModel(key: String): DbCacheData {
return DbCacheData(
key = key,
value_ = value,
updatedAt = updatedAt.time,
)
}
internal fun DbCacheData.toApiModel(): CacheData {
return CacheData(
value = value_,
updatedAt = Date(updatedAt),
)
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.cachestore.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DatabaseCacheStore(
private val database: CacheDatabase,
) : CacheStore {
override suspend fun getData(key: String): CacheData? {
return database.cacheDataQueries.selectData(key)
.executeAsOneOrNull()
?.toApiModel()
}
override suspend fun storeData(key: String, data: CacheData) {
database.cacheDataQueries.insertData(
data.toDbModel(key)
)
}
override suspend fun deleteData(key: String) {
database.cacheDataQueries.deleteData(key)
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.cachestore.impl.di
import android.content.Context
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.cachestore.impl.CacheDatabase
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.encrypteddb.SqlCipherDriverFactory
import io.element.encrypteddb.passphrase.RandomSecretPassphraseProvider
@BindingContainer
@ContributesTo(AppScope::class)
object CacheStoreModule {
@Provides
@SingleIn(AppScope::class)
fun provideCacheDatabase(
@ApplicationContext context: Context,
): CacheDatabase {
val name = "cache_database"
val secretFile = context.getDatabasePath("$name.key")
// Make sure the parent directory of the key file exists, otherwise it will crash in older Android versions
val parentDir = secretFile.parentFile
if (parentDir != null && !parentDir.exists()) {
parentDir.mkdirs()
}
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
val driver = SqlCipherDriverFactory(passphraseProvider)
.create(CacheDatabase.Schema, "$name.db", context)
return CacheDatabase(driver)
}
}

View file

@ -0,0 +1,26 @@
--------------------------------------------------------------------
-- Current version of the DB is the highest value of filename
-- in the folder `sqldelight/databases`.
--
-- When upgrading the schema, you have to create a file .sqm in the
-- `sqldelight/databases` folder and run the following task to
-- generate a .db file using the latest schema
-- > ./gradlew generateDebugCacheDatabaseSchema
--------------------------------------------------------------------
CREATE TABLE CacheData (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL,
updatedAt INTEGER NOT NULL
);
selectData:
SELECT * FROM CacheData WHERE key = ?;
-- insert or update data by key
insertData:
INSERT INTO CacheData VALUES ? ON CONFLICT(key) DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt;
deleteData:
DELETE FROM CacheData WHERE key = ?;

View file

@ -0,0 +1,68 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.sessionstorage.impl
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.impl.CacheDatabase
import io.element.android.libraries.cachestore.impl.DatabaseCacheStore
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import io.element.android.libraries.cachestore.CacheData as DbCacheData
private const val A_KEY = "aKey"
private const val A_DATA_1 = "aData1"
private const val A_DATA_2 = "aData2"
class DatabaseCacheStoreTest {
private lateinit var database: CacheDatabase
private lateinit var databaseCacheStore: DatabaseCacheStore
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setup() {
// Initialise in memory SQLite driver
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
CacheDatabase.Schema.create(driver)
database = CacheDatabase(driver)
databaseCacheStore = DatabaseCacheStore(
database = database,
)
}
@Test
fun `storeData persists the CacheData into the DB, deleteData deletes it`() = runTest {
// Assert that no data is stored for the key
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isNull()
// Store data
databaseCacheStore.storeData(A_KEY, CacheData(A_DATA_1, 1))
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isEqualTo(
DbCacheData(
key = A_KEY,
value_ = A_DATA_1,
updatedAt = 1,
)
)
// Update data
databaseCacheStore.storeData(A_KEY, CacheData(A_DATA_2, 2))
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isEqualTo(
DbCacheData(
key = A_KEY,
value_ = A_DATA_2,
updatedAt = 2,
)
)
// Delete data
databaseCacheStore.deleteData(A_KEY)
assertThat(database.cacheDataQueries.selectData(A_KEY).executeAsOneOrNull()).isNull()
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.sessionstorage.impl
import io.element.android.libraries.cachestore.CacheData
import java.util.Date
internal fun aCacheData(
key: String = "aKey",
value: String = "aValue",
updatedAt: Date = Date(),
) = CacheData(
key = key,
value_ = value,
updatedAt = updatedAt.time,
)

View file

@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.cachestore.test"
}
dependencies {
implementation(projects.libraries.cachestore.api)
}

View file

@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.sessionstorage.test
import io.element.android.libraries.cachestore.api.CacheData
fun aCacheData(
value: String = "aValue",
updatedAt: Long = 0,
) = CacheData(
value = value,
updatedAt = updatedAt,
)

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.sessionstorage.test
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
class InMemoryCacheStore(
initialData: Map<String, CacheData> = emptyMap(),
) : CacheStore {
val dataMap = initialData.toMutableMap()
override suspend fun storeData(key: String, data: CacheData) {
dataMap[key] = data
}
override suspend fun getData(key: String): CacheData? {
return dataMap[key]
}
override suspend fun deleteData(key: String) {
dataMap.remove(key)
}
}

View file

@ -104,6 +104,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
implementation(project(":libraries:architecture"))
implementation(project(":libraries:dateformatter:impl"))
implementation(project(":libraries:di"))
implementation(project(":libraries:cachestore:impl"))
implementation(project(":libraries:session-storage:impl"))
implementation(project(":libraries:mediapickers:impl"))
implementation(project(":libraries:mediaupload:impl"))