CodePairViewModel.kt

7.8 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.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.AdoptionSourceDeps17import com.peachpasswords.sync.AdoptionFrame18import com.peachpasswords.sync.LocalDeviceIdentity19import 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 pairing: occupy a relay room, show the invitation, wait to38 * be scanned.39 *40 * There is one transport. LAN pairing codes are retired along with LAN sync41 * (docs/sync-lan-retirement.md), so this screen only ever displays a QR and42 * then presents the SAS for comparison.43 */44sealed interface CodePairUiState {45    data object Starting : CodePairUiState46    data class ShowingInvitation(val invitation: String) : 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 relayHost: RelayAdoptionHost,57    private val relaySettings: RelaySettingsStore,58    private val backgroundSync: BackgroundSyncPreference,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        val decision = relaySettings.relayInForce()87        if (decision is RelayDecision.Refuse) {88            _state.value = CodePairUiState.Failed(decision.reason.wire())89            return90        }91        PeachSyncService.start(appContext)9293        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))94        val identity = buildLocalIdentity(appContext, master)95        VaultCrypto.zero(master)96        startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))97    }9899    private fun startRelay(100        privateKey: ByteArray,101        publicKey: ByteArray,102        identity: LocalDeviceIdentity,103        sourceDeps: AdoptionSourceDeps,104    ) {105        collectJob = viewModelScope.launch {106            try {107                relayHost.start(108                    viewModelScope,109                    privateKey,110                    publicKey,111                    identity,112                    sourceDeps,113                ).collect { event ->114                    _state.value = when (event) {115                        is RelayAdoptionEvent.InvitationReady ->116                            CodePairUiState.ShowingInvitation(event.invitation)117                        is RelayAdoptionEvent.AwaitingConfirmation ->118                            CodePairUiState.ConfirmSas(119                                event.sas,120                                event.deviceName,121                                event.session,122                            )123                        is RelayAdoptionEvent.Adopted ->124                            CodePairUiState.Paired(event.deviceName)125                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired126                        is RelayAdoptionEvent.Rejected -> {127                            if (BuildConfig.DEBUG) {128                                Log.w(129                                    "PeachPairing",130                                    "relay adoption rejected: ${event.reason}",131                                )132                            }133                            CodePairUiState.Failed(event.reason)134                        }135                    }136                }137            } finally {138                VaultCrypto.zero(privateKey)139                VaultCrypto.zero(publicKey)140            }141        }142    }143144    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =145        object : AdoptionSourceDeps {146            override val sourceIdentity: LocalDeviceIdentity = identity147148            override suspend fun rootKeyHex(): String {149                val root = vault.syncMasterKey()150                    ?: throw IllegalStateException("vault_locked")151                return try {152                    root.joinToString("") { "%02x".format(it) }153                } finally {154                    VaultCrypto.zero(root)155                }156            }157158            override suspend fun vaultId(): String =159                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")160161            override suspend fun license(): String? =162                license.getLicenseKey()?.takeIf(license::validateLicense)163164            // RelayAdoptionHost owns the actual UI consent gate so it can bind165            // that decision to the session SAS before delegating here.166            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true167168            override suspend fun upsertPairedDevice(device: PairedDevice) {169                // SAS-confirmed direct adoption — the one consent signal strong170                // enough to lift a terminal revocation.171                deviceStore.clearRevocation(device.deviceId)172                deviceStore.upsert(device)173            }174        }175176    fun setBackgroundSync(enabled: Boolean) {177        backgroundSync.setEnabled(enabled)178        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)179    }180181    fun confirmSas(matches: Boolean) {182        (_state.value as? CodePairUiState.ConfirmSas)?.session?.confirm(matches)183    }184185    fun restart() {186        collectJob?.cancel()187        collectJob = null188        relayHost.stop()189        _state.value = CodePairUiState.Starting190        start()191    }192193    fun stop() {194        collectJob?.cancel()195        collectJob = null196        relayHost.stop()197    }198199    override fun onCleared() {200        stop()201        super.onCleared()202    }203}