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.SyncEndpoint17import com.peachpasswords.sync.SyncAlertStore18import com.peachpasswords.sync.SyncOutcomeStatus19import com.peachpasswords.sync.SyncStatus20import com.peachpasswords.sync.VaultSyncAdapter21import com.peachpasswords.sync.buildLocalIdentity22import com.peachpasswords.sync.deriveSyncIdentityKeypair23import com.peachpasswords.sync.getOrCreateDeviceId24import com.peachpasswords.sync.openChirpSyncSession25import com.peachpasswords.sync.runChirpSyncInitiator26import com.peachpasswords.vault.VaultCrypto27import com.peachpasswords.vault.VaultRepository28import dagger.hilt.android.lifecycle.HiltViewModel29import dagger.hilt.android.qualifiers.ApplicationContext30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.SharingStarted32import kotlinx.coroutines.flow.StateFlow33import kotlinx.coroutines.flow.asStateFlow34import kotlinx.coroutines.flow.stateIn35import kotlinx.coroutines.launch36import javax.inject.Inject373839404142434445464748495051@HiltViewModel52class SyncViewModel @Inject constructor(53 @ApplicationContext private val context: Context,54 private val vault: VaultRepository,55 private val deviceStore: PairedDeviceStore,56 private val pendingVouchedStore: PendingVouchedDeviceStore,57 private val discoveryClient: PairingDiscoveryClient,58 private val syncAlertStore: SyncAlertStore,59 private val relaySettings: RelaySettingsStore,60) : ViewModel() {6162 private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)63 val status: StateFlow<SyncStatus> = _status.asStateFlow()6465 66 private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())67 val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()6869 fun setRelayEnabled(on: Boolean) {70 relaySettings.setRelayEnabled(on)71 _relayEnabled.value = on72 }7374 75 val pairedDevices: StateFlow<List<PairedDevice>> =76 deviceStore.observeAll()77 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())7879 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =80 pendingVouchedStore.observeAll()81 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8283 84 fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }8586 fun confirmVouchedDevice(id: String) = viewModelScope.launch {87 val pending = pendingVouchedStore.get(id) ?: return@launch88 deviceStore.upsert(89 PairedDevice(90 deviceId = pending.deviceId,91 deviceName = pending.deviceName,92 deviceType = pending.deviceType,93 longTermPublicKeyHex = pending.longTermPublicKeyHex,94 signingPublicKeyHex = pending.signingPublicKeyHex,95 trustSource = "vouched",96 wsPort = pending.wsPort,97 pairedAt = pending.pairedAt,98 ),99 )100 pendingVouchedStore.remove(id)101 }102103 fun rejectVouchedDevice(id: String) = viewModelScope.launch {104 pendingVouchedStore.remove(id)105 }106107 108 suspend fun rotateSyncIdentity(): Boolean {109 val ok = vault.rotateSyncIdentity()110 if (ok) {111 deviceStore.clear()112 _status.value = SyncStatus.Idle113 }114 return ok115 }116117 118 fun buildAdapter(): ChirpSyncVaultAdapter? {119 if (!vault.isUnlocked.value) return null120 return VaultSyncAdapter(121 repo = vault,122 ourDeviceId = getOrCreateDeviceId(context),123 pairedDevices = deviceStore,124 pendingVouched = pendingVouchedStore,125 syncAlerts = syncAlertStore,126 )127 }128129 130131132133134 fun syncNow() {135 if (!vault.isUnlocked.value) {136 _status.value = SyncStatus.Error("vault_locked")137 return138 }139 val peers = pairedDevices.value140 if (peers.isEmpty()) {141 _status.value = SyncStatus.Offline142 return143 }144 val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }145 _status.value = SyncStatus.Syncing146 viewModelScope.launch {147 try {148 val identity = buildLocalIdentity(context, master)149 peers.forEach { dev ->150 151 152 153 val cached = dev.lastKnownIp?.let { ip ->154 DiscoveredPeer(155 deviceId = dev.deviceId,156 deviceName = dev.deviceName ?: dev.deviceId,157 host = ip,158 port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,159 remoteStaticPublicKey = dev.longTermPublicKeyHex160 ?.takeIf { it.length == 64 }161 ?.let(::hexToPubkey)162 ?: return@forEach,163 )164 }165 runCatching {166 discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter, cached = cached)167 }168 }169 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())170 } catch (e: Exception) {171 _status.value = SyncStatus.Error(e.message ?: "sync_failed")172 } finally {173 VaultCrypto.zero(master)174 }175 }176 }177178 179180181182 fun triggerSync() = syncNow()183184 185 fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {186 val master = vault.syncMasterKey() ?: run {187 _status.value = SyncStatus.Error("vault_locked")188 return189 }190 val adapter = buildAdapter() ?: run {191 _status.value = SyncStatus.Error("vault_locked")192 master.fill(0)193 return194 }195 _status.value = SyncStatus.Syncing196 viewModelScope.launch {197 try {198 val deviceId = getOrCreateDeviceId(context)199 val identity = deriveSyncIdentityKeypair(master, deviceId)200 val session = openChirpSyncSession(201 endpoint = endpoint,202 ourLongTermPrivateKey = identity.secretKey,203 ourLongTermPublicKey = identity.publicKey,204 expectedRemotePubkeyHex = expectedRemotePubkeyHex,205 )206 val outcome = try {207 runChirpSyncInitiator(session, adapter)208 } finally {209 session.close()210 }211 _status.value = when (outcome.status) {212 SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())213 SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")214 SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")215 SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")216 }217 } catch (e: Exception) {218 _status.value = SyncStatus.Error(e.message ?: "sync_failed")219 } finally {220 VaultCrypto.zero(master)221 }222 }223 }224}