diff --git a/.changeset/optimize-replay-pixel-copy.md b/.changeset/optimize-replay-pixel-copy.md index 5b712780e..50f3a5b3a 100644 --- a/.changeset/optimize-replay-pixel-copy.md +++ b/.changeset/optimize-replay-pixel-copy.md @@ -1,5 +1,5 @@ --- -'posthog-android': patch +'posthog-android': minor --- -Reduce session replay screenshot overhead by capturing directly into a reusable, half-resolution bitmap with a lower-memory pixel format. +Add an experimental `sessionReplayConfig.optimizeScreenshots` option to reduce screenshot overhead with a reusable bitmap at half the width and height. It defaults to `false`, preserving full-resolution ARGB_8888 capture. When enabled, RGB_565 reduces image detail and removes alpha, making transparent window regions appear black; captures are skipped while a timed-out PixelCopy still owns the reusable bitmap. diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 80883e1e6..844b09f99 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -111,6 +111,7 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig { public final fun getDrawableConverter ()Lcom/posthog/android/replay/PostHogDrawableConverter; public final fun getMaskAllImages ()Z public final fun getMaskAllTextInputs ()Z + public final fun getOptimizeScreenshots ()Z public final fun getSampleRate ()Ljava/lang/Double; public final fun getScreenshot ()Z public final fun getThrottleDelayMs ()J @@ -120,6 +121,7 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig { public final fun setDrawableConverter (Lcom/posthog/android/replay/PostHogDrawableConverter;)V public final fun setMaskAllImages (Z)V public final fun setMaskAllTextInputs (Z)V + public final fun setOptimizeScreenshots (Z)V public final fun setSampleRate (Ljava/lang/Double;)V public final fun setScreenshot (Z)V public final fun setThrottleDelayMs (J)V 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 48378c83e..35e5f7286 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 @@ -1501,10 +1501,10 @@ public class PostHogReplayIntegration( scaleY: Float, ) { set( - floor(rect.left * scaleX).toFloat(), - floor(rect.top * scaleY).toFloat(), - ceil(rect.right * scaleX).toFloat(), - ceil(rect.bottom * scaleY).toFloat(), + floor(rect.left * scaleX), + floor(rect.top * scaleY), + ceil(rect.right * scaleX), + ceil(rect.bottom * scaleY), ) } @@ -1512,12 +1512,16 @@ public class PostHogReplayIntegration( rects: List, sourceWidth: Int, sourceHeight: Int, + scaleMasks: Boolean, canPaintMask: () -> Boolean = { true }, ): Boolean { if (!isValid()) { this@PostHogReplayIntegration.config.logger.log("Session Replay Bitmap is invalid.") return false } + if (sourceWidth <= 0 || sourceHeight <= 0) { + return false + } val canvas = try { @@ -1527,14 +1531,18 @@ public class PostHogReplayIntegration( return false } - val scaleX = width.toFloat() / sourceWidth - val scaleY = height.toFloat() / sourceHeight + val scaleX = if (scaleMasks) width.toFloat() / sourceWidth else 1f + val scaleY = if (scaleMasks) height.toFloat() / sourceHeight else 1f val maskRect = RectF() for (rect in rects) { if (!canPaintMask()) { return false } - maskRect.setScaledScreenshotMask(rect, scaleX, scaleY) + if (scaleMasks) { + maskRect.setScaledScreenshotMask(rect, scaleX, scaleY) + } else { + maskRect.set(rect) + } canvas.drawRoundRect(maskRect, 10f * scaleX, 10f * scaleY, paint) } return true @@ -1544,6 +1552,7 @@ public class PostHogReplayIntegration( bitmap: Bitmap, drawState: WindowDrawState, armedCapture: ArmedMaskCapture, + optimizeScreenshots: Boolean, ): Boolean { val postWalk = MaskWalk() // A layout pass or an invalidating draw sample already sealed the verdict as discard, @@ -1564,12 +1573,13 @@ public class PostHogReplayIntegration( config.logger.log("Session Replay screenshot discarded due to screen changes.") return false } - return bitmap.paintScreenshotMasks(postWalk.rects, width, height) + return bitmap.paintScreenshotMasks(postWalk.rects, width, height, optimizeScreenshots) } private fun View.maskLegacyScreenshot( bitmap: Bitmap, drawState: WindowDrawState, + optimizeScreenshots: Boolean, ): Boolean { val unsafeRedraw = { drawState.isOnDrawnCalled && !drawState.isOnlyAnimationRedraw } if (unsafeRedraw()) { @@ -1584,7 +1594,7 @@ public class PostHogReplayIntegration( return false } - return bitmap.paintScreenshotMasks(walk.rects, width, height) { + return bitmap.paintScreenshotMasks(walk.rects, width, height, optimizeScreenshots) { val safe = !unsafeRedraw() if (!safe) { config.logger.log("Session Replay screenshot discarded due to screen changes.") @@ -1682,20 +1692,28 @@ public class PostHogReplayIntegration( return null } - val bitmapLease: PixelCopyBitmapBuffer.Lease + val optimizeScreenshots = config.sessionReplayConfig.optimizeScreenshots + val bitmap: Bitmap + val bitmapLease: PixelCopyBitmapBuffer.Lease? val handler: Handler try { 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 - } + if (optimizeScreenshots) { + 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 + } + bitmap = bitmapLease.bitmap + } else { + bitmapLease = null + bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + } } catch (e: Throwable) { finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) config.logger.log("Session Replay screenshot setup failed: $e.") @@ -1703,7 +1721,14 @@ public class PostHogReplayIntegration( return null } - val bitmap = bitmapLease.bitmap + fun releaseBitmap() { + if (bitmapLease != null) { + bitmapLease.release() + } else { + bitmap.recycle() + } + } + val latch = CountDownLatch(1) val requestState = PixelCopyRequestState() @@ -1728,9 +1753,9 @@ public class PostHogReplayIntegration( } else if (!requestState.isAbandoned()) { succeeded = if (armedCapture != null) { - view.maskVerifiedScreenshot(bitmap, drawState, armedCapture) + view.maskVerifiedScreenshot(bitmap, drawState, armedCapture, optimizeScreenshots) } else { - view.maskLegacyScreenshot(bitmap, drawState) + view.maskLegacyScreenshot(bitmap, drawState, optimizeScreenshots) } } } catch (e: Throwable) { @@ -1739,7 +1764,7 @@ public class PostHogReplayIntegration( val releaseInCallback = requestState.complete(succeeded) try { if (releaseInCallback) { - bitmapLease.release() + releaseBitmap() } } finally { latch.countDown() @@ -1751,7 +1776,7 @@ public class PostHogReplayIntegration( } catch (e: Throwable) { config.logger.log("Session Replay PixelCopy failed: $e.") if (requestState.complete(false)) { - bitmapLease.release() + releaseBitmap() } latch.countDown() } @@ -1792,7 +1817,7 @@ public class PostHogReplayIntegration( } finally { finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment) if (releaseFromWaiter) { - bitmapLease.release() + releaseBitmap() } } diff --git a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt index c01acac88..48913f639 100644 --- a/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt +++ b/posthog-android/src/main/java/com/posthog/android/replay/PostHogSessionReplayConfig.kt @@ -72,6 +72,19 @@ public class PostHogSessionReplayConfig @PostHogExperimental public var verifyScreenshotMaskAlignment: Boolean = false + /** + * Reduces screenshot capture overhead by reusing a bitmap at half the width and height + * with the lower-memory RGB_565 format. This reduces image detail and removes alpha, + * so transparent window regions appear black. Devices that reject RGB_565 fall back + * to ARGB_8888. A timed-out capture holds the reusable bitmap until its callback arrives, + * so subsequent screenshot captures are skipped while it is still in use. + * + * Defaults to false: each capture uses a new full-resolution ARGB_8888 bitmap. + * Applies only to screenshot capture; wireframe capture is unchanged. + */ + @PostHogExperimental + public var optimizeScreenshots: Boolean = false + init { // for keeping back compatibility @Suppress("DEPRECATION") diff --git a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt index 98a6aee12..2371a0edb 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt @@ -35,6 +35,19 @@ internal class PostHogAndroidConfigTest { assertTrue(config.captureScreenViews) } + @Test + fun `screenshot optimizations should be disabled by default`() { + assertFalse(config.sessionReplayConfig.optimizeScreenshots) + assertFalse(config.sessionReplayConfig.screenshot) + } + + @Test + fun `screenshot optimizations can be enabled`() { + config.sessionReplayConfig.optimizeScreenshots = true + + assertTrue(config.sessionReplayConfig.optimizeScreenshots) + } + @Test fun `screenshot mask alignment verification should be disabled by default`() { assertFalse(config.sessionReplayConfig.verifyScreenshotMaskAlignment) 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 badb9dfc7..14278cd55 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 @@ -3,12 +3,15 @@ package com.posthog.android.replay import android.app.Activity import android.content.Context import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color 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 +import android.util.Base64 import android.view.MotionEvent import android.view.PixelCopy import android.view.View @@ -55,6 +58,7 @@ import org.mockito.kotlin.whenever import org.robolectric.Robolectric import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements import org.robolectric.shadows.ShadowLegacyBitmap @@ -1716,10 +1720,196 @@ internal class PostHogReplayIntegrationTest { } } + @Test + @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) + fun `screenshot capture uses a new full resolution ARGB8888 destination by default`() { + val h = screenshotCaptureHarness() + RecordingShadowPixelCopy.reset() + try { + h.hookLayout.layout(0, 0, 101, 99) + h.child.layout(0, 0, 101, 20) + + repeat(2) { + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + } + + val (first, second) = RecordingShadowPixelCopy.requests + assertFalse(first.bitmap === second.bitmap) + for (request in RecordingShadowPixelCopy.requests) { + assertEquals(101, request.width) + assertEquals(99, request.height) + assertEquals(Bitmap.Config.ARGB_8888, request.config) + assertTrue(request.bitmap.isRecycled) + } + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) + fun `default screenshot capture continues after a timeout and recycles late bitmaps`() { + val h = screenshotCaptureHarness() + RecordingShadowPixelCopy.reset() + RecordingShadowPixelCopy.defer = true + try { + repeat(2) { + assertFalse(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + } + assertEquals(2, RecordingShadowPixelCopy.requests.size) + val (first, second) = RecordingShadowPixelCopy.requests + assertFalse(first.bitmap === second.bitmap) + assertFalse(first.bitmap.isRecycled) + assertFalse(second.bitmap.isRecycled) + assertEquals(0, h.fake.captures) + + RecordingShadowPixelCopy.complete(0) + assertTrue(first.bitmap.isRecycled) + assertFalse(second.bitmap.isRecycled) + + RecordingShadowPixelCopy.defer = false + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + RecordingShadowPixelCopy.complete(1) + assertTrue(second.bitmap.isRecycled) + assertEquals(1, h.fake.captures) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + + @Test + @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) + fun `screenshot optimization can change while a previous capture is pending`() { + for (initiallyOptimized in listOf(false, true)) { + val h = screenshotCaptureHarness() + h.fx.config.sessionReplayConfig.optimizeScreenshots = initiallyOptimized + RecordingShadowPixelCopy.reset() + RecordingShadowPixelCopy.defer = true + try { + assertFalse(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + val pendingBitmap = RecordingShadowPixelCopy.requests.single().bitmap + + h.fx.config.sessionReplayConfig.optimizeScreenshots = !initiallyOptimized + RecordingShadowPixelCopy.defer = false + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + val current = RecordingShadowPixelCopy.requests[1] + assertEquals(if (initiallyOptimized) 100 else 50, current.width) + assertFalse(pendingBitmap === current.bitmap) + + RecordingShadowPixelCopy.complete(0) + h.fx.config.sessionReplayConfig.optimizeScreenshots = initiallyOptimized + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + assertEquals(2, h.fake.captures) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + } + + @Test + @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) + fun `late screenshot callbacks release their bitmap after uninstall in both modes`() { + for (optimized in listOf(false, true)) { + val h = screenshotCaptureHarness() + h.fx.config.sessionReplayConfig.optimizeScreenshots = optimized + RecordingShadowPixelCopy.reset() + RecordingShadowPixelCopy.defer = true + try { + assertFalse(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + val bitmap = RecordingShadowPixelCopy.requests.single().bitmap + h.fx.sut.uninstall() + assertFalse(bitmap.isRecycled) + + RecordingShadowPixelCopy.complete(0) + assertTrue(bitmap.isRecycled) + assertEquals(0, h.fake.captures) + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + } + + private fun screenshotBitmap(fake: PostHogFake): Bitmap { + @Suppress("UNCHECKED_CAST") + val events = 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 bytes = Base64.decode(assertNotNull(wireframe.base64).substringAfter(','), Base64.DEFAULT) + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + } + + @Test + @Config(sdk = [28], shadows = [RecordingShadowPixelCopy::class]) + @GraphicsMode(GraphicsMode.Mode.NATIVE) + fun `encoded screenshots preserve transparency unless optimizations are enabled`() { + for (optimized in listOf(false, true)) { + val h = screenshotCaptureHarness() + h.fx.config.sessionReplayConfig.optimizeScreenshots = optimized + RecordingShadowPixelCopy.reset() + RecordingShadowPixelCopy.onRequest = { it.eraseColor(Color.TRANSPARENT) } + try { + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + val bitmap = screenshotBitmap(h.fake) + try { + assertEquals(if (optimized) 50 else 100, bitmap.width) + assertEquals(if (optimized) 255 else 0, Color.alpha(bitmap.getPixel(0, 0))) + } finally { + bitmap.recycle() + } + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + } + + @Test + @Config(sdk = [28], shadows = [RecordingShadowPixelCopy::class]) + @GraphicsMode(GraphicsMode.Mode.NATIVE) + fun `mask scaling uses the capture option even when it changes during PixelCopy`() { + for (optimized in listOf(false, true)) { + val h = screenshotCaptureHarness(enableMaskAlignmentVerification = false) + h.fx.config.sessionReplayConfig.optimizeScreenshots = optimized + h.hookLayout.layout(0, 0, 300, 300) + h.child.layout(40, 40, 140, 140) + val mask = Rect() + assertTrue(h.child.getGlobalVisibleRect(mask)) + RecordingShadowPixelCopy.reset() + RecordingShadowPixelCopy.onRequest = { + it.eraseColor(Color.RED) + h.fx.config.sessionReplayConfig.optimizeScreenshots = !optimized + } + try { + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + val bitmap = screenshotBitmap(h.fake) + try { + val scale = if (optimized) 2 else 1 + val y = mask.centerY() / scale + val maskedPixel = bitmap.getPixel((mask.left + 10) / scale, y) + // Lossy WebP can slightly perturb a solid black mask. + assertTrue(Color.red(maskedPixel) < 10 && Color.green(maskedPixel) < 10 && Color.blue(maskedPixel) < 10) + assertTrue(Color.red(bitmap.getPixel((mask.left - 10) / scale, y)) > 200) + assertTrue(Color.red(bitmap.getPixel((mask.right + 10) / scale, y)) > 200) + } finally { + bitmap.recycle() + } + } finally { + h.fx.sut.uninstall() + RecordingShadowPixelCopy.reset() + } + } + } + @Test @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) fun `screenshot capture reuses a half resolution RGB565 destination`() { val h = screenshotCaptureHarness() + h.fx.config.sessionReplayConfig.optimizeScreenshots = true RecordingShadowPixelCopy.reset() try { h.hookLayout.layout(0, 0, 101, 99) @@ -1765,10 +1955,38 @@ internal class PostHogReplayIntegrationTest { assertEquals(RectF(0f, 1f, 2f, 3f), scaled) } + @Test + @Config(sdk = [26], shadows = [DrawSequenceShadowPixelCopy::class]) + fun `screenshot capture discards non-positive source dimensions before masking`() { + for (optimized in listOf(false, true)) { + for ((width, height) in listOf(0 to 100, 100 to 0, -1 to 100, 100 to -1)) { + val h = screenshotCaptureHarness(enableMaskAlignmentVerification = false) + h.fx.config.sessionReplayConfig.optimizeScreenshots = optimized + try { + DrawSequenceShadowPixelCopy.onRequest = { + h.hookLayout.layout(0, 0, width, height) + } + + assertFalse(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + assertEquals(0, h.fake.captures) + + DrawSequenceShadowPixelCopy.onRequest = null + h.hookLayout.layout(0, 0, 100, 100) + assertTrue(h.fx.sut.generateSnapshot(WeakReference(h.hookLayout), WeakReference(h.window))) + assertEquals(1, h.fake.captures) + } finally { + DrawSequenceShadowPixelCopy.onRequest = null + h.fx.sut.uninstall() + } + } + } + } + @Test @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) fun `timed out PixelCopy lease is quarantined until its callback`() { val h = screenshotCaptureHarness() + h.fx.config.sessionReplayConfig.optimizeScreenshots = true RecordingShadowPixelCopy.reset() RecordingShadowPixelCopy.defer = true try { @@ -1795,6 +2013,7 @@ internal class PostHogReplayIntegrationTest { @Config(sdk = [26], shadows = [RecordingShadowPixelCopy::class]) fun `invalid RGB565 destination falls back once to ARGB8888`() { val h = screenshotCaptureHarness() + h.fx.config.sessionReplayConfig.optimizeScreenshots = true RecordingShadowPixelCopy.reset() try { RecordingShadowPixelCopy.result = PixelCopy.ERROR_DESTINATION_INVALID @@ -2483,12 +2702,17 @@ internal class PostHogReplayIntegrationTest { data class Request( val bitmap: Bitmap, val listener: PixelCopy.OnPixelCopyFinishedListener, - ) + ) { + val width = bitmap.width + val height = bitmap.height + val config = bitmap.config + } companion object { val requests = mutableListOf() var defer = false var result = PixelCopy.SUCCESS + var onRequest: ((Bitmap) -> Unit)? = null @JvmStatic @Implementation @@ -2499,6 +2723,7 @@ internal class PostHogReplayIntegrationTest { handler: Handler, ) { requests.add(Request(bitmap, listener)) + onRequest?.invoke(bitmap) if (!defer) { listener.onPixelCopyFinished(result) } @@ -2512,6 +2737,7 @@ internal class PostHogReplayIntegrationTest { requests.clear() defer = false result = PixelCopy.SUCCESS + onRequest = null } } }