Skip to content
Merged
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/android-replay-flag-secure.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Boolean>("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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -358,6 +360,54 @@ internal class PosthogFlutterPluginTest {
Mockito.verify(mockResult).success(emptyList<ByteArray?>())
}

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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
1 change: 1 addition & 0 deletions posthog_flutter/lib/src/posthog_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ class PostHogWidgetState extends State<PostHogWidget> {
y: imageInfo.y,
width: imageInfo.width,
height: imageInfo.height,
secureAtCapture: imageInfo.secureAtCapture,
) ??
false;
if (!snapshotSent) {
Expand Down
12 changes: 9 additions & 3 deletions posthog_flutter/lib/src/replay/native_communicator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class NativeCommunicator {
required int y,
int? width,
int? height,
bool secureAtCapture = false,
}) async {
try {
await _channel.invokeMethod('sendFullSnapshot', {
Expand All @@ -23,6 +24,7 @@ class NativeCommunicator {
'y': y,
if (width != null) 'width': width,
if (height != null) 'height': height,
'secureAtCapture': secureAtCapture,
});
return true;
} catch (e) {
Expand Down Expand Up @@ -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<String, Object?>(
Expand All @@ -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);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -46,6 +50,7 @@ class ImageInfo {
this.imageBytes, {
required this.sessionId,
required this.generation,
this.secureAtCapture = false,
});
}

Expand Down Expand Up @@ -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<ImageInfo?> _captureScreenshot(String? sessionId, int generation) {
Future<ImageInfo?> _captureScreenshot(
String? sessionId, int generation, bool secureAtCapture) {
final target = _resolveCaptureTarget();
if (target == null) {
return Future.value(null);
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion posthog_flutter/test/replay_screenshot_capture_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,18 @@ void main() {
final calls = <MethodCall>[];
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;
});
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
Loading