Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions Library/src/main/java/dev/testify/ScreenshotUtility.kt
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ fun createBitmapFromActivity(

val destination = getDestination(activity, fileName)
saveBitmapToDestination(activity, currentActivityBitmap[0], destination)
currentActivityBitmap[0]?.recycle()
currentActivityBitmap[0] = null
return destination.loadBitmap(preferredBitmapOptions)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,25 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.nio.IntBuffer
import java.util.BitSet
import kotlin.math.ceil
import kotlin.math.min

typealias AnalyzePixelFunction = (baselinePixel: Int, currentPixel: Int, position: Pair<Int, Int>) -> Boolean

private const val BYTES_PER_PIXEL = 4

/**
* A class that allows for parallel processing of pixels in a bitmap.
*
* Uses coroutines to process pixels in two [Bitmap] objects in parallel.
* [analyze] is used to compare two bitmaps in parallel.
* [transform] is used to transform two bitmaps in parallel.
*
* Processes images in horizontal stripes to limit heap memory usage. Each stripe's pixels are
* read via [Bitmap.getPixels] into temporary arrays sized for just that stripe, avoiding the
* need to allocate full-image buffers on the Java heap.
*/
class ParallelPixelProcessor private constructor(
private val configuration: ParallelProcessorConfiguration
Expand All @@ -66,17 +74,17 @@ class ParallelPixelProcessor private constructor(
}

/**
* Prepare the bitmaps for parallel processing.
* Calculate the number of rows to process per stripe based on available heap memory.
* Targets using no more than 1/4 of free heap for the two per-stripe IntArrays.
*/
private fun prepareBuffers(allocateDiffBuffer: Boolean = false): ImageBuffers {
return ImageBuffers.allocate(
width = currentBitmap.width,
height = currentBitmap.height,
allocateDiffBuffer = allocateDiffBuffer
).apply {
baselineBitmap.copyPixelsToBuffer(baselineBuffer)
currentBitmap.copyPixelsToBuffer(currentBuffer)
}
@VisibleForTesting
internal fun calculateStripeHeight(width: Int, height: Int): Int {
val runtime = Runtime.getRuntime()
val freeMemory = runtime.maxMemory() - (runtime.totalMemory() - runtime.freeMemory())
val targetBudget = freeMemory / 4
val bytesPerRow = width.toLong() * BYTES_PER_PIXEL * 2 // two IntArrays (baseline + current)
val maxRows = (targetBudget / bytesPerRow).toInt().coerceAtLeast(1)
return min(maxRows, height)
}

/**
Expand Down Expand Up @@ -128,60 +136,105 @@ class ParallelPixelProcessor private constructor(
* Analyze the two bitmaps in parallel.
* The analyzer function is called for each pixel in the bitmaps.
*
* Processes the image in horizontal stripes to minimize heap usage.
*
* @param analyzer The analyzer function to call for each pixel.
* @return True if all pixels pass the analyzer function, false otherwise.
*/
fun analyze(analyzer: AnalyzePixelFunction): Boolean {
val buffers = prepareBuffers()
val chunkData = getChunkData(buffers.width, buffers.height)
val results = BitSet(chunkData.chunks).apply { set(0, chunkData.chunks) }

runBlockingInChunks(chunkData) { chunk, index ->
val position = getPosition(index, buffers.width)
val baselinePixel = buffers.baselineBuffer[index]
val currentPixel = buffers.currentBuffer[index]
if (!analyzer(baselinePixel, currentPixel, position)) {
results.clear(chunk)
false
} else {
true
val width = currentBitmap.width
val height = currentBitmap.height
val stripeHeight = calculateStripeHeight(width, height)

for (stripeY in 0 until height step stripeHeight) {
val h = min(stripeHeight, height - stripeY)
val stripeSize = width * h

val baselineBuffer = IntBuffer.allocate(stripeSize)
val currentBuffer = IntBuffer.allocate(stripeSize)

val baselineStripe = Bitmap.createBitmap(baselineBitmap, 0, stripeY, width, h)
val currentStripe = Bitmap.createBitmap(currentBitmap, 0, stripeY, width, h)
try {
baselineStripe.copyPixelsToBuffer(baselineBuffer)
currentStripe.copyPixelsToBuffer(currentBuffer)
} finally {
if (baselineStripe !== baselineBitmap) baselineStripe.recycle()
if (currentStripe !== currentBitmap) currentStripe.recycle()
}

val chunkData = getChunkData(width, h)
val results = BitSet(chunkData.chunks).apply { set(0, chunkData.chunks) }

runBlockingInChunks(chunkData) { chunk, index ->
val x = index % width
val y = stripeY + index / width
if (!analyzer(baselineBuffer[index], currentBuffer[index], x to y)) {
results.clear(chunk)
false
} else {
true
}
}

if (results.cardinality() != chunkData.chunks) {
return false
}
}

buffers.free()
return results.cardinality() == chunkData.chunks
return true
}

/**
* Transform the two bitmaps in parallel.
* The transformer function is called for each pixel in the bitmaps.
*
* Processes the image in horizontal stripes to minimize heap usage.
*
* @param transformer The transformer function to call for each pixel.
* @return A [TransformResult] containing the transformed pixels.
*/
fun transform(
transformer: (baselinePixel: Int, currentPixel: Int, position: Pair<Int, Int>) -> Int
): TransformResult {
val buffers = prepareBuffers(allocateDiffBuffer = true)
val chunkData = getChunkData(buffers.width, buffers.height)
val diffBuffer = buffers.diffBuffer

runBlockingInChunks(chunkData) { _, index ->
val position = getPosition(index, buffers.width)
val baselinePixel = buffers.baselineBuffer[index]
val currentPixel = buffers.currentBuffer[index]
diffBuffer.put(index, transformer(baselinePixel, currentPixel, position))
true
}
val width = currentBitmap.width
val height = currentBitmap.height
val stripeHeight = calculateStripeHeight(width, height)
val outputPixels = IntArray(width * height)

for (stripeY in 0 until height step stripeHeight) {
val h = min(stripeHeight, height - stripeY)
val stripeSize = width * h

val baselineBuffer = IntBuffer.allocate(stripeSize)
val currentBuffer = IntBuffer.allocate(stripeSize)

val result = TransformResult(
width = buffers.width,
height = buffers.height,
pixels = diffBuffer.array()
)
val baselineStripe = Bitmap.createBitmap(baselineBitmap, 0, stripeY, width, h)
val currentStripe = Bitmap.createBitmap(currentBitmap, 0, stripeY, width, h)
try {
baselineStripe.copyPixelsToBuffer(baselineBuffer)
currentStripe.copyPixelsToBuffer(currentBuffer)
} finally {
if (baselineStripe !== baselineBitmap) baselineStripe.recycle()
if (currentStripe !== currentBitmap) currentStripe.recycle()
}

val chunkData = getChunkData(width, h)
val stripeOffset = stripeY * width

runBlockingInChunks(chunkData) { _, index ->
val x = index % width
val y = stripeY + index / width
outputPixels[stripeOffset + index] = transformer(
baselineBuffer[index],
currentBuffer[index],
x to y
)
true
}
}

buffers.free()
return result
return TransformResult(width = width, height = height, pixels = outputPixels)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,16 @@ class HighContrastDiff private constructor(
}
}

saveBitmapToDestination(
context = context,
bitmap = transformResult.createBitmap(),
destination = getDestination(context, "$fileName.diff")
)
val diffBitmap = transformResult.createBitmap()
try {
saveBitmapToDestination(
context = context,
bitmap = diffBitmap,
destination = getDestination(context, "$fileName.diff")
)
} finally {
diffBitmap.recycle()
}
}

private var exactness: Float? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,62 @@ import android.graphics.Bitmap
import android.graphics.Rect
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.slot
import java.nio.Buffer
import java.nio.IntBuffer

const val DEFAULT_BITMAP_WIDTH = 1080
const val DEFAULT_BITMAP_HEIGHT = 2220

/**
* Set up the static mock for [Bitmap.createBitmap] so that stripe-based processing works in unit tests.
* Call this from @Before in tests that use [ParallelPixelProcessor].
*/
fun mockBitmapCreateBitmap() {
mockkStatic(Bitmap::class)
every { Bitmap.createBitmap(any<Bitmap>(), any(), any(), any(), any()) } answers {
val source = arg<Bitmap>(0)
val x = arg<Int>(1)
val y = arg<Int>(2)
val w = arg<Int>(3)
val h = arg<Int>(4)
createSubBitmapMock(source, x, y, w, h)
}
}

/**
* Creates a mock sub-bitmap that extracts pixel data from the source bitmap's region.
*/
private fun createSubBitmapMock(source: Bitmap, srcX: Int, srcY: Int, w: Int, h: Int): Bitmap {
// Read the source region's pixels via copyPixelsToBuffer on the full source,
// then extract just the sub-region.
val fullWidth = source.width
val fullHeight = source.height
val fullBuffer = IntBuffer.allocate(fullWidth * fullHeight)
source.copyPixelsToBuffer(fullBuffer)

val subBuffer = IntBuffer.allocate(w * h)
for (row in 0 until h) {
for (col in 0 until w) {
val srcIndex = (srcY + row) * fullWidth + (srcX + col)
subBuffer.put(row * w + col, fullBuffer[srcIndex])
}
}

return mockk(relaxed = true) {
every { this@mockk.width } returns w
every { this@mockk.height } returns h
val slotBuffer = slot<Buffer>()
every { this@mockk.copyPixelsToBuffer(capture(slotBuffer)) } answers {
val outputBuffer = slotBuffer.captured as IntBuffer
for (i in 0 until w * h) {
outputBuffer.put(subBuffer[i])
}
}
}
}

fun mockBitmap(
width: Int = DEFAULT_BITMAP_WIDTH,
height: Int = DEFAULT_BITMAP_HEIGHT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class ParallelPixelProcessorTest {
fun setUp() {
mockkStatic(::formatMemoryState)
every { formatMemoryState() } returns ""
mockBitmapCreateBitmap()
}

@After
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import dev.testify.core.processor.ParallelPixelProcessor
import dev.testify.core.processor.ParallelProcessorConfiguration
import dev.testify.core.processor.formatMemoryState
import dev.testify.core.processor.mockBitmap
import dev.testify.core.processor.mockBitmapCreateBitmap
import dev.testify.internal.helpers.ManifestPlaceholder
import dev.testify.internal.helpers.getMetaDataValue
import io.mockk.clearAllMocks
Expand Down Expand Up @@ -59,6 +60,7 @@ class FuzzyCompareTest {
mockkObject(ParallelPixelProcessor.Companion)
mockkStatic("dev.testify.internal.helpers.ManifestHelpersKt")
mockkStatic(::formatMemoryState)
mockBitmapCreateBitmap()
every { any<ManifestPlaceholder>().getMetaDataValue() } returns null
every { formatMemoryState() } returns ""
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import android.graphics.Rect
import dev.testify.core.TestifyConfiguration
import dev.testify.core.processor.ParallelProcessorConfiguration
import dev.testify.core.processor.formatMemoryState
import dev.testify.core.processor.mockBitmapCreateBitmap
import dev.testify.core.processor.mockRect
import dev.testify.internal.helpers.ManifestPlaceholder
import dev.testify.internal.helpers.getMetaDataValue
Expand Down Expand Up @@ -61,6 +62,7 @@ class RegionCompareTest {
Dispatchers.setMain(mainThreadSurrogate)
mockkStatic("dev.testify.internal.helpers.ManifestHelpersKt")
mockkStatic(::formatMemoryState)
mockBitmapCreateBitmap()
every { any<ManifestPlaceholder>().getMetaDataValue() } returns null
every { formatMemoryState() } returns ""
}
Expand Down Expand Up @@ -157,6 +159,23 @@ class RegionCompareTest {
}
}
}
every { getPixels(any(), any(), any(), any(), any(), any(), any()) } answers {
val pixels = arg<IntArray>(0)
val offset = arg<Int>(1)
val stride = arg<Int>(2)
val startX = arg<Int>(3)
val startY = arg<Int>(4)
val regionWidth = arg<Int>(5)
val regionHeight = arg<Int>(6)
for (row in 0 until regionHeight) {
for (col in 0 until regionWidth) {
val x = startX + col
val y = startY + row
pixels[offset + row * stride + col] =
if (alternateColor != null) alternateColor(color, x, y) else color
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import dev.testify.core.processor.ParallelProcessorConfiguration
import dev.testify.core.processor.createBitmap
import dev.testify.core.processor.formatMemoryState
import dev.testify.core.processor.mockBitmap
import dev.testify.core.processor.mockBitmapCreateBitmap
import dev.testify.core.processor.mockRect
import dev.testify.internal.helpers.ManifestPlaceholder
import dev.testify.internal.helpers.getMetaDataValue
Expand Down Expand Up @@ -76,6 +77,7 @@ class HighContrastDiffTest {
mockkStatic("dev.testify.core.processor.BitmapExtentionsKt")
mockkStatic("dev.testify.internal.helpers.ManifestHelpersKt")
mockkStatic(::formatMemoryState)
mockBitmapCreateBitmap()

every { formatMemoryState() } returns ""
every { any<ParallelPixelProcessor.TransformResult>().createBitmap() } answers {
Expand Down