From 69341aff9ca407e73df81e3413929715a7ce20af Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 17 Jul 2026 10:38:32 -0400 Subject: [PATCH 1/5] fix(android): encode replay screenshots off the main thread Each replay frame ran BitmapFactory.decodeByteArray plus a JPEG re-encode and base64 synchronously in the method-channel handler on the platform main thread - 25-75ms per frame on midrange hardware, up to once per second while recording. iOS routes the identical work through a background serial queue. Meta and frame sends share one single-threaded executor so a meta event can never be overtaken by the frame it describes; submissions after engine detach are dropped. --- .changeset/android-replay-encode-off-main.md | 5 ++++ .../posthog/flutter/PosthogFlutterPlugin.kt | 27 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 .changeset/android-replay-encode-off-main.md diff --git a/.changeset/android-replay-encode-off-main.md b/.changeset/android-replay-encode-off-main.md new file mode 100644 index 00000000..588e804a --- /dev/null +++ b/.changeset/android-replay-encode-off-main.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Android: session replay screenshots are decoded, re-encoded, and queued off the main thread. Previously each frame cost the main thread 25-75ms on midrange hardware (up to once per second while recording), a visible per-second hitch during scrolling — iOS already did this work on a background queue. diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 3cac8811..b4c017db 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -126,6 +126,12 @@ class PosthogFlutterPlugin : private val mainHandler = Handler(Looper.getMainLooper()) private val bitmapExportExecutor = Executors.newSingleThreadExecutor() + // Replay frames are decoded and re-encoded before sending — 25-75ms on + // midrange hardware, too slow for the main thread (iOS moved the same + // work off-main for the same reason). Single-threaded so a meta event + // can never be overtaken by the frame it describes. + private val snapshotSendExecutor = Executors.newSingleThreadExecutor() + // The native SDK stamps its replay events (touches, bridged frames) with // config.dateProvider, which prefers the network-time clock on API 33+ and // can diverge from System.currentTimeMillis. Replay is ordered by @@ -428,13 +434,29 @@ class PosthogFlutterPlugin : return } - snapshotSender.sendMetaEvent(width, height, screen) + submitSnapshotWork { snapshotSender.sendMetaEvent(width, height, screen) } result.success(null) } catch (e: Throwable) { result.error("PosthogFlutterException", e.localizedMessage, null) } } + // Rejection only happens after engine detach; the frame is not + // recoverable then, so it is dropped rather than thrown. + private fun submitSnapshotWork(work: () -> Unit) { + try { + snapshotSendExecutor.execute { + try { + work() + } catch (e: Throwable) { + Log.w("PostHog", "Replay snapshot send failed: $e") + } + } + } catch (e: RejectedExecutionException) { + Log.w("PostHog", "Replay snapshot dropped, executor is shut down: $e") + } + } + private fun setup( call: MethodCall, result: Result, @@ -681,6 +703,7 @@ class PosthogFlutterPlugin : stopOcclusionDetector() channel.setMethodCallHandler(null) bitmapExportExecutor.shutdown() + snapshotSendExecutor.shutdown() } private var cachedReplayIntegration: PostHogReplayIntegration? = null @@ -970,7 +993,7 @@ class PosthogFlutterPlugin : val x = call.argument("x") ?: 0 val y = call.argument("y") ?: 0 if (imageBytes != null) { - snapshotSender.sendFullSnapshot(imageBytes, id, x, y) + submitSnapshotWork { snapshotSender.sendFullSnapshot(imageBytes, id, x, y) } result.success(null) } else { result.error("INVALID_ARGUMENT", "Image bytes are null", null) From 0abaaf4825a37db0f607f1c08ee6709ed91d6b9d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 17 Jul 2026 10:50:31 -0400 Subject: [PATCH 2/5] test: cover the snapshot executor contract; trim the executor comment Covers success-before-work and the post-detach drop (a rejected submission must never escape to the channel). --- .../posthog/flutter/PosthogFlutterPlugin.kt | 7 +++--- .../flutter/PosthogFlutterPluginTest.kt | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index b4c017db..3731e780 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -126,10 +126,9 @@ class PosthogFlutterPlugin : private val mainHandler = Handler(Looper.getMainLooper()) private val bitmapExportExecutor = Executors.newSingleThreadExecutor() - // Replay frames are decoded and re-encoded before sending — 25-75ms on - // midrange hardware, too slow for the main thread (iOS moved the same - // work off-main for the same reason). Single-threaded so a meta event - // can never be overtaken by the frame it describes. + // Frame sends decode and re-encode the image — too slow for the main + // thread. Single-threaded so a meta event can never be overtaken by the + // frame it describes. private val snapshotSendExecutor = Executors.newSingleThreadExecutor() // The native SDK stamps its replay events (touches, bridged frames) with diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index 4b6f1469..35fb7cd7 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -1,7 +1,10 @@ package com.posthog.flutter import android.app.Activity +import android.content.Context +import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import org.mockito.Mockito @@ -46,6 +49,27 @@ internal class PosthogFlutterPluginTest { Mockito.verify(mockResult).success(null) } + @Test + fun onMethodCall_sendMetaEvent_succeedsBeforeAndAfterEngineDetach() { + val plugin = PosthogFlutterPlugin() + val binding = Mockito.mock(FlutterPlugin.FlutterPluginBinding::class.java) + Mockito.`when`(binding.applicationContext).thenReturn(Mockito.mock(Context::class.java)) + Mockito.`when`(binding.binaryMessenger).thenReturn(Mockito.mock(BinaryMessenger::class.java)) + plugin.onAttachedToEngine(binding) + + val call = MethodCall("sendMetaEvent", mapOf("width" to 10, "height" to 20, "screen" to "Home")) + val whileAttached: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, whileAttached) + Mockito.verify(whileAttached).success(null) + + plugin.onDetachedFromEngine(binding) + + // The executor is shut down: the submission is dropped, never thrown. + val afterDetach: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, afterDetach) + Mockito.verify(afterDetach).success(null) + } + @Test fun onMethodCall_setCaptureNativeScreens_returnsSuccess() { val plugin = PosthogFlutterPlugin() From a11ae47da4a7925ee91d84f02912a4d411c299e1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 17 Jul 2026 18:32:13 -0400 Subject: [PATCH 3/5] =?UTF-8?q?fix(android):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20reply=20after=20encode,=20stamp=20at=20enqueue,=20r?= =?UTF-8?q?evive=20executors=20on=20reattach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Complete the channel reply after the worker finishes (posted via the main handler): Dart's await then spans the encode, so a throttleDelay below the encode time self-regulates instead of growing the queue of full-screen buffers without bound. - Capture the SDK-clock timestamp in the handler and pass it into SnapshotSender, matching iOS and capping skew under any backlog. - Recreate shut-down executors on engine reattach so a reused plugin instance does not silently drop snapshots. --- .../posthog/flutter/PosthogFlutterPlugin.kt | 57 ++++++++++++++----- .../com/posthog/flutter/SnapshotSender.kt | 9 ++- .../flutter/PosthogFlutterPluginTest.kt | 17 +++++- .../com/posthog/flutter/SnapshotSenderTest.kt | 9 +++ 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 3731e780..b89db2e8 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -124,25 +124,30 @@ class PosthogFlutterPlugin : } private val mainHandler = Handler(Looper.getMainLooper()) - private val bitmapExportExecutor = Executors.newSingleThreadExecutor() + private var bitmapExportExecutor = Executors.newSingleThreadExecutor() // Frame sends decode and re-encode the image — too slow for the main // thread. Single-threaded so a meta event can never be overtaken by the // frame it describes. - private val snapshotSendExecutor = Executors.newSingleThreadExecutor() + private var snapshotSendExecutor = Executors.newSingleThreadExecutor() // The native SDK stamps its replay events (touches, bridged frames) with // config.dateProvider, which prefers the network-time clock on API 33+ and // can diverge from System.currentTimeMillis. Replay is ordered by // timestamp, so Flutter frames must use the same clock. - private val snapshotSender = - SnapshotSender { - postHogConfig?.dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() - } + private val snapshotSender = SnapshotSender(::replayTimeMillis) + + private fun replayTimeMillis(): Long = postHogConfig?.dateProvider?.currentTimeMillis() ?: System.currentTimeMillis() private var flutterSurveysDelegate: PostHogFlutterSurveysDelegate? = null override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + if (bitmapExportExecutor.isShutdown) { + bitmapExportExecutor = Executors.newSingleThreadExecutor() + } + if (snapshotSendExecutor.isShutdown) { + snapshotSendExecutor = Executors.newSingleThreadExecutor() + } channel = MethodChannel(flutterPluginBinding.binaryMessenger, "posthog_flutter") this.applicationContext = flutterPluginBinding.applicationContext @@ -433,26 +438,48 @@ class PosthogFlutterPlugin : return } - submitSnapshotWork { snapshotSender.sendMetaEvent(width, height, screen) } - result.success(null) + val timestampMs = replayTimeMillis() + submitSnapshotWork(result) { snapshotSender.sendMetaEvent(width, height, screen, timestampMs) } } catch (e: Throwable) { result.error("PosthogFlutterException", e.localizedMessage, null) } } - // Rejection only happens after engine detach; the frame is not - // recoverable then, so it is dropped rather than thrown. - private fun submitSnapshotWork(work: () -> Unit) { + // The reply is completed after the worker finishes so Dart's await spans + // the encode: a throttleDelay below the encode time then self-regulates + // instead of growing the queue without bound. Rejection only happens after + // engine detach; the frame is not recoverable then, so it is dropped + // rather than thrown. + private fun submitSnapshotWork( + result: Result, + work: () -> Unit, + ) { try { snapshotSendExecutor.execute { try { work() + postSnapshotReply { result.success(null) } } catch (e: Throwable) { - Log.w("PostHog", "Replay snapshot send failed: $e") + postSnapshotReply { + result.error("PosthogFlutterException", e.localizedMessage, null) + } } } } catch (e: RejectedExecutionException) { Log.w("PostHog", "Replay snapshot dropped, executor is shut down: $e") + result.success(null) + } + } + + // The engine can detach between the work finishing and the reply landing; + // replying into a dead channel must not crash the main looper. + private fun postSnapshotReply(reply: () -> Unit) { + mainHandler.post { + try { + reply() + } catch (e: Throwable) { + Log.w("PostHog", "Replay snapshot reply dropped: $e") + } } } @@ -992,8 +1019,10 @@ class PosthogFlutterPlugin : val x = call.argument("x") ?: 0 val y = call.argument("y") ?: 0 if (imageBytes != null) { - submitSnapshotWork { snapshotSender.sendFullSnapshot(imageBytes, id, x, y) } - result.success(null) + val timestampMs = replayTimeMillis() + submitSnapshotWork(result) { + snapshotSender.sendFullSnapshot(imageBytes, id, x, y, timestampMs) + } } else { result.error("INVALID_ARGUMENT", "Image bytes are null", null) } diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/SnapshotSender.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/SnapshotSender.kt index 49110d8f..255921dd 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/SnapshotSender.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/SnapshotSender.kt @@ -17,6 +17,7 @@ class SnapshotSender( id: Int, x: Int, y: Int, + timestampMs: Long = currentTimeMillis(), ) { val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) val base64String = bitmap.base64() @@ -38,7 +39,7 @@ class SnapshotSender( listOf(wireframe), initialOffsetTop = 0, initialOffsetLeft = 0, - timestamp = currentTimeMillis(), + timestamp = timestampMs, ) listOf(snapshotEvent).capture() @@ -48,9 +49,10 @@ class SnapshotSender( width: Int, height: Int, screen: String, + timestampMs: Long = currentTimeMillis(), ) { val events = mutableListOf() - events.add(buildMetaEvent(width, height, screen)) + events.add(buildMetaEvent(width, height, screen, timestampMs)) events.capture() } @@ -59,11 +61,12 @@ class SnapshotSender( width: Int, height: Int, screen: String, + timestampMs: Long = currentTimeMillis(), ): RRMetaEvent = RRMetaEvent( href = screen, width = width, height = height, - timestamp = currentTimeMillis(), + timestamp = timestampMs, ) } diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index 35fb7cd7..13941d5b 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -50,7 +50,7 @@ internal class PosthogFlutterPluginTest { } @Test - fun onMethodCall_sendMetaEvent_succeedsBeforeAndAfterEngineDetach() { + fun onMethodCall_sendMetaEvent_repliesAfterWorkDropsOnDetachRecoversOnReattach() { val plugin = PosthogFlutterPlugin() val binding = Mockito.mock(FlutterPlugin.FlutterPluginBinding::class.java) Mockito.`when`(binding.applicationContext).thenReturn(Mockito.mock(Context::class.java)) @@ -58,16 +58,27 @@ internal class PosthogFlutterPluginTest { plugin.onAttachedToEngine(binding) val call = MethodCall("sendMetaEvent", mapOf("width" to 10, "height" to 20, "screen" to "Home")) + + // The reply completes after the worker runs, never synchronously in + // the handler — that await is the Dart-side backpressure. val whileAttached: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) plugin.onMethodCall(call, whileAttached) - Mockito.verify(whileAttached).success(null) + Mockito.verify(whileAttached, Mockito.never()).success(null) plugin.onDetachedFromEngine(binding) - // The executor is shut down: the submission is dropped, never thrown. + // Shut-down executor: the submission is dropped and answered + // immediately, never thrown. val afterDetach: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) plugin.onMethodCall(call, afterDetach) Mockito.verify(afterDetach).success(null) + + // Reattach recreates the executor: submissions are accepted again + // instead of hitting the immediate drop path. + plugin.onAttachedToEngine(binding) + val afterReattach: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, afterReattach) + Mockito.verify(afterReattach, Mockito.never()).success(null) } @Test diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/SnapshotSenderTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/SnapshotSenderTest.kt index c044affb..dfacc09e 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/SnapshotSenderTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/SnapshotSenderTest.kt @@ -16,4 +16,13 @@ internal class SnapshotSenderTest { assertEquals(20, data["height"]) assertEquals("Home", data["href"]) } + + @Test + fun buildMetaEvent_prefersAnExplicitTimestamp() { + val sender = SnapshotSender(currentTimeMillis = { 1234L }) + + val metaEvent = sender.buildMetaEvent(width = 1, height = 2, screen = "s", timestampMs = 99L) + + assertEquals(99L, metaEvent.timestamp) + } } From 31740ad2975d0c776a40bd74747a847a90150bfe Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 20 Jul 2026 09:30:08 -0400 Subject: [PATCH 4/5] test: state what the async-reply assertion actually covers --- .../kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index 13941d5b..f21a6f92 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -59,8 +59,9 @@ internal class PosthogFlutterPluginTest { val call = MethodCall("sendMetaEvent", mapOf("width" to 10, "height" to 20, "screen" to "Home")) - // The reply completes after the worker runs, never synchronously in - // the handler — that await is the Dart-side backpressure. + // Asserts only that no reply happens synchronously in the handler. + // Actual delivery after the worker runs is not observable here (the + // stubbed test looper drops posts) and is covered end to end. val whileAttached: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) plugin.onMethodCall(call, whileAttached) Mockito.verify(whileAttached, Mockito.never()).success(null) From 8afa68595fa72596cdc5758269668539ff3a4073 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 20 Jul 2026 09:49:29 -0400 Subject: [PATCH 5/5] chore: trim comments to the necessary minimum --- .../com/posthog/flutter/PosthogFlutterPlugin.kt | 13 +++++-------- .../com/posthog/flutter/PosthogFlutterPluginTest.kt | 5 +---- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index b89db2e8..53fc5a5a 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -126,9 +126,8 @@ class PosthogFlutterPlugin : private val mainHandler = Handler(Looper.getMainLooper()) private var bitmapExportExecutor = Executors.newSingleThreadExecutor() - // Frame sends decode and re-encode the image — too slow for the main - // thread. Single-threaded so a meta event can never be overtaken by the - // frame it describes. + // Single-threaded so a meta event can never be overtaken by the frame + // it describes. private var snapshotSendExecutor = Executors.newSingleThreadExecutor() // The native SDK stamps its replay events (touches, bridged frames) with @@ -445,11 +444,9 @@ class PosthogFlutterPlugin : } } - // The reply is completed after the worker finishes so Dart's await spans - // the encode: a throttleDelay below the encode time then self-regulates - // instead of growing the queue without bound. Rejection only happens after - // engine detach; the frame is not recoverable then, so it is dropped - // rather than thrown. + // Replying after the work keeps Dart's await as backpressure: a + // throttleDelay below the encode time self-regulates instead of growing + // the queue. Rejection means the engine detached; drop, don't throw. private fun submitSnapshotWork( result: Result, work: () -> Unit, diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt index f21a6f92..3aad7ad7 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/PosthogFlutterPluginTest.kt @@ -68,14 +68,11 @@ internal class PosthogFlutterPluginTest { plugin.onDetachedFromEngine(binding) - // Shut-down executor: the submission is dropped and answered - // immediately, never thrown. val afterDetach: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) plugin.onMethodCall(call, afterDetach) Mockito.verify(afterDetach).success(null) - // Reattach recreates the executor: submissions are accepted again - // instead of hitting the immediate drop path. + // No immediate drop reply proves the executor was recreated. plugin.onAttachedToEngine(binding) val afterReattach: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) plugin.onMethodCall(call, afterReattach)