package com.peachpasswords.codex import android.content.Context import android.graphics.Bitmap import android.graphics.Color import android.graphics.pdf.PdfRenderer import android.net.Uri import android.os.ParcelFileDescriptor /** * Restore side: render a Codex PDF back to bitmaps, crop the 6×6 grid using the * same geometry [CodexPdf] laid down, decode each Aztec, then Reed-Solomon * reconstruct the payload — surviving missing or unreadable codes up to the * parity budget. */ object CodexRestore { data class ScanResult(val envelopes: List, val cellsTried: Int, val decoded: Int) fun decodeFromUri(context: Context, uri: Uri, onPage: (Int, Int) -> Unit = { _, _ -> }): ScanResult { val pfd: ParcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r") ?: throw CodexException(CodexErrorCode.RESTORE_FAILED, "Cannot open file") val found = LinkedHashMap() var cells = 0 var decoded = 0 pfd.use { descriptor -> PdfRenderer(descriptor).use { renderer -> val pages = renderer.pageCount for (p in 0 until pages) { onPage(p + 1, pages) val page = renderer.openPage(p) for (env in decodeGrid(page)) { cells++ found.putIfAbsent("${env.s}:${env.i}", env) decoded++ } page.close() } } } return ScanResult(found.values.toList(), cells, decoded) } /** * Render each code's region into a high-res bitmap via a per-cell * transform, then decode. Per-cell rendering keeps each Aztec at high * fidelity without allocating a giant full-page bitmap. * * Positions come from [CodexPageLayout] — the shared browser/Android page * format — not from whatever grid this app's own generator happens to use. */ private fun decodeGrid(page: PdfRenderer.Page): List { val L = CodexPageLayout // Crop each code plus its quiet zone, at the canonical positions — // mirrors the browser importer's cellRectFor. Aztec cannot tolerate a // clipped finder pattern, so the region must match the generator's // geometry exactly rather than being an evenly-sliced grid. val quiet = L.CODE_SIZE * L.QUIET_FRACTION val region = L.CODE_SIZE + 2 * quiet val target = 1600 val scale = target / region val out = ArrayList() for (row in 0 until CODES_PER_COL) for (col in 0 until CODES_PER_ROW) { val leftPt = L.codeLeft(col) - quiet val topPt = L.codeTop(row) - quiet val cellBmp = Bitmap.createBitmap(target, target, Bitmap.Config.ARGB_8888) cellBmp.eraseColor(Color.WHITE) val m = android.graphics.Matrix().apply { postTranslate(-leftPt, -topPt) postScale(scale, scale) } page.render(cellBmp, null, m, PdfRenderer.Page.RENDER_MODE_FOR_PRINT) val text = CodexAztec.decode(cellBmp) cellBmp.recycle() if (text != null) parseEnvelope(text)?.let(out::add) } return out } fun parseEnvelope(text: String): ChunkEnvelope? = try { CodexCodec.json.decodeFromString(ChunkEnvelope.serializer(), text) } catch (_: Exception) { null } /** Rebuild and validate the payload from decoded envelopes. */ fun reconstructPayload(envelopes: List): CodexPayload { if (envelopes.isEmpty()) throw CodexException(CodexErrorCode.INSUFFICIENT_CHUNKS, "No codes decoded") val compressed = CodexCodec.reconstructCompressed(envelopes) val payload = CodexCodec.json.decodeFromString(CodexPayload.serializer(), String(CodexCodec.inflate(compressed))) val fp = CodexCodec.sha256Hex(payload.vaultBlob.toByteArray(Charsets.UTF_8)).take(32) if (fp != payload.vaultFingerprint) { throw CodexException(CodexErrorCode.RECONSTRUCTION_FINGERPRINT_MISMATCH, "Fingerprint mismatch — codes corrupted") } return payload } }