diff --git a/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/LuminancePlane.kt b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/LuminancePlane.kt new file mode 100644 index 0000000000..556a8f5c78 --- /dev/null +++ b/libs/codes/kikcode/src/commonMain/kotlin/com/getcode/codes/kikcode/LuminancePlane.kt @@ -0,0 +1,74 @@ +package com.getcode.codes.kikcode + +/** + * The contract between a camera frame's luminance (Y) plane and the native code scanner. + * + * `kikCodeScan` does `memcpy(greyscale.data, image, height * width)` — it reads exactly + * [scannedByteCount] bytes from the front of whatever buffer it is handed, and assumes those bytes + * are a tightly packed `width * height` greyscale image. Cameras do not always hand us that: rows + * are commonly padded out to a hardware alignment, so a plane's row stride can exceed its width. + * + * Both platforms got this wrong in different directions, which is why the rule lives here: + * - Android guarded the unpadding with `pixelStride != -1`, which is vacuously true for a + * YUV_420_888 Y plane, so it ran an O(width * height) per-pixel copy on *every* frame — ~1.5 ms + * at 1080p — even when the plane was already packed. + * - iOS never unpadded at all, and read its stride with `CVPixelBufferGetBytesPerRow` (which + * reports a whole-buffer value for planar formats) instead of + * `CVPixelBufferGetBytesPerRowOfPlane(_, 0)`. It survives only because the 1080p capture width + * happens to be 64-aligned and therefore unpadded. + * + * ## Why this shares the decision and not the bytes + * + * [unpad] is deliberately *not* part of the iOS-facing surface. Handing a plane across the + * Kotlin/Native bridge would convert `Data` to `ByteArray`, copying the whole ~2 MB frame — far + * worse than the copy this is meant to avoid. Callers ask [isTightlyPacked] whether a copy is + * needed and then move the bytes in their own native code, so the shared piece stays branch-only + * and allocation-free. + */ +object LuminancePlane { + + /** + * Whether the plane can be handed to the scanner as-is. + * + * When true the first [scannedByteCount] bytes are already the image the scanner expects, so the + * buffer can be passed through with no copy. When false the caller must repack it — see [unpad] + * for the reference implementation. + * + * A YUV_420_888 Y plane always reports a [pixelStride] of 1 (the format guarantees the Y plane + * is never interleaved), so in practice only [rowStride] decides this; the pixel-stride term is + * kept so the rule stays correct if that guarantee ever loosens. + */ + fun isTightlyPacked(width: Int, rowStride: Int, pixelStride: Int): Boolean = + rowStride == width && pixelStride == 1 + + /** How many bytes the scanner reads for a `width x height` frame. */ + fun scannedByteCount(width: Int, height: Int): Int = width * height + + /** + * Repacks a padded or interleaved plane into a tightly packed `width * height` buffer. + * + * Returns [data] untouched when [isTightlyPacked] already holds, so the common case costs + * nothing. Note the returned array may be *longer* than [scannedByteCount] — the scanner only + * reads the front of it. + * + * This is the JVM/Android path. iOS repacks in Swift over the raw plane pointer rather than + * calling this, to keep the frame out of the Kotlin/Native bridge. + */ + fun unpad( + data: ByteArray, + width: Int, + height: Int, + rowStride: Int, + pixelStride: Int, + ): ByteArray { + if (isTightlyPacked(width, rowStride, pixelStride)) return data + + val cleanData = ByteArray(scannedByteCount(width, height)) + for (y in 0 until height) { + for (x in 0 until width) { + cleanData[y * width + x] = data[y * rowStride + x * pixelStride] + } + } + return cleanData + } +} diff --git a/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/LuminancePlaneTest.kt b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/LuminancePlaneTest.kt new file mode 100644 index 0000000000..2bd5dfe0a3 --- /dev/null +++ b/libs/codes/kikcode/src/commonTest/kotlin/com/getcode/codes/kikcode/LuminancePlaneTest.kt @@ -0,0 +1,135 @@ +package com.getcode.codes.kikcode + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Covers the packing rule every analyzed camera frame goes through, on both platforms. + * + * Android's guard used to read `pixelStride != -1`, which is vacuously true for a YUV_420_888 Y + * plane (whose pixel stride is always 1), so the `||` short-circuited to always-true and the + * per-pixel copy ran on every frame even for tightly packed buffers. iOS had the opposite bug: it + * never unpadded at all. These tests run on both targets so the rule cannot drift again. + */ +class LuminancePlaneTest { + + /** Android's pre-fix guard, kept verbatim so we can assert the two agree on output. */ + private fun legacyUnpad( + data: ByteArray, + width: Int, + height: Int, + rowStride: Int, + pixelStride: Int, + ): ByteArray { + if (width != rowStride || pixelStride != -1) { + val cleanData = ByteArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + cleanData[y * width + x] = data[y * rowStride + x * pixelStride] + } + } + return cleanData + } + return data + } + + private fun plane(height: Int, rowStride: Int): ByteArray = + ByteArray(rowStride * height) { (it % 251).toByte() } + + @Test + fun `a plane is tightly packed only when the row stride matches the width`() { + assertTrue(LuminancePlane.isTightlyPacked(width = 1920, rowStride = 1920, pixelStride = 1)) + // 64-byte row alignment, the usual source of padding on both platforms + assertFalse(LuminancePlane.isTightlyPacked(width = 1440, rowStride = 1472, pixelStride = 1)) + assertFalse(LuminancePlane.isTightlyPacked(width = 1000, rowStride = 1024, pixelStride = 1)) + // interleaved planes are never packed, even when the arithmetic happens to line up + assertFalse(LuminancePlane.isTightlyPacked(width = 64, rowStride = 64, pixelStride = 2)) + } + + @Test + fun `the scanner reads exactly width times height bytes`() { + assertEquals(1920 * 1080, LuminancePlane.scannedByteCount(1920, 1080)) + } + + @Test + fun `tightly packed plane takes the fast path and avoids a copy`() { + val width = 640 + val height = 480 + val data = plane(height, rowStride = width) + + val result = LuminancePlane.unpad(data, width, height, rowStride = width, pixelStride = 1) + + assertSame(data, result, "tightly packed plane should be returned without copying") + } + + @Test + fun `padded plane still strips row padding`() { + val width = 640 + val height = 480 + val rowStride = 768 // 128 bytes of row padding + val data = plane(height, rowStride) + + val result = LuminancePlane.unpad(data, width, height, rowStride, pixelStride = 1) + + assertEquals(width * height, result.size) + for (y in 0 until height) { + for (x in 0 until width) { + assertEquals(data[y * rowStride + x], result[y * width + x], "mismatch at ($x,$y)") + } + } + } + + @Test + fun `interleaved plane still honours pixel stride`() { + val width = 32 + val height = 16 + val pixelStride = 2 + val rowStride = width * pixelStride + val data = plane(height, rowStride) + + val result = LuminancePlane.unpad(data, width, height, rowStride, pixelStride) + + assertEquals(width * height, result.size) + for (y in 0 until height) { + for (x in 0 until width) { + assertEquals(data[y * rowStride + x * pixelStride], result[y * width + x]) + } + } + } + + /** + * The Android fix is a pure speedup: for every plane geometry the camera can hand us, the bytes + * the scanner sees must be byte-identical to what the old code produced. + */ + @Test + fun `output is byte-identical to the pre-fix implementation`() { + val geometries = listOf( + Triple(640, 480, 640), + Triple(640, 480, 768), + Triple(1280, 720, 1280), + Triple(1280, 720, 1408), + Triple(1920, 1080, 1920), + Triple(1920, 1080, 2048), + ) + + for ((width, height, rowStride) in geometries) { + val data = plane(height, rowStride) + val fixed = LuminancePlane.unpad(data, width, height, rowStride, pixelStride = 1) + val legacy = legacyUnpad(data, width, height, rowStride, pixelStride = 1) + + // The fast path hands back the backing array, which is longer than width*height when + // the buffer is over-allocated; the scanner only reads the first width*height. + val scanned = LuminancePlane.scannedByteCount(width, height) + assertTrue(fixed.size >= scanned, "${width}x$height/$rowStride too small") + assertContentEquals( + legacy.copyOf(scanned), + fixed.copyOf(scanned), + "content drift at ${width}x$height rowStride=$rowStride", + ) + } + } +} diff --git a/vendor/kik/scanner/build.gradle.kts b/vendor/kik/scanner/build.gradle.kts index 657540956c..ab1cbd9ea3 100644 --- a/vendor/kik/scanner/build.gradle.kts +++ b/vendor/kik/scanner/build.gradle.kts @@ -36,4 +36,10 @@ dependencies { api(project(":libs:codes:kikcode")) implementation(project(":libs:encryption:ed25519")) implementation(project(":vendor:opencv:sdk")) + + androidTestImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotlin.test.junit) + androidTestImplementation(libs.kotlinx.coroutines.core) } diff --git a/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeScanTest.kt b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeScanTest.kt new file mode 100644 index 0000000000..66e0e96a6a --- /dev/null +++ b/vendor/kik/scanner/src/androidTest/kotlin/com/kik/scan/KikCodeScanTest.kt @@ -0,0 +1,430 @@ +package com.kik.scan + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.ShapeDrawable +import android.graphics.drawable.shapes.OvalShape +import android.os.Debug +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.getcode.codes.kikcode.LuminancePlane +import com.kik.kikx.kikcodes.ScanQuality +import com.kik.kikx.kikcodes.implementation.KikCodeScannerImpl +import com.kik.kikx.kincodes.KikCodeContentRendererImpl +import com.kik.kikx.models.ScannableKikCode +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.system.measureNanoTime +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end sweep over rendered Kik codes. + * + * Follows the production pipeline exactly: [Scanner.encode] produces the same encoded bytes the + * backend hands the bill UI, [KikCodeContentRendererImpl] draws them through the shared geometry, + * the result is packed into a synthetic YUV_420_888 Y plane, run through the same + * [LuminancePlane.unpad] conversion the camera analyzer uses, and handed to the native scanner. + * + * Results are logged under [TAG]. + */ +@RunWith(AndroidJUnit4::class) +class KikCodeScanTest { + + /** + * The detector locates a code by its centre ellipse, so the badge well must be filled — in the + * app that is the round logo drawable. An empty well is simply not scannable. + */ + private val renderer = KikCodeContentRendererImpl().apply { + badge = ShapeDrawable(OvalShape()).apply { paint.color = Color.WHITE } + } + private val scanner = KikCodeScannerImpl() + + /** Analysis resolutions the app requests, plus common fallbacks. */ + private val resolutions = listOf( + 640 to 480, + 1280 to 720, + 1920 to 1080, + ) + + /** Fraction of the frame's short side the code graphic occupies. */ + private val codeScales = listOf(0.5f, 0.7f, 0.9f) + + private data class Frame( + val data: ByteArray, + val width: Int, + val height: Int, + val rowStride: Int, + ) + + /** A remote code payload is 20 bytes; encode it the way the backend does. */ + private fun encodeRemoteCode(seed: Int): Pair { + val payload = ByteArray(REMOTE_PAYLOAD_BYTES) { ((it * 7 + seed) and 0xFF).toByte() } + val encoded = requireNotNull(Scanner.encode(payload)) { "native encode returned null" } + return payload to encoded + } + + /** + * Renders [encoded] centred in a `width x height` frame and returns it as a Y plane with + * [rowPadding] bytes of stride padding per row — i.e. the shape the camera hands us. + */ + private fun renderFrame( + encoded: ByteArray, + width: Int, + height: Int, + scale: Float, + rowPadding: Int, + ): Frame { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + canvas.drawColor(Color.BLACK) + + val codeSize = (minOf(width, height) * scale).toInt() + canvas.save() + canvas.translate((width - codeSize) / 2f, (height - codeSize) / 2f) + renderer.render(encoded, codeSize, canvas) + canvas.restore() + + val pixels = IntArray(width * height) + bitmap.getPixels(pixels, 0, width, 0, 0, width, height) + bitmap.recycle() + + val rowStride = width + rowPadding + val plane = ByteArray(rowStride * height) + for (y in 0 until height) { + val rowStart = y * rowStride + val pixelRow = y * width + for (x in 0 until width) { + val p = pixels[pixelRow + x] + // BT.601 luma, matching what the camera produces for the Y plane. + val luma = ( + 77 * ((p shr 16) and 0xFF) + + 150 * ((p shr 8) and 0xFF) + + 29 * (p and 0xFF) + ) shr 8 + plane[rowStart + x] = luma.toByte() + } + } + return Frame(plane, width, height, rowStride) + } + + private fun scan(frame: Frame): ScannableKikCode? { + val converted = LuminancePlane.unpad( + data = frame.data, + width = frame.width, + height = frame.height, + rowStride = frame.rowStride, + pixelStride = 1, + ) + return runBlocking { + scanner.scanKikCode(converted, frame.width, frame.height, ScanQuality.Best).getOrNull() + } + } + + @Test + fun sweepRenderedCodesAcrossResolutionsAndStrides() { + val (payload, encoded) = encodeRemoteCode(seed = 3) + + var attempts = 0 + var decoded = 0 + val failures = mutableListOf() + + for ((width, height) in resolutions) { + for (scale in codeScales) { + // rowPadding 0 exercises the fast path; 64 exercises the unpadding copy. + for (rowPadding in listOf(0, 64)) { + val frame = renderFrame(encoded, width, height, scale, rowPadding) + val label = "${width}x$height scale=$scale rowStride=${frame.rowStride}" + attempts++ + + val result = scan(frame) + if (result is ScannableKikCode.RemoteKikCode && + result.payloadId.contentEquals(payload) + ) { + decoded++ + Log.i(TAG, "DECODED $label") + } else { + failures += label + Log.w(TAG, "MISSED $label -> $result") + } + } + } + } + + Log.i(TAG, "sweep: $decoded/$attempts decoded") + assertTrue( + failures.isEmpty(), + "scanner failed to decode rendered code at: ${failures.joinToString()}", + ) + } + + /** + * The padded and packed representations of the same frame must decode identically — this is what + * proves the fast path is a pure speedup and not a behaviour change. + */ + @Test + fun paddedAndPackedPlanesDecodeIdentically() { + val (payload, encoded) = encodeRemoteCode(seed = 11) + + for ((width, height) in resolutions) { + val packed = renderFrame(encoded, width, height, 0.7f, rowPadding = 0) + val padded = renderFrame(encoded, width, height, 0.7f, rowPadding = 128) + + // Sanity: the fast path really is taken for the packed frame and not for the padded one. + assertTrue( + LuminancePlane.unpad(packed.data, width, height, packed.rowStride, 1) === packed.data, + "${width}x$height packed frame should take the fast path", + ) + assertTrue( + LuminancePlane.unpad(padded.data, width, height, padded.rowStride, 1) !== padded.data, + "${width}x$height padded frame should be unpadded", + ) + + val fromPacked = scan(packed) + val fromPadded = scan(padded) + Log.i(TAG, "stride parity ${width}x$height: packed=$fromPacked padded=$fromPadded") + + assertTrue( + fromPacked is ScannableKikCode.RemoteKikCode && + fromPacked.payloadId.contentEquals(payload), + "packed frame did not decode at ${width}x$height", + ) + // RemoteKikCode is a data class over a ByteArray, so its generated equals() compares + // array identity -- compare contents explicitly. + assertTrue( + fromPadded is ScannableKikCode.RemoteKikCode && + fromPadded.payloadId.contentEquals(payload), + "padded frame did not decode at ${width}x$height", + ) + assertEquals( + (fromPacked as ScannableKikCode.RemoteKikCode).colorIndex, + fromPadded.colorIndex, + "colour drift at ${width}x$height", + ) + } + } + + /** + * Measures the per-frame Y-plane conversion cost on-device: the fast path vs. the per-pixel copy + * the old `pixelStride != -1` guard forced on every frame. + */ + @Test + fun benchmarkPerFrameConversion() { + for ((width, height) in resolutions) { + val data = ByteArray(width * height) { (it % 251).toByte() } + + repeat(5) { + LuminancePlane.unpad(data, width, height, width, 1) + legacyUnpad(data, width, height, width, 1) + } + + val iterations = 30 + val legacyNanos = measureNanoTime { + repeat(iterations) { legacyUnpad(data, width, height, width, 1) } + } / iterations + + // The fast path returns in a few instructions, so 30 iterations sits at the + // System.nanoTime measurement floor -- run it enough times to actually resolve. + val fastIterations = 200_000 + val fastNanos = measureNanoTime { + repeat(fastIterations) { LuminancePlane.unpad(data, width, height, width, 1) } + }.toDouble() / fastIterations + + Log.i( + TAG, + "conversion ${width}x$height packed: fast=${fastNanos / 1000.0}us " + + "legacy=${legacyNanos / 1000.0}us " + + "saved=${legacyNanos / 1000.0 - fastNanos / 1000.0}us/frame", + ) + } + } + + /** + * The shared packing rule lives in `:libs:codes:kikcode` so iOS applies the same one, which puts + * a cross-module call on the hot path where there used to be a module-local function. + * + * Unoptimized, that hop is not free: in a debug build it costs a consistent ~2.4x a module-local + * call (2.5ns on an emulator, 7.4ns on an S25 Ultra). R8 all but erases it -- the same A/B on a + * minified release build measures 3.77ns shared vs 3.43ns local, a 0.34ns difference, because + * the function is a two-int comparison and a return and gets inlined. Users run the optimized + * build, so the honest figure for sharing the rule is ~0.3ns/frame. + * + * Either way this does NOT assert an absolute nanosecond budget: that would encode the speed of + * whatever hardware and build type it last ran on, and flip-flops between them. + * + * What actually matters is that the fast path stays orders of magnitude below the copy it + * replaces. A real regression -- someone making the packed case copy again -- moves it from + * nanoseconds to milliseconds, a ~350,000x jump, not a few nanoseconds. So the gate is measured + * against the legacy cost on the same device, and the A/B delta is logged as an observation. + * + * Both timings are taken over several alternating rounds keeping the best of each: a single + * round is dominated by whichever loop the JIT compiled first, which is enough to invent a + * double-digit-nanosecond "difference" that reverses if you swap the order. + */ + @Test + fun sharedFastPathStaysOrdersOfMagnitudeBelowTheCopyItReplaces() { + val (width, height) = resolutions.last() + val data = ByteArray(width * height) { (it % 251).toByte() } + val iterations = 200_000 + + repeat(50_000) { + LuminancePlane.unpad(data, width, height, width, 1) + localUnpad(data, width, height, width, 1) + } + + fun timeShared(): Double = measureNanoTime { + repeat(iterations) { LuminancePlane.unpad(data, width, height, width, 1) } + }.toDouble() / iterations + + fun timeLocal(): Double = measureNanoTime { + repeat(iterations) { localUnpad(data, width, height, width, 1) } + }.toDouble() / iterations + + var shared = Double.MAX_VALUE + var local = Double.MAX_VALUE + repeat(5) { round -> + // alternate which runs first so neither systematically pays for the other's warmup + if (round % 2 == 0) { + shared = minOf(shared, timeShared()) + local = minOf(local, timeLocal()) + } else { + local = minOf(local, timeLocal()) + shared = minOf(shared, timeShared()) + } + } + + // The copy the fast path exists to avoid, on this same device, as the yardstick. + val legacyIterations = 30 + val legacy = measureNanoTime { + repeat(legacyIterations) { legacyUnpad(data, width, height, width, 1) } + }.toDouble() / legacyIterations + + Log.i( + TAG, + "fast path ${width}x$height: shared=${shared}ns local=${local}ns " + + "delta=${shared - local}ns/frame legacy=${legacy / 1_000}us " + + "ratio=1:${(legacy / shared).toLong()}", + ) + + // A fast path that stopped being one shows up as a four-to-five-order-of-magnitude move, + // not a few nanoseconds. 1000x is far below the ~350,000x actually observed and far above + // any plausible cross-module dispatch cost, on any device. + assertTrue( + shared * 1_000 < legacy, + "shared fast path regressed: shared=${shared}ns is not <1/1000th of the " + + "${legacy / 1_000}us copy it replaces (local baseline=${local}ns)", + ) + } + + /** + * What sustained scanning costs the collector. + * + * The wall-clock benchmarks above measure one frame in isolation, which says nothing about the + * *shape* of the original complaint: scanning that is occasionally slow rather than uniformly + * slow. A steady per-frame tax reads as the latter. Blocking GC reads as the former. + * + * The pre-fix path allocated twice per frame at 1080p — once to read the plane out of the + * `ByteBuffer`, once more for the unpadding copy — roughly 4MB/frame, ~120MB/s at 30fps. The fix + * removes the second. A reusable frame buffer would remove the first as well, which is the only + * reason variant C is here: to size that remaining opportunity before anyone builds it. + * + * Reported as blocking GC count and time, since that is the part a user actually feels. + * + * Read the raw GC *counts* with care: the variants differ by two orders of magnitude in wall + * time, which gives the concurrent collector correspondingly more opportunity to run during the + * slow one. The quantity that compares cleanly across variants is allocations per frame — two, + * one, none. + */ + @Test + fun sustainedScanningGcCost() { + val (width, height) = resolutions.last() + val frames = 300 // ten seconds of scanning at 30fps + val bufferBytes = width * height // packed: the common case, and the one the fix targets + + fun gcStat(name: String): Long = Debug.getRuntimeStat(name)?.toLongOrNull() ?: -1L + + fun measure(label: String, frame: (Int) -> ByteArray) { + Runtime.getRuntime().gc() + Thread.sleep(SETTLE_MS) + val gcBefore = gcStat("art.gc.gc-count") + val blockingBefore = gcStat("art.gc.blocking-gc-count") + val blockingTimeBefore = gcStat("art.gc.blocking-gc-time") + + var sink = 0L + val elapsed = measureNanoTime { + repeat(frames) { i -> sink += frame(i)[0].toLong() } + } + + Log.i( + TAG, + "gc $label ${width}x$height over $frames frames: " + + "gc=${gcStat("art.gc.gc-count") - gcBefore} " + + "blockingGc=${gcStat("art.gc.blocking-gc-count") - blockingBefore} " + + "blockingGcTime=${gcStat("art.gc.blocking-gc-time") - blockingTimeBefore}ms " + + "wall=${elapsed / 1_000_000}ms sink=$sink", + ) + } + + // Pre-fix: a fresh plane read plus the unpadding copy, every frame. + measure("legacy") { + legacyUnpad(ByteArray(bufferBytes), width, height, width, 1) + } + + // Current: the plane read still allocates; the fast path adds nothing. + measure("fastPath") { + LuminancePlane.unpad(ByteArray(bufferBytes), width, height, width, 1) + } + + // Hypothetical: ImageAnalysis delivers frames serially, so one buffer could be reused. + val reusable = ByteArray(bufferBytes) + measure("reusedBuffer") { + LuminancePlane.unpad(reusable, width, height, width, 1) + } + } + + /** A module-local copy of the fast path, used only as the A/B baseline above. */ + private fun localUnpad( + data: ByteArray, + width: Int, + height: Int, + rowStride: Int, + pixelStride: Int, + ): ByteArray { + if (rowStride == width && pixelStride == 1) return data + val cleanData = ByteArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + cleanData[y * width + x] = data[y * rowStride + x * pixelStride] + } + } + return cleanData + } + + /** The pre-fix guard, reproduced so the benchmark compares like for like. */ + private fun legacyUnpad( + data: ByteArray, + width: Int, + height: Int, + rowStride: Int, + pixelStride: Int, + ): ByteArray { + if (width != rowStride || pixelStride != -1) { + val cleanData = ByteArray(width * height) + for (y in 0 until height) { + for (x in 0 until width) { + cleanData[y * width + x] = data[y * rowStride + x * pixelStride] + } + } + return cleanData + } + return data + } + + private companion object { + const val TAG = "KikCodeScanSweep" + const val REMOTE_PAYLOAD_BYTES = 20 + const val SETTLE_MS = 200L + } +} diff --git a/vendor/kik/scanner/src/main/kotlin/com/getcode/util/ImageProxy.kt b/vendor/kik/scanner/src/main/kotlin/com/getcode/util/ImageProxy.kt index e14334f934..d6eb9edd8b 100644 --- a/vendor/kik/scanner/src/main/kotlin/com/getcode/util/ImageProxy.kt +++ b/vendor/kik/scanner/src/main/kotlin/com/getcode/util/ImageProxy.kt @@ -1,13 +1,21 @@ package com.getcode.util import androidx.camera.core.ImageProxy +import com.getcode.codes.kikcode.LuminancePlane fun ImageProxy.toByteArray(): ByteArray { - // Remove padding from Y plane data before passing it to ZXing + // Remove padding from Y plane data before passing it to the scanner // @see https://github.com/beemdevelopment/Aegis/commit/fb58c877d1b305b1c66db497880da5651dda78d7 - return getLuminancePlaneData() + return getLuminancePlaneData() } +/** + * Reads the Y plane of an analyzed frame into the tightly packed `width * height` buffer the native + * scanner expects. + * + * The packing rule lives in [LuminancePlane] because iOS has to apply the same one — see that file + * for why the shared piece is the decision rather than the bytes. + */ private fun ImageProxy.getLuminancePlaneData(): ByteArray { val plane = planes[0] val buffer = plane.buffer @@ -15,22 +23,11 @@ private fun ImageProxy.getLuminancePlaneData(): ByteArray { buffer.get(data) buffer.rewind() - val width = width - val height = height - val rowStride = plane.rowStride - val pixelStride = plane.pixelStride - - if (width != rowStride || pixelStride != -1) { - // remove padding from the Y plane data - val cleanData = ByteArray(width * height) - for (y in 0 until height) { - for (x in 0 until width) { - cleanData[y * width + x] = data[y * rowStride + x * pixelStride] - } - } - - return cleanData - } - - return data -} \ No newline at end of file + return LuminancePlane.unpad( + data = data, + width = width, + height = height, + rowStride = plane.rowStride, + pixelStride = plane.pixelStride, + ) +}