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.SyncOutcomeStatus14import com.peachpasswords.sync.SyncStatus15import com.peachpasswords.sync.VaultSyncAdapter16import com.peachpasswords.sync.buildLocalIdentity17import com.peachpasswords.sync.deriveSyncIdentityKeypair18import com.peachpasswords.sync.getOrCreateDeviceId19import com.peachpasswords.sync.openChirpSyncSession20import com.peachpasswords.sync.runChirpSyncInitiator21import 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.Inject323334353637383940414243444546@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) : ViewModel() {5455 private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)56 val status: StateFlow<SyncStatus> = _status.asStateFlow()5758 59 val pairedDevices: StateFlow<List<PairedDevice>> =60 deviceStore.observeAll()61 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())6263 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =64 pendingVouchedStore.observeAll()65 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())6667 68 fun unpairDevice(id: String) = viewModelScope.launch { deviceStore.remove(id) }6970 fun confirmVouchedDevice(id: String) = viewModelScope.launch {71 val pending = pendingVouchedStore.get(id) ?: return@launch72 deviceStore.upsert(73 PairedDevice(74 deviceId = pending.deviceId,75 deviceName = pending.deviceName,76 deviceType = pending.deviceType,77 longTermPublicKeyHex = pending.longTermPublicKeyHex,78 signingPublicKeyHex = pending.signingPublicKeyHex,79 trustSource = "vouched",80 wsPort = pending.wsPort,81 pairedAt = pending.pairedAt,82 ),83 )84 pendingVouchedStore.remove(id)85 }8687 fun rejectVouchedDevice(id: String) = viewModelScope.launch {88 pendingVouchedStore.remove(id)89 }9091 92 suspend fun rotateSyncIdentity(): Boolean {93 val ok = vault.rotateSyncIdentity()94 if (ok) {95 deviceStore.clear()96 _status.value = SyncStatus.Idle97 }98 return ok99 }100101 102 fun buildAdapter(): ChirpSyncVaultAdapter? {103 if (!vault.isUnlocked.value) return null104 return VaultSyncAdapter(105 repo = vault,106 ourDeviceId = getOrCreateDeviceId(context),107 pairedDevices = deviceStore,108 pendingVouched = pendingVouchedStore,109 )110 }111112 113114115116117 fun syncNow() {118 if (!vault.isUnlocked.value) {119 _status.value = SyncStatus.Error("vault_locked")120 return121 }122 val peers = pairedDevices.value123 if (peers.isEmpty()) {124 _status.value = SyncStatus.Offline125 return126 }127 val master = vault.syncMasterKey() ?: run { _status.value = SyncStatus.Error("vault_locked"); return }128 _status.value = SyncStatus.Syncing129 viewModelScope.launch {130 try {131 val identity = buildLocalIdentity(context, master)132 peers.forEach { dev ->133 runCatching { discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) }134 }135 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())136 } catch (e: Exception) {137 _status.value = SyncStatus.Error(e.message ?: "sync_failed")138 } finally {139 VaultCrypto.zero(master)140 }141 }142 }143144 145146147148 fun triggerSync() = syncNow()149150 151 fun syncWith(endpoint: SyncEndpoint, expectedRemotePubkeyHex: String) {152 val master = vault.syncMasterKey() ?: run {153 _status.value = SyncStatus.Error("vault_locked")154 return155 }156 val adapter = buildAdapter() ?: run {157 _status.value = SyncStatus.Error("vault_locked")158 master.fill(0)159 return160 }161 _status.value = SyncStatus.Syncing162 viewModelScope.launch {163 try {164 val deviceId = getOrCreateDeviceId(context)165 val identity = deriveSyncIdentityKeypair(master, deviceId)166 val session = openChirpSyncSession(167 endpoint = endpoint,168 ourLongTermPrivateKey = identity.secretKey,169 ourLongTermPublicKey = identity.publicKey,170 expectedRemotePubkeyHex = expectedRemotePubkeyHex,171 )172 val outcome = try {173 runChirpSyncInitiator(session, adapter)174 } finally {175 session.close()176 }177 _status.value = when (outcome.status) {178 SyncOutcomeStatus.COMPLETED -> SyncStatus.LastSynced(System.currentTimeMillis())179 SyncOutcomeStatus.VAULT_MISMATCH -> SyncStatus.Error("vault_id_mismatch")180 SyncOutcomeStatus.TIMEOUT -> SyncStatus.Error("timeout")181 SyncOutcomeStatus.ERRORED -> SyncStatus.Error(outcome.error ?: "sync_failed")182 }183 } catch (e: Exception) {184 _status.value = SyncStatus.Error(e.message ?: "sync_failed")185 } finally {186 VaultCrypto.zero(master)187 }188 }189 }190}