Skip to content
Open
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/android-replay-encode-off-main.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -681,6 +729,7 @@ class PosthogFlutterPlugin :
stopOcclusionDetector()
channel.setMethodCallHandler(null)
bitmapExportExecutor.shutdown()
snapshotSendExecutor.shutdown()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reattaching the same plugin instance leaves snapshot delivery disabled

snapshotSendExecutor is created once, shut down here, and not recreated in onAttachedToEngine. If Flutter removes and later re-adds the same plugin instance, both snapshot handlers report success while their work is rejected by the terminated executor.

Generated registration normally creates a fresh instance, so this is an uncommon lifecycle edge. Consider recreating attachment-scoped executors on attach, with coverage for attach → send → detach → reattach → send.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair — generated registration makes this unreachable today, but it's cheap insurance. Fixed in a11ae47: both attachment-scoped executors are recreated on attach, with the attach → send → detach → reattach → send coverage you suggested.

}

private var cachedReplayIntegration: PostHogReplayIntegration? = null
Expand Down Expand Up @@ -970,8 +1019,10 @@ class PosthogFlutterPlugin :
val x = call.argument<Int>("x") ?: 0
val y = call.argument<Int>("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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -38,7 +39,7 @@ class SnapshotSender(
listOf(wireframe),
initialOffsetTop = 0,
initialOffsetLeft = 0,
timestamp = currentTimeMillis(),
timestamp = timestampMs,
)

listOf(snapshotEvent).capture()
Expand All @@ -48,9 +49,10 @@ class SnapshotSender(
width: Int,
height: Int,
screen: String,
timestampMs: Long = currentTimeMillis(),
) {
val events = mutableListOf<RREvent>()
events.add(buildMetaEvent(width, height, screen))
events.add(buildMetaEvent(width, height, screen, timestampMs))

events.capture()
}
Expand All @@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading