Skip to content

Commit d7f7fcb

Browse files
authored
feat(codes): share scannable-code geometry via KMP; export tip codes as PNG/SVG (#1287)
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.
1 parent 1b44ace commit d7f7fcb

26 files changed

Lines changed: 2543 additions & 136 deletions

File tree

‎apps/flipcash/app/src/main/res/xml/file_paths.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@
33
<files-path name="traces" path="traces/" />
44
<!-- Rendered tip-code Sharesheet previews (cacheDir/share_previews/). -->
55
<cache-path name="share_previews" path="share_previews/" />
6+
<!-- Exported tip codes, PNG + SVG (cacheDir/share_exports/). -->
7+
<cache-path name="share_exports" path="share_exports/" />
68
</paths>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.flipcash.app.core.share
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import androidx.core.content.FileProvider
6+
import java.io.File
7+
8+
/**
9+
* A user-facing export of a tip code: the scannable graphic on its own, written to a file the
10+
* Sharesheet (or a "save to Files" target) can consume.
11+
*
12+
* Distinct from [TipCodePreview], which exists only to give the Sharesheet a thumbnail while the
13+
* thing actually shared stays the tip URL. An export *is* the payload.
14+
*/
15+
data class TipCodeExport(
16+
val uri: Uri,
17+
val format: TipCodeExportFormat,
18+
) {
19+
val mimeType: String get() = format.mimeType
20+
}
21+
22+
enum class TipCodeExportFormat(val extension: String, val mimeType: String) {
23+
/** Raster. Universally pasteable; fixed resolution. */
24+
Png("png", "image/png"),
25+
26+
/**
27+
* Vector. Scales to any size (print, large-format) and stays a few KB.
28+
*
29+
* Self-contained by construction — no fonts, no embedded raster, no external references —
30+
* because the export is code-only, so there is no text to worry about.
31+
*/
32+
Svg("svg", "image/svg+xml"),
33+
}
34+
35+
/**
36+
* Where exported codes live on disk and how they map to `content://` URIs.
37+
*
38+
* Separate directory from [TipCodePreviewStorage] so the preview cache's aggressive pruning can't
39+
* delete an export out from under a share in progress, and so the two can be tuned independently.
40+
* Files are named by the card's content [tipCodePreviewSignature], so re-exporting the same card
41+
* overwrites rather than accumulates.
42+
*/
43+
object TipCodeExportStorage {
44+
const val SUBDIR = "share_exports"
45+
46+
fun dir(context: Context): File =
47+
File(context.cacheDir, SUBDIR).apply { if (!exists()) mkdirs() }
48+
49+
/**
50+
* Name is user-visible in some share targets ("save to Files"), hence the readable prefix
51+
* rather than a bare hash.
52+
*/
53+
fun file(context: Context, signature: String, format: TipCodeExportFormat): File =
54+
File(dir(context), "flipcash-code-$signature.${format.extension}")
55+
56+
fun uriFor(context: Context, file: File): Uri =
57+
FileProvider.getUriForFile(context, TipCodePreviewStorage.authority(context), file)
58+
59+
/** See [TipCodePreviewStorage.prune]; exports are re-creatable, so over-pruning only costs a re-render. */
60+
fun prune(
61+
context: Context,
62+
maxTotalBytes: Long = DEFAULT_MAX_TOTAL_BYTES,
63+
maxAge: Long = DEFAULT_MAX_AGE_MILLIS,
64+
now: Long = System.currentTimeMillis(),
65+
) = pruneDirectory(dir(context), maxTotalBytes, maxAge, now)
66+
67+
private const val DEFAULT_MAX_TOTAL_BYTES = 8L * 1024 * 1024 // 8 MiB
68+
private const val DEFAULT_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000 // 1 day
69+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.flipcash.app.core.share
2+
3+
import android.content.Context
4+
import android.graphics.Bitmap
5+
import androidx.core.content.ContextCompat
6+
import com.flipcash.core.R
7+
import com.flipcash.app.core.bill.Scannable
8+
import com.flipcash.libs.coroutines.DispatcherProvider
9+
import com.getcode.codes.kikcode.KikCodeSvg
10+
import com.getcode.codes.kikcode.kikCodeBitmap
11+
import dagger.hilt.android.qualifiers.ApplicationContext
12+
import kotlinx.coroutines.withContext
13+
import java.io.File
14+
import java.io.FileOutputStream
15+
import javax.inject.Inject
16+
import javax.inject.Singleton
17+
18+
/**
19+
* Exports a tip card's scannable code as a PNG or an SVG.
20+
*
21+
* Code-only, matching what the share preview already shows: no display name, no avatar, no card
22+
* chrome. That keeps the SVG honest (nothing to embed a font for) and makes both formats render the
23+
* same thing.
24+
*
25+
* Both formats come off the *same* shared geometry (`:libs:codes:kikcode`), which is also what the
26+
* on-screen `KikCodeContentView` and iOS draw — so an exported file and the code the user is looking
27+
* at cannot disagree. Rasterisation stays native (Android [Bitmap] here, `CGContext` on iOS); only
28+
* the maths is shared.
29+
*/
30+
@Singleton
31+
class TipCodeExporter @Inject constructor(
32+
@ApplicationContext private val context: Context,
33+
private val dispatchers: DispatcherProvider,
34+
) {
35+
36+
/**
37+
* Writes [card]'s code in [format] and returns a `content://` handle to it, or `null` if the
38+
* write failed — callers should degrade (share the URL alone) rather than surface an error.
39+
*
40+
* [sizePx] applies to [TipCodeExportFormat.Png] only; the SVG carries a `viewBox` and scales.
41+
*/
42+
suspend fun export(
43+
card: Scannable.TipCard,
44+
format: TipCodeExportFormat,
45+
sizePx: Int = DEFAULT_PNG_SIZE_PX,
46+
): TipCodeExport? = withContext(dispatchers.IO) {
47+
val payload = card.data.toByteArray()
48+
if (payload.isEmpty()) return@withContext null
49+
50+
runCatching {
51+
val file = TipCodeExportStorage.file(
52+
context = context,
53+
signature = tipCodePreviewSignature(card),
54+
format = format,
55+
)
56+
when (format) {
57+
TipCodeExportFormat.Png -> writePng(payload, sizePx, file)
58+
TipCodeExportFormat.Svg -> writeSvg(payload, file)
59+
}
60+
TipCodeExport(uri = TipCodeExportStorage.uriFor(context, file), format = format)
61+
}.getOrNull()
62+
}
63+
64+
private fun writePng(payload: ByteArray, sizePx: Int, file: File) {
65+
val badge = ContextCompat.getDrawable(context, R.drawable.ic_logo_round_white)
66+
val bitmap = kikCodeBitmap(payload = payload, size = sizePx, badge = badge)
67+
try {
68+
FileOutputStream(file).use { out ->
69+
bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, out)
70+
}
71+
} finally {
72+
bitmap.recycle()
73+
}
74+
}
75+
76+
private fun writeSvg(payload: ByteArray, file: File) {
77+
file.writeText(KikCodeSvg.render(payload))
78+
}
79+
80+
private companion object {
81+
/**
82+
* Large enough that the code stays crisp when a share target scales it up, small enough to
83+
* stay well under a megabyte -- the graphic is flat white on transparent, so PNG compresses
84+
* it hard.
85+
*/
86+
const val DEFAULT_PNG_SIZE_PX = 1024
87+
88+
// PNG is lossless; the parameter only controls compression effort.
89+
const val PNG_QUALITY = 100
90+
}
91+
}

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodePreviewStorage.kt‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,27 @@ object TipCodePreviewStorage {
4545
maxAge: Long = DEFAULT_MAX_AGE_MILLIS,
4646
now: Long = System.currentTimeMillis(),
4747
) {
48-
runCatching {
49-
val files = dir(context).listFiles()?.sortedByDescending { it.lastModified() }
50-
?: return
51-
var kept = 0L
52-
for (file in files) {
53-
val expired = now - file.lastModified() > maxAge
54-
kept += file.length()
55-
if (expired || kept > maxTotalBytes) {
56-
file.delete()
57-
}
58-
}
59-
}
48+
pruneDirectory(dir(context), maxTotalBytes, maxAge, now)
6049
}
6150

6251
private const val DEFAULT_MAX_TOTAL_BYTES = 8L * 1024 * 1024 // 8 MiB
6352
private const val DEFAULT_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000 // 1 day
6453
}
54+
55+
/**
56+
* Deletes anything in [dir] older than [maxAge], then -- newest first -- keeps files until
57+
* [maxTotalBytes] is reached and deletes the rest. Never throws; cleanup is best-effort.
58+
*/
59+
internal fun pruneDirectory(dir: File, maxTotalBytes: Long, maxAge: Long, now: Long) {
60+
runCatching {
61+
val files = dir.listFiles()?.sortedByDescending { it.lastModified() } ?: return
62+
var kept = 0L
63+
for (file in files) {
64+
val expired = now - file.lastModified() > maxAge
65+
kept += file.length()
66+
if (expired || kept > maxTotalBytes) {
67+
file.delete()
68+
}
69+
}
70+
}
71+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package com.flipcash.app.core.share
2+
3+
import android.graphics.Bitmap
4+
import android.graphics.Color
5+
import android.graphics.drawable.ColorDrawable
6+
import com.getcode.codes.kikcode.kikCodeBitmap
7+
import org.junit.runner.RunWith
8+
import org.robolectric.RobolectricTestRunner
9+
import org.robolectric.annotation.Config
10+
import org.robolectric.annotation.GraphicsMode
11+
import kotlin.math.hypot
12+
import kotlin.test.Test
13+
import kotlin.test.assertEquals
14+
import kotlin.test.assertTrue
15+
16+
/**
17+
* Covers the Android *painter* — the half of code rendering that the cross-platform vectors can't
18+
* reach. `:libs:codes:kikcode` gates the geometry and the SVG on both toolchains; these assertions
19+
* gate the translation of that geometry into `Canvas` draw calls, where an inverted arc sweep or a
20+
* mis-scaled badge would be invisible to the vectors.
21+
*
22+
* Native graphics so `Canvas` actually rasterises rather than recording no-ops.
23+
*/
24+
@RunWith(RobolectricTestRunner::class)
25+
@GraphicsMode(GraphicsMode.Mode.NATIVE)
26+
@Config(sdk = [34])
27+
class KikCodePainterTest {
28+
29+
/**
30+
* The full 35-byte payload with every bit set -> all six rings full, i.e. the largest graphic
31+
* the geometry can produce. A 20-byte tip payload leaves the outermost ring empty (24 bytes of
32+
* finder+payload is 192 bits, and ring 5 starts at bit 240), so it can't measure the framing.
33+
*/
34+
private val saturated = ByteArray(35) { 0xFF.toByte() }
35+
36+
/** Irregular, so runs, isolated dots and gaps all appear. */
37+
private val scattered = ByteArray(20) { (it * 7 + 1).toByte() }
38+
39+
@Test
40+
fun `renders marks`() {
41+
val bitmap = kikCodeBitmap(scattered, SIZE)
42+
assertEquals(SIZE, bitmap.width)
43+
assertEquals(SIZE, bitmap.height)
44+
assertTrue(bitmap.opaquePixelCount() > 0, "nothing was drawn")
45+
}
46+
47+
@Test
48+
fun `graphic fills its box without overflowing it`() {
49+
// The outermost stroke reaches ~0.939 of the radius by construction (ring centre 0.90625
50+
// plus half a 0.0656 stroke). Both bounds matter: the upper one catches clipping, and the
51+
// lower one catches a re-introduced inset -- Android used to shrink the code to 0.93 and
52+
// then overscan the view by 1.03 to compensate, which put it ~4% under iOS.
53+
val extent = kikCodeBitmap(saturated, SIZE).maxOpaqueRadius() / (SIZE / 2.0)
54+
assertTrue(extent in 0.93..0.96, "outer extent was $extent of the radius")
55+
}
56+
57+
@Test
58+
fun `centre well is left empty when there is no badge`() {
59+
val bitmap = kikCodeBitmap(scattered, SIZE)
60+
assertEquals(Color.TRANSPARENT, bitmap.getPixel(SIZE / 2, SIZE / 2))
61+
}
62+
63+
@Test
64+
fun `badge is drawn into the centre well`() {
65+
val bitmap = kikCodeBitmap(scattered, SIZE, badge = ColorDrawable(Color.RED))
66+
assertEquals(Color.RED, bitmap.getPixel(SIZE / 2, SIZE / 2))
67+
68+
// The well is INNER_RING_RATIO (0.32) of the outer radius, and the badge fills it exactly.
69+
val expected = (SIZE / 2.0) * 0.32
70+
val actual = bitmap.run {
71+
var left = width
72+
for (x in 0 until width) {
73+
if (getPixel(x, height / 2) == Color.RED) { left = x; break }
74+
}
75+
width / 2.0 - left
76+
}
77+
assertTrue(kotlin.math.abs(actual - expected) <= 1.0, "badge half-width $actual, want $expected")
78+
}
79+
80+
@Test
81+
fun `output scales linearly with size`() {
82+
val small = kikCodeBitmap(saturated, SIZE).maxOpaqueRadius() / SIZE
83+
val large = kikCodeBitmap(saturated, SIZE * 2).maxOpaqueRadius() / (SIZE * 2)
84+
assertTrue(kotlin.math.abs(small - large) < 0.005, "extent $small vs $large")
85+
}
86+
87+
private fun Bitmap.pixels(): IntArray =
88+
IntArray(width * height).also { getPixels(it, 0, width, 0, 0, width, height) }
89+
90+
private fun Bitmap.opaquePixelCount(): Int = pixels().count { Color.alpha(it) > ALPHA_FLOOR }
91+
92+
/** Distance from the centre to the furthest drawn pixel, in px. */
93+
private fun Bitmap.maxOpaqueRadius(): Double {
94+
val centre = width / 2.0
95+
val pixels = pixels()
96+
var furthest = 0.0
97+
for (index in pixels.indices) {
98+
if (Color.alpha(pixels[index]) <= ALPHA_FLOOR) continue
99+
val distance = hypot(index % width - centre, (index / width) - centre)
100+
if (distance > furthest) furthest = distance
101+
}
102+
return furthest
103+
}
104+
105+
private companion object {
106+
const val SIZE = 512
107+
108+
// Ignore antialiasing fringes so the extent measurement tracks the shape, not its feather.
109+
const val ALPHA_FLOOR = 128
110+
}
111+
}

‎kmp/shared-core/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ kotlin {
2020
it.binaries.framework {
2121
baseName = "SharedCore"
2222
isStatic = true
23+
export(project(":libs:codes:kikcode"))
2324
export(project(":libs:encryption:base58"))
2425
export(project(":libs:encryption:sha256"))
2526
export(project(":libs:encryption:sha512"))
@@ -31,6 +32,7 @@ kotlin {
3132
sourceSets {
3233
commonMain {
3334
dependencies {
35+
api(project(":libs:codes:kikcode"))
3436
api(project(":libs:encryption:base58"))
3537
api(project(":libs:encryption:sha256"))
3638
api(project(":libs:encryption:sha512"))

‎libs/codes/kikcode/.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/build

0 commit comments

Comments
 (0)