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.SyncEndpoint13import com.peachpasswords.sync.SyncAlertStore14import com.peachpasswords.sync.SyncOutcomeStatus15import com.peachpasswords.sync.SyncStatus16import com.peachpasswords.sync.VaultSyncAdapter17import com.peachpasswords.sync.buildLocalIdentity18import com.peachpasswords.sync.deriveSyncIdentityKeypair19import com.peachpasswords.sync.getOrCreateDeviceId20import com.peachpasswords.sync.openChirpSyncSession21import com.peachpasswords.sync.runChirpSyncInitiator22import 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.Inject333435363738394041424344454647@HiltViewModel48class SyncViewModel @Inject constructor(49 @ApplicationContext private val context: Context,50 private val vault: VaultRepository,51 private val deviceStore: PairedDeviceStore,52 private val pendingVouchedStore: PendingVouchedDeviceStore,53 private val discoveryClient: PairingDiscoveryClient,54 private val syncAlertStore: SyncAlertStore,55) : ViewModel() {5657 private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)58 val status: StateFlow<SyncStatus> = _status.asStateFlow()5960 61 val pairedDevices: StateFlow<List<PairedDevice>> =62 deviceStore.observeAll()63 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())6465 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =66 pendingVouchedStore.observeAll()67 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())6869 70 fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }7172 fun confirmVouchedDevice(id: String) = viewModelScope.launch {73 val pending = pendingVouchedStore.get(id) ?: return@launch74 deviceStore.upsert(75 PairedDevice(76 deviceId = pending.deviceId,77 deviceName = pending.deviceName,78 deviceType = pending.deviceType,79 longTermPublicKeyHex = pending.longTermPublicKeyHex,80 signingPublicKeyHex = pending.signingPublicKeyHex,81 trustSource = "vouched",82 wsPort = pending.wsPort,83 pairedAt = pending.pairedAt,84 ),85 )86 pendingVouchedStore.remove(id)87 }8889 fun rejectVouchedDevice(id: String) = viewModelScope.launch {90 pendingVouchedStore.remove(id)91 }9293 94 suspend fun rotateSyncIdentity(): Boolean {95 val ok = vault.rotateSyncIdentity()96 if (ok) {97 deviceStore.clear()98 _status.value = SyncStatus.Idle99 }100 return ok101 }102103 104 fun buildAdapter(): ChirpSyncVaultAdapter? {105 if (!vault.isUnlocked.value) return null106 return VaultSyncAdapter(107 repo = vault,108 ourDeviceId = getOrCreateDeviceId(context),109 pairedDevices = deviceStore,110 pendingVouched = pendingVouchedStore,111 syncAlerts = syncAlertStore,112 )113 }114115 116117118119120 fun syncNow() {121 if (!vault.isUnlocked.value) {122 _status.value = SyncStatus.Error("vault_locked")123 return124 }125 val peers = pairedDevices.value126 if (peers.isEmpty()) {127 _status.value = SyncStatus.Offline128 return129 }130 val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }131 _status.value = SyncStatus.Syncing132 viewModelScope.launch {133 try {134 val identity = buildLocalIdentity(context, master)135 peers.forEach { dev ->136 runCatching { discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) }137 }138 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())139 } catch (e: Exception) {140 _status.value = SyncStatus.Error(e.message ?: "sync_failed")141 } finally {142 VaultCrypto.zero(master)143 }144 }145 }146147 148149150151 fun triggerSync() = syncNow()152153 154 fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {155 val master = vault.syncMasterKey() ?: run {156 _status.value = SyncStatus.Error("vault_locked")157 return158 }159 val adapter = buildAdapter() ?: run {160 _status.value = SyncStatus.Error("vault_locked")161 master.fill(0)162 return163 }164 _status.value = SyncStatus.Syncing165 viewModelScope.launch {166 try {167 val deviceId = getOrCreateDeviceId(context)168 val identity = deriveSyncIdentityKeypair(master, deviceId)169 val session = openChirpSyncSession(170 endpoint = endpoint,171 ourLongTermPrivateKey = identity.secretKey,172 ourLongTermPublicKey = identity.publicKey,173 expectedRemotePubkeyHex = expectedRemotePubkeyHex,174 )175 val outcome = try {176 runChirpSyncInitiator(session, adapter)177 } finally {178 session.close()179 }180 _status.value = when (outcome.status) {181 SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())182 SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")183 SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")184 SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")185 }186 } catch (e: Exception) {187 _status.value = SyncStatus.Error(e.message ?: "sync_failed")188 } finally {189 VaultCrypto.zero(master)190 }191 }192 }193}