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 renameDevice(id: String, name: String) = viewModelScope.launch {140 deviceStore.rename(id, name)141 }142143 fun confirmVouchedDevice(id: String) = viewModelScope.launch {144 val pending = pendingVouchedStore.get(id) ?: return@launch145 146 147 148 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {149 pendingVouchedStore.removeBatch(setOf(id))150 return@launch151 }152 153 154 155 if (deviceStore.get(pending.deviceId) != null) {156 pendingVouchedStore.removeBatch(setOf(id))157 return@launch158 }159 160 161 162 if (!pendingVouchedStore.removeBatch(setOf(id))) return@launch163 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) return@launch164 deviceStore.upsert(165 PairedDevice(166 deviceId = pending.deviceId,167 deviceName = pending.deviceName,168 deviceType = pending.deviceType,169 longTermPublicKeyHex = pending.longTermPublicKeyHex,170 signingPublicKeyHex = pending.signingPublicKeyHex,171 trustSource = "vouched",172 wsPort = pending.wsPort,173 pairedAt = pending.pairedAt,174 ),175 )176 }177178 fun rejectVouchedDevice(id: String) = viewModelScope.launch {179 pendingVouchedStore.remove(id)180 }181182 183 suspend fun rotateSyncIdentity(): Boolean {184 val ok = vault.rotateSyncIdentity()185 if (ok) {186 deviceStore.clear()187 syncOutcomes.reset()188 }189 return ok190 }191192 193 fun buildAdapter(): SyncVaultAdapter? {194 if (!vault.isUnlocked.value) return null195 return VaultSyncAdapter(196 repo = vault,197 ourDeviceId = getOrCreateDeviceId(context),198 pairedDevices = deviceStore,199 pendingVouched = pendingVouchedStore,200 syncAlerts = syncAlertStore,201 )202 }203204 205206207208209210211212213 fun syncNow() {214 val decision = relaySettings.relayInForce()215 if (decision is RelayDecision.Refuse) {216 syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))217 return218 }219 if (!vault.isUnlocked.value) {220 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))221 return222 }223 224 225 226 227 val devices = deviceStore.getAll()228 if (devices.isEmpty()) {229 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))230 return231 }232 233 234 235 236 if (devices.none { it.rendezvousSecretHex != null }) {237 syncOutcomes.report(238 SyncOutcomeSummary(239 reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,240 detail = devices.joinToString("; ") {241 "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"242 },243 ),244 )245 return246 }247248 syncOutcomes.begin()249 PeachSyncService.start(context)250 251 252 253 254 255 attemptTimeout?.cancel()256 attemptTimeout = viewModelScope.launch {257 delay(SYNC_ATTEMPT_TIMEOUT_MS)258 if (syncOutcomes.summary.value.syncing) {259 syncOutcomes.report(260 SyncOutcomeSummary(261 reason = SyncOutcomeReason.RELAY_UNREACHABLE,262 detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",263 ),264 )265 }266 }267 }268269 270271272273 fun triggerSync() = syncNow()274275 private var attemptTimeout: Job? = null276277 private companion object {278 const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L279 }280}