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) } - } -