-
Notifications
You must be signed in to change notification settings - Fork 71
feat: capture platform views in session replay screenshots #393
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "posthog_flutter": minor | ||
| --- | ||
|
|
||
| Add opt-in session replay support for native platform views on iOS and Android. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,18 @@ | ||
| package com.posthog.flutter | ||
|
|
||
| import android.app.Activity | ||
| import android.content.ActivityNotFoundException | ||
| import android.content.Context | ||
| import android.content.Intent | ||
| import android.graphics.Bitmap | ||
| import android.graphics.Rect | ||
| import android.net.Uri | ||
| import android.os.Bundle | ||
| import android.os.Build | ||
| import android.os.Handler | ||
| import android.os.Looper | ||
| import android.view.PixelCopy | ||
| import android.view.View | ||
| import android.util.Log | ||
| import com.posthog.PersonProfiles | ||
| import com.posthog.PostHog | ||
|
|
@@ -15,16 +21,22 @@ import com.posthog.PostHogOnFeatureFlags | |
| import com.posthog.android.PostHogAndroid | ||
| import com.posthog.android.PostHogAndroidConfig | ||
| import com.posthog.android.internal.getApplicationInfo | ||
| import io.flutter.embedding.engine.plugins.activity.ActivityAware | ||
| import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding | ||
| import io.flutter.embedding.engine.plugins.FlutterPlugin | ||
| import io.flutter.plugin.common.MethodCall | ||
| import io.flutter.plugin.common.MethodChannel | ||
| import io.flutter.plugin.common.MethodChannel.MethodCallHandler | ||
| import io.flutter.plugin.common.MethodChannel.Result | ||
| import java.io.ByteArrayOutputStream | ||
| import java.util.Date | ||
| import java.util.concurrent.Executors | ||
| import kotlin.math.roundToInt | ||
|
|
||
| /** PosthogFlutterPlugin */ | ||
| class PosthogFlutterPlugin : | ||
| FlutterPlugin, | ||
| ActivityAware, | ||
| MethodCallHandler { | ||
| // / The MethodChannel that will be the communication between Flutter and native Android | ||
| // / | ||
|
|
@@ -33,7 +45,10 @@ class PosthogFlutterPlugin : | |
| private lateinit var channel: MethodChannel | ||
|
|
||
| private lateinit var applicationContext: Context | ||
| private var activity: Activity? = null | ||
|
|
||
| private val mainHandler = Handler(Looper.getMainLooper()) | ||
| private val screenshotCompressionExecutor = Executors.newSingleThreadExecutor() | ||
| private val snapshotSender = SnapshotSender() | ||
|
|
||
| // The surveys delegate | ||
|
|
@@ -206,6 +221,10 @@ class PosthogFlutterPlugin : | |
| handleSendFullSnapshot(call, result) | ||
| } | ||
|
|
||
| "captureNativeScreenshot" -> { | ||
| handleCaptureNativeScreenshot(call, result) | ||
| } | ||
|
|
||
| "isSessionReplayActive" -> { | ||
| result.success(isSessionReplayActive()) | ||
| } | ||
|
|
@@ -398,8 +417,25 @@ class PosthogFlutterPlugin : | |
| PostHogAndroid.setup(applicationContext, config) | ||
| } | ||
|
|
||
| override fun onAttachedToActivity(binding: ActivityPluginBinding) { | ||
| activity = binding.activity | ||
| } | ||
|
|
||
| override fun onDetachedFromActivityForConfigChanges() { | ||
| activity = null | ||
| } | ||
|
|
||
| override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { | ||
| activity = binding.activity | ||
| } | ||
|
|
||
| override fun onDetachedFromActivity() { | ||
| activity = null | ||
| } | ||
|
|
||
| override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { | ||
| channel.setMethodCallHandler(null) | ||
| screenshotCompressionExecutor.shutdown() | ||
| } | ||
|
|
||
| private fun handleSendFullSnapshot( | ||
|
|
@@ -422,6 +458,187 @@ class PosthogFlutterPlugin : | |
| } | ||
| } | ||
|
|
||
| private fun handleCaptureNativeScreenshot( | ||
| call: MethodCall, | ||
| result: Result, | ||
| ) { | ||
| try { | ||
| val currentActivity = activity | ||
| if (currentActivity == null) { | ||
| result.success(null) | ||
| return | ||
| } | ||
|
|
||
| val x = call.argument<Int>("x") ?: 0 | ||
| val y = call.argument<Int>("y") ?: 0 | ||
| val width = call.argument<Int>("width") ?: 0 | ||
| val height = call.argument<Int>("height") ?: 0 | ||
|
|
||
| if (width <= 0 || height <= 0) { | ||
| result.error("INVALID_ARGUMENT", "Width or height is 0", null) | ||
| return | ||
| } | ||
|
|
||
| captureNativeScreenshot( | ||
| activity = currentActivity, | ||
| x = x, | ||
| y = y, | ||
| width = width, | ||
| height = height, | ||
| result = result, | ||
| ) | ||
| } catch (e: Throwable) { | ||
| result.error("PosthogFlutterException", e.localizedMessage, null) | ||
| } | ||
| } | ||
|
|
||
| private fun captureNativeScreenshot( | ||
| activity: Activity, | ||
| x: Int, | ||
| y: Int, | ||
| width: Int, | ||
| height: Int, | ||
| result: Result, | ||
| ) { | ||
| val contentView = activity.findViewById<View>(android.R.id.content) ?: run { | ||
| result.success(null) | ||
| return | ||
| } | ||
|
|
||
| val contentWidthPx = contentView.width | ||
| val contentHeightPx = contentView.height | ||
| if (contentWidthPx <= 0 || contentHeightPx <= 0) { | ||
| result.success(null) | ||
| return | ||
| } | ||
|
|
||
| val density = activity.resources.displayMetrics.density | ||
| val cropLeft = (x * density).roundToInt().coerceIn(0, contentWidthPx - 1) | ||
| val cropTop = (y * density).roundToInt().coerceIn(0, contentHeightPx - 1) | ||
| val cropRight = ((x + width) * density).roundToInt().coerceIn(cropLeft + 1, contentWidthPx) | ||
| val cropBottom = ((y + height) * density).roundToInt().coerceIn(cropTop + 1, contentHeightPx) | ||
|
|
||
| val logicalWidth = width.coerceAtLeast(1) | ||
| val logicalHeight = height.coerceAtLeast(1) | ||
|
|
||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||
| val locationInWindow = IntArray(2) | ||
| contentView.getLocationInWindow(locationInWindow) | ||
|
|
||
| val srcRect = | ||
| Rect( | ||
| locationInWindow[0] + cropLeft, | ||
| locationInWindow[1] + cropTop, | ||
| locationInWindow[0] + cropRight, | ||
| locationInWindow[1] + cropBottom, | ||
| ) | ||
|
|
||
| val bitmap = Bitmap.createBitmap(logicalWidth, logicalHeight, Bitmap.Config.ARGB_8888) | ||
|
|
||
| PixelCopy.request( | ||
| activity.window, | ||
| srcRect, | ||
| bitmap, | ||
| { copyResult -> | ||
| if (copyResult == PixelCopy.SUCCESS) { | ||
| compressBitmapToPngAsync(bitmap, result) | ||
| } else { | ||
| bitmap.recycle() | ||
| captureNativeScreenshotFallback( | ||
| contentView = contentView, | ||
| cropLeft = cropLeft, | ||
| cropTop = cropTop, | ||
| cropRight = cropRight, | ||
| cropBottom = cropBottom, | ||
| logicalWidth = logicalWidth, | ||
| logicalHeight = logicalHeight, | ||
| result = result, | ||
| ) | ||
| } | ||
| }, | ||
| mainHandler, | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| captureNativeScreenshotFallback( | ||
| contentView = contentView, | ||
| cropLeft = cropLeft, | ||
| cropTop = cropTop, | ||
| cropRight = cropRight, | ||
| cropBottom = cropBottom, | ||
| logicalWidth = logicalWidth, | ||
| logicalHeight = logicalHeight, | ||
| result = result, | ||
| ) | ||
| } | ||
|
|
||
| private fun captureNativeScreenshotFallback( | ||
| contentView: View, | ||
| cropLeft: Int, | ||
| cropTop: Int, | ||
| cropRight: Int, | ||
| cropBottom: Int, | ||
| logicalWidth: Int, | ||
| logicalHeight: Int, | ||
| result: Result, | ||
| ) { | ||
| val contentBitmap = | ||
| Bitmap.createBitmap(contentView.width, contentView.height, Bitmap.Config.ARGB_8888) | ||
| // Software Canvas cannot read GPU-composited surfaces like SurfaceView or TextureView, | ||
| // so those platform views may still appear blank when we hit this fallback path. | ||
| contentView.draw(android.graphics.Canvas(contentBitmap)) | ||
|
|
||
| val croppedBitmap = | ||
| Bitmap.createBitmap( | ||
| contentBitmap, | ||
| cropLeft, | ||
| cropTop, | ||
| cropRight - cropLeft, | ||
| cropBottom - cropTop, | ||
| ) | ||
| contentBitmap.recycle() | ||
|
|
||
| val outputBitmap = | ||
| if (croppedBitmap.width == logicalWidth && croppedBitmap.height == logicalHeight) { | ||
| croppedBitmap | ||
| } else { | ||
| Bitmap.createScaledBitmap(croppedBitmap, logicalWidth, logicalHeight, true).also { | ||
| croppedBitmap.recycle() | ||
| } | ||
| } | ||
|
|
||
| compressBitmapToPngAsync(outputBitmap, result) | ||
| } | ||
|
|
||
| private fun bitmapToPng(bitmap: Bitmap): ByteArray { | ||
| val outputStream = ByteArrayOutputStream() | ||
| bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) | ||
| bitmap.recycle() | ||
| return outputStream.toByteArray() | ||
| } | ||
|
Comment on lines
+614
to
+619
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt
Line: 608-613
Comment:
**PNG compression runs on the main UI thread**
`bitmapToPng` is called directly inside the `PixelCopy` completion callback, which is dispatched to `Handler(Looper.getMainLooper())`. PNG encoding of a full-screen `ARGB_8888` bitmap (`Bitmap.CompressFormat.PNG, 100`) can take 100–300 ms on a typical device, blocking the main thread for the duration of every session-replay snapshot when `capturePlatformViews` is enabled. The same applies to the `captureNativeScreenshotFallback` path. Consider offloading the compression to a background thread (e.g. `Executors.newSingleThreadExecutor()`) and marshalling the byte-array result back to the main thread before calling `result.success()`.
How can I resolve this? If you propose a fix, please make it concise. |
||
|
|
||
| private fun compressBitmapToPngAsync( | ||
| bitmap: Bitmap, | ||
| result: Result, | ||
| ) { | ||
| screenshotCompressionExecutor.execute { | ||
| try { | ||
| val pngBytes = bitmapToPng(bitmap) | ||
| mainHandler.post { | ||
| result.success(pngBytes) | ||
| } | ||
| } catch (e: Throwable) { | ||
| if (!bitmap.isRecycled) { | ||
| bitmap.recycle() | ||
| } | ||
| mainHandler.post { | ||
| result.error("PosthogFlutterException", e.localizedMessage, null) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun getFeatureFlag( | ||
| call: MethodCall, | ||
| result: Result, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
View.draw()renders using a softwareCanvas, which cannot read content from hardware-accelerated surfaces likeSurfaceView,TextureView, or Metal/Vulkan-backed views. This is the exact content typecapturePlatformViewsis meant to capture. On pre-API 26 devices, or wheneverPixelCopyfails, this fallback will produce blank areas for the native platform views the user opted into capturing. A brief inline comment explaining this limitation would prevent confusion when developers debug unexpected blank regions on older API levels.Prompt To Fix With AI