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 _relayUrl.value = relaySettings.setRelayUrl(input)75 }.isSuccess7677 fun setLanOnly(on: Boolean) {78 relaySettings.setLanOnly(on)79 _lanOnly.value = on80 }8182 83 val pairedDevices: StateFlow<List<PairedDevice>> =84 deviceStore.observeAll()85 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8687 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =88 pendingVouchedStore.observeAll()89 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9091 92 fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }9394 fun confirmVouchedDevice(id: String) = viewModelScope.launch {95 val pending = pendingVouchedStore.get(id) ?: return@launch96 deviceStore.upsert(97 PairedDevice(98 deviceId = pending.deviceId,99 deviceName = pending.deviceName,100 deviceType = pending.deviceType,101 longTermPublicKeyHex = pending.longTermPublicKeyHex,102 signingPublicKeyHex = pending.signingPublicKeyHex,103 trustSource = "vouched",104 wsPort = pending.wsPort,105 pairedAt = pending.pairedAt,106 ),107 )108 pendingVouchedStore.remove(id)109 }110111 fun rejectVouchedDevice(id: String) = viewModelScope.launch {112 pendingVouchedStore.remove(id)113 }114115 116 suspend fun rotateSyncIdentity(): Boolean {117 val ok = vault.rotateSyncIdentity()118 if (ok) {119 deviceStore.clear()120 _status.value = SyncStatus.Idle121 }122 return ok123 }124125 126 fun buildAdapter(): ChirpSyncVaultAdapter? {127 if (!vault.isUnlocked.value) return null128 return VaultSyncAdapter(129 repo = vault,130 ourDeviceId = getOrCreateDeviceId(context),131 pairedDevices = deviceStore,132 pendingVouched = pendingVouchedStore,133 syncAlerts = syncAlertStore,134 )135 }136137 138139140141142 fun syncNow() {143 if (!vault.isUnlocked.value) {144 _status.value = SyncStatus.Error("vault_locked")145 return146 }147 val peers = pairedDevices.value148 if (peers.isEmpty()) {149 _status.value = SyncStatus.Offline150 return151 }152 val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }153 _status.value = SyncStatus.Syncing154 viewModelScope.launch {155 try {156 val identity = buildLocalIdentity(context, master)157 val localKeys = deriveLocalSyncKeypair(master, identity.deviceId)158 try {159 peers.forEach { dev ->160 161 162 163 val cached = dev.lastKnownIp?.let { ip ->164 DiscoveredPeer(165 deviceId = dev.deviceId,166 deviceName = dev.deviceName ?: dev.deviceId,167 host = ip,168 port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,169 remoteStaticPublicKey = dev.longTermPublicKeyHex170 ?.takeIf { it.length == 64 }171 ?.let(::hexToPubkey)172 ?: return@forEach,173 )174 }175 runCatching {176 discoveryClient.syncWithKnownDevice(177 deviceId = dev.deviceId,178 localIdentity = identity,179 localStaticPrivateKey = localKeys.secretKey,180 vaultFactory = ::buildAdapter,181 cached = cached,182 )183 }184 }185 } finally {186 VaultCrypto.zero(localKeys.secretKey)187 }188 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())189 } catch (e: Exception) {190 _status.value = SyncStatus.Error(e.message ?: "sync_failed")191 } finally {192 VaultCrypto.zero(master)193 }194 }195 }196197 198199200201 fun triggerSync() = syncNow()202203}