diff --git a/app/src/main/java/chat/vojo/proxy/data/ConfigStore.kt b/app/src/main/java/chat/vojo/proxy/data/ConfigStore.kt index ad8146e..4a74421 100644 --- a/app/src/main/java/chat/vojo/proxy/data/ConfigStore.kt +++ b/app/src/main/java/chat/vojo/proxy/data/ConfigStore.kt @@ -1,6 +1,7 @@ package chat.vojo.proxy.data import android.content.Context +import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.serialization.SerializationException import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -32,7 +34,25 @@ data class ProxyState( private val Context.dataStore: DataStore by preferencesDataStore(name = "vojo_proxy") private val STATE_KEY = stringPreferencesKey("state") +// Last known-good encoded state, snapshotted before each successful write. If STATE_KEY +// later fails to decode (disk corruption / incompatible schema), both the read path and +// update() recover from this instead of silently wiping every saved server. +private val STATE_BACKUP_KEY = stringPreferencesKey("state_backup") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } +private const val TAG = "ConfigStore" + +/** + * Decode persisted state, treating only a genuine decode failure (corruption / incompatible + * schema) as "corrupt" → null. Error/OOM propagate so a transient failure never masquerades + * as missing data and triggers a wipe. + */ +private fun decodeStateOrNull(raw: String): ProxyState? = try { + json.decodeFromString(raw) +} catch (e: SerializationException) { + null +} catch (e: IllegalArgumentException) { + null +} /** Single source of truth for persisted configuration, backed by Preferences DataStore. */ class ConfigStore(context: Context) { @@ -43,7 +63,8 @@ class ConfigStore(context: Context) { // rethrow anything else so a real bug isn't hidden as "no data". .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } .map { prefs -> - prefs[STATE_KEY]?.let { runCatching { json.decodeFromString(it) }.getOrNull() } + prefs[STATE_KEY]?.let { decodeStateOrNull(it) } + ?: prefs[STATE_BACKUP_KEY]?.let { decodeStateOrNull(it) } ?: ProxyState() } @@ -51,10 +72,22 @@ class ConfigStore(context: Context) { suspend fun update(transform: (ProxyState) -> ProxyState) { store.edit { prefs -> - val cur = prefs[STATE_KEY]?.let { - runCatching { json.decodeFromString(it) }.getOrNull() - } ?: ProxyState() - prefs[STATE_KEY] = json.encodeToString(transform(cur)) + val raw = prefs[STATE_KEY] + val cur = raw?.let { decodeStateOrNull(it) } + val base = if (cur != null) { + // Current state is good: snapshot it as last-known-good before overwriting, + // so a future on-disk corruption of STATE_KEY can be auto-recovered on read. + prefs[STATE_BACKUP_KEY] = raw + cur + } else { + // STATE_KEY present but undecodable (disk corruption or a forward-incompatible + // schema after a downgrade). Recover from the last known-good snapshot if we + // have one — the original bug here wiped every saved server. Leave the backup + // untouched so the good snapshot survives. Start fresh only if there is none. + if (raw != null) Log.w(TAG, "persisted state did not decode — recovering from last-known-good backup") + prefs[STATE_BACKUP_KEY]?.let { decodeStateOrNull(it) } ?: ProxyState() + } + prefs[STATE_KEY] = json.encodeToString(transform(base)) } }