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.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @Serializable data class PendingVouchedDevice( val deviceId: String, val deviceName: String, val deviceType: String, val longTermPublicKeyHex: String, val signingPublicKeyHex: String, val wsPort: Int? = null, val pairedAt: Long, val receivedAt: Long, val vouchedByDeviceId: String, ) interface PendingVouchedRegistry { fun getAll(): List fun upsert(device: PendingVouchedDevice) fun remove(id: String) /** * Remove a set of pending introductions synchronously and report failure. * Implementations must linearize the whole batch; the default exists only * for small in-memory test registries that have no durable store. */ fun removeBatch(ids: Set): Boolean { return runCatching { ids.forEach(::remove) true }.getOrDefault(false) } /** Restore a previously captured pending set after a later trust commit fails. */ fun restoreBatch(devices: List): Boolean { return runCatching { devices.forEach(::upsert) true }.getOrDefault(false) } } @Singleton class PendingVouchedDeviceStore @Inject constructor( @ApplicationContext context: Context, ) : PendingVouchedRegistry { 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 listSerializer = ListSerializer(PendingVouchedDevice.serializer()) override fun getAll(): List { val raw = prefs.getString(KEY_PENDING, null) ?: return emptyList() return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList()) } fun get(id: String): PendingVouchedDevice? = getAll().firstOrNull { it.deviceId == id } override fun upsert(device: PendingVouchedDevice) { val next = (getAll().filterNot { it.deviceId == device.deviceId } + device).takeLast(MAX_PENDING) persist(next) } override fun remove(id: String) { persist(getAll().filterNot { it.deviceId == id }) } override fun removeBatch(ids: Set): Boolean { if (ids.isEmpty()) return true val before = getAll() val after = before.filterNot { it.deviceId in ids } return commitWithRollback(before, after, ::commitList) } override fun restoreBatch(devices: List): Boolean { if (devices.isEmpty()) return true val before = getAll() val restoredIds = devices.map { it.deviceId }.toSet() val after = (before.filterNot { it.deviceId in restoredIds } + devices).takeLast(MAX_PENDING) return commitWithRollback(before, after, ::commitList) } fun clear() { persist(emptyList()) } fun observeAll(): Flow> = callbackFlow { trySend(getAll()) val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == KEY_PENDING || key == null) trySend(getAll()) } prefs.registerOnSharedPreferenceChangeListener(listener) awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } } private fun persist(devices: List) { prefs.edit() .putString(KEY_PENDING, json.encodeToString(listSerializer, devices)) // Pending introductions participate in fail-closed trust ordering; // an outstanding async apply could replay a stale row after a // synchronous revocation/removal commit. Keep every write in this // store synchronous so removal cannot be overwritten by a queued // disk task after a crash. .commit() } /** Synchronous writer used by the fail-closed batch/rollback paths. */ private fun commitList(devices: List): Boolean = prefs.edit() .putString(KEY_PENDING, json.encodeToString(listSerializer, devices)) .commit() private companion object { const val PREFS_FILE = "peach_pending_vouched_devices" const val KEY_PENDING = "pending_vouched_devices" const val MAX_PENDING = 50 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } } } /** * Apply a durable state transition, explicitly restoring the prior in-process * value when the forward commit reports failure. Android's SharedPreferences * contract normally leaves the old value in memory on `commit()==false`, but * relying on that implementation detail made failure injection indistinguishable * from a successful transaction. Both registry stores use this helper. */ internal fun commitWithRollback( before: T, after: T, commit: (T) -> Boolean, ): Boolean { val applied = runCatching { commit(after) }.getOrDefault(false) if (applied) return true runCatching { commit(before) } return false }