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