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 fun isRevoked(deviceId: String): Boolean =87 listRevocations().any { it.deviceId == deviceId }8889 90919293 fun revoke(94 deviceId: String,95 revokedByDeviceId: String,96 nowMs: Long = System.currentTimeMillis(),97 ): DeviceRevocation? {98 val existing = listRevocations()99 if (existing.any { it.deviceId == deviceId }) return null100 val record = DeviceRevocation(101 deviceId = deviceId,102 longTermPublicKeyHex = get(deviceId)?.longTermPublicKeyHex ?: "0".repeat(64),103 revokedAt = nowMs,104 revokedByDeviceId = revokedByDeviceId,105 )106 persistRevocations(existing + record)107 remove(deviceId)108 return record109 }110111 112113114115 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {116 val existing = listRevocations()117 val known = existing.map { it.deviceId }.toSet()118 val fresh = incoming.filterNot { known.contains(it.deviceId) }119 if (fresh.isEmpty()) return emptyList()120 persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))121 return fresh.map { it.deviceId }122 }123124 125126127128129 override fun clearRevocation(deviceId: String) {130 val existing = listRevocations()131 val filtered = existing.filterNot { it.deviceId == deviceId }132 if (filtered.size != existing.size) persistRevocations(filtered)133 }134135 fun clear() {136 prefs.edit().remove(KEY_DEVICES).apply()137 }138139 140141142143 fun updateLastSeen(id: String, ip: String? = null) {144 val current = get(id) ?: return145 if (ip == null) return146 upsert(current.copy(lastKnownIp = ip))147 }148149 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {150 val current = get(id) ?: return151 upsert(current.copy(lastSyncedAt = at))152 }153154 155156157158 fun setRelayUrlForAll(relayUrl: String) {159 persist(withRelayUrl(getAll(), relayUrl))160 }161162 163164165166167 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {168 trySend(getAll())169 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->170 if (key == KEY_DEVICES || key == null) trySend(getAll())171 }172 prefs.registerOnSharedPreferenceChangeListener(listener)173 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }174 }175176 private fun persistRevocations(records: List<DeviceRevocation>) {177 prefs.edit()178 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))179 .apply()180 }181182 private fun persist(devices: List<PairedDevice>) {183 prefs.edit()184 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))185 .apply()186 }187188 private companion object {189 const val PREFS_FILE = "peach_paired_devices"190 const val KEY_DEVICES = "paired_devices"191 const val KEY_REVOCATIONS = "device_revocations"192 193 const val MAX_REVOCATIONS = 500194 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }195 }196}197198internal fun withRelayUrl(199 devices: List<PairedDevice>,200 relayUrl: String,201): List<PairedDevice> = devices.map { it.copy(relayUrl = relayUrl) }