diff --git a/MobileSdk/build.gradle.kts b/MobileSdk/build.gradle.kts index 830db05..62fb571 100644 --- a/MobileSdk/build.gradle.kts +++ b/MobileSdk/build.gradle.kts @@ -131,6 +131,7 @@ dependencies { implementation("androidx.camera:camera-mlkit-vision:1.3.0-alpha06") implementation("com.google.android.gms:play-services-mlkit-text-recognition:19.0.0") /* End UI dependencies */ + implementation("androidx.datastore:datastore-preferences:1.1.1") testImplementation("junit:junit:4.13.2") androidTestImplementation("com.android.support.test:runner:1.0.2") androidTestImplementation("com.android.support.test.espresso:espresso-core:3.0.2") diff --git a/MobileSdk/src/main/java/com/spruceid/mobile/sdk/StorageManager.kt b/MobileSdk/src/main/java/com/spruceid/mobile/sdk/StorageManager.kt new file mode 100644 index 0000000..a95e5fb --- /dev/null +++ b/MobileSdk/src/main/java/com/spruceid/mobile/sdk/StorageManager.kt @@ -0,0 +1,158 @@ +import android.content.Context +import android.util.Base64 +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.* +import androidx.datastore.preferences.preferencesDataStoreFile +import com.spruceid.mobile.sdk.KeyManager +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map + +private class DataStoreSingleton private constructor(context: Context) { + val dataStore: DataStore = store(context, "default") + + companion object { + private const val FILENAME_PREFIX = "sprucekit/datastore/" + + private fun location(context: Context, file: String) = + context.preferencesDataStoreFile(FILENAME_PREFIX + file.lowercase()) + + private fun store(context: Context, file: String): DataStore = + PreferenceDataStoreFactory.create(produceFile = { location(context, file) }) + + @Volatile + private var instance: DataStoreSingleton? = null + + fun getInstance(context: Context) = + instance + ?: synchronized(this) { + instance ?: DataStoreSingleton(context).also { + instance = it + } + } + } +} + +object StorageManager { + private const val B64_FLAGS = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + private const val KEY_NAME = "sprucekit/datastore" + + /// Function: encrypt + /// + /// Encrypts the given string. + /// + /// Arguments: + /// value - The string value to be encrypted + private fun encrypt(value: String): Result { + val keyManager = KeyManager() + try { + if (!keyManager.keyExists(KEY_NAME)) { + keyManager.generateEncryptionKey(KEY_NAME) + } + val encrypted = keyManager.encryptPayload(KEY_NAME, value.toByteArray()) + val iv = Base64.encodeToString(encrypted.first, B64_FLAGS) + val bytes = Base64.encodeToString(encrypted.second, B64_FLAGS) + val res = "$iv;$bytes".toByteArray() + return Result.success(res) + } catch (e: Exception) { + return Result.failure(e) + } + } + + /// Function: decrypt + /// + /// Decrypts the given byte array. + /// + /// Arguments: + /// value - The byte array to be decrypted + private fun decrypt(value: ByteArray): Result { + val keyManager = KeyManager() + try { + if (!keyManager.keyExists(KEY_NAME)) { + return Result.failure(Exception("Cannot retrieve values before creating encryption keys")) + } + val decoded = value.decodeToString().split(";") + assert(decoded.size == 2) + val iv = Base64.decode(decoded.first(), B64_FLAGS) + val encrypted = Base64.decode(decoded.last(), B64_FLAGS) + val decrypted = + keyManager.decryptPayload(KEY_NAME, iv, encrypted) + ?: return Result.failure(Exception("Failed to decrypt value")) + return Result.success(decrypted.decodeToString()) + } catch (e: Exception) { + return Result.failure(e) + } + } + + /// Function: add + /// + /// Adds a key-value pair to storage. Should the key already exist, the value will be + /// replaced. + /// + /// Arguments: + /// context - The application context to be able to access the DataStore + /// key - The key to add + /// value - The value to add under the key + suspend fun add(context: Context, key: String, value: String): Result { + val storeKey = byteArrayPreferencesKey(key) + val storeValue = encrypt(value) + + if (storeValue.isFailure) { + return Result.failure(Exception("Failed to encrypt value for storage")) + } + + DataStoreSingleton.getInstance(context).dataStore.edit { store -> + store[storeKey] = storeValue.getOrThrow() + } + + return Result.success(Unit) + } + + /// Function: get + /// + /// Retrieves the value from storage identified by key. + /// + /// Arguments: + /// context - The application context to be able to access the DataStore + /// key - The key to retrieve + suspend fun get(context: Context, key: String): Result { + val storeKey = byteArrayPreferencesKey(key) + return DataStoreSingleton.getInstance(context) + .dataStore + .data + .map { store -> + try { + store[storeKey]?.let { v -> + val storeValue = decrypt(v) + when { + storeValue.isSuccess -> Result.success(storeValue.getOrThrow()) + storeValue.isFailure -> Result.failure(storeValue.exceptionOrNull()!!) + else -> Result.failure(Exception("Failed to decrypt value for storage")) + } + } + ?: Result.success(null) + } catch (e: Exception) { + Result.failure(e) + } + } + .catch { exception -> emit(Result.failure(exception)) } + .first() + } + + /// Function: remove + /// + /// Removes a key-value pair from storage by key. + /// + /// Arguments: + /// context - The application context to be able to access the DataStore + /// key - The key to remove + suspend fun remove(context: Context, key: String): Result { + val storeKey = stringPreferencesKey(key) + DataStoreSingleton.getInstance(context).dataStore.edit { store -> + if (store.contains(storeKey)) { + store.remove(storeKey) + } + } + return Result.success(Unit) + } +}