diff --git a/e2e/android/app/build.gradle.kts b/e2e/android/app/build.gradle.kts index 2dc9334d79..0aba5dfa8f 100644 --- a/e2e/android/app/build.gradle.kts +++ b/e2e/android/app/build.gradle.kts @@ -120,9 +120,6 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation("androidx.compose.runtime:runtime") - // Serialization runtime (used by benchmark to encode replay events) - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") - // Compose UI dependencies -- only for the compose flavor "composeImplementation"(libs.androidx.activity.compose) "composeImplementation"(libs.androidx.ui) diff --git a/e2e/android/app/src/androidTest/java/com/example/androidobservability/TileHashParityInstrumentedTest.kt b/e2e/android/app/src/androidTest/java/com/example/androidobservability/TileHashParityInstrumentedTest.kt deleted file mode 100644 index 3f6789fbed..0000000000 --- a/e2e/android/app/src/androidTest/java/com/example/androidobservability/TileHashParityInstrumentedTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.example.androidobservability - -import android.graphics.Bitmap -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.launchdarkly.observability.replay.capture.TileSignatureManager -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assume.assumeTrue -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class TileHashParityInstrumentedTest { - - @Test - fun nativeSignaturesParity() { - val width = 191 - val height = 67 - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val pixels = IntArray(width * height) { i -> - val value = (i * 1103515245 + 12345) and 0x00FFFFFF - (0xFF shl 24) or value - } - bitmap.setPixels(pixels, 0, width, 0, 0, width, height) - - val nativePacked = nativeCompute(bitmap) - assumeTrue(nativePacked != null) - - val manager = TileSignatureManager() - val tileHeight = expectedDefaultTileHeight(height) - val nativeSig = manager.compute(bitmap) - val kotlinSig = manager.compute(bitmap, 64, tileHeight) - - assertNotNull(nativeSig) - assertNotNull(kotlinSig) - assertEquals(kotlinSig, nativeSig) - } - - private fun nativeCompute(bitmap: Bitmap): LongArray? { - return try { - val cls = Class.forName("com.launchdarkly.observability.replay.capture.TileHashNative") - val method = cls.getDeclaredMethod("nativeCompute", Bitmap::class.java) - runCatching { method.invoke(null, bitmap) as? LongArray } - .getOrElse { - val instance = cls.getDeclaredField("INSTANCE").get(null) - method.invoke(instance, bitmap) as? LongArray - } - } catch (_: Throwable) { - null - } - } - - private fun expectedDefaultTileHeight(height: Int): Int { - val preferred = 22 - val range = 22..44 - if (height <= 0) return preferred - if (height % preferred == 0) return preferred - - val maxDistance = maxOf( - kotlin.math.abs(range.first - preferred), - kotlin.math.abs(range.last - preferred), - ) - for (offset in 1..maxDistance) { - val positive = preferred + offset - if (positive in range && height % positive == 0) return positive - val negative = preferred - offset - if (negative in range && height % negative == 0) return negative - } - return preferred - } -} diff --git a/e2e/android/app/src/compose/AndroidManifest.xml b/e2e/android/app/src/compose/AndroidManifest.xml index ae29b7108c..ccb816ecf4 100644 --- a/e2e/android/app/src/compose/AndroidManifest.xml +++ b/e2e/android/app/src/compose/AndroidManifest.xml @@ -15,12 +15,6 @@ - - - BenchmarkScreen( - framesDirectory = framesDirectory, - modifier = Modifier.padding(padding), - ) - } - } - } - } -} - -@Composable -private fun BenchmarkScreen(framesDirectory: File, modifier: Modifier = Modifier) { - val benchmarkRuns = 3 - val executor = remember { BenchmarkExecutor() } - var results by remember { mutableStateOf>(emptyList()) } - var isRunning by remember { mutableStateOf(false) } - var showResults by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - var signatureResult by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Button( - onClick = { - isRunning = true - scope.launch { - try { - val compressionResults = executor.compression( - framesDirectory, - runs = benchmarkRuns, - ) - val baseline = compressionResults.firstOrNull()?.bytes ?: 1 - results = compressionResults.map { result -> - val pct = result.bytes.toDouble() / baseline * 100 - BenchmarkResultRow( - name = result.compression.displayName, - bytes = result.bytes, - captureTimeNanos = result.captureTimeNanos, - totalTimeNanos = result.totalTimeNanos, - percent = "%.0f%%".format(pct), - ) - } - showResults = true - } catch (e: Exception) { - errorMessage = e.message ?: e.toString() - } - isRunning = false - } - }, - enabled = !isRunning, - ) { - if (isRunning) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - ) - } else { - Text("Mastodon iOS 200 sec walk") - } - } - - Button( - onClick = { - isRunning = true - signatureResult = null - scope.launch { - try { - val r = executor.signatureBenchmark(framesDirectory) - val elapsedSec = r.elapsedNanos / 1_000_000_000.0 - val mb = r.totalBytes / (1024.0 * 1024.0) - signatureResult = "%.3fs — %.1f MB (%d frames)".format(elapsedSec, mb, r.frameCount) - } catch (e: Exception) { - errorMessage = e.message ?: e.toString() - } - isRunning = false - } - }, - enabled = !isRunning, - ) { - Text("Compute ImageSignature") - } - - signatureResult?.let { result -> - Text( - result, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 8.dp), - ) - } - } - - if (showResults) { - BenchmarkResultsSheet( - results = results, - onDismiss = { showResults = false }, - ) - } - - errorMessage?.let { message -> - AlertDialog( - onDismissRequest = { errorMessage = null }, - title = { Text("Benchmark Failed") }, - text = { Text(message) }, - confirmButton = { - TextButton(onClick = { errorMessage = null }) { - Text("OK") - } - }, - ) - } -} - -private data class BenchmarkResultRow( - val name: String, - val bytes: Int, - val captureTimeNanos: Long, - val totalTimeNanos: Long, - val percent: String, -) { - val formattedBytes: String - get() { - val kb = bytes / 1024.0 - return if (kb >= 1024) "%.1f MB".format(kb / 1024) - else "%.1f KB".format(kb) - } - - val formattedCaptureTime: String - get() = "%.2fs".format(captureTimeNanos / 1_000_000_000.0) - - val formattedTotalTime: String - get() = "%.2fs".format(totalTimeNanos / 1_000_000_000.0) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun BenchmarkResultsSheet( - results: List, - onDismiss: () -> Unit, -) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 32.dp), - ) { - Text( - "Results", - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(bottom = 12.dp), - ) - results.forEachIndexed { index, row -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(row.name, modifier = Modifier.weight(1f)) - Text( - row.percent, - textAlign = TextAlign.End, - modifier = Modifier.width(48.dp), - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Light - ), - ) - Text( - buildAnnotatedString { - append(row.formattedCaptureTime) - append(" / ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append(row.formattedTotalTime) - } - }, - textAlign = TextAlign.End, - modifier = Modifier.width(120.dp), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - row.formattedBytes, - textAlign = TextAlign.End, - modifier = Modifier.width(72.dp), - style = MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.Light - ), - ) - } - if (index < results.lastIndex) { - HorizontalDivider() - } - } - } - } -} - -private val ReplayOptions.CompressionMethod.displayName: String - get() = when (this) { - is ReplayOptions.CompressionMethod.ScreenImage -> "Screen Image" - is ReplayOptions.CompressionMethod.OverlayTiles -> "layers: $layers backtracking: $backtracking" - } - -private fun copyAssetsIfNeeded(assets: AssetManager, assetPath: String, destDir: File) { - if (destDir.exists() && (destDir.list()?.size ?: 0) > 0) return - destDir.mkdirs() - for (name in assets.list(assetPath) ?: return) { - val destFile = File(destDir, name) - assets.open("$assetPath/$name").use { input -> - destFile.outputStream().use { output -> input.copyTo(output) } - } - } -} diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt deleted file mode 100644 index 26948a1b8b..0000000000 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/BenchmarkExecutor.kt +++ /dev/null @@ -1,136 +0,0 @@ -package com.example.androidobservability.benchmark - -import android.graphics.Bitmap -import com.launchdarkly.observability.replay.Event -import com.launchdarkly.observability.replay.ReplayOptions -import com.launchdarkly.observability.replay.capture.ExportDiffManager -import com.launchdarkly.observability.replay.capture.ImageCaptureService -import com.launchdarkly.observability.replay.capture.TileSignatureManager -import com.launchdarkly.observability.replay.exporter.RRWebEventGenerator -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.json.Json -import java.io.File - -class BenchmarkExecutor { - data class CompressionResult( - val compression: ReplayOptions.CompressionMethod, - val bytes: Int, - val captureTimeNanos: Long, - val totalTimeNanos: Long, - ) - - private val compressionMethods = listOf( - ReplayOptions.CompressionMethod.ScreenImage, - ReplayOptions.CompressionMethod.OverlayTiles(layers = 15, backtracking = false), - ReplayOptions.CompressionMethod.OverlayTiles(layers = 15, backtracking = true), - ) - - suspend fun compression(framesDirectory: File, runs: Int = 1): List = - withContext(Dispatchers.Default) { - val frames = RawFrameReader(framesDirectory).toList() - - val results = mutableListOf() - val runCount = maxOf(1, runs) - - for (method in compressionMethods) { - var bytes = 0 - var captureTimeNanos = 0L - var totalTimeNanos = 0L - - for (i in 0 until runCount) { - val runResult = runCompression(method, frames) - bytes += runResult.bytes - captureTimeNanos += runResult.captureTimeNanos - totalTimeNanos += runResult.totalTimeNanos - } - - results.add(CompressionResult( - method, - bytes / runCount, - captureTimeNanos / runCount, - totalTimeNanos / runCount, - )) - } - - frames.forEach { if (!it.bitmap.isRecycled) it.bitmap.recycle() } - results - } - - data class SignatureResult( - val elapsedNanos: Long, - val totalBytes: Long, - val frameCount: Int, - ) - - suspend fun signatureBenchmark(framesDirectory: File): SignatureResult = - withContext(Dispatchers.Default) { - val frames = RawFrameReader(framesDirectory).toList() - val manager = TileSignatureManager() - var totalBytes = 0L - - for (frame in frames) { - totalBytes += frame.bitmap.byteCount.toLong() - } - - val start = System.nanoTime() - for (frame in frames) { - manager.compute(frame.bitmap) - } - val elapsed = System.nanoTime() - start - - SignatureResult(elapsedNanos = elapsed, totalBytes = totalBytes, frameCount = frames.size) - } - - private fun runCompression( - method: ReplayOptions.CompressionMethod, - sourceFrames: List, - ): CompressionResult { - val copies = sourceFrames.map { frame -> - ImageCaptureService.RawFrame( - bitmap = frame.bitmap.copy(frame.bitmap.config ?: Bitmap.Config.ARGB_8888, true), - timestamp = frame.timestamp, - orientation = frame.orientation, - ) - } - - val exportDiffManager = ExportDiffManager(compression = method, scale = 1f) - val eventGenerator = RRWebEventGenerator(canvasDrawEntourage = 300, title = "benchmark") - val json = Json - var bytes = 0 - var isFirst = true - var captureTimeNanos = 0L - - val start = System.nanoTime() - - for (frame in copies) { - val captureStart = System.nanoTime() - val exportFrame = exportDiffManager.createCaptureEvent(frame, "benchmark") - captureTimeNanos += System.nanoTime() - captureStart - - if (exportFrame == null) continue - - val events = if (isFirst) { - isFirst = false - eventGenerator.generateCaptureFullEvents(exportFrame) - } else { - eventGenerator.generateCaptureIncrementalEvents(exportFrame) - } - - try { - val data = json.encodeToString(ListSerializer(Event.serializer()), events) - bytes += data.toByteArray(Charsets.UTF_8).size - } catch (_: Exception) { - } - } - - val totalElapsed = System.nanoTime() - start - return CompressionResult( - compression = method, - bytes = bytes, - captureTimeNanos = captureTimeNanos, - totalTimeNanos = totalElapsed, - ) - } -} diff --git a/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/RawFrameIO.kt b/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/RawFrameIO.kt deleted file mode 100644 index 0f1a1ad633..0000000000 --- a/e2e/android/app/src/compose/java/com/example/androidobservability/benchmark/RawFrameIO.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.example.androidobservability.benchmark - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import com.launchdarkly.observability.replay.capture.ImageCaptureService -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.FileOutputStream -import java.io.PrintWriter -import java.util.UUID - -class RawFrameWriter(baseDir: File) { - val directory: File = File(baseDir, "RawFrames-${UUID.randomUUID()}") - private var frameIndex = 0 - private var imageIndex = 0 - private var lastImageBytes: ByteArray? = null - private val csvWriter: PrintWriter - - init { - directory.mkdirs() - val csvFile = File(directory, "frames.csv") - csvWriter = PrintWriter(FileOutputStream(csvFile), true) - csvWriter.println("frameIndex,imageIndex,timestamp,orientation") - } - - fun write(rawFrame: ImageCaptureService.RawFrame) { - val index = frameIndex++ - val pngBytes = rawFrame.bitmap.toPngBytes() - ?: error("Failed to encode bitmap to PNG") - - val currentImageIndex: Int - if (pngBytes.contentEquals(lastImageBytes)) { - currentImageIndex = imageIndex - 1 - } else { - currentImageIndex = imageIndex - File(directory, "%06d.png".format(currentImageIndex)).writeBytes(pngBytes) - lastImageBytes = pngBytes - imageIndex++ - } - - csvWriter.println("$index,$currentImageIndex,${rawFrame.timestamp},${rawFrame.orientation}") - } - - fun close() { - csvWriter.close() - } -} - -private fun Bitmap.toPngBytes(): ByteArray? { - val stream = ByteArrayOutputStream() - return if (compress(Bitmap.CompressFormat.PNG, 100, stream)) { - stream.toByteArray() - } else { - null - } -} - -// MARK: - RawFrameReader - -class RawFrameReader(private val directory: File) : Sequence { - private val rows: List - - init { - val csvFile = File(directory, "frames.csv") - rows = csvFile.readText().lines().drop(1).filter { it.isNotBlank() } - } - - override fun iterator(): Iterator = FrameIterator(directory, rows) - - private class FrameIterator( - private val directory: File, - private val rows: List, - ) : Iterator { - private var index = 0 - private val imageCache = mutableMapOf() - - override fun hasNext(): Boolean = index < rows.size - - override fun next(): ImageCaptureService.RawFrame { - val frame = parse(rows[index]) - ?: throw NoSuchElementException("Failed to parse frame at index $index") - index++ - return frame - } - - private fun parse(line: String): ImageCaptureService.RawFrame? { - val columns = line.split(",") - if (columns.size < 4) return null - val imageIndex = columns[1].trim().toIntOrNull() ?: return null - val timestamp = columns[2].trim().toDoubleOrNull()?.let { (it * 1000).toLong() } ?: return null - val orientation = columns[3].trim().toIntOrNull() ?: return null - - val bitmap = imageCache.getOrPut(imageIndex) { - val imageFile = File(directory, "%06d.png".format(imageIndex)) - BitmapFactory.decodeFile(imageFile.absolutePath) ?: return null - } - - return ImageCaptureService.RawFrame( - bitmap = bitmap, - timestamp = timestamp, - orientation = orientation, - ) - } - } -} diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/arm64-v8a/libsession_replay_c.so b/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/arm64-v8a/libsession_replay_c.so index f7df94a7fa..1771147901 100755 Binary files a/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/arm64-v8a/libsession_replay_c.so and b/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/arm64-v8a/libsession_replay_c.so differ diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/armeabi-v7a/libsession_replay_c.so b/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/armeabi-v7a/libsession_replay_c.so index ce217a5593..c443427ddb 100755 Binary files a/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/armeabi-v7a/libsession_replay_c.so and b/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/armeabi-v7a/libsession_replay_c.so differ diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/x86/libsession_replay_c.so b/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/x86/libsession_replay_c.so index 9a6ac72097..820fad5c76 100755 Binary files a/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/x86/libsession_replay_c.so and b/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/x86/libsession_replay_c.so differ diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/x86_64/libsession_replay_c.so b/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/x86_64/libsession_replay_c.so index 5bb50b1316..7366cbedcc 100755 Binary files a/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/x86_64/libsession_replay_c.so and b/sdk/@launchdarkly/observability-android/lib/src/main/jniLibs/x86_64/libsession_replay_c.so differ diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/TileDiffManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/TileDiffManager.kt index 68838e8a07..bb2d1c183d 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/TileDiffManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/TileDiffManager.kt @@ -31,20 +31,20 @@ class TileDiffManager( } previousSignature = imageSignature - val needWholeScreen = - diffRect.width >= frameWidth && diffRect.height >= frameHeight - val isKeyframe = when (val method = compression) { is ReplayOptions.CompressionMethod.OverlayTiles -> { - if (method.layers > 0) { + if (method.layers <= 0) { + true + } else { incrementalSnapshots = (incrementalSnapshots + 1) % method.layers - val keyframe = needWholeScreen || incrementalSnapshots == 0 - if (needWholeScreen) { - incrementalSnapshots = 0 + if (incrementalSnapshots == 0) { + true + } else { + val needWholeScreen = + diffRect.width >= frameWidth && diffRect.height >= frameHeight + if (needWholeScreen) incrementalSnapshots = 0 + needWholeScreen } - keyframe - } else { - true } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/TileSignatureManager.kt b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/TileSignatureManager.kt index 975bd4736d..7fbf46d6c4 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/TileSignatureManager.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/TileSignatureManager.kt @@ -3,258 +3,16 @@ package com.launchdarkly.observability.replay.capture import android.graphics.Bitmap /** - * Computes tile-based signatures for bitmaps. - * - * This class is intentionally not thread-safe in order to reuse a single internal - * pixel buffer allocation and minimize memory churn and GC pressure. Do not invoke - * methods on the same instance from multiple threads concurrently. If cross-thread - * use is required, create one instance per thread or guard access with external - * synchronization. + * Computes tile-based signatures for bitmaps using the native C implementation via JNI + * (NEON-accelerated on ARM). Requires the `session_replay_c` shared library. */ class TileSignatureManager { - companion object { - private const val TILE_W = 64 - private const val DEFAULT_PREFERRED_TILE_HEIGHT = 22 - - private const val SEED_H0 = 0x517cc1b727220a95L - private const val SEED_H1 = 0x6c62272e07bb0142L - private const val SEED_H2 = -0x61c8864680b583ebL // 0x9e3779b97f4a7c15 - private const val SEED_H3 = -0x40a7b892e31b1a47L // 0xbf58476d1ce4e5b9 - private const val MIX_C1 = -0x00ae502812aa7333L // 0xff51afd7ed558ccd - private const val MIX_C2 = -0x3b314601e57a13adL // 0xc4ceb9fe1a85ec53 - - } - - @Volatile - private var pixelBuffer: IntArray = IntArray(0) - - /** - * Computes a tile-based signature with fixed 64-pixel tile width and - * a height adjusted to a nearby divisor of the image height. - * Uses native C implementation via JNI when available (NEON-accelerated on ARM), - * falls back to Kotlin otherwise. - */ fun compute(bitmap: Bitmap): ImageSignature? { val width = bitmap.width val height = bitmap.height if (width <= 0 || height <= 0) return null - if (TileHashNative.isAvailable) { - TileHashNative.compute(bitmap)?.let { return it } - } - - val tileHeight = nearestDivisor(height, DEFAULT_PREFERRED_TILE_HEIGHT, 22..44) - return computeFixed64(bitmap, tileHeight) - } - - /** - * Computes a tile-based signature with explicitly provided tile dimensions. - * No nearest-divisor adjustment is applied. - */ - fun compute(bitmap: Bitmap, tileWidth: Int, tileHeight: Int): ImageSignature? { - if (tileWidth <= 0 || tileHeight <= 0) return null - return computeGeneric(bitmap, tileWidth, tileHeight) - } - - /** - * Convenience overload for square tiles. No nearest-divisor adjustment is applied. - */ - fun compute(bitmap: Bitmap, tileSize: Int): ImageSignature? = compute(bitmap, tileSize, tileSize) - - private fun loadPixels(bitmap: Bitmap): IntArray { - val w = bitmap.width - val h = bitmap.height - val needed = w * h - if (pixelBuffer.size < needed) { - pixelBuffer = IntArray(needed) - } - val buf = pixelBuffer - bitmap.getPixels(buf, 0, w, 0, 0, w, h) - return buf - } - - private fun computeFixed64(bitmap: Bitmap, tileHeight: Int): ImageSignature? { - val width = bitmap.width - val height = bitmap.height - if (width <= 0 || height <= 0) return null - val pixels = loadPixels(bitmap) - - val columns = (width + TILE_W - 1) / TILE_W - val rows = (height + tileHeight - 1) / tileHeight - val fullCols = width / TILE_W - val tileSignatures = ArrayList(columns * rows) - - var tileAccHash = 0 - for (row in 0 until rows) { - val startY = row * tileHeight - val tileRows = minOf(tileHeight, height - startY) - - for (col in 0 until fullCols) { - val sig = tileHashW64(pixels, width, col * TILE_W, startY, tileRows) - tileSignatures.add(sig) - tileAccHash = ImageSignature.accumulateTile(tileAccHash, sig) - } - - if (fullCols < columns) { - val startX = fullCols * TILE_W - val sig = tileHashGeneric(pixels, width, startX, startY, width, startY + tileRows) - tileSignatures.add(sig) - tileAccHash = ImageSignature.accumulateTile(tileAccHash, sig) - } - } - - return ImageSignature.createWithAccHash( - rows = rows, columns = columns, - tileWidth = TILE_W, tileHeight = tileHeight, - tileSignatures = tileSignatures, tileAccHash = tileAccHash, - ) - } - - private fun computeGeneric(bitmap: Bitmap, tileWidth: Int, tileHeight: Int): ImageSignature? { - val width = bitmap.width - val height = bitmap.height - if (width <= 0 || height <= 0) return null - val pixels = loadPixels(bitmap) - - val columns = (width + tileWidth - 1) / tileWidth - val rows = (height + tileHeight - 1) / tileHeight - val tileSignatures = ArrayList(columns * rows) - - var tileAccHash = 0 - for (row in 0 until rows) { - val startY = row * tileHeight - val endY = minOf(startY + tileHeight, height) - for (col in 0 until columns) { - val startX = col * tileWidth - val endX = minOf(startX + tileWidth, width) - val sig = tileHashGeneric(pixels, width, startX, startY, endX, endY) - tileSignatures.add(sig) - tileAccHash = ImageSignature.accumulateTile(tileAccHash, sig) - } - } - - return ImageSignature.createWithAccHash( - rows = rows, columns = columns, - tileWidth = tileWidth, tileHeight = tileHeight, - tileSignatures = tileSignatures, tileAccHash = tileAccHash, - ) - } - - /** - * Fast hash for full 64-pixel-wide tiles. The inner loop is fixed at 8 iterations - * (8 pixels each = 64 pixels) with 4 parallel accumulators for ILP. - */ - private fun tileHashW64( - pixels: IntArray, - imageWidth: Int, - startX: Int, - startY: Int, - tileRows: Int, - ): TileSignature { - var h0 = SEED_H0; var h1 = SEED_H1 - var h2 = SEED_H2; var h3 = SEED_H3 - - for (y in 0 until tileRows) { - var idx = (startY + y) * imageWidth + startX - for (i in 0 until 8) { - h0 += packNativePair(pixels[idx], pixels[idx + 1]) - h1 += packNativePair(pixels[idx + 2], pixels[idx + 3]) - h2 += packNativePair(pixels[idx + 4], pixels[idx + 5]) - h3 += packNativePair(pixels[idx + 6], pixels[idx + 7]) - idx += 8 - } - h0 = h0 xor h2; h1 = h1 xor h3 - h2 += h0; h3 += h1 - } - - h0 = h0 xor h2; h1 = h1 xor h3 - h0 = h0 xor (h0 ushr 33); h0 *= MIX_C1; h0 = h0 xor (h0 ushr 33) - h1 = h1 xor (h1 ushr 29); h1 *= MIX_C2; h1 = h1 xor (h1 ushr 29) - return TileSignature(hashLo = h0, hashHi = h1) - } - - /** - * Generic hash for tiles of any width. Uses the same 4-accumulator scheme - * with 8-pixel processing groups and remainder handling. - */ - private fun tileHashGeneric( - pixels: IntArray, - imageWidth: Int, - startX: Int, - startY: Int, - endX: Int, - endY: Int, - ): TileSignature { - val pixelWidth = endX - startX - val quads = pixelWidth ushr 3 - val remPixels = pixelWidth and 7 - val remPairs = remPixels ushr 1 - val hasTail = remPixels and 1 != 0 - - var h0 = SEED_H0; var h1 = SEED_H1 - var h2 = SEED_H2; var h3 = SEED_H3 - - for (y in startY until endY) { - var idx = y * imageWidth + startX - - for (q in 0 until quads) { - h0 += packNativePair(pixels[idx], pixels[idx + 1]) - h1 += packNativePair(pixels[idx + 2], pixels[idx + 3]) - h2 += packNativePair(pixels[idx + 4], pixels[idx + 5]) - h3 += packNativePair(pixels[idx + 6], pixels[idx + 7]) - idx += 8 - } - - if (remPairs >= 1) h0 += packNativePair(pixels[idx], pixels[idx + 1]) - if (remPairs >= 2) h1 += packNativePair(pixels[idx + 2], pixels[idx + 3]) - if (remPairs >= 3) h2 += packNativePair(pixels[idx + 4], pixels[idx + 5]) - if (hasTail) h3 += toNativeWord(pixels[idx + remPairs * 2]) - - h0 = h0 xor h2; h1 = h1 xor h3 - h2 += h0; h3 += h1 - } - - h0 = h0 xor h2; h1 = h1 xor h3 - h0 = h0 xor (h0 ushr 33); h0 *= MIX_C1; h0 = h0 xor (h0 ushr 33) - h1 = h1 xor (h1 ushr 29); h1 *= MIX_C2; h1 = h1 xor (h1 ushr 29) - return TileSignature(hashLo = h0, hashHi = h1) - } - - /** - * Android's getPixels returns ARGB words (0xAARRGGBB), while native hashing reads - * RGBA_8888 bytes as little-endian 32-bit words (0xAABBGGRR). Normalize to native - * word layout so Kotlin and JNI paths produce identical hashes. - */ - private fun toNativeWord(argb: Int): Long { - val native = (argb and 0xFF00FF00.toInt()) or - ((argb and 0x00FF0000) ushr 16) or - ((argb and 0x000000FF) shl 16) - return native.toLong() and 0xFFFFFFFFL - } - - private fun packNativePair(firstArgb: Int, secondArgb: Int): Long = - toNativeWord(firstArgb) or (toNativeWord(secondArgb) shl 32) - - private fun nearestDivisor(value: Int, preferred: Int, range: IntRange): Int { - if (value <= 0) return preferred - - fun isDivisor(candidate: Int): Boolean = candidate > 0 && value % candidate == 0 - - if (preferred in range && isDivisor(preferred)) return preferred - - val maxDistance = maxOf( - kotlin.math.abs(range.first - preferred), - kotlin.math.abs(range.last - preferred) - ) - - for (offset in 1..maxDistance) { - val positive = preferred + offset - if (positive in range && isDivisor(positive)) return positive - - val negative = preferred - offset - if (negative in range && isDivisor(negative)) return negative - } - - return preferred + if (!TileHashNative.isAvailable) return null + return TileHashNative.compute(bitmap) } } diff --git a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/capture/TiledSignatureManagerTest.kt b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/capture/TiledSignatureManagerTest.kt index 9a3a16ab34..4a76372206 100644 --- a/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/capture/TiledSignatureManagerTest.kt +++ b/sdk/@launchdarkly/observability-android/lib/src/test/kotlin/com/launchdarkly/observability/replay/capture/TiledSignatureManagerTest.kt @@ -1,8 +1,5 @@ package com.launchdarkly.observability.replay.capture -import com.launchdarkly.observability.testutil.mockBitmap -import com.launchdarkly.observability.testutil.withOverlayRect -import java.util.Arrays import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertNotNull @@ -11,294 +8,219 @@ import org.junit.jupiter.api.Test class TileSignatureManagerTest { - private val RED = 0xFFFF0000.toInt() - private val BLUE = 0xFF0000FF.toInt() - private val WHITE = 0xFFFFFFFF.toInt() + private fun sig(hashLo: Long, hashHi: Long) = TileSignature(hashLo, hashHi) - private val SEED_H0 = 0x517cc1b727220a95L - private val SEED_H1 = 0x6c62272e07bb0142L - private val SEED_H2 = -0x61c8864680b583ebL - private val SEED_H3 = -0x40a7b892e31b1a47L - private val MIX_C1 = -0x00ae502812aa7333L - private val MIX_C2 = -0x3b314601e57a13adL + private fun imageSignature( + rows: Int, + columns: Int, + tileWidth: Int, + tileHeight: Int, + tiles: List, + ) = ImageSignature(rows, columns, tileWidth, tileHeight, tiles) - private fun solidPixels(width: Int, height: Int, color: Int): IntArray { - val pixels = IntArray(width * height) - Arrays.fill(pixels, color) - return pixels + @Test + fun `identical signatures are equal`() { + val tiles = listOf(sig(1L, 2L), sig(3L, 4L)) + val a = imageSignature(1, 2, 64, 22, tiles) + val b = imageSignature(1, 2, 64, 22, tiles) + assertEquals(a, b) } - private fun nativeWordFromArgb(argb: Int): Long { - val nativeWord = (argb and 0xFF00FF00.toInt()) or - ((argb and 0x00FF0000) ushr 16) or - ((argb and 0x000000FF) shl 16) - return nativeWord.toLong() and 0xFFFFFFFFL + @Test + fun `signatures with different tile hashes are not equal`() { + val a = imageSignature(1, 2, 64, 22, listOf(sig(1L, 2L), sig(3L, 4L))) + val b = imageSignature(1, 2, 64, 22, listOf(sig(1L, 2L), sig(5L, 6L))) + assertNotEquals(a, b) } - private fun packPair(firstArgb: Int, secondArgb: Int, normalizeForNativeLayout: Boolean): Long { - val first = if (normalizeForNativeLayout) nativeWordFromArgb(firstArgb) else firstArgb.toLong() and 0xFFFFFFFFL - val second = if (normalizeForNativeLayout) nativeWordFromArgb(secondArgb) else secondArgb.toLong() and 0xFFFFFFFFL - return first or (second shl 32) + @Test + fun `signatures with different dimensions are not equal`() { + val tiles = listOf(sig(1L, 2L)) + val a = imageSignature(1, 1, 64, 22, tiles) + val b = imageSignature(1, 1, 64, 44, tiles) + assertNotEquals(a, b) } - private fun hashGenericTile( - pixels: IntArray, - imageWidth: Int, - startX: Int, - startY: Int, - endX: Int, - endY: Int, - normalizeForNativeLayout: Boolean, - ): TileSignature { - val pixelWidth = endX - startX - val quads = pixelWidth ushr 3 - val remPixels = pixelWidth and 7 - val remPairs = remPixels ushr 1 - val hasTail = remPixels and 1 != 0 + @Test + fun `signatures with different grid layout are not equal`() { + val tiles = listOf(sig(1L, 2L), sig(3L, 4L)) + val a = imageSignature(1, 2, 64, 22, tiles) + val b = imageSignature(2, 1, 64, 22, tiles) + assertNotEquals(a, b) + } - var h0 = SEED_H0; var h1 = SEED_H1 - var h2 = SEED_H2; var h3 = SEED_H3 + @Test + fun `small overlay changes only affected tiles`() { + // 3x3 grid, all tiles identical except bottom-right + val baseTiles = List(9) { sig(42L, 99L) } + val overlayTiles = baseTiles.toMutableList().apply { + this[8] = sig(100L, 200L) + } - for (y in startY until endY) { - var idx = y * imageWidth + startX + val sigBase = imageSignature(3, 3, 4, 4, baseTiles) + val sigOverlay = imageSignature(3, 3, 4, 4, overlayTiles) - for (q in 0 until quads) { - h0 += packPair(pixels[idx], pixels[idx + 1], normalizeForNativeLayout) - h1 += packPair(pixels[idx + 2], pixels[idx + 3], normalizeForNativeLayout) - h2 += packPair(pixels[idx + 4], pixels[idx + 5], normalizeForNativeLayout) - h3 += packPair(pixels[idx + 6], pixels[idx + 7], normalizeForNativeLayout) - idx += 8 - } + assertEquals(9, sigBase.tileSignatures.size) + assertEquals(9, sigOverlay.tileSignatures.size) - if (remPairs >= 1) h0 += packPair(pixels[idx], pixels[idx + 1], normalizeForNativeLayout) - if (remPairs >= 2) h1 += packPair(pixels[idx + 2], pixels[idx + 3], normalizeForNativeLayout) - if (remPairs >= 3) h2 += packPair(pixels[idx + 4], pixels[idx + 5], normalizeForNativeLayout) - if (hasTail) { - h3 += if (normalizeForNativeLayout) { - nativeWordFromArgb(pixels[idx + remPairs * 2]) - } else { - pixels[idx + remPairs * 2].toLong() and 0xFFFFFFFFL - } + var diffCount = 0 + for (i in sigBase.tileSignatures.indices) { + if (sigBase.tileSignatures[i] != sigOverlay.tileSignatures[i]) { + diffCount++ } - - h0 = h0 xor h2; h1 = h1 xor h3 - h2 += h0; h3 += h1 } + assertEquals(1, diffCount) - h0 = h0 xor h2; h1 = h1 xor h3 - h0 = h0 xor (h0 ushr 33); h0 *= MIX_C1; h0 = h0 xor (h0 ushr 33) - h1 = h1 xor (h1 ushr 29); h1 *= MIX_C2; h1 = h1 xor (h1 ushr 29) - return TileSignature(hashLo = h0, hashHi = h1) + // diffRectangle should only cover the bottom-right tile + val diff = sigOverlay.diffRectangle(sigBase) + assertNotNull(diff) + assertEquals(IntRect(8, 8, 4, 4), diff) } - private fun expectedDefaultTileHeight(height: Int): Int { - val preferred = 22 - val range = 22..44 - if (height <= 0) return preferred - if (preferred in range && height % preferred == 0) return preferred - val maxDistance = maxOf(kotlin.math.abs(range.first - preferred), kotlin.math.abs(range.last - preferred)) - for (offset in 1..maxDistance) { - val positive = preferred + offset - if (positive in range && positive > 0 && height % positive == 0) return positive - val negative = preferred - offset - if (negative in range && negative > 0 && height % negative == 0) return negative + @Test + fun `overlay in the middle changes only affected tiles`() { + // 3x3 grid, middle tile changed + val baseTiles = List(9) { sig(it.toLong(), 0L) } + val overlayTiles = baseTiles.toMutableList().apply { + this[4] = sig(999L, 999L) // center tile (row=1, col=1) } - return preferred - } - @Test - fun `compute returns null when tile size is non positive`() { - val manager = TileSignatureManager() - val bitmap = mockBitmap(2, 2, RED) + val sigBase = imageSignature(3, 3, 10, 10, baseTiles) + val sigOverlay = imageSignature(3, 3, 10, 10, overlayTiles) - assertNull(manager.compute(bitmap, 0)) - assertNull(manager.compute(bitmap, -8)) + var diffCount = 0 + for (i in sigBase.tileSignatures.indices) { + if (sigBase.tileSignatures[i] != sigOverlay.tileSignatures[i]) { + diffCount++ + } + } + assertEquals(1, diffCount) + + val diff = sigOverlay.diffRectangle(sigBase) + assertNotNull(diff) + assertEquals(IntRect(10, 10, 10, 10), diff) } @Test - fun `compute returns signature when inputs are valid`() { - val manager = TileSignatureManager() - val bitmap = mockBitmap(4, 4, BLUE) - - val signature = manager.compute(bitmap, 2) - assertNotNull(signature) - // 4x4 with tileSize 2 => 2x2 = 4 tiles - assertEquals(4, signature!!.tileSignatures.size) + fun `diffRectangle returns full rect when comparing against null`() { + val sig = imageSignature(2, 3, 64, 22, List(6) { sig(it.toLong(), 0L) }) + val diff = sig.diffRectangle(null) + assertEquals(IntRect(0, 0, 3 * 64, 2 * 22), diff) } @Test - fun `signatures are equal for identical content`() { - val manager = TileSignatureManager() - val a = mockBitmap(8, 8, BLUE) - val b = mockBitmap(8, 8, BLUE) - - val sigA = manager.compute(a, 4) - val sigB = manager.compute(b, 4) - - assertNotNull(sigA) - assertNotNull(sigB) - assertEquals(sigA, sigB) + fun `diffRectangle returns null for identical signatures`() { + val tiles = listOf(sig(1L, 2L), sig(3L, 4L)) + val a = imageSignature(1, 2, 64, 22, tiles) + val b = imageSignature(1, 2, 64, 22, tiles) + assertNull(a.diffRectangle(b)) } @Test - fun `signatures differ for different content`() { - val manager = TileSignatureManager() - val a = mockBitmap(8, 8, RED) - val b = mockBitmap(8, 8, WHITE) - - val sigA = manager.compute(a, 4) - val sigB = manager.compute(b, 4) - - assertNotNull(sigA) - assertNotNull(sigB) - assertNotEquals(sigA, sigB) + fun `diffRectangle returns full rect when dimensions differ`() { + val a = imageSignature(2, 3, 64, 22, List(6) { sig(it.toLong(), 0L) }) + val b = imageSignature(3, 2, 64, 22, List(6) { sig(it.toLong(), 0L) }) + val diff = a.diffRectangle(b) + assertEquals(IntRect(0, 0, 3 * 64, 2 * 22), diff) } @Test - fun `tile count matches expected ceil division`() { - val manager = TileSignatureManager() - val bmp = mockBitmap(10, 10, RED) - - // tileSize 4 => ceil(10/4)=3 in each dimension => 9 tiles - val sig4 = manager.compute(bmp, 4) - assertNotNull(sig4) - assertEquals(9, sig4!!.tileSignatures.size) + fun `diffRectangle returns full rect when tile sizes differ`() { + val tiles = List(4) { sig(it.toLong(), 0L) } + val a = imageSignature(2, 2, 64, 22, tiles) + val b = imageSignature(2, 2, 32, 22, tiles) + val diff = a.diffRectangle(b) + assertEquals(IntRect(0, 0, 2 * 64, 2 * 22), diff) + } - // tileSize 6 => ceil(10/6)=2 in each dimension => 4 tiles - val sig6 = manager.compute(bmp, 6) - assertNotNull(sig6) - assertEquals(4, sig6!!.tileSignatures.size) + @Test + fun `diffRectangle detects single changed tile`() { + val tilesA = listOf(sig(1L, 0L), sig(2L, 0L), sig(3L, 0L), sig(4L, 0L)) + val tilesB = listOf(sig(1L, 0L), sig(2L, 0L), sig(3L, 0L), sig(99L, 0L)) + val a = imageSignature(2, 2, 64, 22, tilesA) + val b = imageSignature(2, 2, 64, 22, tilesB) + val diff = a.diffRectangle(b) + assertEquals(IntRect(64, 22, 64, 22), diff) } @Test - fun `small overlay changes only affected tiles hashes`() { - val manager = TileSignatureManager() - val width = 12 - val height = 12 - val basePixels = solidPixels(width, height, WHITE) - val overlayPixels = withOverlayRect( - basePixels = basePixels, - imageWidth = width, - imageHeight = height, - color = RED, - left = 8, // touches only the last column of tiles for tileSize=4 - top = 8, // touches only the last row of tiles for tileSize=4 - right = 12, - bottom = 12 + fun `diffRectangle spans multiple changed tiles`() { + val tilesA = listOf( + sig(1L, 0L), sig(2L, 0L), sig(3L, 0L), + sig(4L, 0L), sig(5L, 0L), sig(6L, 0L), ) - val base = mockBitmap(width, height, basePixels) - val withOverlay = mockBitmap(width, height, overlayPixels) - - val tileSize = 4 - val sigBase = manager.compute(base, tileSize)!! - val sigOverlay = manager.compute(withOverlay, tileSize)!! - - // 12x12 with tile size 4 => 3x3 tiles - assertEquals(9, sigBase.tileSignatures.size) - assertEquals(9, sigOverlay.tileSignatures.size) - - var diffCount = 0 - for (i in sigBase.tileSignatures.indices) { - if (sigBase.tileSignatures[i] != sigOverlay.tileSignatures[i]) { - diffCount++ - } - } - assertNotEquals(0, diffCount) + val tilesB = listOf( + sig(99L, 0L), sig(2L, 0L), sig(3L, 0L), + sig(4L, 0L), sig(5L, 0L), sig(88L, 0L), + ) + val a = imageSignature(2, 3, 10, 10, tilesA) + val b = imageSignature(2, 3, 10, 10, tilesB) + val diff = a.diffRectangle(b) + assertEquals(IntRect(0, 0, 30, 20), diff) } @Test - fun `default compute uses fixed 64 width and divisor-based height`() { - val manager = TileSignatureManager() - val bitmap = mockBitmap(130, 88, BLUE) - - val signature = manager.compute(bitmap) - assertNotNull(signature) - assertEquals(64, signature!!.tileWidth) - assertEquals(22, signature.tileHeight) - assertEquals(3, signature.columns) // ceil(130/64) - assertEquals(4, signature.rows) // ceil(88/22) + fun `diffRectangle covers row strip for row-only changes`() { + // 3x3 grid, only middle row changes + val tilesA = List(9) { sig(it.toLong(), 0L) } + val tilesB = tilesA.toMutableList().apply { + this[3] = sig(90L, 0L) + this[4] = sig(91L, 0L) + this[5] = sig(92L, 0L) + } + val a = imageSignature(3, 3, 10, 10, tilesA) + val b = imageSignature(3, 3, 10, 10, tilesB) + val diff = a.diffRectangle(b) + assertEquals(IntRect(0, 10, 30, 10), diff) } @Test - fun `default fixed64 path matches explicit generic path`() { - val manager = TileSignatureManager() - val width = 130 - val height = 88 - val pixels = IntArray(width * height) { i -> - // deterministic non-uniform pattern - (0xFF shl 24) or ((i * 17) and 0x00FFFFFF) + fun `diffRectangle covers column strip for column-only changes`() { + // 3x3 grid, only middle column changes + val tilesA = List(9) { sig(it.toLong(), 0L) } + val tilesB = tilesA.toMutableList().apply { + this[1] = sig(90L, 0L) + this[4] = sig(91L, 0L) + this[7] = sig(92L, 0L) } - val bitmap = mockBitmap(width, height, pixels) - - val defaultSig = manager.compute(bitmap) - val explicitSig = manager.compute(bitmap, 64, 22) - - assertNotNull(defaultSig) - assertNotNull(explicitSig) - assertEquals(explicitSig, defaultSig) + val a = imageSignature(3, 3, 10, 10, tilesA) + val b = imageSignature(3, 3, 10, 10, tilesB) + val diff = a.diffRectangle(b) + assertEquals(IntRect(10, 0, 10, 30), diff) } @Test - fun `default fixed64 path matches explicit path across content patterns`() { - val manager = TileSignatureManager() - val cases = listOf( - Triple(64, 22, 0x000000), // exact single tile - Triple(128, 44, 0xFFFFFF), // exact multi-tile - Triple(191, 67, 0x123456), // partial right-edge and bottom tile - ) - - for ((width, height, seed) in cases) { - val pixels = IntArray(width * height) { i -> - val v = (seed + i * 1315423911).toInt() - (0xFF shl 24) or (v and 0x00FFFFFF) - } - val bitmap = mockBitmap(width, height, pixels) - val tileHeight = expectedDefaultTileHeight(height) + fun `hashCode is consistent for equal signatures`() { + val tiles = listOf(sig(42L, 99L)) + val a = imageSignature(1, 1, 64, 22, tiles) + val b = imageSignature(1, 1, 64, 22, tiles) + assertEquals(a.hashCode(), b.hashCode()) + } - val defaultSig = manager.compute(bitmap) - val explicitSig = manager.compute(bitmap, 64, tileHeight) - assertNotNull(defaultSig) - assertNotNull(explicitSig) - assertEquals(explicitSig, defaultSig) - } + @Test + fun `hashCode differs for different signatures`() { + val a = imageSignature(1, 1, 64, 22, listOf(sig(1L, 2L))) + val b = imageSignature(1, 1, 64, 22, listOf(sig(3L, 4L))) + assertNotEquals(a.hashCode(), b.hashCode()) } @Test - fun `kotlin hashing matches native byte order instead of raw ARGB words`() { - val manager = TileSignatureManager() - val width = 3 - val height = 2 - val pixels = intArrayOf( - 0xFF112233.toInt(), 0xFF445566.toInt(), 0xFF778899.toInt(), - 0xFF99AA00.toInt(), 0xFF00CC44.toInt(), 0xFF3300EE.toInt(), - ) - val bitmap = mockBitmap(width, height, pixels) + fun `createWithAccHash produces equal signature to constructor`() { + val tiles = listOf(sig(1L, 2L), sig(3L, 4L)) + var acc = 0 + for (tile in tiles) acc = ImageSignature.accumulateTile(acc, tile) - val signature = manager.compute(bitmap, width, height) - assertNotNull(signature) - assertEquals(1, signature!!.tileSignatures.size) + val fromConstructor = ImageSignature(1, 2, 64, 22, tiles) + val fromFactory = ImageSignature.createWithAccHash(1, 2, 64, 22, tiles, acc) - val expectedNative = hashGenericTile( - pixels = pixels, - imageWidth = width, - startX = 0, - startY = 0, - endX = width, - endY = height, - normalizeForNativeLayout = true, - ) - val legacyArgbDirect = hashGenericTile( - pixels = pixels, - imageWidth = width, - startX = 0, - startY = 0, - endX = width, - endY = height, - normalizeForNativeLayout = false, - ) + assertEquals(fromConstructor, fromFactory) + assertEquals(fromConstructor.hashCode(), fromFactory.hashCode()) + } - assertNotEquals(legacyArgbDirect, expectedNative) - assertEquals(expectedNative, signature.tileSignatures.single()) + @Test + fun `single tile signature convenience constructor sets hashHi to zero`() { + val sig = TileSignature(hash = 42L) + assertEquals(42L, sig.hashLo) + assertEquals(0L, sig.hashHi) } } -