CodexViewModel.kt

13.6 KB · raw

1package com.peachpasswords.ui.codex23import android.content.Context4import android.net.Uri5import androidx.core.content.FileProvider6import android.util.Log7import com.peachpasswords.BuildConfig8import androidx.lifecycle.ViewModel9import androidx.lifecycle.viewModelScope10import androidx.camera.core.ImageProxy11import com.peachpasswords.codex.ChunkEnvelope12import com.peachpasswords.codex.CodexAztecFrame13import com.peachpasswords.codex.CodexCodec14import com.peachpasswords.codex.CodexErrorCode15import com.peachpasswords.codex.CodexException16import com.peachpasswords.codex.CodexGenerator17import com.peachpasswords.codex.CodexManifest18import com.peachpasswords.codex.CodexPayload19import com.peachpasswords.codex.CodexRestore20import com.peachpasswords.codex.CodexStep21import com.peachpasswords.license.LicenseRepository22import com.peachpasswords.vault.UnlockResult23import com.peachpasswords.vault.VaultRepository24import dagger.hilt.android.lifecycle.HiltViewModel25import dagger.hilt.android.qualifiers.ApplicationContext26import kotlinx.coroutines.Dispatchers27import kotlinx.coroutines.flow.MutableStateFlow28import kotlinx.coroutines.flow.StateFlow29import kotlinx.coroutines.flow.asStateFlow30import kotlinx.coroutines.launch31import kotlinx.coroutines.withContext32import java.io.File33import javax.inject.Inject3435sealed interface GenerateState {36    data object Idle : GenerateState37    data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState38    data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState39    data class Error(val message: String) : GenerateState40}4142sealed interface RestoreState {43    data object Idle : RestoreState44    data class Scanning(val page: Int, val pages: Int) : RestoreState45    data class Shortfall(val found: Int, val missing: Int, val ready: Int, val total: Int) : RestoreState46    data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState47    data object Restoring : RestoreState48    data object Restored : RestoreState49    data class Error(val message: String) : RestoreState50}5152/**53 * D10 camera-scan phase. `Choosing` is the restore landing (two option cards);54 * `Scanning` runs while the camera analyzes frames; `Timeout` shows the55 * no-detection overlay after 10s. The decode handoff reuses [RestoreState].56 */57sealed interface CameraScanState {58    data object Choosing : CameraScanState59    data class Scanning(val codesFound: Int) : CameraScanState60    data class Timeout(val codesFound: Int) : CameraScanState61}6263@HiltViewModel64class CodexViewModel @Inject constructor(65    @ApplicationContext private val context: Context,66    private val vault: VaultRepository,67    private val license: LicenseRepository,68) : ViewModel() {6970    private val _generate = MutableStateFlow<GenerateState>(GenerateState.Idle)71    val generate: StateFlow<GenerateState> = _generate.asStateFlow()7273    /** D9 gate: Codex generation is allowed only once recovery words are set up. */74    val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet()7576    /** Browser parity: Codex generation is Pro-only, while restore remains free. */77    val isPro: Boolean get() = license.isProActive()7879    /** See VaultRepository.expectTransientBackground — call before launching a SAF picker/save dialog. */80    fun expectTransientBackground() = vault.expectTransientBackground()8182    private val recoveryWords: List<String> by lazy {83        vault.sessionRootKey()?.let { k ->84            try { com.peachpasswords.vault.rootKeyToWords(k) }85            finally { com.peachpasswords.vault.VaultCrypto.zero(k) }86        } ?: emptyList()87    }8889    /** True when the three challenge words (positions 3, 7, 11) match the vault's recovery words. */90    fun gateMatches(w3: String, w7: String, w11: String): Boolean {91        val ws = recoveryWords92        if (ws.size < 11) return false93        return w3.trim().lowercase() == ws[2] &&94            w7.trim().lowercase() == ws[6] &&95            w11.trim().lowercase() == ws[10]96    }9798    private val _restore = MutableStateFlow<RestoreState>(RestoreState.Idle)99    val restore: StateFlow<RestoreState> = _restore.asStateFlow()100101    fun startGenerate(now: Long) {102        if (!isPro) {103            _generate.value = GenerateState.Error("Codex generation is a Pro feature.")104            return105        }106        _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1)107        viewModelScope.launch {108            try {109                val (pdf, manifest) = withContext(Dispatchers.Default) {110                    CodexGenerator.generate(vault, now) { step, cur, total ->111                        _generate.value = GenerateState.Running(step, cur, total)112                    }113                }114                _generate.value = GenerateState.Done(manifest, pdf)115            } catch (e: CodexException) {116                _generate.value = GenerateState.Error(e.message ?: e.code.name)117            } catch (e: Exception) {118                _generate.value = GenerateState.Error(e.message ?: "generation_failed")119            }120        }121    }122123    fun writePdf(uri: Uri): Boolean = try {124        val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false125        context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null126    } catch (_: Exception) {127        false128    }129130    fun sharePdfUri(fileName: String = codexPdfFileName()): Uri? = try {131        val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return null132        val dir = File(context.cacheDir, "codex").apply { mkdirs() }133        val file = File(dir, fileName)134        file.outputStream().use { it.write(pdf) }135        FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)136    } catch (_: Exception) {137        null138    }139140    /**141     * Drop the share artefact once the chooser returns. The payload embeds both142     * sealed slots, so leaving it in the cache kept vault ciphertext on disk for143     * a reader with local access (PB-26-137).144     */145    fun discardSharedPdf(fileName: String = codexPdfFileName()) {146        runCatching { File(File(context.cacheDir, "codex"), fileName).delete() }147    }148149    fun scan(uri: Uri) {150        _restore.value = RestoreState.Scanning(0, 0)151        viewModelScope.launch {152            try {153                val payload = withContext(Dispatchers.Default) {154                    val result = CodexRestore.decodeFromUri(context, uri) { page, pages ->155                        _restore.value = RestoreState.Scanning(page, pages)156                    }157                    if (result.envelopes.isEmpty()) {158                        throw CodexException(159                            CodexErrorCode.INSUFFICIENT_CHUNKS,160                            "No Aztec codes could be read from this file",161                        )162                    }163                    try {164                        CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size165                    } catch (e: CodexException) {166                        val shortfall = codexRestoreShortfall(result.envelopes)167                        if (e.code == CodexErrorCode.INSUFFICIENT_CHUNKS && shortfall != null) {168                            _restore.value = RestoreState.Shortfall(169                                found = shortfall.found,170                                missing = shortfall.missing,171                                ready = shortfall.ready,172                                total = shortfall.total,173                            )174                            return@withContext null175                        }176                        throw e177                    }178                }179                if (payload != null) {180                    _restore.value = RestoreState.Decoded(payload.first, payload.second)181                }182            } catch (e: CodexException) {183                _restore.value = RestoreState.Error(codexRestoreErrorMessage(e.code, e.message))184            } catch (e: Exception) {185                _restore.value = RestoreState.Error(e.message ?: "restore_failed")186            }187        }188    }189190    fun confirmRestore(password: String, onDone: () -> Unit) {191        val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return192        _restore.value = RestoreState.Restoring193        viewModelScope.launch {194            when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {195                is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }196                is UnlockResult.WrongPassword -> {197                    // The three outcomes below look nearly identical on screen but198                    // mean completely different things (bad password vs. a slot199                    // shape neither reader understands). Codex screens are200                    // FLAG_SECURE, so a screenshot can't be used to tell them201                    // apart — this line is the only way to see which one happened.202                    if (BuildConfig.DEBUG) Log.w("PeachCodex", "restore rejected: WRONG_PASSPHRASE")203                    _restore.value = RestoreState.Error(204                        codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"),205                    )206                }207                is UnlockResult.Error -> {208                    if (BuildConfig.DEBUG) Log.w("PeachCodex", "restore failed: ${r.reason}")209                    _restore.value = RestoreState.Error(210                        codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, r.reason),211                    )212                }213            }214        }215    }216217    fun resetRestore() { _restore.value = RestoreState.Idle }218219    // ---- D10 camera scan --------------------------------------------------220    private val _camera = MutableStateFlow<CameraScanState>(CameraScanState.Choosing)221    val camera: StateFlow<CameraScanState> = _camera.asStateFlow()222223    // Envelopes collected this session, deduped by stripe:index (mirrors decodeFromUri).224    private val cameraEnvelopes = LinkedHashMap<String, ChunkEnvelope>()225226    /** Show the two-card choose surface (default restore landing). */227    fun chooseRestore() {228        cameraEnvelopes.clear()229        _camera.value = CameraScanState.Choosing230        _restore.value = RestoreState.Idle231    }232233    /** Begin (or restart after a timeout) live camera scanning. */234    fun startCameraScan() {235        cameraEnvelopes.clear()236        _camera.value = CameraScanState.Scanning(0)237        _restore.value = RestoreState.Idle238    }239240    /** UI reports 10s elapsed with no successful reconstruction. */241    fun cameraTimedOut() {242        val s = _camera.value243        if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound)244    }245246    /**247     * Analyze one camera frame: decode one Aztec, accumulate, and on a successful248     * reconstruction transition to [RestoreState.Decoded] — the same entry point249     * the PDF path uses. Returns true once decoded (caller stops analysis). The250     * analyzer owns closing the [ImageProxy]; this does not close it.251     */252    fun onCameraFrame(image: ImageProxy): Boolean {253        if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded254        val text = CodexAztecFrame.decode(image) ?: return false255        val env = CodexRestore.parseEnvelope(text) ?: return false256        cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env)257        _camera.value = CameraScanState.Scanning(cameraEnvelopes.size)258        return try {259            val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList())260            _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size)261            true262        } catch (_: Exception) {263            false264        }265    }266}267268internal data class CodexShortfall(val found: Int, val missing: Int, val ready: Int, val total: Int)269270internal fun codexRestoreShortfall(envelopes: List<ChunkEnvelope>): CodexShortfall? {271    val good = envelopes272        .filter { CodexCodec.verifyChunkIntegrity(it) }273        .distinctBy { "${it.s}:${it.i}" }274    if (good.isEmpty()) return null275    val stripeCount = good.maxOf { it.capS.coerceAtLeast(1) }276    val byStripe = good.groupBy { it.s }277    var ready = 0278    var missing = 0279    for (stripe in 0 until stripeCount) {280        val stripeEnvelopes = byStripe[stripe].orEmpty()281        if (stripeEnvelopes.isEmpty()) continue282        val total = stripeEnvelopes.first().t283        val (dataShards, _) = com.peachpasswords.codex.deriveShardCounts(total)284        val found = stripeEnvelopes.count { it.i in 0 until total }285        if (found >= dataShards) ready++ else missing += dataShards - found286    }287    return CodexShortfall(found = good.size, missing = missing, ready = ready, total = stripeCount)288}289290internal fun codexRestoreErrorMessage(code: CodexErrorCode, raw: String?): String {291    val message = raw.orEmpty()292    return when {293        message.contains("WRONG_PASSPHRASE", ignoreCase = true) ||294            message.contains("wrong", ignoreCase = true) ->295            "wrong master password. try again."296        message.contains("INVALID_VAULT_BLOB", ignoreCase = true) ||297            code == CodexErrorCode.INVALID_VAULT_BLOB ->298            "the reconstructed vault is unreadable. please re-scan the Codex."299        message.contains("FINGERPRINT_MISMATCH", ignoreCase = true) ||300            code == CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH ->301            "vault integrity check failed. please re-scan the Codex."302        code == CodexErrorCode.INSUFFICIENT_CHUNKS && message.contains("No Aztec", ignoreCase = true) ->303            "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document."304        message.isNotBlank() -> message305        else -> "restoration failed. please try again."306    }307}