CodexViewModel.kt

8.6 KB · raw

1package com.peachpasswords.ui.codex23import android.content.Context4import android.net.Uri5import androidx.lifecycle.ViewModel6import androidx.lifecycle.viewModelScope7import androidx.camera.core.ImageProxy8import com.peachpasswords.codex.ChunkEnvelope9import com.peachpasswords.codex.CodexAztecFrame10import com.peachpasswords.codex.CodexException11import com.peachpasswords.codex.CodexGenerator12import com.peachpasswords.codex.CodexManifest13import com.peachpasswords.codex.CodexPayload14import com.peachpasswords.codex.CodexRestore15import com.peachpasswords.codex.CodexStep16import com.peachpasswords.license.LicenseRepository17import com.peachpasswords.vault.UnlockResult18import com.peachpasswords.vault.VaultRepository19import dagger.hilt.android.lifecycle.HiltViewModel20import dagger.hilt.android.qualifiers.ApplicationContext21import kotlinx.coroutines.Dispatchers22import kotlinx.coroutines.flow.MutableStateFlow23import kotlinx.coroutines.flow.StateFlow24import kotlinx.coroutines.flow.asStateFlow25import kotlinx.coroutines.launch26import kotlinx.coroutines.withContext27import javax.inject.Inject2829sealed interface GenerateState {30    data object Idle : GenerateState31    data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState32    data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState33    data class Error(val message: String) : GenerateState34}3536sealed interface RestoreState {37    data object Idle : RestoreState38    data class Scanning(val page: Int, val pages: Int) : RestoreState39    data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState40    data object Restoring : RestoreState41    data object Restored : RestoreState42    data class Error(val message: String) : RestoreState43}4445/**46 * D10 camera-scan phase. `Choosing` is the restore landing (two option cards);47 * `Scanning` runs while the camera analyzes frames; `Timeout` shows the48 * no-detection overlay after 10s. The decode handoff reuses [RestoreState].49 */50sealed interface CameraScanState {51    data object Choosing : CameraScanState52    data class Scanning(val codesFound: Int) : CameraScanState53    data class Timeout(val codesFound: Int) : CameraScanState54}5556@HiltViewModel57class CodexViewModel @Inject constructor(58    @ApplicationContext private val context: Context,59    private val vault: VaultRepository,60    private val license: LicenseRepository,61) : ViewModel() {6263    private val _generate = MutableStateFlow<GenerateState>(GenerateState.Idle)64    val generate: StateFlow<GenerateState> = _generate.asStateFlow()6566    /** D9 gate: Codex generation is allowed only once recovery words are set up. */67    val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet()6869    /** Browser parity: Codex generation is Pro-only, while restore remains free. */70    val isPro: Boolean get() = license.isProActive()7172    private val recoveryWords: List<String> by lazy {73        vault.syncMasterKey()?.let { k ->74            try { com.peachpasswords.vault.rootKeyToWords(k) }75            finally { com.peachpasswords.vault.VaultCrypto.zero(k) }76        } ?: emptyList()77    }7879    /** True when the three challenge words (positions 3, 7, 11) match the vault's recovery words. */80    fun gateMatches(w3: String, w7: String, w11: String): Boolean {81        val ws = recoveryWords82        if (ws.size < 11) return false83        return w3.trim().lowercase() == ws[2] &&84            w7.trim().lowercase() == ws[6] &&85            w11.trim().lowercase() == ws[10]86    }8788    private val _restore = MutableStateFlow<RestoreState>(RestoreState.Idle)89    val restore: StateFlow<RestoreState> = _restore.asStateFlow()9091    fun startGenerate(now: Long) {92        if (!isPro) {93            _generate.value = GenerateState.Error("Codex generation is a Pro feature.")94            return95        }96        _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1)97        viewModelScope.launch {98            try {99                val (pdf, manifest) = withContext(Dispatchers.Default) {100                    CodexGenerator.generate(vault, now) { step, cur, total ->101                        _generate.value = GenerateState.Running(step, cur, total)102                    }103                }104                _generate.value = GenerateState.Done(manifest, pdf)105            } catch (e: CodexException) {106                _generate.value = GenerateState.Error(e.message ?: e.code.name)107            } catch (e: Exception) {108                _generate.value = GenerateState.Error(e.message ?: "generation_failed")109            }110        }111    }112113    fun writePdf(uri: Uri): Boolean = try {114        val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false115        context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null116    } catch (_: Exception) {117        false118    }119120    fun scan(uri: Uri) {121        _restore.value = RestoreState.Scanning(0, 0)122        viewModelScope.launch {123            try {124                val payload = withContext(Dispatchers.Default) {125                    val result = CodexRestore.decodeFromUri(context, uri) { page, pages ->126                        _restore.value = RestoreState.Scanning(page, pages)127                    }128                    if (result.envelopes.isEmpty()) {129                        throw CodexException(130                            com.peachpasswords.codex.CodexErrorCode.INSUFFICIENT_CHUNKS,131                            "No Aztec codes could be read from this file",132                        )133                    }134                    CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size135                }136                _restore.value = RestoreState.Decoded(payload.first, payload.second)137            } catch (e: CodexException) {138                _restore.value = RestoreState.Error(e.message ?: e.code.name)139            } catch (e: Exception) {140                _restore.value = RestoreState.Error(e.message ?: "restore_failed")141            }142        }143    }144145    fun confirmRestore(password: String, onDone: () -> Unit) {146        val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return147        _restore.value = RestoreState.Restoring148        viewModelScope.launch {149            when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {150                is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }151                is UnlockResult.WrongPassword -> _restore.value = RestoreState.Error("Wrong master password")152                is UnlockResult.Error -> _restore.value = RestoreState.Error(r.reason)153            }154        }155    }156157    fun resetRestore() { _restore.value = RestoreState.Idle }158159    // ---- D10 camera scan --------------------------------------------------160    private val _camera = MutableStateFlow<CameraScanState>(CameraScanState.Choosing)161    val camera: StateFlow<CameraScanState> = _camera.asStateFlow()162163    // Envelopes collected this session, deduped by stripe:index (mirrors decodeFromUri).164    private val cameraEnvelopes = LinkedHashMap<String, ChunkEnvelope>()165166    /** Show the two-card choose surface (default restore landing). */167    fun chooseRestore() {168        cameraEnvelopes.clear()169        _camera.value = CameraScanState.Choosing170        _restore.value = RestoreState.Idle171    }172173    /** Begin (or restart after a timeout) live camera scanning. */174    fun startCameraScan() {175        cameraEnvelopes.clear()176        _camera.value = CameraScanState.Scanning(0)177    }178179    /** UI reports 10s elapsed with no successful reconstruction. */180    fun cameraTimedOut() {181        val s = _camera.value182        if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound)183    }184185    /**186     * Analyze one camera frame: decode one Aztec, accumulate, and on a successful187     * reconstruction transition to [RestoreState.Decoded] — the same entry point188     * the PDF path uses. Returns true once decoded (caller stops analysis). The189     * analyzer owns closing the [ImageProxy]; this does not close it.190     */191    fun onCameraFrame(image: ImageProxy): Boolean {192        if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded193        val text = CodexAztecFrame.decode(image) ?: return false194        val env = CodexRestore.parseEnvelope(text) ?: return false195        cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env)196        _camera.value = CameraScanState.Scanning(cameraEnvelopes.size)197        return try {198            val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList())199            _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size)200            true201        } catch (_: Exception) {202            false203        }204    }205}