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.Serializable11import kotlinx.serialization.builtins.ListSerializer12import kotlinx.serialization.json.Json13import javax.inject.Inject14import javax.inject.Singleton1516@Serializable17data class PendingVouchedDevice(18 val deviceId: String,19 val deviceName: String,20 val deviceType: String,21 val longTermPublicKeyHex: String,22 val signingPublicKeyHex: String,23 val wsPort: Int? = null,24 val pairedAt: Long,25 val receivedAt: Long,26 val vouchedByDeviceId: String,27)2829interface PendingVouchedRegistry {30 fun getAll(): List<PendingVouchedDevice>31 fun upsert(device: PendingVouchedDevice)32 33 fun upsertAndReport(device: PendingVouchedDevice): Boolean {34 return runCatching {35 upsert(device)36 true37 }.getOrDefault(false)38 }39 4041424344 fun removeAndReport(id: String): Boolean {45 return runCatching {46 remove(id)47 true48 }.getOrDefault(false)49 }5051 52 fun remove(id: String)5354 5556575859 fun removeIdentityAndReport(device: PairedDevice): Boolean {60 return runCatching {61 remove(device.deviceId)62 true63 }.getOrDefault(false)64 }6566 6768697071 fun removeBatch(ids: Set<String>): Boolean {72 return runCatching {73 ids.forEach(::remove)74 true75 }.getOrDefault(false)76 }7778 79 fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {80 return runCatching {81 devices.forEach(::upsert)82 true83 }.getOrDefault(false)84 }85}8687@Singleton88class PendingVouchedDeviceStore @Inject constructor(89 @ApplicationContext context: Context,90) : PendingVouchedRegistry {91 private val masterKey = MasterKey.Builder(context)92 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)93 .build()9495 private val prefs = EncryptedSharedPreferences.create(96 context,97 PREFS_FILE,98 masterKey,99 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,100 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,101 )102103 private val listSerializer = ListSerializer(PendingVouchedDevice.serializer())104105 override fun getAll(): List<PendingVouchedDevice> {106 val raw = prefs.getString(KEY_PENDING, null) ?: return emptyList()107 return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())108 }109110 111112113114 fun get(115 deviceId: String,116 longTermPublicKeyHex: String,117 signingPublicKeyHex: String,118 ): PendingVouchedDevice? = getAll().firstOrNull {119 it.deviceId == deviceId &&120 it.longTermPublicKeyHex == longTermPublicKeyHex &&121 it.signingPublicKeyHex == signingPublicKeyHex122 }123124 125 fun get(id: String): PendingVouchedDevice? = getAll().firstOrNull { it.deviceId == id }126127 override fun upsert(device: PendingVouchedDevice) {128 upsertAndReport(device)129 }130131 override fun upsertAndReport(device: PendingVouchedDevice): Boolean {132 133 val normalized = device.copy(deviceName = capDeviceName(device.deviceName))134 val next = (135 getAll().filterNot {136 it.deviceId == normalized.deviceId &&137 it.longTermPublicKeyHex == normalized.longTermPublicKeyHex &&138 it.signingPublicKeyHex == normalized.signingPublicKeyHex139 } + normalized140 ).takeLast(MAX_PENDING)141 return commitList(next)142 }143144 override fun remove(id: String) {145 removeAndReport(id)146 }147148 override fun removeAndReport(id: String): Boolean {149 val before = getAll()150 val after = before.filterNot { it.deviceId == id }151 return commitWithRollback(before, after, ::commitList)152 }153154 override fun removeIdentityAndReport(device: PairedDevice): Boolean {155 val before = getAll()156 val after = before.filterNot {157 it.deviceId == device.deviceId &&158 it.longTermPublicKeyHex == device.longTermPublicKeyHex &&159 it.signingPublicKeyHex == device.signingPublicKeyHex160 }161 return commitWithRollback(before, after, ::commitList)162 }163164 165166167168169 override fun removeBatch(ids: Set<String>): Boolean {170 if (ids.isEmpty()) return true171 val before = getAll()172 val after = before.filterNot { it.deviceId in ids }173 return commitWithRollback(before, after, ::commitList)174 }175176 override fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {177 if (devices.isEmpty()) return true178 val before = getAll()179 val restoredIds = devices.map { it.deviceId }.toSet()180 val after = (before.filterNot { it.deviceId in restoredIds } + devices).takeLast(MAX_PENDING)181 return commitWithRollback(before, after, ::commitList)182 }183184 fun clear() {185 persist(emptyList())186 }187188 189 internal fun snapshot(): Map<String, Any?> = prefs.all190191 fun observeAll(): Flow<List<PendingVouchedDevice>> = callbackFlow {192 trySend(getAll())193 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->194 if (key == KEY_PENDING || key == null) trySend(getAll())195 }196 prefs.registerOnSharedPreferenceChangeListener(listener)197 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }198 }199200 private fun persist(devices: List<PendingVouchedDevice>) {201 prefs.edit()202 .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))203 204 205 206 207 208 .commit()209 }210211 212 private fun commitList(devices: List<PendingVouchedDevice>): Boolean =213 prefs.edit()214 .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))215 .commit()216217 private companion object {218 const val PREFS_FILE = "peach_pending_vouched_devices"219 const val KEY_PENDING = "pending_vouched_devices"220 const val MAX_PENDING = 50221 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }222 }223}224225226227228229230231232internal fun <T> commitWithRollback(233 before: T,234 after: T,235 commit: (T) -> Boolean,236): Boolean {237 val applied = runCatching { commit(after) }.getOrDefault(false)238 if (applied) return true239 runCatching { commit(before) }240 return false241}