CodexRestore.kt

4.6 KB · raw

1package com.peachpasswords.codex23import android.content.Context4import android.graphics.Bitmap5import android.graphics.Color6import android.graphics.pdf.PdfRenderer7import android.net.Uri8import android.os.ParcelFileDescriptor910/**11 * Restore side: render a Codex PDF back to bitmaps, crop the 6×6 grid using the12 * same geometry [CodexPdf] laid down, decode each Aztec, then Reed-Solomon13 * reconstruct the payload — surviving missing or unreadable codes up to the14 * parity budget.15 */16object CodexRestore {1718    data class ScanResult(val envelopes: List<ChunkEnvelope>, val cellsTried: Int, val decoded: Int)1920    fun decodeFromUri(context: Context, uri: Uri, onPage: (Int, Int) -> Unit = { _, _ -> }): ScanResult {21        val pfd: ParcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r")22            ?: throw CodexException(CodexErrorCode.RESTORE_FAILED, "Cannot open file")23        val found = LinkedHashMap<String, ChunkEnvelope>()24        var cells = 025        var decoded = 026        pfd.use { descriptor ->27            PdfRenderer(descriptor).use { renderer ->28                val pages = renderer.pageCount29                for (p in 0 until pages) {30                    onPage(p + 1, pages)31                    val page = renderer.openPage(p)32                    for (env in decodeGrid(page)) {33                        cells++34                        found.putIfAbsent("${env.s}:${env.i}", env)35                        decoded++36                    }37                    page.close()38                }39            }40        }41        return ScanResult(found.values.toList(), cells, decoded)42    }4344    /**45     * Render each grid cell directly into a high-res bitmap via a per-cell46     * transform (CodexPdf geometry), then decode. Per-cell rendering keeps each47     * Aztec at high fidelity without allocating a giant full-page bitmap.48     *49     * [target] was 640 and consistently under-decoded any Codex past a small50     * handful of codes — a live-generated real vault's PDF decoded roughly51     * 5 of 76 codes at that resolution. Bumped to 1600 (confirmed via an52     * instrumented round-trip test: 75/75 codes decoded cleanly at a53     * realistic 76-code scale, vs. failing at 640 once codes needed more than54     * a handful of Aztec layers to fit their data). See [CodexPdf] for the55     * other half of this fix — the codes themselves used to be corrupted by56     * raster-image PDF embedding before this crop resolution even mattered.57     */58    private fun decodeGrid(page: PdfRenderer.Page): List<ChunkEnvelope> {59        val margin = 36f60        val gridTop = margin + 54f61        val cell = minOf((595f - 2 * margin) / CODES_PER_ROW, (842f - gridTop - margin - 28f) / CODES_PER_COL)62        val pad = cell * 0.05f // must match CodexPdf63        val sizePt = cell - 2 * pad64        val bleed = sizePt * 0.10f65        val region = sizePt + 2 * bleed66        val target = 160067        val scale = target / region6869        val out = ArrayList<ChunkEnvelope>()70        for (row in 0 until CODES_PER_COL) for (col in 0 until CODES_PER_ROW) {71            val leftPt = margin + col * cell + pad - bleed72            val topPt = gridTop + row * cell + pad - bleed73            val cellBmp = Bitmap.createBitmap(target, target, Bitmap.Config.ARGB_8888)74            cellBmp.eraseColor(Color.WHITE)75            val m = android.graphics.Matrix().apply {76                postTranslate(-leftPt, -topPt)77                postScale(scale, scale)78            }79            page.render(cellBmp, null, m, PdfRenderer.Page.RENDER_MODE_FOR_PRINT)80            val text = CodexAztec.decode(cellBmp)81            cellBmp.recycle()82            if (text != null) parseEnvelope(text)?.let(out::add)83        }84        return out85    }8687    fun parseEnvelope(text: String): ChunkEnvelope? = try {88        CodexCodec.json.decodeFromString(ChunkEnvelope.serializer(), text)89    } catch (_: Exception) {90        null91    }9293    /** Rebuild and validate the payload from decoded envelopes. */94    fun reconstructPayload(envelopes: List<ChunkEnvelope>): CodexPayload {95        if (envelopes.isEmpty()) throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "No codes decoded")96        val compressed = CodexCodec.reconstructCompressed(envelopes)97        val payload = CodexCodec.json.decodeFromString(CodexPayload.serializer(), String(CodexCodec.inflate(compressed)))98        val fp = CodexCodec.sha256Hex(payload.vaultBlob.toByteArray(Charsets.UTF_8)).take(32)99        if (fp != payload.vaultFingerprint) {100            throw CodexException(CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH, "Fingerprint mismatch — codes corrupted")101        }102        return payload103    }104}