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.getOrCreateDeviceId21import com.peachpasswords.vault.VaultCrypto22import com.peachpasswords.vault.VaultRepository23import dagger.hilt.android.lifecycle.HiltViewModel24import dagger.hilt.android.qualifiers.ApplicationContext25import kotlinx.coroutines.flow.MutableStateFlow26import kotlinx.coroutines.flow.SharingStarted27import kotlinx.coroutines.flow.StateFlow28import kotlinx.coroutines.flow.asStateFlow29import kotlinx.coroutines.flow.stateIn30import kotlinx.coroutines.launch31import javax.inject.Inject3233343536373839404142434445@HiltViewModel46class SyncViewModel @Inject constructor(47 @ApplicationContext private val context: Context,48 private val vault: VaultRepository,49 private val deviceStore: PairedDeviceStore,50 private val pendingVouchedStore: PendingVouchedDeviceStore,51 private val discoveryClient: PairingDiscoveryClient,52 private val syncAlertStore: SyncAlertStore,53 private val relaySettings: RelaySettingsStore,54) : ViewModel() {5556 private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)57 val status: StateFlow<SyncStatus> = _status.asStateFlow()5859 60 private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())61 val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()62 private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())63 val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()64 private val _lanOnly = MutableStateFlow(relaySettings.isLanOnly())65 val lanOnly: StateFlow<Boolean> = _lanOnly.asStateFlow()6667 fun setRelayEnabled(on: Boolean) {68 relaySettings.setRelayEnabled(on)69 _relayEnabled.value = on70 }7172 fun setRelayUrl(input: String): Boolean = runCatching {73 _relayUrl.value = relaySettings.setRelayUrl(input)74 }.isSuccess7576 fun setLanOnly(on: Boolean) {77 relaySettings.setLanOnly(on)78 _lanOnly.value = on79 }8081 82 val pairedDevices: StateFlow<List<PairedDevice>> =83 deviceStore.observeAll()84 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8586 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =87 pendingVouchedStore.observeAll()88 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8990 91 fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }9293 fun confirmVouchedDevice(id: String) = viewModelScope.launch {94 val pending = pendingVouchedStore.get(id) ?: return@launch95 deviceStore.upsert(96 PairedDevice(97 deviceId = pending.deviceId,98 deviceName = pending.deviceName,99 deviceType = pending.deviceType,100 longTermPublicKeyHex = pending.longTermPublicKeyHex,101 signingPublicKeyHex = pending.signingPublicKeyHex,102 trustSource = "vouched",103 wsPort = pending.wsPort,104 pairedAt = pending.pairedAt,105 ),106 )107 pendingVouchedStore.remove(id)108 }109110 fun rejectVouchedDevice(id: String) = viewModelScope.launch {111 pendingVouchedStore.remove(id)112 }113114 115 suspend fun rotateSyncIdentity(): Boolean {116 val ok = vault.rotateSyncIdentity()117 if (ok) {118 deviceStore.clear()119 _status.value = SyncStatus.Idle120 }121 return ok122 }123124 125 fun buildAdapter(): ChirpSyncVaultAdapter? {126 if (!vault.isUnlocked.value) return null127 return VaultSyncAdapter(128 repo = vault,129 ourDeviceId = getOrCreateDeviceId(context),130 pairedDevices = deviceStore,131 pendingVouched = pendingVouchedStore,132 syncAlerts = syncAlertStore,133 )134 }135136 137138139140141 fun syncNow() {142 if (!vault.isUnlocked.value) {143 _status.value = SyncStatus.Error("vault_locked")144 return145 }146 val peers = pairedDevices.value147 if (peers.isEmpty()) {148 _status.value = SyncStatus.Offline149 return150 }151 val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }152 _status.value = SyncStatus.Syncing153 viewModelScope.launch {154 try {155 val identity = buildLocalIdentity(context, master)156 peers.forEach { dev ->157 158 159 160 val cached = dev.lastKnownIp?.let { ip ->161 DiscoveredPeer(162 deviceId = dev.deviceId,163 deviceName = dev.deviceName ?: dev.deviceId,164 host = ip,165 port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,166 remoteStaticPublicKey = dev.longTermPublicKeyHex167 ?.takeIf { it.length == 64 }168 ?.let(::hexToPubkey)169 ?: return@forEach,170 )171 }172 runCatching {173 discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter, cached = cached)174 }175 }176 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())177 } catch (e: Exception) {178 _status.value = SyncStatus.Error(e.message ?: "sync_failed")179 } finally {180 VaultCrypto.zero(master)181 }182 }183 }184185 186187188189 fun triggerSync() = syncNow()190191}