Skip to content
Draft
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/replay-screenshot-base64-buffer.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}