diff --git a/apps/flipcash/app/src/main/res/xml/file_paths.xml b/apps/flipcash/app/src/main/res/xml/file_paths.xml
index 7bd340de94..df595ae07d 100644
--- a/apps/flipcash/app/src/main/res/xml/file_paths.xml
+++ b/apps/flipcash/app/src/main/res/xml/file_paths.xml
@@ -3,4 +3,6 @@
+
+
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExport.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExport.kt
new file mode 100644
index 0000000000..a77fbd1e79
--- /dev/null
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExport.kt
@@ -0,0 +1,69 @@
+package com.flipcash.app.core.share
+
+import android.content.Context
+import android.net.Uri
+import androidx.core.content.FileProvider
+import java.io.File
+
+/**
+ * A user-facing export of a tip code: the scannable graphic on its own, written to a file the
+ * Sharesheet (or a "save to Files" target) can consume.
+ *
+ * Distinct from [TipCodePreview], which exists only to give the Sharesheet a thumbnail while the
+ * thing actually shared stays the tip URL. An export *is* the payload.
+ */
+data class TipCodeExport(
+ val uri: Uri,
+ val format: TipCodeExportFormat,
+) {
+ val mimeType: String get() = format.mimeType
+}
+
+enum class TipCodeExportFormat(val extension: String, val mimeType: String) {
+ /** Raster. Universally pasteable; fixed resolution. */
+ Png("png", "image/png"),
+
+ /**
+ * Vector. Scales to any size (print, large-format) and stays a few KB.
+ *
+ * Self-contained by construction — no fonts, no embedded raster, no external references —
+ * because the export is code-only, so there is no text to worry about.
+ */
+ Svg("svg", "image/svg+xml"),
+}
+
+/**
+ * Where exported codes live on disk and how they map to `content://` URIs.
+ *
+ * Separate directory from [TipCodePreviewStorage] so the preview cache's aggressive pruning can't
+ * delete an export out from under a share in progress, and so the two can be tuned independently.
+ * Files are named by the card's content [tipCodePreviewSignature], so re-exporting the same card
+ * overwrites rather than accumulates.
+ */
+object TipCodeExportStorage {
+ const val SUBDIR = "share_exports"
+
+ fun dir(context: Context): File =
+ File(context.cacheDir, SUBDIR).apply { if (!exists()) mkdirs() }
+
+ /**
+ * Name is user-visible in some share targets ("save to Files"), hence the readable prefix
+ * rather than a bare hash.
+ */
+ fun file(context: Context, signature: String, format: TipCodeExportFormat): File =
+ File(dir(context), "flipcash-code-$signature.${format.extension}")
+
+ fun uriFor(context: Context, file: File): Uri =
+ FileProvider.getUriForFile(context, TipCodePreviewStorage.authority(context), file)
+
+ /** See [TipCodePreviewStorage.prune]; exports are re-creatable, so over-pruning only costs a re-render. */
+ fun prune(
+ context: Context,
+ maxTotalBytes: Long = DEFAULT_MAX_TOTAL_BYTES,
+ maxAge: Long = DEFAULT_MAX_AGE_MILLIS,
+ now: Long = System.currentTimeMillis(),
+ ) = pruneDirectory(dir(context), maxTotalBytes, maxAge, now)
+
+ private const val DEFAULT_MAX_TOTAL_BYTES = 8L * 1024 * 1024 // 8 MiB
+ private const val DEFAULT_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000 // 1 day
+}
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExporter.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExporter.kt
new file mode 100644
index 0000000000..25782ebd6a
--- /dev/null
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExporter.kt
@@ -0,0 +1,91 @@
+package com.flipcash.app.core.share
+
+import android.content.Context
+import android.graphics.Bitmap
+import androidx.core.content.ContextCompat
+import com.flipcash.core.R
+import com.flipcash.app.core.bill.Scannable
+import com.flipcash.libs.coroutines.DispatcherProvider
+import com.getcode.codes.kikcode.KikCodeSvg
+import com.getcode.codes.kikcode.kikCodeBitmap
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Exports a tip card's scannable code as a PNG or an SVG.
+ *
+ * Code-only, matching what the share preview already shows: no display name, no avatar, no card
+ * chrome. That keeps the SVG honest (nothing to embed a font for) and makes both formats render the
+ * same thing.
+ *
+ * Both formats come off the *same* shared geometry (`:libs:codes:kikcode`), which is also what the
+ * on-screen `KikCodeContentView` and iOS draw — so an exported file and the code the user is looking
+ * at cannot disagree. Rasterisation stays native (Android [Bitmap] here, `CGContext` on iOS); only
+ * the maths is shared.
+ */
+@Singleton
+class TipCodeExporter @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val dispatchers: DispatcherProvider,
+) {
+
+ /**
+ * Writes [card]'s code in [format] and returns a `content://` handle to it, or `null` if the
+ * write failed — callers should degrade (share the URL alone) rather than surface an error.
+ *
+ * [sizePx] applies to [TipCodeExportFormat.Png] only; the SVG carries a `viewBox` and scales.
+ */
+ suspend fun export(
+ card: Scannable.TipCard,
+ format: TipCodeExportFormat,
+ sizePx: Int = DEFAULT_PNG_SIZE_PX,
+ ): TipCodeExport? = withContext(dispatchers.IO) {
+ val payload = card.data.toByteArray()
+ if (payload.isEmpty()) return@withContext null
+
+ runCatching {
+ val file = TipCodeExportStorage.file(
+ context = context,
+ signature = tipCodePreviewSignature(card),
+ format = format,
+ )
+ when (format) {
+ TipCodeExportFormat.Png -> writePng(payload, sizePx, file)
+ TipCodeExportFormat.Svg -> writeSvg(payload, file)
+ }
+ TipCodeExport(uri = TipCodeExportStorage.uriFor(context, file), format = format)
+ }.getOrNull()
+ }
+
+ private fun writePng(payload: ByteArray, sizePx: Int, file: File) {
+ val badge = ContextCompat.getDrawable(context, R.drawable.ic_logo_round_white)
+ val bitmap = kikCodeBitmap(payload = payload, size = sizePx, badge = badge)
+ try {
+ FileOutputStream(file).use { out ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, out)
+ }
+ } finally {
+ bitmap.recycle()
+ }
+ }
+
+ private fun writeSvg(payload: ByteArray, file: File) {
+ file.writeText(KikCodeSvg.render(payload))
+ }
+
+ private companion object {
+ /**
+ * Large enough that the code stays crisp when a share target scales it up, small enough to
+ * stay well under a megabyte -- the graphic is flat white on transparent, so PNG compresses
+ * it hard.
+ */
+ const val DEFAULT_PNG_SIZE_PX = 1024
+
+ // PNG is lossless; the parameter only controls compression effort.
+ const val PNG_QUALITY = 100
+ }
+}
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodePreviewStorage.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodePreviewStorage.kt
index 02353becc1..0bc4c8ba20 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodePreviewStorage.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodePreviewStorage.kt
@@ -45,20 +45,27 @@ object TipCodePreviewStorage {
maxAge: Long = DEFAULT_MAX_AGE_MILLIS,
now: Long = System.currentTimeMillis(),
) {
- runCatching {
- val files = dir(context).listFiles()?.sortedByDescending { it.lastModified() }
- ?: return
- var kept = 0L
- for (file in files) {
- val expired = now - file.lastModified() > maxAge
- kept += file.length()
- if (expired || kept > maxTotalBytes) {
- file.delete()
- }
- }
- }
+ pruneDirectory(dir(context), maxTotalBytes, maxAge, now)
}
private const val DEFAULT_MAX_TOTAL_BYTES = 8L * 1024 * 1024 // 8 MiB
private const val DEFAULT_MAX_AGE_MILLIS = 24L * 60 * 60 * 1000 // 1 day
}
+
+/**
+ * Deletes anything in [dir] older than [maxAge], then -- newest first -- keeps files until
+ * [maxTotalBytes] is reached and deletes the rest. Never throws; cleanup is best-effort.
+ */
+internal fun pruneDirectory(dir: File, maxTotalBytes: Long, maxAge: Long, now: Long) {
+ runCatching {
+ val files = dir.listFiles()?.sortedByDescending { it.lastModified() } ?: return
+ var kept = 0L
+ for (file in files) {
+ val expired = now - file.lastModified() > maxAge
+ kept += file.length()
+ if (expired || kept > maxTotalBytes) {
+ file.delete()
+ }
+ }
+ }
+}
diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/share/KikCodePainterTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/share/KikCodePainterTest.kt
new file mode 100644
index 0000000000..f3ee6a1fcd
--- /dev/null
+++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/share/KikCodePainterTest.kt
@@ -0,0 +1,111 @@
+package com.flipcash.app.core.share
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import com.getcode.codes.kikcode.kikCodeBitmap
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.GraphicsMode
+import kotlin.math.hypot
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Covers the Android *painter* — the half of code rendering that the cross-platform vectors can't
+ * reach. `:libs:codes:kikcode` gates the geometry and the SVG on both toolchains; these assertions
+ * gate the translation of that geometry into `Canvas` draw calls, where an inverted arc sweep or a
+ * mis-scaled badge would be invisible to the vectors.
+ *
+ * Native graphics so `Canvas` actually rasterises rather than recording no-ops.
+ */
+@RunWith(RobolectricTestRunner::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+@Config(sdk = [34])
+class KikCodePainterTest {
+
+ /**
+ * The full 35-byte payload with every bit set -> all six rings full, i.e. the largest graphic
+ * the geometry can produce. A 20-byte tip payload leaves the outermost ring empty (24 bytes of
+ * finder+payload is 192 bits, and ring 5 starts at bit 240), so it can't measure the framing.
+ */
+ private val saturated = ByteArray(35) { 0xFF.toByte() }
+
+ /** Irregular, so runs, isolated dots and gaps all appear. */
+ private val scattered = ByteArray(20) { (it * 7 + 1).toByte() }
+
+ @Test
+ fun `renders marks`() {
+ val bitmap = kikCodeBitmap(scattered, SIZE)
+ assertEquals(SIZE, bitmap.width)
+ assertEquals(SIZE, bitmap.height)
+ assertTrue(bitmap.opaquePixelCount() > 0, "nothing was drawn")
+ }
+
+ @Test
+ fun `graphic fills its box without overflowing it`() {
+ // The outermost stroke reaches ~0.939 of the radius by construction (ring centre 0.90625
+ // plus half a 0.0656 stroke). Both bounds matter: the upper one catches clipping, and the
+ // lower one catches a re-introduced inset -- Android used to shrink the code to 0.93 and
+ // then overscan the view by 1.03 to compensate, which put it ~4% under iOS.
+ val extent = kikCodeBitmap(saturated, SIZE).maxOpaqueRadius() / (SIZE / 2.0)
+ assertTrue(extent in 0.93..0.96, "outer extent was $extent of the radius")
+ }
+
+ @Test
+ fun `centre well is left empty when there is no badge`() {
+ val bitmap = kikCodeBitmap(scattered, SIZE)
+ assertEquals(Color.TRANSPARENT, bitmap.getPixel(SIZE / 2, SIZE / 2))
+ }
+
+ @Test
+ fun `badge is drawn into the centre well`() {
+ val bitmap = kikCodeBitmap(scattered, SIZE, badge = ColorDrawable(Color.RED))
+ assertEquals(Color.RED, bitmap.getPixel(SIZE / 2, SIZE / 2))
+
+ // The well is INNER_RING_RATIO (0.32) of the outer radius, and the badge fills it exactly.
+ val expected = (SIZE / 2.0) * 0.32
+ val actual = bitmap.run {
+ var left = width
+ for (x in 0 until width) {
+ if (getPixel(x, height / 2) == Color.RED) { left = x; break }
+ }
+ width / 2.0 - left
+ }
+ assertTrue(kotlin.math.abs(actual - expected) <= 1.0, "badge half-width $actual, want $expected")
+ }
+
+ @Test
+ fun `output scales linearly with size`() {
+ val small = kikCodeBitmap(saturated, SIZE).maxOpaqueRadius() / SIZE
+ val large = kikCodeBitmap(saturated, SIZE * 2).maxOpaqueRadius() / (SIZE * 2)
+ assertTrue(kotlin.math.abs(small - large) < 0.005, "extent $small vs $large")
+ }
+
+ private fun Bitmap.pixels(): IntArray =
+ IntArray(width * height).also { getPixels(it, 0, width, 0, 0, width, height) }
+
+ private fun Bitmap.opaquePixelCount(): Int = pixels().count { Color.alpha(it) > ALPHA_FLOOR }
+
+ /** Distance from the centre to the furthest drawn pixel, in px. */
+ private fun Bitmap.maxOpaqueRadius(): Double {
+ val centre = width / 2.0
+ val pixels = pixels()
+ var furthest = 0.0
+ for (index in pixels.indices) {
+ if (Color.alpha(pixels[index]) <= ALPHA_FLOOR) continue
+ val distance = hypot(index % width - centre, (index / width) - centre)
+ if (distance > furthest) furthest = distance
+ }
+ return furthest
+ }
+
+ private companion object {
+ const val SIZE = 512
+
+ // Ignore antialiasing fringes so the extent measurement tracks the shape, not its feather.
+ const val ALPHA_FLOOR = 128
+ }
+}
diff --git a/kmp/shared-core/build.gradle.kts b/kmp/shared-core/build.gradle.kts
index d1ba88a669..e028daa86c 100644
--- a/kmp/shared-core/build.gradle.kts
+++ b/kmp/shared-core/build.gradle.kts
@@ -20,6 +20,7 @@ kotlin {
it.binaries.framework {
baseName = "SharedCore"
isStatic = true
+ export(project(":libs:codes:kikcode"))
export(project(":libs:encryption:base58"))
export(project(":libs:encryption:sha256"))
export(project(":libs:encryption:sha512"))
@@ -31,6 +32,7 @@ kotlin {
sourceSets {
commonMain {
dependencies {
+ api(project(":libs:codes:kikcode"))
api(project(":libs:encryption:base58"))
api(project(":libs:encryption:sha256"))
api(project(":libs:encryption:sha512"))
diff --git a/libs/codes/kikcode/.gitignore b/libs/codes/kikcode/.gitignore
new file mode 100644
index 0000000000..42afabfd2a
--- /dev/null
+++ b/libs/codes/kikcode/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/libs/codes/kikcode/build.gradle.kts b/libs/codes/kikcode/build.gradle.kts
new file mode 100644
index 0000000000..2907456ca5
--- /dev/null
+++ b/libs/codes/kikcode/build.gradle.kts
@@ -0,0 +1,95 @@
+plugins {
+ kotlin("multiplatform")
+ id("com.android.kotlin.multiplatform.library")
+}
+
+/**
+ * Compiles the cross-platform fixtures into `commonTest` as Kotlin constants.
+ *
+ * The parity gate is only worth something if it runs on *both* platforms, and Kotlin/Native test
+ * binaries ship no resource bundle -- `NSBundle.pathForResource` finds nothing there, so a
+ * resource-based loader quietly only ever runs on the JVM. Generating a source file instead makes
+ * the same fixtures readable from every target with no platform code at all.
+ */
+abstract class GenerateTestFixtures : DefaultTask() {
+
+ @get:InputDirectory
+ abstract val fixtures: DirectoryProperty
+
+ @get:OutputDirectory
+ abstract val outputDirectory: DirectoryProperty
+
+ @TaskAction
+ fun generate() {
+ val files = fixtures.get().asFile.listFiles().orEmpty().sortedBy { it.name }
+ val destination = outputDirectory.get().asFile
+ .resolve("com/getcode/codes/kikcode/TestFixtures.kt")
+ destination.parentFile.mkdirs()
+
+ destination.writeText(
+ buildString {
+ appendLine("package com.getcode.codes.kikcode")
+ appendLine()
+ appendLine("// Generated from src/commonTest/resources -- do not edit.")
+ appendLine()
+ appendLine("private val FIXTURES: Map = mapOf(")
+ files.forEach { file ->
+ append(" \"").append(file.name).append("\" to \"")
+ append(file.readText().escapeForKotlin())
+ appendLine("\",")
+ }
+ appendLine(")")
+ appendLine()
+ appendLine("/** Reads a fixture compiled in from `src/commonTest/resources/`. */")
+ appendLine("fun readTestResource(name: String): String =")
+ append(" requireNotNull(FIXTURES[name]) { \"unknown fixture '")
+ appendLine("\$name'\" }")
+ }
+ )
+ }
+
+ private fun String.escapeForKotlin(): String = buildString(length) {
+ this@escapeForKotlin.forEach { character ->
+ when (character) {
+ '\\' -> append("\\\\")
+ '"' -> append("\\\"")
+ '$' -> append("\\$")
+ '\n' -> append("\\n")
+ '\r' -> append("\\r")
+ '\t' -> append("\\t")
+ else -> append(character)
+ }
+ }
+ }
+}
+
+val generateTestFixtures = tasks.register("generateTestFixtures") {
+ fixtures.set(layout.projectDirectory.dir("src/commonTest/resources"))
+ outputDirectory.set(layout.buildDirectory.dir("generated/testFixtures"))
+}
+
+kotlin {
+ android {
+ namespace = "com.getcode.codes.kikcode"
+ compileSdk = 37
+ minSdk = 29
+ withHostTest {}
+ }
+
+ iosArm64()
+ iosSimulatorArm64()
+ iosX64()
+
+ sourceSets {
+ commonMain {
+ // Pure Kotlin -- geometry + string building, no platform APIs.
+ }
+ commonTest {
+ kotlin.srcDir(generateTestFixtures)
+ dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotlinx.serialization.json)
+ }
+ }
+ }
+}
diff --git a/libs/codes/kikcode/src/androidMain/kotlin/com/getcode/codes/kikcode/KikCodePainter.kt b/libs/codes/kikcode/src/androidMain/kotlin/com/getcode/codes/kikcode/KikCodePainter.kt
new file mode 100644
index 0000000000..5e73f1599a
--- /dev/null
+++ b/libs/codes/kikcode/src/androidMain/kotlin/com/getcode/codes/kikcode/KikCodePainter.kt
@@ -0,0 +1,109 @@
+package com.getcode.codes.kikcode
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.RectF
+import android.graphics.drawable.Drawable
+import kotlin.math.PI
+
+/**
+ * Paints a [KikCodeDescription] onto an Android [Canvas].
+ *
+ * Deliberately thin: every coordinate is decided by the shared geometry, so this only turns marks
+ * into draw calls. Keeping it that way is what lets the on-screen code, the exported PNG, the
+ * exported SVG, and iOS all agree — the moment layout maths creeps back in here, they can drift.
+ *
+ * Paints are retained because this is used from `onDraw`.
+ */
+class KikCodePainter(color: Int = Color.WHITE) {
+
+ /** Drawn into the centre well the geometry reserves; `null` leaves the well empty. */
+ var badge: Drawable? = null
+
+ var color: Int = color
+ set(value) {
+ field = value
+ fillPaint.color = value
+ strokePaint.color = value
+ }
+
+ private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.FILL
+ this.color = color
+ }
+
+ private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ style = Paint.Style.STROKE
+ strokeCap = Paint.Cap.ROUND
+ this.color = color
+ }
+
+ private val arcBounds = RectF()
+
+ fun draw(description: KikCodeDescription, canvas: Canvas) {
+ // Runs are stroked, so the stroke width *is* the dot diameter -- a run of bits and an
+ // isolated bit end up exactly the same thickness.
+ strokePaint.strokeWidth = description.dotDiameter.toFloat()
+
+ val center = description.center.toFloat()
+ val dotRadius = (description.dotDiameter / 2.0).toFloat()
+
+ description.marks.forEach { mark ->
+ when (mark) {
+ is KikCodeMark.Dot ->
+ canvas.drawCircle(mark.x.toFloat(), mark.y.toFloat(), dotRadius, fillPaint)
+
+ is KikCodeMark.Ring ->
+ canvas.drawCircle(center, center, mark.radius.toFloat(), strokePaint)
+
+ is KikCodeMark.Arc -> {
+ val radius = mark.radius.toFloat()
+ arcBounds.set(center - radius, center - radius, center + radius, center + radius)
+ canvas.drawArc(
+ arcBounds,
+ mark.startRadians.toDegrees(),
+ mark.sweepRadians.toDegrees(),
+ false,
+ strokePaint,
+ )
+ }
+ }
+ }
+
+ badge?.let { drawable ->
+ val radius = description.badgeRadius
+ drawable.setBounds(
+ (description.center - radius).toInt(),
+ (description.center - radius).toInt(),
+ (description.center + radius).toInt(),
+ (description.center + radius).toInt(),
+ )
+ drawable.draw(canvas)
+ }
+ }
+
+ private fun Double.toDegrees(): Float = (this * 180.0 / PI).toFloat()
+}
+
+/**
+ * Rasterises [payload] into a square [Bitmap] of [size] px.
+ *
+ * [background] defaults to transparent; pass an opaque colour when the destination can't handle
+ * alpha. The caller owns the returned bitmap.
+ */
+fun kikCodeBitmap(
+ payload: ByteArray,
+ size: Int,
+ badge: Drawable? = null,
+ color: Int = Color.WHITE,
+ background: Int = Color.TRANSPARENT,
+): Bitmap {
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ if (background != Color.TRANSPARENT) canvas.drawColor(background)
+ KikCodePainter(color).apply { this.badge = badge }
+ .draw(KikCodeGeometry.describe(payload, size.toDouble()), canvas)
+ return bitmap
+}
diff --git a/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeBadge.kt b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeBadge.kt
new file mode 100644
index 0000000000..b756e91d06
--- /dev/null
+++ b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeBadge.kt
@@ -0,0 +1,20 @@
+package com.getcode.codes.kikcode
+
+/**
+ * The Flipcash badge that sits in a code's middle well, as a single SVG-compatible path.
+ *
+ * Shared so an exported code is self-contained: [KikCodeSvg] embeds it directly rather than
+ * reaching for a platform asset. Transcribed from the Android vector drawable
+ * `ic_logo_round_white.xml`, which is the self-contained form -- a filled disc with the glyph
+ * knocked out via the even-odd rule. (iOS composes the same figure at runtime by masking a circle
+ * with a luminance-to-alpha glyph; both produce a solid disc with a transparent glyph.)
+ */
+object KikCodeBadge {
+
+ /** Side of the square viewport [PATH_DATA] is authored in. */
+ const val VIEWPORT: Double = 62.0
+
+ /** Must be filled with the even-odd rule, or the glyph fills in solid. */
+ const val PATH_DATA: String =
+ "M61.665,30.832C61.665,47.861 47.861,61.665 30.832,61.665C13.804,61.665 0,47.861 0,30.832C0,13.804 13.804,-0 30.832,-0C47.861,-0 61.665,13.804 61.665,30.832ZM24.843,15L24.811,15C22.154,15 20,17.154 20,19.811C20,22.469 22.154,24.623 24.811,24.623L24.811,24.623L34.434,24.623L34.434,24.623L37.642,24.623L39.245,24.623L39.246,24.623C41.903,24.623 44.057,22.469 44.057,19.812C44.057,17.154 41.903,15 39.246,15L39.245,15L37.642,15L34.434,15L34.434,15L24.843,15ZM34.434,27.188L36.038,27.188L36.038,27.188C38.695,27.188 40.849,29.342 40.849,32C40.849,34.657 38.695,36.811 36.038,36.811L36.038,36.811L34.434,36.811L24.858,36.811L24.811,36.811C22.154,36.811 20,34.657 20,32C20,29.343 22.154,27.188 24.811,27.188L24.811,27.188L24.811,27.188L34.434,27.188ZM29.623,44.189C29.623,41.531 27.469,39.377 24.811,39.377C22.154,39.377 20,41.531 20,44.189C20,46.846 22.154,49 24.811,49C27.469,49 29.623,46.846 29.623,44.189Z"
+}
diff --git a/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeGeometry.kt b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeGeometry.kt
new file mode 100644
index 0000000000..fb2e8b79b4
--- /dev/null
+++ b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeGeometry.kt
@@ -0,0 +1,171 @@
+package com.getcode.codes.kikcode
+
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * A resolved, platform-free description of a Kik code graphic: where every mark goes, at what size.
+ *
+ * Produced once in shared code and consumed by every renderer — the Android `Canvas` painter, the
+ * iOS `CGPath` painter, and [KikCodeSvg] — so all three draw byte-for-byte the same figure.
+ *
+ * All coordinates are in the same space as [dimension], with the origin at the graphic's top-left.
+ */
+data class KikCodeDescription(
+ /** Side of the (square) graphic these coordinates are laid out in. */
+ val dimension: Double,
+ /** Center of the graphic, on both axes (`dimension / 2`). */
+ val center: Double,
+ /** Radius of the badge well at the middle, where the logo sits. */
+ val badgeRadius: Double,
+ /** Diameter of a dot, and equivalently the stroke width of an arc. */
+ val dotDiameter: Double,
+ /** Every mark to draw, innermost ring first, in ascending bit order within a ring. */
+ val marks: List,
+)
+
+/**
+ * One drawable element of a code.
+ *
+ * A run of consecutive set bits collapses into a single [Arc]: stroked at [KikCodeDescription
+ * .dotDiameter] with round caps, the arc's caps land exactly where the run's first and last dots
+ * would, so the figure is identical to drawing every dot plus connecting bands — with far fewer
+ * elements.
+ */
+sealed interface KikCodeMark {
+
+ /** A lone set bit, with no set neighbour on either side. Filled, radius `dotDiameter / 2`. */
+ data class Dot(val x: Double, val y: Double) : KikCodeMark
+
+ /**
+ * A run of two or more consecutive set bits, stroked with round caps.
+ *
+ * Angles are in radians, measured from the positive x-axis and increasing clockwise in a
+ * y-down coordinate space (so `-PI / 2` is the apex of the circle). [sweepRadians] is always
+ * positive and strictly less than `2 * PI`; a run that wraps past the ring's first bit simply
+ * sweeps past it, so `startRadians + sweepRadians` may exceed `3 * PI / 2`.
+ */
+ data class Arc(
+ val radius: Double,
+ val startRadians: Double,
+ val sweepRadians: Double,
+ ) : KikCodeMark
+
+ /** A ring whose every bit is set — a closed circle, which no single [Arc] can express. */
+ data class Ring(val radius: Double) : KikCodeMark
+}
+
+/**
+ * Computes [KikCodeDescription]s from a payload.
+ *
+ * The algorithm is the reference Kik code layout, previously reimplemented in
+ * `KikCodeContentRendererImpl` (Android) and `KikCode.generateDescription` (iOS): six concentric
+ * rings, ring `i` carrying `32 + 8i` bits read LSB-first out of [KikCodeSpec.FINDER_BYTES] followed
+ * by the payload; a set bit is a mark at that bit's angle.
+ */
+object KikCodeGeometry {
+
+ /**
+ * Lays [payload] out in a [dimension] x [dimension] box.
+ *
+ * @throws IllegalArgumentException if [dimension] is not positive, or [payload] is empty or
+ * longer than [KikCodeSpec.MAX_PAYLOAD_BYTES].
+ */
+ fun describe(payload: ByteArray, dimension: Double): KikCodeDescription {
+ require(dimension > 0.0) { "dimension must be positive, was $dimension" }
+ require(payload.isNotEmpty()) { "payload is empty" }
+ require(payload.size <= KikCodeSpec.MAX_PAYLOAD_BYTES) {
+ "payload is ${payload.size} bytes; at most ${KikCodeSpec.MAX_PAYLOAD_BYTES} fit"
+ }
+
+ val bytes = KikCodeSpec.FINDER_BYTES + payload
+
+ val center = dimension / 2.0
+ val outerRadius = dimension * KikCodeSpec.OUTER_RATIO
+ val badgeRadius = outerRadius * KikCodeSpec.INNER_RING_RATIO
+ val firstRingEdge = outerRadius * KikCodeSpec.FIRST_RING_RATIO
+ val lastRingEdge = outerRadius * KikCodeSpec.LAST_RING_RATIO
+ val ringWidth = (lastRingEdge - firstRingEdge) / KikCodeSpec.RING_COUNT
+ val dotDiameter = ringWidth * KikCodeSpec.DOT_RATIO
+
+ val marks = mutableListOf()
+ var offset = 0
+
+ for (ring in 0 until KikCodeSpec.RING_COUNT) {
+ var innerEdge = ringWidth * ring + firstRingEdge
+ // The innermost ring is nudged inward so it doesn't crowd the badge well.
+ if (ring == 0) innerEdge -= badgeRadius / 10.0
+
+ val bitCount = KikCodeSpec.bitsInRing(ring)
+ val bits = BooleanArray(bitCount) { bitAt(bytes, offset + it) }
+ addRingMarks(
+ into = marks,
+ bits = bits,
+ radius = innerEdge + ringWidth / 2.0,
+ center = center,
+ )
+ offset += bitCount
+ }
+
+ return KikCodeDescription(
+ dimension = dimension,
+ center = center,
+ badgeRadius = badgeRadius,
+ dotDiameter = dotDiameter,
+ marks = marks,
+ )
+ }
+
+ /** Collapses [bits] into the minimal set of marks on the ring at [radius]. */
+ private fun addRingMarks(
+ into: MutableList,
+ bits: BooleanArray,
+ radius: Double,
+ center: Double,
+ ) {
+ val n = bits.size
+ val delta = 2.0 * PI / n
+
+ // Fully set and fully clear rings have no run boundary to anchor the walk below.
+ if (bits.all { it }) {
+ into += KikCodeMark.Ring(radius)
+ return
+ }
+ if (bits.none { it }) return
+
+ for (index in 0 until n) {
+ if (!bits[index]) continue
+ // Only start at a run head, so each run is emitted exactly once. Because at least one
+ // bit is clear, a run head always exists and every run is bounded.
+ if (bits[(index - 1 + n) % n]) continue
+
+ var length = 1
+ while (bits[(index + length) % n]) length++
+
+ val angle = angleOf(index, delta)
+ into += if (length == 1) {
+ KikCodeMark.Dot(
+ x = center + radius * cos(angle),
+ y = center + radius * sin(angle),
+ )
+ } else {
+ KikCodeMark.Arc(
+ radius = radius,
+ startRadians = angle,
+ sweepRadians = delta * (length - 1),
+ )
+ }
+ }
+ }
+
+ /** Angle of bit [index] on a ring of `2 * PI / delta` bits; bit 0 sits at the apex. */
+ private fun angleOf(index: Int, delta: Double): Double = index * delta - PI / 2.0
+
+ /** The [offset]-th bit of [bytes], LSB-first within each byte; `false` past the end. */
+ private fun bitAt(bytes: ByteArray, offset: Int): Boolean {
+ val byteIndex = offset / 8
+ if (byteIndex >= bytes.size) return false
+ return (bytes[byteIndex].toInt() and (1 shl (offset % 8))) != 0
+ }
+}
diff --git a/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeSpec.kt b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeSpec.kt
new file mode 100644
index 0000000000..dca2fe190a
--- /dev/null
+++ b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeSpec.kt
@@ -0,0 +1,65 @@
+package com.getcode.codes.kikcode
+
+/**
+ * The canonical Kik-code drawing spec — the single source of truth both apps derive their code
+ * graphic from.
+ *
+ * These constants previously lived twice: Android's `KikCodeContentRendererImpl` and iOS's
+ * `KikCode.swift`. The ratios agreed; the *frame* they were applied to did not (see [OUTER_RATIO]).
+ */
+object KikCodeSpec {
+
+ /** Number of concentric data rings. */
+ const val RING_COUNT: Int = 6
+
+ /** Additional bits each successive ring carries over the innermost ring's 32. */
+ const val BITS_PER_RING_STEP: Int = 8
+
+ /** Bits carried by the innermost ring. */
+ const val BASE_BITS_PER_RING: Int = 32
+
+ /**
+ * Radius of the badge/logo well, as a fraction of the code's outer radius.
+ * Also the diameter fraction of the whole graphic (`2 * 0.32 * 0.5 == 0.32`).
+ */
+ const val INNER_RING_RATIO: Double = 0.32
+
+ /** Inner edge of the first data ring, as a fraction of the outer radius. */
+ const val FIRST_RING_RATIO: Double = 0.425
+
+ /** Outer edge of the last data ring, as a fraction of the outer radius. */
+ const val LAST_RING_RATIO: Double = 0.95
+
+ /** Dot diameter (and arc stroke width) as a fraction of a single ring's width. */
+ const val DOT_RATIO: Double = 0.75
+
+ /**
+ * The code's outer radius as a fraction of the graphic's smaller side.
+ *
+ * Canonically `0.5` — the code fills its box, and the outermost dots still clear the edge by
+ * ~3% because [LAST_RING_RATIO] already reserves that margin.
+ *
+ * Note this adopts iOS's framing. Android previously computed `size / 2 * 0.93` and then had
+ * `KikCodeContentView.onDraw` scale the render size up by `1.03` to compensate — a net `0.958`.
+ * Adopting the canonical value makes the Android graphic ~4.2% larger within the same box, and
+ * grows the badge well to match iOS (`0.298` -> `0.32` of the graphic).
+ */
+ const val OUTER_RATIO: Double = 0.5
+
+ /** Fixed prefix every payload is drawn with, so scanners can lock onto the code. */
+ val FINDER_BYTES: ByteArray = byteArrayOf(0xB2.toByte(), 0xCB.toByte(), 0x25.toByte(), 0xC6.toByte())
+
+ /**
+ * Total bits the six rings can carry: `32 + 40 + 48 + 56 + 64 + 72`.
+ */
+ const val CAPACITY_BITS: Int = 312
+
+ /** Total bytes (finder bytes included) the rings can carry: `312 / 8`. */
+ const val CAPACITY_BYTES: Int = CAPACITY_BITS / 8
+
+ /** Largest caller payload that fits once [FINDER_BYTES] is prepended. */
+ const val MAX_PAYLOAD_BYTES: Int = CAPACITY_BYTES - 4
+
+ /** Bits carried by ring [index] (0-based, innermost first). */
+ fun bitsInRing(index: Int): Int = BASE_BITS_PER_RING + BITS_PER_RING_STEP * index
+}
diff --git a/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeSvg.kt b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeSvg.kt
new file mode 100644
index 0000000000..9836fe50b1
--- /dev/null
+++ b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeSvg.kt
@@ -0,0 +1,166 @@
+package com.getcode.codes.kikcode
+
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.roundToLong
+import kotlin.math.sin
+
+/**
+ * Serializes a code graphic to SVG.
+ *
+ * Entirely shared: SVG export needs no platform drawing at all, so Android and iOS emit identical
+ * bytes for the same payload. Output is a standalone document with the badge embedded as a path --
+ * no external assets, no fonts, no text.
+ */
+object KikCodeSvg {
+
+ /** Default export size. Only affects the numbers in the file; SVG scales losslessly. */
+ const val DEFAULT_DIMENSION: Double = 1024.0
+
+ /**
+ * Renders [payload] as a standalone SVG document.
+ *
+ * @param foreground CSS color for the code marks and badge.
+ * @param background CSS color painted behind the code, or `null` for a transparent document.
+ * Codes are light-on-dark, so a transparent export is invisible on light surfaces -- pass the
+ * surface color the code is presented on.
+ * @param includeBadge whether to embed the logo in the middle well.
+ */
+ fun render(
+ payload: ByteArray,
+ dimension: Double = DEFAULT_DIMENSION,
+ foreground: String = "#FFFFFF",
+ background: String? = null,
+ includeBadge: Boolean = true,
+ ): String = render(
+ description = KikCodeGeometry.describe(payload, dimension),
+ foreground = foreground,
+ background = background,
+ includeBadge = includeBadge,
+ )
+
+ /** Renders an already-computed [description]. */
+ fun render(
+ description: KikCodeDescription,
+ foreground: String = "#FFFFFF",
+ background: String? = null,
+ includeBadge: Boolean = true,
+ ): String = buildString {
+ val side = num(description.dimension)
+ append("\n")
+ }
+
+ private fun StringBuilder.appendDots(description: KikCodeDescription, foreground: String) {
+ val dots = description.marks.filterIsInstance()
+ if (dots.isEmpty()) return
+
+ val radius = num(description.dotDiameter / 2.0)
+ append("\n")
+ for (dot in dots) {
+ append("\n")
+ }
+ append("\n")
+ }
+
+ /**
+ * Arcs and full rings share one stroked group: both are centerline paths widened to
+ * [KikCodeDescription.dotDiameter] with round caps, which is what makes a run of bits read as a
+ * capsule with dot-shaped ends.
+ */
+ private fun StringBuilder.appendStrokes(description: KikCodeDescription, foreground: String) {
+ val strokes = description.marks.filter { it !is KikCodeMark.Dot }
+ if (strokes.isEmpty()) return
+
+ append("\n")
+
+ val center = description.center
+ for (mark in strokes) {
+ when (mark) {
+ is KikCodeMark.Ring -> {
+ append("\n")
+ }
+
+ is KikCodeMark.Arc -> {
+ val endAngle = mark.startRadians + mark.sweepRadians
+ val radius = num(mark.radius)
+ // Sweep flag 1: angles increase clockwise, matching the y-down layout space.
+ val largeArc = if (mark.sweepRadians > PI) 1 else 0
+ append("\n")
+ }
+
+ is KikCodeMark.Dot -> Unit // Filtered out above; drawn filled, not stroked.
+ }
+ }
+ append("\n")
+ }
+
+ private fun StringBuilder.appendBadge(description: KikCodeDescription, foreground: String) {
+ val diameter = description.badgeRadius * 2.0
+ val scale = diameter / KikCodeBadge.VIEWPORT
+ val origin = description.center - description.badgeRadius
+
+ append("\n")
+ append("\n")
+ append("\n")
+ }
+
+ /**
+ * Formats a coordinate deterministically.
+ *
+ * `Double.toString()` is not specified to agree between Kotlin/JVM and Kotlin/Native, and the
+ * geometry runs through `cos`/`sin`, whose last-place results may differ between platform libms.
+ * Rounding to [DECIMALS] places -- far coarser than any such difference, and finer than a pixel
+ * at any sane export size -- makes the emitted document byte-identical on both.
+ *
+ * `roundToLong` (ties toward positive infinity), not `round`, because only the former has a
+ * specified tie-break; `round`'s ties-to-even would be a second source of platform drift.
+ */
+ internal fun num(value: Double): String {
+ val scaled = (value * SCALE).roundToLong()
+ if (scaled == 0L) return "0"
+
+ val negative = scaled < 0
+ val magnitude = if (negative) -scaled else scaled
+ val fraction = (magnitude % SCALE_LONG).toString()
+ .padStart(DECIMALS, '0')
+ .trimEnd('0')
+
+ return buildString {
+ if (negative) append('-')
+ append(magnitude / SCALE_LONG)
+ if (fraction.isNotEmpty()) append('.').append(fraction)
+ }
+ }
+
+ private const val DECIMALS = 3
+ private const val SCALE = 1000.0
+ private const val SCALE_LONG = 1000L
+}
diff --git a/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/KikCodeGeometryTest.kt b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/KikCodeGeometryTest.kt
new file mode 100644
index 0000000000..9717d88ae9
--- /dev/null
+++ b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/KikCodeGeometryTest.kt
@@ -0,0 +1,139 @@
+package com.getcode.codes.kikcode
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+class KikCodeGeometryTest {
+
+ private val payload = ByteArray(20) { (it * 37 + 11).toByte() }
+
+ @Test
+ fun rejects_a_non_positive_dimension() {
+ assertFailsWith { KikCodeGeometry.describe(payload, 0.0) }
+ assertFailsWith { KikCodeGeometry.describe(payload, -1.0) }
+ }
+
+ @Test
+ fun rejects_an_empty_payload() {
+ assertFailsWith { KikCodeGeometry.describe(ByteArray(0), 512.0) }
+ }
+
+ @Test
+ fun rejects_a_payload_that_would_overflow_the_rings() {
+ val tooLong = ByteArray(KikCodeSpec.MAX_PAYLOAD_BYTES + 1)
+ assertFailsWith { KikCodeGeometry.describe(tooLong, 512.0) }
+ // One byte less is the exact capacity, and must be accepted.
+ KikCodeGeometry.describe(ByteArray(KikCodeSpec.MAX_PAYLOAD_BYTES), 512.0)
+ }
+
+ @Test
+ fun the_rings_carry_exactly_the_advertised_capacity() {
+ val bits = (0 until KikCodeSpec.RING_COUNT).sumOf { KikCodeSpec.bitsInRing(it) }
+ assertEquals(KikCodeSpec.CAPACITY_BITS, bits)
+ assertEquals(
+ KikCodeSpec.CAPACITY_BYTES,
+ KikCodeSpec.MAX_PAYLOAD_BYTES + KikCodeSpec.FINDER_BYTES.size,
+ )
+ }
+
+ @Test
+ fun every_mark_stays_inside_the_canvas() {
+ val dimension = 1024.0
+ val description = KikCodeGeometry.describe(payload, dimension)
+ val half = description.dotDiameter / 2.0
+
+ for (mark in description.marks) {
+ when (mark) {
+ is KikCodeMark.Dot -> {
+ assertTrue(mark.x - half >= 0.0 && mark.x + half <= dimension, "dot x: $mark")
+ assertTrue(mark.y - half >= 0.0 && mark.y + half <= dimension, "dot y: $mark")
+ }
+ is KikCodeMark.Arc ->
+ assertTrue(mark.radius + half <= description.center, "arc: $mark")
+ is KikCodeMark.Ring ->
+ assertTrue(mark.radius + half <= description.center, "ring: $mark")
+ }
+ }
+ }
+
+ @Test
+ fun no_data_ring_overlaps_the_badge_well() {
+ val description = KikCodeGeometry.describe(payload, 1024.0)
+ val half = description.dotDiameter / 2.0
+ val radii = description.marks.mapNotNull {
+ when (it) {
+ is KikCodeMark.Arc -> it.radius
+ is KikCodeMark.Ring -> it.radius
+ is KikCodeMark.Dot -> null
+ }
+ }
+ assertTrue(radii.isNotEmpty(), "expected some stroked marks")
+ assertTrue(
+ radii.min() - half >= description.badgeRadius,
+ "innermost ring at ${radii.min()} intrudes on the badge (${description.badgeRadius})",
+ )
+ }
+
+ @Test
+ fun geometry_scales_linearly_with_the_dimension() {
+ val small = KikCodeGeometry.describe(payload, 512.0)
+ val large = KikCodeGeometry.describe(payload, 1024.0)
+
+ assertEquals(small.marks.size, large.marks.size)
+ assertEquals(
+ KikCodeSvg.num(small.dotDiameter * 2.0),
+ KikCodeSvg.num(large.dotDiameter),
+ )
+ small.marks.zip(large.marks).forEach { (a, b) ->
+ when {
+ a is KikCodeMark.Dot && b is KikCodeMark.Dot -> {
+ assertEquals(KikCodeSvg.num(a.x * 2.0), KikCodeSvg.num(b.x))
+ assertEquals(KikCodeSvg.num(a.y * 2.0), KikCodeSvg.num(b.y))
+ }
+ a is KikCodeMark.Arc && b is KikCodeMark.Arc -> {
+ assertEquals(KikCodeSvg.num(a.radius * 2.0), KikCodeSvg.num(b.radius))
+ // Angles are dimensionless -- they must not move with the size.
+ assertEquals(a.startRadians, b.startRadians)
+ assertEquals(a.sweepRadians, b.sweepRadians)
+ }
+ a is KikCodeMark.Ring && b is KikCodeMark.Ring ->
+ assertEquals(KikCodeSvg.num(a.radius * 2.0), KikCodeSvg.num(b.radius))
+ else -> throw AssertionError("mark kinds diverged: $a vs $b")
+ }
+ }
+ }
+
+ @Test
+ fun a_fully_set_ring_collapses_to_a_single_ring_mark() {
+ // 0xFF from byte 4 on: rings 1..3 sit entirely inside the all-ones region.
+ val description = KikCodeGeometry.describe(ByteArray(20) { 0xFF.toByte() }, 1024.0)
+ assertTrue(
+ description.marks.filterIsInstance().isNotEmpty(),
+ "expected at least one fully-set ring",
+ )
+ }
+
+ @Test
+ fun an_alternating_payload_produces_only_isolated_dots_in_the_data_rings() {
+ // 0xAA has no two adjacent set bits, so past the finder bytes every mark is a lone dot.
+ val description = KikCodeGeometry.describe(ByteArray(20) { 0xAA.toByte() }, 1024.0)
+ val dots = description.marks.count { it is KikCodeMark.Dot }
+ assertTrue(dots > description.marks.size / 2, "expected mostly dots, got $dots")
+ }
+
+ @Test
+ fun a_run_of_set_bits_becomes_one_arc_not_many_dots() {
+ // Bits 32..39 set (the first data byte), the rest clear: one run of 8 on ring 1.
+ val payload = ByteArray(20).also { it[0] = 0xFF.toByte() }
+ val description = KikCodeGeometry.describe(payload, 1024.0)
+ val arcs = description.marks.filterIsInstance()
+ // Ring 1 has 40 bits; a run of 8 sweeps 7 of its 40 steps.
+ val expectedSweep = 2.0 * kotlin.math.PI / 40.0 * 7.0
+ assertTrue(
+ arcs.any { KikCodeSvg.num(it.sweepRadians) == KikCodeSvg.num(expectedSweep) },
+ "expected an arc sweeping 7 steps of ring 1; got ${arcs.map { it.sweepRadians }}",
+ )
+ }
+}
diff --git a/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/KikCodeSvgTest.kt b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/KikCodeSvgTest.kt
new file mode 100644
index 0000000000..c4f3fae34f
--- /dev/null
+++ b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/KikCodeSvgTest.kt
@@ -0,0 +1,66 @@
+package com.getcode.codes.kikcode
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class KikCodeSvgTest {
+
+ private val payload = ByteArray(20) { (it * 37 + 11).toByte() }
+
+ @Test
+ fun omits_the_background_rect_when_no_background_is_given() {
+ val svg = KikCodeSvg.render(payload, background = null)
+ assertFalse(svg.contains(""))
+ }
+
+ @Test
+ fun omits_the_badge_when_it_is_switched_off() {
+ assertFalse(KikCodeSvg.render(payload, includeBadge = false).contains("evenodd"))
+ assertTrue(KikCodeSvg.render(payload, includeBadge = true).contains("evenodd"))
+ }
+
+ @Test
+ fun the_document_is_self_contained() {
+ val svg = KikCodeSvg.render(payload, background = "#000000")
+ assertTrue(svg.startsWith("