package com.peachpasswords.ui.codex import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.camera.core.ImageProxy import com.peachpasswords.codex.ChunkEnvelope import com.peachpasswords.codex.CodexAztecFrame import com.peachpasswords.codex.CodexException import com.peachpasswords.codex.CodexGenerator import com.peachpasswords.codex.CodexManifest import com.peachpasswords.codex.CodexPayload import com.peachpasswords.codex.CodexRestore import com.peachpasswords.codex.CodexStep import com.peachpasswords.license.LicenseRepository import com.peachpasswords.vault.UnlockResult import com.peachpasswords.vault.VaultRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject sealed interface GenerateState { data object Idle : GenerateState data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState data class Error(val message: String) : GenerateState } sealed interface RestoreState { data object Idle : RestoreState data class Scanning(val page: Int, val pages: Int) : RestoreState data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState data object Restoring : RestoreState data object Restored : RestoreState data class Error(val message: String) : RestoreState } /** * D10 camera-scan phase. `Choosing` is the restore landing (two option cards); * `Scanning` runs while the camera analyzes frames; `Timeout` shows the * no-detection overlay after 10s. The decode handoff reuses [RestoreState]. */ sealed interface CameraScanState { data object Choosing : CameraScanState data class Scanning(val codesFound: Int) : CameraScanState data class Timeout(val codesFound: Int) : CameraScanState } @HiltViewModel class CodexViewModel @Inject constructor( @ApplicationContext private val context: Context, private val vault: VaultRepository, private val license: LicenseRepository, ) : ViewModel() { private val _generate = MutableStateFlow(GenerateState.Idle) val generate: StateFlow = _generate.asStateFlow() /** D9 gate: Codex generation is allowed only once recovery words are set up. */ val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet() /** Browser parity: Codex generation is Pro-only, while restore remains free. */ val isPro: Boolean get() = license.isProActive() private val recoveryWords: List by lazy { vault.syncMasterKey()?.let { k -> try { com.peachpasswords.vault.rootKeyToWords(k) } finally { com.peachpasswords.vault.VaultCrypto.zero(k) } } ?: emptyList() } /** True when the three challenge words (positions 3, 7, 11) match the vault's recovery words. */ fun gateMatches(w3: String, w7: String, w11: String): Boolean { val ws = recoveryWords if (ws.size < 11) return false return w3.trim().lowercase() == ws[2] && w7.trim().lowercase() == ws[6] && w11.trim().lowercase() == ws[10] } private val _restore = MutableStateFlow(RestoreState.Idle) val restore: StateFlow = _restore.asStateFlow() fun startGenerate(now: Long) { if (!isPro) { _generate.value = GenerateState.Error("Codex generation is a Pro feature.") return } _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1) viewModelScope.launch { try { val (pdf, manifest) = withContext(Dispatchers.Default) { CodexGenerator.generate(vault, now) { step, cur, total -> _generate.value = GenerateState.Running(step, cur, total) } } _generate.value = GenerateState.Done(manifest, pdf) } catch (e: CodexException) { _generate.value = GenerateState.Error(e.message ?: e.code.name) } catch (e: Exception) { _generate.value = GenerateState.Error(e.message ?: "generation_failed") } } } fun writePdf(uri: Uri): Boolean = try { val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null } catch (_: Exception) { false } fun scan(uri: Uri) { _restore.value = RestoreState.Scanning(0, 0) viewModelScope.launch { try { val payload = withContext(Dispatchers.Default) { val result = CodexRestore.decodeFromUri(context, uri) { page, pages -> _restore.value = RestoreState.Scanning(page, pages) } if (result.envelopes.isEmpty()) { throw CodexException( com.peachpasswords.codex.CodexErrorCode.INSUFFICIENT_CHUNKS, "No Aztec codes could be read from this file", ) } CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size } _restore.value = RestoreState.Decoded(payload.first, payload.second) } catch (e: CodexException) { _restore.value = RestoreState.Error(e.message ?: e.code.name) } catch (e: Exception) { _restore.value = RestoreState.Error(e.message ?: "restore_failed") } } } fun confirmRestore(password: String, onDone: () -> Unit) { val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return _restore.value = RestoreState.Restoring viewModelScope.launch { when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) { is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() } is UnlockResult.WrongPassword -> _restore.value = RestoreState.Error("Wrong master password") is UnlockResult.Error -> _restore.value = RestoreState.Error(r.reason) } } } fun resetRestore() { _restore.value = RestoreState.Idle } // ---- D10 camera scan -------------------------------------------------- private val _camera = MutableStateFlow(CameraScanState.Choosing) val camera: StateFlow = _camera.asStateFlow() // Envelopes collected this session, deduped by stripe:index (mirrors decodeFromUri). private val cameraEnvelopes = LinkedHashMap() /** Show the two-card choose surface (default restore landing). */ fun chooseRestore() { cameraEnvelopes.clear() _camera.value = CameraScanState.Choosing _restore.value = RestoreState.Idle } /** Begin (or restart after a timeout) live camera scanning. */ fun startCameraScan() { cameraEnvelopes.clear() _camera.value = CameraScanState.Scanning(0) } /** UI reports 10s elapsed with no successful reconstruction. */ fun cameraTimedOut() { val s = _camera.value if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound) } /** * Analyze one camera frame: decode one Aztec, accumulate, and on a successful * reconstruction transition to [RestoreState.Decoded] — the same entry point * the PDF path uses. Returns true once decoded (caller stops analysis). The * analyzer owns closing the [ImageProxy]; this does not close it. */ fun onCameraFrame(image: ImageProxy): Boolean { if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded val text = CodexAztecFrame.decode(image) ?: return false val env = CodexRestore.parseEnvelope(text) ?: return false cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env) _camera.value = CameraScanState.Scanning(cameraEnvelopes.size) return try { val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList()) _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size) true } catch (_: Exception) { false } } }