Skip to content

feat(codes): share scannable-code geometry via KMP; export tip codes as PNG/SVG - #1287

Merged
bmc08gt merged 1 commit into
code/cashfrom
claude/tip-card-export-png-svg-561def
Aug 20, 2026
Merged

feat(codes): share scannable-code geometry via KMP; export tip codes as PNG/SVG#1287
bmc08gt merged 1 commit into
code/cashfrom
claude/tip-card-export-png-svg-561def

Conversation

@bmc08gt

Copy link
Copy Markdown
Collaborator

Tip cards need PNG and SVG export. Rather than write that twice, the part that actually has to agree between platforms — where every mark goes — moves into a KMP module, and each platform keeps only a thin painter over it.

commonMain (KMP) native (thin)
────────────────────────────── ──────────────────────────
KikCodeGeometry.describe(size) ──┬───► Canvas drawCircle/drawArc → Bitmap → PNG
├───► CGContext (iOS, next) → UIImage → PNG
KikCodeSvg.render(...) ────┴───► just write the bytes (no painter)

Rasterisation stays native deliberately — Skiko/CMP would add several MB to the iOS framework to produce byte-identical PNGs nobody needs. Only the maths is shared, and the maths is the part that can drift.

:libs:codes:kikcode

Pure Kotlin, no platform APIs:

  • KikCodeGeometry.describe(payload, dimension)KikCodeDescription: the centre, badge well, dot diameter, and an ordered list of Dot / Arc / Ring marks. Runs of consecutive set bits collapse into a single stroked arc with round caps — geometrically the same union as per-bit dots plus connecting bands, with far fewer elements, which is what keeps the SVG small.
  • KikCodeSvg.render(...) → a self-contained SVG document. Export is code-only (matching what the share preview already shows), so there is no text and therefore no font to embed.

Exported through :kmp:shared-core, so iOS picks it up in the SharedCore XCFramework.

Android becomes a painter, not a second implementation

KikCodeContentRendererImpl drops from 139 lines of fused maths-and-drawing to ~30 lines of draw calls. The on-screen view, the exported PNG, the exported SVG, and iOS now come off the same numbers.

TipCodeExporter writes either format to cacheDir/share_exports/ behind the existing FileProvider.

The parity gate

test-vectors/kikcode.json plus a golden SVG, produced by an independent Python implementation written from the spec ratios rather than transcribed from the Kotlin — so a match is corroboration, not a tautology. Both run on the JVM host and on Kotlin/Native.

The byte-for-byte SVG comparison is the one that earns its keep: Double.toString() is unspecified across Kotlin targets and libm cos/sin can differ in the last place, so every emitted number is fixed to 3 decimals via roundToLong() (ties toward positive infinity — the only tie-break Kotlin actually specifies).

Getting the native half to run at all needed a fix: Kotlin/Native test binaries ship no resource bundle, so NSBundle.pathForResource finds nothing and a resource-based loader silently only ever runs on the JVM. Fixtures are now compiled into commonTest as generated Kotlin, which works on every target and drops the expect/actual loader.

Five Robolectric tests cover what the vectors cannot reach — that arc sweeps aren't inverted, that the badge fills its well, and that the graphic frames correctly.

Worth a look on device

The on-screen code is ~4.2% larger. Android used to inset the graphic to 0.93 and then have KikCodeContentView overscan by 1.03 to compensate (a net 0.958); iOS just uses 0.5. This adopts iOS's canonical value so the export and the on-screen code can't disagree — but it's a visible change to the bill and tip card, and the badge well grows from 0.298 to 0.32 of the graphic to match iOS.

Follow-ups

  • TipCodeExporter has no UI entry point yet — that lands with the new You tab design work.
  • iOS consumption (swap KikCode.generateDescription for the exported geometry, add a CGContext painter) is a code-ios-app change.
  • Noticed in passing: :libs:encryption:base58's iosTest has the same missing ExperimentalForeignApi opt-in, which means its Kotlin/Native vector test has never compiled. Its iOS parity runs through FlipcashCoreVectors instead, so coverage isn't actually missing, but the dead source is misleading.

…as PNG/SVG
Tip cards need PNG and SVG export. Rather than write that twice, the part that
actually has to agree between platforms -- where every mark goes -- moves into a
new KMP module, and each platform keeps only a thin painter over it.
:libs:codes:kikcode (commonMain, no platform APIs):
- KikCodeGeometry.describe(payload, dimension) -> KikCodeDescription: the
centre, badge well, dot diameter, and an ordered list of Dot/Arc/Ring marks.
Runs of consecutive set bits collapse to one stroked arc with round caps --
the same union as per-bit dots plus connecting bands, far fewer elements.
- KikCodeSvg.render(...) -> a self-contained SVG document. Export is code-only
(matching the existing share preview), so there is no text and no font to
embed.
- Exported through :kmp:shared-core, so iOS gets it in the SharedCore
XCFramework.
Android is now a painter, not a second implementation. KikCodeContentRendererImpl
drops from 139 lines of fused maths-and-drawing to ~30 lines of draw calls, so the
on-screen view, the exported PNG, the exported SVG, and iOS are all the same
numbers. TipCodeExporter writes either format to cacheDir/share_exports/ behind
the existing FileProvider.
Framing: adopt iOS's canonical outer ratio of 0.5. Android used to inset the
graphic to 0.93 and then have KikCodeContentView overscan by 1.03 to compensate,
a net 0.958; the code is now ~4.2% larger within the same box and the badge well
grows from 0.298 to 0.32 of the graphic, matching iOS.
Gated by test-vectors/kikcode.json plus a golden SVG, generated by an independent
Python implementation written from the spec ratios rather than transcribed from
the Kotlin. Both run on the JVM host and on Kotlin/Native: the byte-for-byte SVG
comparison is what proves the two toolchains agree, since Double.toString() is
unspecified across Kotlin targets and libm trig can differ in the last place
(both handled by formatting every emitted number at 3 decimals via roundToLong).
Fixtures are compiled into commonTest as generated Kotlin -- Kotlin/Native test
binaries ship no resource bundle, so a resource-based loader silently only ever
runs on the JVM.
Five Robolectric tests cover what the vectors cannot reach: that arc sweeps are
not inverted, that the badge fills its well, and that the graphic frames
correctly.
TipCodeExporter has no UI entry point yet; that lands with the You tab work.
@github-actionsgithub-actionsBot added type: feature New functionality area: scanner QR/Kikcode scanning, camera area: build-system Gradle, convention plugins, build-logic and removed type: feature New functionality labels Aug 20, 2026
@bmc08gt
bmc08gt merged commit d7f7fcb into code/cashAug 20, 2026
2 of 3 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: build-systemGradle, convention plugins, build-logicarea: scannerQR/Kikcode scanning, camera

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bmc08gt