From 733b55a84666eddf72fa65b81a3eb32615c86132 Mon Sep 17 00:00:00 2001 From: Marco Rizza Date: Sat, 23 May 2026 12:26:24 +0200 Subject: [PATCH 1/2] feat: capture platform views in session replay screenshots --- .changeset/fiery-lamps-guess.md | 5 + .../posthog/flutter/PosthogFlutterPlugin.kt | 190 ++++++++++++++++++ .../PosthogFlutterPlugin.swift | 63 ++++++ posthog_flutter/lib/src/posthog_config.dart | 9 + .../lib/src/replay/native_communicator.dart | 29 +++ .../screenshot/screenshot_capturer.dart | 36 +++- posthog_flutter/test/posthog_test.dart | 16 ++ 7 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 .changeset/fiery-lamps-guess.md diff --git a/.changeset/fiery-lamps-guess.md b/.changeset/fiery-lamps-guess.md new file mode 100644 index 00000000..d5459022 --- /dev/null +++ b/.changeset/fiery-lamps-guess.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Add opt-in session replay support for native platform views on iOS and Android. 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 502bacab..d9f7f2ba 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 @@ -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,21 @@ 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 kotlin.math.roundToInt /** PosthogFlutterPlugin */ class PosthogFlutterPlugin : FlutterPlugin, + ActivityAware, MethodCallHandler { // / The MethodChannel that will be the communication between Flutter and native Android // / @@ -33,6 +44,7 @@ class PosthogFlutterPlugin : private lateinit var channel: MethodChannel private lateinit var applicationContext: Context + private var activity: Activity? = null private val snapshotSender = SnapshotSender() @@ -206,6 +218,10 @@ class PosthogFlutterPlugin : handleSendFullSnapshot(call, result) } + "captureNativeScreenshot" -> { + handleCaptureNativeScreenshot(call, result) + } + "isSessionReplayActive" -> { result.success(isSessionReplayActive()) } @@ -398,6 +414,22 @@ 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) } @@ -422,6 +454,164 @@ class PosthogFlutterPlugin : } } + private fun handleCaptureNativeScreenshot( + call: MethodCall, + result: Result, + ) { + try { + val currentActivity = activity + if (currentActivity == null) { + result.success(null) + return + } + + val x = call.argument("x") ?: 0 + val y = call.argument("y") ?: 0 + val width = call.argument("width") ?: 0 + val height = call.argument("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(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) { + result.success(bitmapToPng(bitmap)) + } else { + bitmap.recycle() + captureNativeScreenshotFallback( + contentView = contentView, + cropLeft = cropLeft, + cropTop = cropTop, + cropRight = cropRight, + cropBottom = cropBottom, + logicalWidth = logicalWidth, + logicalHeight = logicalHeight, + result = result, + ) + } + }, + Handler(Looper.getMainLooper()), + ) + 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) + 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() + } + } + + result.success(bitmapToPng(outputBitmap)) + } + + private fun bitmapToPng(bitmap: Bitmap): ByteArray { + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) + bitmap.recycle() + return outputStream.toByteArray() + } + private fun getFeatureFlag( call: MethodCall, result: Result, diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 9d513d51..b917164b 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -271,6 +271,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { sendMetaEvent(call, result: result) case "sendFullSnapshot": sendFullSnapshot(call, result: result) + case "captureNativeScreenshot": + captureNativeScreenshot(call, result: result) case "isSessionReplayActive": isSessionReplayActive(result: result) case "startSessionRecording": @@ -405,6 +407,67 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { #endif extension PosthogFlutterPlugin { + private func captureNativeScreenshot(_ call: FlutterMethodCall, + result: @escaping FlutterResult) + { + #if os(iOS) + guard let args = call.arguments as? [String: Any] else { + _badArgumentError(result) + return + } + + let x = args["x"] as? Int ?? 0 + let y = args["y"] as? Int ?? 0 + let width = args["width"] as? Int ?? 0 + let height = args["height"] as? Int ?? 0 + + guard width > 0, height > 0 else { + _badArgumentError(result) + return + } + + DispatchQueue.main.async { + guard let window = self.captureWindow() else { + result(nil) + return + } + + let cropRect = CGRect(x: x, y: y, width: width, height: height) + .intersection(window.bounds) + guard !cropRect.isNull, !cropRect.isEmpty else { + result(nil) + return + } + + let format = UIGraphicsImageRendererFormat.default() + format.scale = 1 + format.opaque = window.isOpaque + + let image = UIGraphicsImageRenderer(size: cropRect.size, format: format).image { _ in + let drawRect = window.bounds.offsetBy(dx: -cropRect.origin.x, dy: -cropRect.origin.y) + window.drawHierarchy(in: drawRect, afterScreenUpdates: false) + } + + result(image.pngData().map(FlutterStandardTypedData.init(bytes:)) ?? nil) + } + #else + result(nil) + #endif + } + + #if os(iOS) + private func captureWindow() -> UIWindow? { + if #available(iOS 13.0, *) { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first(where: \.isKeyWindow) ?? UIApplication.shared.windows.first + } else { + return UIApplication.shared.keyWindow + } + } + #endif + private func sendMetaEvent(_ call: FlutterMethodCall, result: @escaping FlutterResult) { diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 965b84e9..21a4c1fb 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -213,11 +213,20 @@ class PostHogSessionReplayConfig { /// If null, sampling is controlled by remote config (when available). double? sampleRate; + /// Capture native platform views, such as maps and web views, in session replay. + /// + /// Default: false. + /// + /// Platform view content is captured from the native view hierarchy and cannot + /// be automatically masked by Flutter widget masking rules. + var capturePlatformViews = false; + Map toMap() { return { 'maskAllImages': maskAllImages, 'maskAllTexts': maskAllTexts, 'throttleDelayMs': throttleDelay.inMilliseconds, + 'capturePlatformViews': capturePlatformViews, if (sampleRate != null) 'sampleRate': sampleRate, }; } diff --git a/posthog_flutter/lib/src/replay/native_communicator.dart b/posthog_flutter/lib/src/replay/native_communicator.dart index 67f490b2..5ce439f2 100644 --- a/posthog_flutter/lib/src/replay/native_communicator.dart +++ b/posthog_flutter/lib/src/replay/native_communicator.dart @@ -51,4 +51,33 @@ class NativeCommunicator { return false; } } + + Future captureNativeScreenshot({ + required int x, + required int y, + required int width, + required int height, + }) async { + if (kIsWeb) { + return null; + } + try { + final bytes = await _channel.invokeMethod( + 'captureNativeScreenshot', + { + 'x': x, + 'y': y, + 'width': width, + 'height': height, + }, + ); + if (bytes == null || bytes.isEmpty) { + return null; + } + return bytes; + } catch (e) { + printIfDebug('Error capturing native screenshot: $e'); + return null; + } + } } diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index a872f3c7..d0d4398d 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -88,6 +88,18 @@ class ScreenshotCapturer { } } + Future _decodeImage(Uint8List bytes) async { + try { + final codec = await ui.instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + codec.dispose(); + return frame.image; + } catch (e) { + printIfDebug('Error decoding image bytes: $e'); + return null; + } + } + /// Computes a hash of the full raw RGBA byte array for change detection. /// This avoids retaining the full image bytes while still hashing every byte. int _computeImageHash(Uint8List bytes) { @@ -140,8 +152,6 @@ class ScreenshotCapturer { srcHeight: srcHeight, ); - final syncImage = renderObject.toImage(pixelRatio: pixelRatio); - final replayConfig = _config.sessionReplayConfig; final postHogWidgetWrapperElements = @@ -154,9 +164,8 @@ class ScreenshotCapturer { PostHogMaskController.instance.getCurrentWidgetsElements(); } - /// we firstly get current image (syncImage) and masks - /// (postHogWidgetWrapperElements, elementsDataWidgets) synchronously and - /// then executed the main process asynchronous + /// We capture the mask metadata synchronously, then resolve the image + /// asynchronously from the native view hierarchy when available. ui.Image? image; ui.PictureRecorder? recorder; ui.Picture? picture; @@ -169,10 +178,25 @@ class ScreenshotCapturer { completer.complete(null); return; } + if (!isSessionReplayActive) { + _snapshotManager.clear(); + completer.complete(null); + return; + } // wait the UI to settle await SchedulerBinding.instance.endOfFrame; - image = await syncImage; + final nativeImageBytes = replayConfig.capturePlatformViews + ? await _nativeCommunicator.captureNativeScreenshot( + x: globalPosition.dx.round(), + y: globalPosition.dy.round(), + width: srcWidth.round(), + height: srcHeight.round(), + ) + : null; + image = nativeImageBytes != null + ? await _decodeImage(nativeImageBytes) + : await renderObject.toImage(pixelRatio: pixelRatio); final currentImage = image; if (_cancelled) { currentImage?.dispose(); diff --git a/posthog_flutter/test/posthog_test.dart b/posthog_flutter/test/posthog_test.dart index d3bcbda4..96535078 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -103,6 +103,22 @@ void main() { expect(config.host, equals('https://us.i.posthog.com')); expect(config.toMap()['host'], equals('https://us.i.posthog.com')); }); + + test('session replay platform view capture is opt-in', () { + final config = PostHogConfig('test_project_token'); + + expect(config.sessionReplayConfig.capturePlatformViews, isFalse); + + final replayConfig = + config.toMap()['sessionReplayConfig'] as Map; + expect(replayConfig['capturePlatformViews'], isFalse); + + config.sessionReplayConfig.capturePlatformViews = true; + + final updatedReplayConfig = + config.toMap()['sessionReplayConfig'] as Map; + expect(updatedReplayConfig['capturePlatformViews'], isTrue); + }); }); group('getFeatureFlagResult', () { From f566df2171bf2a9bade57d095e6d5a25d652a64e Mon Sep 17 00:00:00 2001 From: Marco Rizza Date: Sat, 23 May 2026 12:55:32 +0200 Subject: [PATCH 2/2] fix: implement asynchronous bitmap compression for improved performance --- .../posthog/flutter/PosthogFlutterPlugin.kt | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 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 d9f7f2ba..a8ee61d4 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 @@ -30,6 +30,7 @@ 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 */ @@ -46,6 +47,8 @@ class PosthogFlutterPlugin : 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 @@ -432,6 +435,7 @@ class PosthogFlutterPlugin : override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) + screenshotCompressionExecutor.shutdown() } private fun handleSendFullSnapshot( @@ -537,7 +541,7 @@ class PosthogFlutterPlugin : bitmap, { copyResult -> if (copyResult == PixelCopy.SUCCESS) { - result.success(bitmapToPng(bitmap)) + compressBitmapToPngAsync(bitmap, result) } else { bitmap.recycle() captureNativeScreenshotFallback( @@ -552,7 +556,7 @@ class PosthogFlutterPlugin : ) } }, - Handler(Looper.getMainLooper()), + mainHandler, ) return } @@ -581,6 +585,8 @@ class PosthogFlutterPlugin : ) { 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 = @@ -602,7 +608,7 @@ class PosthogFlutterPlugin : } } - result.success(bitmapToPng(outputBitmap)) + compressBitmapToPngAsync(outputBitmap, result) } private fun bitmapToPng(bitmap: Bitmap): ByteArray { @@ -612,6 +618,27 @@ class PosthogFlutterPlugin : return outputStream.toByteArray() } + 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,