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..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,20 +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 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 @@ -428,13 +438,51 @@ class PosthogFlutterPlugin : return } - 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) } } + // 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) { + 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") + } + } + } + private fun setup( call: MethodCall, result: Result, @@ -681,6 +729,7 @@ class PosthogFlutterPlugin : stopOcclusionDetector() channel.setMethodCallHandler(null) bitmapExportExecutor.shutdown() + snapshotSendExecutor.shutdown() } private var cachedReplayIntegration: PostHogReplayIntegration? = null @@ -970,8 +1019,10 @@ class PosthogFlutterPlugin : val x = call.argument("x") ?: 0 val y = call.argument("y") ?: 0 if (imageBytes != null) { - 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 4b6f1469..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 @@ -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,38 @@ internal class PosthogFlutterPluginTest { Mockito.verify(mockResult).success(null) } + @Test + 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)) + 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")) + + // 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, Mockito.never()).success(null) + + 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. + 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 fun onMethodCall_setCaptureNativeScreens_returnsSuccess() { val plugin = PosthogFlutterPlugin() 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) + } }