Skip to content
Open
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
4 changes: 2 additions & 2 deletions .changeset/optimize-replay-pixel-copy.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1501,23 +1501,27 @@ 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),
)
}

private fun Bitmap.paintScreenshotMasks(
rects: List<Rect>,
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 {
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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()) {
Expand All @@ -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.")
Expand Down Expand Up @@ -1682,28 +1692,43 @@ 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low: Unbounded bitmap retention after PixelCopy timeouts

When optimizations are disabled, every capture allocates a new full-resolution bitmap. A timed-out request retains that bitmap until its callback arrives, but unlike the lease-backed path, it does not prevent subsequent captures from allocating more; repeated UI redraws during stalled callbacks can therefore exhaust the host app's memory. Keep full-resolution ARGB_8888 capture while applying equivalent single-request backpressure or another explicit bound to outstanding bitmaps.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Pending bitmaps can leak

If a default-mode PixelCopy times out, its fresh full-resolution bitmap is left for the callback to recycle. If uninstall() quits the callback thread before that callback is delivered, the bitmap is not recycled and is not tracked by pixelCopyBitmapBuffer.close(). Multiple delayed captures can therefore retain large ARGB_8888 bitmaps without deterministic cleanup. Please retain ownership of pending default-mode bitmaps so uninstall can release them explicitly.

Knowledge Base Used: Android session replay

Prompt To Fix With AI
This is a comment left during a code review.
Path: posthog-android/src/main/java/com/posthog/android/replay/PostHogReplayIntegration.kt
Line: 1715

Comment:
**Pending bitmaps can leak**

If a default-mode PixelCopy times out, its fresh full-resolution bitmap is left for the callback to recycle. If `uninstall()` quits the callback thread before that callback is delivered, the bitmap is not recycled and is not tracked by `pixelCopyBitmapBuffer.close()`. Multiple delayed captures can therefore retain large ARGB_8888 bitmaps without deterministic cleanup. Please retain ownership of pending default-mode bitmaps so uninstall can release them explicitly.

**Knowledge Base Used:** [Android session replay](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog-android/-/docs/android-session-replay.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}
} catch (e: Throwable) {
finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment)
config.logger.log("Session Replay screenshot setup failed: $e.")
recordScreenshotDiscarded(drawState)
return null
}

val bitmap = bitmapLease.bitmap
fun releaseBitmap() {
if (bitmapLease != null) {
bitmapLease.release()
} else {
bitmap.recycle()
}
}

val latch = CountDownLatch(1)
val requestState = PixelCopyRequestState()

Expand All @@ -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) {
Expand All @@ -1739,7 +1764,7 @@ public class PostHogReplayIntegration(
val releaseInCallback = requestState.complete(succeeded)
try {
if (releaseInCallback) {
bitmapLease.release()
releaseBitmap()
}
} finally {
latch.countDown()
Expand All @@ -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()
}
Expand Down Expand Up @@ -1792,7 +1817,7 @@ public class PostHogReplayIntegration(
} finally {
finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment)
if (releaseFromWaiter) {
bitmapLease.release()
releaseBitmap()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading