From 255672262cec4800affde0bd57e4ff1ba1650b28 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 16:51:03 -0400 Subject: [PATCH] feat(codes): share scannable-code geometry via KMP; export tip codes 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. --- .../app/src/main/res/xml/file_paths.xml | 2 + .../flipcash/app/core/share/TipCodeExport.kt | 69 +++ .../app/core/share/TipCodeExporter.kt | 91 ++++ .../app/core/share/TipCodePreviewStorage.kt | 31 +- .../app/core/share/KikCodePainterTest.kt | 111 +++++ kmp/shared-core/build.gradle.kts | 2 + libs/codes/kikcode/.gitignore | 1 + libs/codes/kikcode/build.gradle.kts | 95 ++++ .../getcode/codes/kikcode/KikCodePainter.kt | 109 +++++ .../com/getcode/codes/kikcode/KikCodeBadge.kt | 20 + .../getcode/codes/kikcode/KikCodeGeometry.kt | 171 +++++++ .../com/getcode/codes/kikcode/KikCodeSpec.kt | 65 +++ .../com/getcode/codes/kikcode/KikCodeSvg.kt | 166 +++++++ .../codes/kikcode/KikCodeGeometryTest.kt | 139 ++++++ .../getcode/codes/kikcode/KikCodeSvgTest.kt | 66 +++ .../codes/kikcode/KikCodeVectorTest.kt | 92 ++++ .../src/commonTest/resources/kikcode.json | 432 ++++++++++++++++++ .../commonTest/resources/kikcode_golden.svg | 57 +++ settings.gradle.kts | 2 + test-vectors/README.md | 33 +- test-vectors/gen_kikcode.py | 288 ++++++++++++ test-vectors/kikcode.json | 432 ++++++++++++++++++ test-vectors/kikcode_golden.svg | 57 +++ vendor/kik/scanner/build.gradle.kts | 1 + .../kincodes/KikCodeContentRendererImpl.kt | 139 +----- .../kik/kikx/kincodes/KikCodeContentView.kt | 8 +- 26 files changed, 2543 insertions(+), 136 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExport.kt create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/share/TipCodeExporter.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/share/KikCodePainterTest.kt create mode 100644 libs/codes/kikcode/.gitignore create mode 100644 libs/codes/kikcode/build.gradle.kts create mode 100644 libs/codes/kikcode/src/androidMain/kotlin/com/getcode/codes/kikcode/KikCodePainter.kt create mode 100644 libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeBadge.kt create mode 100644 libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeGeometry.kt create mode 100644 libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeSpec.kt create mode 100644 libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/KikCodeSvg.kt create mode 100644 libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/KikCodeGeometryTest.kt create mode 100644 libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/KikCodeSvgTest.kt create mode 100644 libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/KikCodeVectorTest.kt create mode 100644 libs/codes/kikcode/src/commonTest/resources/kikcode.json create mode 100644 libs/codes/kikcode/src/commonTest/resources/kikcode_golden.svg create mode 100644 test-vectors/gen_kikcode.py create mode 100644 test-vectors/kikcode.json create mode 100644 test-vectors/kikcode_golden.svg 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") + + if (background != null) { + append("\n") + } + + appendDots(description, foreground) + appendStrokes(description, foreground) + if (includeBadge) appendBadge(description, foreground) + + 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("")) + // No fonts, no text, no external references -- the export must not depend on the host. + assertFalse(svg.contains(" "D ${KikCodeSvg.num(x)} ${KikCodeSvg.num(y)}" + is KikCodeMark.Arc -> + "A ${KikCodeSvg.num(radius)} ${KikCodeSvg.num(startRadians)} ${KikCodeSvg.num(sweepRadians)}" + is KikCodeMark.Ring -> "R ${KikCodeSvg.num(radius)}" +} + +private fun String.hexToBytes(): ByteArray = + if (isEmpty()) ByteArray(0) + else ByteArray(length / 2) { i -> + ((this[i * 2].digitToInt(16) shl 4) or this[i * 2 + 1].digitToInt(16)).toByte() + } diff --git a/libs/codes/kikcode/src/commonTest/resources/kikcode.json b/libs/codes/kikcode/src/commonTest/resources/kikcode.json new file mode 100644 index 0000000000..c86b80e2b5 --- /dev/null +++ b/libs/codes/kikcode/src/commonTest/resources/kikcode.json @@ -0,0 +1,432 @@ +{ + "description": "Kik code graphic geometry. Marks are 'D x y' (dot), 'A radius start sweep' (arc, radians) or 'R radius' (full ring); numbers are rounded to 3 decimals, ties toward positive infinity.", + "golden": { + "case": "tip-card-20", + "payload": "934fe83b4817ced1ed90cdf6570dcbc08609d4af", + "dimension": "512", + "foreground": "#FFFFFF", + "background": "#000000", + "file": "kikcode_golden.svg", + "sha256": "aab0aff4097158c1d087fd51e961c46ec67439c267cab0ab605a3043882b4de3" + }, + "vectors": [ + { + "name": "zeros-20", + "payload": "0000000000000000000000000000000000000000", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196" + ] + }, + { + "name": "ones-20", + "payload": "ffffffffffffffffffffffffffffffffffffffff", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "R 284.8", + "R 329.6", + "R 374.4", + "A 419.2 -1.571 1.473" + ] + }, + { + "name": "alternating-20", + "payload": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "D 556.553 230.706", + "D 641.296 258.241", + "D 713.384 310.616", + "D 765.759 382.704", + "D 793.294 467.447", + "D 793.294 556.553", + "D 765.759 641.296", + "D 713.384 713.384", + "D 641.296 765.759", + "D 556.553 793.294", + "D 467.447 793.294", + "D 382.704 765.759", + "D 310.616 713.384", + "D 258.241 641.296", + "D 230.706 556.553", + "D 230.706 467.447", + "D 258.241 382.704", + "D 310.616 310.616", + "D 382.704 258.241", + "D 467.447 230.706", + "D 555.021 185.22", + "D 638.132 207.489", + "D 712.648 250.511", + "D 773.489 311.352", + "D 816.511 385.868", + "D 838.78 468.979", + "D 838.78 555.021", + "D 816.511 638.132", + "D 773.489 712.648", + "D 712.648 773.489", + "D 638.132 816.511", + "D 555.021 838.78", + "D 468.979 838.78", + "D 385.868 816.511", + "D 311.352 773.489", + "D 250.511 712.648", + "D 207.489 638.132", + "D 185.22 555.021", + "D 185.22 468.979", + "D 207.489 385.868", + "D 250.511 311.352", + "D 311.352 250.511", + "D 385.868 207.489", + "D 468.979 185.22", + "D 553.919 139.954", + "D 635.656 158.61", + "D 711.193 194.986", + "D 776.741 247.259", + "D 829.014 312.807", + "D 865.39 388.344", + "D 884.046 470.081", + "D 884.046 553.919", + "D 865.39 635.656", + "D 829.014 711.193", + "D 776.741 776.741", + "D 711.193 829.014", + "D 635.656 865.39", + "D 553.919 884.046", + "D 470.081 884.046", + "D 388.344 865.39", + "D 312.807 829.014", + "D 247.259 776.741", + "D 194.986 711.193", + "D 158.61 635.656", + "D 139.954 553.919", + "D 139.954 470.081", + "D 158.61 388.344", + "D 194.986 312.807", + "D 247.259 247.259", + "D 312.807 194.986", + "D 388.344 158.61", + "D 470.081 139.954", + "D 553.089 94.819", + "D 633.687 110.851", + "D 709.61 142.299", + "D 777.938 187.954", + "D 836.046 246.062", + "D 881.701 314.39", + "D 913.149 390.313", + "D 929.181 470.911" + ] + }, + { + "name": "tip-card-20", + "payload": "934fe83b4817ced1ed90cdf6570dcbc08609d4af", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "A 284.8 -1.571 0.157", + "D 679.401 281.592", + "A 284.8 -0.471 0.628", + "D 742.408 679.401", + "D 556.553 793.294", + "A 284.8 1.728 0.628", + "A 284.8 2.67 0.314", + "D 310.616 310.616", + "D 423.992 241.139", + "D 676.8 226.558", + "A 329.6 -0.393 0.262", + "A 329.6 0.262 0.262", + "D 676.8 797.442", + "A 329.6 1.309 0.262", + "A 329.6 1.833 0.131", + "A 329.6 2.225 0.262", + "D 182.4 512", + "A 329.6 3.534 0.131", + "A 329.6 3.927 0.131", + "A 329.6 4.451 0.524", + "A 374.4 -1.459 0.112", + "A 374.4 -1.122 0.673", + "D 877.013 428.688", + "D 886.4 512", + "D 877.013 595.312", + "A 374.4 0.449 0.112", + "A 374.4 1.122 0.112", + "D 553.919 884.046", + "A 374.4 1.795 0.112", + "A 374.4 2.693 0.112", + "A 374.4 3.029 0.112", + "A 374.4 3.703 0.112", + "D 312.807 194.986", + "D 593.782 100.855", + "D 672.421 124.71", + "A 419.2 -0.982 0.491", + "D 913.149 390.313", + "D 929.181 470.911" + ] + }, + { + "name": "tip-card-20-at-300", + "payload": "934fe83b4817ced1ed90cdf6570dcbc08609d4af", + "dimension": "300", + "center": "150", + "badgeRadius": "48", + "dotDiameter": "9.844", + "marks": [ + "D 162.781 85.746", + "A 65.513 -0.785 0.196", + "A 65.513 -0.196 0.393", + "D 204.472 186.397", + "A 65.513 1.178 0.393", + "D 124.929 210.526", + "D 95.528 186.397", + "A 65.513 3.338 0.196", + "A 65.513 4.32 0.196", + "A 83.438 -1.571 0.157", + "D 199.043 82.498", + "A 83.438 -0.471 0.628", + "D 217.502 199.043", + "D 163.053 232.41", + "A 83.438 1.728 0.628", + "A 83.438 2.67 0.314", + "D 91.001 91.001", + "D 124.216 70.646", + "D 198.281 66.374", + "A 96.563 -0.393 0.262", + "A 96.563 0.262 0.262", + "D 198.281 233.626", + "A 96.563 1.309 0.262", + "A 96.563 1.833 0.131", + "A 96.563 2.225 0.262", + "D 53.438 150", + "A 96.563 3.534 0.131", + "A 96.563 3.927 0.131", + "A 96.563 4.451 0.524", + "A 109.688 -1.459 0.112", + "A 109.688 -1.122 0.673", + "D 256.937 125.592", + "D 259.688 150", + "D 256.937 174.408", + "A 109.688 0.449 0.112", + "A 109.688 1.122 0.112", + "D 162.281 258.998", + "A 109.688 1.795 0.112", + "A 109.688 2.693 0.112", + "A 109.688 3.029 0.112", + "A 109.688 3.703 0.112", + "D 91.643 57.125", + "D 173.96 29.547", + "D 196.998 36.536", + "A 122.813 -0.982 0.491", + "D 267.524 114.349", + "D 272.221 137.962" + ] + }, + { + "name": "tip-card-20-at-512", + "payload": "934fe83b4817ced1ed90cdf6570dcbc08609d4af", + "dimension": "512", + "center": "256", + "badgeRadius": "81.92", + "dotDiameter": "16.8", + "marks": [ + "D 277.813 146.34", + "A 111.808 -0.785 0.196", + "A 111.808 -0.196 0.393", + "D 348.965 318.117", + "A 111.808 1.178 0.393", + "D 213.213 359.297", + "D 163.035 318.117", + "A 111.808 3.338 0.196", + "A 111.808 4.32 0.196", + "A 142.4 -1.571 0.157", + "D 339.701 140.796", + "A 142.4 -0.471 0.628", + "D 371.204 339.701", + "D 278.276 396.647", + "A 142.4 1.728 0.628", + "A 142.4 2.67 0.314", + "D 155.308 155.308", + "D 211.996 120.57", + "D 338.4 113.279", + "A 164.8 -0.393 0.262", + "A 164.8 0.262 0.262", + "D 338.4 398.721", + "A 164.8 1.309 0.262", + "A 164.8 1.833 0.131", + "A 164.8 2.225 0.262", + "D 91.2 256", + "A 164.8 3.534 0.131", + "A 164.8 3.927 0.131", + "A 164.8 4.451 0.524", + "A 187.2 -1.459 0.112", + "A 187.2 -1.122 0.673", + "D 438.507 214.344", + "D 443.2 256", + "D 438.507 297.656", + "A 187.2 0.449 0.112", + "A 187.2 1.122 0.112", + "D 276.96 442.023", + "A 187.2 1.795 0.112", + "A 187.2 2.693 0.112", + "A 187.2 3.029 0.112", + "A 187.2 3.703 0.112", + "D 156.404 97.493", + "D 296.891 50.427", + "D 336.21 62.355", + "A 209.6 -0.982 0.491", + "D 456.575 195.156", + "D 464.591 235.456" + ] + }, + { + "name": "single-byte", + "payload": "01", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "D 512 227.2" + ] + }, + { + "name": "max-payload-35", + "payload": "19b59a026ebecc43a313798594148ed34db0d7063aee390cee5313bdf3301408749886", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "D 512 227.2", + "A 284.8 -1.1 0.157", + "D 782.861 423.992", + "D 796.8 512", + "A 284.8 0.314 0.157", + "D 713.384 713.384", + "D 641.296 765.759", + "A 284.8 1.414 0.157", + "D 382.704 765.759", + "D 310.616 713.384", + "A 284.8 3.613 0.314", + "A 284.8 4.241 0.157", + "A 329.6 -1.44 0.524", + "D 773.489 311.352", + "A 329.6 -0.262 0.131", + "A 329.6 0.262 0.393", + "D 597.307 830.369", + "A 329.6 1.571 0.131", + "D 311.352 773.489", + "A 329.6 2.487 0.262", + "D 182.4 512", + "D 226.558 347.2", + "A 329.6 4.058 0.393", + "D 595.312 146.987", + "D 776.741 247.259", + "D 849.323 349.554", + "D 877.013 428.688", + "D 884.046 553.919", + "D 849.323 674.446", + "D 804.718 745.435", + "A 374.4 1.234 0.224", + "A 374.4 1.907 0.224", + "D 219.282 745.435", + "A 374.4 2.693 0.224", + "A 374.4 3.142 0.112", + "D 174.677 349.554", + "A 374.4 4.264 0.112", + "A 374.4 4.6 0.112", + "A 419.2 -1.571 0.196", + "D 672.421 124.71", + "A 419.2 -0.982 0.098", + "A 419.2 -0.687 0.098", + "D 929.181 553.089", + "A 419.2 0.295 0.196", + "A 419.2 0.884 0.196", + "A 419.2 1.276 0.295", + "A 419.2 1.865 0.196", + "A 419.2 2.553 0.098", + "A 419.2 3.24 0.196", + "A 419.2 3.632 0.393", + "D 351.579 124.71", + "D 430.218 100.855", + "D 670.697 75.983", + "D 810.253 156.555", + "A 464 -0.698 0.262", + "A 464 -0.262 0.175", + "A 464 0.175 0.262", + "A 464 0.873 0.087", + "D 592.573 968.951", + "D 512 976", + "D 245.861 892.087", + "D 75.983 670.697", + "A 464 2.967 0.175", + "A 464 3.578 0.087", + "D 183.902 183.902", + "A 464 4.102 0.087", + "A 464 4.625 0.175" + ] + } + ] +} diff --git a/libs/codes/kikcode/src/commonTest/resources/kikcode_golden.svg b/libs/codes/kikcode/src/commonTest/resources/kikcode_golden.svg new file mode 100644 index 0000000000..045b82e1cf --- /dev/null +++ b/libs/codes/kikcode/src/commonTest/resources/kikcode_golden.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 73dea3dcf8..3a46883b8e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -135,6 +135,7 @@ include( ":libs:analytics", ":libs:biometrics", ":libs:code-detection", + ":libs:codes:kikcode", ":libs:coroutines", ":libs:crypto:kin", ":libs:crypto:solana", @@ -253,6 +254,7 @@ val unitTestPaths = listOf(":apps:flipcash", ":services:flipcash", ":services:op val jvmUnitTestModules = setOf(":apps:flipcash:shared:ksp") // KMP modules run their host tests via `testAndroidHostTest`, not `testDebugUnitTest`. val kmpUnitTestModules = setOf( + ":libs:codes:kikcode", ":libs:encryption:base58", ":libs:encryption:sha256", ":libs:encryption:sha512", diff --git a/test-vectors/README.md b/test-vectors/README.md index 8fd82cc400..8a1143c306 100644 --- a/test-vectors/README.md +++ b/test-vectors/README.md @@ -36,6 +36,7 @@ impl must reproduce the fixtures before the native duplicates are deleted. | `curve.json` | `:libs:currency-math` androidTest → `connectedAndroidTest` (device, loads .bin tables) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `solana_message.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `compact_message.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | +| `kikcode.json` + `kikcode_golden.svg` | `:libs:codes:kikcode` → `testAndroidHostTest` (host JVM) — **green** | *same Kotlin source* → `iosSimulatorArm64Test` (Kotlin/Native) — **green** | Why the iOS split: **ed25519** lives in the standalone `CodeCurves` C package → host `swift test`. Everything else (**base58**, and later derivation/bonding curve) lives in **FlipcashCore**, whose @@ -44,6 +45,11 @@ FlipcashCore declares only iOS — so `FlipcashCoreVectors` runs on an **iOS sim test -scheme FlipcashCoreVectors-Package`. (If FlipcashCore ever declares macOS support, these could run on the host too.) +`kikcode.json` is the first fixture where "both platforms" means **one** implementation: the module is +KMP, so the same `commonTest` sources are compiled and run twice — once on the JVM, once on +Kotlin/Native. The gate there isn't "do two hand-written impls agree" but "does the *shared* impl emit +byte-identical output on both toolchains" (`Double` formatting and libm trig are the hazards). + ## ed25519 (`ed25519.json`) RFC 8032 vectors (SHA-512) + app-shaped cases, via Python `cryptography` (`gen_ed25519.py`). Each: @@ -135,6 +141,28 @@ little-endian on both (iOS `withUnsafeBytes(of: littleEndian)`, Android `Long.to Remaining C3 increments: versioned (V0) messages + address-lookup-tables; specific program-instruction data (`BuyTokens` etc.); and the second SubmitIntent path (the proto `SubmitActions` signature). +## kikcode (`kikcode.json` + `kikcode_golden.svg`) + +Scannable-code geometry and SVG serialization for the shared `:libs:codes:kikcode` module +(`gen_kikcode.py`), which backs tip-card PNG/SVG export on both apps. Each vector: `payload` (hex) + +`dimension` → expected `center` / `badgeRadius` / `dotDiameter` and the full ordered list of **marks**, +encoded as `"D "` (dot), `"A "` (stroked arc, radians), `"R "` +(full ring). The golden file is the complete SVG document for the `tip-card-20` payload at 512px, +compared **byte-for-byte** — that comparison is what actually proves both toolchains agree, since it +folds every coordinate through the same formatter. + +The Python reference is written from the spec ratios (ring count, `0.32`/`0.425`/`0.95`/`0.75`, the +`B2 CB 25 C6` finder prefix, LSB-first bit order), not transcribed from the Kotlin, so a match is +independent corroboration rather than a tautology. Two determinism notes baked into both sides: +`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 (`kotlin.math.round` is ties-to-even and was +avoided). Python mirrors it with `math.floor(v * 1000.0 + 0.5)`. + +Coverage: all-zero / all-one / alternating bit patterns (so `Dot`, `Arc`, and `Ring` marks are all +exercised — `ones-20` alone yields 4 dots, 6 arcs, 3 rings), a realistic tip-card payload at three +dimensions (to pin exact linear scaling), a single-byte payload, and the 35-byte maximum. + ## Regenerate Run from `code-android-app/test-vectors/`: @@ -147,6 +175,7 @@ python3 gen_curve.py > curve.json # whole-token buy-side ( python3 gen_curve_fractional.py > curve_fractional.json # fractional sell-path + rounding-tie (exact rational) python3 gen_solana_message.py > solana_message.json # Solana legacy-message serialization python3 gen_compact_message.py > compact_message.json # intent-signing compact message + SHA256 +python3 gen_kikcode.py # writes kikcode.json AND kikcode_golden.svg # Sync to Android per-module copies (from the repo root): cp test-vectors/ed25519.json libs/encryption/ed25519/src/androidTest/assets/ @@ -156,6 +185,8 @@ cp test-vectors/curve.json libs/currency-math/src/androidTest/assets/ cp test-vectors/curve_fractional.json libs/currency-math/src/androidTest/assets/ cp test-vectors/solana_message.json services/opencode/src/test/resources/ cp test-vectors/compact_message.json services/opencode/src/test/resources/ +cp test-vectors/kikcode.json test-vectors/kikcode_golden.svg \ + libs/codes/kikcode/src/commonTest/resources/ # KMP: one copy serves both platforms # Sync to iOS repo (from the orchestrator root, adjust paths as needed): cp test-vectors/ed25519.json ../code-ios-app/CrossPlatformVectors/Tests/CrossPlatformVectorsTests/Fixtures/ @@ -170,7 +201,7 @@ cp test-vectors/compact_message.json ../code-ios-app/FlipcashCoreVectors/Tests/F After copying, verify SHA-256 parity: ```bash -for f in ed25519.json base58.json slip10.json curve.json curve_fractional.json solana_message.json compact_message.json; do +for f in ed25519.json base58.json slip10.json curve.json curve_fractional.json solana_message.json compact_message.json kikcode.json kikcode_golden.svg; do canonical=$(shasum -a 256 "test-vectors/$f" | awk '{print $1}') echo "$f: $canonical (canonical)" done diff --git a/test-vectors/gen_kikcode.py b/test-vectors/gen_kikcode.py new file mode 100644 index 0000000000..16683c656d --- /dev/null +++ b/test-vectors/gen_kikcode.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Generates `kikcode.json` (+ `kikcode_golden.svg`), the parity gate for the Kik code graphic. + +Written as an *independent* implementation of the drawing spec -- from the ratios, not from the +Kotlin -- so a transcription slip in `:libs:codes:kikcode` shows up as a failing vector rather than +matching fixtures generated by the same mistake. + +Marks are encoded as compact strings so comparison is exact rather than float-fuzzy: + + "D " a lone dot + "A " a run of consecutive set bits, angles in radians + "R " a ring whose every bit is set + +Every number uses the same 3-decimal, ties-toward-positive-infinity rounding the Kotlin writer +applies, which is what makes the emitted SVG byte-identical on JVM and Native. + +Usage: python3 gen_kikcode.py +""" + +import hashlib +import json +import math +import os + +RING_COUNT = 6 +BASE_BITS_PER_RING = 32 +BITS_PER_RING_STEP = 8 + +INNER_RING_RATIO = 0.32 +FIRST_RING_RATIO = 0.425 +LAST_RING_RATIO = 0.95 +DOT_RATIO = 0.75 +OUTER_RATIO = 0.5 + +FINDER_BYTES = bytes([0xB2, 0xCB, 0x25, 0xC6]) +MAX_PAYLOAD_BYTES = 35 + +BADGE_VIEWPORT = 62.0 +BADGE_PATH = None # filled in from the Kotlin constant at run time + + +def num(value): + """3-decimal rounding, ties toward positive infinity (Kotlin `roundToLong`).""" + scaled = math.floor(value * 1000.0 + 0.5) + if scaled == 0: + return "0" + negative = scaled < 0 + magnitude = -scaled if negative else scaled + fraction = str(magnitude % 1000).rjust(3, "0").rstrip("0") + out = ("-" if negative else "") + str(magnitude // 1000) + if fraction: + out += "." + fraction + return out + + +def bit_at(data, offset): + index = offset // 8 + if index >= len(data): + return False + return (data[index] & (1 << (offset % 8))) != 0 + + +def ring_marks(bits, radius, center): + n = len(bits) + delta = 2.0 * math.pi / n + if all(bits): + return [("R", radius, None, None)] + if not any(bits): + return [] + + marks = [] + for index in range(n): + if not bits[index]: + continue + if bits[(index - 1 + n) % n]: # not a run head + continue + length = 1 + while bits[(index + length) % n]: + length += 1 + angle = index * delta - math.pi / 2.0 + if length == 1: + marks.append( + ("D", center + radius * math.cos(angle), center + radius * math.sin(angle), None) + ) + else: + marks.append(("A", radius, angle, delta * (length - 1))) + return marks + + +def describe(payload, dimension): + data = FINDER_BYTES + payload + center = dimension / 2.0 + outer_radius = dimension * OUTER_RATIO + badge_radius = outer_radius * INNER_RING_RATIO + first_edge = outer_radius * FIRST_RING_RATIO + last_edge = outer_radius * LAST_RING_RATIO + ring_width = (last_edge - first_edge) / RING_COUNT + + marks = [] + offset = 0 + for ring in range(RING_COUNT): + inner_edge = ring_width * ring + first_edge + if ring == 0: + inner_edge -= badge_radius / 10.0 + bit_count = BASE_BITS_PER_RING + BITS_PER_RING_STEP * ring + bits = [bit_at(data, offset + j) for j in range(bit_count)] + marks += ring_marks(bits, inner_edge + ring_width / 2.0, center) + offset += bit_count + + return { + "dimension": dimension, + "center": center, + "badgeRadius": badge_radius, + "dotDiameter": ring_width * DOT_RATIO, + "marks": marks, + } + + +def encode_mark(mark): + kind, a, b, c = mark + if kind == "D": + return "D %s %s" % (num(a), num(b)) + if kind == "R": + return "R %s" % num(a) + return "A %s %s %s" % (num(a), num(b), num(c)) + + +def render_svg(description, foreground="#FFFFFF", background=None, include_badge=True): + side = num(description["dimension"]) + center = description["center"] + dot_diameter = description["dotDiameter"] + out = [] + out.append( + '\n' + % (side, side, side, side) + ) + if background is not None: + out.append('\n' % (side, side, background)) + + dots = [m for m in description["marks"] if m[0] == "D"] + if dots: + radius = num(dot_diameter / 2.0) + out.append('\n' % foreground) + for _, x, y, _unused in dots: + out.append('\n' % (num(x), num(y), radius)) + out.append("\n") + + strokes = [m for m in description["marks"] if m[0] != "D"] + if strokes: + out.append( + '\n' + % (foreground, num(dot_diameter)) + ) + for kind, radius, start, sweep in strokes: + if kind == "R": + out.append( + '\n' % (num(center), num(center), num(radius)) + ) + else: + end = start + sweep + large_arc = 1 if sweep > math.pi else 0 + out.append( + '\n' + % ( + num(center + radius * math.cos(start)), + num(center + radius * math.sin(start)), + num(radius), + num(radius), + large_arc, + num(center + radius * math.cos(end)), + num(center + radius * math.sin(end)), + ) + ) + out.append("\n") + + if include_badge: + diameter = description["badgeRadius"] * 2.0 + origin = center - description["badgeRadius"] + out.append( + '\n' + % (foreground, num(origin), num(origin), num(diameter / BADGE_VIEWPORT)) + ) + out.append('\n' % BADGE_PATH) + out.append("\n") + + out.append("\n") + return "".join(out) + + +def load_badge_path(): + """Reads the badge path from the shared Kotlin constant (it is one long literal).""" + here = os.path.dirname(os.path.abspath(__file__)) + src = os.path.join( + here, "..", "libs", "codes", "kikcode", "src", "commonMain", "kotlin", + "com", "getcode", "codes", "kikcode", "KikCodeBadge.kt", + ) + with open(src) as handle: + for line in handle: + stripped = line.strip() + if stripped.startswith('"M') and stripped.endswith('"'): + return stripped[1:-1] + raise SystemExit("badge path not found in KikCodeBadge.kt") + + +def digest_bytes(seed, length): + """Deterministic pseudo-random payload, so vectors are reproducible without a fixed blob.""" + out = b"" + counter = 0 + while len(out) < length: + out += hashlib.sha256(("%s:%d" % (seed, counter)).encode()).digest() + counter += 1 + return out[:length] + + +CASES = [ + # name, payload, dimension + ("zeros-20", bytes(20), 1024.0), + ("ones-20", bytes([0xFF] * 20), 1024.0), + ("alternating-20", bytes([0xAA] * 20), 1024.0), + ("tip-card-20", digest_bytes("flipcash tip card", 20), 1024.0), + ("tip-card-20-at-300", digest_bytes("flipcash tip card", 20), 300.0), + ("tip-card-20-at-512", digest_bytes("flipcash tip card", 20), 512.0), + ("single-byte", bytes([0x01]), 1024.0), + ("max-payload-35", digest_bytes("max payload", MAX_PAYLOAD_BYTES), 1024.0), +] + +GOLDEN_CASE = ("tip-card-20", digest_bytes("flipcash tip card", 20), 512.0) +GOLDEN_FOREGROUND = "#FFFFFF" +GOLDEN_BACKGROUND = "#000000" + + +def main(): + global BADGE_PATH + BADGE_PATH = load_badge_path() + here = os.path.dirname(os.path.abspath(__file__)) + + vectors = [] + for name, payload, dimension in CASES: + description = describe(payload, dimension) + vectors.append({ + "name": name, + "payload": payload.hex(), + "dimension": num(dimension), + "center": num(description["center"]), + "badgeRadius": num(description["badgeRadius"]), + "dotDiameter": num(description["dotDiameter"]), + "marks": [encode_mark(m) for m in description["marks"]], + }) + + _, golden_payload, golden_dimension = GOLDEN_CASE + golden = render_svg( + describe(golden_payload, golden_dimension), + foreground=GOLDEN_FOREGROUND, + background=GOLDEN_BACKGROUND, + ) + + document = { + "description": ( + "Kik code graphic geometry. Marks are 'D x y' (dot), " + "'A radius start sweep' (arc, radians) or 'R radius' (full ring); " + "numbers are rounded to 3 decimals, ties toward positive infinity." + ), + "golden": { + "case": GOLDEN_CASE[0], + "payload": golden_payload.hex(), + "dimension": num(golden_dimension), + "foreground": GOLDEN_FOREGROUND, + "background": GOLDEN_BACKGROUND, + "file": "kikcode_golden.svg", + "sha256": hashlib.sha256(golden.encode()).hexdigest(), + }, + "vectors": vectors, + } + + with open(os.path.join(here, "kikcode.json"), "w") as handle: + json.dump(document, handle, indent=2) + handle.write("\n") + with open(os.path.join(here, "kikcode_golden.svg"), "w") as handle: + handle.write(golden) + + total = sum(len(v["marks"]) for v in vectors) + print("wrote kikcode.json (%d vectors, %d marks)" % (len(vectors), total)) + print("wrote kikcode_golden.svg (%d bytes)" % len(golden)) + + +if __name__ == "__main__": + main() diff --git a/test-vectors/kikcode.json b/test-vectors/kikcode.json new file mode 100644 index 0000000000..c86b80e2b5 --- /dev/null +++ b/test-vectors/kikcode.json @@ -0,0 +1,432 @@ +{ + "description": "Kik code graphic geometry. Marks are 'D x y' (dot), 'A radius start sweep' (arc, radians) or 'R radius' (full ring); numbers are rounded to 3 decimals, ties toward positive infinity.", + "golden": { + "case": "tip-card-20", + "payload": "934fe83b4817ced1ed90cdf6570dcbc08609d4af", + "dimension": "512", + "foreground": "#FFFFFF", + "background": "#000000", + "file": "kikcode_golden.svg", + "sha256": "aab0aff4097158c1d087fd51e961c46ec67439c267cab0ab605a3043882b4de3" + }, + "vectors": [ + { + "name": "zeros-20", + "payload": "0000000000000000000000000000000000000000", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196" + ] + }, + { + "name": "ones-20", + "payload": "ffffffffffffffffffffffffffffffffffffffff", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "R 284.8", + "R 329.6", + "R 374.4", + "A 419.2 -1.571 1.473" + ] + }, + { + "name": "alternating-20", + "payload": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "D 556.553 230.706", + "D 641.296 258.241", + "D 713.384 310.616", + "D 765.759 382.704", + "D 793.294 467.447", + "D 793.294 556.553", + "D 765.759 641.296", + "D 713.384 713.384", + "D 641.296 765.759", + "D 556.553 793.294", + "D 467.447 793.294", + "D 382.704 765.759", + "D 310.616 713.384", + "D 258.241 641.296", + "D 230.706 556.553", + "D 230.706 467.447", + "D 258.241 382.704", + "D 310.616 310.616", + "D 382.704 258.241", + "D 467.447 230.706", + "D 555.021 185.22", + "D 638.132 207.489", + "D 712.648 250.511", + "D 773.489 311.352", + "D 816.511 385.868", + "D 838.78 468.979", + "D 838.78 555.021", + "D 816.511 638.132", + "D 773.489 712.648", + "D 712.648 773.489", + "D 638.132 816.511", + "D 555.021 838.78", + "D 468.979 838.78", + "D 385.868 816.511", + "D 311.352 773.489", + "D 250.511 712.648", + "D 207.489 638.132", + "D 185.22 555.021", + "D 185.22 468.979", + "D 207.489 385.868", + "D 250.511 311.352", + "D 311.352 250.511", + "D 385.868 207.489", + "D 468.979 185.22", + "D 553.919 139.954", + "D 635.656 158.61", + "D 711.193 194.986", + "D 776.741 247.259", + "D 829.014 312.807", + "D 865.39 388.344", + "D 884.046 470.081", + "D 884.046 553.919", + "D 865.39 635.656", + "D 829.014 711.193", + "D 776.741 776.741", + "D 711.193 829.014", + "D 635.656 865.39", + "D 553.919 884.046", + "D 470.081 884.046", + "D 388.344 865.39", + "D 312.807 829.014", + "D 247.259 776.741", + "D 194.986 711.193", + "D 158.61 635.656", + "D 139.954 553.919", + "D 139.954 470.081", + "D 158.61 388.344", + "D 194.986 312.807", + "D 247.259 247.259", + "D 312.807 194.986", + "D 388.344 158.61", + "D 470.081 139.954", + "D 553.089 94.819", + "D 633.687 110.851", + "D 709.61 142.299", + "D 777.938 187.954", + "D 836.046 246.062", + "D 881.701 314.39", + "D 913.149 390.313", + "D 929.181 470.911" + ] + }, + { + "name": "tip-card-20", + "payload": "934fe83b4817ced1ed90cdf6570dcbc08609d4af", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "A 284.8 -1.571 0.157", + "D 679.401 281.592", + "A 284.8 -0.471 0.628", + "D 742.408 679.401", + "D 556.553 793.294", + "A 284.8 1.728 0.628", + "A 284.8 2.67 0.314", + "D 310.616 310.616", + "D 423.992 241.139", + "D 676.8 226.558", + "A 329.6 -0.393 0.262", + "A 329.6 0.262 0.262", + "D 676.8 797.442", + "A 329.6 1.309 0.262", + "A 329.6 1.833 0.131", + "A 329.6 2.225 0.262", + "D 182.4 512", + "A 329.6 3.534 0.131", + "A 329.6 3.927 0.131", + "A 329.6 4.451 0.524", + "A 374.4 -1.459 0.112", + "A 374.4 -1.122 0.673", + "D 877.013 428.688", + "D 886.4 512", + "D 877.013 595.312", + "A 374.4 0.449 0.112", + "A 374.4 1.122 0.112", + "D 553.919 884.046", + "A 374.4 1.795 0.112", + "A 374.4 2.693 0.112", + "A 374.4 3.029 0.112", + "A 374.4 3.703 0.112", + "D 312.807 194.986", + "D 593.782 100.855", + "D 672.421 124.71", + "A 419.2 -0.982 0.491", + "D 913.149 390.313", + "D 929.181 470.911" + ] + }, + { + "name": "tip-card-20-at-300", + "payload": "934fe83b4817ced1ed90cdf6570dcbc08609d4af", + "dimension": "300", + "center": "150", + "badgeRadius": "48", + "dotDiameter": "9.844", + "marks": [ + "D 162.781 85.746", + "A 65.513 -0.785 0.196", + "A 65.513 -0.196 0.393", + "D 204.472 186.397", + "A 65.513 1.178 0.393", + "D 124.929 210.526", + "D 95.528 186.397", + "A 65.513 3.338 0.196", + "A 65.513 4.32 0.196", + "A 83.438 -1.571 0.157", + "D 199.043 82.498", + "A 83.438 -0.471 0.628", + "D 217.502 199.043", + "D 163.053 232.41", + "A 83.438 1.728 0.628", + "A 83.438 2.67 0.314", + "D 91.001 91.001", + "D 124.216 70.646", + "D 198.281 66.374", + "A 96.563 -0.393 0.262", + "A 96.563 0.262 0.262", + "D 198.281 233.626", + "A 96.563 1.309 0.262", + "A 96.563 1.833 0.131", + "A 96.563 2.225 0.262", + "D 53.438 150", + "A 96.563 3.534 0.131", + "A 96.563 3.927 0.131", + "A 96.563 4.451 0.524", + "A 109.688 -1.459 0.112", + "A 109.688 -1.122 0.673", + "D 256.937 125.592", + "D 259.688 150", + "D 256.937 174.408", + "A 109.688 0.449 0.112", + "A 109.688 1.122 0.112", + "D 162.281 258.998", + "A 109.688 1.795 0.112", + "A 109.688 2.693 0.112", + "A 109.688 3.029 0.112", + "A 109.688 3.703 0.112", + "D 91.643 57.125", + "D 173.96 29.547", + "D 196.998 36.536", + "A 122.813 -0.982 0.491", + "D 267.524 114.349", + "D 272.221 137.962" + ] + }, + { + "name": "tip-card-20-at-512", + "payload": "934fe83b4817ced1ed90cdf6570dcbc08609d4af", + "dimension": "512", + "center": "256", + "badgeRadius": "81.92", + "dotDiameter": "16.8", + "marks": [ + "D 277.813 146.34", + "A 111.808 -0.785 0.196", + "A 111.808 -0.196 0.393", + "D 348.965 318.117", + "A 111.808 1.178 0.393", + "D 213.213 359.297", + "D 163.035 318.117", + "A 111.808 3.338 0.196", + "A 111.808 4.32 0.196", + "A 142.4 -1.571 0.157", + "D 339.701 140.796", + "A 142.4 -0.471 0.628", + "D 371.204 339.701", + "D 278.276 396.647", + "A 142.4 1.728 0.628", + "A 142.4 2.67 0.314", + "D 155.308 155.308", + "D 211.996 120.57", + "D 338.4 113.279", + "A 164.8 -0.393 0.262", + "A 164.8 0.262 0.262", + "D 338.4 398.721", + "A 164.8 1.309 0.262", + "A 164.8 1.833 0.131", + "A 164.8 2.225 0.262", + "D 91.2 256", + "A 164.8 3.534 0.131", + "A 164.8 3.927 0.131", + "A 164.8 4.451 0.524", + "A 187.2 -1.459 0.112", + "A 187.2 -1.122 0.673", + "D 438.507 214.344", + "D 443.2 256", + "D 438.507 297.656", + "A 187.2 0.449 0.112", + "A 187.2 1.122 0.112", + "D 276.96 442.023", + "A 187.2 1.795 0.112", + "A 187.2 2.693 0.112", + "A 187.2 3.029 0.112", + "A 187.2 3.703 0.112", + "D 156.404 97.493", + "D 296.891 50.427", + "D 336.21 62.355", + "A 209.6 -0.982 0.491", + "D 456.575 195.156", + "D 464.591 235.456" + ] + }, + { + "name": "single-byte", + "payload": "01", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "D 512 227.2" + ] + }, + { + "name": "max-payload-35", + "payload": "19b59a026ebecc43a313798594148ed34db0d7063aee390cee5313bdf3301408749886", + "dimension": "1024", + "center": "512", + "badgeRadius": "163.84", + "dotDiameter": "33.6", + "marks": [ + "D 555.625 292.681", + "A 223.616 -0.785 0.196", + "A 223.616 -0.196 0.393", + "D 697.93 636.234", + "A 223.616 1.178 0.393", + "D 426.426 718.594", + "D 326.07 636.234", + "A 223.616 3.338 0.196", + "A 223.616 4.32 0.196", + "D 512 227.2", + "A 284.8 -1.1 0.157", + "D 782.861 423.992", + "D 796.8 512", + "A 284.8 0.314 0.157", + "D 713.384 713.384", + "D 641.296 765.759", + "A 284.8 1.414 0.157", + "D 382.704 765.759", + "D 310.616 713.384", + "A 284.8 3.613 0.314", + "A 284.8 4.241 0.157", + "A 329.6 -1.44 0.524", + "D 773.489 311.352", + "A 329.6 -0.262 0.131", + "A 329.6 0.262 0.393", + "D 597.307 830.369", + "A 329.6 1.571 0.131", + "D 311.352 773.489", + "A 329.6 2.487 0.262", + "D 182.4 512", + "D 226.558 347.2", + "A 329.6 4.058 0.393", + "D 595.312 146.987", + "D 776.741 247.259", + "D 849.323 349.554", + "D 877.013 428.688", + "D 884.046 553.919", + "D 849.323 674.446", + "D 804.718 745.435", + "A 374.4 1.234 0.224", + "A 374.4 1.907 0.224", + "D 219.282 745.435", + "A 374.4 2.693 0.224", + "A 374.4 3.142 0.112", + "D 174.677 349.554", + "A 374.4 4.264 0.112", + "A 374.4 4.6 0.112", + "A 419.2 -1.571 0.196", + "D 672.421 124.71", + "A 419.2 -0.982 0.098", + "A 419.2 -0.687 0.098", + "D 929.181 553.089", + "A 419.2 0.295 0.196", + "A 419.2 0.884 0.196", + "A 419.2 1.276 0.295", + "A 419.2 1.865 0.196", + "A 419.2 2.553 0.098", + "A 419.2 3.24 0.196", + "A 419.2 3.632 0.393", + "D 351.579 124.71", + "D 430.218 100.855", + "D 670.697 75.983", + "D 810.253 156.555", + "A 464 -0.698 0.262", + "A 464 -0.262 0.175", + "A 464 0.175 0.262", + "A 464 0.873 0.087", + "D 592.573 968.951", + "D 512 976", + "D 245.861 892.087", + "D 75.983 670.697", + "A 464 2.967 0.175", + "A 464 3.578 0.087", + "D 183.902 183.902", + "A 464 4.102 0.087", + "A 464 4.625 0.175" + ] + } + ] +} diff --git a/test-vectors/kikcode_golden.svg b/test-vectors/kikcode_golden.svg new file mode 100644 index 0000000000..045b82e1cf --- /dev/null +++ b/test-vectors/kikcode_golden.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vendor/kik/scanner/build.gradle.kts b/vendor/kik/scanner/build.gradle.kts index afd6d65e92..657540956c 100644 --- a/vendor/kik/scanner/build.gradle.kts +++ b/vendor/kik/scanner/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.javax.inject) implementation(libs.hilt.android) implementation(project(":libs:code-detection")) + api(project(":libs:codes:kikcode")) implementation(project(":libs:encryption:ed25519")) implementation(project(":vendor:opencv:sdk")) } diff --git a/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kincodes/KikCodeContentRendererImpl.kt b/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kincodes/KikCodeContentRendererImpl.kt index 13c4861649..82c452c326 100644 --- a/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kincodes/KikCodeContentRendererImpl.kt +++ b/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kincodes/KikCodeContentRendererImpl.kt @@ -1,130 +1,31 @@ package com.kik.kikx.kincodes import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.RectF import android.graphics.drawable.Drawable -import kotlin.experimental.and - +import com.getcode.codes.kikcode.KikCodeGeometry +import com.getcode.codes.kikcode.KikCodePainter + +/** + * Draws a scannable code by delegating its layout to the shared (KMP) geometry and painting the + * resulting marks. + * + * This used to compute the ring radii and walk the bits itself, which meant the on-screen code, the + * exported images, and iOS each had their own copy of the maths. The geometry now lives in + * `:libs:codes:kikcode` and is gated by cross-platform vectors, so all four surfaces are the same + * numbers. + */ class KikCodeContentRendererImpl : KikCodeContentRenderer { - companion object { - private const val RING_COUNT = 6 - private const val SCALE_FACTOR = 8 - - private const val INNER_RING_RATIO = 0.32f - private const val FIRST_RING_RATIO = 0.425f - private const val LAST_RING_RATIO = 0.95f - - private val FINDER_BYTES = byteArrayOf(0xB2.toByte(), 0xCB.toByte(), 0x25.toByte(), 0xC6.toByte()) - } - - var badge: Drawable? = null - - private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - isAntiAlias = true - style = Paint.Style.FILL_AND_STROKE - setARGB(255, 255, 255, 255) - } - private val arcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - isAntiAlias = true - style = Paint.Style.STROKE - setARGB(255, 255, 255, 255) - strokeCap = Paint.Cap.ROUND - } - - override fun render(encodedKikCode: ByteArray, size: Int, canvas: Canvas) { - val midX = size / 2f - val midY = size / 2f - - val dataByteArray = FINDER_BYTES + encodedKikCode - - // Offset maxRadius by border - val maxRadius = (size / 2 * 0.93).toFloat() - - // Calculate all the radii - val innerRingRadius = maxRadius * INNER_RING_RATIO - val firstRingRadius = maxRadius * FIRST_RING_RATIO - val lastRingRadius = maxRadius * LAST_RING_RATIO - - val ringRadius = (lastRingRadius - firstRingRadius) / RING_COUNT - val dotSize = ringRadius * 3 / 4 - - arcPaint.strokeWidth = dotSize - var bitsRead = 0 - - for (i in 0 until RING_COUNT) { - var currentRingRadius = ringRadius * i + firstRingRadius + private val painter = KikCodePainter() - if (i == 0) { - currentRingRadius -= innerRingRadius / 10 - } - - val bitsPerRing = 32 + SCALE_FACTOR * i - - val anglePerBit = 2 * Math.PI / bitsPerRing - - val bitsReadBeforeCurrentRing = bitsRead - val currentRadius = currentRingRadius + ringRadius / 2 - - var bitsInARow = 0 - var startAngle = 0.0 - - for (j in 0 until bitsPerRing) { - // This is so that the angle will start at the apex of circle - val angle = j * anglePerBit - Math.PI / 2 - - // get correct bit from byte - val bitMask = 0x1 shl bitsRead % 8 - - val byteIndex = bitsRead / 8 - // check if bit is on or off - val currentBit = byteIndex < dataByteArray.count() && (dataByteArray[byteIndex] and bitMask.toByte()) != 0.toByte() - if (!currentBit) { - bitsRead++ - continue - } - - if (bitsInARow == 0) { - startAngle = angle - } - bitsInARow++ - - val nextOffset = (bitsRead - bitsReadBeforeCurrentRing + 1) % bitsPerRing + bitsReadBeforeCurrentRing - val nextBitMask = 0x1 shl nextOffset % 8 - val nextIndex = nextOffset / 8 - var nextBit = nextIndex < dataByteArray.count() && (dataByteArray[nextOffset / 8] and nextBitMask.toByte()) != 0.toByte() - - // This is for the edge case where the start bit of the ring and the end bit of the ring both are there to draw over - // Note:: nextbit in this case would be the first bit of the current ring (nextOffset is modded by bitsPerRing) - if (j + 1 == bitsPerRing && nextBit) { - bitsInARow++ - // Set to false so it will draw - nextBit = false - } - - // If the next bit is not present, draw the arc, draw's 1 arc to avoid weird artifacts - if (!nextBit) { - if (bitsInARow > 1) { - val rectF = RectF(midX - currentRadius, midY - currentRadius, midX + currentRadius, midY + currentRadius) - canvas.drawArc(rectF, Math.toDegrees(startAngle).toFloat(), Math.toDegrees(anglePerBit * (bitsInARow - 1)).toFloat(), false, arcPaint) - } else { - val currentX = midX + currentRadius * Math.cos(angle) - val currentY = midY + currentRadius * Math.sin(angle) - - canvas.drawCircle(currentX.toFloat(), currentY.toFloat(), dotSize / 2, circlePaint) - } - bitsInARow = 0 - } - - bitsRead++ - } + var badge: Drawable? + get() = painter.badge + set(value) { + painter.badge = value } - // Render logo in the middle - badge?.apply { - setBounds((midX - innerRingRadius).toInt(), (midY - innerRingRadius).toInt(), (midX + innerRingRadius).toInt(), (midY + innerRingRadius).toInt()) - draw(canvas) - } + override fun render(encodedKikCode: ByteArray, size: Int, canvas: Canvas) { + if (encodedKikCode.isEmpty()) return + painter.draw(KikCodeGeometry.describe(encodedKikCode, size.toDouble()), canvas) } } diff --git a/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kincodes/KikCodeContentView.kt b/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kincodes/KikCodeContentView.kt index 38347e3397..ea86243c31 100644 --- a/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kincodes/KikCodeContentView.kt +++ b/vendor/kik/scanner/src/main/kotlin/com/kik/kikx/kincodes/KikCodeContentView.kt @@ -6,7 +6,6 @@ import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.View import kotlin.math.min -import kotlin.math.roundToInt class KikCodeContentView @JvmOverloads constructor( context: Context, @@ -32,12 +31,13 @@ class KikCodeContentView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - val smallSide = (min(width, height) * 1.03f).roundToInt() + // The shared geometry already keeps every mark inside its box (the outermost stroke reaches + // ~0.94 of the radius), so the code fills the square directly. There used to be a 1.03 + // overscan here to undo a 0.93 inset the renderer applied; both are gone. + val smallSide = min(width, height) canvas.translate((width - smallSide) / 2f, (height - smallSide) / 2f) val encodedKikCode = encodedKikCode ?: return renderer.render(encodedKikCode, smallSide, canvas) } - } -