fix(data): snapshot last-known-good config and recover on decode failure instead of wiping

This commit is contained in:
heaven 2026-06-16 12:30:53 +03:00
parent a5681608ed
commit fbeb15be88

View file

@ -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<Preferences> 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<ProxyState>(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<ProxyState>(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<ProxyState>(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))
}
}