CodexViewModel.kt

11.8 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.CodexCodec11import com.peachpasswords.codex.CodexErrorCode12import com.peachpasswords.codex.CodexException13import com.peachpasswords.codex.CodexGenerator14import com.peachpasswords.codex.CodexManifest15import com.peachpasswords.codex.CodexPayload16import com.peachpasswords.codex.CodexRestore17import com.peachpasswords.codex.CodexStep18import com.peachpasswords.license.LicenseRepository19import com.peachpasswords.vault.UnlockResult20import com.peachpasswords.vault.VaultRepository21import dagger.hilt.android.lifecycle.HiltViewModel22import dagger.hilt.android.qualifiers.ApplicationContext23import kotlinx.coroutines.Dispatchers24import kotlinx.coroutines.flow.MutableStateFlow25import kotlinx.coroutines.flow.StateFlow26import kotlinx.coroutines.flow.asStateFlow27import kotlinx.coroutines.launch28import kotlinx.coroutines.withContext29import javax.inject.Inject3031sealed interface GenerateState {32    data object Idle : GenerateState33    data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState34    data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState35    data class Error(val message: String) : GenerateState36}3738sealed interface RestoreState {39    data object Idle : RestoreState40    data class Scanning(val page: Int, val pages: Int) : RestoreState41    data class Shortfall(val found: Int, val missing: Int, val ready: Int, val total: Int) : RestoreState42    data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState43    data object Restoring : RestoreState44    data object Restored : RestoreState45    data class Error(val message: String) : RestoreState46}4748/**49 * D10 camera-scan phase. `Choosing` is the restore landing (two option cards);50 * `Scanning` runs while the camera analyzes frames; `Timeout` shows the51 * no-detection overlay after 10s. The decode handoff reuses [RestoreState].52 */53sealed interface CameraScanState {54    data object Choosing : CameraScanState55    data class Scanning(val codesFound: Int) : CameraScanState56    data class Timeout(val codesFound: Int) : CameraScanState57}5859@HiltViewModel60class CodexViewModel @Inject constructor(61    @ApplicationContext private val context: Context,62    private val vault: VaultRepository,63    private val license: LicenseRepository,64) : ViewModel() {6566    private val _generate = MutableStateFlow<GenerateState>(GenerateState.Idle)67    val generate: StateFlow<GenerateState> = _generate.asStateFlow()6869    /** D9 gate: Codex generation is allowed only once recovery words are set up. */70    val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet()7172    /** Browser parity: Codex generation is Pro-only, while restore remains free. */73    val isPro: Boolean get() = license.isProActive()7475    private val recoveryWords: List<String> by lazy {76        vault.syncMasterKey()?.let { k ->77            try { com.peachpasswords.vault.rootKeyToWords(k) }78            finally { com.peachpasswords.vault.VaultCrypto.zero(k) }79        } ?: emptyList()80    }8182    /** True when the three challenge words (positions 3, 7, 11) match the vault's recovery words. */83    fun gateMatches(w3: String, w7: String, w11: String): Boolean {84        val ws = recoveryWords85        if (ws.size < 11) return false86        return w3.trim().lowercase() == ws[2] &&87            w7.trim().lowercase() == ws[6] &&88            w11.trim().lowercase() == ws[10]89    }9091    private val _restore = MutableStateFlow<RestoreState>(RestoreState.Idle)92    val restore: StateFlow<RestoreState> = _restore.asStateFlow()9394    fun startGenerate(now: Long) {95        if (!isPro) {96            _generate.value = GenerateState.Error("Codex generation is a Pro feature.")97            return98        }99        _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1)100        viewModelScope.launch {101            try {102                val (pdf, manifest) = withContext(Dispatchers.Default) {103                    CodexGenerator.generate(vault, now) { step, cur, total ->104                        _generate.value = GenerateState.Running(step, cur, total)105                    }106                }107                _generate.value = GenerateState.Done(manifest, pdf)108            } catch (e: CodexException) {109                _generate.value = GenerateState.Error(e.message ?: e.code.name)110            } catch (e: Exception) {111                _generate.value = GenerateState.Error(e.message ?: "generation_failed")112            }113        }114    }115116    fun writePdf(uri: Uri): Boolean = try {117        val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false118        context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null119    } catch (_: Exception) {120        false121    }122123    fun scan(uri: Uri) {124        _restore.value = RestoreState.Scanning(0, 0)125        viewModelScope.launch {126            try {127                val payload = withContext(Dispatchers.Default) {128                    val result = CodexRestore.decodeFromUri(context, uri) { page, pages ->129                        _restore.value = RestoreState.Scanning(page, pages)130                    }131                    if (result.envelopes.isEmpty()) {132                        throw CodexException(133                            CodexErrorCode.INSUFFICIENT_CHUNKS,134                            "No Aztec codes could be read from this file",135                        )136                    }137                    try {138                        CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size139                    } catch (e: CodexException) {140                        val shortfall = codexRestoreShortfall(result.envelopes)141                        if (e.code == CodexErrorCode.INSUFFICIENT_CHUNKS && shortfall != null) {142                            _restore.value = RestoreState.Shortfall(143                                found = shortfall.found,144                                missing = shortfall.missing,145                                ready = shortfall.ready,146                                total = shortfall.total,147                            )148                            return@withContext null149                        }150                        throw e151                    }152                }153                if (payload != null) {154                    _restore.value = RestoreState.Decoded(payload.first, payload.second)155                }156            } catch (e: CodexException) {157                _restore.value = RestoreState.Error(codexRestoreErrorMessage(e.code, e.message))158            } catch (e: Exception) {159                _restore.value = RestoreState.Error(e.message ?: "restore_failed")160            }161        }162    }163164    fun confirmRestore(password: String, onDone: () -> Unit) {165        val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return166        _restore.value = RestoreState.Restoring167        viewModelScope.launch {168            when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {169                is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }170                is UnlockResult.WrongPassword -> _restore.value = RestoreState.Error(171                    codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"),172                )173                is UnlockResult.Error -> _restore.value = RestoreState.Error(174                    codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, r.reason),175                )176            }177        }178    }179180    fun resetRestore() { _restore.value = RestoreState.Idle }181182    // ---- D10 camera scan --------------------------------------------------183    private val _camera = MutableStateFlow<CameraScanState>(CameraScanState.Choosing)184    val camera: StateFlow<CameraScanState> = _camera.asStateFlow()185186    // Envelopes collected this session, deduped by stripe:index (mirrors decodeFromUri).187    private val cameraEnvelopes = LinkedHashMap<String, ChunkEnvelope>()188189    /** Show the two-card choose surface (default restore landing). */190    fun chooseRestore() {191        cameraEnvelopes.clear()192        _camera.value = CameraScanState.Choosing193        _restore.value = RestoreState.Idle194    }195196    /** Begin (or restart after a timeout) live camera scanning. */197    fun startCameraScan() {198        cameraEnvelopes.clear()199        _camera.value = CameraScanState.Scanning(0)200        _restore.value = RestoreState.Idle201    }202203    /** UI reports 10s elapsed with no successful reconstruction. */204    fun cameraTimedOut() {205        val s = _camera.value206        if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound)207    }208209    /**210     * Analyze one camera frame: decode one Aztec, accumulate, and on a successful211     * reconstruction transition to [RestoreState.Decoded] — the same entry point212     * the PDF path uses. Returns true once decoded (caller stops analysis). The213     * analyzer owns closing the [ImageProxy]; this does not close it.214     */215    fun onCameraFrame(image: ImageProxy): Boolean {216        if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded217        val text = CodexAztecFrame.decode(image) ?: return false218        val env = CodexRestore.parseEnvelope(text) ?: return false219        cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env)220        _camera.value = CameraScanState.Scanning(cameraEnvelopes.size)221        return try {222            val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList())223            _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size)224            true225        } catch (_: Exception) {226            false227        }228    }229}230231internal data class CodexShortfall(val found: Int, val missing: Int, val ready: Int, val total: Int)232233internal fun codexRestoreShortfall(envelopes: List<ChunkEnvelope>): CodexShortfall? {234    val good = envelopes235        .filter { CodexCodec.verifyChunkIntegrity(it) }236        .distinctBy { "${it.s}:${it.i}" }237    if (good.isEmpty()) return null238    val stripeCount = good.maxOf { it.capS.coerceAtLeast(1) }239    val byStripe = good.groupBy { it.s }240    var ready = 0241    var missing = 0242    for (stripe in 0 until stripeCount) {243        val stripeEnvelopes = byStripe[stripe].orEmpty()244        if (stripeEnvelopes.isEmpty()) continue245        val total = stripeEnvelopes.first().t246        val (dataShards, _) = com.peachpasswords.codex.deriveShardCounts(total)247        val found = stripeEnvelopes.count { it.i in 0 until total }248        if (found >= dataShards) ready++ else missing += dataShards - found249    }250    return CodexShortfall(found = good.size, missing = missing, ready = ready, total = stripeCount)251}252253internal fun codexRestoreErrorMessage(code: CodexErrorCode, raw: String?): String {254    val message = raw.orEmpty()255    return when {256        message.contains("WRONG_PASSPHRASE", ignoreCase = true) ||257            message.contains("wrong", ignoreCase = true) ->258            "wrong master password. try again."259        message.contains("INVALID_VAULT_BLOB", ignoreCase = true) ||260            code == CodexErrorCode.INVALID_VAULT_BLOB ->261            "the reconstructed vault is unreadable. please re-scan the Codex."262        message.contains("FINGERPRINT_MISMATCH", ignoreCase = true) ||263            code == CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH ->264            "vault integrity check failed. please re-scan the Codex."265        code == CodexErrorCode.INSUFFICIENT_CHUNKS && message.contains("No Aztec", ignoreCase = true) ->266            "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document."267        message.isNotBlank() -> message268        else -> "restoration failed. please try again."269    }270}