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.Singleton141516171819202122232425262728293031323334353637@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())54 private val revocationSerializer = ListSerializer(DeviceRevocation.serializer())5556 override fun getAll(): List<PairedDevice> {57 val raw = prefs.getString(KEY_DEVICES, null) ?: return emptyList()58 return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())59 }6061 override fun get(id: String): PairedDevice? = getAll().firstOrNull { it.deviceId == id }6263 override fun getByPubkey(pubkeyHex: String): PairedDevice? =64 getAll().firstOrNull {65 it.longTermPublicKeyHex?.let { key -> CryptoCompare.hex(key, pubkeyHex) } == true66 }6768 69 override fun upsert(device: PairedDevice) {70 val next = getAll().filterNot { it.deviceId == device.deviceId } + device71 persist(next)72 }7374 override fun remove(deviceId: String) {75 persist(getAll().filterNot { it.deviceId == deviceId })76 }7778 7980 override fun listRevocations(): List<DeviceRevocation> {81 val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()82 return runCatching { json.decodeFromString(revocationSerializer, raw) }83 .getOrDefault(emptyList())84 }8586 878889909192 fun revoke(93 deviceId: String,94 revokedByDeviceId: String,95 nowMs: Long = System.currentTimeMillis(),96 ): DeviceRevocation? {97 val existing = listRevocations()98 if (existing.any { it.deviceId == deviceId }) return null99 val record = DeviceRevocation(100 deviceId = deviceId,101 longTermPublicKeyHex = get(deviceId)?.longTermPublicKeyHex ?: "0".repeat(64),102 revokedAt = nowMs,103 revokedByDeviceId = revokedByDeviceId,104 )105 persistRevocations(existing + record)106 remove(deviceId)107 return record108 }109110 111112113114 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {115 val existing = listRevocations()116 val known = existing.map { it.deviceId }.toSet()117 val fresh = incoming.filterNot { known.contains(it.deviceId) }118 if (fresh.isEmpty()) return emptyList()119 persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))120 return fresh.map { it.deviceId }121 }122123 124125126127128 override fun clearRevocation(deviceId: String) {129 val existing = listRevocations()130 val filtered = existing.filterNot { it.deviceId == deviceId }131 if (filtered.size != existing.size) persistRevocations(filtered)132 }133134 fun clear() {135 prefs.edit().remove(KEY_DEVICES).apply()136 }137138 139140141142 fun updateLastSeen(id: String, ip: String? = null) {143 val current = get(id) ?: return144 if (ip == null) return145 upsert(current.copy(lastKnownIp = ip))146 }147148 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {149 val current = get(id) ?: return150 upsert(current.copy(lastSyncedAt = at))151 }152153 154155156157 fun setRelayUrlForAll(relayUrl: String) {158 persist(withRelayUrl(getAll(), relayUrl))159 }160161 162163164165166 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {167 trySend(getAll())168 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->169 if (key == KEY_DEVICES || key == null) trySend(getAll())170 }171 prefs.registerOnSharedPreferenceChangeListener(listener)172 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }173 }174175 private fun persistRevocations(records: List<DeviceRevocation>) {176 prefs.edit()177 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))178 .apply()179 }180181 private fun persist(devices: List<PairedDevice>) {182 prefs.edit()183 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))184 .apply()185 }186187 private companion object {188 const val PREFS_FILE = "peach_paired_devices"189 const val KEY_DEVICES = "paired_devices"190 const val KEY_REVOCATIONS = "device_revocations"191 192 const val MAX_REVOCATIONS = 500193 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }194 }195}196197internal fun withRelayUrl(198 devices: List<PairedDevice>,199 relayUrl: String,200): List<PairedDevice> = devices.map { it.copy(relayUrl = relayUrl) }