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
5 changes: 5 additions & 0 deletions .changeset/optimize-replay-pixel-copy.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 ->
Expand Down Expand Up @@ -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.")
}
}
}

Expand Down Expand Up @@ -1486,7 +1495,25 @@ public class PostHogReplayIntegration(
}
}

private fun Bitmap.paintScreenshotMasks(rects: List<Rect>): Boolean {
internal fun RectF.setScaledScreenshotMask(

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.

[nit] internal here is only for the unit test — this could be private and covered through paintScreenshotMasks.

rect: Rect,
scaleX: Float,
scaleY: Float,
) {
set(
floor(rect.left * scaleX).toFloat(),

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.

[nit] floor/ceil on a Float already return Float, so these four .toFloat() calls are no-ops.

floor(rect.top * scaleY).toFloat(),
ceil(rect.right * scaleX).toFloat(),
ceil(rect.bottom * scaleY).toFloat(),
)
}

private fun Bitmap.paintScreenshotMasks(
rects: List<Rect>,
sourceWidth: Int,
sourceHeight: Int,
canPaintMask: () -> Boolean = { true },
): Boolean {
if (!isValid()) {
this@PostHogReplayIntegration.config.logger.log("Session Replay Bitmap is invalid.")
return false
Expand All @@ -1500,10 +1527,15 @@ public class PostHogReplayIntegration(
return false
}

val scaleX = width.toFloat() / sourceWidth

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.

[suggestion] isVisible() rules out a zero-width view at capture time, but masking runs later on the callback thread — if the view has been relaid out to 0 by then, scale is Infinity and the resulting NaN rects get silently skipped by drawRoundRect, which fails open on a masking path. A guard closes it:

Suggested change
val scaleX = width.toFloat() / sourceWidth
if (sourceWidth <= 0 || sourceHeight <= 0) 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
}
Expand Down Expand Up @@ -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(
Expand All @@ -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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

currently we always downscale to half the resolution. in my testing, i didn't see a noticeable difference in quality. open to feedback on whether or not this should be configurable.

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.

[question] posthog-ios captures at native screen scale with no downscale (UIView+Util.swift), so after this Android replays render at roughly half the effective density of iOS ones on comparable devices. Reads like a deliberate trade to me — worth a line in the changeset or the replay docs so it doesn't come back later as an Android bug report?

}

// PixelCopy is only API >= 24 but this is already protected by the isSupported method
Expand Down Expand Up @@ -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.")

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.

[question] With a single lease, a timeout also drops every capture until the late callback lands — on main the next capture just allocated fresh and went ahead. Good trade for killing the 9.89 MiB-per-timeout leak, but on a loaded device it turns one dropped frame into several. Was a two-slot pool considered?

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 &&

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.

[question] This is the only escape hatch, and RGB_565 readback is verified on one emulator — if a device rejects it with a different code, or accepts it and returns wrong colours, there's no recovery and no way for a user to opt out. Worth widening this to any repeated non-SUCCESS, or gating the format behind a config flag for a release?

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()
}
}

Expand Down Expand Up @@ -2264,6 +2366,7 @@ public class PostHogReplayIntegration(
val currentSessionId = postHog?.getSessionId()?.toString()
resetSessionStateIfNeeded(currentSessionId, force = !resumeCurrent)

pixelCopyBitmapBuffer.open()
startedWithAutomaticDisabled = !config.sessionReplay
isSessionReplayActive = true

Expand Down Expand Up @@ -2295,6 +2398,7 @@ public class PostHogReplayIntegration(

private fun stopRecording() {
isSessionReplayActive = false
pixelCopyBitmapBuffer.close()
synchronized(decorViews) {
decorViews.values.forEach { it.drawState.invalidateMaskCapture() }
}
Expand Down Expand Up @@ -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)
}
Expand Down
Loading