package com.peachpasswords.ui.codex import android.content.Context import android.net.Uri import androidx.core.content.FileProvider import android.util.Log import com.peachpasswords.BuildConfig 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.CodexCodec import com.peachpasswords.codex.CodexErrorCode 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 java.io.File 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 Shortfall(val found: Int, val missing: Int, val ready: Int, val total: 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() /** See VaultRepository.expectTransientBackground — call before launching a SAF picker/save dialog. */ fun expectTransientBackground() = vault.expectTransientBackground() private val recoveryWords: List by lazy { vault.sessionRootKey()?.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 sharePdfUri(fileName: String = codexPdfFileName()): Uri? = try { val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return null val dir = File(context.cacheDir, "codex").apply { mkdirs() } val file = File(dir, fileName) file.outputStream().use { it.write(pdf) } FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) } catch (_: Exception) { null } /** * Drop the share artefact once the chooser returns. The payload embeds both * sealed slots, so leaving it in the cache kept vault ciphertext on disk for * a reader with local access (PB-26-137). */ fun discardSharedPdf(fileName: String = codexPdfFileName()) { runCatching { File(File(context.cacheDir, "codex"), fileName).delete() } } 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( CodexErrorCode.INSUFFICIENT_CHUNKS, "No Aztec codes could be read from this file", ) } try { CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size } catch (e: CodexException) { val shortfall = codexRestoreShortfall(result.envelopes) if (e.code == CodexErrorCode.INSUFFICIENT_CHUNKS && shortfall != null) { _restore.value = RestoreState.Shortfall( found = shortfall.found, missing = shortfall.missing, ready = shortfall.ready, total = shortfall.total, ) return@withContext null } throw e } } if (payload != null) { _restore.value = RestoreState.Decoded(payload.first, payload.second) } } catch (e: CodexException) { _restore.value = RestoreState.Error(codexRestoreErrorMessage(e.code, e.message)) } 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 -> { // The three outcomes below look nearly identical on screen but // mean completely different things (bad password vs. a slot // shape neither reader understands). Codex screens are // FLAG_SECURE, so a screenshot can't be used to tell them // apart — this line is the only way to see which one happened. if (BuildConfig.DEBUG) Log.w("PeachCodex", "restore rejected: WRONG_PASSPHRASE") _restore.value = RestoreState.Error( codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"), ) } is UnlockResult.Error -> { if (BuildConfig.DEBUG) Log.w("PeachCodex", "restore failed: ${r.reason}") _restore.value = RestoreState.Error( codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, 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) _restore.value = RestoreState.Idle } /** 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 } } } internal data class CodexShortfall(val found: Int, val missing: Int, val ready: Int, val total: Int) internal fun codexRestoreShortfall(envelopes: List): CodexShortfall? { val good = envelopes .filter { CodexCodec.verifyChunkIntegrity(it) } .distinctBy { "${it.s}:${it.i}" } if (good.isEmpty()) return null val stripeCount = good.maxOf { it.capS.coerceAtLeast(1) } val byStripe = good.groupBy { it.s } var ready = 0 var missing = 0 for (stripe in 0 until stripeCount) { val stripeEnvelopes = byStripe[stripe].orEmpty() if (stripeEnvelopes.isEmpty()) continue val total = stripeEnvelopes.first().t val (dataShards, _) = com.peachpasswords.codex.deriveShardCounts(total) val found = stripeEnvelopes.count { it.i in 0 until total } if (found >= dataShards) ready++ else missing += dataShards - found } return CodexShortfall(found = good.size, missing = missing, ready = ready, total = stripeCount) } internal fun codexRestoreErrorMessage(code: CodexErrorCode, raw: String?): String { val message = raw.orEmpty() return when { message.contains("WRONG_PASSPHRASE", ignoreCase = true) || message.contains("wrong", ignoreCase = true) -> "wrong master password. try again." message.contains("INVALID_VAULT_BLOB", ignoreCase = true) || code == CodexErrorCode.INVALID_VAULT_BLOB -> "the reconstructed vault is unreadable. please re-scan the Codex." message.contains("FINGERPRINT_MISMATCH", ignoreCase = true) || code == CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH -> "vault integrity check failed. please re-scan the Codex." code == CodexErrorCode.INSUFFICIENT_CHUNKS && message.contains("No Aztec", ignoreCase = true) -> "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document." message.isNotBlank() -> message else -> "restoration failed. please try again." } }