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 9495969798 fun unpairDevice(id: String) = viewModelScope.launch {99 deviceStore.revoke(id, getOrCreateDeviceId(context))100 }101102 fun confirmVouchedDevice(id: String) = viewModelScope.launch {103 val pending = pendingVouchedStore.get(id) ?: return@launch104 deviceStore.upsert(105 PairedDevice(106 deviceId = pending.deviceId,107 deviceName = pending.deviceName,108 deviceType = pending.deviceType,109 longTermPublicKeyHex = pending.longTermPublicKeyHex,110 signingPublicKeyHex = pending.signingPublicKeyHex,111 trustSource = "vouched",112 wsPort = pending.wsPort,113 pairedAt = pending.pairedAt,114 ),115 )116 pendingVouchedStore.remove(id)117 }118119 fun rejectVouchedDevice(id: String) = viewModelScope.launch {120 pendingVouchedStore.remove(id)121 }122123 124 suspend fun rotateSyncIdentity(): Boolean {125 val ok = vault.rotateSyncIdentity()126 if (ok) {127 deviceStore.clear()128 _status.value = SyncStatus.Idle129 }130 return ok131 }132133 134 fun buildAdapter(): ChirpSyncVaultAdapter? {135 if (!vault.isUnlocked.value) return null136 return VaultSyncAdapter(137 repo = vault,138 ourDeviceId = getOrCreateDeviceId(context),139 pairedDevices = deviceStore,140 pendingVouched = pendingVouchedStore,141 syncAlerts = syncAlertStore,142 )143 }144145 146147148149150 fun syncNow() {151 if (!vault.isUnlocked.value) {152 _status.value = SyncStatus.Error("vault_locked")153 return154 }155 val peers = pairedDevices.value156 if (peers.isEmpty()) {157 _status.value = SyncStatus.Offline158 return159 }160 val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }161 _status.value = SyncStatus.Syncing162 viewModelScope.launch {163 try {164 val identity = buildLocalIdentity(context, master)165 val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)166 try {167 peers.forEach { dev ->168 169 170 171 val cached = dev.lastKnownIp?.let { ip ->172 DiscoveredPeer(173 deviceId = dev.deviceId,174 deviceName = dev.deviceName ?: dev.deviceId,175 host = ip,176 port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,177 remoteStaticPublicKey = dev.longTermPublicKeyHex178 ?.takeIf { it.length == 64 }179 ?.let(::hexToPubkey)180 ?: return@forEach,181 )182 }183 runCatching {184 discoveryClient.syncWithKnownDevice(185 deviceId = dev.deviceId,186 localIdentity = identity,187 localStaticPrivateKey = localKeys.secretKey,188 vaultFactory = ::buildAdapter,189 cached = cached,190 )191 }192 }193 } finally {194 VaultCrypto.zero(localKeys.secretKey)195 }196 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())197 } catch (e: Exception) {198 _status.value = SyncStatus.Error(e.message ?: "sync_failed")199 } finally {200 VaultCrypto.zero(master)201 }202 }203 }204205 206207208209 fun triggerSync() = syncNow()210211}