CodePairViewModel.kt

9.6 KB · raw

1package com.peachpasswords.ui.pairing23import android.content.Context4import android.util.Log5import com.peachpasswords.BuildConfig6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import com.peachpasswords.sync.BatteryExemption9import com.peachpasswords.sync.CodePairingServer10import com.peachpasswords.sync.PairingEvent11import com.peachpasswords.sync.PairingSession12import com.peachpasswords.sync.PeachSyncService13import com.peachpasswords.sync.RelayAdoptionEvent14import com.peachpasswords.sync.RelayAdoptionHost15import com.peachpasswords.sync.RelaySettingsStore16import com.peachpasswords.sync.AdoptionSourceDeps17import com.peachpasswords.sync.AdoptionFrame18import com.peachpasswords.sync.LocalChirpIdentity19import com.peachpasswords.sync.PairedDevice20import com.peachpasswords.sync.PairedDeviceStore21import com.peachpasswords.sync.buildLocalIdentity22import com.peachpasswords.sync.deriveLocalSyncKeypair23import com.peachpasswords.sync.getOrCreateDeviceId24import com.peachpasswords.vault.VaultCrypto25import com.peachpasswords.vault.VaultRepository26import com.peachpasswords.license.LicenseRepository27import dagger.hilt.android.lifecycle.HiltViewModel28import dagger.hilt.android.qualifiers.ApplicationContext29import kotlinx.coroutines.Job30import kotlinx.coroutines.flow.MutableStateFlow31import kotlinx.coroutines.flow.StateFlow32import kotlinx.coroutines.flow.asStateFlow33import kotlinx.coroutines.launch34import javax.inject.Inject3536/**37 * The phone half of code pairing: show a code, wait to be dialed.38 *39 * The browser drives the conversation from here on — it is always the dialer40 * (see [CodePairingServer] for the wire contract), so there is nothing for this41 * screen to do but publish the code and then present the SAS for comparison.42 */43sealed interface CodePairUiState {44    data object Starting : CodePairUiState45    data class ShowingInvitation(val invitation: String) : CodePairUiState46    data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState47    data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState48    data class Paired(val deviceName: String) : CodePairUiState49    data object Expired : CodePairUiState50    data class Failed(val reason: String) : CodePairUiState51}5253@HiltViewModel54class CodePairViewModel @Inject constructor(55    private val vault: VaultRepository,56    private val server: CodePairingServer,57    private val relayHost: RelayAdoptionHost,58    private val relaySettings: RelaySettingsStore,59    private val deviceStore: PairedDeviceStore,60    private val license: LicenseRepository,61    @ApplicationContext private val appContext: Context,62) : ViewModel() {6364    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)65    val state: StateFlow<CodePairUiState> = _state.asStateFlow()6667    /**68     * The single live transport collector. Switching between relay and LAN69     * restarts the screen, and without cancelling the previous collector both70     * would keep writing [_state] — the older one landing last and winning.71     */72    private var collectJob: Job? = null7374    /**75     * The battery-exemption dialog to show, or null if it's already granted or76     * already been declined once. Consumed by the screen, which owns an77     * Activity to launch it from.78     */79    fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)8081    fun start() {82        val master = vault.syncMasterKey() ?: run {83            _state.value = CodePairUiState.Failed("vault_locked")84            return85        }86        PeachSyncService.start(appContext)8788        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))89        val identity = buildLocalIdentity(appContext, master)90        VaultCrypto.zero(master)91        val sourceDeps = sourceDeps(identity)9293        if (!relaySettings.isLanOnly()) {94            startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps)95            return96        }9798        // The handler blocks inside the live connection while the user compares99        // digits, so the SAS has to reach the UI out of band rather than as the100        // connection's result.101        server.confirmationSink = { pending ->102            _state.value = CodePairUiState.ConfirmSas(pending.sas, pending.deviceName, pending.session)103        }104105        collectJob = viewModelScope.launch {106            try {107                server.start(108                    viewModelScope,109                    keys.secretKey,110                    keys.publicKey,111                    identity,112                    adoptionSourceDeps = sourceDeps,113                ).collect { event ->114                    _state.value = when (event) {115                        is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)116                        is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)117                        PairingEvent.Expired -> CodePairUiState.Expired118                        is PairingEvent.Rejected -> {119                            // Nothing else records why a pairing attempt died, and120                            // the on-screen copy is deliberately vague. Without this121                            // a failure on someone else's phone is undiagnosable.122                            if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")123                            CodePairUiState.Failed(event.reason)124                        }125                        // Delivered via confirmationSink; the flow only sees the outcome.126                        is PairingEvent.AwaitingConfirmation -> _state.value127                    }128                }129            } finally {130                VaultCrypto.zero(keys.secretKey)131                VaultCrypto.zero(keys.publicKey)132            }133        }134    }135136    private fun startRelay(137        privateKey: ByteArray,138        publicKey: ByteArray,139        identity: LocalChirpIdentity,140        sourceDeps: AdoptionSourceDeps,141    ) {142        collectJob = viewModelScope.launch {143            try {144                relayHost.start(145                    viewModelScope,146                    privateKey,147                    publicKey,148                    identity,149                    sourceDeps,150                ).collect { event ->151                    _state.value = when (event) {152                        is RelayAdoptionEvent.InvitationReady ->153                            CodePairUiState.ShowingInvitation(event.invitation)154                        is RelayAdoptionEvent.AwaitingConfirmation ->155                            CodePairUiState.ConfirmSas(156                                event.sas,157                                event.deviceName,158                                event.session,159                            )160                        is RelayAdoptionEvent.Adopted ->161                            CodePairUiState.Paired(event.deviceName)162                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired163                        is RelayAdoptionEvent.Rejected -> {164                            if (BuildConfig.DEBUG) {165                                Log.w(166                                    "PeachPairing",167                                    "relay adoption rejected: ${event.reason}",168                                )169                            }170                            CodePairUiState.Failed(event.reason)171                        }172                    }173                }174            } finally {175                VaultCrypto.zero(privateKey)176                VaultCrypto.zero(publicKey)177            }178        }179    }180181    private fun sourceDeps(identity: LocalChirpIdentity): AdoptionSourceDeps =182        object : AdoptionSourceDeps {183            override val sourceIdentity: LocalChirpIdentity = identity184185            override suspend fun rootKeyHex(): String {186                val root = vault.syncMasterKey()187                    ?: throw IllegalStateException("vault_locked")188                return try {189                    root.joinToString("") { "%02x".format(it) }190                } finally {191                    VaultCrypto.zero(root)192                }193            }194195            override suspend fun vaultId(): String =196                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")197198            override suspend fun license(): String? =199                license.getLicenseKey()?.takeIf(license::validateLicense)200201            // RelayAdoptionHost owns the actual UI consent gate so it can bind202            // that decision to the session SAS before delegating here.203            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true204205            override suspend fun upsertPairedDevice(device: PairedDevice) {206                // SAS-confirmed direct adoption — the one consent signal strong207                // enough to lift a terminal revocation.208                deviceStore.clearRevocation(device.deviceId)209                deviceStore.upsert(device)210            }211        }212213    fun confirmSas(matches: Boolean) {214        (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)215    }216217    fun restart() {218        collectJob?.cancel()219        collectJob = null220        relayHost.stop()221        server.stop()222        _state.value = CodePairUiState.Starting223        start()224    }225226    fun useLanOnly(lanOnly: Boolean) {227        relaySettings.setLanOnly(lanOnly)228        restart()229    }230231    fun stop() {232        collectJob?.cancel()233        collectJob = null234        relayHost.stop()235        server.confirmationSink = null236        server.stop()237    }238239    override fun onCleared() {240        stop()241        super.onCleared()242    }243}