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 71 72 val device = device.copy(deviceName = device.deviceName?.let(::capDeviceName))73 val next = getAll().filterNot { it.deviceId == device.deviceId } + device74 persist(next)75 }7677 override fun remove(deviceId: String) {78 persist(getAll().filterNot { it.deviceId == deviceId })79 }8081 82838485868788 override fun applyManifestBatch(batch: DeviceManifestRegistryBatch): Boolean {89 val priorPrefs = readPrefsState()90 val currentDevices = getAll()91 val currentRevocations = listRevocations()92 val knownRevokedIds = currentRevocations.map { it.deviceId }.toSet()93 val freshRevocations = batch.incomingRevocations.filterNot { knownRevokedIds.contains(it.deviceId) }94 val nextRevocations = (currentRevocations + freshRevocations).takeLast(MAX_REVOCATIONS)95 val removed = batch.removals96 val nextDevices = currentDevices97 .filterNot { removed.contains(it.deviceId) }98 .toMutableList()99 batch.upserts.forEach { device ->100 val device = device.copy(deviceName = device.deviceName?.let(::capDeviceName))101 nextDevices.removeAll { it.deviceId == device.deviceId }102 nextDevices += device103 }104105 val nextPrefs = RegistryPrefsState(106 devices = json.encodeToString(listSerializer, nextDevices),107 revocations = json.encodeToString(revocationSerializer, nextRevocations),108 )109 110 111 112 return commitWithRollback(priorPrefs, nextPrefs, ::writePrefsState)113 }114115 116117 override fun listRevocations(): List<DeviceRevocation> {118 val raw = prefs.getString(KEY_REVOCATIONS, null) ?: return emptyList()119 return runCatching { json.decodeFromString(revocationSerializer, raw) }120 .getOrDefault(emptyList())121 }122123 124125126127128129 fun revoke(130 deviceId: String,131 revokedByDeviceId: String,132 nowMs: Long = System.currentTimeMillis(),133 ): DeviceRevocation? {134 val currentDevices = getAll()135 val currentRevocations = listRevocations()136 val mutation = planDeviceRevocation(137 devices = currentDevices,138 revocations = currentRevocations,139 deviceId = deviceId,140 revokedByDeviceId = revokedByDeviceId,141 nowMs = nowMs,142 )143 if (mutation.revocations !== currentRevocations) {144 persistRevocations(mutation.revocations)145 }146 if (mutation.devices != currentDevices) {147 persist(mutation.devices)148 }149 return mutation.added150 }151152 153154155156 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {157 val existing = listRevocations()158 val known = existing.map { it.deviceId }.toSet()159 val fresh = incoming.filterNot { known.contains(it.deviceId) }160 if (fresh.isEmpty()) return emptyList()161 persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))162 return fresh.map { it.deviceId }163 }164165 166167168169170 override fun clearRevocation(deviceId: String) {171 val existing = listRevocations()172 val filtered = existing.filterNot { it.deviceId == deviceId }173 if (filtered.size != existing.size) persistRevocations(filtered)174 }175176 fun clear() {177 prefs.edit()178 .remove(KEY_DEVICES)179 .remove(KEY_REVOCATIONS)180 .apply()181 }182183 184185186187 fun updateLastSeen(id: String, ip: String? = null) {188 val current = get(id) ?: return189 if (ip == null) return190 upsert(current.copy(lastKnownIp = ip))191 }192193 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {194 val current = get(id) ?: return195 upsert(current.copy(lastSyncedAt = at))196 }197198 199200201202203204 fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {205 val current = getAll()206 val next = withRelaySetting(current, mode, storedRelayUrl)207 if (next != current) persist(next)208 }209210 211212213214215 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {216 trySend(getAll())217 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->218 if (key == KEY_DEVICES || key == null) trySend(getAll())219 }220 prefs.registerOnSharedPreferenceChangeListener(listener)221 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }222 }223224 private fun persistRevocations(records: List<DeviceRevocation>) {225 prefs.edit()226 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))227 .apply()228 }229230 private fun persist(devices: List<PairedDevice>) {231 prefs.edit()232 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))233 .apply()234 }235236 private data class RegistryPrefsState(237 val devices: String?,238 val revocations: String?,239 )240241 private fun readPrefsState(): RegistryPrefsState = RegistryPrefsState(242 devices = prefs.getString(KEY_DEVICES, null),243 revocations = prefs.getString(KEY_REVOCATIONS, null),244 )245246 private fun writePrefsState(state: RegistryPrefsState): Boolean {247 val edit = prefs.edit()248 if (state.devices == null) edit.remove(KEY_DEVICES) else edit.putString(KEY_DEVICES, state.devices)249 if (state.revocations == null) edit.remove(KEY_REVOCATIONS) else edit.putString(KEY_REVOCATIONS, state.revocations)250 return edit.commit()251 }252253 private companion object {254 const val PREFS_FILE = "peach_paired_devices"255 const val KEY_DEVICES = "paired_devices"256 const val KEY_REVOCATIONS = "device_revocations"257 258 const val MAX_REVOCATIONS = 500259 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }260 }261}262263internal data class DeviceRevocationMutation(264 val devices: List<PairedDevice>,265 val revocations: List<DeviceRevocation>,266 val added: DeviceRevocation?,267)268269270271272273274275276internal fun planDeviceRevocation(277 devices: List<PairedDevice>,278 revocations: List<DeviceRevocation>,279 deviceId: String,280 revokedByDeviceId: String,281 nowMs: Long,282): DeviceRevocationMutation {283 val remaining = devices.filterNot { it.deviceId == deviceId }284 if (revocations.any { it.deviceId == deviceId }) {285 return DeviceRevocationMutation(remaining, revocations, null)286 }287 val device = devices.firstOrNull { it.deviceId == deviceId }288 val record = DeviceRevocation(289 deviceId = deviceId,290 longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),291 revokedAt = nowMs,292 revokedByDeviceId = revokedByDeviceId,293 )294 return DeviceRevocationMutation(remaining, revocations + record, record)295}296297298299300301302303internal fun withRelaySetting(304 devices: List<PairedDevice>,305 mode: SyncMode,306 storedRelayUrl: String,307): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {308 is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }309 is RelayDecision.Refuse -> devices310}