diff --git a/.changeset/optimize-replay-pixel-copy.md b/.changeset/optimize-replay-pixel-copy.md new file mode 100644 index 000000000..5b712780e --- /dev/null +++ b/.changeset/optimize-replay-pixel-copy.md @@ -0,0 +1,5 @@ +--- +'posthog-android': patch +--- + +Reduce session replay screenshot overhead by capturing directly into a reusable, half-resolution bitmap with a lower-memory pixel format. diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt index 1ebc1d138..48378c83e 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt @@ -71,6 +71,7 @@ import com.posthog.android.replay.internal.BaselineResult import com.posthog.android.replay.internal.IntHashSet import com.posthog.android.replay.internal.MaskCaptureToken import com.posthog.android.replay.internal.NextDrawListener.Companion.onNextDraw +import com.posthog.android.replay.internal.PixelCopyBitmapBuffer import com.posthog.android.replay.internal.ViewTreeSnapshotStatus import com.posthog.android.replay.internal.WindowDrawState import com.posthog.android.replay.internal.isAlive @@ -107,6 +108,8 @@ import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.ceil +import kotlin.math.floor public class PostHogReplayIntegration( private val context: Context, @@ -149,6 +152,7 @@ public class PostHogReplayIntegration( // directly, eliminating the need for a HandlerThread entirely. Requires compileSdk 34+. private var pixelCopyThread: HandlerThread? = null private var pixelCopyHandler: Handler? = null + private val pixelCopyBitmapBuffer = PixelCopyBitmapBuffer() private fun ensurePixelCopyHandler(): Handler { pixelCopyThread?.let { thread -> @@ -594,6 +598,11 @@ public class PostHogReplayIntegration( } finally { ownsInstallation = false integrationInstalled.set(false) + try { + pixelCopyBitmapBuffer.close() + } catch (e: Throwable) { + config.logger.log("Session Replay screenshot buffer cleanup failed: $e.") + } } } @@ -1486,7 +1495,25 @@ public class PostHogReplayIntegration( } } - private fun Bitmap.paintScreenshotMasks(rects: List): Boolean { + internal fun RectF.setScaledScreenshotMask( + rect: Rect, + scaleX: Float, + scaleY: Float, + ) { + set( + floor(rect.left * scaleX).toFloat(), + floor(rect.top * scaleY).toFloat(), + ceil(rect.right * scaleX).toFloat(), + ceil(rect.bottom * scaleY).toFloat(), + ) + } + + private fun Bitmap.paintScreenshotMasks( + rects: List, + sourceWidth: Int, + sourceHeight: Int, + canPaintMask: () -> Boolean = { true }, + ): Boolean { if (!isValid()) { this@PostHogReplayIntegration.config.logger.log("Session Replay Bitmap is invalid.") return false @@ -1500,10 +1527,15 @@ public class PostHogReplayIntegration( return false } + val scaleX = width.toFloat() / sourceWidth + val scaleY = height.toFloat() / sourceHeight val maskRect = RectF() - rects.forEach { - maskRect.set(it) - canvas.drawRoundRect(maskRect, 10f, 10f, paint) + for (rect in rects) { + if (!canPaintMask()) { + return false + } + maskRect.setScaledScreenshotMask(rect, scaleX, scaleY) + canvas.drawRoundRect(maskRect, 10f * scaleX, 10f * scaleY, paint) } return true } @@ -1532,7 +1564,7 @@ public class PostHogReplayIntegration( config.logger.log("Session Replay screenshot discarded due to screen changes.") return false } - return bitmap.paintScreenshotMasks(postWalk.rects) + return bitmap.paintScreenshotMasks(postWalk.rects, width, height) } private fun View.maskLegacyScreenshot( @@ -1552,27 +1584,58 @@ public class PostHogReplayIntegration( return false } - if (!bitmap.isValid()) { - config.logger.log("Session Replay Bitmap is invalid.") - return false - } - val canvas = - try { - Canvas(bitmap) - } catch (e: Throwable) { - config.logger.log("Session Replay Canvas creation failed: $e.") - return false - } - val maskRect = RectF() - walk.rects.forEach { - if (unsafeRedraw()) { + return bitmap.paintScreenshotMasks(walk.rects, width, height) { + val safe = !unsafeRedraw() + if (!safe) { config.logger.log("Session Replay screenshot discarded due to screen changes.") + } + safe + } + } + + private class PixelCopyRequestState { + private var callbackFinished = false + private var waiterAbandoned = false + private var copySucceeded = false + + @Synchronized + fun isAbandoned(): Boolean = waiterAbandoned + + @Synchronized + fun complete(succeeded: Boolean): Boolean { + if (callbackFinished) { return false } - maskRect.set(it) - canvas.drawRoundRect(maskRect, 10f, 10f, paint) + copySucceeded = succeeded + callbackFinished = true + return waiterAbandoned } - return true + + @Synchronized + fun abandon(): Boolean { + waiterAbandoned = true + return callbackFinished + } + + @Synchronized + fun succeeded(): Boolean = callbackFinished && copySucceeded + } + + private fun finishScreenshotCapture( + drawState: WindowDrawState, + armedCapture: ArmedMaskCapture?, + verifyMaskAlignment: Boolean, + ) { + armedCapture?.let { drawState.cancelMaskCapture(it.token) } + if (verifyMaskAlignment) { + drawState.reset() + } else { + drawState.finishLegacyCapture() + } + } + + private fun downscaledScreenshotDimension(size: Int): Int { + return maxOf(1, (size + SCREENSHOT_DOWNSCALE_FACTOR - 1) / SCREENSHOT_DOWNSCALE_FACTOR) } // PixelCopy is only API >= 24 but this is already protected by the isSupported method @@ -1618,79 +1681,118 @@ public class PostHogReplayIntegration( recordScreenshotDiscarded(drawState) return null } - val bitmap: Bitmap + + val bitmapLease: PixelCopyBitmapBuffer.Lease val handler: Handler try { - bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) handler = ensurePixelCopyHandler() + bitmapLease = + pixelCopyBitmapBuffer.acquire( + downscaledScreenshotDimension(view.width), + downscaledScreenshotDimension(view.height), + ) ?: run { + finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) + config.logger.log("Session Replay screenshot skipped because the previous PixelCopy is still in progress.") + recordScreenshotDiscarded(drawState) + return null + } } catch (e: Throwable) { - armedCapture?.let { drawState.cancelMaskCapture(it.token) } - if (verifyMaskAlignment) { - drawState.reset() - } else { - drawState.finishLegacyCapture() - } + finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) config.logger.log("Session Replay screenshot setup failed: $e.") recordScreenshotDiscarded(drawState) return null } + val bitmap = bitmapLease.bitmap val latch = CountDownLatch(1) - var success = true - - // Track whether the PixelCopy callback has finished to avoid recycling the bitmap - // while the callback is still using it (e.g. if latch.await times out). - // We use the latch itself as the synchronization mechanism (await happens-before countDown) - var callbackCompleted = false + val requestState = PixelCopyRequestState() try { - PixelCopy.request(window, bitmap, { copyResult -> - try { - if (copyResult != PixelCopy.SUCCESS) { - config.logger.log("Session Replay PixelCopy failed: $copyResult.") - success = false - } else { - success = - if (armedCapture != null) { - view.maskVerifiedScreenshot(bitmap, drawState, armedCapture) - } else { - view.maskLegacyScreenshot(bitmap, drawState) + PixelCopy.request( + window, + bitmap, + { copyResult -> + var succeeded = false + try { + if (copyResult != PixelCopy.SUCCESS) { + if ( + copyResult == PixelCopy.ERROR_DESTINATION_INVALID && + bitmap.config == Bitmap.Config.RGB_565 && + pixelCopyBitmapBuffer.fallbackToArgb8888() + ) { + config.logger.log( + "Session Replay PixelCopy does not support RGB_565; falling back to ARGB_8888.", + ) } + config.logger.log("Session Replay PixelCopy failed: $copyResult.") + } else if (!requestState.isAbandoned()) { + succeeded = + if (armedCapture != null) { + view.maskVerifiedScreenshot(bitmap, drawState, armedCapture) + } else { + view.maskLegacyScreenshot(bitmap, drawState) + } + } + } catch (e: Throwable) { + config.logger.log("Session Replay PixelCopy failed: $e.") + } finally { + val releaseInCallback = requestState.complete(succeeded) + try { + if (releaseInCallback) { + bitmapLease.release() + } + } finally { + latch.countDown() + } } - } catch (e: Throwable) { - config.logger.log("Session Replay PixelCopy failed: $e.") - success = false - } finally { - callbackCompleted = true - latch.countDown() - } - }, handler) + }, + handler, + ) } catch (e: Throwable) { config.logger.log("Session Replay PixelCopy failed: $e.") - success = false - callbackCompleted = true + if (requestState.complete(false)) { + bitmapLease.release() + } latch.countDown() } + var releaseFromWaiter = false + val callbackFinished = + try { + latch.await(1000, TimeUnit.MILLISECONDS) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + config.logger.log("Session Replay PixelCopy wait interrupted: $e.") + releaseFromWaiter = requestState.abandon() + null + } catch (e: Throwable) { + config.logger.log("Session Replay PixelCopy wait failed: $e.") + releaseFromWaiter = requestState.abandon() + null + } + try { - // On timeout the masks aren't painted yet, so the bitmap must not be shipped. - if (latch.await(1000, TimeUnit.MILLISECONDS) && success) { - base64 = bitmap.webpBase64() + when (callbackFinished) { + true -> { + releaseFromWaiter = true + if (requestState.succeeded()) { + try { + base64 = bitmap.webpBase64() + } catch (e: Throwable) { + config.logger.log("Session Replay screenshot encoding failed: $e.") + } + } + } + false -> { + config.logger.log("Session Replay PixelCopy timed out.") + releaseFromWaiter = requestState.abandon() + } + null -> Unit } - } catch (e: Throwable) { - config.logger.log("Session Replay PixelCopy timed out: $e.") } finally { - armedCapture?.let { drawState.cancelMaskCapture(it.token) } - if (verifyMaskAlignment) { - drawState.reset() - } else { - drawState.finishLegacyCapture() - } - // Only recycle the bitmap if the callback has completed. - // If the latch timed out, the PixelCopy callback may still be writing to the bitmap - // on another thread; recycling it now would cause a native SIGSEGV. - if (callbackCompleted && !bitmap.isRecycled) { - bitmap.recycle() + finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) + if (releaseFromWaiter) { + bitmapLease.release() } } @@ -2264,6 +2366,7 @@ public class PostHogReplayIntegration( val currentSessionId = postHog?.getSessionId()?.toString() resetSessionStateIfNeeded(currentSessionId, force = !resumeCurrent) + pixelCopyBitmapBuffer.open() startedWithAutomaticDisabled = !config.sessionReplay isSessionReplayActive = true @@ -2295,6 +2398,7 @@ public class PostHogReplayIntegration( private fun stopRecording() { isSessionReplayActive = false + pixelCopyBitmapBuffer.close() synchronized(decorViews) { decorViews.values.forEach { it.drawState.invalidateMaskCapture() } } @@ -2728,6 +2832,7 @@ public class PostHogReplayIntegration( // Pre-walk re-arm attempts per capture: a screen that redraws during every attempt // discards this tick and retries at the next scheduled snapshot. private const val MAX_BASELINE_ARM_ATTEMPTS: Int = 3 + private const val SCREENSHOT_DOWNSCALE_FACTOR: Int = 2 private val integrationInstalled = AtomicBoolean(false) } diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/PixelCopyBitmapBuffer.kt b/posthog-android/src/main/java/com/posthog/android/replay/internal/PixelCopyBitmapBuffer.kt new file mode 100644 index 000000000..673497d6c --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/PixelCopyBitmapBuffer.kt @@ -0,0 +1,117 @@ +package com.posthog.android.replay.internal + +import android.graphics.Bitmap +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Owns the reusable destination bitmap used by session replay PixelCopy requests. + * + * Only one lease can be active per recording run. Closing a run recycles an idle bitmap and + * detaches an in-flight lease so a late callback cannot return it to a later run. + */ +internal class PixelCopyBitmapBuffer { + private var isOpen = false + private var generation = 0L + private var nextLeaseId = 0L + private var activeLeaseId: Long? = null + private var idleBitmap: Bitmap? = null + private var bitmapConfig = Bitmap.Config.RGB_565 + + @Synchronized + fun open() { + if (!isOpen) { + generation++ + isOpen = true + } + } + + @Synchronized + fun acquire( + width: Int, + height: Int, + ): Lease? { + if (!isOpen || activeLeaseId != null) { + return null + } + + val bitmap = obtainBitmap(width, height) + val leaseId = ++nextLeaseId + activeLeaseId = leaseId + return Lease(this, bitmap, generation, leaseId) + } + + @Synchronized + fun fallbackToArgb8888(): Boolean { + if (bitmapConfig == Bitmap.Config.ARGB_8888) { + return false + } + bitmapConfig = Bitmap.Config.ARGB_8888 + idleBitmap?.recycle() + idleBitmap = null + return true + } + + @Synchronized + fun close() { + if (!isOpen && idleBitmap == null && activeLeaseId == null) { + return + } + isOpen = false + activeLeaseId = null + idleBitmap?.recycle() + idleBitmap = null + } + + @Synchronized + private fun release(lease: Lease) { + val ownsActiveLease = lease.generation == generation && lease.id == activeLeaseId + if (ownsActiveLease) { + activeLeaseId = null + } + if (isOpen && ownsActiveLease && lease.bitmap.config == bitmapConfig) { + idleBitmap = lease.bitmap + } else { + lease.bitmap.recycle() + } + } + + private fun obtainBitmap( + width: Int, + height: Int, + ): Bitmap { + require(width > 0 && height > 0) { "PixelCopy bitmap dimensions must be positive" } + + val bitmap = idleBitmap + idleBitmap = null + if (bitmap == null || bitmap.isRecycled || bitmap.config != bitmapConfig) { + bitmap?.recycle() + return Bitmap.createBitmap(width, height, bitmapConfig) + } + if (bitmap.width == width && bitmap.height == height) { + return bitmap + } + + return try { + bitmap.reconfigure(width, height, bitmapConfig) + bitmap + } catch (_: IllegalArgumentException) { + bitmap.recycle() + Bitmap.createBitmap(width, height, bitmapConfig) + } + } + + internal class Lease internal constructor( + private val owner: PixelCopyBitmapBuffer, + val bitmap: Bitmap, + internal val generation: Long, + internal val id: Long, + ) { + private val released = AtomicBoolean(false) + + fun release() { + if (released.compareAndSet(false, true)) { + owner.release(this) + } + } + } +} diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt index c7c7c0fa9..badb9dfc7 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogReplayIntegrationTest.kt @@ -5,6 +5,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Point import android.graphics.Rect +import android.graphics.RectF import android.graphics.drawable.BitmapDrawable import android.os.Handler import android.os.Looper @@ -40,6 +41,9 @@ import com.posthog.internal.PostHogQueue import com.posthog.internal.PostHogQueueInterface import com.posthog.internal.PostHogRemoteConfig import com.posthog.internal.PostHogSessionManager +import com.posthog.internal.replay.RREvent +import com.posthog.internal.replay.RREventType +import com.posthog.internal.replay.RRWireframe import curtains.DispatchState import org.junit.Rule import org.junit.rules.TemporaryFolder @@ -1712,6 +1716,104 @@ internal class PostHogReplayIntegrationTest { } } + @Test + @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) + fun `screenshot capture reuses a half resolution RGB565 destination`() { + val h = screenshotCaptureHarness() + RecordingShadowPixelCopy.reset() + try { + h.hookLayout.layout(0, 0, 101, 99) + h.child.layout(0, 0, 101, 20) + + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + + assertEquals(2, RecordingShadowPixelCopy.requests.size) + val first = RecordingShadowPixelCopy.requests[0].bitmap + val second = RecordingShadowPixelCopy.requests[1].bitmap + assertTrue(first === second) + assertEquals(51, first.width) + assertEquals(50, first.height) + assertEquals(Bitmap.Config.RGB_565, first.config) + + @Suppress("UNCHECKED_CAST") + val events = h.fake.properties?.get("\$snapshot_data") as List + val fullSnapshot = events.first { it.type == RREventType.FullSnapshot } + val wireframes = (fullSnapshot.data as Map<*, *>)["wireframes"] as List<*> + val wireframe = wireframes.single() as RRWireframe + val density = h.hookLayout.resources.displayMetrics.density + assertEquals((h.hookLayout.width / density).toInt(), wireframe.width) + assertEquals((h.hookLayout.height / density).toInt(), wireframe.height) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + fun `screenshot masks round outward when scaled`() { + val scaled = RectF() + + with(getSut()) { + scaled.setScaledScreenshotMask( + Rect(1, 3, 2, 4), + scaleX = 51f / 101f, + scaleY = 50f / 99f, + ) + } + + assertEquals(RectF(0f, 1f, 2f, 3f), scaled) + } + + @Test + @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) + fun `timed out PixelCopy lease is quarantined until its callback`() { + val h = screenshotCaptureHarness() + RecordingShadowPixelCopy.reset() + RecordingShadowPixelCopy.defer = true + try { + assertFalse(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + val timedOutBitmap = RecordingShadowPixelCopy.requests.single().bitmap + assertFalse(timedOutBitmap.isRecycled) + + assertFalse(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + assertEquals(1, RecordingShadowPixelCopy.requests.size) + + RecordingShadowPixelCopy.complete(0) + RecordingShadowPixelCopy.defer = false + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + + assertEquals(2, RecordingShadowPixelCopy.requests.size) + assertTrue(timedOutBitmap === RecordingShadowPixelCopy.requests[1].bitmap) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) + fun `invalid RGB565 destination falls back once to ARGB8888`() { + val h = screenshotCaptureHarness() + RecordingShadowPixelCopy.reset() + try { + RecordingShadowPixelCopy.result = PixelCopy.ERROR_DESTINATION_INVALID + assertFalse(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + val rejectedBitmap = RecordingShadowPixelCopy.requests.single().bitmap + assertEquals(Bitmap.Config.RGB_565, rejectedBitmap.config) + assertTrue(rejectedBitmap.isRecycled) + + RecordingShadowPixelCopy.result = PixelCopy.SUCCESS + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + + assertEquals(2, RecordingShadowPixelCopy.requests.size) + assertEquals(Bitmap.Config.ARGB_8888, RecordingShadowPixelCopy.requests[1].bitmap.config) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + private class VisibleRectHookView( context: Context, private val onGetGlobalVisibleRect: (Rect, Point?) -> Unit, @@ -2019,7 +2121,8 @@ internal class PostHogReplayIntegrationTest { @Implements(Bitmap::class) class ThrowingShadowBitmap { companion object { - const val ALLOCATION_FAILURE_WIDTH = 823476 + const val SOURCE_ALLOCATION_FAILURE_WIDTH = 823476 + const val ALLOCATION_FAILURE_WIDTH = SOURCE_ALLOCATION_FAILURE_WIDTH / 2 @JvmStatic @Implementation @@ -2064,14 +2167,14 @@ internal class PostHogReplayIntegrationTest { val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager val decorView = View(context).apply { - layout(0, 0, ThrowingShadowBitmap.ALLOCATION_FAILURE_WIDTH, 10) + layout(0, 0, ThrowingShadowBitmap.SOURCE_ALLOCATION_FAILURE_WIDTH, 10) } windowManager.addView( decorView, WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION), ) shadowOf(Looper.getMainLooper()).idle() - decorView.layout(0, 0, ThrowingShadowBitmap.ALLOCATION_FAILURE_WIDTH, 10) + decorView.layout(0, 0, ThrowingShadowBitmap.SOURCE_ALLOCATION_FAILURE_WIDTH, 10) makeWindowVisible(decorView) fx.sut.decorViews[decorView] = ViewTreeSnapshotStatus(mock()) @@ -2375,6 +2478,44 @@ internal class PostHogReplayIntegrationTest { } } + @Implements(PixelCopy::class) + class RecordingShadowPixelCopy { + data class Request( + val bitmap: Bitmap, + val listener: PixelCopy.OnPixelCopyFinishedListener, + ) + + companion object { + val requests = mutableListOf() + var defer = false + var result = PixelCopy.SUCCESS + + @JvmStatic + @Implementation + fun request( + window: Window, + bitmap: Bitmap, + listener: PixelCopy.OnPixelCopyFinishedListener, + handler: Handler, + ) { + requests.add(Request(bitmap, listener)) + if (!defer) { + listener.onPixelCopyFinished(result) + } + } + + fun complete(index: Int) { + requests[index].listener.onPixelCopyFinished(result) + } + + fun reset() { + requests.clear() + defer = false + result = PixelCopy.SUCCESS + } + } + } + @Implements(PixelCopy::class) class ThrowingShadowPixelCopy { companion object { diff --git a/posthog-android/src/test/java/com/posthog/android/replay/internal/PixelCopyBitmapBufferTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/internal/PixelCopyBitmapBufferTest.kt new file mode 100644 index 000000000..8d56bc493 --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/internal/PixelCopyBitmapBufferTest.kt @@ -0,0 +1,72 @@ +package com.posthog.android.replay.internal + +import android.graphics.Bitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [26]) +internal class PixelCopyBitmapBufferTest { + @Test + fun `reuses and reconfigures one RGB565 bitmap within a recording run`() { + val buffer = PixelCopyBitmapBuffer() + buffer.open() + + val first = buffer.acquire(51, 50)!! + assertEquals(Bitmap.Config.RGB_565, first.bitmap.config) + first.release() + + val second = buffer.acquire(50, 51)!! + assertSame(first.bitmap, second.bitmap) + assertEquals(50, second.bitmap.width) + assertEquals(51, second.bitmap.height) + second.release() + + buffer.close() + assertTrue(first.bitmap.isRecycled) + } + + @Test + fun `closed run detaches and recycles a late lease`() { + val buffer = PixelCopyBitmapBuffer() + buffer.open() + val oldRun = buffer.acquire(50, 50)!! + + buffer.close() + buffer.open() + val newRun = buffer.acquire(50, 50)!! + + assertNotSame(oldRun.bitmap, newRun.bitmap) + oldRun.release() + assertTrue(oldRun.bitmap.isRecycled) + assertFalse(newRun.bitmap.isRecycled) + + newRun.release() + buffer.close() + assertTrue(newRun.bitmap.isRecycled) + } + + @Test + fun `falls back to ARGB8888 once and discards the RGB565 cache`() { + val buffer = PixelCopyBitmapBuffer() + buffer.open() + val rgb565 = buffer.acquire(10, 10)!! + rgb565.release() + + assertTrue(buffer.fallbackToArgb8888()) + assertFalse(buffer.fallbackToArgb8888()) + assertTrue(rgb565.bitmap.isRecycled) + + val argb8888 = buffer.acquire(10, 10)!! + assertEquals(Bitmap.Config.ARGB_8888, argb8888.bitmap.config) + argb8888.release() + buffer.close() + } +}