diff --git a/.changeset/brave-lions-dance.md b/.changeset/brave-lions-dance.md new file mode 100644 index 000000000..477bee8cc --- /dev/null +++ b/.changeset/brave-lions-dance.md @@ -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. diff --git a/posthog-android/api/posthog-android.api b/posthog-android/api/posthog-android.api index 71c9f8c4a..69b0641b7 100644 --- a/posthog-android/api/posthog-android.api +++ b/posthog-android/api/posthog-android.api @@ -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 @@ -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 } 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 daec3775b..ef866c8fb 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 @@ -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 @@ -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, @@ -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) { @@ -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) @@ -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.") } 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 bc3c492a8..6ae399ba7 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 @@ -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. @@ -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) } /** diff --git a/posthog-android/src/main/java/com/posthog/android/replay/internal/ScreenshotScaling.kt b/posthog-android/src/main/java/com/posthog/android/replay/internal/ScreenshotScaling.kt new file mode 100644 index 000000000..f7a3c9c2d --- /dev/null +++ b/posthog-android/src/main/java/com/posthog/android/replay/internal/ScreenshotScaling.kt @@ -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? { + 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) + 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() + } +} 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 23d74cd34..b1736be0d 100644 --- a/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/PostHogAndroidConfigTest.kt @@ -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 { @@ -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) } 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 fcf0b0a72..a5faefb9b 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 @@ -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 + 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() diff --git a/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt index a5d7f35f7..c98a3cf3d 100644 --- a/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt +++ b/posthog-android/src/test/java/com/posthog/android/replay/PostHogSessionReplayConfigTest.kt @@ -5,6 +5,7 @@ import org.junit.runners.Parameterized import org.junit.runners.Parameterized.Parameters import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull internal class PostHogSessionReplayConfigTest { @Test @@ -16,11 +17,11 @@ internal class PostHogSessionReplayConfigTest { } @RunWith(Parameterized::class) - class ScreenshotScaleTest(private val input: Float, private val expected: Float) { + class ScreenshotScaleTest(private val input: Float, private val expected: Float?) { companion object { @JvmStatic @Parameters(name = "scale={0}, effective={1}") - fun scales(): List> = + fun scales(): List> = listOf( arrayOf(-Float.MAX_VALUE, 0.1f), arrayOf(-1f, 0.1f), @@ -33,9 +34,9 @@ internal class PostHogSessionReplayConfigTest { arrayOf(1f, 1f), arrayOf(2f, 1f), arrayOf(Float.MAX_VALUE, 1f), - arrayOf(Float.NaN, 1f), - arrayOf(Float.NEGATIVE_INFINITY, 1f), - arrayOf(Float.POSITIVE_INFINITY, 1f), + arrayOf(Float.NaN, null), + arrayOf(Float.NEGATIVE_INFINITY, null), + arrayOf(Float.POSITIVE_INFINITY, null), ) } @@ -51,6 +52,22 @@ internal class PostHogSessionReplayConfigTest { } } + class ScreenshotScaleDefaultTest { + @Test + fun `scale defaults to null`() { + assertNull(PostHogSessionReplayConfig().screenshotScale) + } + + @Test + fun `scale accepts null after an explicit value`() { + val config = PostHogSessionReplayConfig() + config.screenshotScale = 0.5f + config.screenshotScale = null + + assertNull(config.screenshotScale) + } + } + @RunWith(Parameterized::class) class ScreenshotCompressionQualityTest(private val input: Int, private val expected: Int) { companion object { @@ -74,7 +91,7 @@ internal class PostHogSessionReplayConfigTest { config.screenshotCompressionQuality = input assertEquals(expected, config.screenshotCompressionQuality) - assertEquals(1f, config.screenshotScale) + assertNull(config.screenshotScale) assertEquals(PostHogScreenshotColorMode.ARGB_8888, config.screenshotColorMode) } } diff --git a/posthog-android/src/test/java/com/posthog/android/replay/internal/ScreenshotScalingTest.kt b/posthog-android/src/test/java/com/posthog/android/replay/internal/ScreenshotScalingTest.kt new file mode 100644 index 000000000..b07f9bb6b --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/replay/internal/ScreenshotScalingTest.kt @@ -0,0 +1,99 @@ +package com.posthog.android.replay.internal + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [28]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +internal class ScreenshotScalingTest { + @Test + fun `null scale resolves to one pixel per dp`() { + assertEquals(1f / 3f, resolveScreenshotScale(null, 3f)) + assertEquals(0.5f, resolveScreenshotScale(null, 2f)) + } + + @Test + fun `null scale captures full resolution at density of one or less`() { + assertEquals(1f, resolveScreenshotScale(null, 1f)) + assertEquals(1f, resolveScreenshotScale(null, 0.75f)) + } + + @Test + fun `null scale is clamped for very high densities`() { + assertEquals(0.1f, resolveScreenshotScale(null, 20f)) + } + + @Test + fun `invalid density resolves to full resolution`() { + assertEquals(1f, resolveScreenshotScale(null, 0f)) + assertEquals(1f, resolveScreenshotScale(null, -2f)) + assertEquals(1f, resolveScreenshotScale(null, Float.NaN)) + assertEquals(1f, resolveScreenshotScale(null, Float.POSITIVE_INFINITY)) + } + + @Test + fun `explicit scale ignores density`() { + assertEquals(0.5f, resolveScreenshotScale(0.5f, 3f)) + assertEquals(1f, resolveScreenshotScale(1f, 3f)) + } + + @Test + fun `dimensions round up to at least one pixel`() { + assertEquals(101, scaledScreenshotDimension(301, 1f / 3f)) + assertEquals(1, scaledScreenshotDimension(1, 0.1f)) + } + + @Test + fun `float error does not add a pixel to an exact dp size`() { + val scale = resolveScreenshotScale(null, 3.5f) + assertEquals(100, scaledScreenshotDimension(350, scale)) + assertEquals(400, scaledScreenshotDimension(1400, scale)) + assertEquals(101, scaledScreenshotDimension(351, scale)) + } + + @Test + fun `copy goes straight to the target up to a 2 to 1 ratio`() { + assertNull(twoStageCopySize(200, 100, 100, 50)) + assertNull(twoStageCopySize(100, 100, 100, 100)) + } + + @Test + fun `copy uses a 2x intermediate above a 2 to 1 ratio on either axis`() { + assertEquals(200 to 200, twoStageCopySize(300, 300, 100, 100)) + assertEquals(200 to 100, twoStageCopySize(300, 100, 100, 100)) + assertEquals(100 to 200, twoStageCopySize(100, 300, 100, 100)) + } + + @Test + fun `filtered downscale keeps one pixel lines at every phase`() { + for (phase in 0 until 6) { + val source = Bitmap.createBitmap(60, 60, Bitmap.Config.ARGB_8888) + source.eraseColor(Color.WHITE) + for (y in 0 until 60) { + for (x in phase until 60 step 6) { + source.setPixel(x, y, Color.BLACK) + } + } + + val target = source.downscaleFiltered(30, 30) + try { + assertEquals(30, target.width) + assertEquals(30, target.height) + val darkest = (0 until 30).minOf { Color.red(target.getPixel(it, 15)) } + assertTrue(darkest < 200, "line at phase $phase vanished, darkest=$darkest") + } finally { + source.recycle() + target.recycle() + } + } + } +}