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.RelaySettingsStore14import com.peachpasswords.sync.SyncMode15import com.peachpasswords.sync.SyncAlertStore16import com.peachpasswords.sync.SyncOutcomeReason17import com.peachpasswords.sync.SyncOutcomeStore18import com.peachpasswords.sync.SyncOutcomeSummary19import com.peachpasswords.sync.VaultSyncAdapter20import com.peachpasswords.sync.buildLocalIdentity21import com.peachpasswords.sync.deriveLocalSyncKeypair22import com.peachpasswords.sync.getOrCreateDeviceId23import 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.delay33import kotlinx.coroutines.Job34import kotlinx.coroutines.launch35import javax.inject.Inject36373839404142434445464748@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 syncAlertStore: SyncAlertStore,55 private val relaySettings: RelaySettingsStore,56 private val syncOutcomes: SyncOutcomeStore,57) : ViewModel() {5859 60 val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary6162 private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())63 val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()646566 private val _syncMode = MutableStateFlow(relaySettings.syncMode())67 val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()6869 fun setSyncMode(mode: SyncMode) {70 relaySettings.setSyncMode(mode)71 _syncMode.value = relaySettings.syncMode()72 _relayUrl.value = relaySettings.relayUrl()73 }7475 76 fun setPrivateRelayUrl(input: String): Boolean = runCatching {77 _relayUrl.value = relaySettings.setPrivateRelayUrl(input)78 _syncMode.value = relaySettings.syncMode()79 true80 }.getOrDefault(false)818283 84 val pairedDevices: StateFlow<List<PairedDevice>> =85 deviceStore.observeAll()86 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())8788 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =89 pendingVouchedStore.observeAll()90 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())9192 9394959697 fun unpairDevice(id: String) = viewModelScope.launch {98 deviceStore.revoke(id, getOrCreateDeviceId(context))99 }100101 fun confirmVouchedDevice(id: String) = viewModelScope.launch {102 val pending = pendingVouchedStore.get(id) ?: return@launch103 104 105 106 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {107 pendingVouchedStore.remove(id)108 return@launch109 }110 deviceStore.upsert(111 PairedDevice(112 deviceId = pending.deviceId,113 deviceName = pending.deviceName,114 deviceType = pending.deviceType,115 longTermPublicKeyHex = pending.longTermPublicKeyHex,116 signingPublicKeyHex = pending.signingPublicKeyHex,117 trustSource = "vouched",118 wsPort = pending.wsPort,119 pairedAt = pending.pairedAt,120 ),121 )122 pendingVouchedStore.remove(id)123 }124125 fun rejectVouchedDevice(id: String) = viewModelScope.launch {126 pendingVouchedStore.remove(id)127 }128129 130 suspend fun rotateSyncIdentity(): Boolean {131 val ok = vault.rotateSyncIdentity()132 if (ok) {133 deviceStore.clear()134 syncOutcomes.reset()135 }136 return ok137 }138139 140 fun buildAdapter(): SyncVaultAdapter? {141 if (!vault.isUnlocked.value) return null142 return VaultSyncAdapter(143 repo = vault,144 ourDeviceId = getOrCreateDeviceId(context),145 pairedDevices = deviceStore,146 pendingVouched = pendingVouchedStore,147 syncAlerts = syncAlertStore,148 )149 }150151 152153154155156157158159160 fun syncNow() {161 if (!relaySettings.isSyncEnabled()) {162 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED))163 return164 }165 if (!vault.isUnlocked.value) {166 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))167 return168 }169 170 171 172 173 val devices = deviceStore.getAll()174 if (devices.isEmpty()) {175 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))176 return177 }178 179 180 181 182 if (devices.none { it.rendezvousSecretHex != null }) {183 syncOutcomes.report(184 SyncOutcomeSummary(185 reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,186 detail = devices.joinToString("; ") {187 "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"188 },189 ),190 )191 return192 }193194 syncOutcomes.begin()195 PeachSyncService.start(context)196 197 198 199 200 201 attemptTimeout?.cancel()202 attemptTimeout = viewModelScope.launch {203 delay(SYNC_ATTEMPT_TIMEOUT_MS)204 if (syncOutcomes.summary.value.syncing) {205 syncOutcomes.report(206 SyncOutcomeSummary(207 reason = SyncOutcomeReason.RELAY_UNREACHABLE,208 detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",209 ),210 )211 }212 }213 }214215 216217218219 fun triggerSync() = syncNow()220221 private var attemptTimeout: Job? = null222223 private companion object {224 const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L225 }226}