CodexViewModel.kt

12.4 KB · raw

1package com.peachpasswords.ui.codex23import android.content.Context4import android.net.Uri5import androidx.core.content.FileProvider6import androidx.lifecycle.ViewModel7import androidx.lifecycle.viewModelScope8import androidx.camera.core.ImageProxy9import com.peachpasswords.codex.ChunkEnvelope10import com.peachpasswords.codex.CodexAztecFrame11import com.peachpasswords.codex.CodexCodec12import com.peachpasswords.codex.CodexErrorCode13import com.peachpasswords.codex.CodexException14import com.peachpasswords.codex.CodexGenerator15import com.peachpasswords.codex.CodexManifest16import com.peachpasswords.codex.CodexPayload17import com.peachpasswords.codex.CodexRestore18import com.peachpasswords.codex.CodexStep19import com.peachpasswords.license.LicenseRepository20import com.peachpasswords.vault.UnlockResult21import com.peachpasswords.vault.VaultRepository22import dagger.hilt.android.lifecycle.HiltViewModel23import dagger.hilt.android.qualifiers.ApplicationContext24import kotlinx.coroutines.Dispatchers25import kotlinx.coroutines.flow.MutableStateFlow26import kotlinx.coroutines.flow.StateFlow27import kotlinx.coroutines.flow.asStateFlow28import kotlinx.coroutines.launch29import kotlinx.coroutines.withContext30import java.io.File31import javax.inject.Inject3233sealed interface GenerateState {34    data object Idle : GenerateState35    data class Running(val step: CodexStep, val current: Int, val total: Int) : GenerateState36    data class Done(val manifest: CodexManifest, val pdf: ByteArray) : GenerateState37    data class Error(val message: String) : GenerateState38}3940sealed interface RestoreState {41    data object Idle : RestoreState42    data class Scanning(val page: Int, val pages: Int) : RestoreState43    data class Shortfall(val found: Int, val missing: Int, val ready: Int, val total: Int) : RestoreState44    data class Decoded(val payload: CodexPayload, val codesFound: Int) : RestoreState45    data object Restoring : RestoreState46    data object Restored : RestoreState47    data class Error(val message: String) : RestoreState48}4950/**51 * D10 camera-scan phase. `Choosing` is the restore landing (two option cards);52 * `Scanning` runs while the camera analyzes frames; `Timeout` shows the53 * no-detection overlay after 10s. The decode handoff reuses [RestoreState].54 */55sealed interface CameraScanState {56    data object Choosing : CameraScanState57    data class Scanning(val codesFound: Int) : CameraScanState58    data class Timeout(val codesFound: Int) : CameraScanState59}6061@HiltViewModel62class CodexViewModel @Inject constructor(63    @ApplicationContext private val context: Context,64    private val vault: VaultRepository,65    private val license: LicenseRepository,66) : ViewModel() {6768    private val _generate = MutableStateFlow<GenerateState>(GenerateState.Idle)69    val generate: StateFlow<GenerateState> = _generate.asStateFlow()7071    /** D9 gate: Codex generation is allowed only once recovery words are set up. */72    val recoveryWordsSet: Boolean get() = vault.isRecoveryWordsSet()7374    /** Browser parity: Codex generation is Pro-only, while restore remains free. */75    val isPro: Boolean get() = license.isProActive()7677    /** See VaultRepository.expectTransientBackground — call before launching a SAF picker/save dialog. */78    fun expectTransientBackground() = vault.expectTransientBackground()7980    private val recoveryWords: List<String> by lazy {81        vault.syncMasterKey()?.let { k ->82            try { com.peachpasswords.vault.rootKeyToWords(k) }83            finally { com.peachpasswords.vault.VaultCrypto.zero(k) }84        } ?: emptyList()85    }8687    /** True when the three challenge words (positions 3, 7, 11) match the vault's recovery words. */88    fun gateMatches(w3: String, w7: String, w11: String): Boolean {89        val ws = recoveryWords90        if (ws.size < 11) return false91        return w3.trim().lowercase() == ws[2] &&92            w7.trim().lowercase() == ws[6] &&93            w11.trim().lowercase() == ws[10]94    }9596    private val _restore = MutableStateFlow<RestoreState>(RestoreState.Idle)97    val restore: StateFlow<RestoreState> = _restore.asStateFlow()9899    fun startGenerate(now: Long) {100        if (!isPro) {101            _generate.value = GenerateState.Error("Codex generation is a Pro feature.")102            return103        }104        _generate.value = GenerateState.Running(CodexStep.COMPRESS, 0, 1)105        viewModelScope.launch {106            try {107                val (pdf, manifest) = withContext(Dispatchers.Default) {108                    CodexGenerator.generate(vault, now) { step, cur, total ->109                        _generate.value = GenerateState.Running(step, cur, total)110                    }111                }112                _generate.value = GenerateState.Done(manifest, pdf)113            } catch (e: CodexException) {114                _generate.value = GenerateState.Error(e.message ?: e.code.name)115            } catch (e: Exception) {116                _generate.value = GenerateState.Error(e.message ?: "generation_failed")117            }118        }119    }120121    fun writePdf(uri: Uri): Boolean = try {122        val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return false123        context.contentResolver.openOutputStream(uri)?.use { it.write(pdf) } != null124    } catch (_: Exception) {125        false126    }127128    fun sharePdfUri(fileName: String = codexPdfFileName()): Uri? = try {129        val pdf = (_generate.value as? GenerateState.Done)?.pdf ?: return null130        val dir = File(context.cacheDir, "codex").apply { mkdirs() }131        val file = File(dir, fileName)132        file.outputStream().use { it.write(pdf) }133        FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)134    } catch (_: Exception) {135        null136    }137138    fun scan(uri: Uri) {139        _restore.value = RestoreState.Scanning(0, 0)140        viewModelScope.launch {141            try {142                val payload = withContext(Dispatchers.Default) {143                    val result = CodexRestore.decodeFromUri(context, uri) { page, pages ->144                        _restore.value = RestoreState.Scanning(page, pages)145                    }146                    if (result.envelopes.isEmpty()) {147                        throw CodexException(148                            CodexErrorCode.INSUFFICIENT_CHUNKS,149                            "No Aztec codes could be read from this file",150                        )151                    }152                    try {153                        CodexRestore.reconstructPayload(result.envelopes) to result.envelopes.size154                    } catch (e: CodexException) {155                        val shortfall = codexRestoreShortfall(result.envelopes)156                        if (e.code == CodexErrorCode.INSUFFICIENT_CHUNKS && shortfall != null) {157                            _restore.value = RestoreState.Shortfall(158                                found = shortfall.found,159                                missing = shortfall.missing,160                                ready = shortfall.ready,161                                total = shortfall.total,162                            )163                            return@withContext null164                        }165                        throw e166                    }167                }168                if (payload != null) {169                    _restore.value = RestoreState.Decoded(payload.first, payload.second)170                }171            } catch (e: CodexException) {172                _restore.value = RestoreState.Error(codexRestoreErrorMessage(e.code, e.message))173            } catch (e: Exception) {174                _restore.value = RestoreState.Error(e.message ?: "restore_failed")175            }176        }177    }178179    fun confirmRestore(password: String, onDone: () -> Unit) {180        val payload = (_restore.value as? RestoreState.Decoded)?.payload ?: return181        _restore.value = RestoreState.Restoring182        viewModelScope.launch {183            when (val r = vault.restoreFromCodex(payload.vaultBlob, password)) {184                is UnlockResult.Success -> { _restore.value = RestoreState.Restored; onDone() }185                is UnlockResult.WrongPassword -> _restore.value = RestoreState.Error(186                    codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, "WRONG_PASSPHRASE"),187                )188                is UnlockResult.Error -> _restore.value = RestoreState.Error(189                    codexRestoreErrorMessage(CodexErrorCode.RESTORE_FAILED, r.reason),190                )191            }192        }193    }194195    fun resetRestore() { _restore.value = RestoreState.Idle }196197    // ---- D10 camera scan --------------------------------------------------198    private val _camera = MutableStateFlow<CameraScanState>(CameraScanState.Choosing)199    val camera: StateFlow<CameraScanState> = _camera.asStateFlow()200201    // Envelopes collected this session, deduped by stripe:index (mirrors decodeFromUri).202    private val cameraEnvelopes = LinkedHashMap<String, ChunkEnvelope>()203204    /** Show the two-card choose surface (default restore landing). */205    fun chooseRestore() {206        cameraEnvelopes.clear()207        _camera.value = CameraScanState.Choosing208        _restore.value = RestoreState.Idle209    }210211    /** Begin (or restart after a timeout) live camera scanning. */212    fun startCameraScan() {213        cameraEnvelopes.clear()214        _camera.value = CameraScanState.Scanning(0)215        _restore.value = RestoreState.Idle216    }217218    /** UI reports 10s elapsed with no successful reconstruction. */219    fun cameraTimedOut() {220        val s = _camera.value221        if (s is CameraScanState.Scanning) _camera.value = CameraScanState.Timeout(s.codesFound)222    }223224    /**225     * Analyze one camera frame: decode one Aztec, accumulate, and on a successful226     * reconstruction transition to [RestoreState.Decoded] — the same entry point227     * the PDF path uses. Returns true once decoded (caller stops analysis). The228     * analyzer owns closing the [ImageProxy]; this does not close it.229     */230    fun onCameraFrame(image: ImageProxy): Boolean {231        if (_camera.value !is CameraScanState.Scanning) return _restore.value is RestoreState.Decoded232        val text = CodexAztecFrame.decode(image) ?: return false233        val env = CodexRestore.parseEnvelope(text) ?: return false234        cameraEnvelopes.putIfAbsent("${env.s}:${env.i}", env)235        _camera.value = CameraScanState.Scanning(cameraEnvelopes.size)236        return try {237            val payload = CodexRestore.reconstructPayload(cameraEnvelopes.values.toList())238            _restore.value = RestoreState.Decoded(payload, cameraEnvelopes.size)239            true240        } catch (_: Exception) {241            false242        }243    }244}245246internal data class CodexShortfall(val found: Int, val missing: Int, val ready: Int, val total: Int)247248internal fun codexRestoreShortfall(envelopes: List<ChunkEnvelope>): CodexShortfall? {249    val good = envelopes250        .filter { CodexCodec.verifyChunkIntegrity(it) }251        .distinctBy { "${it.s}:${it.i}" }252    if (good.isEmpty()) return null253    val stripeCount = good.maxOf { it.capS.coerceAtLeast(1) }254    val byStripe = good.groupBy { it.s }255    var ready = 0256    var missing = 0257    for (stripe in 0 until stripeCount) {258        val stripeEnvelopes = byStripe[stripe].orEmpty()259        if (stripeEnvelopes.isEmpty()) continue260        val total = stripeEnvelopes.first().t261        val (dataShards, _) = com.peachpasswords.codex.deriveShardCounts(total)262        val found = stripeEnvelopes.count { it.i in 0 until total }263        if (found >= dataShards) ready++ else missing += dataShards - found264    }265    return CodexShortfall(found = good.size, missing = missing, ready = ready, total = stripeCount)266}267268internal fun codexRestoreErrorMessage(code: CodexErrorCode, raw: String?): String {269    val message = raw.orEmpty()270    return when {271        message.contains("WRONG_PASSPHRASE", ignoreCase = true) ||272            message.contains("wrong", ignoreCase = true) ->273            "wrong master password. try again."274        message.contains("INVALID_VAULT_BLOB", ignoreCase = true) ||275            code == CodexErrorCode.INVALID_VAULT_BLOB ->276            "the reconstructed vault is unreadable. please re-scan the Codex."277        message.contains("FINGERPRINT_MISMATCH", ignoreCase = true) ||278            code == CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH ->279            "vault integrity check failed. please re-scan the Codex."280        code == CodexErrorCode.INSUFFICIENT_CHUNKS && message.contains("No Aztec", ignoreCase = true) ->281            "no Codex codes found in this PDF. make sure you're selecting a Peach Codex PDF, not a different document."282        message.isNotBlank() -> message283        else -> "restoration failed. please try again."284    }285}