1package com.peachpasswords.ui.sync23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.sync.SyncVaultAdapter7import com.peachpasswords.sync.PairedDevice8import com.peachpasswords.sync.PairedDeviceStore9import com.peachpasswords.sync.PendingVouchedDevice10import com.peachpasswords.sync.PendingVouchedDeviceStore11import com.peachpasswords.sync.hexToPubkey12import com.peachpasswords.sync.PeachSyncService13import com.peachpasswords.sync.RelayDecision14import com.peachpasswords.sync.RelaySettingsStore15import com.peachpasswords.sync.SyncMode16import com.peachpasswords.sync.SyncAlertStore17import com.peachpasswords.sync.SyncOutcomeReason18import com.peachpasswords.sync.SyncOutcomeStore19import com.peachpasswords.sync.SyncOutcomeSummary20import com.peachpasswords.sync.VaultSyncAdapter21import com.peachpasswords.sync.buildLocalIdentity22import com.peachpasswords.sync.deriveLocalSyncKeypair23import com.peachpasswords.sync.getOrCreateDeviceId24import com.peachpasswords.vault.VaultCrypto25import com.peachpasswords.vault.VaultRepository26import dagger.hilt.android.lifecycle.HiltViewModel27import dagger.hilt.android.qualifiers.ApplicationContext28import kotlinx.coroutines.flow.MutableStateFlow29import kotlinx.coroutines.flow.SharingStarted30import kotlinx.coroutines.flow.StateFlow31import kotlinx.coroutines.flow.asStateFlow32import kotlinx.coroutines.flow.stateIn33import kotlinx.coroutines.delay34import kotlinx.coroutines.Job35import kotlinx.coroutines.launch36import javax.inject.Inject37383940414243444546474849@HiltViewModel50class SyncViewModel @Inject constructor(51 @ApplicationContext private val context: Context,52 private val vault: VaultRepository,53 private val deviceStore: PairedDeviceStore,54 private val pendingVouchedStore: PendingVouchedDeviceStore,55 private val syncAlertStore: SyncAlertStore,56 private val relaySettings: RelaySettingsStore,57 private val syncOutcomes: SyncOutcomeStore,58) : ViewModel() {5960 61 val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary6263 64 private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())65 val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()666768 private val _syncMode = MutableStateFlow(relaySettings.syncMode())69 val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7071 fun setSyncMode(mode: SyncMode) {72 relaySettings.setSyncMode(mode)73 _syncMode.value = relaySettings.syncMode()74 _privateRelayUrl.value = relaySettings.privateRelayUrl()75 }7677 78 fun setRelayUrl(input: String): Boolean = runCatching {79 relaySettings.setRelayUrl(input)80 _privateRelayUrl.value = relaySettings.privateRelayUrl()81 _syncMode.value = relaySettings.syncMode()82 true83 }.getOrDefault(false)848586 87 val pairedDevices: StateFlow<List<PairedDevice>> =88 deviceStore.observeAll()89 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9091 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =92 pendingVouchedStore.observeAll()93 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9495 96979899100 fun unpairDevice(id: String) = viewModelScope.launch {101 deviceStore.revoke(id, getOrCreateDeviceId(context))102 }103104 fun confirmVouchedDevice(id: String) = viewModelScope.launch {105 val pending = pendingVouchedStore.get(id) ?: return@launch106 107 108 109 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {110 pendingVouchedStore.remove(id)111 return@launch112 }113 deviceStore.upsert(114 PairedDevice(115 deviceId = pending.deviceId,116 deviceName = pending.deviceName,117 deviceType = pending.deviceType,118 longTermPublicKeyHex = pending.longTermPublicKeyHex,119 signingPublicKeyHex = pending.signingPublicKeyHex,120 trustSource = "vouched",121 wsPort = pending.wsPort,122 pairedAt = pending.pairedAt,123 ),124 )125 pendingVouchedStore.remove(id)126 }127128 fun rejectVouchedDevice(id: String) = viewModelScope.launch {129 pendingVouchedStore.remove(id)130 }131132 133 suspend fun rotateSyncIdentity(): Boolean {134 val ok = vault.rotateSyncIdentity()135 if (ok) {136 deviceStore.clear()137 syncOutcomes.reset()138 }139 return ok140 }141142 143 fun buildAdapter(): SyncVaultAdapter? {144 if (!vault.isUnlocked.value) return null145 return VaultSyncAdapter(146 repo = vault,147 ourDeviceId = getOrCreateDeviceId(context),148 pairedDevices = deviceStore,149 pendingVouched = pendingVouchedStore,150 syncAlerts = syncAlertStore,151 )152 }153154 155156157158159160161162163 fun syncNow() {164 val decision = relaySettings.relayInForce()165 if (decision is RelayDecision.Refuse) {166 syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))167 return168 }169 if (!vault.isUnlocked.value) {170 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))171 return172 }173 174 175 176 177 val devices = deviceStore.getAll()178 if (devices.isEmpty()) {179 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))180 return181 }182 183 184 185 186 if (devices.none { it.rendezvousSecretHex != null }) {187 syncOutcomes.report(188 SyncOutcomeSummary(189 reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,190 detail = devices.joinToString("; ") {191 "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"192 },193 ),194 )195 return196 }197198 syncOutcomes.begin()199 PeachSyncService.start(context)200 201 202 203 204 205 attemptTimeout?.cancel()206 attemptTimeout = viewModelScope.launch {207 delay(SYNC_ATTEMPT_TIMEOUT_MS)208 if (syncOutcomes.summary.value.syncing) {209 syncOutcomes.report(210 SyncOutcomeSummary(211 reason = SyncOutcomeReason.RELAY_UNREACHABLE,212 detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",213 ),214 )215 }216 }217 }218219 220221222223 fun triggerSync() = syncNow()224225 private var attemptTimeout: Job? = null226227 private companion object {228 const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L229 }230}