SyncOutcomeStore.kt

3.1 KB · raw

1package com.peachpasswords.sync23import android.content.Context4import androidx.security.crypto.EncryptedSharedPreferences5import androidx.security.crypto.MasterKey6import dagger.hilt.android.qualifiers.ApplicationContext7import kotlinx.coroutines.flow.MutableStateFlow8import kotlinx.coroutines.flow.StateFlow9import kotlinx.coroutines.flow.asStateFlow10import kotlinx.serialization.json.Json11import javax.inject.Inject12import javax.inject.Singleton1314/**15 * The single place a sync attempt is allowed to end. Everything that can16 * terminate a run — the settings card's "sync now", the pull-to-refresh, the17 * relay reconnect loop, the service that could not start — reports here, and18 * the indicator light reads nothing else.19 *20 * Encrypted at rest like [SyncAlertStore]: [SyncOutcomeSummary.detail] can21 * carry the peer inventory, which is device names.22 */23@Singleton24class SyncOutcomeStore @Inject constructor(25    @ApplicationContext context: Context,26) {27    private val masterKey = MasterKey.Builder(context)28        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)29        .build()3031    private val prefs = EncryptedSharedPreferences.create(32        context,33        PREFS_FILE,34        masterKey,35        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,36        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,37    )3839    private val _summary = MutableStateFlow(decodeSyncOutcome(prefs.getString(KEY_LAST, null)))40    val summary: StateFlow<SyncOutcomeSummary> = _summary.asStateFlow()4142    /** A run has started. Keeps the previous reason visible until one replaces it. */43    fun begin() {44        _summary.value = _summary.value.copy(syncing = true)45    }4647    /**48     * A run ended. The counterpart of the browser's `finish()`: no sync path may49     * terminate without passing through here.50     */51    fun report(outcome: SyncOutcomeSummary) {52        val next = outcome.copy(syncing = false, finishedAt = System.currentTimeMillis())53        prefs.edit().putString(KEY_LAST, encodeSyncOutcome(next)).apply()54        _summary.value = next55    }5657    /** Rotating the sync identity forgets every peer, so the history means nothing. */58    fun reset() {59        prefs.edit().remove(KEY_LAST).apply()60        _summary.value = SyncOutcomeSummary()61    }6263    private companion object {64        const val PREFS_FILE = "peach_sync_outcome"65        const val KEY_LAST = "last_outcome"66    }67}6869private val outcomeJson = Json { encodeDefaults = true; ignoreUnknownKeys = true }7071internal fun encodeSyncOutcome(summary: SyncOutcomeSummary): String =72    outcomeJson.encodeToString(SyncOutcomeSummary.serializer(), summary.copy(syncing = false))7374/**75 * Never restores `syncing`: a process killed mid-sync would otherwise come back76 * claiming to be syncing forever, with nothing left running to finish it.77 */78internal fun decodeSyncOutcome(raw: String?): SyncOutcomeSummary {79    if (raw == null) return SyncOutcomeSummary()80    return runCatching {81        outcomeJson.decodeFromString(SyncOutcomeSummary.serializer(), raw).copy(syncing = false)82    }.getOrDefault(SyncOutcomeSummary())83}