diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59c7d5009..91cc5cb59 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,9 +9,11 @@ compileSdk = "34" composeBom = "2024.05.00" composeNavigation = "2.8.0-alpha08" coreKtx = "1.13.1" +coreKtxVersion = "1.5.0" coreTestingVersion = "2.2.0" coroutinesVersion = "1.8.0" credentialsPlayServicesAuth = "1.2.2" +datastorePreferences = "1.1.1" detekt = "1.23.6" # please adjust github action version as well in case of version change dokka = "1.9.20" espressoCore = "3.5.1" @@ -38,6 +40,7 @@ mockKVersion = "1.13.10" playServicesAuth = "21.2.0" rulesVersion = "1.5.0" runnerVersion = "1.5.2" +securityCryptoKtx = "1.1.0-alpha06" targetSdk = "34" testCoreVersion = "1.5.0" timberVersion = "5.0.1" @@ -50,18 +53,20 @@ android-gradle = { group = "com.android.tools.build", name = "gradle", version.r android-tools-common = { group = "com.android.tools", name = "common", version.ref = "androidTools" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -androidx-health-connect-client = { module = "androidx.health.connect:connect-client", version.ref = "healthConnectClient" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "coreTestingVersion" } androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentialsPlayServicesAuth" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" } +androidx-health-connect-client = { module = "androidx.health.connect:connect-client", version.ref = "healthConnectClient" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleKtx" } androidx-lifecycle-view-model-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } +androidx-security-crypto-ktx = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "securityCryptoKtx" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "testCoreVersion" } -androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "runnerVersion" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "rulesVersion" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "runnerVersion" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } @@ -71,6 +76,7 @@ compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" } compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "coreKtxVersion" } coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutinesVersion" } coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutinesVersion" } coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesVersion" } diff --git a/modules/storage/.gitignore b/modules/storage/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/modules/storage/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/modules/storage/README.MD b/modules/storage/README.MD new file mode 100644 index 000000000..244ea60ca --- /dev/null +++ b/modules/storage/README.MD @@ -0,0 +1,80 @@ +# Module storage + +The storage module provides components for managing storage in your application. It includes classes +for handling key-value/file storage and secure storage of data. + +## Usage + +To use the Storage module in your project, add the following dependency to your `build.gradle` file: + +```gradle +dependencies { + implementation(":modules:storage")` +} +``` + +and provide the wanted storage implementation with Hilt DI. There are the following storage +implementations: + +- `EncryptedFileStorage` for the `FileStorage` interface +- `EncryptedSharedPreferencesStorage` for the `KeyValueStorage` interface +- `LocalStorage` for the `KeyValueStorage` interface + +## Key-Value Storage + +The key-value storage provides a simple interface for storing and retrieving key-value pairs: + +```kotlin +interface KeyValueStorage { + suspend fun saveData(key: PreferenceKey, data: T) + fun readData(key: PreferenceKey): Flow + suspend fun readDataBlocking(key: PreferenceKey): T? + suspend fun deleteData(key: PreferenceKey) +} +``` + +It can be used like this: + +```kotlin +val stringKey = PreferenceKey.StringKey("user_name") +keyValueStorage.saveData(stringKey, "test_user_name") +keyValueStorage.readDataBlocking(stringKey)?.let { + println("Read string data blocking: $it") +} +keyValueStorage.deleteData(stringKey) +``` + +or you can use the `Flow` interface to observe changes: + +```kotlin +val job = launch { + keyValueStorage.readData(stringKey).collect { data: String? -> + println("Read string data: $data") + } +} +``` + +## File Storage + +The file storage provides a simple interface for storing and retrieving files: + +```kotlin +interface FileStorage { + suspend fun readFile(fileName: String): ByteArray? + suspend fun deleteFile(fileName: String) + suspend fun saveFile(fileName: String, data: ByteArray) +} +``` + +It can be used like this: + +```kotlin +val fileName = "testFile.data" +val data = "Hello, Stanford!".toByteArray() +fileStorage.saveFile(fileName, data) +val readData = fileStorage.readFile(fileName) +readData?.let { + println("Read file data: ${String(it)}") +} +fileStorage.deleteFile(fileName) +``` diff --git a/modules/storage/build.gradle.kts b/modules/storage/build.gradle.kts new file mode 100644 index 000000000..ee425f994 --- /dev/null +++ b/modules/storage/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.spezi.library) + alias(libs.plugins.spezi.hilt) +} + +android { + namespace = "edu.stanford.spezi.modules.storage" +} + +dependencies { + implementation(project(":core:coroutines")) + implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.security.crypto.ktx) + implementation(libs.core.ktx) +} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileKeyValueStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileKeyValueStorageTest.kt new file mode 100644 index 000000000..d81244bdd --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileKeyValueStorageTest.kt @@ -0,0 +1,82 @@ +package edu.stanford.spezi.modules.storage.file + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import edu.stanford.spezi.core.testing.runTestUnconfined +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EncryptedFileKeyValueStorageTest { + private var context: Context = ApplicationProvider.getApplicationContext() + private var fileStorage: FileStorage = + EncryptedFileStorage( + context = context, + ioDispatcher = UnconfinedTestDispatcher(), + ) + private val fileName = "testFile" + + @After + fun tearDown() = runTestUnconfined { + // Delete the file after each test to clean up + fileStorage.deleteFile(fileName) + } + + @Test + fun `it should save and read file correctly`() = runTestUnconfined { + // Given + val data = "Hello, World!".toByteArray() + + // When + fileStorage.saveFile(fileName, data) + + // Then + val readData = fileStorage.readFile(fileName) + assertThat(readData).isEqualTo(data) + } + + @Test + fun `it should return null when reading non-existent file`() = runTestUnconfined { + // Given + val fileName = "nonExistentFile" + + // When + val readData = fileStorage.readFile(fileName) + + // Then + assertThat(readData).isNull() + } + + @Test + fun `it should overwrite existing file when saving with same filename`() = runTestUnconfined { + // Given + val initialData = "Hello, World!".toByteArray() + val newData = "New data".toByteArray() + + // When + fileStorage.saveFile(fileName, initialData) + fileStorage.saveFile(fileName, newData) + + // Then + val readData = fileStorage.readFile(fileName) + assertThat(readData).isEqualTo(newData) + } + + @Test + fun `it should delete file correctly`() = runTestUnconfined { + // Given + val data = "Hello, World!".toByteArray() + fileStorage.saveFile(fileName, data) + + // When + fileStorage.deleteFile(fileName) + + // Then + val readData = fileStorage.readFile(fileName) + assertThat(readData).isNull() + } +} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedSharedPreferencesKeyValueStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedSharedPreferencesKeyValueStorageTest.kt new file mode 100644 index 000000000..12617ea17 --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedSharedPreferencesKeyValueStorageTest.kt @@ -0,0 +1,171 @@ +package edu.stanford.spezi.modules.storage.key + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import edu.stanford.spezi.core.testing.runTestUnconfined +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Test + +class EncryptedSharedPreferencesKeyValueStorageTest { + + private val context = ApplicationProvider.getApplicationContext() + private var storage: EncryptedSharedPreferencesKeyValueStorage = + EncryptedSharedPreferencesKeyValueStorage(context) + + @Test + fun `it should save and read string data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.StringKey("test_string_key") + val expectedValue = "Test String" + + // When + storage.saveData(key, expectedValue) + + // Then + val actualValue = storage.readDataBlocking(key) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return null when reading non-existent data`() = runTestUnconfined { + // Given + val key = PreferenceKey.StringKey("non_existent_key") + + // When + val actualValue = storage.readDataBlocking(key) + + // Then + assertThat(actualValue).isNull() + } + + @Test + fun `it should overwrite existing data when saving with same key`() = runTestUnconfined { + // Given + val key = PreferenceKey.StringKey("test_string_key") + val initialData = "Initial Data" + val newData = "New Data" + + // When + storage.saveData(key, initialData) + storage.saveData(key, newData) + + // Then + val actualValue = storage.readDataBlocking(key) + assertThat(actualValue).isEqualTo(newData) + } + + @Test + fun `it should save and read int data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.IntKey("test_int_key") + val expectedValue = 42 + + // When + storage.saveData(key, expectedValue) + + // Then + val actualValue = storage.readDataBlocking(key) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should save and read boolean data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.BooleanKey("test_boolean_key") + val expectedValue = true + + // When + storage.saveData(key, expectedValue) + + // Then + val actualValue = storage.readDataBlocking(key) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should save and read float data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.FloatKey("test_float_key") + val expectedValue = 3.14f + + // When + storage.saveData(key, expectedValue) + + // Then + val actualValue = storage.readDataBlocking(key) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should save and read long data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.LongKey("test_long_key") + val expectedValue = 123456789L + + // When + storage.saveData(key, expectedValue) + + // Then + val actualValue = storage.readDataBlocking(key) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should save and read double data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.DoubleKey("test_double_key") + val expectedValue = 3.14159 + + // When + storage.saveData(key, expectedValue) + + // Then + val actualValue = storage.readDataBlocking(key) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should save and read byte array data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.ByteArrayKey("test_byte_array_key") + val expectedValue = byteArrayOf(0x01, 0x02, 0x03, 0x04) + + // When + storage.saveData(key, expectedValue) + + // Then + val actualValue = storage.readDataBlocking(key) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should delete data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.StringKey("test_string_key") + val value = "Test String" + storage.saveData(key, value) + + // When + storage.deleteData(key) + + // Then + val actualValue = storage.readDataBlocking(key) + assertThat(actualValue).isNull() + } + + @Test + fun `it should read data flow correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.StringKey("test_string_key") + val expectedValue = "Test String" + storage.saveData(key, expectedValue) + + // When + val actualValue = runBlocking { storage.readData(key).first() } + + // Then + assertThat(actualValue).isEqualTo(expectedValue) + } +} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorageTest.kt new file mode 100644 index 000000000..d8588451d --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorageTest.kt @@ -0,0 +1,74 @@ +package edu.stanford.spezi.modules.storage.key + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import edu.stanford.spezi.core.testing.runTestUnconfined +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LocalKeyValueStorageTest { + private var context: Context = ApplicationProvider.getApplicationContext() + private var localStorage: LocalKeyValueStorage = LocalKeyValueStorage(context) + + @Test + fun `it should save and read data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.StringKey("testKey") + val data = "Hello, Leland Stanford!" + + // When + localStorage.saveData(key, data) + + // Then + val readData = runBlocking { localStorage.readDataBlocking(key) } + assertThat(readData).isEqualTo(data) + } + + @Test + fun `it should return null when reading non-existent data`() = runTestUnconfined { + // Given + val key = PreferenceKey.StringKey("nonExistentKey") + + // When + val readData = localStorage.readDataBlocking(key) + + // Then + assertThat(readData).isNull() + } + + @Test + fun `it should overwrite existing data when saving with same key`() = runTestUnconfined { + // Given + val key = PreferenceKey.StringKey("testKey") + val initialData = "Hello, Leland Stanford!" + val newData = "New data" + + // When + localStorage.saveData(key, initialData) + localStorage.saveData(key, newData) + + // Then + val readData = localStorage.readDataBlocking(key) + assertThat(readData).isEqualTo(newData) + } + + @Test + fun `it should delete data correctly`() = runTestUnconfined { + // Given + val key = PreferenceKey.StringKey("testKey") + val data = "Hello, Leland Stanford!" + + // When + + localStorage.saveData(key, data) + localStorage.deleteData(key) + + // Then + val readData = localStorage.readDataBlocking(key) + assertThat(readData).isNull() + } +} diff --git a/modules/storage/src/main/AndroidManifest.xml b/modules/storage/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e10007615 --- /dev/null +++ b/modules/storage/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileStorage.kt new file mode 100644 index 000000000..40d1d6c60 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileStorage.kt @@ -0,0 +1,65 @@ +package edu.stanford.spezi.modules.storage.file + +import android.content.Context +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import edu.stanford.spezi.core.coroutines.di.Dispatching +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +class EncryptedFileStorage @Inject constructor( + @ApplicationContext private val context: Context, + @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, +) : + FileStorage { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private fun getEncryptedFile(fileName: String): EncryptedFile { + val file = File(context.filesDir, fileName) + + return EncryptedFile.Builder( + context, + file, + masterKey, + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ).build() + } + + override suspend fun saveFile(fileName: String, data: ByteArray): Result = + withContext(ioDispatcher) { + runCatching { + deleteFile(fileName) + val encryptedFile = getEncryptedFile(fileName) + encryptedFile.openFileOutput().use { outputStream -> + outputStream.write(data) + } + } + } + + override suspend fun readFile(fileName: String): Result = + withContext(ioDispatcher) { + runCatching { + val file = File(context.filesDir, fileName) + if (!file.exists()) return@runCatching null + + val encryptedFile = getEncryptedFile(fileName) + encryptedFile.openFileInput().use { inputStream -> + inputStream.readBytes() + } + } + } + + override suspend fun deleteFile(fileName: String): Result = withContext(ioDispatcher) { + runCatching { + val file = File(context.filesDir, fileName) + if (file.exists()) { + file.delete() + } + } + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/FileStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/FileStorage.kt new file mode 100644 index 000000000..236925487 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/FileStorage.kt @@ -0,0 +1,7 @@ +package edu.stanford.spezi.modules.storage.file + +interface FileStorage { + suspend fun readFile(fileName: String): Result + suspend fun deleteFile(fileName: String): Result + suspend fun saveFile(fileName: String, data: ByteArray): Result +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedSharedPreferencesKeyValueStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedSharedPreferencesKeyValueStorage.kt new file mode 100644 index 000000000..3ada22f51 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedSharedPreferencesKeyValueStorage.kt @@ -0,0 +1,83 @@ +package edu.stanford.spezi.modules.storage.key + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class EncryptedSharedPreferencesKeyValueStorage @Inject constructor( + @ApplicationContext private val context: Context, +) : KeyValueStorage { + private val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + private val sharedPreferences: SharedPreferences = + EncryptedSharedPreferences.create( + context, + "spezi_shared_preferences", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + @OptIn(ExperimentalStdlibApi::class) + override suspend fun saveData(key: PreferenceKey, data: T) { + sharedPreferences.edit(commit = false) { + when (data) { + is String -> putString(key.key.name, data) + is Int -> putInt(key.key.name, data) + is Boolean -> putBoolean(key.key.name, data) + is Float -> putFloat(key.key.name, data) + is Long -> putLong(key.key.name, data) + is Double -> putString(key.key.name, data.toString()) + is ByteArray -> putString(key.key.name, data.toHexString()) + } + } + } + + @OptIn(ExperimentalStdlibApi::class) + override fun readData(key: PreferenceKey): Flow = flow { + emit( + when (key) { + is PreferenceKey.StringKey -> sharedPreferences.getString(key.key.name, null) + is PreferenceKey.IntKey -> sharedPreferences.getInt(key.key.name, 0) + is PreferenceKey.BooleanKey -> sharedPreferences.getBoolean(key.key.name, false) + is PreferenceKey.FloatKey -> sharedPreferences.getFloat(key.key.name, 0f) + is PreferenceKey.LongKey -> sharedPreferences.getLong(key.key.name, 0L) + is PreferenceKey.DoubleKey -> sharedPreferences.getString(key.key.name, null) + ?.toDouble() + + is PreferenceKey.ByteArrayKey -> sharedPreferences.getString(key.key.name, null) + ?.hexToByteArray() + } as T? + ) + } + + @OptIn(ExperimentalStdlibApi::class) + override suspend fun readDataBlocking(key: PreferenceKey): T? { + return when (key) { + is PreferenceKey.StringKey -> sharedPreferences.getString(key.key.name, null) + is PreferenceKey.IntKey -> sharedPreferences.getInt(key.key.name, 0) + is PreferenceKey.BooleanKey -> sharedPreferences.getBoolean(key.key.name, false) + is PreferenceKey.FloatKey -> sharedPreferences.getFloat(key.key.name, 0f) + is PreferenceKey.LongKey -> sharedPreferences.getLong(key.key.name, 0L) + is PreferenceKey.DoubleKey -> sharedPreferences.getString(key.key.name, null) + ?.toDouble() + + is PreferenceKey.ByteArrayKey -> sharedPreferences.getString(key.key.name, null) + ?.hexToByteArray() + } as T? + } + + override suspend fun deleteData(key: PreferenceKey) { + sharedPreferences.edit(commit = false) { + remove(key.key.name) + } + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorage.kt new file mode 100644 index 000000000..56faa5cb0 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorage.kt @@ -0,0 +1,10 @@ +package edu.stanford.spezi.modules.storage.key + +import kotlinx.coroutines.flow.Flow + +interface KeyValueStorage { + suspend fun saveData(key: PreferenceKey, data: T) + fun readData(key: PreferenceKey): Flow + suspend fun readDataBlocking(key: PreferenceKey): T? + suspend fun deleteData(key: PreferenceKey) +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorage.kt new file mode 100644 index 000000000..80092df78 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorage.kt @@ -0,0 +1,52 @@ +package edu.stanford.spezi.modules.storage.key + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class LocalKeyValueStorage @Inject constructor( + @ApplicationContext context: Context, +) : KeyValueStorage { + companion object { + private val Context.dataStore: DataStore by preferencesDataStore( + name = "spezi_preferences" + ) + } + + private val dataStore = context.dataStore + + override suspend fun saveData(key: PreferenceKey, data: T) { + dataStore.edit { preferences -> + preferences[key.key] = data + } + } + + override fun readData(key: PreferenceKey): Flow { + return dataStore.data + .catch { emit(emptyPreferences()) } + .map { preferences -> + preferences[key.key] + } + } + + override suspend fun readDataBlocking(key: PreferenceKey): T? { + return dataStore.data + .catch { emit(emptyPreferences()) } + .firstOrNull()?.get(key.key) + } + + override suspend fun deleteData(key: PreferenceKey) { + dataStore.edit { preferences -> + preferences.remove(key.key) + } + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/PreferenceKey.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/PreferenceKey.kt new file mode 100644 index 000000000..27a24d284 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/PreferenceKey.kt @@ -0,0 +1,20 @@ +package edu.stanford.spezi.modules.storage.key + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.byteArrayPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +sealed class PreferenceKey(val key: Preferences.Key) { + class IntKey(name: String) : PreferenceKey(intPreferencesKey(name)) + class DoubleKey(name: String) : PreferenceKey(doublePreferencesKey(name)) + class StringKey(name: String) : PreferenceKey(stringPreferencesKey(name)) + class BooleanKey(name: String) : PreferenceKey(booleanPreferencesKey(name)) + class FloatKey(name: String) : PreferenceKey(floatPreferencesKey(name)) + class LongKey(name: String) : PreferenceKey(longPreferencesKey(name)) + class ByteArrayKey(name: String) : PreferenceKey(byteArrayPreferencesKey(name)) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c162c0d52..553e9c7e5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -40,3 +40,4 @@ include(":modules:account") include(":modules:contact") include(":modules:healthconnectonfhir") include(":modules:onboarding") +include(":modules:storage")