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..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 @@ -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 @@ -472,8 +473,16 @@ class PosthogFlutterPlugin : mapOf( "isActive" to isSessionReplayActive(), "sessionId" to PostHogSessionManager.peekSessionId()?.toString(), + "secure" to isWindowSecure(), ) + private fun isWindowSecure(): Boolean = ((activity?.window?.attributes?.flags ?: 0) and FLAG_SECURE) != 0 + + // 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 isFrameMasked(secureAtCapture: Boolean): Boolean = secureAtCapture || isWindowSecure() + private fun startSessionRecording( call: MethodCall, result: Result, @@ -1322,8 +1331,9 @@ class PosthogFlutterPlugin : val timestampMs = replayTimeMillis() val replayConfig = postHogConfig?.sessionReplayConfig val quality = replayConfig?.screenshotCompressionQuality ?: DEFAULT_SCREENSHOT_COMPRESSION_QUALITY + val masked = isFrameMasked(call.argument("secureAtCapture") == true) 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..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 @@ -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,19 @@ 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 bitmap = + if (masked) { + 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 { + requireNotNull(BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)) + } 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..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 @@ -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,54 @@ 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 + } + + 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 getSessionReplayState_reportsTheWindowSecureFlag() { + val plugin = PosthogFlutterPlugin() + val attributes = attachActivityWithWindowFlags(plugin) + + assertEquals(false, reportedSecure(plugin)) + attributes.flags = WindowManager.LayoutParams.FLAG_SECURE + assertEquals(true, reportedSecure(plugin)) + } + + @Test + fun frameCapturedOnSecureWindow_isMaskedAfterTheFlagClears() { + val plugin = PosthogFlutterPlugin() + attachActivityWithWindowFlags(plugin) + + assertTrue(plugin.isFrameMasked(secureAtCapture = true)) + } + + @Test + fun frameSentOnSecureWindow_isMaskedOnlyWhileTheFlagIsSet() { + val plugin = PosthogFlutterPlugin() + val attributes = attachActivityWithWindowFlags(plugin) + + assertFalse(plugin.isFrameMasked(secureAtCapture = false)) + attributes.flags = WindowManager.LayoutParams.FLAG_SECURE + assertTrue(plugin.isFrameMasked(secureAtCapture = false)) + } + @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() + } + } } 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 62b56b26..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) { @@ -57,10 +59,13 @@ class NativeCommunicator { /// native SDK actually holds without a second round trip per tick. /// /// Non-mutating on both platforms, so a rotation is observed one tick late. - Future<({bool isActive, String? sessionId})> getSessionReplayState() async { + /// `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( @@ -69,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 e9bc9fce..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, }); } @@ -663,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); @@ -917,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..e8a3dabb 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; }); @@ -114,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'); @@ -178,6 +181,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