From 12d164b11f859083ad2d1dc6d2034d9260402e2b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 15:01:20 -0400 Subject: [PATCH 1/5] fix(android): mask session replay frames on FLAG_SECURE windows --- .changeset/android-replay-flag-secure.md | 5 ++ .../posthog/flutter/PosthogFlutterPlugin.kt | 28 +++++++++-- .../com/posthog/flutter/SnapshotSender.kt | 17 ++++++- .../flutter/PosthogFlutterPluginTest.kt | 48 +++++++++++++++++++ .../flutter/SnapshotSenderEncodingTest.kt | 19 ++++++++ 5 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 .changeset/android-replay-flag-secure.md diff --git a/.changeset/android-replay-flag-secure.md b/.changeset/android-replay-flag-secure.md new file mode 100644 index 00000000..1dd0ba15 --- /dev/null +++ b/.changeset/android-replay-flag-secure.md @@ -0,0 +1,5 @@ +--- +'posthog_flutter': patch +--- + +Mask Android session replay frames while the app window has `FLAG_SECURE` set, instead of recording the secure screen. 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 61909fa6..f8ed68a4 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 @@ -19,6 +19,7 @@ import android.view.PixelCopy import android.view.SurfaceView import android.view.View import android.view.ViewGroup +import android.view.WindowManager.LayoutParams.FLAG_SECURE import androidx.annotation.RequiresApi import androidx.annotation.VisibleForTesting import com.posthog.PersonProfiles @@ -468,11 +469,31 @@ class PosthogFlutterPlugin : // peekSessionId(), not getSessionId(): the expiring accessor would rotate the // session from this read, moving where the idle and maximum-duration bounds // are applied. The send still resolves an expiring id, as it always has. - private fun getSessionReplayState(): Map = - mapOf( + // Dart reads this at the start of every capture, so it also records whether + // the frame about to be captured shows a FLAG_SECURE window. + private fun getSessionReplayState(): Map { + if (isWindowSecure()) { + secureWindowSinceLastFrame = true + } + return mapOf( "isActive" to isSessionReplayActive(), "sessionId" to PostHogSessionManager.peekSessionId()?.toString(), ) + } + + // Set when a capture starts on a FLAG_SECURE window, so the frame stays masked + // even if the flag clears (e.g. the secure screen is popped) before it's sent. + private var secureWindowSinceLastFrame = false + + private fun isWindowSecure(): Boolean = ((activity?.window?.attributes?.flags ?: 0) and FLAG_SECURE) != 0 + + /** Whether the frame being sent must be fully masked. Resets for the next frame. */ + @VisibleForTesting + internal fun takeFrameMasked(): Boolean { + val masked = secureWindowSinceLastFrame || isWindowSecure() + secureWindowSinceLastFrame = false + return masked + } private fun startSessionRecording( call: MethodCall, @@ -1322,8 +1343,9 @@ class PosthogFlutterPlugin : val timestampMs = replayTimeMillis() val replayConfig = postHogConfig?.sessionReplayConfig val quality = replayConfig?.screenshotCompressionQuality ?: DEFAULT_SCREENSHOT_COMPRESSION_QUALITY + val masked = takeFrameMasked() submitSnapshotWork(result) { - snapshotSender.sendFullSnapshot(imageBytes, id, x, y, timestampMs, width, height, quality) + snapshotSender.sendFullSnapshot(imageBytes, id, x, y, timestampMs, width, height, quality, masked) } } 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 976da3fd..bbd7c347 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 @@ -1,6 +1,8 @@ package com.posthog.flutter +import android.graphics.Bitmap import android.graphics.BitmapFactory +import android.graphics.Color import com.posthog.android.internal.base64 import com.posthog.internal.replay.RREvent import com.posthog.internal.replay.RRFullSnapshotEvent @@ -23,8 +25,9 @@ class SnapshotSender( width: Int? = null, height: Int? = null, quality: Int = DEFAULT_SCREENSHOT_COMPRESSION_QUALITY, + masked: Boolean = false, ) { - listOf(buildFullSnapshot(imageBytes, id, x, y, timestampMs, width, height, quality)).capture() + listOf(buildFullSnapshot(imageBytes, id, x, y, timestampMs, width, height, quality, masked)).capture() } internal fun buildFullSnapshot( @@ -36,8 +39,18 @@ class SnapshotSender( width: Int? = null, height: Int? = null, quality: Int = DEFAULT_SCREENSHOT_COMPRESSION_QUALITY, + masked: Boolean = false, ): RRFullSnapshotEvent { - val bitmap = requireNotNull(BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)) + val decoded = requireNotNull(BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)) + val bitmap = + if (masked) { + Bitmap.createBitmap(decoded.width, decoded.height, Bitmap.Config.ARGB_8888).also { + it.eraseColor(Color.BLACK) + decoded.recycle() + } + } else { + decoded + } try { val wireframe = RRWireframe( 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 fc333ff8..40ba0896 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 @@ -4,6 +4,8 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.os.BadParcelableException +import android.view.Window +import android.view.WindowManager import com.google.firebase.FirebaseApp import com.posthog.PostHogCompression import com.posthog.PostHogEvent @@ -358,6 +360,52 @@ internal class PosthogFlutterPluginTest { Mockito.verify(mockResult).success(emptyList()) } + private fun attachActivityWithWindowFlags(plugin: PosthogFlutterPlugin): WindowManager.LayoutParams { + val attributes = Mockito.mock(WindowManager.LayoutParams::class.java) + val window = Mockito.mock(Window::class.java) + Mockito.`when`(window.attributes).thenReturn(attributes) + val activity = Mockito.mock(Activity::class.java) + Mockito.`when`(activity.window).thenReturn(window) + val binding = Mockito.mock(ActivityPluginBinding::class.java) + Mockito.`when`(binding.activity).thenReturn(activity) + plugin.onAttachedToActivity(binding) + return attributes + } + + @Test + fun frameCapturedOnSecureWindow_staysMaskedAfterTheFlagClears() { + val plugin = PosthogFlutterPlugin() + val attributes = attachActivityWithWindowFlags(plugin) + attributes.flags = WindowManager.LayoutParams.FLAG_SECURE + + plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) + attributes.flags = 0 + + assertTrue(plugin.takeFrameMasked()) + assertFalse(plugin.takeFrameMasked()) + } + + @Test + fun frameSentOnSecureWindow_isMasked() { + val plugin = PosthogFlutterPlugin() + val attributes = attachActivityWithWindowFlags(plugin) + + plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) + attributes.flags = WindowManager.LayoutParams.FLAG_SECURE + + assertTrue(plugin.takeFrameMasked()) + } + + @Test + fun frameOnNonSecureWindow_isNotMasked() { + val plugin = PosthogFlutterPlugin() + attachActivityWithWindowFlags(plugin) + + plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) + + assertFalse(plugin.takeFrameMasked()) + } + @Test fun onMethodCall_captureNativeScreenshots_emptyViews_returnsEmptyList() { val plugin = PosthogFlutterPlugin() diff --git a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/SnapshotSenderEncodingTest.kt b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/SnapshotSenderEncodingTest.kt index bc2b09df..4e659b93 100644 --- a/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/SnapshotSenderEncodingTest.kt +++ b/posthog_flutter/android/src/test/kotlin/com/posthog/flutter/SnapshotSenderEncodingTest.kt @@ -129,4 +129,23 @@ class SnapshotSenderEncodingTest { decoded.recycle() } } + + @Test + fun maskedFrameIsBlackAtTheSameDimensions() { + val frame = wireframe(SnapshotSender().buildFullSnapshot(png(), id = 1, x = 0, y = 0, quality = 100, masked = true)) + val jpeg = Base64.decode(assertNotNull(frame.base64).substringAfter(','), Base64.DEFAULT) + val image = assertNotNull(BitmapFactory.decodeByteArray(jpeg, 0, jpeg.size)) + try { + assertEquals(51, image.width) + assertEquals(50, image.height) + for (y in 0 until image.height) { + for (x in 0 until image.width) { + val pixel = image.getPixel(x, y) + assertTrue(Color.red(pixel) < 8 && Color.green(pixel) < 8 && Color.blue(pixel) < 8, "pixel ($x, $y)") + } + } + } finally { + image.recycle() + } + } } From ff1d5804c60f8a5cacb536ba7a4c90cd08398827 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 15:09:20 -0400 Subject: [PATCH 2/5] fix(android): reset the secure-capture flag on every replay tick --- .../com/posthog/flutter/PosthogFlutterPlugin.kt | 10 ++++------ .../kotlin/com/posthog/flutter/SnapshotSender.kt | 11 ++++++----- .../com/posthog/flutter/PosthogFlutterPluginTest.kt | 13 +++++++++++++ .../lib/src/replay/native_communicator.dart | 4 +++- .../src/replay/screenshot/screenshot_capturer.dart | 1 + 5 files changed, 27 insertions(+), 12 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 f8ed68a4..c0ca0132 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 @@ -472,9 +472,7 @@ class PosthogFlutterPlugin : // Dart reads this at the start of every capture, so it also records whether // the frame about to be captured shows a FLAG_SECURE window. private fun getSessionReplayState(): Map { - if (isWindowSecure()) { - secureWindowSinceLastFrame = true - } + captureStartedOnSecureWindow = isWindowSecure() return mapOf( "isActive" to isSessionReplayActive(), "sessionId" to PostHogSessionManager.peekSessionId()?.toString(), @@ -483,15 +481,15 @@ class PosthogFlutterPlugin : // Set when a capture starts on a FLAG_SECURE window, so the frame stays masked // even if the flag clears (e.g. the secure screen is popped) before it's sent. - private var secureWindowSinceLastFrame = false + private var captureStartedOnSecureWindow = false private fun isWindowSecure(): Boolean = ((activity?.window?.attributes?.flags ?: 0) and FLAG_SECURE) != 0 /** Whether the frame being sent must be fully masked. Resets for the next frame. */ @VisibleForTesting internal fun takeFrameMasked(): Boolean { - val masked = secureWindowSinceLastFrame || isWindowSecure() - secureWindowSinceLastFrame = false + val masked = captureStartedOnSecureWindow || isWindowSecure() + captureStartedOnSecureWindow = false return masked } 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 bbd7c347..9356a556 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 @@ -41,15 +41,16 @@ class SnapshotSender( quality: Int = DEFAULT_SCREENSHOT_COMPRESSION_QUALITY, masked: Boolean = false, ): RRFullSnapshotEvent { - val decoded = requireNotNull(BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)) val bitmap = if (masked) { - Bitmap.createBitmap(decoded.width, decoded.height, Bitmap.Config.ARGB_8888).also { - it.eraseColor(Color.BLACK) - decoded.recycle() + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, bounds) + require(bounds.outWidth > 0 && bounds.outHeight > 0) + Bitmap.createBitmap(bounds.outWidth, bounds.outHeight, Bitmap.Config.ARGB_8888).apply { + eraseColor(Color.BLACK) } } else { - decoded + requireNotNull(BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)) } try { val wireframe = 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 40ba0896..ac99207b 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 @@ -396,6 +396,19 @@ internal class PosthogFlutterPluginTest { assertTrue(plugin.takeFrameMasked()) } + @Test + fun droppedCaptureOnSecureWindow_doesNotMaskTheNextFrame() { + val plugin = PosthogFlutterPlugin() + val attributes = attachActivityWithWindowFlags(plugin) + attributes.flags = WindowManager.LayoutParams.FLAG_SECURE + plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) + + attributes.flags = 0 + plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) + + assertFalse(plugin.takeFrameMasked()) + } + @Test fun frameOnNonSecureWindow_isNotMasked() { val plugin = PosthogFlutterPlugin() diff --git a/posthog_flutter/lib/src/replay/native_communicator.dart b/posthog_flutter/lib/src/replay/native_communicator.dart index 62b56b26..d7533ab7 100644 --- a/posthog_flutter/lib/src/replay/native_communicator.dart +++ b/posthog_flutter/lib/src/replay/native_communicator.dart @@ -56,7 +56,9 @@ class NativeCommunicator { /// travel together so the capturer can key its per-session reset on the id the /// native SDK actually holds without a second round trip per tick. /// - /// Non-mutating on both platforms, so a rotation is observed one tick late. + /// Never rotates the session, so a rotation is observed one tick late. On + /// Android it also records whether the window has FLAG_SECURE, which masks + /// the next [sendFullSnapshot]; call it once per capture, before rendering. Future<({bool isActive, String? sessionId})> getSessionReplayState() async { if (kIsWeb) { // Flutter doesn't capture screenshots on web, JS SDK handles session replay diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index e9bc9fce..e43143e8 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -646,6 +646,7 @@ class ScreenshotCapturer { // Cleared again in _resolveCaptureTarget, but that runs after the round trip // below — whose post-await check would veto this capture on a prior flag. _cancelled = false; + // Also arms Android's FLAG_SECURE masking for the frame captured below. final state = await _nativeCommunicator.getSessionReplayState(); if (_cancelled) { return null; From 1d834acbd802c000875caa10e0b54842d51eae9d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 15:11:09 -0400 Subject: [PATCH 3/5] chore: tidy FLAG_SECURE comments --- .../kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 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 c0ca0132..522d915a 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 @@ -469,8 +469,6 @@ class PosthogFlutterPlugin : // peekSessionId(), not getSessionId(): the expiring accessor would rotate the // session from this read, moving where the idle and maximum-duration bounds // are applied. The send still resolves an expiring id, as it always has. - // Dart reads this at the start of every capture, so it also records whether - // the frame about to be captured shows a FLAG_SECURE window. private fun getSessionReplayState(): Map { captureStartedOnSecureWindow = isWindowSecure() return mapOf( @@ -479,8 +477,9 @@ class PosthogFlutterPlugin : ) } - // Set when a capture starts on a FLAG_SECURE window, so the frame stays masked - // even if the flag clears (e.g. the secure screen is popped) before it's sent. + // Set by getSessionReplayState, which Dart calls once at the start of each + // capture, so a frame captured on a FLAG_SECURE window stays masked even if + // the flag clears (e.g. the secure screen is popped) before it's sent. private var captureStartedOnSecureWindow = false private fun isWindowSecure(): Boolean = ((activity?.window?.attributes?.flags ?: 0) and FLAG_SECURE) != 0 From 9014b54b71b88976500542fa216d44bcf7eaa232 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 15:21:06 -0400 Subject: [PATCH 4/5] fix(android): carry the capture-time FLAG_SECURE state on each replay frame --- .../posthog/flutter/PosthogFlutterPlugin.kt | 23 +++------- .../flutter/PosthogFlutterPluginTest.kt | 45 +++++++------------ posthog_flutter/lib/src/posthog_widget.dart | 1 + .../lib/src/replay/native_communicator.dart | 16 ++++--- .../screenshot/screenshot_capturer.dart | 13 ++++-- .../test/replay_screenshot_capture_test.dart | 15 ++++++- 6 files changed, 59 insertions(+), 54 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 522d915a..be04dccd 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 @@ -469,28 +469,19 @@ class PosthogFlutterPlugin : // peekSessionId(), not getSessionId(): the expiring accessor would rotate the // session from this read, moving where the idle and maximum-duration bounds // are applied. The send still resolves an expiring id, as it always has. - private fun getSessionReplayState(): Map { - captureStartedOnSecureWindow = isWindowSecure() - return mapOf( + private fun getSessionReplayState(): Map = + mapOf( "isActive" to isSessionReplayActive(), "sessionId" to PostHogSessionManager.peekSessionId()?.toString(), + "secure" to isWindowSecure(), ) - } - - // Set by getSessionReplayState, which Dart calls once at the start of each - // capture, so a frame captured on a FLAG_SECURE window stays masked even if - // the flag clears (e.g. the secure screen is popped) before it's sent. - private var captureStartedOnSecureWindow = false private fun isWindowSecure(): Boolean = ((activity?.window?.attributes?.flags ?: 0) and FLAG_SECURE) != 0 - /** Whether the frame being sent must be fully masked. Resets for the next frame. */ + // Dart's capture-time value plus a send-time re-check, so a frame stays masked + // if FLAG_SECURE clears (e.g. the secure screen is popped) before it's sent. @VisibleForTesting - internal fun takeFrameMasked(): Boolean { - val masked = captureStartedOnSecureWindow || isWindowSecure() - captureStartedOnSecureWindow = false - return masked - } + internal fun isFrameMasked(secureAtCapture: Boolean): Boolean = secureAtCapture || isWindowSecure() private fun startSessionRecording( call: MethodCall, @@ -1340,7 +1331,7 @@ class PosthogFlutterPlugin : val timestampMs = replayTimeMillis() val replayConfig = postHogConfig?.sessionReplayConfig val quality = replayConfig?.screenshotCompressionQuality ?: DEFAULT_SCREENSHOT_COMPRESSION_QUALITY - val masked = takeFrameMasked() + val masked = isFrameMasked(call.argument("secureAtCapture") == true) submitSnapshotWork(result) { snapshotSender.sendFullSnapshot(imageBytes, id, x, y, timestampMs, width, height, quality, masked) } 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 ac99207b..fc526752 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 @@ -372,51 +372,40 @@ internal class PosthogFlutterPluginTest { return attributes } - @Test - fun frameCapturedOnSecureWindow_staysMaskedAfterTheFlagClears() { - val plugin = PosthogFlutterPlugin() - val attributes = attachActivityWithWindowFlags(plugin) - attributes.flags = WindowManager.LayoutParams.FLAG_SECURE - - plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) - attributes.flags = 0 - - assertTrue(plugin.takeFrameMasked()) - assertFalse(plugin.takeFrameMasked()) + private fun reportedSecure(plugin: PosthogFlutterPlugin): Any? { + val result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(MethodCall("getSessionReplayState", null), result) + val captor = ArgumentCaptor.forClass(Map::class.java) + Mockito.verify(result).success(captor.capture()) + return captor.value["secure"] } @Test - fun frameSentOnSecureWindow_isMasked() { + fun getSessionReplayState_reportsTheWindowSecureFlag() { val plugin = PosthogFlutterPlugin() val attributes = attachActivityWithWindowFlags(plugin) - plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) + assertEquals(false, reportedSecure(plugin)) attributes.flags = WindowManager.LayoutParams.FLAG_SECURE - - assertTrue(plugin.takeFrameMasked()) + assertEquals(true, reportedSecure(plugin)) } @Test - fun droppedCaptureOnSecureWindow_doesNotMaskTheNextFrame() { + fun frameCapturedOnSecureWindow_isMaskedAfterTheFlagClears() { val plugin = PosthogFlutterPlugin() - val attributes = attachActivityWithWindowFlags(plugin) - attributes.flags = WindowManager.LayoutParams.FLAG_SECURE - plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) - - attributes.flags = 0 - plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) + attachActivityWithWindowFlags(plugin) - assertFalse(plugin.takeFrameMasked()) + assertTrue(plugin.isFrameMasked(secureAtCapture = true)) } @Test - fun frameOnNonSecureWindow_isNotMasked() { + fun frameSentOnSecureWindow_isMaskedOnlyWhileTheFlagIsSet() { val plugin = PosthogFlutterPlugin() - attachActivityWithWindowFlags(plugin) - - plugin.onMethodCall(MethodCall("getSessionReplayState", null), Mockito.mock(MethodChannel.Result::class.java)) + val attributes = attachActivityWithWindowFlags(plugin) - assertFalse(plugin.takeFrameMasked()) + assertFalse(plugin.isFrameMasked(secureAtCapture = false)) + attributes.flags = WindowManager.LayoutParams.FLAG_SECURE + assertTrue(plugin.isFrameMasked(secureAtCapture = false)) } @Test diff --git a/posthog_flutter/lib/src/posthog_widget.dart b/posthog_flutter/lib/src/posthog_widget.dart index 74e54d15..43fc1850 100644 --- a/posthog_flutter/lib/src/posthog_widget.dart +++ b/posthog_flutter/lib/src/posthog_widget.dart @@ -333,6 +333,7 @@ class PostHogWidgetState extends State { y: imageInfo.y, width: imageInfo.width, height: imageInfo.height, + secureAtCapture: imageInfo.secureAtCapture, ) ?? false; if (!snapshotSent) { diff --git a/posthog_flutter/lib/src/replay/native_communicator.dart b/posthog_flutter/lib/src/replay/native_communicator.dart index d7533ab7..2e595895 100644 --- a/posthog_flutter/lib/src/replay/native_communicator.dart +++ b/posthog_flutter/lib/src/replay/native_communicator.dart @@ -14,6 +14,7 @@ class NativeCommunicator { required int y, int? width, int? height, + bool secureAtCapture = false, }) async { try { await _channel.invokeMethod('sendFullSnapshot', { @@ -23,6 +24,7 @@ class NativeCommunicator { 'y': y, if (width != null) 'width': width, if (height != null) 'height': height, + 'secureAtCapture': secureAtCapture, }); return true; } catch (e) { @@ -56,13 +58,14 @@ class NativeCommunicator { /// travel together so the capturer can key its per-session reset on the id the /// native SDK actually holds without a second round trip per tick. /// - /// Never rotates the session, so a rotation is observed one tick late. On - /// Android it also records whether the window has FLAG_SECURE, which masks - /// the next [sendFullSnapshot]; call it once per capture, before rendering. - Future<({bool isActive, String? sessionId})> getSessionReplayState() async { + /// Non-mutating on both platforms, so a rotation is observed one tick late. + /// `secure` reports whether the Android window has FLAG_SECURE; it is always + /// false elsewhere. + Future<({bool isActive, String? sessionId, bool secure})> + getSessionReplayState() async { if (kIsWeb) { // Flutter doesn't capture screenshots on web, JS SDK handles session replay - return (isActive: false, sessionId: null); + return (isActive: false, sessionId: null, secure: false); } try { final state = await _channel.invokeMapMethod( @@ -71,10 +74,11 @@ class NativeCommunicator { return ( isActive: state?['isActive'] as bool? ?? false, sessionId: state?['sessionId'] as String?, + secure: state?['secure'] as bool? ?? false, ); } catch (e) { printIfDebug('Error checking session replay status: $e'); - return (isActive: false, sessionId: null); + return (isActive: false, sessionId: null, secure: false); } } diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index e43143e8..17a2b1c3 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -36,6 +36,10 @@ class ImageInfo { /// it if the session rotates mid-flight. final String? sessionId; + /// Whether the Android window had FLAG_SECURE when this frame was captured, + /// so the native side masks it even if the flag clears before the send. + final bool secureAtCapture; + ImageInfo( this.id, this.x, @@ -46,6 +50,7 @@ class ImageInfo { this.imageBytes, { required this.sessionId, required this.generation, + this.secureAtCapture = false, }); } @@ -646,7 +651,6 @@ class ScreenshotCapturer { // Cleared again in _resolveCaptureTarget, but that runs after the round trip // below — whose post-await check would veto this capture on a prior flag. _cancelled = false; - // Also arms Android's FLAG_SECURE masking for the frame captured below. final state = await _nativeCommunicator.getSessionReplayState(); if (_cancelled) { return null; @@ -664,10 +668,12 @@ class ScreenshotCapturer { if (previousSessionId != null && state.sessionId != previousSessionId) { onSessionRotated?.call(); } - return _captureScreenshot(state.sessionId, _sessionGeneration); + return _captureScreenshot( + state.sessionId, _sessionGeneration, state.secure); } - Future _captureScreenshot(String? sessionId, int generation) { + Future _captureScreenshot( + String? sessionId, int generation, bool secureAtCapture) { final target = _resolveCaptureTarget(); if (target == null) { return Future.value(null); @@ -918,6 +924,7 @@ class ScreenshotCapturer { pngBytes, sessionId: sessionId, generation: generation, + secureAtCapture: secureAtCapture, ); // No status commit here: the sender may still drop this frame, and // committing for a never-sent frame breaks playback / freezes dedup. diff --git a/posthog_flutter/test/replay_screenshot_capture_test.dart b/posthog_flutter/test/replay_screenshot_capture_test.dart index 203f1c0d..e6c9a348 100644 --- a/posthog_flutter/test/replay_screenshot_capture_test.dart +++ b/posthog_flutter/test/replay_screenshot_capture_test.dart @@ -19,16 +19,18 @@ void main() { final calls = []; late PosthogFlutterPlatformInterface previousPlatform; var sessionId = 'session-a'; + var secure = false; setUp(() { previousPlatform = PosthogFlutterPlatformInterface.instance; PosthogFlutterPlatformInterface.instance = PosthogFlutterIO(); calls.clear(); sessionId = 'session-a'; + secure = false; messenger.setMockMethodCallHandler(channel, (call) async { calls.add(call); if (call.method == 'getSessionReplayState') { - return {'isActive': true, 'sessionId': sessionId}; + return {'isActive': true, 'sessionId': sessionId, 'secure': secure}; } return null; }); @@ -178,6 +180,17 @@ void main() { await unmount(tester); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + testWidgets('a frame captured on a secure window is sent as secureAtCapture', + (tester) async { + secure = true; + await mount(tester, 1.0); + final args = calls + .firstWhere((call) => call.method == 'sendFullSnapshot') + .arguments as Map; + expect(args['secureAtCapture'], isTrue); + await unmount(tester); + }, variant: TargetPlatformVariant.only(TargetPlatform.android)); + testWidgets('scaled screenshots retain at least one pixel', (tester) async { await mount(tester, 0.1, size: const Size(1, 1)); final args = calls From 3adc3de6eb2d873ba1a28d7e2f28af3177b8e7c9 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 15:23:41 -0400 Subject: [PATCH 5/5] test: check ordinary replay frames are not sent as secure --- posthog_flutter/test/replay_screenshot_capture_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/posthog_flutter/test/replay_screenshot_capture_test.dart b/posthog_flutter/test/replay_screenshot_capture_test.dart index e6c9a348..e8a3dabb 100644 --- a/posthog_flutter/test/replay_screenshot_capture_test.dart +++ b/posthog_flutter/test/replay_screenshot_capture_test.dart @@ -116,6 +116,7 @@ void main() { expect(image.height, (99 * scale).ceil()); expect(args['width'], 101); expect(args['height'], 99); + expect(args['secureAtCapture'], isFalse); expect(args['x'], 0); expect(args['y'], 0); final meta = calls.firstWhere((call) => call.method == 'sendMetaEvent');