SyncOutcomeStore.kt

3.3 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(52        reason: SyncOutcomeReason,53        detail: String? = null,54        entriesSent: Int = 0,55        entriesReceived: Int = 0,56    ) {57        val next = SyncOutcomeSummary(58            reason = reason,59            detail = detail,60            entriesSent = entriesSent,61            entriesReceived = entriesReceived,62            finishedAt = System.currentTimeMillis(),63        )64        prefs.edit().putString(KEY_LAST, encodeSyncOutcome(next)).apply()65        _summary.value = next66    }6768    /** Rotating the sync identity forgets every peer, so the history means nothing. */69    fun reset() {70        prefs.edit().remove(KEY_LAST).apply()71        _summary.value = SyncOutcomeSummary()72    }7374    private companion object {75        const val PREFS_FILE = "peach_sync_outcome"76        const val KEY_LAST = "last_outcome"77    }78}7980private val outcomeJson = Json { encodeDefaults = true; ignoreUnknownKeys = true }8182internal fun encodeSyncOutcome(summary: SyncOutcomeSummary): String =83    outcomeJson.encodeToString(SyncOutcomeSummary.serializer(), summary.copy(syncing = false))8485/**86 * Never restores `syncing`: a process killed mid-sync would otherwise come back87 * claiming to be syncing forever, with nothing left running to finish it.88 */89internal fun decodeSyncOutcome(raw: String?): SyncOutcomeSummary {90    if (raw == null) return SyncOutcomeSummary()91    return runCatching {92        outcomeJson.decodeFromString(SyncOutcomeSummary.serializer(), raw).copy(syncing = false)93    }.getOrDefault(SyncOutcomeSummary())94}