1package com.peachpasswords.ui.pairing23import android.content.Context4import android.util.Log5import com.peachpasswords.BuildConfig6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.sync.BackgroundSyncPreference9import com.peachpasswords.sync.BatteryExemption10import com.peachpasswords.sync.PairingSession11import com.peachpasswords.sync.PeachSyncService12import com.peachpasswords.sync.RelayAdoptionEvent13import com.peachpasswords.sync.RelayAdoptionHost14import com.peachpasswords.sync.RelaySettingsStore15import com.peachpasswords.sync.RelayDecision16import com.peachpasswords.sync.SyncMode17import com.peachpasswords.sync.PendingVouchedDevice18import com.peachpasswords.sync.PendingVouchedDeviceStore19import com.peachpasswords.sync.AdoptionFrame20import com.peachpasswords.sync.AdoptionPairState21import com.peachpasswords.sync.AdoptionSourceDeps22import com.peachpasswords.sync.AdoptionSourceV2Deps23import com.peachpasswords.sync.LocalDeviceIdentity24import com.peachpasswords.sync.PairedDevice25import com.peachpasswords.sync.PairedDeviceStore26import com.peachpasswords.sync.SignedDeviceManifest27import com.peachpasswords.sync.VaultSyncAdapter28import com.peachpasswords.sync.buildLocalIdentity29import com.peachpasswords.sync.loadOrCreateLocalIdentity30import com.peachpasswords.sync.localSyncKeypair31import com.peachpasswords.sync.rotateDeviceId32import com.peachpasswords.vault.VaultCrypto33import com.peachpasswords.vault.VaultRepository34import com.peachpasswords.license.LicenseRepository35import dagger.hilt.android.lifecycle.HiltViewModel36import dagger.hilt.android.qualifiers.ApplicationContext37import kotlinx.coroutines.Job38import kotlinx.coroutines.flow.MutableStateFlow39import kotlinx.coroutines.flow.StateFlow40import kotlinx.coroutines.flow.asStateFlow41import kotlinx.coroutines.launch42import javax.inject.Inject43444546474849505152sealed interface CodePairUiState {53 data object Starting : CodePairUiState54 data class ShowingInvitation(val invitation: String) : CodePairUiState55 data class AwaitingPeerConfirmation(val sas: String, val deviceName: String) : CodePairUiState56 data class Paired(val deviceName: String) : CodePairUiState57 data object Expired : CodePairUiState58 data class Failed(val reason: String) : CodePairUiState59}6061@HiltViewModel62class CodePairViewModel @Inject constructor(63 private val vault: VaultRepository,64 private val relayHost: RelayAdoptionHost,65 private val relaySettings: RelaySettingsStore,66 private val backgroundSync: BackgroundSyncPreference,67 private val deviceStore: PairedDeviceStore,68 private val pendingVouchedStore: PendingVouchedDeviceStore,69 private val license: LicenseRepository,70 @ApplicationContext private val appContext: Context,71) : ViewModel() {7273 private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)74 val state: StateFlow<CodePairUiState> = _state.asStateFlow()7576 private val _syncMode = MutableStateFlow(relaySettings.syncMode())77 val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7879 private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())80 val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()8182 8384858687 private var collectJob: Job? = null8889 909192939495969798 fun batteryExemptionIntent(userConfirmed: Boolean): android.content.Intent? =99 BatteryExemption.requestIntent(appContext, userConfirmed)100101 102 fun syncOnce() = BatteryExemption.syncOnce(appContext)103104 105106107108109 fun confirmSas(matches: Boolean) = relayHost.confirmSas(matches)110111 fun start() {112 val master = vault.syncMasterKey() ?: run {113 _state.value = CodePairUiState.Failed("vault_locked")114 return115 }116 val decision = relaySettings.relayInForce()117 if (decision is RelayDecision.Refuse) {118 _state.value = CodePairUiState.Failed(decision.reason.wire())119 return120 }121 PeachSyncService.start(appContext)122 val credentials = loadOrCreateLocalIdentity(appContext)123 val keys = localSyncKeypair(credentials)124 val identity = buildLocalIdentity(appContext, credentials)125 VaultCrypto.zero(master)126 startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))127 }128129 private fun startRelay(130 privateKey: ByteArray,131 publicKey: ByteArray,132 identity: LocalDeviceIdentity,133 sourceDeps: AdoptionSourceV2Deps,134 ) {135 collectJob = viewModelScope.launch {136 try {137 relayHost.start(138 viewModelScope,139 privateKey,140 publicKey,141 identity,142 sourceDeps,143 ).collect { event ->144 _state.value = when (event) {145 is RelayAdoptionEvent.InvitationReady ->146 CodePairUiState.ShowingInvitation(event.invitation)147 is RelayAdoptionEvent.AwaitingPeerConfirmation ->148 CodePairUiState.AwaitingPeerConfirmation(149 event.sas,150 event.deviceName,151 )152 is RelayAdoptionEvent.Adopted ->153 CodePairUiState.Paired(event.deviceName)154 RelayAdoptionEvent.Expired -> CodePairUiState.Expired155 is RelayAdoptionEvent.Rejected -> {156 if (BuildConfig.DEBUG) {157 Log.w(158 "PeachPairing",159 "relay adoption rejected: ${event.reason}",160 )161 }162 CodePairUiState.Failed(event.reason)163 }164 }165 }166 } finally {167 VaultCrypto.zero(privateKey)168 VaultCrypto.zero(publicKey)169 }170 }171 }172173 private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceV2Deps =174 object : AdoptionSourceV2Deps {175 override val sourceIdentity: LocalDeviceIdentity = identity176177 178 179 override val pairState: AdoptionPairState? get() = null180181 override fun snapshotCapable(): Boolean = true182183 override suspend fun onConsent(request: AdoptionFrame.RequestV2): Boolean = true184185 186 187 override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long): Boolean {188 val deviceName = device.deviceName ?: return false189 val deviceType = device.deviceType ?: return false190 val longTermKey = device.longTermPublicKeyHex ?: return false191 val signingKey = device.signingPublicKeyHex ?: return false192 return runCatching {193 pendingVouchedStore.upsertAndReport(194 PendingVouchedDevice(195 deviceId = device.deviceId,196 deviceName = deviceName,197 deviceType = deviceType,198 longTermPublicKeyHex = longTermKey,199 signingPublicKeyHex = signingKey,200 wsPort = device.wsPort,201 pairedAt = nowMs,202 receivedAt = nowMs,203 vouchedByDeviceId = identity.deviceId,204 ),205 )206 }.getOrDefault(false)207 }208209 override suspend fun completePendingIdentity(device: PairedDevice): Boolean =210 runCatching { pendingVouchedStore.removeIdentityAndReport(device) }.getOrDefault(false)211212 override suspend fun rootKeyHex(): String {213 val root = vault.syncMasterKey()214 ?: throw IllegalStateException("vault_locked")215 return try {216 root.joinToString("") { "%02x".format(it) }217 } finally {218 VaultCrypto.zero(root)219 }220 }221222 override suspend fun vaultId(): String =223 vault.syncVaultId() ?: throw IllegalStateException("vault_locked")224225 override suspend fun license(): String? =226 license.getLicenseKey()?.takeIf(license::validateLicense)227228 override suspend fun applyLicense(licenseKey: String) {229 if (!license.storeLicense(licenseKey) || !license.isProActive()) {230 throw IllegalStateException("license_transfer_failed")231 }232 }233234 235 236 override suspend fun persistPendingHolder(request: AdoptionFrame.Request, nowMs: Long): Boolean =237 runCatching {238 pendingVouchedStore.upsertAndReport(239 PendingVouchedDevice(240 deviceId = request.sessionRequestId,241 deviceName = request.deviceName,242 deviceType = request.deviceType,243 longTermPublicKeyHex = identity.longTermPublicKeyHex,244 signingPublicKeyHex = identity.signingPublicKeyHex,245 pairedAt = nowMs,246 receivedAt = nowMs,247 vouchedByDeviceId = identity.deviceId,248 ),249 )250 }.getOrDefault(false)251 override suspend fun completePendingHolder(request: AdoptionFrame.Request, device: PairedDevice): Boolean =252 runCatching {253 pendingVouchedStore.removeAndReport(request.sessionRequestId)254 }.getOrDefault(false)255 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true256257 override fun hasDeviceIdentityCollision(device: PairedDevice): Boolean =258 deviceStore.hasIdentityCollision(device)259260 override suspend fun rotateSourceIdentity(): LocalDeviceIdentity {261 rotateDeviceId(appContext)262 val master = vault.syncMasterKey()263 ?: throw IllegalStateException("vault_locked")264 return try {265 buildLocalIdentity(appContext, master)266 } finally {267 VaultCrypto.zero(master)268 }269 }270271 override suspend fun upsertPairedDevice(device: PairedDevice) {272 273 274 deviceStore.upsertAndClearRevocation(device)275 }276277 override suspend fun vaultSnapshot(): Pair<Map<String, String>, Map<String, Long>>? {278 val credentials = loadOrCreateLocalIdentity(appContext)279 val adapter = VaultSyncAdapter(280 repo = vault,281 ourDeviceId = credentials.deviceId,282 pairedDevices = deviceStore,283 localCredentials = credentials,284 )285 val entryManifest = adapter.buildEntryManifest()286 val entryIds = entryManifest.keys.toList()287 val entries = if (entryIds.isNotEmpty()) adapter.getEncryptedEntries(entryIds) else emptyMap()288 val tombstones = adapter.buildTombstoneManifest()289 return entries to tombstones290 }291292 override suspend fun outgoingDeviceManifest(): SignedDeviceManifest? {293 val credentials = loadOrCreateLocalIdentity(appContext)294 val adapter = VaultSyncAdapter(295 repo = vault,296 ourDeviceId = credentials.deviceId,297 pairedDevices = deviceStore,298 localCredentials = credentials,299 )300 return adapter.buildOutgoingDeviceManifest()301 }302303 override suspend fun markSynced(deviceId: String) {304 deviceStore.updateLastSync(deviceId)305 }306307 override suspend fun duressSlotJson(): String? =308 vault.exportDuressSlotForAdoption()309 }310311 fun setBackgroundSync(enabled: Boolean) {312 backgroundSync.setEnabled(enabled)313 if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)314 }315316 fun selectRelayMode(mode: SyncMode) {317 require(mode != SyncMode.OFF) { "Pairing requires a relay" }318 relaySettings.setSyncMode(mode)319 refreshRelayState()320 if (relaySettings.relayInForce() is RelayDecision.Dial) {321 restart()322 } else {323 stop()324 _state.value = CodePairUiState.Failed("private_relay_unset")325 }326 }327328 fun savePrivateRelayUrl(input: String): Boolean = runCatching {329 relaySettings.setRelayUrl(input)330 refreshRelayState()331 restart()332 true333 }.getOrDefault(false)334335 private fun refreshRelayState() {336 _syncMode.value = relaySettings.syncMode()337 _privateRelayUrl.value = relaySettings.privateRelayUrl()338 }339340 fun restart() {341 collectJob?.cancel()342 collectJob = null343 relayHost.stop()344 _state.value = CodePairUiState.Starting345 start()346 }347348 fun stop() {349 collectJob?.cancel()350 collectJob = null351 relayHost.stop()352 }353354 override fun onCleared() {355 stop()356 super.onCleared()357 }358}