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 fun remove(id: String)3334 3536373839 fun removeBatch(ids: Set<String>): Boolean {40 return runCatching {41 ids.forEach(::remove)42 true43 }.getOrDefault(false)44 }4546 47 fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {48 return runCatching {49 devices.forEach(::upsert)50 true51 }.getOrDefault(false)52 }53}5455@Singleton56class PendingVouchedDeviceStore @Inject constructor(57 @ApplicationContext context: Context,58) : PendingVouchedRegistry {59 private val masterKey = MasterKey.Builder(context)60 .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)61 .build()6263 private val prefs = EncryptedSharedPreferences.create(64 context,65 PREFS_FILE,66 masterKey,67 EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,68 EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,69 )7071 private val listSerializer = ListSerializer(PendingVouchedDevice.serializer())7273 override fun getAll(): List<PendingVouchedDevice> {74 val raw = prefs.getString(KEY_PENDING, null) ?: return emptyList()75 return runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())76 }7778 fun get(id: String): PendingVouchedDevice? = getAll().firstOrNull { it.deviceId == id }7980 override fun upsert(device: PendingVouchedDevice) {81 val next = (getAll().filterNot { it.deviceId == device.deviceId } + device).takeLast(MAX_PENDING)82 persist(next)83 }8485 override fun remove(id: String) {86 persist(getAll().filterNot { it.deviceId == id })87 }8889 override fun removeBatch(ids: Set<String>): Boolean {90 if (ids.isEmpty()) return true91 val before = getAll()92 val after = before.filterNot { it.deviceId in ids }93 return commitWithRollback(before, after, ::commitList)94 }9596 override fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {97 if (devices.isEmpty()) return true98 val before = getAll()99 val restoredIds = devices.map { it.deviceId }.toSet()100 val after = (before.filterNot { it.deviceId in restoredIds } + devices).takeLast(MAX_PENDING)101 return commitWithRollback(before, after, ::commitList)102 }103104 fun clear() {105 persist(emptyList())106 }107108 fun observeAll(): Flow<List<PendingVouchedDevice>> = callbackFlow {109 trySend(getAll())110 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->111 if (key == KEY_PENDING || key == null) trySend(getAll())112 }113 prefs.registerOnSharedPreferenceChangeListener(listener)114 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }115 }116117 private fun persist(devices: List<PendingVouchedDevice>) {118 prefs.edit()119 .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))120 121 122 123 124 125 .commit()126 }127128 129 private fun commitList(devices: List<PendingVouchedDevice>): Boolean =130 prefs.edit()131 .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))132 .commit()133134 private companion object {135 const val PREFS_FILE = "peach_pending_vouched_devices"136 const val KEY_PENDING = "pending_vouched_devices"137 const val MAX_PENDING = 50138 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }139 }140}141142143144145146147148149internal fun <T> commitWithRollback(150 before: T,151 after: T,152 commit: (T) -> Boolean,153): Boolean {154 val applied = runCatching { commit(after) }.getOrDefault(false)155 if (applied) return true156 runCatching { commit(before) }157 return false158}