Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/brave-lions-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"posthog-android": minor
---

Session replay screenshots now default to one pixel per dp (about 1/9 of the pixels on a 3x screen, so smaller uploads), using a filtered downscale that keeps small text readable. Set `screenshotScale = 1f` to keep native resolution.

**Breaking change to the experimental `screenshotScale`:** its type changes from `Float` to `Float?`, and `null` (the new default) means one pixel per dp. Explicit values keep their meaning, and assigning one (`screenshotScale = 0.5f`) still compiles. Code compiled against 3.63.x must be rebuilt. Kotlin code that reads it into a `Float` no longer compiles, and Java code that reads it into a `float` throws `NullPointerException` while it is unset.
4 changes: 2 additions & 2 deletions posthog-android/api/posthog-android.api
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig {
public final fun getScreenshot ()Z
public final fun getScreenshotColorMode ()Lcom/posthog/android/replay/PostHogScreenshotColorMode;
public final fun getScreenshotCompressionQuality ()I
public final fun getScreenshotScale ()F
public final fun getScreenshotScale ()Ljava/lang/Float;
public final fun getThrottleDelayMs ()J
public final fun getVerifyScreenshotMaskAlignment ()Z
public final fun setCaptureLogcat (Z)V
Expand All @@ -156,7 +156,7 @@ public final class com/posthog/android/replay/PostHogSessionReplayConfig {
public final fun setScreenshot (Z)V
public final fun setScreenshotColorMode (Lcom/posthog/android/replay/PostHogScreenshotColorMode;)V
public final fun setScreenshotCompressionQuality (I)V
public final fun setScreenshotScale (F)V
public final fun setScreenshotScale (Ljava/lang/Float;)V
public final fun setThrottleDelayMs (J)V
public final fun setVerifyScreenshotMaskAlignment (Z)V
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,12 @@ 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.downscaledWebpBase64
import com.posthog.android.replay.internal.isAlive
import com.posthog.android.replay.internal.isAliveAndAttachedToWindow
import com.posthog.android.replay.internal.resolveScreenshotScale
import com.posthog.android.replay.internal.scaledScreenshotDimension
import com.posthog.android.replay.internal.twoStageCopySize
import com.posthog.internal.PostHogSessionManager
import com.posthog.internal.PostHogThreadFactory
import com.posthog.internal.replay.PostHogSessionReplayHandler
Expand Down Expand Up @@ -1758,11 +1762,6 @@ public class PostHogReplayIntegration(
}
}

private fun scaledScreenshotDimension(
size: Int,
scale: Float,
): Int = maxOf(1, ceil(size * scale).toInt())

private class ScreenshotGeometry(
val x: Int,
val y: Int,
Expand Down Expand Up @@ -1802,7 +1801,11 @@ public class PostHogReplayIntegration(
val sourceHeight = geometry.height
val width = sourceWidth.densityValue(screenDensity)
val height = sourceHeight.densityValue(screenDensity)
val screenshotScale = config.sessionReplayConfig.screenshotScale
val screenshotScale = resolveScreenshotScale(config.sessionReplayConfig.screenshotScale, screenDensity)
val targetWidth = scaledScreenshotDimension(sourceWidth, screenshotScale)
val targetHeight = scaledScreenshotDimension(sourceHeight, screenshotScale)
val copySize = twoStageCopySize(sourceWidth, sourceHeight, targetWidth, targetHeight)
val (copyWidth, copyHeight) = copySize ?: (targetWidth to targetHeight)
val compressionQuality = config.sessionReplayConfig.screenshotCompressionQuality
val bitmapConfig =
when (config.sessionReplayConfig.screenshotColorMode) {
Expand Down Expand Up @@ -1839,8 +1842,8 @@ public class PostHogReplayIntegration(
handler = ensurePixelCopyHandler()
bitmapLease =
pixelCopyBitmapBuffer.acquire(
scaledScreenshotDimension(sourceWidth, screenshotScale),
scaledScreenshotDimension(sourceHeight, screenshotScale),
copyWidth,
copyHeight,
bitmapConfig,
) ?: run {
finishScreenshotCapture(drawState, armedCapture, verifyMaskAlignment)
Expand Down Expand Up @@ -1934,7 +1937,12 @@ public class PostHogReplayIntegration(
releaseFromWaiter = true
if (requestState.succeeded()) {
try {
base64 = bitmap.webpBase64(compressionQuality)
base64 =
if (copySize != null) {
bitmap.downscaledWebpBase64(targetWidth, targetHeight, compressionQuality)
} else {
bitmap.webpBase64(compressionQuality)
}
} catch (e: Throwable) {
config.logger.log("Session Replay screenshot encoding failed: $e.")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.posthog.android.replay

import com.posthog.PostHogExperimental
import com.posthog.android.replay.internal.MAX_SCREENSHOT_SCALE
import com.posthog.android.replay.internal.MIN_SCREENSHOT_SCALE

/**
* Android session replay capture options.
Expand Down Expand Up @@ -96,16 +98,17 @@ public class PostHogSessionReplayConfig

/**
* Multiplier for the physical width and height of screenshot captures, clamped to 0.1..1.0.
* For example, 0.5 captures half the width and height, or one quarter of the pixels.
* `null` (default) captures one pixel per dp (1/density), so screens with a density of 1 or
* less capture at full resolution. For example, 0.5 captures half the width and height.
* Dimensions are rounded up to at least one pixel; the logical replay viewport is unchanged.
* Defaults to 1.0 (full resolution). NaN and infinite values reset the scale to 1.0.
* NaN and infinite values reset the scale to `null`.
* Does not change compression quality, color mode, or enable screenshot capture.
*/
@PostHogExperimental
@Volatile
public var screenshotScale: Float = 1f
public var screenshotScale: Float? = null
set(value) {
field = if (value.isFinite()) value.coerceIn(0.1f, 1f) else 1f
field = value?.takeIf { it.isFinite() }?.coerceIn(MIN_SCREENSHOT_SCALE, MAX_SCREENSHOT_SCALE)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.posthog.android.replay.internal

import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import com.posthog.android.internal.webpBase64
import kotlin.math.ceil

internal const val MIN_SCREENSHOT_SCALE = 0.1f
internal const val MAX_SCREENSHOT_SCALE = 1f

internal fun resolveScreenshotScale(
configured: Float?,
density: Float,
): Float {
val scale = configured ?: if (density.isFinite() && density > 0f) 1f / density else 1f
return scale.coerceIn(MIN_SCREENSHOT_SCALE, MAX_SCREENSHOT_SCALE)
}

internal fun scaledScreenshotDimension(
size: Int,
scale: Float,
): Int {
// The tolerance keeps Float error, such as 350 * (1 / 3.5f) = 100.0000076, from adding a pixel.
return maxOf(1, ceil(size * scale - 0.001f).toInt())
}

/**
* PixelCopy resamples with a single bilinear tap, which skips source pixels above 2:1.
* Returns the PixelCopy destination size when a second filtered downscale is needed, else null.
*/
internal fun twoStageCopySize(
sourceWidth: Int,
sourceHeight: Int,
targetWidth: Int,
targetHeight: Int,
): Pair<Int, Int>? {
if (sourceWidth <= 2 * targetWidth && sourceHeight <= 2 * targetHeight) {
return null
}
return minOf(sourceWidth, 2 * targetWidth) to minOf(sourceHeight, 2 * targetHeight)
}

internal fun Bitmap.downscaleFiltered(
targetWidth: Int,
targetHeight: Int,
): Bitmap {
val target = Bitmap.createBitmap(targetWidth, targetHeight, config ?: Bitmap.Config.ARGB_8888)

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.

This allocates a new target bitmap every frame (about 1.4 MiB at 402x874 ARGB). #756 pooled the PixelCopy buffer to avoid per-frame allocations, so I think it might be worth pooling the 1x target too. Fine as a follow-up.

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.

πŸ€– An AI agent wrote this on behalf of @ioannisj.

Yeah, left it unpooled on purpose. Pooling it means a second slot in PixelCopyBitmapBuffer's lease logic, and I'd rather not widen that in this PR. It only allocates on the two-stage path (copy ratio above 2:1), at most once per throttle tick, so I'll pick it up as a follow-up.

try {
Canvas(target).drawBitmap(
this,
Rect(0, 0, width, height),
Rect(0, 0, targetWidth, targetHeight),
Paint(Paint.FILTER_BITMAP_FLAG),
)
return target
} catch (e: Throwable) {
target.recycle()
throw e
}
}

internal fun Bitmap.downscaledWebpBase64(
targetWidth: Int,
targetHeight: Int,
quality: Int,
): String? {
val downscaled = downscaleFiltered(targetWidth, targetHeight)
try {
return downscaled.webpBase64(quality)
} finally {
downscaled.recycle()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.posthog.android.replay.PostHogScreenshotColorMode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue

internal class PostHogAndroidConfigTest {
Expand Down Expand Up @@ -51,7 +52,7 @@ internal class PostHogAndroidConfigTest {
@Test
fun `screenshot settings preserve the default capture fidelity`() {
assertEquals(30, config.sessionReplayConfig.screenshotCompressionQuality)
assertEquals(1f, config.sessionReplayConfig.screenshotScale)
assertNull(config.sessionReplayConfig.screenshotScale)
assertEquals(PostHogScreenshotColorMode.ARGB_8888, config.sessionReplayConfig.screenshotColorMode)
assertFalse(config.sessionReplayConfig.screenshot)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3184,6 +3184,155 @@ internal class PostHogReplayIntegrationTest {
assertEquals(RectF(0f, 1f, 2f, 3f), scaled)
}

private fun captureAndDecode(h: ScreenshotCaptureHarness): Bitmap {
assertTrue(h.fx.sut.captureSnapshot(WeakReference(h.hookLayout), WeakReference(h.window)))
@Suppress("UNCHECKED_CAST")
val events = h.fake.properties?.get("\$snapshot_data") as List<RREvent>
val full = events.first { it.type == RREventType.FullSnapshot }
val wireframe = ((full.data as Map<*, *>)["wireframes"] as List<*>).single() as RRWireframe
val bytes = Base64.decode(assertNotNull(wireframe.base64).substringAfter(','), Base64.DEFAULT)
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}

@Test
@Config(sdk = [28], qualifiers = "xxhdpi", shadows = [RecordingShadowPixelCopy::class])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
fun `default scale captures one pixel per dp through a 2x intermediate on a density 3 screen`() {
val h = screenshotCaptureHarness()
h.hookLayout.layout(0, 0, 301, 299)
RecordingShadowPixelCopy.reset()
try {
val frame = captureAndDecode(h)
try {
val request = RecordingShadowPixelCopy.requests.single()
assertEquals(202, request.width)
assertEquals(200, request.height)
assertEquals(ceil(301 / 3f).toInt(), frame.width)
assertEquals(ceil(299 / 3f).toInt(), frame.height)
} finally {
frame.recycle()
}
} finally {
h.fx.sut.uninstall()
RecordingShadowPixelCopy.reset()
}
}

@Test
@Config(sdk = [28], qualifiers = "xxhdpi", shadows = [RecordingShadowPixelCopy::class])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
fun `explicit scale keeps its meaning and copies straight into the target at a 2 to 1 ratio`() {
val h = screenshotCaptureHarness()
h.fx.config.sessionReplayConfig.screenshotScale = 0.5f
h.hookLayout.layout(0, 0, 301, 299)
RecordingShadowPixelCopy.reset()
try {
val frame = captureAndDecode(h)
try {
val request = RecordingShadowPixelCopy.requests.single()
assertEquals(151, request.width)
assertEquals(150, request.height)
assertEquals(151, frame.width)
assertEquals(150, frame.height)
} finally {
frame.recycle()
}
} finally {
h.fx.sut.uninstall()
RecordingShadowPixelCopy.reset()
}
}

@Test
@Config(sdk = [28], qualifiers = "xhdpi", shadows = [RecordingShadowPixelCopy::class])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
fun `default scale on a density 2 screen copies straight into the target`() {
val h = screenshotCaptureHarness()
h.hookLayout.layout(0, 0, 101, 99)
RecordingShadowPixelCopy.reset()
try {
val frame = captureAndDecode(h)
try {
val request = RecordingShadowPixelCopy.requests.single()
assertEquals(51, request.width)
assertEquals(50, request.height)
assertEquals(51, frame.width)
assertEquals(50, frame.height)
} finally {
frame.recycle()
}
} finally {
h.fx.sut.uninstall()
RecordingShadowPixelCopy.reset()
}
}

@Test
@Config(sdk = [28], qualifiers = "mdpi", shadows = [RecordingShadowPixelCopy::class])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
fun `default scale captures full resolution at density 1`() {
val h = screenshotCaptureHarness()
h.hookLayout.layout(0, 0, 101, 99)
RecordingShadowPixelCopy.reset()
try {
val frame = captureAndDecode(h)
try {
val request = RecordingShadowPixelCopy.requests.single()
assertEquals(101, request.width)
assertEquals(99, request.height)
assertEquals(101, frame.width)
assertEquals(99, frame.height)
} finally {
frame.recycle()
}
} finally {
h.fx.sut.uninstall()
RecordingShadowPixelCopy.reset()
}
}

@Test
@Config(sdk = [28], qualifiers = "xxhdpi", shadows = [RecordingShadowPixelCopy::class])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
fun `masks still cover the masked view after the filtered downscale`() {
val h = screenshotCaptureHarness()
h.fx.config.sessionReplayConfig.screenshotCompressionQuality = 100
h.hookLayout.layout(0, 0, 300, 300)
// Edges off the 3 px grid: the 2x copy maps x to 40.67 and 119.33, rounded out to 40 and 120,
// so frame columns 20 until 60 must be fully masked.
h.child.layout(61, 61, 179, 179)
val mask = Rect()
assertTrue(h.child.getGlobalVisibleRect(mask))
assertEquals(61, mask.left)
assertEquals(179, mask.right)
RecordingShadowPixelCopy.reset()
// White, not a colour: WebP stores chroma at half resolution, which would tint the mask edge.
RecordingShadowPixelCopy.onRequest = { it.eraseColor(Color.WHITE) }
try {
val frame = captureAndDecode(h)
try {
assertEquals(100, frame.width)
assertEquals(200, RecordingShadowPixelCopy.requests.single().width)
// Masks have 10 px rounded corners, so skip rows that reach them.
val rows = ceil(mask.top / 3f).toInt() + 4 until minOf(mask.bottom / 3 - 4, frame.height)
for (y in rows) {
for (x in 20 until 60) {
val pixel = frame.getPixel(x, y)
assertTrue(Color.green(pixel) < 32, "pixel ($x, $y) shows the masked view: ${Integer.toHexString(pixel)}")
}
}
for (column in listOf(19, 60)) {
assertTrue(Color.green(frame.getPixel(column, mask.centerY() / 3)) > 200, "column $column should stay unmasked")
}
} finally {
frame.recycle()
}
} finally {
h.fx.sut.uninstall()
RecordingShadowPixelCopy.reset()
}
}

@RunWith(ParameterizedRobolectricTestRunner::class)
class ScreenshotDimensionsTest(private val scale: Float, private val width: Int, private val height: Int) {
private val fixture = PostHogReplayIntegrationTest()
Expand Down
Loading
Loading