diff --git a/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorage.kt b/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorage.kt index ca160d513..7b496cbcc 100644 --- a/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorage.kt +++ b/core/bluetooth/src/main/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorage.kt @@ -10,7 +10,6 @@ import edu.stanford.spezi.modules.storage.di.Storage import edu.stanford.spezi.modules.storage.key.KeyValueStorage import edu.stanford.spezi.modules.storage.key.getSerializableList import edu.stanford.spezi.modules.storage.key.putSerializable -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,14 +22,11 @@ internal class BLEPairedDevicesStorage @Inject constructor( private val bluetoothAdapter: BluetoothAdapter, private val bleDevicePairingNotifier: BLEDevicePairingNotifier, @Storage.Encrypted - private val encryptedKeyValueStorage: KeyValueStorage, + private val storage: KeyValueStorage, private val timeProvider: TimeProvider, @Dispatching.IO private val ioScope: CoroutineScope, ) { private val logger by speziLogger() - private val coroutineExceptionHandler = CoroutineExceptionHandler { _, error -> - logger.e(error) { "Error executing paired devices storage operations" } - } private val _pairedDevices = MutableStateFlow(emptyList()) val pairedDevices = _pairedDevices.asStateFlow() @@ -40,8 +36,8 @@ internal class BLEPairedDevicesStorage @Inject constructor( observeUnpairingEvents() } - fun updateDeviceConnection(device: BluetoothDevice, connected: Boolean) = execute { - if (isPaired(device).not()) return@execute + fun updateDeviceConnection(device: BluetoothDevice, connected: Boolean) { + if (isPaired(device).not()) return val currentDevices = getCurrentStoredDevices() currentDevices.removeAll { it.address == device.address } val newDevice = BLEDevice( @@ -54,8 +50,8 @@ internal class BLEPairedDevicesStorage @Inject constructor( update(devices = currentDevices + newDevice) } - private fun refreshState() = execute { - val systemBoundDevices = bluetoothAdapter.bondedDevices ?: return@execute + private fun refreshState() { + val systemBoundDevices = bluetoothAdapter.bondedDevices ?: return val newDevices = getCurrentStoredDevices().filter { storedDevice -> systemBoundDevices.any { it.address == storedDevice.address } } @@ -63,15 +59,15 @@ internal class BLEPairedDevicesStorage @Inject constructor( update(devices = newDevices) } - fun onStopped() = execute { + fun onStopped() { val devices = getCurrentStoredDevices().map { it.copy(connected = false, lastSeenTimeStamp = timeProvider.currentTimeMillis()) } update(devices = devices) } - private fun update(devices: List) = execute { - encryptedKeyValueStorage.putSerializable(key = KEY, devices) + private fun update(devices: List) { + storage.putSerializable(key = KEY, devices) _pairedDevices.update { devices } logger.i { "Updating local storage with $devices" } } @@ -112,12 +108,8 @@ internal class BLEPairedDevicesStorage @Inject constructor( } } - private suspend fun getCurrentStoredDevices() = - encryptedKeyValueStorage.getSerializableList(key = KEY).toMutableList() - - private fun execute(block: suspend () -> Unit) { - ioScope.launch(coroutineExceptionHandler) { block() } - } + private fun getCurrentStoredDevices() = + storage.getSerializableList(key = KEY).toMutableList() private companion object { const val KEY = "paired_ble_devices" diff --git a/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorageTest.kt b/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorageTest.kt index b5b13cd8e..5d294e5b3 100644 --- a/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorageTest.kt +++ b/core/bluetooth/src/test/kotlin/edu/stanford/spezi/core/bluetooth/domain/BLEPairedDevicesStorageTest.kt @@ -32,7 +32,7 @@ class BLEPairedDevicesStorageTest { private val pairedDevicesStorage by lazy { BLEPairedDevicesStorage( bluetoothAdapter = adapter, - encryptedKeyValueStorage = storage, + storage = storage, ioScope = SpeziTestScope(), bleDevicePairingNotifier = bleDevicePairingNotifier, timeProvider = timeProvider, diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorageTests.kt new file mode 100644 index 000000000..9a1053803 --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorageTests.kt @@ -0,0 +1,202 @@ +package edu.stanford.spezi.modules.storage.credential + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.spezi.core.utils.UUID +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class CredentialStorageTests { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var credentialStorage: CredentialStorage + + private val serverCredential = Credential( + username = "@Schmiedmayer", + password = "top-secret", + server = "apple.com" + ) + + private val nonServerCredential = Credential( + username = "@Spezi", + password = "123456", + ) + + @Before + fun setup() { + hiltRule.inject() + } + + @After + fun tearDown() { + credentialStorage.deleteAll(CredentialTypes.All) + } + + @Test + fun `it should store server credentials correctly`() { + // given + credentialStorage.store(serverCredential) + + // when + val serverCredentials = credentialStorage.retrieveAll(server = serverCredential.server!!) + val userServerCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = serverCredential.server, + ) + + // then + assertThat(serverCredentials).containsExactly(serverCredential) + assertThat(userServerCredential).isEqualTo(serverCredential) + } + + @Test + fun `it should store non server credentials correctly`() { + // given + credentialStorage.store(nonServerCredential) + + // when + val userServerCredential = credentialStorage.retrieve( + username = nonServerCredential.username + ) + val userCredentials = credentialStorage.retrieve(nonServerCredential.username) + + // then + assertThat(userCredentials).isEqualTo(nonServerCredential) + assertThat(userServerCredential).isEqualTo(nonServerCredential) + } + + @Test + fun `it should retrieve all server credentials correctly`() { + // given + val server = "edu.stanford.spezi" + val credentials = List(10) { serverCredential.copy(username = "User$it", server = server) } + credentials.forEach { credentialStorage.store(it) } + + // when + val storedCredentials = credentialStorage.retrieveAll(server) + + // then + assertThat(storedCredentials).containsExactlyElementsIn(credentials) + assertThat(storedCredentials.all { it.server == server }).isTrue() + } + + @Test + fun `it should update credentials correctly`() { + // given + val updatedUserName = serverCredential.username + "- @Spezi" + val newPassword = serverCredential.password.plus(UUID().toString()) + credentialStorage.store(serverCredential) + val updatedCredential = serverCredential.copy( + username = updatedUserName, + password = newPassword, + ) + credentialStorage.update( + username = serverCredential.username, + server = serverCredential.server, + newCredential = updatedCredential, + ) + + // when + val oldCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = serverCredential.server, + ) + val newCredential = credentialStorage.retrieve( + username = updatedCredential.username, + server = updatedCredential.server, + ) + + // then + assertThat(oldCredential).isNull() + assertThat(newCredential).isEqualTo(updatedCredential) + } + + @Test + fun `it should delete credentials correctly`() { + // given + credentialStorage.store(serverCredential) + val beforeDeleteCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = "apple.com", + ) + + // when + credentialStorage.delete( + username = serverCredential.username, + server = serverCredential.server, + ) + val afterDeleteCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = serverCredential.server, + ) + + // then + assertThat(beforeDeleteCredential).isEqualTo(serverCredential) + assertThat(afterDeleteCredential).isNull() + } + + @Test + fun `it should handle deleting all server credentials correctly`() { + // given + listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } + + // when + credentialStorage.deleteAll(CredentialTypes.Server) + val storedServerCredential = credentialStorage.retrieve( + username = serverCredential.username, + ) + val storedNonServerCredential = credentialStorage.retrieve( + username = nonServerCredential.username, + ) + + // then + assertThat(storedServerCredential).isNull() + assertThat(storedNonServerCredential).isEqualTo(nonServerCredential) + } + + @Test + fun `it should handle deleting non server credentials correctly`() { + // given + listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } + + // when + credentialStorage.deleteAll(CredentialTypes.NonServer) + val storedServerCredential = credentialStorage.retrieve( + username = serverCredential.username, + server = serverCredential.server, + ) + val storedNonServerCredential = credentialStorage.retrieve( + username = nonServerCredential.username, + ) + + // then + assertThat(storedServerCredential).isEqualTo(serverCredential) + assertThat(storedNonServerCredential).isNull() + } + + @Test + fun `it should handle deleting all credentials correctly`() { + // given + listOf(serverCredential, nonServerCredential).forEach { credentialStorage.store(it) } + + // when + credentialStorage.deleteAll(CredentialTypes.All) + val storedServerCredential = credentialStorage.retrieve( + username = serverCredential.username, + ) + val storedNonServerCredential = credentialStorage.retrieve( + username = nonServerCredential.username, + ) + + // then + assertThat(storedServerCredential).isNull() + assertThat(storedNonServerCredential).isNull() + } +} 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 deleted file mode 100644 index 606119c14..000000000 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileKeyValueStorageTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -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).getOrNull() - 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).getOrNull() - - // 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).getOrNull() - 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).getOrNull() - assertThat(readData).isNull() - } -} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorageTest.kt deleted file mode 100644 index e059b206d..000000000 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorageTest.kt +++ /dev/null @@ -1,299 +0,0 @@ -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.test.UnconfinedTestDispatcher -import kotlinx.serialization.Serializable -import org.junit.Test - -class EncryptedKeyValueStorageTest { - - private val context = ApplicationProvider.getApplicationContext() - private var storage: EncryptedKeyValueStorage = - EncryptedKeyValueStorage(context, UnconfinedTestDispatcher()) - - private val key = "test_key" - - @Test - fun `it should save and read string data correctly`() = runTestUnconfined { - // given - val expectedValue = "Test String" - - // when - storage.putString(key, expectedValue) - - // then - val actualValue = storage.getString(key, "") - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent string data`() = runTestUnconfined { - // given - val default = "default" - - // when - val actualValue = storage.getString(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete string data correctly`() = runTestUnconfined { - // given - val value = "Test String" - storage.putString(key, value) - - // when - storage.deleteString(key) - - // then - val actualValue = storage.getString(key, "default") - assertThat(actualValue).isEqualTo("default") - } - - @Test - fun `it should save and read int data correctly`() = runTestUnconfined { - // given - val expectedValue = 42 - - // when - storage.putInt(key, expectedValue) - - // then - val actualValue = storage.getInt(key, 0) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent int data`() = runTestUnconfined { - // given - val default = 0 - - // when - val actualValue = storage.getInt(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete int data correctly`() = runTestUnconfined { - // given - val value = 42 - storage.putInt(key, value) - - // when - storage.deleteInt(key) - - // then - val actualValue = storage.getInt(key, 0) - assertThat(actualValue).isEqualTo(0) - } - - @Test - fun `it should save and read boolean data correctly`() = runTestUnconfined { - // given - val expectedValue = true - - // when - storage.putBoolean(key, expectedValue) - - // then - val actualValue = storage.getBoolean(key, false) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent boolean data`() = runTestUnconfined { - // given - val default = false - - // when - val actualValue = storage.getBoolean(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete boolean data correctly`() = runTestUnconfined { - // given - storage.putBoolean(key, true) - - // when - storage.deleteBoolean(key) - - // then - val actualValue = storage.getBoolean(key, false) - assertThat(actualValue).isEqualTo(false) - } - - @Test - fun `it should save and read float data correctly`() = runTestUnconfined { - // given - val expectedValue = 3.14f - - // when - storage.putFloat(key, expectedValue) - - // then - val actualValue = storage.getFloat(key, 0f) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent float data`() = runTestUnconfined { - // given - val default = 0f - - // when - val actualValue = storage.getFloat(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete float data correctly`() = runTestUnconfined { - // given - val value = 3.14f - storage.putFloat(key, value) - - // when - storage.deleteFloat(key) - - // then - val actualValue = storage.getFloat(key, 0f) - assertThat(actualValue).isEqualTo(0f) - } - - @Test - fun `it should save and read long data correctly`() = runTestUnconfined { - // given - val expectedValue = 123456789L - - // when - storage.putLong(key, expectedValue) - - // then - val actualValue = storage.getLong(key, 0L) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent long data`() = runTestUnconfined { - // given - val default = 0L - - // when - val actualValue = storage.getLong(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete long data correctly`() = runTestUnconfined { - // given - val value = 123456789L - storage.putLong(key, value) - - // when - storage.deleteLong(key) - - // then - val actualValue = storage.getLong(key, 0L) - assertThat(actualValue).isEqualTo(0L) - } - - @Test - fun `it should save and read byte array data correctly`() = runTestUnconfined { - // given - val expectedValue = byteArrayOf(0x01, 0x02, 0x03, 0x04) - - // when - storage.putByteArray(key, expectedValue) - - // then - val actualValue = storage.getByteArray(key, byteArrayOf()) - assertThat(actualValue).isEqualTo(expectedValue) - } - - @Test - fun `it should return default when reading non-existent byte array data`() = runTestUnconfined { - // given - val default = byteArrayOf() - - // when - val actualValue = storage.getByteArray(key, default) - - // then - assertThat(actualValue).isEqualTo(default) - } - - @Test - fun `it should delete byte array data correctly`() = runTestUnconfined { - // given - val value = byteArrayOf(0x01, 0x02, 0x03, 0x04) - storage.putByteArray(key, value) - - // when - storage.deleteByteArray(key) - - // then - val actualValue = storage.getByteArray(key, byteArrayOf()) - assertThat(actualValue).isEqualTo(byteArrayOf()) - } - - @Test - fun `it should handle serializable type correctly`() = runTestUnconfined { - // given - val data = Complex() - storage.putSerializable(key, data) - - // when - val contains = storage.getSerializable(key) == data - storage.deleteSerializable(key) - val deleted = storage.getSerializable(key) == null - - // then - assertThat(contains).isTrue() - assertThat(deleted).isTrue() - } - - @Test - fun `it should handle serializable list read correctly`() = runTestUnconfined { - // given - val data = listOf(Complex()) - storage.putSerializable(key, data) - - // when - val contains = storage.getSerializableList(key) == data - storage.deleteSerializable>(key) - val deleted = storage.getSerializable>(key) == null - - // then - assertThat(contains).isTrue() - assertThat(deleted).isTrue() - } - - @Test - fun `it should handle clear correctly`() = runTestUnconfined { - // given - val initialValue = 1234 - storage.putInt(key, initialValue) - - // when - storage.clear() - - // then - assertThat(storage.getInt(key, -1)).isNotEqualTo(initialValue) - } - - @Serializable - data class Complex(val id: Int = 1) -} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryStorageTest.kt index 42483cf07..233f665da 100644 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryStorageTest.kt +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryStorageTest.kt @@ -1,7 +1,6 @@ package edu.stanford.spezi.modules.storage.key import com.google.common.truth.Truth.assertThat -import edu.stanford.spezi.core.testing.runTestUnconfined import kotlinx.serialization.Serializable import org.junit.Test @@ -11,7 +10,7 @@ class InMemoryStorageTest { private val key = "local_storage_test_key" @Test - fun `it should save and read string data correctly`() = runTestUnconfined { + fun `it should save and read string data correctly`() { // given val expectedValue = "Test String" @@ -24,7 +23,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent string data`() = runTestUnconfined { + fun `it should return default when reading non-existent string data`() { // given val default = "default" @@ -36,13 +35,13 @@ class InMemoryStorageTest { } @Test - fun `it should delete string data correctly`() = runTestUnconfined { + fun `it should delete string data correctly`() { // given val value = "Test String" storage.putString(key, value) // when - storage.deleteString(key) + storage.delete(key) // then val actualValue = storage.getString(key, "default") @@ -50,7 +49,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read int data correctly`() = runTestUnconfined { + fun `it should save and read int data correctly`() { // given val expectedValue = 42 @@ -63,7 +62,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent int data`() = runTestUnconfined { + fun `it should return default when reading non-existent int data`() { // given val default = 0 @@ -75,13 +74,13 @@ class InMemoryStorageTest { } @Test - fun `it should delete int data correctly`() = runTestUnconfined { + fun `it should delete int data correctly`() { // given val value = 42 storage.putInt(key, value) // when - storage.deleteInt(key) + storage.delete(key) // then val actualValue = storage.getInt(key, 0) @@ -89,7 +88,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read boolean data correctly`() = runTestUnconfined { + fun `it should save and read boolean data correctly`() { // given val expectedValue = true @@ -102,7 +101,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent boolean data`() = runTestUnconfined { + fun `it should return default when reading non-existent boolean data`() { // given val default = false @@ -114,12 +113,12 @@ class InMemoryStorageTest { } @Test - fun `it should delete boolean data correctly`() = runTestUnconfined { + fun `it should delete boolean data correctly`() { // given storage.putBoolean(key, true) // when - storage.deleteBoolean(key) + storage.delete(key) // then val actualValue = storage.getBoolean(key, false) @@ -127,7 +126,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read float data correctly`() = runTestUnconfined { + fun `it should save and read float data correctly`() { // given val expectedValue = 3.14f @@ -140,7 +139,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent float data`() = runTestUnconfined { + fun `it should return default when reading non-existent float data`() { // given val default = 0f @@ -152,13 +151,13 @@ class InMemoryStorageTest { } @Test - fun `it should delete float data correctly`() = runTestUnconfined { + fun `it should delete float data correctly`() { // given val value = 3.14f storage.putFloat(key, value) // when - storage.deleteFloat(key) + storage.delete(key) // then val actualValue = storage.getFloat(key, 0f) @@ -166,7 +165,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read long data correctly`() = runTestUnconfined { + fun `it should save and read long data correctly`() { // given val expectedValue = 123456789L @@ -179,7 +178,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent long data`() = runTestUnconfined { + fun `it should return default when reading non-existent long data`() { // given val default = 0L @@ -191,13 +190,13 @@ class InMemoryStorageTest { } @Test - fun `it should delete long data correctly`() = runTestUnconfined { + fun `it should delete long data correctly`() { // given val value = 123456789L storage.putLong(key, value) // when - storage.deleteLong(key) + storage.delete(key) // then val actualValue = storage.getLong(key, 0L) @@ -205,7 +204,7 @@ class InMemoryStorageTest { } @Test - fun `it should save and read byte array data correctly`() = runTestUnconfined { + fun `it should save and read byte array data correctly`() { // given val expectedValue = byteArrayOf(0x01, 0x02, 0x03, 0x04) @@ -218,7 +217,7 @@ class InMemoryStorageTest { } @Test - fun `it should return default when reading non-existent byte array data`() = runTestUnconfined { + fun `it should return default when reading non-existent byte array data`() { // given val default = byteArrayOf() @@ -230,13 +229,25 @@ class InMemoryStorageTest { } @Test - fun `it should delete byte array data correctly`() = runTestUnconfined { + fun `it should return null when reading non-existent byte array data`() { + // given + storage.clear() + + // when + val actualValue = storage.getByteArray(key) + + // then + assertThat(actualValue).isNull() + } + + @Test + fun `it should delete byte array data correctly`() { // given val value = byteArrayOf(0x01, 0x02, 0x03, 0x04) storage.putByteArray(key, value) // when - storage.deleteByteArray(key) + storage.delete(key) // then val actualValue = storage.getByteArray(key, byteArrayOf()) @@ -244,14 +255,14 @@ class InMemoryStorageTest { } @Test - fun `it should handle serializable type correctly`() = runTestUnconfined { + fun `it should handle serializable type correctly`() { // given val data = Complex() storage.putSerializable(key, data) // when val contains = storage.getSerializable(key) == data - storage.deleteSerializable(key) + storage.delete(key) val deleted = storage.getSerializable(key) == null // then @@ -260,14 +271,14 @@ class InMemoryStorageTest { } @Test - fun `it should handle serializable list read correctly`() = runTestUnconfined { + fun `it should handle serializable list read correctly`() { // given val data = listOf(Complex()) storage.putSerializable(key, data) // when val contains = storage.getSerializableList(key) == data - storage.deleteSerializable>(key) + storage.delete(key) val deleted = storage.getSerializable>(key) == null // then @@ -276,7 +287,7 @@ class InMemoryStorageTest { } @Test - fun `it should handle clear correctly`() = runTestUnconfined { + fun `it should handle clear correctly`() { // given val initialValue = 1234 storage.putInt(key, initialValue) diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageTest.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageTest.kt new file mode 100644 index 000000000..6e045ea41 --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageTest.kt @@ -0,0 +1,337 @@ +package edu.stanford.spezi.modules.storage.key + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.spezi.modules.storage.di.Storage +import kotlinx.serialization.Serializable +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class KeyValueStorageTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Storage.Encrypted + @Inject + lateinit var encryptedStorage: KeyValueStorage + + @Storage.Unencrypted + @Inject + lateinit var unencryptedStorage: KeyValueStorage + + private val key = "test_key" + + @Before + fun setup() { + hiltRule.inject() + } + + @After + fun tearDown() { + encryptedStorage.clear() + unencryptedStorage.clear() + } + + @Test + fun `it should save and read string data correctly`() = runAllStoragesTest { + // given + val expectedValue = "Test String" + + // when + putString(key, expectedValue) + + // then + val actualValue = getString(key, "") + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent string data`() = runAllStoragesTest { + // given + val default = "default" + + // when + val actualValue = getString(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete string data correctly`() = runAllStoragesTest { + // given + val value = "Test String" + putString(key, value) + + // when + delete(key) + + // then + val actualValue = getString(key, "default") + assertThat(actualValue).isEqualTo("default") + } + + @Test + fun `it should save and read int data correctly`() = runAllStoragesTest { + // given + val expectedValue = 42 + + // when + putInt(key, expectedValue) + + // then + val actualValue = getInt(key, 0) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent int data`() = runAllStoragesTest { + // given + val default = 0 + + // when + val actualValue = getInt(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete int data correctly`() = runAllStoragesTest { + // given + val value = 42 + putInt(key, value) + + // when + delete(key) + + // then + val actualValue = getInt(key, 0) + assertThat(actualValue).isEqualTo(0) + } + + @Test + fun `it should save and read boolean data correctly`() = runAllStoragesTest { + // given + val expectedValue = true + + // when + putBoolean(key, expectedValue) + + // then + val actualValue = getBoolean(key, false) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent boolean data`() = runAllStoragesTest { + // given + val default = false + + // when + val actualValue = getBoolean(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete boolean data correctly`() = runAllStoragesTest { + // given + putBoolean(key, true) + + // when + delete(key) + + // then + val actualValue = getBoolean(key, false) + assertThat(actualValue).isEqualTo(false) + } + + @Test + fun `it should save and read float data correctly`() = runAllStoragesTest { + // given + val expectedValue = 3.14f + + // when + putFloat(key, expectedValue) + + // then + val actualValue = getFloat(key, 0f) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent float data`() = runAllStoragesTest { + // given + val default = 0f + + // when + val actualValue = getFloat(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete float data correctly`() = runAllStoragesTest { + // given + val value = 3.14f + putFloat(key, value) + + // when + delete(key) + + // then + val actualValue = getFloat(key, 0f) + assertThat(actualValue).isEqualTo(0f) + } + + @Test + fun `it should save and read long data correctly`() = runAllStoragesTest { + // given + val expectedValue = 123456789L + + // when + putLong(key, expectedValue) + + // then + val actualValue = getLong(key, 0L) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent long data`() = runAllStoragesTest { + // given + val default = 0L + + // when + val actualValue = getLong(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should delete long data correctly`() = runAllStoragesTest { + // given + val value = 123456789L + putLong(key, value) + + // when + delete(key) + + // then + val actualValue = getLong(key, 0L) + assertThat(actualValue).isEqualTo(0L) + } + + @Test + fun `it should save and read byte array data correctly`() = runAllStoragesTest { + // given + val expectedValue = byteArrayOf(0x01, 0x02, 0x03, 0x04) + + // when + putByteArray(key, expectedValue) + + // then + val actualValue = getByteArray(key, byteArrayOf()) + assertThat(actualValue).isEqualTo(expectedValue) + } + + @Test + fun `it should return default when reading non-existent byte array data`() = runAllStoragesTest { + // given + val default = byteArrayOf() + + // when + val actualValue = getByteArray(key, default) + + // then + assertThat(actualValue).isEqualTo(default) + } + + @Test + fun `it should return null when reading non-existent byte array data`() = runAllStoragesTest { + // given + clear() + + // when + val actualValue = getByteArray(key) + + // then + assertThat(actualValue).isNull() + } + + @Test + fun `it should delete byte array data correctly`() = runAllStoragesTest { + // given + val value = byteArrayOf(0x01, 0x02, 0x03, 0x04) + putByteArray(key, value) + + // when + delete(key) + + // then + val actualValue = getByteArray(key, byteArrayOf()) + assertThat(actualValue).isEqualTo(byteArrayOf()) + } + + @Test + fun `it should handle serializable type correctly`() = runAllStoragesTest { + // given + val data = Complex() + putSerializable(key, data) + + // when + val contains = getSerializable(key) == data + delete(key) + val deleted = getSerializable(key) == null + + // then + assertThat(contains).isTrue() + assertThat(deleted).isTrue() + } + + @Test + fun `it should handle serializable list read correctly`() = runAllStoragesTest { + // given + val data = listOf(Complex()) + putSerializable(key, data) + + // when + val contains = getSerializableList(key) == data + delete(key) + val deleted = getSerializable>(key) == null + + // then + assertThat(contains).isTrue() + assertThat(deleted).isTrue() + } + + @Test + fun `it should handle clear correctly`() = runAllStoragesTest { + // given + val initialValue = 1234 + putInt(key, initialValue) + + // when + clear() + + // then + assertThat(getInt(key, -1)).isNotEqualTo(initialValue) + } + + private fun runAllStoragesTest(block: KeyValueStorage.() -> Unit) { + listOf(encryptedStorage, unencryptedStorage).forEach { block(it) } + } + + @Serializable + data class Complex(val id: Int = 1) +} 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 deleted file mode 100644 index baa68d591..000000000 --- a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorageTest.kt +++ /dev/null @@ -1,393 +0,0 @@ -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.serialization.Serializable -import org.junit.Before -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) - private val key = "local_storage_test_key" - - @Before - fun before() = runTestUnconfined { - localStorage.clear() - } - - @Test - fun `it should save and read String data correctly`() = runTestUnconfined { - // given - val data = "Hello, Leland Stanford!" - - // when - localStorage.putString(key, data) - - // then - val readData = localStorage.getString(key, "") - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default String when reading non-existent data`() = runTestUnconfined { - // given - val default = "default string" - - // when - val readData = localStorage.getString(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing String data`() = runTestUnconfined { - // given - val initialData = "Initial String" - val newData = "Updated String" - - // when - localStorage.putString(key, initialData) - localStorage.putString(key, newData) - - // then - val readData = localStorage.getString(key, "") - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete String data`() = runTestUnconfined { - // given - val data = "Some String" - - // when - localStorage.putString(key, data) - localStorage.deleteString(key) - - // then - val readData = localStorage.getString(key, "") - assertThat(readData).isEqualTo("") - } - - @Test - fun `it should save and read Boolean data correctly`() = runTestUnconfined { - // given - val data = true - - // when - localStorage.putBoolean(key, data) - - // then - val readData = localStorage.getBoolean(key, false) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default Boolean when reading non-existent data`() = runTestUnconfined { - // given - val default = false - - // when - val readData = localStorage.getBoolean(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing Boolean data`() = runTestUnconfined { - // given - val initialData = true - val newData = false - - // when - localStorage.putBoolean(key, initialData) - localStorage.putBoolean(key, newData) - - // then - val readData = localStorage.getBoolean(key, true) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete Boolean data`() = runTestUnconfined { - // given - val data = true - - // when - localStorage.putBoolean(key, data) - localStorage.deleteBoolean(key) - - // then - val readData = localStorage.getBoolean(key, false) - assertThat(readData).isEqualTo(false) - } - - @Test - fun `it should save and read Long data correctly`() = runTestUnconfined { - // given - val data = 12345L - - // when - localStorage.putLong(key, data) - - // then - val readData = localStorage.getLong(key, 0L) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default Long when reading non-existent data`() = runTestUnconfined { - // given - val default = 0L - - // when - val readData = localStorage.getLong(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing Long data`() = runTestUnconfined { - // given - val initialData = 12345L - val newData = 67890L - - // when - localStorage.putLong(key, initialData) - localStorage.putLong(key, newData) - - // then - val readData = localStorage.getLong(key, 0L) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete Long data`() = runTestUnconfined { - // given - val data = 12345L - - // when - localStorage.putLong(key, data) - localStorage.deleteLong(key) - - // then - val readData = localStorage.getLong(key, 0L) - assertThat(readData).isEqualTo(0L) - } - - @Test - fun `it should save and read Int data correctly`() = runTestUnconfined { - // given - val data = 42 - - // when - localStorage.putInt(key, data) - - // then - val readData = localStorage.getInt(key, 0) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default Int when reading non-existent data`() = runTestUnconfined { - // given - val default = 0 - - // then - val readData = localStorage.getInt(key, default) - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing Int data`() = runTestUnconfined { - // given - val initialData = 42 - val newData = 100 - - // when - localStorage.putInt(key, initialData) - localStorage.putInt(key, newData) - - // then - val readData = localStorage.getInt(key, 0) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete Int data`() = runTestUnconfined { - // given - val data = 42 - localStorage.putInt(key, data) - - // when - localStorage.deleteInt(key) - - // then - val readData = localStorage.getInt(key, 0) - assertThat(readData).isEqualTo(0) - } - - @Test - fun `it should save and read Float data correctly`() = runTestUnconfined { - // given - val data = 3.14f - - // when - localStorage.putFloat(key, data) - - // then - val readData = localStorage.getFloat(key, 0.0f) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default Float when reading non-existent data`() = runTestUnconfined { - // given - val default = 0.0f - - // when - val readData = localStorage.getFloat(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing Float data`() = runTestUnconfined { - // given - val initialData = 3.14f - val newData = 2.71f - - // when - localStorage.putFloat(key, initialData) - localStorage.putFloat(key, newData) - - // then - val readData = localStorage.getFloat(key, 0.0f) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete Float data`() = runTestUnconfined { - // given - val data = 3.14f - - // when - localStorage.putFloat(key, data) - localStorage.deleteFloat(key) - - // then - val readData = localStorage.getFloat(key, 0.0f) - assertThat(readData).isEqualTo(0.0f) - } - - @Test - fun `it should save and read ByteArray data correctly`() = runTestUnconfined { - // given - val data = byteArrayOf(1, 2, 3, 4) - - // when - localStorage.putByteArray(key, data) - - // then - val readData = localStorage.getByteArray(key, byteArrayOf()) - assertThat(readData).isEqualTo(data) - } - - @Test - fun `it should return default ByteArray when reading non-existent data`() = runTestUnconfined { - // given - val default = byteArrayOf(0) - - // when - val readData = localStorage.getByteArray(key, default) - - // then - assertThat(readData).isEqualTo(default) - } - - @Test - fun `it should overwrite existing ByteArray data`() = runTestUnconfined { - // given - val initialData = byteArrayOf(1, 2, 3, 4) - val newData = byteArrayOf(5, 6, 7, 8) - - // when - localStorage.putByteArray(key, initialData) - localStorage.putByteArray(key, newData) - - // then - val readData = localStorage.getByteArray(key, byteArrayOf()) - assertThat(readData).isEqualTo(newData) - } - - @Test - fun `it should delete ByteArray data`() = runTestUnconfined { - // given - val data = byteArrayOf(1, 2, 3, 4) - - // when - localStorage.putByteArray(key, data) - localStorage.deleteByteArray(key) - - // then - val readData = localStorage.getByteArray(key, byteArrayOf()) - assertThat(readData).isEqualTo(byteArrayOf()) - } - - @Test - fun `it should handle serializable type correctly`() = runTestUnconfined { - // given - val data = Complex() - localStorage.putSerializable(key, data) - - // when - val contains = localStorage.getSerializable(key) == data - localStorage.deleteSerializable(key) - val deleted = localStorage.getSerializable(key) == null - - // then - assertThat(contains).isTrue() - assertThat(deleted).isTrue() - } - - @Test - fun `it should handle serializable list read correctly`() = runTestUnconfined { - // given - val data = listOf(Complex()) - localStorage.putSerializable(key, data) - - // when - val contains = localStorage.getSerializableList(key) == data - localStorage.deleteSerializable>(key) - val deleted = localStorage.getSerializable>(key) == null - - // then - assertThat(contains).isTrue() - assertThat(deleted).isTrue() - } - - @Test - fun `it should handle clear correctly`() = runTestUnconfined { - // given - val initialValue = 1234 - localStorage.putInt(key, initialValue) - - // when - localStorage.clear() - - // then - assertThat(localStorage.getInt(key, -1)).isNotEqualTo(initialValue) - } - - @Serializable - data class Complex(val id: Int = 1) -} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorageTests.kt new file mode 100644 index 000000000..a48b9d6cd --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorageTests.kt @@ -0,0 +1,70 @@ +package edu.stanford.spezi.modules.storage.local + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import javax.inject.Inject + +@HiltAndroidTest +class KeyStorageTests { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var keyStorage: KeyStorage + + private val keyName = "TestKey" + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun `it should create keys correctly`() { + // given + val key = keyStorage.create(keyName).getOrThrow() + + // when + val keyPair = keyStorage.retrieveKeyPair(keyName) + val privateKey = keyStorage.retrievePrivateKey(keyName) + val publicKey = keyStorage.retrievePublicKey(keyName) + + // then + assertThat(privateKey).isEqualTo(key.private) + assertThat(privateKey).isEqualTo(keyPair?.private) + assertThat(publicKey).isEqualTo(key.public) + assertThat(publicKey).isEqualTo(keyPair?.public) + } + + @Test + fun `it should handle key deletion correctly`() { + // given + keyStorage.create(keyName) + + // when + keyStorage.delete(keyName) + val privateKey = keyStorage.retrievePrivateKey(keyName) + val publicKey = keyStorage.retrievePublicKey(keyName) + + // then + assertThat(privateKey).isNull() + assertThat(publicKey).isNull() + } + + @Test + fun `it should handle clear correctly`() { + // given + keyStorage.create(keyName) + + // when + keyStorage.deleteAll() + + // then + assertThat(keyStorage.retrieveKeyPair(keyName)).isNull() + } +} diff --git a/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt new file mode 100644 index 000000000..cb5fb6f0f --- /dev/null +++ b/modules/storage/src/androidTest/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageTests.kt @@ -0,0 +1,213 @@ +package edu.stanford.spezi.modules.storage.local + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import edu.stanford.spezi.core.testing.runTestUnconfined +import edu.stanford.spezi.core.utils.UUID +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.nio.charset.StandardCharsets +import javax.inject.Inject +import kotlin.random.Random + +@HiltAndroidTest +class LocalStorageTests { + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var localStorage: LocalStorage + + @Inject + lateinit var keyStorage: KeyStorage + + private val key = "storage_key" + + @Before + fun setup() { + hiltRule.inject() + } + + @After + fun tearDown() = runTestUnconfined { + localStorage.delete(key = key) + keyStorage.deleteAll() + } + + @Serializable + data class Letter(val greeting: String) + + @Test + fun `it should handle complex type correctly`() = runTestUnconfined { + // given + val greeting = "Hello Paul 👋 ${"🚀".repeat(Random.nextInt(10))}" + val letter = Letter(greeting = greeting) + localStorage.store( + key = key, + value = letter, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer() + ) + + // when + val storedLetter = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer(), + ) + + // then + assertThat(letter).isEqualTo(storedLetter) + } + + @Test + fun `it should handle custom coding correctly`() = runTestUnconfined { + // given + val greeting = "Hello Paul 👋 ${"🚀".repeat(Random.nextInt(10))}" + val letter = Letter(greeting = greeting) + localStorage.store( + key = key, + value = letter, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + encoding = { Json.encodeToString(serializer(), it).toByteArray(StandardCharsets.UTF_8) } + ) + + // when + val storedLetter = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + decoding = { Json.decodeFromString(serializer(), String(it, StandardCharsets.UTF_8)) } + ) + + // then + assertThat(letter).isEqualTo(storedLetter) + } + + @Test + fun `it should handle deletion of complex type correctly`() = runTestUnconfined { + // given + val greeting = "Hello Paul 👋 ${"🚀".repeat(Random.nextInt(10))}" + val letter = Letter(greeting = greeting) + localStorage.store( + key = key, + value = letter, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer() + ) + val storedLetter = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer(), + ) + + // when + localStorage.delete(key) + + // then + val afterDelete = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer(), + ) + assertThat(letter).isEqualTo(storedLetter) + assertThat(afterDelete).isNull() + } + + @Test + fun `it should handle list of complex types correctly`() = runTestUnconfined { + // given + val greeting = "Hello Paul 👋 ${"🚀".repeat(Random.nextInt(10))}" + val letters = listOf(Letter(greeting = greeting)) + localStorage.store( + key = key, + value = letters, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = ListSerializer(serializer()) + ) + + // when + val storedLetters = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = ListSerializer(serializer()), + ) + + // then + assertThat(letters).isEqualTo(storedLetters) + } + + @Test + fun `it should handle primitive type correctly`() = runTestUnconfined { + // given + val value = Random.nextBoolean() + localStorage.store( + key = key, + value = value, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer() + ) + + // when + val storedValue = localStorage.read( + key = key, + settings = LocalStorageSetting.EncryptedUsingKeyStore, + serializer = serializer() + ) + + // then + assertThat(value).isEqualTo(storedValue) + } + + @Test + fun `it should handle Unencrypted setting correctly`() = runTestUnconfined { + // given + val value = UUID().toString() + localStorage.store( + key = key, + value = value, + settings = LocalStorageSetting.Unencrypted, + serializer = serializer() + ) + + // when + val storedValue = localStorage.read( + key = key, + settings = LocalStorageSetting.Unencrypted, + serializer = serializer() + ) + + // then + assertThat(value).isEqualTo(storedValue) + } + + @Test + fun `it should handle Encrypted with custom key pair setting correctly`() = runTestUnconfined { + // given + val value = UUID().toString() + val androidKeyStoreKey = "androidKeyStoreKey" + val keyPair = keyStorage.create(androidKeyStoreKey).getOrThrow() + localStorage.store( + key = key, + value = value, + settings = LocalStorageSetting.Encrypted(keyPair), + serializer = serializer() + ) + + // when + val storedValue = localStorage.read( + key = key, + settings = LocalStorageSetting.Encrypted(keyPair), + serializer = serializer() + ) + + // then + assertThat(value).isEqualTo(storedValue) + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/Credential.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/Credential.kt new file mode 100644 index 000000000..4093d196c --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/Credential.kt @@ -0,0 +1,10 @@ +package edu.stanford.spezi.modules.storage.credential + +import kotlinx.serialization.Serializable + +@Serializable +data class Credential( + val username: String, + val password: String, + val server: String? = null, +) diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorage.kt new file mode 100644 index 000000000..95b12a564 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialStorage.kt @@ -0,0 +1,97 @@ +package edu.stanford.spezi.modules.storage.credential + +import edu.stanford.spezi.modules.storage.di.Storage +import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactory +import edu.stanford.spezi.modules.storage.key.KeyValueStorageType +import edu.stanford.spezi.modules.storage.key.getSerializable +import edu.stanford.spezi.modules.storage.key.putSerializable +import javax.inject.Inject + +interface CredentialStorage { + fun store(credential: Credential) + + fun update( + username: String, + server: String? = null, + newCredential: Credential, + ) + + fun retrieve(username: String, server: String? = null): Credential? + fun retrieveAll(server: String): List + + fun delete(username: String, server: String? = null) + fun deleteAll(types: CredentialTypes) +} + +internal class CredentialStorageImpl @Inject constructor( + storageFactory: KeyValueStorageFactory, +) : CredentialStorage { + + private val storage = storageFactory.create( + fileName = SECURE_STORAGE_FILE_NAME, + type = KeyValueStorageType.ENCRYPTED, + ) + + override fun store(credential: Credential) { + storage.putSerializable( + key = storageKey(credential.server, credential.username), + value = credential + ) + } + + override fun retrieve( + username: String, + server: String?, + ): Credential? { + return storage.getSerializable(storageKey(server, username)) + } + + override fun retrieveAll(server: String): List { + val serverKey = storageKey(server, "") + return storage.allKeys().mapNotNull { key -> + if (key.startsWith(serverKey)) { + storage.getSerializable(key) + } else { + null + } + } + } + + override fun delete( + username: String, + server: String?, + ) { + storage.delete(key = storageKey(server, username)) + } + + override fun deleteAll(types: CredentialTypes) { + if (types.set.isEmpty()) return + if (types.set == CredentialTypes.All.set) return storage.clear() + val deleteServer = types.set.contains(CredentialType.SERVER) + val deleteNonServer = types.set.contains(CredentialType.NON_SERVER) + storage.allKeys().forEach { key -> + val isServerKey = key.substringBefore(SERVER_USERNAME_SEPARATOR).isNotEmpty() + when { + isServerKey && deleteServer -> storage.delete(key) + !isServerKey && deleteNonServer -> storage.delete(key) + } + } + } + + override fun update( + username: String, + server: String?, + newCredential: Credential, + ) { + delete(username, server) + store(newCredential) + } + + private fun storageKey(server: String?, username: String): String = + "${server ?: ""}$SERVER_USERNAME_SEPARATOR$username" + + private companion object { + const val SECURE_STORAGE_FILE_NAME = "${Storage.STORAGE_FILE_PREFIX}CredentialStorage" + const val SERVER_USERNAME_SEPARATOR = "__@__" + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialTypes.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialTypes.kt new file mode 100644 index 000000000..050a4c966 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/credential/CredentialTypes.kt @@ -0,0 +1,19 @@ +package edu.stanford.spezi.modules.storage.credential + +import edu.stanford.spezi.modules.storage.credential.CredentialType.NON_SERVER +import edu.stanford.spezi.modules.storage.credential.CredentialType.SERVER +import java.util.EnumSet + +data class CredentialTypes( + internal val set: EnumSet, +) { + companion object { + val All = CredentialTypes(EnumSet.allOf(CredentialType::class.java)) + val Server = CredentialTypes(EnumSet.of(SERVER)) + val NonServer = CredentialTypes(EnumSet.of(NON_SERVER)) + } +} + +enum class CredentialType { + SERVER, NON_SERVER +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/Storage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/Storage.kt index cb7999d7e..574e79b32 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/Storage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/Storage.kt @@ -7,4 +7,12 @@ interface Storage { @Qualifier @Retention(AnnotationRetention.BINARY) annotation class Encrypted + + @Qualifier + @Retention(AnnotationRetention.BINARY) + annotation class Unencrypted + + companion object { + internal const val STORAGE_FILE_PREFIX = "edu.stanford.spezi.storage." + } } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt index 84d5de38d..785a5e2b7 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/di/StorageModule.kt @@ -2,18 +2,80 @@ package edu.stanford.spezi.modules.storage.di import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import edu.stanford.spezi.modules.storage.key.EncryptedKeyValueStorage +import edu.stanford.spezi.modules.storage.credential.CredentialStorage +import edu.stanford.spezi.modules.storage.credential.CredentialStorageImpl import edu.stanford.spezi.modules.storage.key.KeyValueStorage +import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactory +import edu.stanford.spezi.modules.storage.key.KeyValueStorageFactoryImpl +import edu.stanford.spezi.modules.storage.key.KeyValueStorageType +import edu.stanford.spezi.modules.storage.local.KeyStorage +import edu.stanford.spezi.modules.storage.local.KeyStorageImpl +import edu.stanford.spezi.modules.storage.local.LocalStorage +import edu.stanford.spezi.modules.storage.local.LocalStorageImpl +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -abstract class StorageModule { +class StorageModule { - @Binds + @Singleton + @Provides @Storage.Encrypted - abstract fun bindLocalKeyValueStorage( - encryptedKeyValueStorage: EncryptedKeyValueStorage, - ): KeyValueStorage + fun provideDefaultEncryptedKeyValueStorage( + keyValueStorageFactory: KeyValueStorageFactory, + ): KeyValueStorage { + return createDefaultKeyValueStorage( + keyValueStorageFactory = keyValueStorageFactory, + type = KeyValueStorageType.ENCRYPTED, + ) + } + + @Singleton + @Provides + @Storage.Unencrypted + fun provideDefaultUnEncryptedKeyValueStorage( + keyValueStorageFactory: KeyValueStorageFactory, + ): KeyValueStorage { + return createDefaultKeyValueStorage( + keyValueStorageFactory = keyValueStorageFactory, + type = KeyValueStorageType.UNENCRYPTED, + ) + } + + private fun createDefaultKeyValueStorage( + keyValueStorageFactory: KeyValueStorageFactory, + type: KeyValueStorageType, + ): KeyValueStorage { + return keyValueStorageFactory.create( + fileName = "${Storage.STORAGE_FILE_PREFIX}${type.name}", + type = type + ) + } + + @Module + @InstallIn(SingletonComponent::class) + abstract class Bindings { + @Binds + internal abstract fun bindKeyValueStorageFactory( + impl: KeyValueStorageFactoryImpl, + ): KeyValueStorageFactory + + @Binds + internal abstract fun bindCredentialStorage( + impl: CredentialStorageImpl, + ): CredentialStorage + + @Binds + internal abstract fun bindKeyStorage( + impl: KeyStorageImpl, + ): KeyStorage + + @Binds + internal abstract fun bindLocalStorage( + impl: LocalStorageImpl, + ): LocalStorage + } } 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 deleted file mode 100644 index 40d1d6c60..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/EncryptedFileStorage.kt +++ /dev/null @@ -1,65 +0,0 @@ -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 deleted file mode 100644 index 236925487..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/file/FileStorage.kt +++ /dev/null @@ -1,7 +0,0 @@ -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/EncryptedKeyValueStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorage.kt deleted file mode 100644 index ea7faf08a..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/EncryptedKeyValueStorage.kt +++ /dev/null @@ -1,116 +0,0 @@ -package edu.stanford.spezi.modules.storage.key - -import android.content.Context -import android.content.SharedPreferences -import android.util.Base64 -import androidx.core.content.edit -import androidx.security.crypto.EncryptedSharedPreferences -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 javax.inject.Inject - -@Suppress("TooManyFunctions") -class EncryptedKeyValueStorage @Inject constructor( - @ApplicationContext private val context: Context, - @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, -) : 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 - ) - - override suspend fun getString(key: String, default: String): String { - return execute { sharedPreferences.getString(key, default) ?: default } - } - - override suspend fun putString(key: String, value: String) { - execute { sharedPreferences.edit { putString(key, value) } } - } - - override suspend fun deleteString(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getBoolean(key: String, default: Boolean): Boolean { - return execute { sharedPreferences.getBoolean(key, default) } - } - - override suspend fun putBoolean(key: String, value: Boolean) { - execute { sharedPreferences.edit { putBoolean(key, value) } } - } - - override suspend fun deleteBoolean(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getLong(key: String, default: Long): Long { - return execute { sharedPreferences.getLong(key, default) } - } - - override suspend fun putLong(key: String, value: Long) { - execute { sharedPreferences.edit { putLong(key, value) } } - } - - override suspend fun deleteLong(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getInt(key: String, default: Int): Int { - return execute { sharedPreferences.getInt(key, default) } - } - - override suspend fun putInt(key: String, value: Int) { - execute { sharedPreferences.edit { putInt(key, value) } } - } - - override suspend fun deleteInt(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getFloat(key: String, default: Float): Float { - return execute { sharedPreferences.getFloat(key, default) } - } - - override suspend fun putFloat(key: String, value: Float) { - execute { sharedPreferences.edit { putFloat(key, value) } } - } - - override suspend fun deleteFloat(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - override suspend fun getByteArray(key: String, default: ByteArray): ByteArray { - return execute { - val encoded = sharedPreferences.getString(key, null) - encoded?.let { Base64.decode(it, Base64.DEFAULT) } ?: default - } - } - - override suspend fun clear() { - sharedPreferences.edit { clear() } - } - - override suspend fun putByteArray(key: String, value: ByteArray) { - execute { - val encoded = Base64.encodeToString(value, Base64.DEFAULT) - sharedPreferences.edit { putString(key, encoded) } - } - } - - override suspend fun deleteByteArray(key: String) { - execute { sharedPreferences.edit { remove(key) } } - } - - private suspend fun execute(block: suspend () -> T) = withContext(ioDispatcher) { block() } -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryKeyValueStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryKeyValueStorage.kt index baec5c461..0d66d7b6c 100644 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryKeyValueStorage.kt +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/InMemoryKeyValueStorage.kt @@ -7,79 +7,71 @@ import javax.inject.Inject class InMemoryKeyValueStorage @Inject constructor() : KeyValueStorage { private val storage = ConcurrentHashMap() - override suspend fun getString(key: String, default: String): String { - return getValue(key) as? String ?: default + override fun getString(key: String): String? { + return getValue(key) as? String } - override suspend fun putString(key: String, value: String) { - putValue(key, value) + override fun getString(key: String, default: String): String { + return getValue(key) as? String ?: default } - override suspend fun deleteString(key: String) { - remove(key) + override fun putString(key: String, value: String) { + putValue(key, value) } - override suspend fun getBoolean(key: String, default: Boolean): Boolean { + override fun getBoolean(key: String, default: Boolean): Boolean { return getValue(key) as? Boolean ?: default } - override suspend fun putBoolean(key: String, value: Boolean) { + override fun putBoolean(key: String, value: Boolean) { putValue(key, value) } - override suspend fun deleteBoolean(key: String) { - remove(key) - } - - override suspend fun getLong(key: String, default: Long): Long { + override fun getLong(key: String, default: Long): Long { return getValue(key) as? Long ?: default } - override suspend fun putLong(key: String, value: Long) { + override fun putLong(key: String, value: Long) { putValue(key, value) } - override suspend fun deleteLong(key: String) { - remove(key) - } - - override suspend fun getInt(key: String, default: Int): Int { + override fun getInt(key: String, default: Int): Int { return getValue(key) as? Int ?: default } - override suspend fun putInt(key: String, value: Int) { + override fun putInt(key: String, value: Int) { putValue(key, value) } - override suspend fun deleteInt(key: String) { - remove(key) - } - - override suspend fun getFloat(key: String, default: Float): Float { + override fun getFloat(key: String, default: Float): Float { return getValue(key) as? Float ?: default } - override suspend fun putFloat(key: String, value: Float) { + override fun putFloat(key: String, value: Float) { putValue(key, value) } - override suspend fun deleteFloat(key: String) { - remove(key) + override fun getByteArray(key: String): ByteArray? { + return getValue(key) as? ByteArray } - override suspend fun getByteArray(key: String, default: ByteArray): ByteArray { - return getValue(key) as? ByteArray ?: default + override fun getByteArray(key: String, default: ByteArray): ByteArray { + return getByteArray(key) ?: default } - override suspend fun putByteArray(key: String, value: ByteArray) { + override fun putByteArray(key: String, value: ByteArray) { putValue(key, value) } - override suspend fun deleteByteArray(key: String) { - remove(key) + override fun allKeys(): Set { + return storage.keys + } + + override fun delete(key: String) { + storage.remove(key) } - override suspend fun clear() { + override fun clear() { storage.clear() } @@ -88,8 +80,4 @@ class InMemoryKeyValueStorage @Inject constructor() : KeyValueStorage { } fun getValue(key: String): Any? = storage[key] - - private fun remove(key: String) { - storage.remove(key) - } } 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 index 214f6c310..3aa89e5f0 100644 --- 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 @@ -1,30 +1,76 @@ package edu.stanford.spezi.modules.storage.key +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer + @Suppress("TooManyFunctions") -interface KeyValueStorage { - suspend fun getString(key: String, default: String): String - suspend fun putString(key: String, value: String) - suspend fun deleteString(key: String) +sealed interface KeyValueStorage { + + fun getString(key: String): String? + fun getString(key: String, default: String): String + fun putString(key: String, value: String) + + fun getBoolean(key: String, default: Boolean): Boolean + fun putBoolean(key: String, value: Boolean) - suspend fun getBoolean(key: String, default: Boolean): Boolean - suspend fun putBoolean(key: String, value: Boolean) - suspend fun deleteBoolean(key: String) + fun getLong(key: String, default: Long): Long + fun putLong(key: String, value: Long) - suspend fun getLong(key: String, default: Long): Long - suspend fun putLong(key: String, value: Long) - suspend fun deleteLong(key: String) + fun getInt(key: String, default: Int): Int + fun putInt(key: String, value: Int) - suspend fun getInt(key: String, default: Int): Int - suspend fun putInt(key: String, value: Int) - suspend fun deleteInt(key: String) + fun getFloat(key: String, default: Float): Float + fun putFloat(key: String, value: Float) - suspend fun getFloat(key: String, default: Float): Float - suspend fun putFloat(key: String, value: Float) - suspend fun deleteFloat(key: String) + fun getByteArray(key: String): ByteArray? + fun getByteArray(key: String, default: ByteArray): ByteArray + fun putByteArray(key: String, value: ByteArray) - suspend fun getByteArray(key: String, default: ByteArray): ByteArray - suspend fun putByteArray(key: String, value: ByteArray) - suspend fun deleteByteArray(key: String) + fun allKeys(): Set - suspend fun clear() + fun delete(key: String) + + fun clear() } + +inline fun KeyValueStorage.getSerializable(key: String): T? = + when (this) { + is KeyValueStorageImpl -> { + val jsonString = getString(key, "") + runCatching { + Json.decodeFromString(serializer(), jsonString) + }.getOrNull() + } + + is InMemoryKeyValueStorage -> getValue(key) as? T + } + +inline fun KeyValueStorage.putSerializable(key: String, value: T) { + when (this) { + is KeyValueStorageImpl -> { + runCatching { + putString(key = key, Json.encodeToString(value)) + } + } + is InMemoryKeyValueStorage -> putValue(key, value) + } +} + +inline fun KeyValueStorage.getSerializable(key: String, default: T): T = + getSerializable(key) ?: default + +inline fun KeyValueStorage.getSerializableList( + key: String, +): List = + when (this) { + is KeyValueStorageImpl -> { + val jsonString = getString(key, "") + runCatching { + Json.decodeFromString(ListSerializer(serializer()), jsonString) + }.getOrNull() ?: emptyList() + } + + is InMemoryKeyValueStorage -> getSerializable>(key, emptyList()) + } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageFactory.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageFactory.kt new file mode 100644 index 000000000..d78796ca1 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageFactory.kt @@ -0,0 +1,58 @@ +package edu.stanford.spezi.modules.storage.key + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +interface KeyValueStorageFactory { + fun create( + fileName: String, + type: KeyValueStorageType, + ): KeyValueStorage +} + +@Singleton +internal class KeyValueStorageFactoryImpl @Inject constructor( + private val storageFactory: KeyValueStorageImpl.Factory, + @ApplicationContext private val context: Context, +) : KeyValueStorageFactory { + + private val masterKey: MasterKey by lazy { + MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + } + + override fun create(fileName: String, type: KeyValueStorageType): KeyValueStorage { + val preferences = createSharedPreferences(fileName = fileName, type = type) + return storageFactory.create(preferences) + } + + private fun createSharedPreferences( + fileName: String, + type: KeyValueStorageType, + ): Lazy { + return lazy { + when (type) { + KeyValueStorageType.UNENCRYPTED -> context.getSharedPreferences( + fileName, + Context.MODE_PRIVATE + ) + + KeyValueStorageType.ENCRYPTED -> { + EncryptedSharedPreferences.create( + context, + fileName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + } + } + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageImpl.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageImpl.kt new file mode 100644 index 000000000..e833ce209 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageImpl.kt @@ -0,0 +1,83 @@ +package edu.stanford.spezi.modules.storage.key + +import android.content.SharedPreferences +import android.util.Base64 +import androidx.core.content.edit +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +@Suppress("TooManyFunctions") +class KeyValueStorageImpl @AssistedInject internal constructor( + @Assisted preferences: Lazy, +) : KeyValueStorage { + + private val sharedPreferences by preferences + + override fun allKeys(): Set = sharedPreferences.all.keys + + override fun getString(key: String): String? = sharedPreferences.getString(key, null) + + override fun getString(key: String, default: String): String = + sharedPreferences.getString(key, default) ?: default + + override fun putString(key: String, value: String) { + sharedPreferences.edit { putString(key, value) } + } + + override fun getBoolean(key: String, default: Boolean): Boolean = + sharedPreferences.getBoolean(key, default) + + override fun putBoolean(key: String, value: Boolean) { + sharedPreferences.edit { putBoolean(key, value) } + } + + override fun getLong(key: String, default: Long): Long = + sharedPreferences.getLong(key, default) + + override fun putLong(key: String, value: Long) { + sharedPreferences.edit { putLong(key, value) } + } + + override fun getInt(key: String, default: Int): Int = sharedPreferences.getInt(key, default) + + override fun putInt(key: String, value: Int) { + sharedPreferences.edit { putInt(key, value) } + } + + override fun getFloat(key: String, default: Float): Float = + sharedPreferences.getFloat(key, default) + + override fun putFloat(key: String, value: Float) { + sharedPreferences.edit { putFloat(key, value) } + } + + override fun getByteArray(key: String): ByteArray? = runCatching { + val encoded = sharedPreferences.getString(key, null) + encoded?.let { Base64.decode(it, Base64.DEFAULT) } + }.getOrNull() + + override fun getByteArray(key: String, default: ByteArray): ByteArray { + return getByteArray(key) ?: default + } + + override fun delete(key: String) { + sharedPreferences.edit { remove(key) } + } + + override fun clear() { + sharedPreferences.edit { clear() } + } + + override fun putByteArray(key: String, value: ByteArray) { + val encoded = Base64.encodeToString(value, Base64.DEFAULT) + sharedPreferences.edit { putString(key, encoded) } + } + + @AssistedFactory + internal interface Factory { + fun create( + preferences: Lazy, + ): KeyValueStorageImpl + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageSerialization.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageSerialization.kt deleted file mode 100644 index c93d2e9e2..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageSerialization.kt +++ /dev/null @@ -1,51 +0,0 @@ -package edu.stanford.spezi.modules.storage.key - -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.serializer - -suspend inline fun KeyValueStorage.getSerializable(key: String): T? = - when (this) { - is EncryptedKeyValueStorage, is LocalKeyValueStorage -> { - val jsonString = getString(key, "") - runCatching { - Json.decodeFromString(serializer(), jsonString) - }.getOrNull() - } - - is InMemoryKeyValueStorage -> getValue(key) as? T - else -> null - } - -suspend inline fun KeyValueStorage.putSerializable(key: String, value: T) { - when (this) { - is EncryptedKeyValueStorage, is LocalKeyValueStorage -> { - runCatching { - putString(key = key, Json.encodeToString(value)) - } - } - is InMemoryKeyValueStorage -> putValue(key, value) - } -} - -suspend inline fun KeyValueStorage.getSerializable(key: String, default: T): T = - getSerializable(key) ?: default - -suspend inline fun KeyValueStorage.deleteSerializable(key: String) = - deleteString(key) - -suspend inline fun KeyValueStorage.getSerializableList( - key: String, -): List = - when (this) { - is EncryptedKeyValueStorage, is LocalKeyValueStorage -> { - val jsonString = getString(key, "") - runCatching { - Json.decodeFromString(ListSerializer(serializer()), jsonString) - }.getOrNull() ?: emptyList() - } - - is InMemoryKeyValueStorage -> getSerializable>(key, emptyList()) - else -> emptyList() - } diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageType.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageType.kt new file mode 100644 index 000000000..2d78d640a --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/KeyValueStorageType.kt @@ -0,0 +1,6 @@ +package edu.stanford.spezi.modules.storage.key + +enum class KeyValueStorageType { + ENCRYPTED, + UNENCRYPTED, +} 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 deleted file mode 100644 index fc68f8dae..000000000 --- a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/key/LocalKeyValueStorage.kt +++ /dev/null @@ -1,128 +0,0 @@ -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.booleanPreferencesKey -import androidx.datastore.preferences.core.byteArrayPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.floatPreferencesKey -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.firstOrNull -import javax.inject.Inject - -@Suppress("TooManyFunctions") -class LocalKeyValueStorage @Inject constructor( - @ApplicationContext private val context: Context, -) : KeyValueStorage { - companion object { - const val FILE_NAME = "spezi_preferences" - private val Context.dataStore: DataStore by preferencesDataStore( - name = FILE_NAME - ) - } - - private val dataStore = context.dataStore - - override suspend fun getString(key: String, default: String): String { - return getData(stringPreferencesKey(key)) ?: default - } - - override suspend fun putString(key: String, value: String) { - saveData(stringPreferencesKey(key), value) - } - - override suspend fun deleteString(key: String) { - deleteData(stringPreferencesKey(key)) - } - - override suspend fun getBoolean(key: String, default: Boolean): Boolean { - return getData(booleanPreferencesKey(key)) ?: default - } - - override suspend fun putBoolean(key: String, value: Boolean) { - saveData(booleanPreferencesKey(key), value) - } - - override suspend fun deleteBoolean(key: String) { - deleteData(booleanPreferencesKey(key)) - } - - override suspend fun getLong(key: String, default: Long): Long { - return getData(longPreferencesKey(key)) ?: default - } - - override suspend fun putLong(key: String, value: Long) { - saveData(longPreferencesKey(key), value) - } - - override suspend fun deleteLong(key: String) { - deleteData(longPreferencesKey(key)) - } - - override suspend fun getInt(key: String, default: Int): Int { - return getData(intPreferencesKey(key)) ?: default - } - - override suspend fun putInt(key: String, value: Int) { - saveData(intPreferencesKey(key), value) - } - - override suspend fun deleteInt(key: String) { - deleteData(intPreferencesKey(key)) - } - - override suspend fun getFloat(key: String, default: Float): Float { - return getData(floatPreferencesKey(key)) ?: default - } - - override suspend fun putFloat(key: String, value: Float) { - saveData(floatPreferencesKey(key), value) - } - - override suspend fun deleteFloat(key: String) { - deleteData(floatPreferencesKey(key)) - } - - override suspend fun getByteArray(key: String, default: ByteArray): ByteArray { - return getData(byteArrayPreferencesKey(key)) ?: default - } - - override suspend fun putByteArray(key: String, value: ByteArray) { - saveData(byteArrayPreferencesKey(key), value) - } - - override suspend fun deleteByteArray(key: String) { - deleteData(byteArrayPreferencesKey(key)) - } - - override suspend fun clear() { - context.dataStore.edit { preferences -> preferences.clear() } - } - - private suspend fun saveData(key: Preferences.Key, data: T) { - dataStore.edit { preferences -> - preferences[key] = data - } - } - - private suspend fun getData(key: Preferences.Key): T? { - return runCatching { - dataStore.data - .catch { emit(emptyPreferences()) } - .firstOrNull()?.get(key) - }.getOrNull() - } - - private suspend fun deleteData(key: Preferences.Key) { - dataStore.edit { preferences -> - preferences.remove(key) - } - } -} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorage.kt new file mode 100644 index 000000000..488afb9fd --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/KeyStorage.kt @@ -0,0 +1,85 @@ +package edu.stanford.spezi.modules.storage.local + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import edu.stanford.spezi.core.logging.speziLogger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import javax.inject.Inject + +interface KeyStorage { + fun create(tag: String, size: Int = DEFAULT_KEY_SIZE): Result + + fun retrieveKeyPair(tag: String): KeyPair? + fun retrievePrivateKey(tag: String): PrivateKey? + fun retrievePublicKey(tag: String): PublicKey? + + fun delete(tag: String) + fun deleteAll() + + companion object { + internal const val DEFAULT_KEY_SIZE = 2048 + internal const val PROVIDER = "AndroidKeyStore" + const val CIPHER_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding" + } +} + +internal class KeyStorageImpl @Inject constructor() : KeyStorage { + private val logger by speziLogger() + + private val keyStore: KeyStore by lazy { + KeyStore.getInstance(KeyStorage.PROVIDER).apply { load(null) } + } + + override fun create(tag: String, size: Int): Result = runCatching { + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + tag, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_ECB) + .setKeySize(size) + .setDigests(KeyProperties.DIGEST_SHA1) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .build() + val keyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA) + keyPairGenerator.initialize(keyGenParameterSpec) + keyPairGenerator.genKeyPair() + } + + override fun retrieveKeyPair(tag: String): KeyPair? = runCatching { + val publicKey = retrievePublicKey(tag) + val privateKey = retrievePrivateKey(tag) + if (publicKey != null && privateKey != null) { + KeyPair(publicKey, privateKey) + } else { + null + } + }.getOrNull() + + override fun retrievePrivateKey(tag: String): PrivateKey? = runCatching { + keyStore.getKey(tag, null) as? PrivateKey + }.onFailure { + logger.e(it) { "Failure during retrieval of private key with $tag" } + }.getOrNull() + + override fun retrievePublicKey(tag: String): PublicKey? = runCatching { + keyStore.getCertificate(tag)?.publicKey + }.onFailure { + logger.e(it) { "Failure during retrieval of public key with $tag" } + }.getOrNull() + + override fun delete(tag: String) = runCatching { + keyStore.deleteEntry(tag) + }.onFailure { + logger.e(it) { "Failed to delete entry with $tag" } + }.getOrDefault(Unit) + + override fun deleteAll() { + for (tag in keyStore.aliases()) { + delete(tag) + } + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt new file mode 100644 index 000000000..d35428128 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorage.kt @@ -0,0 +1,166 @@ +package edu.stanford.spezi.modules.storage.local + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import edu.stanford.spezi.core.coroutines.di.Dispatching +import edu.stanford.spezi.core.logging.speziLogger +import edu.stanford.spezi.modules.storage.di.Storage +import edu.stanford.spezi.modules.storage.local.LocalStorageSetting.Encrypted +import edu.stanford.spezi.modules.storage.local.LocalStorageSetting.EncryptedUsingKeyStore +import edu.stanford.spezi.modules.storage.local.LocalStorageSetting.Unencrypted +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.json.Json +import java.io.File +import java.nio.charset.StandardCharsets +import java.security.Key +import java.security.KeyPair +import javax.crypto.Cipher +import javax.inject.Inject + +interface LocalStorage { + + suspend fun store( + key: String, + value: T, + settings: LocalStorageSetting, + serializer: SerializationStrategy, + ) + + suspend fun store( + key: String, + value: T, + settings: LocalStorageSetting, + encoding: (T) -> ByteArray, + ) + + suspend fun read( + key: String, + settings: LocalStorageSetting, + serializer: DeserializationStrategy, + ): T? + + suspend fun read( + key: String, + settings: LocalStorageSetting, + decoding: (ByteArray) -> T, + ): T? + + suspend fun delete(key: String) +} + +internal class LocalStorageImpl @Inject constructor( + @ApplicationContext private val context: Context, + @Dispatching.IO private val ioDispatcher: CoroutineDispatcher, + private val keyStorage: KeyStorage, +) : LocalStorage { + + private val logger by speziLogger() + + override suspend fun store( + key: String, + value: T, + settings: LocalStorageSetting, + serializer: SerializationStrategy, + ) { + store( + key = key, + value = value, + settings = settings, + encoding = { instance -> + Json.encodeToString(serializer, instance).toByteArray(StandardCharsets.UTF_8) + } + ) + } + + override suspend fun store( + key: String, + value: T, + settings: LocalStorageSetting, + encoding: (T) -> ByteArray, + ) { + execute { + val jsonData = encoding(value) + val keys = keys(settings) + val writeData = if (keys == null) { + jsonData + } else { + getInitializedCipher(Cipher.ENCRYPT_MODE, keys.public).doFinal(jsonData) + } + file(key).writeBytes(writeData) + } + } + + override suspend fun read( + key: String, + settings: LocalStorageSetting, + serializer: DeserializationStrategy, + ): T? { + return read( + key = key, + settings = settings, + decoding = { data -> + Json.decodeFromString(serializer, String(data, StandardCharsets.UTF_8)) + } + ) + } + + override suspend fun read( + key: String, + settings: LocalStorageSetting, + decoding: (ByteArray) -> T, + ): T? = execute { + val keys = keys(settings) + val bytes = file(key).readBytes() + val data = if (keys == null) { + bytes + } else { + getInitializedCipher(Cipher.DECRYPT_MODE, keys.private).doFinal(bytes) + } + decoding(data) + } + + override suspend fun delete(key: String) { + execute { + val file = file(key) + if (file.exists()) { + file.delete() + } + } + } + + private fun file(key: String): File { + val directory = File(context.filesDir, "${Storage.STORAGE_FILE_PREFIX}LocalStorage") + if (!directory.exists()) { + directory.mkdirs() + } + return File(directory, "$key.localstorage") + } + + private fun keys(settings: LocalStorageSetting): KeyPair? { + return when (settings) { + is Unencrypted -> null + is Encrypted -> settings.keyPair + is EncryptedUsingKeyStore -> with(keyStorage) { + retrieveKeyPair(ANDROID_KEYSTORE_TAG) + ?: create(ANDROID_KEYSTORE_TAG).getOrThrow() + } + } + } + + private fun getInitializedCipher(mode: Int, key: Key): Cipher = + Cipher.getInstance(KeyStorage.CIPHER_TRANSFORMATION).apply { init(mode, key) } + + private suspend fun execute(block: suspend () -> T) = withContext(ioDispatcher) { + runCatching { block() } + .onFailure { + logger.e(it) { "Error executing local storage operation" } + }.getOrNull() + } + + private companion object { + const val ANDROID_KEYSTORE_TAG = "LocalStorageTag" + } +} diff --git a/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt new file mode 100644 index 000000000..fa99a2dc9 --- /dev/null +++ b/modules/storage/src/main/kotlin/edu/stanford/spezi/modules/storage/local/LocalStorageSetting.kt @@ -0,0 +1,9 @@ +package edu.stanford.spezi.modules.storage.local + +import java.security.KeyPair + +sealed interface LocalStorageSetting { + data object Unencrypted : LocalStorageSetting + data class Encrypted(val keyPair: KeyPair) : LocalStorageSetting + data object EncryptedUsingKeyStore : LocalStorageSetting +}