Skip to content
Draft
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/fiery-lamps-guess.md
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
Expand All @@ -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
// /
Expand All @@ -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
Expand Down Expand Up @@ -206,6 +221,10 @@ class PosthogFlutterPlugin :
handleSendFullSnapshot(call, result)
}

"captureNativeScreenshot" -> {
handleCaptureNativeScreenshot(call, result)
}

"isSessionReplayActive" -> {
result.success(isSessionReplayActive())
}
Expand Down Expand Up @@ -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(
Expand All @@ -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)
}
Comment on lines +576 to +612
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Soft-canvas fallback silently misses GPU-composited platform views

View.draw() renders using a software Canvas, which cannot read content from hardware-accelerated surfaces like SurfaceView, TextureView, or Metal/Vulkan-backed views. This is the exact content type capturePlatformViews is meant to capture. On pre-API 26 devices, or whenever PixelCopy fails, 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
This is a comment left during a code review.
Path: posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt
Line: 572-606

Comment:
**Soft-canvas fallback silently misses GPU-composited platform views**

`View.draw()` renders using a software `Canvas`, which cannot read content from hardware-accelerated surfaces like `SurfaceView`, `TextureView`, or Metal/Vulkan-backed views. This is the exact content type `capturePlatformViews` is meant to capture. On pre-API 26 devices, or whenever `PixelCopy` fails, 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.

How can I resolve this? If you propose a fix, please make it concise.


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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().

Prompt To Fix With AI
This 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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)
{
Expand Down
9 changes: 9 additions & 0 deletions posthog_flutter/lib/src/posthog_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> toMap() {
return {
'maskAllImages': maskAllImages,
'maskAllTexts': maskAllTexts,
'throttleDelayMs': throttleDelay.inMilliseconds,
'capturePlatformViews': capturePlatformViews,
if (sampleRate != null) 'sampleRate': sampleRate,
};
}
Expand Down
Loading