CodexViewModel.kt

8.2 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.vault.UnlockResult17import com.peachpasswords.vault.VaultRepository18import dagger.hilt.android.lifecycle.HiltViewModel19import dagger.hilt.android.qualifiers.ApplicationContext20import kotlinx.coroutines.Dispatchers21import kotlinx.coroutines.flow.MutableStateFlow22import kotlinx.coroutines.flow.StateFlow23import kotlinx.coroutines.flow.asStateFlow24import kotlinx.coroutines.launch25import kotlinx.coroutines.withContext26import javax.inject.Inject2728sealed interface GenerateState {29    data object Idle : GenerateState30    data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState31    data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState32    data class Error(val message: String) : GenerateState33}3435sealed interface RestoreState {36    data object Idle : RestoreState37    data class Scanning(val page: Int, val pages: Int) : RestoreState38    data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState39    data object Restoring : RestoreState40    data object Restored : RestoreState41    data class Error(val message: String) : RestoreState42}4344/**45 * D10 camera-scan phase. `Choosing` is the restore landing (two option cards);46 * `Scanning` runs while the camera analyzes frames; `Timeout` shows the47 * no-detection overlay after 10s. The decode handoff reuses [RestoreState].48 */49sealed interface CameraScanState {50    data object Choosing : CameraScanState51    data class Scanning(val codesFound: Int) : CameraScanState52    data class Timeout(val codesFound: Int) : CameraScanState53}5455@HiltViewModel56class CodexViewModel @Inject constructor(57    @ApplicationContext private val context: Context,58    private val vault: VaultRepository,59) : ViewModel() {6061    private val _generate = MutableStateFlow<GenerateState>(GenerateState.Idle)62    val generate: StateFlow<GenerateState> = _generate.asStateFlow()6364    /** D9 gate: Codex generation is allowed only once recovery words are set up. */65    val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet()6667    private val recoveryWords: List<String> by lazy {68        vault.syncMasterKey()?.let { k ->69            try { com.peachpasswords.vault.rootKeyToWords(k) }70            finally { com.peachpasswords.vault.VaultCrypto.zero(k) }71        } ?: emptyList()72    }7374    /** True when the three challenge words (positions 3, 7, 11) match the vault's recovery words. */75    fun gateMatches(w3: String, w7: String, w11: String): Boolean {76        val ws = recoveryWords77        if (ws.size < 11) return false78        return w3.trim().lowercase() == ws[2] &&79            w7.trim().lowercase() == ws[6] &&80            w11.trim().lowercase() == ws[10]81    }8283    private val _restore = MutableStateFlow<RestoreState>(RestoreState.Idle)84    val restore: StateFlow<RestoreState> = _restore.asStateFlow()8586    fun startGenerate(now: Long) {87        _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1)88        viewModelScope.launch {89            try {90                val (pdf, manifest) = withContext(Dispatchers.Default) {91                    CodexGenerator.generate(vault, now) { step, cur, total ->92                        _generate.value = GenerateState.Running(step, cur, total)93                    }94                }95                _generate.value = GenerateState.Done(manifest, pdf)96            } catch (e: CodexException) {97                _generate.value = GenerateState.Error(e.message ?: e.code.name)98            } catch (e: Exception) {99                _generate.value = GenerateState.Error(e.message ?: "generation_failed")100            }101        }102    }103104    fun writePdf(uri: Uri): Boolean = try {105        val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false106        context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null107    } catch (_: Exception) {108        false109    }110111    fun scan(uri: Uri) {112        _restore.value = RestoreState.Scanning(0, 0)113        viewModelScope.launch {114            try {115                val payload = withContext(Dispatchers.Default) {116                    val result = CodexRestore.decodeFromUri(context, uri) { page, pages ->117                        _restore.value = RestoreState.Scanning(page, pages)118                    }119                    if (result.envelopes.isEmpty()) {120                        throw CodexException(121                            com.peachpasswords.codex.CodexErrorCode.INSUFFICIENT_CHUNKS,122                            "No Aztec codes could be read from this file",123                        )124                    }125                    CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size126                }127                _restore.value = RestoreState.Decoded(payload.first, payload.second)128            } catch (e: CodexException) {129                _restore.value = RestoreState.Error(e.message ?: e.code.name)130            } catch (e: Exception) {131                _restore.value = RestoreState.Error(e.message ?: "restore_failed")132            }133        }134    }135136    fun confirmRestore(password: String, onDone: () -> Unit) {137        val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return138        _restore.value = RestoreState.Restoring139        viewModelScope.launch {140            when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {141                is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }142                is UnlockResult.WrongPassword -> _restore.value = RestoreState.Error("Wrong master password")143                is UnlockResult.Error -> _restore.value = RestoreState.Error(r.reason)144            }145        }146    }147148    fun resetRestore() { _restore.value = RestoreState.Idle }149150    // ---- D10 camera scan --------------------------------------------------151    private val _camera = MutableStateFlow<CameraScanState>(CameraScanState.Choosing)152    val camera: StateFlow<CameraScanState> = _camera.asStateFlow()153154    // Envelopes collected this session, deduped by stripe:index (mirrors decodeFromUri).155    private val cameraEnvelopes = LinkedHashMap<String, ChunkEnvelope>()156157    /** Show the two-card choose surface (default restore landing). */158    fun chooseRestore() {159        cameraEnvelopes.clear()160        _camera.value = CameraScanState.Choosing161        _restore.value = RestoreState.Idle162    }163164    /** Begin (or restart after a timeout) live camera scanning. */165    fun startCameraScan() {166        cameraEnvelopes.clear()167        _camera.value = CameraScanState.Scanning(0)168    }169170    /** UI reports 10s elapsed with no successful reconstruction. */171    fun cameraTimedOut() {172        val s = _camera.value173        if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound)174    }175176    /**177     * Analyze one camera frame: decode one Aztec, accumulate, and on a successful178     * reconstruction transition to [RestoreState.Decoded] — the same entry point179     * the PDF path uses. Returns true once decoded (caller stops analysis). The180     * analyzer owns closing the [ImageProxy]; this does not close it.181     */182    fun onCameraFrame(image: ImageProxy): Boolean {183        if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded184        val text = CodexAztecFrame.decode(image) ?: return false185        val env = CodexRestore.parseEnvelope(text) ?: return false186        cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env)187        _camera.value = CameraScanState.Scanning(cameraEnvelopes.size)188        return try {189            val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList())190            _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size)191            true192        } catch (_: Exception) {193            false194        }195    }196}