chore: refactor the internal storage layer #40

Merged
reya merged 3 commits from refactor-storage into master 2026-07-09 07:53:23 +00:00
9 changed files with 82 additions and 142 deletions
Showing only changes of commit 27d38ed117 - Show all commits

View File

@@ -1,8 +1,13 @@
package su.reya.coop.storage
package su.reya.coop
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import javax.crypto.Cipher
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
data class SecretEntry(
val encrypted: String,
val iv: String
)
private val Context.dataStore by preferencesDataStore("secret_store")
data class SecretEntry(val encrypted: String, val iv: String)
class SecretCrypto {
private val keyAlias = "coop"
@@ -21,11 +25,9 @@ class SecretCrypto {
private val transformation = "AES/GCM/NoPadding"
fun encrypt(content: String): SecretEntry {
// Initialize cipher
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
// Encrypt content
val encrypted = cipher.doFinal(content.toByteArray())
val iv = cipher.iv
@@ -39,12 +41,10 @@ class SecretCrypto {
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
// Initialize cipher
val cipher = Cipher.getInstance(transformation)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
// Decrypt content
val plaintext = cipher.doFinal(encrypted)
return String(plaintext, StandardCharsets.UTF_8)
@@ -54,13 +54,9 @@ class SecretCrypto {
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
val existingKey = keyStore.getKey(keyAlias, null)
// Return existing key if available
if (existingKey is SecretKey) return existingKey
// Construct a new key generator
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
// Initialize key generation parameters
val spec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
@@ -70,9 +66,49 @@ class SecretCrypto {
.setKeySize(256)
.build()
// Generate a new key
keyGenerator.init(spec)
return keyGenerator.generateKey()
}
}
class AppStore(private val context: Context) : AppStorage {
private val crypto = SecretCrypto()
override suspend fun get(key: String): String? {
return context.dataStore.data.first()[stringPreferencesKey(key)]
}
override suspend fun set(key: String, value: String) {
context.dataStore.edit { it[stringPreferencesKey(key)] = value }
}
override suspend fun getSecret(key: String): String? {
val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
return crypto.decrypt(SecretEntry(encrypted, iv))
}
override suspend fun setSecret(key: String, value: String) {
val entry = crypto.encrypt(value)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
}
}
override suspend fun clear(key: String) {
context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey(key))
prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${key}_iv"))
}
}
override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first()
return prefs.contains(stringPreferencesKey(key)) ||
prefs.contains(stringPreferencesKey("${key}_encrypted"))
}
}

View File

@@ -13,9 +13,6 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import su.reya.coop.nostr.NostrManager
import su.reya.coop.storage.SecretStore
import su.reya.coop.storage.SettingStorage
import su.reya.coop.storage.settingsDataStore
import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel
@@ -28,16 +25,13 @@ class MainActivity : ComponentActivity() {
private val factory by lazy {
object : ViewModelProvider.Factory {
private val storage = AppStore(this@MainActivity)
private val nostrViewModel = NostrViewModel(NostrManager.instance)
private val chatViewModel = ChatViewModel(NostrManager.instance)
private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
private val secretStore = SecretStore(this@MainActivity)
private val settingStorage = SettingStorage(this@MainActivity.settingsDataStore)
private val nostrViewModel =
NostrViewModel(NostrManager.instance)
private val chatViewModel =
ChatViewModel(NostrManager.instance)
private val authViewModel =
AuthViewModel(NostrManager.instance, secretStore, settingStorage, androidSigner)
AuthViewModel(NostrManager.instance, storage, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {

View File

@@ -1,8 +0,0 @@
package su.reya.coop.storage
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")

View File

@@ -1,42 +0,0 @@
package su.reya.coop.storage
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
private val Context.dataStore by preferencesDataStore("secret_store")
class SecretStore(private val context: Context) : SecretStorage {
private val crypto = SecretCrypto()
override suspend fun set(key: String, value: String) {
val entry = crypto.encrypt(value)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
}
}
override suspend fun get(key: String): String? {
val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
return crypto.decrypt(SecretEntry(encrypted, iv))
}
override suspend fun clear(key: String) {
context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${key}_iv"))
}
}
override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first()
return prefs[stringPreferencesKey("${key}_encrypted")] != null
}
}

View File

@@ -0,0 +1,14 @@
package su.reya.coop
interface AppStorage {
// Plain text storage
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
// Encrypted storage
suspend fun getSecret(key: String): String?
suspend fun setSecret(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}

View File

@@ -1,11 +0,0 @@
package su.reya.coop.storage
import kotlinx.coroutines.flow.Flow
interface KeyValueStorage {
suspend fun get(key: String): String?
fun getFlow(key: String): Flow<String?>
suspend fun set(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}

View File

@@ -1,8 +0,0 @@
package su.reya.coop.storage
interface SecretStorage {
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}

View File

@@ -1,33 +0,0 @@
package su.reya.coop.storage
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class SettingStorage(
private val dataStore: DataStore<Preferences>
) : KeyValueStorage {
override suspend fun get(key: String): String? {
return dataStore.data.map { it[stringPreferencesKey(key)] }.first()
}
override fun getFlow(key: String): Flow<String?> {
return dataStore.data.map { it[stringPreferencesKey(key)] }
}
override suspend fun set(key: String, value: String) {
dataStore.edit { it[stringPreferencesKey(key)] = value }
}
override suspend fun clear(key: String) {
dataStore.edit { it.remove(stringPreferencesKey(key)) }
}
override suspend fun has(key: String): Boolean {
return dataStore.data.map { it.contains(stringPreferencesKey(key)) }.first()
}
}

View File

@@ -12,13 +12,12 @@ import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.AppStorage
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import su.reya.coop.storage.KeyValueStorage
import su.reya.coop.storage.SecretStorage
import kotlin.time.Duration.Companion.seconds
data class AuthState(
@@ -28,8 +27,7 @@ data class AuthState(
class AuthViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage,
private val settingStorage: KeyValueStorage,
private val storage: AppStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() {
private val mediaRepository = MediaRepository()
@@ -53,7 +51,7 @@ class AuthViewModel(
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
val dismissed = settingStorage.get(KEY_BANNER_DISMISSED) == "true"
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
@@ -62,7 +60,7 @@ class AuthViewModel(
viewModelScope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
secretStore.get(KEY_USER_SIGNER)
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
@@ -96,8 +94,8 @@ class AuthViewModel(
showError("Logout encountered an error: ${e.message}")
} finally {
// Clear credentials from persistent storage
secretStore.clear(KEY_USER_SIGNER)
settingStorage.clear(KEY_BANNER_DISMISSED)
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
// Call cleanup callback (e.g. to reset other ViewModels)
onLogout()
// Reset local states
@@ -108,18 +106,18 @@ class AuthViewModel(
fun dismissNotificationBanner() {
viewModelScope.launch {
settingStorage.set(KEY_BANNER_DISMISSED, "true")
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get(KEY_APP_KEYS)
val secret = storage.getSecret(KEY_APP_KEYS)
// If app keys are already stored, use them
if (secret != null) return Keys.parse(secret)
// Generate new app keys and save to the secret storage
val keys = Keys.generate()
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
return keys
}
@@ -168,7 +166,7 @@ class AuthViewModel(
// Update signer
nostr.setSigner(signer)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret)
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states
_state.update { it.copy(signerRequired = false) }
}
@@ -197,7 +195,7 @@ class AuthViewModel(
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
secretStore.set(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
storage.setSecret(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
// Update local states
_state.update { it.copy(signerRequired = false) }
}
@@ -220,7 +218,7 @@ class AuthViewModel(
// Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret)
storage.setSecret(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false) }
}