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().remove(KEY_DEVICES).apply()141 }142143 144145146147 fun updateLastSeen(id: String, ip: String? = null) {148 val current = get(id) ?: return149 if (ip == null) return150 upsert(current.copy(lastKnownIp = ip))151 }152153 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {154 val current = get(id) ?: return155 upsert(current.copy(lastSyncedAt = at))156 }157158 159160161162163164 fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {165 val current = getAll()166 val next = withRelaySetting(current, mode, storedRelayUrl)167 if (next != current) persist(next)168 }169170 171172173174175 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {176 trySend(getAll())177 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->178 if (key == KEY_DEVICES || key == null) trySend(getAll())179 }180 prefs.registerOnSharedPreferenceChangeListener(listener)181 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }182 }183184 private fun persistRevocations(records: List<DeviceRevocation>) {185 prefs.edit()186 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))187 .apply()188 }189190 private fun persist(devices: List<PairedDevice>) {191 prefs.edit()192 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))193 .apply()194 }195196 private companion object {197 const val PREFS_FILE = "peach_paired_devices"198 const val KEY_DEVICES = "paired_devices"199 const val KEY_REVOCATIONS = "device_revocations"200 201 const val MAX_REVOCATIONS = 500202 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }203 }204}205206internal data class DeviceRevocationMutation(207 val devices: List<PairedDevice>,208 val revocations: List<DeviceRevocation>,209 val added: DeviceRevocation?,210)211212213214215216217218219internal fun planDeviceRevocation(220 devices: List<PairedDevice>,221 revocations: List<DeviceRevocation>,222 deviceId: String,223 revokedByDeviceId: String,224 nowMs: Long,225): DeviceRevocationMutation {226 val remaining = devices.filterNot { it.deviceId == deviceId }227 if (revocations.any { it.deviceId == deviceId }) {228 return DeviceRevocationMutation(remaining, revocations, null)229 }230 val device = devices.firstOrNull { it.deviceId == deviceId }231 val record = DeviceRevocation(232 deviceId = deviceId,233 longTermPublicKeyHex = device?.longTermPublicKeyHex ?: "0".repeat(64),234 revokedAt = nowMs,235 revokedByDeviceId = revokedByDeviceId,236 )237 return DeviceRevocationMutation(remaining, revocations + record, record)238}239240241242243244245246internal fun withRelaySetting(247 devices: List<PairedDevice>,248 mode: SyncMode,249 storedRelayUrl: String,250): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {251 is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }252 is RelayDecision.Refuse -> devices253}