CodePairViewModel.kt

4.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.buildLocalIdentity14import com.peachpasswords.sync.deriveLocalSyncKeypair15import com.peachpasswords.sync.getOrCreateDeviceId16import com.peachpasswords.vault.VaultCrypto17import com.peachpasswords.vault.VaultRepository18import dagger.hilt.android.lifecycle.HiltViewModel19import dagger.hilt.android.qualifiers.ApplicationContext20import kotlinx.coroutines.flow.MutableStateFlow21import kotlinx.coroutines.flow.StateFlow22import kotlinx.coroutines.flow.asStateFlow23import kotlinx.coroutines.launch24import javax.inject.Inject2526/**27 * The phone half of code pairing: show a code, wait to be dialed.28 *29 * The browser drives the conversation from here on — it is always the dialer30 * (see [CodePairingServer] for the wire contract), so there is nothing for this31 * screen to do but publish the code and then present the SAS for comparison.32 */33sealed interface CodePairUiState {34    data object Starting : CodePairUiState35    data class ShowingCode(val code: String, val ip: String, val port: Int) : CodePairUiState36    data class ConfirmSas(val sas: String, val deviceName: String, val session: PairingSession) : CodePairUiState37    data class Paired(val deviceName: String) : CodePairUiState38    data object Expired : CodePairUiState39    data class Failed(val reason: String) : CodePairUiState40}4142@HiltViewModel43class CodePairViewModel @Inject constructor(44    private val vault: VaultRepository,45    private val server: CodePairingServer,46    @ApplicationContext private val appContext: Context,47) : ViewModel() {4849    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)50    val state: StateFlow<CodePairUiState> = _state.asStateFlow()5152    /**53     * The battery-exemption dialog to show, or null if it's already granted or54     * already been declined once. Consumed by the screen, which owns an55     * Activity to launch it from.56     */57    fun batteryExemptionIntent(): android.content.Intent? = BatteryExemption.requestIntent(appContext)5859    fun start() {60        val master = vault.syncMasterKey() ?: run {61            _state.value = CodePairUiState.Failed("vault_locked")62            return63        }64        PeachSyncService.start(appContext)6566        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))67        val identity = buildLocalIdentity(appContext, master)68        VaultCrypto.zero(master)6970        // The handler blocks inside the live connection while the user compares71        // digits, so the SAS has to reach the UI out of band rather than as the72        // connection's result.73        server.confirmationSink = { pending ->74            _state.value = CodePairUiState.ConfirmSas(pending.sas, pending.deviceName, pending.session)75        }7677        viewModelScope.launch {78            server.start(viewModelScope, keys.secretKey, keys.publicKey, identity).collect { event ->79                _state.value = when (event) {80                    is PairingEvent.Listening -> CodePairUiState.ShowingCode(event.code, event.ip, event.port)81                    is PairingEvent.Paired -> CodePairUiState.Paired(event.deviceName)82                    PairingEvent.Expired -> CodePairUiState.Expired83                    is PairingEvent.Rejected -> {84                        // Nothing else records why a pairing attempt died, and85                        // the on-screen copy is deliberately vague. Without this86                        // a failure on someone else's phone is undiagnosable.87                        if (BuildConfig.DEBUG) Log.w("PeachPairing", "pairing rejected: ${event.reason}")88                        CodePairUiState.Failed(event.reason)89                    }90                    // Delivered via confirmationSink; the flow only sees the outcome.91                    is PairingEvent.AwaitingConfirmation -> _state.value92                }93            }94        }95    }9697    fun confirmSas(matches: Boolean) {98        (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)99    }100101    fun restart() {102        server.stop()103        _state.value = CodePairUiState.Starting104        start()105    }106107    fun stop() {108        server.confirmationSink = null109        server.stop()110    }111112    override fun onCleared() {113        stop()114        super.onCleared()115    }116}