diff --git a/Library/src/main/java/dev/testify/ScreenshotUtility.kt b/Library/src/main/java/dev/testify/ScreenshotUtility.kt index bbb3a40cb..6a12182ad 100644 --- a/Library/src/main/java/dev/testify/ScreenshotUtility.kt +++ b/Library/src/main/java/dev/testify/ScreenshotUtility.kt @@ -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) } diff --git a/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt b/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt index da02a4547..d530d5a9f 100644 --- a/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt +++ b/Library/src/main/java/dev/testify/core/processor/ParallelPixelProcessor.kt @@ -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) -> 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 @@ -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) } /** @@ -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 ): 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) } /** diff --git a/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt b/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt index 664beeeab..6ac404c84 100644 --- a/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt +++ b/Library/src/main/java/dev/testify/core/processor/diff/HighContrastDiff.kt @@ -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 diff --git a/Library/src/test/java/dev/testify/core/processor/BitmapTestHelpers.kt b/Library/src/test/java/dev/testify/core/processor/BitmapTestHelpers.kt index e7660e658..f37a8d428 100644 --- a/Library/src/test/java/dev/testify/core/processor/BitmapTestHelpers.kt +++ b/Library/src/test/java/dev/testify/core/processor/BitmapTestHelpers.kt @@ -27,6 +27,7 @@ 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 @@ -34,6 +35,54 @@ 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(), any(), any(), any(), any()) } answers { + val source = arg(0) + val x = arg(1) + val y = arg(2) + val w = arg(3) + val h = arg(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() + 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, diff --git a/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt b/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt index e3d95bd27..22326e562 100644 --- a/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt +++ b/Library/src/test/java/dev/testify/core/processor/ParallelPixelProcessorTest.kt @@ -67,6 +67,7 @@ class ParallelPixelProcessorTest { fun setUp() { mockkStatic(::formatMemoryState) every { formatMemoryState() } returns "" + mockBitmapCreateBitmap() } @After diff --git a/Library/src/test/java/dev/testify/core/processor/compare/FuzzyCompareTest.kt b/Library/src/test/java/dev/testify/core/processor/compare/FuzzyCompareTest.kt index 829e4d752..0bf6fc43c 100644 --- a/Library/src/test/java/dev/testify/core/processor/compare/FuzzyCompareTest.kt +++ b/Library/src/test/java/dev/testify/core/processor/compare/FuzzyCompareTest.kt @@ -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 @@ -59,6 +60,7 @@ class FuzzyCompareTest { mockkObject(ParallelPixelProcessor.Companion) mockkStatic("dev.testify.internal.helpers.ManifestHelpersKt") mockkStatic(::formatMemoryState) + mockBitmapCreateBitmap() every { any().getMetaDataValue() } returns null every { formatMemoryState() } returns "" } diff --git a/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt b/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt index 6148d010b..4f9d642bf 100644 --- a/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt +++ b/Library/src/test/java/dev/testify/core/processor/compare/RegionCompareTest.kt @@ -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 @@ -61,6 +62,7 @@ class RegionCompareTest { Dispatchers.setMain(mainThreadSurrogate) mockkStatic("dev.testify.internal.helpers.ManifestHelpersKt") mockkStatic(::formatMemoryState) + mockBitmapCreateBitmap() every { any().getMetaDataValue() } returns null every { formatMemoryState() } returns "" } @@ -157,6 +159,23 @@ class RegionCompareTest { } } } + every { getPixels(any(), any(), any(), any(), any(), any(), any()) } answers { + val pixels = arg(0) + val offset = arg(1) + val stride = arg(2) + val startX = arg(3) + val startY = arg(4) + val regionWidth = arg(5) + val regionHeight = arg(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 + } + } + } } } } diff --git a/Library/src/test/java/dev/testify/core/processor/diff/HighContrastDiffTest.kt b/Library/src/test/java/dev/testify/core/processor/diff/HighContrastDiffTest.kt index c0d3f4e1d..45f12a202 100644 --- a/Library/src/test/java/dev/testify/core/processor/diff/HighContrastDiffTest.kt +++ b/Library/src/test/java/dev/testify/core/processor/diff/HighContrastDiffTest.kt @@ -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 @@ -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().createBitmap() } answers {