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.removeBatch(setOf(id))146 return@launch147 }148 149 150 151 if (deviceStore.get(pending.deviceId) != null) {152 pendingVouchedStore.removeBatch(setOf(id))153 return@launch154 }155 156 157 158 if (!pendingVouchedStore.removeBatch(setOf(id))) return@launch159 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) return@launch160 deviceStore.upsert(161 PairedDevice(162 deviceId = pending.deviceId,163 deviceName = pending.deviceName,164 deviceType = pending.deviceType,165 longTermPublicKeyHex = pending.longTermPublicKeyHex,166 signingPublicKeyHex = pending.signingPublicKeyHex,167 trustSource = "vouched",168 wsPort = pending.wsPort,169 pairedAt = pending.pairedAt,170 ),171 )172 }173174 fun rejectVouchedDevice(id: String) = viewModelScope.launch {175 pendingVouchedStore.remove(id)176 }177178 179 suspend fun rotateSyncIdentity(): Boolean {180 val ok = vault.rotateSyncIdentity()181 if (ok) {182 deviceStore.clear()183 syncOutcomes.reset()184 }185 return ok186 }187188 189 fun buildAdapter(): SyncVaultAdapter? {190 if (!vault.isUnlocked.value) return null191 return VaultSyncAdapter(192 repo = vault,193 ourDeviceId = getOrCreateDeviceId(context),194 pairedDevices = deviceStore,195 pendingVouched = pendingVouchedStore,196 syncAlerts = syncAlertStore,197 )198 }199200 201202203204205206207208209 fun syncNow() {210 val decision = relaySettings.relayInForce()211 if (decision is RelayDecision.Refuse) {212 syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))213 return214 }215 if (!vault.isUnlocked.value) {216 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))217 return218 }219 220 221 222 223 val devices = deviceStore.getAll()224 if (devices.isEmpty()) {225 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))226 return227 }228 229 230 231 232 if (devices.none { it.rendezvousSecretHex != null }) {233 syncOutcomes.report(234 SyncOutcomeSummary(235 reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,236 detail = devices.joinToString("; ") {237 "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"238 },239 ),240 )241 return242 }243244 syncOutcomes.begin()245 PeachSyncService.start(context)246 247 248 249 250 251 attemptTimeout?.cancel()252 attemptTimeout = viewModelScope.launch {253 delay(SYNC_ATTEMPT_TIMEOUT_MS)254 if (syncOutcomes.summary.value.syncing) {255 syncOutcomes.report(256 SyncOutcomeSummary(257 reason = SyncOutcomeReason.RELAY_UNREACHABLE,258 detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",259 ),260 )261 }262 }263 }264265 266267268269 fun triggerSync() = syncNow()270271 private var attemptTimeout: Job? = null272273 private companion object {274 const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L275 }276}