Skip to content

Commit f3369bc

Browse files
feat: Android SR Do not send duplicate screens (#304)
## Summary Use tiled signature hashes to compare screens between each other. ## How did you test this change? <!-- Frontend - Leave a screencast or a screenshot to visually describe the changes. --> ## Are there any deployment considerations? <!-- Backend - Do we need to consider migrations or backfilling data? --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Integrates a tiled bitmap signature check to skip emitting duplicate screen captures and adds implementation with tests and test utilities. > > - **Capture pipeline (`CaptureSource`)**: > - Compute `TiledSignature` for the composed bitmap (tile size 64) and skip capture emission when signature matches previous. > - Recycle bitmap on duplicate to avoid leaks; maintain last signature state. > - **New capture utility**: > - Add `TiledSignature` and `TiledSignatureManager` to compute per-tile hashes from `Bitmap` pixels. > - **Tests**: > - Add `TiledSignatureManagerTest` covering invalid inputs, equality/differences, tile count, and partial-tile changes. > - Add test helpers in `testutil/BitmapMocks.kt` for mock bitmaps and overlay generation. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 04c252e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 835de98 commit f3369bc

File tree

4 files changed

+320
-0
lines changed

4 files changed

+320
-0
lines changed

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureSource.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ class CaptureSource(
5959
style = Paint.Style.FILL
6060
}
6161

62+
private val tiledSignatureManager = TiledSignatureManager()
63+
@Volatile
64+
private var tiledSignature: TiledSignature? = null
6265
/**
6366
* Requests a [CaptureEvent] be taken now.
6467
*/
@@ -136,6 +139,14 @@ class CaptureSource(
136139
}
137140
}
138141

142+
val newSignature = tiledSignatureManager.compute(baseResult.bitmap, 64)
143+
if (newSignature != null && newSignature == tiledSignature) {
144+
baseResult.bitmap.recycle()
145+
// the similar bitmap not send
146+
return@withContext null
147+
}
148+
tiledSignature = newSignature
149+
139150
createCaptureEvent(baseResult.bitmap, rect, timestamp, session)
140151
}
141152
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.launchdarkly.observability.replay.capture
2+
3+
import android.graphics.Bitmap
4+
5+
data class TiledSignature(
6+
val tileHashes: LongArray
7+
) {
8+
override fun equals(other: Any?): Boolean =
9+
other is TiledSignature && tileHashes.contentEquals(other.tileHashes)
10+
11+
override fun hashCode(): Int = tileHashes.contentHashCode()
12+
}
13+
14+
/**
15+
* Computes tiled signatures for bitmaps.
16+
*
17+
* This class is intentionally not thread-safe in order to reuse a single internal
18+
* pixel buffer allocation and minimize memory churn and GC pressure. Do not invoke
19+
* methods on the same instance from multiple threads concurrently. If cross-thread
20+
* use is required, create one instance per thread or guard access with external
21+
* synchronization.
22+
*/
23+
class TiledSignatureManager {
24+
@Volatile
25+
private var pixelBuffer: IntArray = IntArray(0)
26+
27+
/**
28+
* Computes a tiled signature for the given bitmap. Not thread-safe.
29+
*
30+
* @param bitmap The bitmap to compute a signature for.
31+
* @param tileSize The size of the tiles to use for the signature.
32+
* @return The tiled signature.
33+
*/
34+
fun compute(
35+
bitmap: Bitmap,
36+
tileSize: Int
37+
): TiledSignature? {
38+
if (tileSize <= 0) return null
39+
val width = bitmap.width
40+
val height = bitmap.height
41+
if (width <= 0 || height <= 0) {
42+
return null
43+
}
44+
45+
val pixelsNeeded = width * height
46+
if (pixelBuffer.size < pixelsNeeded) {
47+
pixelBuffer = IntArray(pixelsNeeded)
48+
}
49+
val pixels = pixelBuffer
50+
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)
51+
52+
val tilesX = (width + tileSize - 1) / tileSize
53+
val tilesY = (height + tileSize - 1) / tileSize
54+
val tileCount = tilesX * tilesY
55+
val tileHashes = LongArray(tileCount)
56+
57+
var tileIndex = 0
58+
for(ty in 0 until tilesY) {
59+
val startY = ty * tileSize
60+
val endY = minOf(startY + tileSize, height)
61+
62+
for(tx in 0 until tilesX) {
63+
val startX = tx * tileSize
64+
val endX = minOf(startX + tileSize, width)
65+
tileHashes[tileIndex] = hashTile(
66+
pixels = pixels,
67+
width = width,
68+
startX = startX,
69+
startY = startY,
70+
endX = endX,
71+
endY = endY
72+
)
73+
tileIndex++
74+
}
75+
}
76+
77+
//TODO: optimize memory allocations here to have 2 arrays instead of 1
78+
return TiledSignature(tileHashes)
79+
}
80+
81+
private fun hashTile(
82+
pixels: IntArray,
83+
width: Int,
84+
startX: Int,
85+
startY: Int,
86+
endX: Int,
87+
endY: Int
88+
): Long {
89+
var hash = 5163949831757626579L
90+
val prime = 1238197591667094937L // from https://bigprimes.org
91+
for(y in startY until endY) {
92+
val rowOffset = y * width
93+
for(x in startX until endX) {
94+
val argb = pixels[rowOffset + x]
95+
hash = (hash xor (argb and 0xFF).toLong()) * prime
96+
hash = (hash xor ((argb ushr 8) and 0xFF).toLong()) * prime
97+
hash = (hash xor ((argb ushr 16) and 0xFF).toLong()) * prime
98+
hash = (hash xor ((argb ushr 24) and 0xFF).toLong()) * prime
99+
}
100+
}
101+
return hash
102+
}
103+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.launchdarkly.observability.replay.capture
2+
3+
import com.launchdarkly.observability.testutil.mockBitmap
4+
import com.launchdarkly.observability.testutil.withOverlayRect
5+
import java.util.Arrays
6+
import org.junit.jupiter.api.Assertions.assertEquals
7+
import org.junit.jupiter.api.Assertions.assertNotEquals
8+
import org.junit.jupiter.api.Assertions.assertNotNull
9+
import org.junit.jupiter.api.Assertions.assertNull
10+
import org.junit.jupiter.api.Test
11+
12+
class TiledSignatureManagerTest {
13+
14+
private val RED = 0xFFFF0000.toInt()
15+
private val BLUE = 0xFF0000FF.toInt()
16+
private val WHITE = 0xFFFFFFFF.toInt()
17+
private fun solidPixels(width: Int, height: Int, color: Int): IntArray {
18+
val pixels = IntArray(width * height)
19+
Arrays.fill(pixels, color)
20+
return pixels
21+
}
22+
23+
@Test
24+
fun `compute returns null when tile size is non positive`() {
25+
val manager = TiledSignatureManager()
26+
val bitmap = mockBitmap(2, 2, RED)
27+
28+
assertNull(manager.compute(bitmap, 0))
29+
assertNull(manager.compute(bitmap, -8))
30+
}
31+
32+
@Test
33+
fun `compute returns signature when inputs are valid`() {
34+
val manager = TiledSignatureManager()
35+
val bitmap = mockBitmap(4, 4, BLUE)
36+
37+
val signature = manager.compute(bitmap, 2)
38+
assertNotNull(signature)
39+
// 4x4 with tileSize 2 => 2x2 = 4 tiles
40+
assertEquals(4, signature!!.tileHashes.size)
41+
}
42+
43+
@Test
44+
fun `signatures are equal for identical content`() {
45+
val manager = TiledSignatureManager()
46+
val a = mockBitmap(8, 8, BLUE)
47+
val b = mockBitmap(8, 8, BLUE)
48+
49+
val sigA = manager.compute(a, 4)
50+
val sigB = manager.compute(b, 4)
51+
52+
assertNotNull(sigA)
53+
assertNotNull(sigB)
54+
assertEquals(sigA, sigB)
55+
}
56+
57+
@Test
58+
fun `signatures differ for different content`() {
59+
val manager = TiledSignatureManager()
60+
val a = mockBitmap(8, 8, RED)
61+
val b = mockBitmap(8, 8, WHITE)
62+
63+
val sigA = manager.compute(a, 4)
64+
val sigB = manager.compute(b, 4)
65+
66+
assertNotNull(sigA)
67+
assertNotNull(sigB)
68+
assertNotEquals(sigA, sigB)
69+
}
70+
71+
@Test
72+
fun `tile count matches expected ceil division`() {
73+
val manager = TiledSignatureManager()
74+
val bmp = mockBitmap(10, 10, RED)
75+
76+
// tileSize 4 => ceil(10/4)=3 in each dimension => 9 tiles
77+
val sig4 = manager.compute(bmp, 4)
78+
assertNotNull(sig4)
79+
assertEquals(9, sig4!!.tileHashes.size)
80+
81+
// tileSize 6 => ceil(10/6)=2 in each dimension => 4 tiles
82+
val sig6 = manager.compute(bmp, 6)
83+
assertNotNull(sig6)
84+
assertEquals(4, sig6!!.tileHashes.size)
85+
}
86+
87+
@Test
88+
fun `small overlay changes only affected tiles hashes`() {
89+
val manager = TiledSignatureManager()
90+
val width = 12
91+
val height = 12
92+
val basePixels = solidPixels(width, height, WHITE)
93+
val overlayPixels = withOverlayRect(
94+
basePixels = basePixels,
95+
imageWidth = width,
96+
imageHeight = height,
97+
color = RED,
98+
left = 8, // touches only the last column of tiles for tileSize=4
99+
top = 8, // touches only the last row of tiles for tileSize=4
100+
right = 12,
101+
bottom = 12
102+
)
103+
val base = mockBitmap(width, height, basePixels)
104+
val withOverlay = mockBitmap(width, height, overlayPixels)
105+
106+
val tileSize = 4
107+
val sigBase = manager.compute(base, tileSize)!!
108+
val sigOverlay = manager.compute(withOverlay, tileSize)!!
109+
110+
// 12x12 with tile size 4 => 3x3 tiles
111+
assertEquals(9, sigBase.tileHashes.size)
112+
assertEquals(9, sigOverlay.tileHashes.size)
113+
114+
var diffCount = 0
115+
for (i in sigBase.tileHashes.indices) {
116+
if (sigBase.tileHashes[i] != sigOverlay.tileHashes[i]) {
117+
diffCount++
118+
}
119+
}
120+
assertNotEquals(0, diffCount)
121+
}
122+
}
123+
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.launchdarkly.observability.testutil
2+
3+
import android.graphics.Bitmap
4+
import io.mockk.every
5+
import io.mockk.mockk
6+
7+
/**
8+
* Creates a MockK-based fake Android Bitmap that returns the provided width/height
9+
* and serves pixels from the given [pixels] array via getPixels.
10+
*
11+
* The [pixels] array must be of size width * height, in row-major order (left-to-right, top-to-bottom).
12+
*/
13+
fun mockBitmap(imageWidth: Int, imageHeight: Int, pixels: IntArray): Bitmap {
14+
require(imageWidth > 0 && imageHeight > 0) { "imageWidth and imageHeight must be positive." }
15+
require(pixels.size == imageWidth * imageHeight) {
16+
"pixels size ${pixels.size} must equal imageWidth*imageHeight=${imageWidth * imageHeight}"
17+
}
18+
19+
val bmp = mockk<Bitmap>()
20+
21+
every { bmp.width } returns imageWidth
22+
every { bmp.height } returns imageHeight
23+
every { bmp.getPixels(any(), any(), any(), any(), any(), any(), any()) } answers {
24+
val dest = invocation.args[0] as IntArray
25+
val offset = invocation.args[1] as Int
26+
val stride = invocation.args[2] as Int
27+
val x = invocation.args[3] as Int
28+
val y = invocation.args[4] as Int
29+
val w = invocation.args[5] as Int
30+
val h = invocation.args[6] as Int
31+
32+
for (row in 0 until h) {
33+
val srcRowStart = (y + row) * imageWidth + x
34+
val dstRowStart = offset + row * stride
35+
for (col in 0 until w) {
36+
dest[dstRowStart + col] = pixels[srcRowStart + col]
37+
}
38+
}
39+
Unit
40+
}
41+
42+
return bmp
43+
}
44+
45+
/**
46+
* Returns a copy of [basePixels] with a filled rectangle overlay applied.
47+
*
48+
* The rectangle is defined by [left], [top], [right], [bottom] in pixel coordinates and is
49+
* clamped to the image bounds defined by [imageWidth] and [imageHeight].
50+
*/
51+
fun withOverlayRect(
52+
basePixels: IntArray,
53+
imageWidth: Int,
54+
imageHeight: Int,
55+
color: Int,
56+
left: Int,
57+
top: Int,
58+
right: Int,
59+
bottom: Int
60+
): IntArray {
61+
val out = basePixels.clone()
62+
val clampedLeft = left.coerceIn(0, imageWidth)
63+
val clampedTop = top.coerceIn(0, imageHeight)
64+
val clampedRight = right.coerceIn(0, imageWidth)
65+
val clampedBottom = bottom.coerceIn(0, imageHeight)
66+
for (y in clampedTop until clampedBottom) {
67+
val rowStart = y * imageWidth
68+
for (x in clampedLeft until clampedRight) {
69+
out[rowStart + x] = color
70+
}
71+
}
72+
return out
73+
}
74+
75+
/**
76+
* Convenience overload to create a solid-color mock Bitmap.
77+
*/
78+
fun mockBitmap(imageWidth: Int, imageHeight: Int, color: Int): Bitmap {
79+
val pixels = IntArray(imageWidth * imageHeight) { color }
80+
return mockBitmap(imageWidth, imageHeight, pixels)
81+
}
82+
83+

0 commit comments

Comments
 (0)