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.PairingDiscoveryClient12import com.peachpasswords.sync.RelaySettingsStore13import com.peachpasswords.sync.SyncEndpoint14import com.peachpasswords.sync.SyncAlertStore15import com.peachpasswords.sync.SyncOutcomeStatus16import com.peachpasswords.sync.SyncStatus17import com.peachpasswords.sync.VaultSyncAdapter18import com.peachpasswords.sync.buildLocalIdentity19import com.peachpasswords.sync.deriveSyncIdentityKeypair20import com.peachpasswords.sync.getOrCreateDeviceId21import com.peachpasswords.sync.openChirpSyncSession22import com.peachpasswords.sync.runChirpSyncInitiator23import com.peachpasswords.vault.VaultCrypto24import com.peachpasswords.vault.VaultRepository25import dagger.hilt.android.lifecycle.HiltViewModel26import dagger.hilt.android.qualifiers.ApplicationContext27import kotlinx.coroutines.flow.MutableStateFlow28import kotlinx.coroutines.flow.SharingStarted29import kotlinx.coroutines.flow.StateFlow30import kotlinx.coroutines.flow.asStateFlow31import kotlinx.coroutines.flow.stateIn32import kotlinx.coroutines.launch33import javax.inject.Inject343536373839404142434445464748@HiltViewModel49class SyncViewModel @Inject constructor(50 @ApplicationContext private val context: Context,51 private val vault: VaultRepository,52 private val deviceStore: PairedDeviceStore,53 private val pendingVouchedStore: PendingVouchedDeviceStore,54 private val discoveryClient: PairingDiscoveryClient,55 private val syncAlertStore: SyncAlertStore,56 private val relaySettings: RelaySettingsStore,57) : ViewModel() {5859 private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)60 val status: StateFlow<SyncStatus> = _status.asStateFlow()6162 63 private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())64 val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()6566 fun setRelayEnabled(on: Boolean) {67 relaySettings.setRelayEnabled(on)68 _relayEnabled.value = on69 }7071 72 val pairedDevices: StateFlow<List<PairedDevice>> =73 deviceStore.observeAll()74 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())7576 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =77 pendingVouchedStore.observeAll()78 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())7980 81 fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }8283 fun confirmVouchedDevice(id: String) = viewModelScope.launch {84 val pending = pendingVouchedStore.get(id) ?: return@launch85 deviceStore.upsert(86 PairedDevice(87 deviceId = pending.deviceId,88 deviceName = pending.deviceName,89 deviceType = pending.deviceType,90 longTermPublicKeyHex = pending.longTermPublicKeyHex,91 signingPublicKeyHex = pending.signingPublicKeyHex,92 trustSource = "vouched",93 wsPort = pending.wsPort,94 pairedAt = pending.pairedAt,95 ),96 )97 pendingVouchedStore.remove(id)98 }99100 fun rejectVouchedDevice(id: String) = viewModelScope.launch {101 pendingVouchedStore.remove(id)102 }103104 105 suspend fun rotateSyncIdentity(): Boolean {106 val ok = vault.rotateSyncIdentity()107 if (ok) {108 deviceStore.clear()109 _status.value = SyncStatus.Idle110 }111 return ok112 }113114 115 fun buildAdapter(): ChirpSyncVaultAdapter? {116 if (!vault.isUnlocked.value) return null117 return VaultSyncAdapter(118 repo = vault,119 ourDeviceId = getOrCreateDeviceId(context),120 pairedDevices = deviceStore,121 pendingVouched = pendingVouchedStore,122 syncAlerts = syncAlertStore,123 )124 }125126 127128129130131 fun syncNow() {132 if (!vault.isUnlocked.value) {133 _status.value = SyncStatus.Error("vault_locked")134 return135 }136 val peers = pairedDevices.value137 if (peers.isEmpty()) {138 _status.value = SyncStatus.Offline139 return140 }141 val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }142 _status.value = SyncStatus.Syncing143 viewModelScope.launch {144 try {145 val identity = buildLocalIdentity(context, master)146 peers.forEach { dev ->147 runCatching { discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) }148 }149 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())150 } catch (e: Exception) {151 _status.value = SyncStatus.Error(e.message ?: "sync_failed")152 } finally {153 VaultCrypto.zero(master)154 }155 }156 }157158 159160161162 fun triggerSync() = syncNow()163164 165 fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {166 val master = vault.syncMasterKey() ?: run {167 _status.value = SyncStatus.Error("vault_locked")168 return169 }170 val adapter = buildAdapter() ?: run {171 _status.value = SyncStatus.Error("vault_locked")172 master.fill(0)173 return174 }175 _status.value = SyncStatus.Syncing176 viewModelScope.launch {177 try {178 val deviceId = getOrCreateDeviceId(context)179 val identity = deriveSyncIdentityKeypair(master, deviceId)180 val session = openChirpSyncSession(181 endpoint = endpoint,182 ourLongTermPrivateKey = identity.secretKey,183 ourLongTermPublicKey = identity.publicKey,184 expectedRemotePubkeyHex = expectedRemotePubkeyHex,185 )186 val outcome = try {187 runChirpSyncInitiator(session, adapter)188 } finally {189 session.close()190 }191 _status.value = when (outcome.status) {192 SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())193 SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")194 SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")195 SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")196 }197 } catch (e: Exception) {198 _status.value = SyncStatus.Error(e.message ?: "sync_failed")199 } finally {200 VaultCrypto.zero(master)201 }202 }203 }204}