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 134 135 136 PeachSyncService.start(context)137 }138139 fun confirmVouchedDevice(id: String) = viewModelScope.launch {140 val pending = pendingVouchedStore.get(id) ?: return@launch141 142 143 144 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {145 pendingVouchedStore.remove(id)146 return@launch147 }148 deviceStore.upsert(149 PairedDevice(150 deviceId = pending.deviceId,151 deviceName = pending.deviceName,152 deviceType = pending.deviceType,153 longTermPublicKeyHex = pending.longTermPublicKeyHex,154 signingPublicKeyHex = pending.signingPublicKeyHex,155 trustSource = "vouched",156 wsPort = pending.wsPort,157 pairedAt = pending.pairedAt,158 ),159 )160 pendingVouchedStore.remove(id)161 }162163 fun rejectVouchedDevice(id: String) = viewModelScope.launch {164 pendingVouchedStore.remove(id)165 }166167 168 suspend fun rotateSyncIdentity(): Boolean {169 val ok = vault.rotateSyncIdentity()170 if (ok) {171 deviceStore.clear()172 syncOutcomes.reset()173 }174 return ok175 }176177 178 fun buildAdapter(): SyncVaultAdapter? {179 if (!vault.isUnlocked.value) return null180 return VaultSyncAdapter(181 repo = vault,182 ourDeviceId = getOrCreateDeviceId(context),183 pairedDevices = deviceStore,184 pendingVouched = pendingVouchedStore,185 syncAlerts = syncAlertStore,186 )187 }188189 190191192193194195196197198 fun syncNow() {199 val decision = relaySettings.relayInForce()200 if (decision is RelayDecision.Refuse) {201 syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))202 return203 }204 if (!vault.isUnlocked.value) {205 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))206 return207 }208 209 210 211 212 val devices = deviceStore.getAll()213 if (devices.isEmpty()) {214 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))215 return216 }217 218 219 220 221 if (devices.none { it.rendezvousSecretHex != null }) {222 syncOutcomes.report(223 SyncOutcomeSummary(224 reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,225 detail = devices.joinToString("; ") {226 "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"227 },228 ),229 )230 return231 }232233 syncOutcomes.begin()234 PeachSyncService.start(context)235 236 237 238 239 240 attemptTimeout?.cancel()241 attemptTimeout = viewModelScope.launch {242 delay(SYNC_ATTEMPT_TIMEOUT_MS)243 if (syncOutcomes.summary.value.syncing) {244 syncOutcomes.report(245 SyncOutcomeSummary(246 reason = SyncOutcomeReason.RELAY_UNREACHABLE,247 detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",248 ),249 )250 }251 }252 }253254 255256257258 fun triggerSync() = syncNow()259260 private var attemptTimeout: Job? = null261262 private companion object {263 const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L264 }265}