CodePairViewModel.kt

9.5 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.SyncMode17import com.peachpasswords.sync.AdoptionSourceDeps18import com.peachpasswords.sync.AdoptionFrame19import com.peachpasswords.sync.LocalDeviceIdentity20import com.peachpasswords.sync.PairedDevice21import com.peachpasswords.sync.PairedDeviceStore22import com.peachpasswords.sync.buildLocalIdentity23import com.peachpasswords.sync.deriveLocalSyncKeypair24import com.peachpasswords.sync.getOrCreateDeviceId25import com.peachpasswords.vault.VaultCrypto26import com.peachpasswords.vault.VaultRepository27import com.peachpasswords.license.LicenseRepository28import dagger.hilt.android.lifecycle.HiltViewModel29import dagger.hilt.android.qualifiers.ApplicationContext30import kotlinx.coroutines.Job31import kotlinx.coroutines.flow.MutableStateFlow32import kotlinx.coroutines.flow.StateFlow33import kotlinx.coroutines.flow.asStateFlow34import kotlinx.coroutines.launch35import javax.inject.Inject3637/**38 * The phone half of pairing: occupy a relay room, show the invitation, wait to39 * be scanned.40 *41 * There is one transport. LAN pairing codes are retired along with LAN sync42 * (docs/sync-lan-retirement.md), so this screen only ever displays a QR and43 * then presents the SAS for comparison.44 */45sealed interface CodePairUiState {46    data object Starting : CodePairUiState47    data class ShowingInvitation(val invitation: String) : CodePairUiState48    data class AwaitingPeerConfirmation(val sas: String, val deviceName: String) : CodePairUiState49    data class Paired(val deviceName: String) : CodePairUiState50    data object Expired : CodePairUiState51    data class Failed(val reason: String) : CodePairUiState52}5354@HiltViewModel55class CodePairViewModel @Inject constructor(56    private val vault: VaultRepository,57    private val relayHost: RelayAdoptionHost,58    private val relaySettings: RelaySettingsStore,59    private val backgroundSync: BackgroundSyncPreference,60    private val deviceStore: PairedDeviceStore,61    private val license: LicenseRepository,62    @ApplicationContext private val appContext: Context,63) : ViewModel() {6465    private val _state = MutableStateFlow<CodePairUiState>(CodePairUiState.Starting)66    val state: StateFlow<CodePairUiState> = _state.asStateFlow()6768    private val _syncMode = MutableStateFlow(relaySettings.syncMode())69    val syncMode: StateFlow<SyncMode> = _syncMode.asStateFlow()7071    private val _privateRelayUrl = MutableStateFlow(relaySettings.privateRelayUrl())72    val privateRelayUrl: StateFlow<String> = _privateRelayUrl.asStateFlow()7374    /**75     * The single live transport collector. Switching between relay and LAN76     * restarts the screen, and without cancelling the previous collector both77     * would keep writing [_state] — the older one landing last and winning.78     */79    private var collectJob: Job? = null8081    /**82     * The battery-exemption dialog to show, or null if it's already granted or83     * already been declined once. Consumed by the screen, which owns an84     * Activity to launch it from.85     *86     * [userConfirmed] must come from the tap itself — see [BatteryExemption]'s87     * distribution note. When it yields nothing, sync still works; it just runs88     * from the foreground ([BatteryExemption.syncOnce]).89     */90    fun batteryExemptionIntent(userConfirmed: Boolean): android.content.Intent? =91        BatteryExemption.requestIntent(appContext, userConfirmed)9293    /** Degraded sync for a device with no Doze exemption — see [BatteryExemption.syncOnce]. */94    fun syncOnce() = BatteryExemption.syncOnce(appContext)9596    fun start() {97        val master = vault.syncMasterKey() ?: run {98            _state.value = CodePairUiState.Failed("vault_locked")99            return100        }101        val decision = relaySettings.relayInForce()102        if (decision is RelayDecision.Refuse) {103            _state.value = CodePairUiState.Failed(decision.reason.wire())104            return105        }106        PeachSyncService.start(appContext)107108        val keys = deriveLocalSyncKeypair(master, getOrCreateDeviceId(appContext))109        val identity = buildLocalIdentity(appContext, master)110        VaultCrypto.zero(master)111        startRelay(keys.secretKey, keys.publicKey, identity, sourceDeps(identity))112    }113114    private fun startRelay(115        privateKey: ByteArray,116        publicKey: ByteArray,117        identity: LocalDeviceIdentity,118        sourceDeps: AdoptionSourceDeps,119    ) {120        collectJob = viewModelScope.launch {121            try {122                relayHost.start(123                    viewModelScope,124                    privateKey,125                    publicKey,126                    identity,127                    sourceDeps,128                ).collect { event ->129                    _state.value = when (event) {130                        is RelayAdoptionEvent.InvitationReady ->131                            CodePairUiState.ShowingInvitation(event.invitation)132                        is RelayAdoptionEvent.AwaitingPeerConfirmation ->133                            CodePairUiState.AwaitingPeerConfirmation(134                                event.sas,135                                event.deviceName,136                            )137                        is RelayAdoptionEvent.Adopted ->138                            CodePairUiState.Paired(event.deviceName)139                        RelayAdoptionEvent.Expired -> CodePairUiState.Expired140                        is RelayAdoptionEvent.Rejected -> {141                            if (BuildConfig.DEBUG) {142                                Log.w(143                                    "PeachPairing",144                                    "relay adoption rejected: ${event.reason}",145                                )146                            }147                            CodePairUiState.Failed(event.reason)148                        }149                    }150                }151            } finally {152                VaultCrypto.zero(privateKey)153                VaultCrypto.zero(publicKey)154            }155        }156    }157158    private fun sourceDeps(identity: LocalDeviceIdentity): AdoptionSourceDeps =159        object : AdoptionSourceDeps {160            override val sourceIdentity: LocalDeviceIdentity = identity161162            override suspend fun rootKeyHex(): String {163                val root = vault.syncMasterKey()164                    ?: throw IllegalStateException("vault_locked")165                return try {166                    root.joinToString("") { "%02x".format(it) }167                } finally {168                    VaultCrypto.zero(root)169                }170            }171172            override suspend fun vaultId(): String =173                vault.syncVaultId() ?: throw IllegalStateException("vault_locked")174175            override suspend fun license(): String? =176                license.getLicenseKey()?.takeIf(license::validateLicense)177178            override suspend fun applyLicense(licenseKey: String) {179                if (!license.validateLicense(licenseKey)) {180                    throw IllegalStateException("license_transfer_failed")181                }182                license.storeLicense(licenseKey)183                if (!license.isProActive()) {184                    throw IllegalStateException("license_transfer_failed")185                }186            }187188            // Opening this source screen while unlocked is the source-side189            // consent. The scanning device confirms the SAS before any grant.190            override suspend fun onConsent(request: AdoptionFrame.Request): Boolean = true191192            override suspend fun upsertPairedDevice(device: PairedDevice) {193                // SAS-confirmed direct adoption — the one consent signal strong194                // enough to lift a terminal revocation.195                deviceStore.clearRevocation(device.deviceId)196                deviceStore.upsert(device)197            }198        }199200    fun setBackgroundSync(enabled: Boolean) {201        backgroundSync.setEnabled(enabled)202        if (enabled) PeachSyncService.start(appContext) else PeachSyncService.stop(appContext)203    }204205    fun selectRelayMode(mode: SyncMode) {206        require(mode != SyncMode.OFF) { "Pairing requires a relay" }207        relaySettings.setSyncMode(mode)208        refreshRelayState()209        if (relaySettings.relayInForce() is RelayDecision.Dial) {210            restart()211        } else {212            stop()213            _state.value = CodePairUiState.Failed("private_relay_unset")214        }215    }216217    fun savePrivateRelayUrl(input: String): Boolean = runCatching {218        relaySettings.setRelayUrl(input)219        refreshRelayState()220        restart()221        true222    }.getOrDefault(false)223224    private fun refreshRelayState() {225        _syncMode.value = relaySettings.syncMode()226        _privateRelayUrl.value = relaySettings.privateRelayUrl()227    }228229    fun restart() {230        collectJob?.cancel()231        collectJob = null232        relayHost.stop()233        _state.value = CodePairUiState.Starting234        start()235    }236237    fun stop() {238        collectJob?.cancel()239        collectJob = null240        relayHost.stop()241    }242243    override fun onCleared() {244        stop()245        super.onCleared()246    }247}