package com.peachpasswords.sync import android.content.Context import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton /** * The single place a sync attempt is allowed to end. Everything that can * terminate a run — the settings card's "sync now", the pull-to-refresh, the * relay reconnect loop, the service that could not start — reports here, and * the indicator light reads nothing else. * * Encrypted at rest like [SyncAlertStore]: [SyncOutcomeSummary.detail] can * carry the peer inventory, which is device names. */ @Singleton class SyncOutcomeStore @Inject constructor( @ApplicationContext context: Context, ) { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() private val prefs = EncryptedSharedPreferences.create( context, PREFS_FILE, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, ) private val _summary = MutableStateFlow(decodeSyncOutcome(prefs.getString(KEY_LAST, null))) val summary: StateFlow = _summary.asStateFlow() /** A run has started. Keeps the previous reason visible until one replaces it. */ fun begin() { _summary.value = _summary.value.copy(syncing = true) } /** * A run ended. The counterpart of the browser's `finish()`: no sync path may * terminate without passing through here. */ fun report(outcome: SyncOutcomeSummary) { val next = outcome.copy(syncing = false, finishedAt = System.currentTimeMillis()) prefs.edit().putString(KEY_LAST, encodeSyncOutcome(next)).apply() _summary.value = next } /** Rotating the sync identity forgets every peer, so the history means nothing. */ fun reset() { prefs.edit().remove(KEY_LAST).apply() _summary.value = SyncOutcomeSummary() } private companion object { const val PREFS_FILE = "peach_sync_outcome" const val KEY_LAST = "last_outcome" } } private val outcomeJson = Json { encodeDefaults = true; ignoreUnknownKeys = true } internal fun encodeSyncOutcome(summary: SyncOutcomeSummary): String = outcomeJson.encodeToString(SyncOutcomeSummary.serializer(), summary.copy(syncing = false)) /** * Never restores `syncing`: a process killed mid-sync would otherwise come back * claiming to be syncing forever, with nothing left running to finish it. */ internal fun decodeSyncOutcome(raw: String?): SyncOutcomeSummary { if (raw == null) return SyncOutcomeSummary() return runCatching { outcomeJson.decodeFromString(SyncOutcomeSummary.serializer(), raw).copy(syncing = false) }.getOrDefault(SyncOutcomeSummary()) }