From 2fba28d5eb2c57312b030390d2a0f5fffefbfe87 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:36:54 +0000 Subject: [PATCH] fix(replay): stop reserving an uncompressed frame per screenshot The Base64 encoder buffer in Bitmap.base64 was sized from the bitmap's allocationByteCount, so every capture reserved a second full uncompressed frame - about 10 MB on a 1080x2400 screen - for a payload that lands in the tens of kilobytes. At the default 1 second capture cadence that repeated allocation is what ran small heaps out of memory. The buffer is now sized from a compressed estimate, and the compressed bytes stream straight into the encoder instead of being held in an intermediate array. Generated-By: PostHog Desktop Task-Id: 26fba2a7-1ba9-42fe-927c-f9daa9f1d107 --- .changeset/replay-screenshot-base64-buffer.md | 5 ++ .../android/internal/PostHogAndroidUtils.kt | 20 +++-- .../android/internal/BitmapBase64Test.kt | 87 +++++++++++++++++++ 3 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 .changeset/replay-screenshot-base64-buffer.md create mode 100644 posthog-android/src/test/java/com/posthog/android/internal/BitmapBase64Test.kt diff --git a/.changeset/replay-screenshot-base64-buffer.md b/.changeset/replay-screenshot-base64-buffer.md new file mode 100644 index 000000000..2f7bda157 --- /dev/null +++ b/.changeset/replay-screenshot-base64-buffer.md @@ -0,0 +1,5 @@ +--- +'posthog-android': patch +--- + +Cut the heap a session replay screenshot needs. The Base64 encoder buffer was sized from the bitmap's `allocationByteCount`, so every capture reserved a second full uncompressed frame, about 10 MB on a 1080x2400 screen, for a payload that lands in the tens of kilobytes. The buffer is now sized from a compressed estimate, and the compressed bytes stream straight into the encoder instead of being held in an intermediate array. Devices with a small heap were running out of memory on this allocation at the default 1 second capture cadence. diff --git a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt index d092d17b2..fd065353f 100644 --- a/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt +++ b/posthog-android/src/main/java/com/posthog/android/internal/PostHogAndroidUtils.kt @@ -16,6 +16,7 @@ import android.os.Build import android.os.Process import android.telephony.TelephonyManager import android.util.Base64 +import android.util.Base64OutputStream import android.util.DisplayMetrics import android.view.WindowManager import com.posthog.PostHogInternal @@ -216,6 +217,14 @@ private fun webpLosslessFormat(): Bitmap.CompressFormat = Bitmap.CompressFormat.WEBP } +// Sizing this from allocationByteCount reserves a second uncompressed frame, about 10 MB on a +// 1080x2400 screen, on every capture. A lossy frame of a UI needs well under a sixteenth of a byte +// per pixel, and Base64 adds a third on top. +private fun Bitmap.base64BufferSize(): Int = + (width.toLong() * height.toLong() / 12) + .coerceAtLeast(4L * 1024) + .toInt() + @PostHogInternal @Suppress("DEPRECATION") public fun Bitmap.webpBase64(quality: Int = 30): String? { @@ -249,11 +258,12 @@ public fun Bitmap.base64( else -> "jpeg" } - ByteArrayOutputStream(allocationByteCount).use { + ByteArrayOutputStream(base64BufferSize()).use { out -> + out.write("data:image/$htmlFormat;base64,".toByteArray(Charsets.US_ASCII)) // we can make format and type configurable - compress(format, quality, it) - val byteArray = it.toByteArray() - val encoded = Base64.encodeToString(byteArray, Base64.DEFAULT) ?: return null - return "data:image/$htmlFormat;base64,$encoded" + Base64OutputStream(out, Base64.DEFAULT or Base64.NO_CLOSE).use { + compress(format, quality, it) + } + return out.toString("US-ASCII") } } diff --git a/posthog-android/src/test/java/com/posthog/android/internal/BitmapBase64Test.kt b/posthog-android/src/test/java/com/posthog/android/internal/BitmapBase64Test.kt new file mode 100644 index 000000000..d0767f98a --- /dev/null +++ b/posthog-android/src/test/java/com/posthog/android/internal/BitmapBase64Test.kt @@ -0,0 +1,87 @@ +package com.posthog.android.internal + +import android.graphics.Bitmap +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [28]) +internal class BitmapBase64Test { + private fun bitmap( + width: Int = 8, + height: Int = 8, + ): Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + + private fun expectedDataUri( + bitmap: Bitmap, + format: Bitmap.CompressFormat, + quality: Int, + htmlFormat: String, + ): String { + val out = ByteArrayOutputStream() + bitmap.compress(format, quality, out) + val encoded = Base64.encodeToString(out.toByteArray(), Base64.DEFAULT) + return "data:image/$htmlFormat;base64,$encoded" + } + + // The streaming encoder does not always close with a trailing newline, so both sides are + // trimmed. The wrapped newlines inside the payload still have to match. + private fun assertEncodesLike( + bitmap: Bitmap, + format: Bitmap.CompressFormat, + htmlFormat: String, + ) { + assertEquals( + expectedDataUri(bitmap, format, 30, htmlFormat).trimEnd('\n'), + bitmap.base64(format, 30)?.trimEnd('\n'), + ) + } + + @Test + fun `encodes the same data uri as encodeToString`() { + assertEncodesLike(bitmap(), Bitmap.CompressFormat.PNG, "png") + } + + @Test + fun `encodes the same data uri for a wrapped payload`() { + val bitmap = bitmap(width = 200, height = 200) + + assertTrue(bitmap.base64(Bitmap.CompressFormat.PNG, 30)?.trimEnd('\n')?.contains('\n') == true) + assertEncodesLike(bitmap, Bitmap.CompressFormat.PNG, "png") + } + + @Test + fun `payload decodes back to the compressed bytes`() { + val bitmap = bitmap() + val compressed = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 30, compressed) + + val payload = bitmap.base64(Bitmap.CompressFormat.PNG, 30)?.substringAfter("base64,") + + assertContentEquals(compressed.toByteArray(), Base64.decode(payload, Base64.DEFAULT)) + } + + @Test + fun `webp base64 uses the webp media type`() { + val encoded = bitmap().webpBase64() + + assertTrue(encoded?.startsWith("data:image/webp;base64,") == true) + } + + @Test + fun `returns null for a recycled bitmap`() { + val bitmap = bitmap() + bitmap.recycle() + + assertNull(bitmap.base64()) + assertNull(bitmap.webpBase64()) + } +}