1package com.peachpasswords.ui.sync23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.sync.ChirpSyncVaultAdapter7import com.peachpasswords.sync.PairedDevice8import com.peachpasswords.sync.PairedDeviceStore9import com.peachpasswords.sync.PendingVouchedDevice10import com.peachpasswords.sync.PendingVouchedDeviceStore11import com.peachpasswords.sync.DiscoveredPeer12import com.peachpasswords.sync.SyncReconnectListener13import com.peachpasswords.sync.hexToPubkey14import com.peachpasswords.sync.PairingDiscoveryClient15import com.peachpasswords.sync.RelaySettingsStore16import com.peachpasswords.sync.SyncAlertStore17import com.peachpasswords.sync.SyncStatus18import com.peachpasswords.sync.VaultSyncAdapter19import com.peachpasswords.sync.buildLocalIdentity20import com.peachpasswords.sync.deriveLocalSyncKeypair21import com.peachpasswords.sync.getOrCreateDeviceId22import com.peachpasswords.vault.VaultCrypto23import com.peachpasswords.vault.VaultRepository24import dagger.hilt.android.lifecycle.HiltViewModel25import dagger.hilt.android.qualifiers.ApplicationContext26import kotlinx.coroutines.flow.MutableStateFlow27import kotlinx.coroutines.flow.SharingStarted28import kotlinx.coroutines.flow.StateFlow29import kotlinx.coroutines.flow.asStateFlow30import kotlinx.coroutines.flow.stateIn31import kotlinx.coroutines.launch32import javax.inject.Inject3334353637383940414243444546@HiltViewModel47class SyncViewModel @Inject constructor(48 @ApplicationContext private val context: Context,49 private val vault: VaultRepository,50 private val deviceStore: PairedDeviceStore,51 private val pendingVouchedStore: PendingVouchedDeviceStore,52 private val discoveryClient: PairingDiscoveryClient,53 private val syncAlertStore: SyncAlertStore,54 private val relaySettings: RelaySettingsStore,55) : ViewModel() {5657 private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)58 val status: StateFlow<SyncStatus> = _status.asStateFlow()5960 61 private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())62 val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()63 private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())64 val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()65 private val _lanOnly = MutableStateFlow(relaySettings.isLanOnly())66 val lanOnly: StateFlow<Boolean> = _lanOnly.asStateFlow()6768 fun setRelayEnabled(on: Boolean) {69 relaySettings.setRelayEnabled(on)70 _relayEnabled.value = on71 }7273 fun setRelayUrl(input: String): Boolean = runCatching {74 val normalized = relaySettings.setRelayUrl(input)75 deviceStore.setRelayUrlForAll(normalized)76 _relayUrl.value = normalized77 }.isSuccess7879 fun setLanOnly(on: Boolean) {80 relaySettings.setLanOnly(on)81 _lanOnly.value = on82 }8384 85 val pairedDevices: StateFlow<List<PairedDevice>> =86 deviceStore.observeAll()87 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8889 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =90 pendingVouchedStore.observeAll()91 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9293 94 fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }9596 fun confirmVouchedDevice(id: String) = viewModelScope.launch {97 val pending = pendingVouchedStore.get(id) ?: return@launch98 deviceStore.upsert(99 PairedDevice(100 deviceId = pending.deviceId,101 deviceName = pending.deviceName,102 deviceType = pending.deviceType,103 longTermPublicKeyHex = pending.longTermPublicKeyHex,104 signingPublicKeyHex = pending.signingPublicKeyHex,105 trustSource = "vouched",106 wsPort = pending.wsPort,107 pairedAt = pending.pairedAt,108 ),109 )110 pendingVouchedStore.remove(id)111 }112113 fun rejectVouchedDevice(id: String) = viewModelScope.launch {114 pendingVouchedStore.remove(id)115 }116117 118 suspend fun rotateSyncIdentity(): Boolean {119 val ok = vault.rotateSyncIdentity()120 if (ok) {121 deviceStore.clear()122 _status.value = SyncStatus.Idle123 }124 return ok125 }126127 128 fun buildAdapter(): ChirpSyncVaultAdapter? {129 if (!vault.isUnlocked.value) return null130 return VaultSyncAdapter(131 repo = vault,132 ourDeviceId = getOrCreateDeviceId(context),133 pairedDevices = deviceStore,134 pendingVouched = pendingVouchedStore,135 syncAlerts = syncAlertStore,136 )137 }138139 140141142143144 fun syncNow() {145 if (!vault.isUnlocked.value) {146 _status.value = SyncStatus.Error("vault_locked")147 return148 }149 val peers = pairedDevices.value150 if (peers.isEmpty()) {151 _status.value = SyncStatus.Offline152 return153 }154 val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }155 _status.value = SyncStatus.Syncing156 viewModelScope.launch {157 try {158 val identity = buildLocalIdentity(context, master)159 val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)160 try {161 peers.forEach { dev ->162 163 164 165 val cached = dev.lastKnownIp?.let { ip ->166 DiscoveredPeer(167 deviceId = dev.deviceId,168 deviceName = dev.deviceName ?: dev.deviceId,169 host = ip,170 port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,171 remoteStaticPublicKey = dev.longTermPublicKeyHex172 ?.takeIf { it.length == 64 }173 ?.let(::hexToPubkey)174 ?: return@forEach,175 )176 }177 runCatching {178 discoveryClient.syncWithKnownDevice(179 deviceId = dev.deviceId,180 localIdentity = identity,181 localStaticPrivateKey = localKeys.secretKey,182 vaultFactory = ::buildAdapter,183 cached = cached,184 )185 }186 }187 } finally {188 VaultCrypto.zero(localKeys.secretKey)189 }190 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())191 } catch (e: Exception) {192 _status.value = SyncStatus.Error(e.message ?: "sync_failed")193 } finally {194 VaultCrypto.zero(master)195 }196 }197 }198199 200201202203 fun triggerSync() = syncNow()204205}