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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
28d7485c26
commit
e46d51430a
@@ -0,0 +1,75 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user