CodePairViewModel.kt

9.1 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.flow.MutableStateFlow30import kotlinx.coroutines.flow.StateFlow31import kotlinx.coroutines.flow.asStateFlow32import kotlinx.coroutines.launch33import javax.inject.Inject3435/**36 * The phone half of code pairing: show a code, wait to be dialed.37 *38 * The browser drives the conversation from here on — it is always the dialer39 * (see [CodePairingServer] for the wire contract), so there is nothing for this40 * screen to do but publish the code and then present the SAS for comparison.41 */42sealed interface CodePairUiState {43    data object Starting : CodePairUiState44    data class ShowingInvitation(val invitation: String) : CodePairUiState45    data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState46    data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState47    data class Paired(val deviceName: String) : CodePairUiState48    data object Expired : CodePairUiState49    data class Failed(val reason: String) : CodePairUiState50}5152@HiltViewModel53class CodePairViewModel @Inject constructor(54    private val vault: VaultRepository,55    private val server: CodePairingServer,56    private val relayHost: RelayAdoptionHost,57    private val relaySettings: RelaySettingsStore,58    private val deviceStore: PairedDeviceStore,59    private val license: LicenseRepository,60    @ApplicationContext private val appContext: Context,61) : ViewModel() {6263    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)64    val state: StateFlow<CodePairUiState> = _state.asStateFlow()6566    /**67     * The battery-exemption dialog to show, or null if it's already granted or68     * already been declined once. Consumed by the screen, which owns an69     * Activity to launch it from.70     */71    fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)7273    fun start() {74        val master = vault.syncMasterKey() ?: run {75            _state.value = CodePairUiState.Failed("vault_locked")76            return77        }78        PeachSyncService.start(appContext)7980        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))81        val identity = buildLocalIdentity(appContext, master)82        VaultCrypto.zero(master)83        val sourceDeps = sourceDeps(identity)8485        if (!relaySettings.isLanOnly()) {86            startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps)87            return88        }8990        // The handler blocks inside the live connection while the user compares91        // digits, so the SAS has to reach the UI out of band rather than as the92        // connection's result.93        server.confirmationSink = { pending ->94            _state.value = CodePairUiState.ConfirmSas(pending.sas, pending.deviceName, pending.session)95        }9697        viewModelScope.launch {98            try {99                server.start(100                    viewModelScope,101                    keys.secretKey,102                    keys.publicKey,103                    identity,104                    adoptionSourceDeps = sourceDeps,105                ).collect { event ->106                    _state.value = when (event) {107                        is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)108                        is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)109                        PairingEvent.Expired -> CodePairUiState.Expired110                        is PairingEvent.Rejected -> {111                            // Nothing else records why a pairing attempt died, and112                            // the on-screen copy is deliberately vague. Without this113                            // a failure on someone else's phone is undiagnosable.114                            if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")115                            CodePairUiState.Failed(event.reason)116                        }117                        // Delivered via confirmationSink; the flow only sees the outcome.118                        is PairingEvent.AwaitingConfirmation -> _state.value119                    }120                }121            } finally {122                VaultCrypto.zero(keys.secretKey)123                VaultCrypto.zero(keys.publicKey)124            }125        }126    }127128    private fun startRelay(129        privateKey: ByteArray,130        publicKey: ByteArray,131        identity: LocalChirpIdentity,132        sourceDeps: AdoptionSourceDeps,133    ) {134        viewModelScope.launch {135            try {136                relayHost.start(137                    viewModelScope,138                    privateKey,139                    publicKey,140                    identity,141                    sourceDeps,142                ).collect { event ->143                    _state.value = when (event) {144                        is RelayAdoptionEvent.InvitationReady ->145                            CodePairUiState.ShowingInvitation(event.invitation)146                        is RelayAdoptionEvent.AwaitingConfirmation ->147                            CodePairUiState.ConfirmSas(148                                event.sas,149                                event.deviceName,150                                event.session,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: LocalChirpIdentity): AdoptionSourceDeps =174        object : AdoptionSourceDeps {175            override val sourceIdentity: LocalChirpIdentity = identity176177            override suspend fun rootKeyHex(): String {178                val root = vault.syncMasterKey()179                    ?: throw IllegalStateException("vault_locked")180                return try {181                    root.joinToString("") { "%02x".format(it) }182                } finally {183                    VaultCrypto.zero(root)184                }185            }186187            override suspend fun vaultId(): String =188                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")189190            override suspend fun license(): String? =191                license.getLicenseKey()?.takeIf(license::validateLicense)192193            // RelayAdoptionHost owns the actual UI consent gate so it can bind194            // that decision to the session SAS before delegating here.195            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true196197            override suspend fun upsertPairedDevice(device: PairedDevice) {198                // SAS-confirmed direct adoption — the one consent signal strong199                // enough to lift a terminal revocation.200                deviceStore.clearRevocation(device.deviceId)201                deviceStore.upsert(device)202            }203        }204205    fun confirmSas(matches: Boolean) {206        (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)207    }208209    fun restart() {210        relayHost.stop()211        server.stop()212        _state.value = CodePairUiState.Starting213        start()214    }215216    fun useLanOnly(lanOnly: Boolean) {217        relaySettings.setLanOnly(lanOnly)218        restart()219    }220221    fun stop() {222        relayHost.stop()223        server.confirmationSink = null224        server.stop()225    }226227    override fun onCleared() {228        stop()229        super.onCleared()230    }231}