c7836f5947ee
make Codex and PeachScript import actually work on a real browser-generated vault — three separate bugs, all invisible to the small Android-generated fixtures that previously passed.
- time
- 2026-07-24 21:03 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- codex + peach import of a real browser vault
- parent
- 8984cc3fac9d
↓ peach-android-c7836f59.tar.gzatlas at this save
files changed7 files
app/src/main/java/com/peachpasswords/codex/CodexPageLayout.kt+55 −0app/src/main/java/com/peachpasswords/codex/CodexPdf.kt+14 −18app/src/main/java/com/peachpasswords/codex/CodexRestore.kt+14 −20app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+32 −12app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+31 −8app/src/test/java/com/peachpasswords/ui/vault/RealBackupDecodeTest.kt+48 −0app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+27 −0app/src/main/java/com/peachpasswords/codex/CodexPageLayout.kt+55 −0
@@ -0,0 +1,55 @@1+package com.peachpasswords.codex2+3+/**4+ * A4 Codex page geometry — the exact port of the browser's5+ * `popup/codex/pdf-layout.ts`, which is the canonical Codex page format.6+ *7+ * A Codex is a *cross-platform paper backup*: a PDF printed from the browser8+ * extension has to restore on Android and vice versa. That only holds if both9+ * sides agree on where every Aztec sits on the page, to the point.10+ *11+ * They didn't. Android's generator used its own geometry (36pt margin, 54pt12+ * header, a computed square cell) and its importer blind-cropped that same13+ * private grid, so it could only ever read PDFs it had produced itself. Handed14+ * a real browser-generated Codex it cropped the wrong regions and decoded15+ * roughly a quarter of the codes — enough to look like a flaky scan rather16+ * than a format mismatch.17+ *18+ * Units are PDF points (1pt = 1/72 inch). Keep these values identical to19+ * pdf-layout.ts; drift here silently breaks paper restore across platforms.20+ */21+object CodexPageLayout {22+ const val PAGE_WIDTH = 595.28f23+ const val PAGE_HEIGHT = 841.89f24+25+ const val MARGIN = 28f26+27+ const val HEADER_HEIGHT = 44f28+29+ /** Vertical gap between the header hairline and the top of row 0. */30+ const val HEADER_GAP = 16f31+32+ const val CODE_SIZE = 80f33+34+ /** Horizontal gap between adjacent codes in a row. */35+ const val COL_GAP = (PAGE_WIDTH - 2 * MARGIN - CODES_PER_ROW * CODE_SIZE) / (CODES_PER_ROW - 1)36+37+ /** Space reserved for the per-code numeric label drawn under each Aztec. */38+ const val LABEL_HEIGHT = 14f39+40+ /** Printed white space between a label and the next row's code. */41+ const val ROW_GAP = 18f42+43+ /** Top-of-code-N to top-of-code-N+1 distance — what the importer steps by. */44+ const val ROW_STEP = CODE_SIZE + ROW_GAP + LABEL_HEIGHT45+46+ /** Top of row 0, measured from the top of the page. */47+ const val GRID_TOP = MARGIN + HEADER_HEIGHT + HEADER_GAP48+49+ /** Quiet zone the importer includes around each code, as a fraction of it. */50+ const val QUIET_FRACTION = 0.12f51+52+ fun codeLeft(col: Int): Float = MARGIN + col * (CODE_SIZE + COL_GAP)53+54+ fun codeTop(row: Int): Float = GRID_TOP + row * ROW_STEP55+}app/src/main/java/com/peachpasswords/codex/CodexPdf.kt+14 −18
@@ -24,23 +24,15 @@2424 * lossless path instead.2525 */2626 object CodexPdf {27- private const val PAGE_W = 59528- private const val PAGE_H = 84229- private const val MARGIN = 36f30- private const val HEADER_H = 54f31- private const val FOOTER_H = 28f27+ // Canonical shared page format — see CodexPageLayout. These used to be this28+ // file's own private geometry, which made an Android Codex readable only by29+ // Android.30+ private val PAGE_W = CodexPageLayout.PAGE_WIDTH.toInt()31+ private val PAGE_H = CodexPageLayout.PAGE_HEIGHT.toInt()32+ private const val MARGIN = CodexPageLayout.MARGIN3233 3334 fun render(aztecs: List<BitMatrix>, manifest: CodexManifest): ByteArray {3435 val doc = PdfDocument()35- val gridTop = MARGIN + HEADER_H36- val gridW = PAGE_W - 2 * MARGIN37- val gridH = PAGE_H - gridTop - MARGIN - FOOTER_H38- val cellW = gridW / CODES_PER_ROW39- val cellH = gridH / CODES_PER_COL40- val cell = minOf(cellW, cellH)41- // Tighter padding → larger codes (~78pt, close to the browser's 80pt),42- // which keeps modules above the camera/scan decode threshold.43- val pad = cell * 0.05f4436 4537 val title = Paint().apply { color = Color.BLACK; textSize = 16f; isAntiAlias = true; isFakeBoldText = true }4638 val mono = Paint().apply { color = Color.rgb(0x55, 0x55, 0x55); textSize = 9f; isAntiAlias = true }@@ -68,10 +60,14 @@6860 val slot = idx - first6961 val row = slot / CODES_PER_ROW7062 val col = slot % CODES_PER_ROW71- val left = MARGIN + col * cell + pad72- val top = gridTop + row * cell + pad73- val sizePt = cell - 2 * pad74- drawAztecVector(canvas, aztecs[idx], left, top, sizePt, modulePaint)63+ drawAztecVector(64+ canvas,65+ aztecs[idx],66+ CodexPageLayout.codeLeft(col),67+ CodexPageLayout.codeTop(row),68+ CodexPageLayout.CODE_SIZE,69+ modulePaint,70+ )7571 }7672 7773 canvas.drawText(app/src/main/java/com/peachpasswords/codex/CodexRestore.kt+14 −20
@@ -42,34 +42,28 @@4242 }4343 4444 /**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.45+ * Render each code's region into a high-res bitmap via a per-cell46+ * transform, then decode. Per-cell rendering keeps each Aztec at high47+ * fidelity without allocating a giant full-page bitmap.4848 *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.49+ * Positions come from [CodexPageLayout] — the shared browser/Android page50+ * format — not from whatever grid this app's own generator happens to use.5751 */5852 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 * bleed53+ val L = CodexPageLayout54+ // Crop each code plus its quiet zone, at the canonical positions —55+ // mirrors the browser importer's cellRectFor. Aztec cannot tolerate a56+ // clipped finder pattern, so the region must match the generator's57+ // geometry exactly rather than being an evenly-sliced grid.58+ val quiet = L.CODE_SIZE * L.QUIET_FRACTION59+ val region = L.CODE_SIZE + 2 * quiet6660 val target = 16006761 val scale = target / region6862 6963 val out = ArrayList<ChunkEnvelope>()7064 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 - bleed65+ val leftPt = L.codeLeft(col) - quiet66+ val topPt = L.codeTop(row) - quiet7367 val cellBmp = Bitmap.createBitmap(target, target, Bitmap.Config.ARGB_8888)7468 cellBmp.eraseColor(Color.WHITE)7569 val m = android.graphics.Matrix().apply {app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+32 −12
@@ -525,18 +525,35 @@525525 526526 @Composable527527 private fun PeachScriptView(vm: ImportViewModel, initialPaste: String = "") {528- var paste by remember(initialPaste) { mutableStateOf(initialPaste) }528+ // A backup loaded from a file never goes through the text field. A real529+ // vault's export is hundreds of thousands of PUA characters (June's is530+ // 262k) and pushing that into a BasicTextField bogs the UI down badly531+ // enough that the import looks broken. The field is only for hand-pasted532+ // text; file content is held aside and handed straight to the decoder.533+ val fromFile = initialPaste.isNotEmpty()534+ var paste by remember(initialPaste) { mutableStateOf(if (fromFile) "" else initialPaste) }529535 var backupPassword by remember { mutableStateOf("") }530- val needsPassword = remember(paste) { vm.peachScriptNeedsPassword(paste) }536+ val source = if (fromFile) initialPaste else paste537+ val needsPassword = remember(source) { vm.peachScriptNeedsPassword(source) }531538 532539 GlassCard {533- FieldLabel("Paste your PeachScript export")534- Spacer(Modifier.size(PeachSpacing.sm))535- PeachMultilineField(536- value = paste,537- onValueChange = { paste = it },538- minLines = 6,539- )540+ if (fromFile) {541+ FieldLabel("Backup loaded from file")542+ Spacer(Modifier.size(PeachSpacing.sm))543+ Text(544+ "%,d characters of PeachScript ready to import.".format(initialPaste.length),545+ color = peachColors.T1,546+ style = PeachType.body,547+ )548+ } else {549+ FieldLabel("Paste your PeachScript export")550+ Spacer(Modifier.size(PeachSpacing.sm))551+ PeachMultilineField(552+ value = paste,553+ onValueChange = { paste = it },554+ minLines = 6,555+ )556+ }540557 if (needsPassword) {541558 Spacer(Modifier.size(PeachSpacing.lg))542559 PeachUnderlineField(@@ -550,8 +567,8 @@550567 Spacer(Modifier.size(PeachSpacing.lg))551568 AccentButton(552569 label = "Import",553- enabled = paste.isNotBlank(),554- onClick = { vm.importPeachScript(paste, backupPassword) },570+ enabled = source.isNotBlank(),571+ onClick = { vm.importPeachScript(source, backupPassword) },555572 )556573 }557574 @@ -787,7 +804,10 @@787804 BasicTextField(788805 value = value,789806 onValueChange = onValueChange,790- textStyle = PeachType.bodyMono.copy(color = peachColors.T0),807+ // PeachScript lives in the PUA block (U+E000..U+E0FF). Without the808+ // PeachScript font Android falls back to a font that also maps that809+ // block — Noto CJK — so a pasted backup rendered as Chinese glyphs.810+ textStyle = PeachType.bodyMono.copy(color = peachColors.T0, fontFamily = PeachType.Script),791811 cursorBrush = SolidColor(accent),792812 minLines = minLines,793813 modifier = Modifier.fillMaxWidth(),app/src/main/java/com/peachpasswords/vault/VaultRepository.kt+31 −8
@@ -7,6 +7,11 @@77 import kotlinx.coroutines.sync.withLock88 import java.security.SecureRandom99 import java.text.Normalizer10+import kotlinx.serialization.json.Json11+import kotlinx.serialization.json.JsonElement12+import kotlinx.serialization.json.JsonNull13+import kotlinx.serialization.json.JsonObject14+import kotlinx.serialization.json.JsonPrimitive1015 import java.util.Base641116 import java.util.UUID1217 import javax.inject.Inject@@ -49,6 +54,8 @@4954 * - destroyVault never touches the Pro license (separate store).5055 */5156 @Singleton57+private val CodexJson = Json { ignoreUnknownKeys = true; isLenient = true }58+5259 class VaultRepository @Inject constructor(5360 private val store: VaultStorage,5461 ) {@@ -94,20 +101,36 @@94101 * Writes slot A (and a size-matched noise slot B) before unlocking so the95102 * restored vault is at rest exactly as a freshly-created one would be.96103 */104+ /** A slot is stored either as a JSON string (browser) or inline object (Android). */105+ private fun JsonElement?.asSlotJson(): String? = when {106+ this == null || this is JsonNull -> null107+ this is JsonPrimitive && isString -> content108+ else -> toString()109+ }110+97111 suspend fun restoreFromCodex(vaultBlobBase64: String, password: String): UnlockResult = mutex.withLock {98112 return try {99113 val wrapperJson = String(Base64.getUrlDecoder().decode(vaultBlobBase64), Charsets.UTF_8)100- // The trailing `}` (the wrapper object's own close-brace, after the101- // captured inner slot object) must be escaped — Android's regex102- // compiler rejects a bare, unescaped `}` outside a quantifier103- // context as a syntax error, so this never matched anything and104- // Codex restore always failed with a generic "invalid_codex_blob"105- // regardless of whether the codes themselves decoded correctly.106- val slotJson = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*\\}\\s*$").find(wrapperJson)?.groupValues?.get(1)114+ // Parsed as JSON, not matched with a regex. The wrapper is115+ // `{ "a": <slot>, "b": <slot|null> }` and the browser — which116+ // generates most real Codexes — writes each slot as a JSON117+ // *string*, with "b" present. A regex looking for an object118+ // literal under "a" at the very end of the wrapper matches119+ // neither of those, so every browser-generated Codex failed here120+ // with "invalid_codex_blob" after decoding perfectly.121+ val wrapper = CodexJson.parseToJsonElement(wrapperJson) as? JsonObject107122 ?: return UnlockResult.Error("invalid_codex_blob")123+ val slotJson = wrapper["a"].asSlotJson() ?: return UnlockResult.Error("invalid_codex_blob")108124 val slotA = VaultCodec.decodeSlot(slotJson) ?: return UnlockResult.Error("invalid_codex_blob")109125 if (!store.writeSlotA(slotA)) return UnlockResult.Error("storage_write_failed")110- writeMatchingNoiseSlotB(slotA)126+ // Carry the real slot B across when the Codex has one — it holds a127+ // duress vault, which noise would silently destroy.128+ val slotB = wrapper["b"].asSlotJson()129+ if (slotB != null) {130+ store.writeSlotB(Base64.getEncoder().encodeToString(slotB.toByteArray(Charsets.UTF_8)))131+ } else {132+ writeMatchingNoiseSlotB(slotA)133+ }111134 store.stampLocalMutation()112135 unlockLocked(password)113136 } catch (e: Exception) {app/src/test/java/com/peachpasswords/ui/vault/RealBackupDecodeTest.kt+48 −0
@@ -0,0 +1,48 @@1+package com.peachpasswords.ui.vault2+3+import org.junit.Assume.assumeTrue4+import com.peachpasswords.vault.PeachScript5+import org.junit.Test6+import java.io.File7+import org.junit.Assert.assertTrue8+9+/**10+ * Decode-path check against a real, full-size PeachScript backup rather than a11+ * toy fixture. The small round-trip fixtures all passed while a real export12+ * (262k PUA characters) failed in the app, so size itself is the variable13+ * worth covering.14+ *15+ * Skips when the file isn't present — it's a local artifact, never committed16+ * (it's an encrypted vault backup).17+ */18+class RealBackupDecodeTest {19+20+ private val backup = File(System.getProperty("user.home"), "Downloads/peach-backup-2026-07-24.peach")21+22+ @Test fun real_backup_is_recognised_and_decodes_to_bytes() {23+ assumeTrue("no local backup artifact", backup.exists())24+ val text = backup.readText()25+ println("chars=${text.length}")26+27+ assertTrue("looksLikePeachScript rejected a real export", PeachScriptImport.looksLikePeachScript(text))28+29+ val normalised = PeachScript.normalise(text)30+ assertTrue("normalise broke the sniff", PeachScript.isPeachScript(normalised))31+32+ // Decrypting needs the backup password, which we don't have — but33+ // decode() is the whole pre-crypto path and is where size would bite.34+ val bytes = PeachScript.decode(normalised)35+ println("decoded bytes=${bytes.size}")36+ assertTrue("decoded to nothing", bytes.isNotEmpty())37+ }38+39+ @Test fun wrong_password_is_reported_as_wrong_not_corrupted() {40+ assumeTrue("no local backup artifact", backup.exists())41+ val result = PeachScriptImport.decryptAndParse(backup.readText(), "definitely-not-the-password")42+ println("result=$result")43+ assertTrue(44+ "a real export with a bad password should report WrongPassword, got $result",45+ result is PeachScriptImport.DecryptResult.WrongPassword,46+ )47+ }48+}app/src/test/java/com/peachpasswords/vault/VaultRepositoryExtraTest.kt+27 −0
@@ -77,6 +77,33 @@7777 assertTrue(dst.getEntries().any { it.title == "Portable" })7878 }7979 80+ /**81+ * The browser writes each slot as a JSON *string* and includes "b"; Android82+ * wrote slot "a" as an inline object with nothing after it. Restore used to83+ * pull "a" out with a regex that assumed Android's shape, so every real84+ * browser-generated Codex died with "invalid_codex_blob" after decoding85+ * perfectly. Both shapes must restore.86+ */87+ @Test fun codex_blob_restores_from_browser_shaped_wrapper() = runBlocking {88+ val src = repo()89+ src.addEntry(login("Portable"))90+ val androidBlob = src.codexVaultBlobBase64()!!91+ val wrapper = String(java.util.Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)92+ val slotA = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*\\}\\s*$").find(wrapper)!!.groupValues[1]93+94+ // Slot as a JSON string, plus a "b" key after it — the browser's shape.95+ val browserWrapper = kotlinx.serialization.json.buildJsonObject {96+ put("a", kotlinx.serialization.json.JsonPrimitive(slotA))97+ put("b", kotlinx.serialization.json.JsonNull)98+ }.toString()99+ val browserBlob = java.util.Base64.getUrlEncoder().withoutPadding()100+ .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))101+102+ val dst = VaultRepository(Storage())103+ assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserBlob, "correct horse battery"))104+ assertTrue(dst.getEntries().any { it.title == "Portable" })105+ }106+80107 @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {81108 val rootKeyHex = "66".repeat(32)82109 val rootKey = hexToRootKey(rootKeyHex)