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 82 83 val device = device.copy(deviceName = capDeviceName(device.deviceName))84 val next = (getAll().filterNot { it.deviceId == device.deviceId } + device).takeLast(MAX_PENDING)85 persist(next)86 }8788 override fun remove(id: String) {89 persist(getAll().filterNot { it.deviceId == id })90 }9192 override fun removeBatch(ids: Set<String>): Boolean {93 if (ids.isEmpty()) return true94 val before = getAll()95 val after = before.filterNot { it.deviceId in ids }96 return commitWithRollback(before, after, ::commitList)97 }9899 override fun restoreBatch(devices: List<PendingVouchedDevice>): Boolean {100 if (devices.isEmpty()) return true101 val before = getAll()102 val restoredIds = devices.map { it.deviceId }.toSet()103 val after = (before.filterNot { it.deviceId in restoredIds } + devices).takeLast(MAX_PENDING)104 return commitWithRollback(before, after, ::commitList)105 }106107 fun clear() {108 persist(emptyList())109 }110111 fun observeAll(): Flow<List<PendingVouchedDevice>> = callbackFlow {112 trySend(getAll())113 val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key ->114 if (key == KEY_PENDING || key == null) trySend(getAll())115 }116 prefs.registerOnSharedPreferenceChangeListener(listener)117 awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) }118 }119120 private fun persist(devices: List<PendingVouchedDevice>) {121 prefs.edit()122 .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))123 124 125 126 127 128 .commit()129 }130131 132 private fun commitList(devices: List<PendingVouchedDevice>): Boolean =133 prefs.edit()134 .putString(KEY_PENDING, json.encodeToString(listSerializer, devices))135 .commit()136137 private companion object {138 const val PREFS_FILE = "peach_pending_vouched_devices"139 const val KEY_PENDING = "pending_vouched_devices"140 const val MAX_PENDING = 50141 val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }142 }143}144145146147148149150151152internal fun <T> commitWithRollback(153 before: T,154 after: T,155 commit: (T) -> Boolean,156): Boolean {157 val applied = runCatching { commit(after) }.getOrDefault(false)158 if (applied) return true159 runCatching { commit(before) }160 return false161}