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 existing = listRevocations()98 if (existing.any { it.deviceId == deviceId }) return null99 val record = DeviceRevocation(100 deviceId = deviceId,101 longTermPublicKeyHex = get(deviceId)?.longTermPublicKeyHex ?: "0".repeat(64),102 revokedAt = nowMs,103 revokedByDeviceId = revokedByDeviceId,104 )105 persistRevocations(existing + record)106 remove(deviceId)107 return record108 }109110 111112113114 override fun mergeRevocations(incoming: List<DeviceRevocation>): List<String> {115 val existing = listRevocations()116 val known = existing.map { it.deviceId }.toSet()117 val fresh = incoming.filterNot { known.contains(it.deviceId) }118 if (fresh.isEmpty()) return emptyList()119 persistRevocations((existing + fresh).takeLast(MAX_REVOCATIONS))120 return fresh.map { it.deviceId }121 }122123 124125126127128 override fun clearRevocation(deviceId: String) {129 val existing = listRevocations()130 val filtered = existing.filterNot { it.deviceId == deviceId }131 if (filtered.size != existing.size) persistRevocations(filtered)132 }133134 fun clear() {135 prefs.edit().remove(KEY_DEVICES).apply()136 }137138 139140141142 fun updateLastSeen(id: String, ip: String? = null) {143 val current = get(id) ?: return144 if (ip == null) return145 upsert(current.copy(lastKnownIp = ip))146 }147148 fun updateLastSync(id: String, at: Long = System.currentTimeMillis()) {149 val current = get(id) ?: return150 upsert(current.copy(lastSyncedAt = at))151 }152153 154155156157158159 fun propagateRelaySetting(mode: SyncMode, storedRelayUrl: String) {160 val current = getAll()161 val next = withRelaySetting(current, mode, storedRelayUrl)162 if (next != current) persist(next)163 }164165 166167168169170 fun observeAll(): Flow<List<PairedDevice>> = callbackFlow {171 trySend(getAll())172 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->173 if (key == KEY_DEVICES || key == null) trySend(getAll())174 }175 prefs.registerOnSharedPreferenceChangeListener(listener)176 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }177 }178179 private fun persistRevocations(records: List<DeviceRevocation>) {180 prefs.edit()181 .putString(KEY_REVOCATIONS, json.encodeToString(revocationSerializer, records))182 .apply()183 }184185 private fun persist(devices: List<PairedDevice>) {186 prefs.edit()187 .putString(KEY_DEVICES, json.encodeToString(listSerializer, devices))188 .apply()189 }190191 private companion object {192 const val PREFS_FILE = "peach_paired_devices"193 const val KEY_DEVICES = "paired_devices"194 const val KEY_REVOCATIONS = "device_revocations"195 196 const val MAX_REVOCATIONS = 500197 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }198 }199}200201202203204205206207internal fun withRelaySetting(208 devices: List<PairedDevice>,209 mode: SyncMode,210 storedRelayUrl: String,211): List<PairedDevice> = when (val decision = relayInForce(mode, storedRelayUrl)) {212 is RelayDecision.Dial -> devices.map { it.copy(relayUrl = decision.relayUrl) }213 is RelayDecision.Refuse -> devices214}