1package com.peachpasswords.ui.sync23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.sync.BackgroundSyncCoordinator7import com.peachpasswords.sync.BackgroundSyncPreference8import com.peachpasswords.sync.SyncVaultAdapter9import com.peachpasswords.sync.PairedDevice10import com.peachpasswords.sync.PairedDeviceStore11import com.peachpasswords.sync.PendingVouchedDevice12import com.peachpasswords.sync.PendingVouchedDeviceStore13import com.peachpasswords.sync.hexToPubkey14import com.peachpasswords.sync.PeachSyncService15import com.peachpasswords.sync.RelayDecision16import com.peachpasswords.sync.RelaySettingsStore17import com.peachpasswords.sync.SyncMode18import com.peachpasswords.sync.SyncAlertStore19import com.peachpasswords.sync.SyncOutcomeReason20import com.peachpasswords.sync.SyncOutcomeStore21import com.peachpasswords.sync.SyncOutcomeSummary22import com.peachpasswords.sync.VaultSyncAdapter23import com.peachpasswords.sync.buildLocalIdentity24import com.peachpasswords.sync.deriveLocalSyncKeypair25import com.peachpasswords.sync.getOrCreateDeviceId26import com.peachpasswords.vault.VaultCrypto27import com.peachpasswords.vault.VaultRepository28import dagger.hilt.android.lifecycle.HiltViewModel29import dagger.hilt.android.qualifiers.ApplicationContext30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.SharingStarted32import kotlinx.coroutines.flow.StateFlow33import kotlinx.coroutines.flow.asStateFlow34import kotlinx.coroutines.flow.stateIn35import kotlinx.coroutines.delay36import kotlinx.coroutines.Job37import kotlinx.coroutines.launch38import javax.inject.Inject39404142434445464748495051@HiltViewModel52class SyncViewModel @Inject constructor(53 @ApplicationContext private val context: Context,54 private val vault: VaultRepository,55 private val deviceStore: PairedDeviceStore,56 private val pendingVouchedStore: PendingVouchedDeviceStore,57 private val syncAlertStore: SyncAlertStore,58 private val backgroundSync: BackgroundSyncPreference,59 private val relaySettings: RelaySettingsStore,60 private val syncOutcomes: SyncOutcomeStore,61) : ViewModel() {6263 64 val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary6566 val backgroundSyncEnabled: StateFlow<Boolean> = backgroundSync.enabled6768 697071727374 private val backgroundSyncCoordinator = BackgroundSyncCoordinator(75 start = { PeachSyncService.start(context) },76 stop = { PeachSyncService.stop(context) },77 isUnlocked = vault.isUnlocked,78 preferenceEnabled = backgroundSync.enabled,79 )8081 init {82 83 84 85 86 backgroundSyncCoordinator.observe(viewModelScope)87 }8889 fun setBackgroundSyncEnabled(enabled: Boolean) {90 backgroundSync.setEnabled(enabled)91 backgroundSyncCoordinator.setEnabled(enabled)92 }9394 95 private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())96 val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()979899 private val _syncMode = MutableStateFlow(relaySettings.syncMode())100 val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()101102 fun setSyncMode(mode: SyncMode) {103 relaySettings.setSyncMode(mode)104 _syncMode.value = relaySettings.syncMode()105 _privateRelayUrl.value = relaySettings.privateRelayUrl()106 }107108 109 fun setRelayUrl(input: String): Boolean = runCatching {110 relaySettings.setRelayUrl(input)111 _privateRelayUrl.value = relaySettings.privateRelayUrl()112 _syncMode.value = relaySettings.syncMode()113 true114 }.getOrDefault(false)115116117 118 val pairedDevices: StateFlow<List<PairedDevice>> =119 deviceStore.observeAll()120 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())121122 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =123 pendingVouchedStore.observeAll()124 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())125126 127128129130131 fun unpairDevice(id: String) = viewModelScope.launch {132 deviceStore.revoke(id, getOrCreateDeviceId(context))133 }134135 fun confirmVouchedDevice(id: String) = viewModelScope.launch {136 val pending = pendingVouchedStore.get(id) ?: return@launch137 138 139 140 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {141 pendingVouchedStore.remove(id)142 return@launch143 }144 deviceStore.upsert(145 PairedDevice(146 deviceId = pending.deviceId,147 deviceName = pending.deviceName,148 deviceType = pending.deviceType,149 longTermPublicKeyHex = pending.longTermPublicKeyHex,150 signingPublicKeyHex = pending.signingPublicKeyHex,151 trustSource = "vouched",152 wsPort = pending.wsPort,153 pairedAt = pending.pairedAt,154 ),155 )156 pendingVouchedStore.remove(id)157 }158159 fun rejectVouchedDevice(id: String) = viewModelScope.launch {160 pendingVouchedStore.remove(id)161 }162163 164 suspend fun rotateSyncIdentity(): Boolean {165 val ok = vault.rotateSyncIdentity()166 if (ok) {167 deviceStore.clear()168 syncOutcomes.reset()169 }170 return ok171 }172173 174 fun buildAdapter(): SyncVaultAdapter? {175 if (!vault.isUnlocked.value) return null176 return VaultSyncAdapter(177 repo = vault,178 ourDeviceId = getOrCreateDeviceId(context),179 pairedDevices = deviceStore,180 pendingVouched = pendingVouchedStore,181 syncAlerts = syncAlertStore,182 )183 }184185 186187188189190191192193194 fun syncNow() {195 val decision = relaySettings.relayInForce()196 if (decision is RelayDecision.Refuse) {197 syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))198 return199 }200 if (!vault.isUnlocked.value) {201 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))202 return203 }204 205 206 207 208 val devices = deviceStore.getAll()209 if (devices.isEmpty()) {210 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))211 return212 }213 214 215 216 217 if (devices.none { it.rendezvousSecretHex != null }) {218 syncOutcomes.report(219 SyncOutcomeSummary(220 reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,221 detail = devices.joinToString("; ") {222 "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"223 },224 ),225 )226 return227 }228229 syncOutcomes.begin()230 PeachSyncService.start(context)231 232 233 234 235 236 attemptTimeout?.cancel()237 attemptTimeout = viewModelScope.launch {238 delay(SYNC_ATTEMPT_TIMEOUT_MS)239 if (syncOutcomes.summary.value.syncing) {240 syncOutcomes.report(241 SyncOutcomeSummary(242 reason = SyncOutcomeReason.RELAY_UNREACHABLE,243 detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",244 ),245 )246 }247 }248 }249250 251252253254 fun triggerSync() = syncNow()255256 private var attemptTimeout: Job? = null257258 private companion object {259 const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L260 }261}