PairedDeviceStore.kt

5.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.channels.awaitClose8import kotlinx.coroutines.flow.Flow9import kotlinx.coroutines.flow.callbackFlow10import kotlinx.serialization.builtins.ListSerializer11import kotlinx.serialization.json.Json12import javax.inject.Inject13import javax.inject.Singleton1415/**16 * Persistent registry of paired devices, encrypted at rest with17 * EncryptedSharedPreferences (same construction as DuressConfigStore /18 * LicenseRepository, v2.1.36). The whole list lives under a single key as a19 * kotlinx.serialization JSON array of the REAL [PairedDevice] (sync package).20 *21 * Reconciliation with the C7 brief:22 *  - The brief invents a fresh `PairedDevice(id, name, publicKey: ByteArray,23 *    lastSeen, trustStatus, lastSyncAt)`. That type does NOT exist in this24 *    shape. The real [PairedDevice] (PairedDevice.kt) keys on `deviceId`, holds25 *    the public key as `longTermPublicKeyHex` (hex, not ByteArray), and tracks26 *    `pairedAt` / `lastSyncedAt` / `lastKnownIp`. This store reuses it verbatim;27 *    Base64 publicKey storage is therefore unnecessary — the hex field is the28 *    canonical wire form already produced by ChirpPairing.frameToPairedDevice.29 *  - The brief's `TrustStatus` enum does not exist either. The real model uses a30 *    nullable `trustSource: String?` (e.g. "direct", "manifest"). Trust state is31 *    expressed there. See C7-INTEGRATION.md for the trust-status delta.32 *  - `updateLastSync` here maps to the real `lastSyncedAt` field. There is no33 *    `lastSeen` field on the real PairedDevice; [updateLastSeen] folds into34 *    `lastKnownIp` refresh + `lastSyncedAt` is left untouched. It is kept as a35 *    no-op-friendly hook so callers from the brief compile; see integration note.36 */37@Singleton38class PairedDeviceStore @Inject constructor(39    @ApplicationContext context: Context,40) : PairedDeviceRegistry {41    private val masterKey = MasterKey.Builder(context)42        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)43        .build()4445    private val prefs = EncryptedSharedPreferences.create(46        context,47        PREFS_FILE,48        masterKey,49        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,50        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,51    )5253    private val listSerializer = ListSerializer(PairedDevice.serializer())5455    override fun getAll(): List<PairedDevice> {56        val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()57        return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())58    }5960    override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }6162    override fun getByPubkey(pubkeyHex: String): PairedDevice? =63        getAll().firstOrNull {64            it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true65        }6667    /** Insert or replace by [PairedDevice.deviceId]. */68    override fun upsert(device: PairedDevice) {69        val next = getAll().filterNot { it.deviceId == device.deviceId } + device70        persist(next)71    }7273    fun remove(id: String) {74        persist(getAll().filterNot { it.deviceId == id })75    }7677    fun clear() {78        prefs.edit().remove(KEY_DEVICES).apply()79    }8081    /**82     * Refresh the device's `lastKnownIp` (the closest real analogue to the83     * brief's `lastSeen`). No-op when the id is unknown or [ip] is null.84     */85    fun updateLastSeen(id: String, ip: String? = null) {86        val current = get(id) ?: return87        if (ip == null) return88        upsert(current.copy(lastKnownIp = ip))89    }9091    fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {92        val current = get(id) ?: return93        upsert(current.copy(lastSyncedAt = at))94    }9596    /**97     * Reconnect prefers each pair's relay over the global fallback. Rewrite all98     * established pairs when the user changes the mesh relay in Settings.99     */100    fun setRelayUrlForAll(relayUrl: String) {101        persist(withRelayUrl(getAll(), relayUrl))102    }103104    /**105     * Observe the full list. EncryptedSharedPreferences supports106     * OnSharedPreferenceChangeListener, so this emits on every persist —107     * including writes from other components in-process.108     */109    fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {110        trySend(getAll())111        val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->112            if (key == KEY_DEVICES || key == null) trySend(getAll())113        }114        prefs.registerOnSharedPreferenceChangeListener(listener)115        awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }116    }117118    private fun persist(devices: List<PairedDevice>) {119        prefs.edit()120            .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))121            .apply()122    }123124    private companion object {125        const val PREFS_FILE = "peach_paired_devices"126        const val KEY_DEVICES = "paired_devices"127        val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }128    }129}130131internal fun withRelayUrl(132    devices: List<PairedDevice>,133    relayUrl: String,134): List<PairedDevice> = devices.map { it.copy(relayUrl = relayUrl) }