Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ class CaptureSource(
style = Paint.Style.FILL
}

private val tiledSignatureManager = TiledSignatureManager()
@Volatile
private var tiledSignature: TiledSignature? = null
/**
* Requests a [CaptureEvent] be taken now.
*/
Expand Down Expand Up @@ -136,6 +139,14 @@ class CaptureSource(
}
}

val newSignature = tiledSignatureManager.compute(baseResult.bitmap, 64)
if (newSignature != null && newSignature == tiledSignature) {
baseResult.bitmap.recycle()
// the similar bitmap not send
return@withContext null
}
tiledSignature = newSignature

createCaptureEvent(baseResult.bitmap, rect, timestamp, session)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.launchdarkly.observability.replay.capture

import android.graphics.Bitmap

data class TiledSignature(
val tileHashes: LongArray
) {
override fun equals(other: Any?): Boolean =
other is TiledSignature && tileHashes.contentEquals(other.tileHashes)

override fun hashCode(): Int = tileHashes.contentHashCode()
}

/**
* Computes tiled 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.
*/
class TiledSignatureManager {
@Volatile
private var pixelBuffer: IntArray = IntArray(0)

/**
* Computes a tiled signature for the given bitmap. Not thread-safe.
*
* @param bitmap The bitmap to compute a signature for.
* @param tileSize The size of the tiles to use for the signature.
* @return The tiled signature.
*/
fun compute(
bitmap: Bitmap,
tileSize: Int
): TiledSignature? {
if (tileSize <= 0) return null
val width = bitmap.width
val height = bitmap.height
if (width <= 0 || height <= 0) {
return null
}

val pixelsNeeded = width * height
if (pixelBuffer.size < pixelsNeeded) {
pixelBuffer = IntArray(pixelsNeeded)
}
val pixels = pixelBuffer
bitmap.getPixels(pixels, 0, width, 0, 0, width, height)

val tilesX = (width + tileSize - 1) / tileSize
val tilesY = (height + tileSize - 1) / tileSize
val tileCount = tilesX * tilesY
val tileHashes = LongArray(tileCount)

var tileIndex = 0
for(ty in 0 until tilesY) {
val startY = ty * tileSize
val endY = minOf(startY + tileSize, height)

for(tx in 0 until tilesX) {
val startX = tx * tileSize
val endX = minOf(startX + tileSize, width)
tileHashes[tileIndex] = hashTile(
pixels = pixels,
width = width,
startX = startX,
startY = startY,
endX = endX,
endY = endY
)
tileIndex++
}
}

//TODO: optimize memory allocations here to have 2 arrays instead of 1
return TiledSignature(tileHashes)
}

private fun hashTile(
pixels: IntArray,
width: Int,
startX: Int,
startY: Int,
endX: Int,
endY: Int
): Long {
var hash = 5163949831757626579L
val prime = 1238197591667094937L // from https://bigprimes.org
for(y in startY until endY) {
val rowOffset = y * width
for(x in startX until endX) {
val argb = pixels[rowOffset + x]
hash = (hash xor (argb and 0xFF).toLong()) * prime
hash = (hash xor ((argb ushr 8) and 0xFF).toLong()) * prime
hash = (hash xor ((argb ushr 16) and 0xFF).toLong()) * prime
hash = (hash xor ((argb ushr 24) and 0xFF).toLong()) * prime
}
}
return hash
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.launchdarkly.observability.replay.capture

import com.launchdarkly.observability.testutil.mockBitmap
import com.launchdarkly.observability.testutil.withOverlayRect
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test

class TiledSignatureManagerTest {

private val RED = 0xFFFF0000.toInt()
private val BLUE = 0xFF0000FF.toInt()
private val WHITE = 0xFFFFFFFF.toInt()
private fun solidPixels(width: Int, height: Int, color: Int): IntArray {
val pixels = IntArray(width * height)
java.util.Arrays.fill(pixels, color)
return pixels
}

@Test
fun compute_returnsNull_whenTileSizeNonPositive() {
val manager = TiledSignatureManager()
val bitmap = mockBitmap(2, 2, RED)

assertNull(manager.compute(bitmap, 0))
assertNull(manager.compute(bitmap, -8))
}

@Test
fun compute_returnsSignature_whenValidInputs() {
val manager = TiledSignatureManager()
val bitmap = mockBitmap(4, 4, BLUE)

val signature = manager.compute(bitmap, 2)
assertNotNull(signature)
// 4x4 with tileSize 2 => 2x2 = 4 tiles
assertEquals(4, signature!!.tileHashes.size)
}

@Test
fun signatures_equal_forIdenticalContent() {
val manager = TiledSignatureManager()
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)
}

@Test
fun signatures_differ_forDifferentContent() {
val manager = TiledSignatureManager()
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)
}

@Test
fun tileCount_matchesExpectedCeilDivision() {
val manager = TiledSignatureManager()
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!!.tileHashes.size)

// tileSize 6 => ceil(10/6)=2 in each dimension => 4 tiles
val sig6 = manager.compute(bmp, 6)
assertNotNull(sig6)
assertEquals(4, sig6!!.tileHashes.size)
}

@Test
fun smallOverlay_changesOnlyAffectedTiles_hashes() {
val manager = TiledSignatureManager()
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
)
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.tileHashes.size)
assertEquals(9, sigOverlay.tileHashes.size)

var diffCount = 0
for (i in sigBase.tileHashes.indices) {
if (sigBase.tileHashes[i] != sigOverlay.tileHashes[i]) {
diffCount++
}
}
org.junit.jupiter.api.Assertions.assertNotEquals(0, diffCount)
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.launchdarkly.observability.testutil

import android.graphics.Bitmap
import io.mockk.every
import io.mockk.mockk

/**
* Creates a MockK-based fake Android Bitmap that returns the provided width/height
* and serves pixels from the given [pixels] array via getPixels.
*
* The [pixels] array must be of size width * height, in row-major order (left-to-right, top-to-bottom).
*/
fun mockBitmap(imageWidth: Int, imageHeight: Int, pixels: IntArray): Bitmap {
require(imageWidth > 0 && imageHeight > 0) { "imageWidth and imageHeight must be positive." }
require(pixels.size == imageWidth * imageHeight) {
"pixels size ${pixels.size} must equal imageWidth*imageHeight=${imageWidth * imageHeight}"
}

val bmp = mockk<Bitmap>()

every { bmp.width } returns imageWidth
every { bmp.height } returns imageHeight
every { bmp.getPixels(any(), any(), any(), any(), any(), any(), any()) } answers {
val dest = invocation.args[0] as IntArray
val offset = invocation.args[1] as Int
val stride = invocation.args[2] as Int
val x = invocation.args[3] as Int
val y = invocation.args[4] as Int
val w = invocation.args[5] as Int
val h = invocation.args[6] as Int

for (row in 0 until h) {
val srcRowStart = (y + row) * imageWidth + x
val dstRowStart = offset + row * stride
for (col in 0 until w) {
dest[dstRowStart + col] = pixels[srcRowStart + col]
}
}
Unit
}

return bmp
}

/**
* Returns a copy of [basePixels] with a filled rectangle overlay applied.
*
* The rectangle is defined by [left], [top], [right], [bottom] in pixel coordinates and is
* clamped to the image bounds defined by [imageWidth] and [imageHeight].
*/
fun withOverlayRect(
basePixels: IntArray,
imageWidth: Int,
imageHeight: Int,
color: Int,
left: Int,
top: Int,
right: Int,
bottom: Int
): IntArray {
val out = basePixels.clone()
val clampedLeft = left.coerceIn(0, imageWidth)
val clampedTop = top.coerceIn(0, imageHeight)
val clampedRight = right.coerceIn(0, imageWidth)
val clampedBottom = bottom.coerceIn(0, imageHeight)
for (y in clampedTop until clampedBottom) {
val rowStart = y * imageWidth
for (x in clampedLeft until clampedRight) {
out[rowStart + x] = color
}
}
return out
}

/**
* Convenience overload to create a solid-color mock Bitmap.
*/
fun mockBitmap(imageWidth: Int, imageHeight: Int, color: Int): Bitmap {
val pixels = IntArray(imageWidth * imageHeight) { color }
return mockBitmap(imageWidth, imageHeight, pixels)
}


Loading