Files
flamenet-messenger-android/engine/src/test/kotlin/io/flamenet/e2e/QrCodeRenderForDecodingTest.kt
T
Paul HittandClaude Opus 5 e46d51430a Android can show an address, and the Account screen looks like the app
Two gaps, one in each direction.

Android could ENCODE an address and had no way to DERIVE one, so it
could not show yours and nobody on another relay could be given it --
the cross-relay work was iOS-only in practice. The engine was missing
`localAccountSalt` and a way to get both sign-in secrets from one
stretch; asking for the account key separately re-runs 650,000 PBKDF2
iterations for a value already in hand. The salt is pinned against the
JavaScript engine, which is normative -- a Kotlin test asserting what
Kotlin computes would prove only that Kotlin is self-consistent, and
the failure to guard against is three engines disagreeing, which shows
up as one person having two addresses depending on which phone they
used.

The address is stored in the vault because it can only be derived at
sign-in: the passphrase exists for that one moment and is never kept,
so a restored session had nothing to recompute from. Caught by the
empty state saying so on the emulator rather than showing a blank.

A QR encoder, written rather than depended on. Android has no encoder
in the platform, and this is one fully specified algorithm for one
fixed job -- turning a short ASCII address into a bitmap. It is
verified by DECODING: thirteen codes, every version from 1 to 10 at
exact capacity, read back by CoreImage's detector, which shares no line
with it. That found the bug that mattered. The symbol was structurally
perfect -- finders, timing, alignment, data placement and mask all
confirmed correct against a reference symbol -- and nothing could read
it, because the fifteen format bits were written least-significant
first. It fails identically at every version with no clue where.

The Account screen was a flat stack of fields, buttons and paragraphs
with dividers between them, so a security setting carried the same
weight as a text box and "Delete account" the same as "Save display
name". Cards now, matching the Contacts screen that already had them,
with the destructive pair on their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:36:58 -04:00

76 lines
3.2 KiB
Kotlin

package io.flamenet.e2e
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.test.Test
/**
* Renders QR codes to PNG so an INDEPENDENT decoder can read them back.
*
* 🔴 **The only honest way to test an encoder we wrote ourselves.** A wrong QR
* still looks like a QR; asserting the matrix against values this same code
* produced proves nothing except that it is deterministic. So this writes real
* images and `Scripts/verify-qr.swift` decodes them with CoreImage's detector,
* which shares no line of code with `QrCode`.
*
* ⚠️ Not an assertion by itself — it is the fixture half. It passes as long as
* the encoder does not throw; the verdict comes from the decoder.
*/
class QrCodeRenderForDecodingTest {
@Test
fun `render the corpus`() {
val out = File(System.getProperty("fnmsg.qr.out") ?: "build/qr-corpus")
out.mkdirs()
CORPUS.forEachIndexed { index, text ->
ImageIO.write(render(text, scale = 8, quiet = 4), "png", File(out, "qr-$index.png"))
File(out, "qr-$index.txt").writeText(text)
}
println("wrote ${CORPUS.size} QR images to ${out.absolutePath}")
}
private fun render(text: String, scale: Int, quiet: Int): BufferedImage {
val m = QrCode.encode(text)
val side = (m.size + quiet * 2) * scale
val image = BufferedImage(side, side, BufferedImage.TYPE_INT_RGB)
val g = image.createGraphics()
g.color = java.awt.Color.WHITE
g.fillRect(0, 0, side, side)
g.color = java.awt.Color.BLACK
for (r in 0 until m.size) for (c in 0 until m.size) {
if (m[r, c]) g.fillRect((c + quiet) * scale, (r + quiet) * scale, scale, scale)
}
g.dispose()
return image
}
companion object {
/**
* Chosen to cross every version boundary the encoder supports, because
* the block layout, the alignment patterns and the version-information
* block all change with version and each is its own chance to be wrong.
*/
val CORPUS: List<String> = listOf(
"flamenet:fnid:kx7q2m", // v1
"flamenet:fnid:" + "a".repeat(52) + "@relay1.flamenet.io", // a real address
"flamenet:fnid:" + "b".repeat(52) + "@" + "long".repeat(8) + ".example.com",
// ⚠️ Exactly full, version by version: data codewords minus the
// mode nibble and character count. The boundary is where an
// off-by-one in the terminator or the padding shows up, and it is
// the one place a wrong encoder still produces a scannable-looking
// square.
"x".repeat(14), // v1
"x".repeat(26), // v2
"x".repeat(42), // v3
"x".repeat(62), // v4
"x".repeat(84), // v5
"x".repeat(106), // v6
"x".repeat(122), // v7 — first version with a version-information block
"x".repeat(152), // v8 — two block groups of different sizes
"x".repeat(180), // v9
"x".repeat(213), // v10 — the largest this encoder makes
)
}
}