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.RESET_NOT_COMPLETED9import com.peachpasswords.sync.ResetResult10import com.peachpasswords.sync.SyncGeneration11import com.peachpasswords.sync.SyncGenerationStore12import com.peachpasswords.sync.SyncVaultAdapter13import com.peachpasswords.sync.confirmSecureReset14import com.peachpasswords.sync.enterResetRequired15import com.peachpasswords.sync.resolveGeneration16import com.peachpasswords.sync.syncAllowedNow17import com.peachpasswords.sync.PairedDevice18import com.peachpasswords.sync.PairedDeviceStore19import com.peachpasswords.sync.TrustDataCorruptedException20import com.peachpasswords.sync.PendingVouchedDevice21import com.peachpasswords.sync.PendingVouchedDeviceStore22import com.peachpasswords.sync.PeachSyncService23import com.peachpasswords.sync.RelayDecision24import com.peachpasswords.sync.RelaySettingsStore25import com.peachpasswords.sync.SyncMode26import com.peachpasswords.sync.SyncAlertStore27import com.peachpasswords.sync.SyncOutcomeReason28import com.peachpasswords.sync.SyncOutcomeStore29import com.peachpasswords.sync.SyncOutcomeSummary30import com.peachpasswords.sync.VaultSyncAdapter31import com.peachpasswords.sync.loadOrCreateLocalIdentity32import com.peachpasswords.sync.loadLocalIdentityOrNull33import com.peachpasswords.vault.VaultRepository34import dagger.hilt.android.lifecycle.HiltViewModel35import dagger.hilt.android.qualifiers.ApplicationContext36import kotlinx.coroutines.flow.MutableStateFlow37import kotlinx.coroutines.flow.SharingStarted38import kotlinx.coroutines.flow.StateFlow39import kotlinx.coroutines.flow.asStateFlow40import kotlinx.coroutines.flow.combine41import kotlinx.coroutines.flow.stateIn42import kotlinx.coroutines.flow.catch43import kotlinx.coroutines.Dispatchers44import kotlinx.coroutines.delay45import kotlinx.coroutines.Job46import kotlinx.coroutines.launch47import kotlinx.coroutines.withContext48import javax.inject.Inject49505152535455565758596061@HiltViewModel62class SyncViewModel @Inject constructor(63 @ApplicationContext private val context: Context,64 private val vault: VaultRepository,65 private val deviceStore: PairedDeviceStore,66 private val pendingVouchedStore: PendingVouchedDeviceStore,67 private val syncAlertStore: SyncAlertStore,68 private val backgroundSync: BackgroundSyncPreference,69 private val relaySettings: RelaySettingsStore,70 private val syncOutcomes: SyncOutcomeStore,71 private val generation: SyncGenerationStore,72) : ViewModel() {7374 75 val status: StateFlow<SyncOutcomeSummary> =76 combine(syncOutcomes.summary, vault.isUnlocked) { summary, unlocked ->77 if (unlocked && vault.syncVaultId() == null) SyncOutcomeSummary() else summary78 }.stateIn(79 viewModelScope,80 SharingStarted.WhileSubscribed(5_000),81 if (vault.isUnlocked.value && vault.syncVaultId() == null) {82 SyncOutcomeSummary()83 } else {84 syncOutcomes.summary.value85 },86 )8788 899091929394 private val backgroundSyncCoordinator = BackgroundSyncCoordinator(95 start = { PeachSyncService.start(context) },96 stop = { PeachSyncService.stop(context) },97 isUnlocked = vault.isUnlocked,98 preferenceEnabled = backgroundSync.enabled,99 syncVaultId = vault::syncVaultId,100 syncAllowed = { syncAllowedNow(generation) },101 )102103 init {104 105 106 107 108 backgroundSyncCoordinator.observe(viewModelScope)109 }110111 fun setBackgroundSyncEnabled(enabled: Boolean) {112 if (isDecoySession()) return113 backgroundSync.setEnabled(enabled)114 backgroundSyncCoordinator.setEnabled(enabled)115 }116 val backgroundSyncEnabled: StateFlow<Boolean> = backgroundSync.enabled117118 119 private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())120 val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()121122123 private val _syncMode = MutableStateFlow(relaySettings.syncMode())124 val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()125126 fun setSyncMode(mode: SyncMode) {127 if (isDecoySession()) return128 relaySettings.setSyncMode(mode)129 _syncMode.value = relaySettings.syncMode()130 _privateRelayUrl.value = relaySettings.privateRelayUrl()131 }132133 134 fun setRelayUrl(input: String): Boolean {135 if (isDecoySession()) return false136 return runCatching {137 relaySettings.setRelayUrl(input)138 _privateRelayUrl.value = relaySettings.privateRelayUrl()139 _syncMode.value = relaySettings.syncMode()140 true141 }.getOrDefault(false)142 }143144145 146 val pairedDevices: StateFlow<List<PairedDevice>> =147 deviceStore.observeAll()148 .catch { e ->149 if (e is TrustDataCorruptedException) {150 syncOutcomes.report(151 SyncOutcomeSummary(reason = SyncOutcomeReason.TRUST_DATA_CORRUPTED, detail = e.message),152 )153 emit(emptyList())154 } else {155 throw e156 }157 }158 .combine(vault.isUnlocked) { devices, unlocked ->159 if (unlocked && vault.syncVaultId() == null) emptyList() else devices160 }161 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())162163 val pendingVouched: StateFlow<List<PendingVouchedDevice>> =164 combine(pendingVouchedStore.observeAll(), vault.isUnlocked) { pending, unlocked ->165 if (unlocked && vault.syncVaultId() == null) emptyList() else pending166 }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())167168 169170171172173 fun unpairDevice(id: String) = viewModelScope.launch {174 if (isDecoySession()) return@launch175 try {176 val identity = loadLocalIdentityOrNull(context) ?: return@launch177 deviceStore.revoke(id, identity.deviceId)178 PeachSyncService.start(context)179 } catch (e: TrustDataCorruptedException) {180 syncOutcomes.report(181 SyncOutcomeSummary(reason = SyncOutcomeReason.TRUST_DATA_CORRUPTED, detail = e.message),182 )183 }184 }185186 fun renameDevice(id: String, name: String) = viewModelScope.launch {187 if (isDecoySession()) return@launch188 deviceStore.rename(id, name)189 }190191 fun confirmVouchedDevice(id: String) = viewModelScope.launch {192 if (isDecoySession()) return@launch193 val candidate = pendingVouchedStore.get(id) ?: return@launch194 val pending = pendingVouchedStore.get(195 candidate.deviceId,196 candidate.longTermPublicKeyHex,197 candidate.signingPublicKeyHex,198 ) ?: return@launch199 200 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) {201 pendingVouchedStore.removeBatch(setOf(id))202 return@launch203 }204 205 206 207 if (deviceStore.get(pending.deviceId) != null) {208 pendingVouchedStore.removeBatch(setOf(id))209 return@launch210 }211 212 213 214 if (!pendingVouchedStore.removeBatch(setOf(id))) return@launch215 if (deviceStore.isRevoked(pending.deviceId, pending.longTermPublicKeyHex)) return@launch216 deviceStore.upsert(217 PairedDevice(218 deviceId = pending.deviceId,219 deviceName = pending.deviceName,220 deviceType = pending.deviceType,221 longTermPublicKeyHex = pending.longTermPublicKeyHex,222 signingPublicKeyHex = pending.signingPublicKeyHex,223 trustSource = "vouched",224 wsPort = pending.wsPort,225 pairedAt = pending.pairedAt,226 ),227 )228 }229230 fun rejectVouchedDevice(id: String) = viewModelScope.launch {231 if (isDecoySession()) return@launch232 pendingVouchedStore.remove(id)233 }234235236 237 suspend fun rotateSyncIdentity(): Boolean {238 if (isDecoySession()) return false239 val ok = vault.rotateSyncIdentity()240 if (ok) {241 deviceStore.clear()242 syncOutcomes.reset()243 }244 return ok245 }246247 248 fun buildAdapter(): SyncVaultAdapter? {249 if (!vault.isUnlocked.value || isDecoySession()) return null250 if (!syncAllowedNow(generation)) return null251 val credentials = loadOrCreateLocalIdentity(context)252 return VaultSyncAdapter(253 repo = vault,254 ourDeviceId = credentials.deviceId,255 pairedDevices = deviceStore,256 pendingVouched = pendingVouchedStore,257 syncAlerts = syncAlertStore,258 localCredentials = credentials,259 )260 }261262 263264265266267 private val _generationState = MutableStateFlow(generationRecord())268 val generationState: StateFlow<SyncGeneration> = _generationState.asStateFlow()269270 private fun generationRecord(): SyncGeneration = resolveGeneration(generation, System.currentTimeMillis())271272 273274275276277278279280281 suspend fun confirmReset(masterPassword: String): String? {282 if (isDecoySession()) return "Reset is unavailable in this session"283 if (masterPassword.isBlank()) return "Enter your master password"284 val masterMatches = withContext(Dispatchers.Default) {285 vault.tryUnlockSlotA(masterPassword)?.let {286 vault.discardSlotAUnlock(it)287 true288 } ?: false289 }290 if (!masterMatches) return "Incorrect master password"291 val result = withContext(Dispatchers.Default) {292 _generationState.value = enterResetRequired(generation, System.currentTimeMillis())293 confirmSecureReset(generation, System.currentTimeMillis())294 }295 _generationState.value = generationRecord()296 return when (result) {297 ResetResult.V2_ANCHOR -> null298 ResetResult.RESET_NOT_COMPLETED ->299 "The reset did not complete — nothing was lost, try again ($RESET_NOT_COMPLETED)"300 }301 }302303 304305306307308309310311312 fun syncNow() {313 314 315 if (isDecoySession()) return316317 val decision = relaySettings.relayInForce()318 if (decision is RelayDecision.Refuse) {319 syncOutcomes.report(SyncOutcomeSummary(reason = decision.reason))320 return321 }322 if (!vault.isUnlocked.value) {323 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.VAULT_LOCKED))324 return325 }326327 328 329 330 331 val devices = deviceStore.getAll()332 if (devices.isEmpty()) {333 syncOutcomes.report(SyncOutcomeSummary(reason = SyncOutcomeReason.NO_PAIRED_DEVICES))334 return335 }336 337 338 339 340 if (devices.none { it.rendezvousSecretHex != null }) {341 syncOutcomes.report(342 SyncOutcomeSummary(343 reason = SyncOutcomeReason.NO_ELIGIBLE_HOST,344 detail = devices.joinToString("; ") {345 "${it.deviceName ?: it.deviceId} [type=${it.deviceType ?: "unset"}, trust=${it.trustSource ?: "unset"}]"346 },347 ),348 )349 return350 }351352 if (!syncAllowedNow(generation)) return353 syncOutcomes.begin()354 PeachSyncService.start(context)355 356 357 358 359 360 attemptTimeout?.cancel()361 attemptTimeout = viewModelScope.launch {362 delay(SYNC_ATTEMPT_TIMEOUT_MS)363 if (syncOutcomes.summary.value.syncing) {364 syncOutcomes.report(365 SyncOutcomeSummary(366 reason = SyncOutcomeReason.RELAY_UNREACHABLE,367 detail = "no peer answered within ${SYNC_ATTEMPT_TIMEOUT_MS / 1000}s",368 ),369 )370 }371 }372 }373 private fun isDecoySession(): Boolean =374 vault.isUnlocked.value && vault.syncVaultId() == null375376 377378379380 fun triggerSync() = syncNow()381382 private var attemptTimeout: Job? = null383384 private companion object {385 const val SYNC_ATTEMPT_TIMEOUT_MS = 20_000L386 }387}