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 currentDevices = getAll()98 val currentRevocations = listRevocations()99 val mutation = planDeviceRevocation(100 devices = currentDevices,101 revocations = currentRevocations,102 deviceId = deviceId,103 revokedByDeviceId = revokedByDeviceId,104 nowMs = nowMs,105 )106 if (mutation.revocations !== currentRevocations) {107 persistRevocations(mutation.revocations)108 }109 if (mutation.devices != currentDevices) {110 persist(mutation.devices)111 }112 return mutation.added113 }114115 116117118119 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {120 val existing = listRevocations()121 val known = existing.map { it.deviceId }.toSet()122 val fresh = incoming.filterNot { known.contains(it.deviceId) }123 if (fresh.isEmpty()) return emptyList()124 persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))125 return fresh.map { it.deviceId }126 }127128 129130131132133 override fun clearRevocation(deviceId: String) {134 val existing = listRevocations()135 val filtered = existing.filterNot { it.deviceId == deviceId }136 if (filtered.size != existing.size) persistRevocations(filtered)137 }138139 fun clear() {140 prefs.edit()141 .remove(KEY_DEVICES)142 .remove(KEY_REVOCATIONS)143 .apply()144 }145146 147148149150 fun updateLastSeen(id: String, ip: String? = null) {151 val current = get(id) ?: return152 if (ip == null) return153 upsert(current.copy(lastKnownIp = ip))154 }155156 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {157 val current = get(id) ?: return158 upsert(current.copy(lastSyncedAt = at))159 }160161 162163164165166167 fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {168 val current = getAll()169 val next = withRelaySetting(current, mode, storedRelayUrl)170 if (next != current) persist(next)171 }172173 174175176177178 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {179 trySend(getAll())180 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->181 if (key == KEY_DEVICES || key == null) trySend(getAll())182 }183 prefs.registerOnSharedPreferenceChangeListener(listener)184 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }185 }186187 private fun persistRevocations(records: List<DeviceRevocation>) {188 prefs.edit()189 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))190 .apply()191 }192193 private fun persist(devices: List<PairedDevice>) {194 prefs.edit()195 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))196 .apply()197 }198199 private companion object {200 const val PREFS_FILE = "peach_paired_devices"201 const val KEY_DEVICES = "paired_devices"202 const val KEY_REVOCATIONS = "device_revocations"203 204 const val MAX_REVOCATIONS = 500205 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }206 }207}208209internal data class DeviceRevocationMutation(210 val devices: List<PairedDevice>,211 val revocations: List<DeviceRevocation>,212 val added: DeviceRevocation?,213)214215216217218219220221222internal fun planDeviceRevocation(223 devices: List<PairedDevice>,224 revocations: List<DeviceRevocation>,225 deviceId: String,226 revokedByDeviceId: String,227 nowMs: Long,228): DeviceRevocationMutation {229 val remaining = devices.filterNot { it.deviceId == deviceId }230 if (revocations.any { it.deviceId == deviceId }) {231 return DeviceRevocationMutation(remaining, revocations, null)232 }233 val device = devices.firstOrNull { it.deviceId == deviceId }234 val record = DeviceRevocation(235 deviceId = deviceId,236 longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),237 revokedAt = nowMs,238 revokedByDeviceId = revokedByDeviceId,239 )240 return DeviceRevocationMutation(remaining, revocations + record, record)241}242243244245246247248249internal fun withRelaySetting(250 devices: List<PairedDevice>,251 mode: SyncMode,252 storedRelayUrl: String,253): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {254 is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }255 is RelayDecision.Refuse -> devices256}