1package com.peachpasswords.ui.sync23import android.content.Context4import androidx.lifecycle.ViewModel5import androidx.lifecycle.viewModelScope6import com.peachpasswords.sync.BackgroundSyncPreference7import com.peachpasswords.sync.SyncVaultAdapter8import com.peachpasswords.sync.PairedDevice9import com.peachpasswords.sync.PairedDeviceStore10import com.peachpasswords.sync.PendingVouchedDevice11import com.peachpasswords.sync.PendingVouchedDeviceStore12import com.peachpasswords.sync.hexToPubkey13import com.peachpasswords.sync.PeachSyncService14import com.peachpasswords.sync.RelayDecision15import com.peachpasswords.sync.RelaySettingsStore16import com.peachpasswords.sync.SyncMode17import com.peachpasswords.sync.SyncAlertStore18import com.peachpasswords.sync.SyncOutcomeReason19import com.peachpasswords.sync.SyncOutcomeStore20import com.peachpasswords.sync.SyncOutcomeSummary21import com.peachpasswords.sync.VaultSyncAdapter22import com.peachpasswords.sync.buildLocalIdentity23import com.peachpasswords.sync.deriveLocalSyncKeypair24import com.peachpasswords.sync.getOrCreateDeviceId25import com.peachpasswords.vault.VaultCrypto26import com.peachpasswords.vault.VaultRepository27import dagger.hilt.android.lifecycle.HiltViewModel28import dagger.hilt.android.qualifiers.ApplicationContext29import kotlinx.coroutines.flow.MutableStateFlow30import kotlinx.coroutines.flow.SharingStarted31import kotlinx.coroutines.flow.StateFlow32import kotlinx.coroutines.flow.asStateFlow33import kotlinx.coroutines.flow.stateIn34import kotlinx.coroutines.delay35import kotlinx.coroutines.Job36import kotlinx.coroutines.launch37import javax.inject.Inject38394041424344454647484950@HiltViewModel51class SyncViewModel @Inject constructor(52 @ApplicationContext private val context: Context,53 private val vault: VaultRepository,54 private val deviceStore: PairedDeviceStore,55 private val pendingVouchedStore: PendingVouchedDeviceStore,56 private val syncAlertStore: SyncAlertStore,57 private val backgroundSync: BackgroundSyncPreference,58 private val relaySettings: RelaySettingsStore,59 private val syncOutcomes: SyncOutcomeStore,60) : ViewModel() {6162 63 val status: StateFlow<SyncOutcomeSummary> = syncOutcomes.summary6465 val backgroundSyncEnabled: StateFlow<Boolean> = backgroundSync.enabled6667 fun setBackgroundSyncEnabled(enabled: Boolean) {68 backgroundSync.setEnabled(enabled)69 if (enabled && vault.isUnlocked.value) {70 PeachSyncService.start(context)71 } else if (!enabled) {72 PeachSyncService.stop(context)73 }74 }7576 77 private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())78 val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()798081 private val _syncMode = MutableStateFlow(relaySettings.syncMode())82 val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()8384 fun setSyncMode(mode: SyncMode) {85 relaySettings.setSyncMode(mode)86 _syncMode.value = relaySettings.syncMode()87 _privateRelayUrl.value = relaySettings.privateRelayUrl()88 }8990 91 fun setRelayUrl(input: String): Boolean = runCatching {92 relaySettings.setRelayUrl(input)93 _privateRelayUrl.value = relaySettings.privateRelayUrl()94 _syncMode.value = relaySettings.syncMode()95 true96 }.getOrDefault(false)979899 100 val pairedDevices: StateFlow<List<PairedDevice>> =101 deviceStore.observeAll()102 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())103104 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =105 pendingVouchedStore.observeAll()106 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())107108 109110111112113 fun unpairDevice(id: String) = viewModelScope.launch {114 deviceStore.revoke(id, getOrCreateDeviceId(context))115 }116117 fun confirmVouchedDevice(id: String) = viewModelScope.launch {118 val pending = pendingVouchedStore.get(id) ?: return@launch119 120 121 122 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {123 pendingVouchedStore.remove(id)124 return@launch125 }126 deviceStore.upsert(127 PairedDevice(128 deviceId = pending.deviceId,129 deviceName = pending.deviceName,130 deviceType = pending.deviceType,131 longTermPublicKeyHex = pending.longTermPublicKeyHex,132 signingPublicKeyHex = pending.signingPublicKeyHex,133 trustSource = "vouched",134 wsPort = pending.wsPort,135 pairedAt = pending.pairedAt,136 ),137 )138 pendingVouchedStore.remove(id)139 }140141 fun rejectVouchedDevice(id: String) = viewModelScope.launch {142 pendingVouchedStore.remove(id)143 }144145 146 suspend fun rotateSyncIdentity(): Boolean {147 val ok = vault.rotateSyncIdentity()148 if (ok) {149 deviceStore.clear()150 syncOutcomes.reset()151 }152 return ok153 }154155 156 fun buildAdapter(): SyncVaultAdapter? {157 if (!vault.isUnlocked.value) return null158 return VaultSyncAdapter(159 repo = vault,160 ourDeviceId = getOrCreateDeviceId(context),161 pairedDevices = deviceStore,162 pendingVouched = pendingVouchedStore,163 syncAlerts = syncAlertStore,164 )165 }166167 168169170171172173174175176 fun syncNow() {177 val decision = relaySettings.relayInForce()178 if (decision is RelayDecision.Refuse) {179 syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))180 return181 }182 if (!vault.isUnlocked.value) {183 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))184 return185 }186 187 188 189 190 val devices = deviceStore.getAll()191 if (devices.isEmpty()) {192 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))193 return194 }195 196 197 198 199 if (devices.none { it.rendezvousSecretHex != null }) {200 syncOutcomes.report(201 SyncOutcomeSummary(202 reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,203 detail = devices.joinToString("; ") {204 "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"205 },206 ),207 )208 return209 }210211 syncOutcomes.begin()212 PeachSyncService.start(context)213 214 215 216 217 218 attemptTimeout?.cancel()219 attemptTimeout = viewModelScope.launch {220 delay(SYNC_ATTEMPT_TIMEOUT_MS)221 if (syncOutcomes.summary.value.syncing) {222 syncOutcomes.report(223 SyncOutcomeSummary(224 reason = SyncOutcomeReason.RELAY_UNREACHABLE,225 detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",226 ),227 )228 }229 }230 }231232 233234235236 fun triggerSync() = syncNow()237238 private var attemptTimeout: Job? = null239240 private companion object {241 const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L242 }243}