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 79808182838485 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {86 val priorPrefs = readPrefsState()87 val currentDevices = getAll()88 val currentRevocations = listRevocations()89 val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()90 val freshRevocations = batch.incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }91 val nextRevocations = (currentRevocations + freshRevocations).takeLast(MAX_REVOCATIONS)92 val removed = batch.removals93 val nextDevices = currentDevices94 .filterNot { removed.contains(it.deviceId) }95 .toMutableList()96 batch.upserts.forEach { device ->97 nextDevices.removeAll { it.deviceId == device.deviceId }98 nextDevices += device99 }100101 val nextPrefs = RegistryPrefsState(102 devices = json.encodeToString(listSerializer, nextDevices),103 revocations = json.encodeToString(revocationSerializer, nextRevocations),104 )105 106 107 108 return commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState)109 }110111 112113 override fun listRevocations(): List<DeviceRevocation> {114 val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()115 return runCatching { json.decodeFromString(revocationSerializer, raw) }116 .getOrDefault(emptyList())117 }118119 120121122123124125 fun revoke(126 deviceId: String,127 revokedByDeviceId: String,128 nowMs: Long = System.currentTimeMillis(),129 ): DeviceRevocation? {130 val currentDevices = getAll()131 val currentRevocations = listRevocations()132 val mutation = planDeviceRevocation(133 devices = currentDevices,134 revocations = currentRevocations,135 deviceId = deviceId,136 revokedByDeviceId = revokedByDeviceId,137 nowMs = nowMs,138 )139 if (mutation.revocations !== currentRevocations) {140 persistRevocations(mutation.revocations)141 }142 if (mutation.devices != currentDevices) {143 persist(mutation.devices)144 }145 return mutation.added146 }147148 149150151152 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {153 val existing = listRevocations()154 val known = existing.map { it.deviceId }.toSet()155 val fresh = incoming.filterNot { known.contains(it.deviceId) }156 if (fresh.isEmpty()) return emptyList()157 persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))158 return fresh.map { it.deviceId }159 }160161 162163164165166 override fun clearRevocation(deviceId: String) {167 val existing = listRevocations()168 val filtered = existing.filterNot { it.deviceId == deviceId }169 if (filtered.size != existing.size) persistRevocations(filtered)170 }171172 fun clear() {173 prefs.edit()174 .remove(KEY_DEVICES)175 .remove(KEY_REVOCATIONS)176 .apply()177 }178179 180181182183 fun updateLastSeen(id: String, ip: String? = null) {184 val current = get(id) ?: return185 if (ip == null) return186 upsert(current.copy(lastKnownIp = ip))187 }188189 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {190 val current = get(id) ?: return191 upsert(current.copy(lastSyncedAt = at))192 }193194 195196197198199200 fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {201 val current = getAll()202 val next = withRelaySetting(current, mode, storedRelayUrl)203 if (next != current) persist(next)204 }205206 207208209210211 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {212 trySend(getAll())213 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->214 if (key == KEY_DEVICES || key == null) trySend(getAll())215 }216 prefs.registerOnSharedPreferenceChangeListener(listener)217 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }218 }219220 private fun persistRevocations(records: List<DeviceRevocation>) {221 prefs.edit()222 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))223 .apply()224 }225226 private fun persist(devices: List<PairedDevice>) {227 prefs.edit()228 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))229 .apply()230 }231232 private data class RegistryPrefsState(233 val devices: String?,234 val revocations: String?,235 )236237 private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(238 devices = prefs.getString(KEY_DEVICES, null),239 revocations = prefs.getString(KEY_REVOCATIONS, null),240 )241242 private fun writePrefsState(state: RegistryPrefsState): Boolean {243 val edit = prefs.edit()244 if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)245 if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)246 return edit.commit()247 }248249 private companion object {250 const val PREFS_FILE = "peach_paired_devices"251 const val KEY_DEVICES = "paired_devices"252 const val KEY_REVOCATIONS = "device_revocations"253 254 const val MAX_REVOCATIONS = 500255 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }256 }257}258259internal data class DeviceRevocationMutation(260 val devices: List<PairedDevice>,261 val revocations: List<DeviceRevocation>,262 val added: DeviceRevocation?,263)264265266267268269270271272internal fun planDeviceRevocation(273 devices: List<PairedDevice>,274 revocations: List<DeviceRevocation>,275 deviceId: String,276 revokedByDeviceId: String,277 nowMs: Long,278): DeviceRevocationMutation {279 val remaining = devices.filterNot { it.deviceId == deviceId }280 if (revocations.any { it.deviceId == deviceId }) {281 return DeviceRevocationMutation(remaining, revocations, null)282 }283 val device = devices.firstOrNull { it.deviceId == deviceId }284 val record = DeviceRevocation(285 deviceId = deviceId,286 longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),287 revokedAt = nowMs,288 revokedByDeviceId = revokedByDeviceId,289 )290 return DeviceRevocationMutation(remaining, revocations + record, record)291}292293294295296297298299internal fun withRelaySetting(300 devices: List<PairedDevice>,301 mode: SyncMode,302 storedRelayUrl: String,303): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {304 is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }305 is RelayDecision.Refuse -> devices306}