diff --git a/android/src/main/kotlin/com/posthog/posthog_flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/posthog_flutter/PosthogFlutterPlugin.kt index fb97fbf..7587015 100644 --- a/android/src/main/kotlin/com/posthog/posthog_flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/posthog_flutter/PosthogFlutterPlugin.kt @@ -3,6 +3,8 @@ package com.posthog.posthog_flutter import android.content.Context import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Build import android.os.Bundle import android.util.Log @@ -11,11 +13,19 @@ import com.posthog.PostHog import com.posthog.PostHogConfig import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig +import com.posthog.internal.replay.RRFullSnapshotEvent +import com.posthog.internal.replay.RRIncrementalMutationData +import com.posthog.internal.replay.RRIncrementalSnapshotEvent +import com.posthog.internal.replay.RRMutatedNode +import com.posthog.internal.replay.RRStyle +import com.posthog.internal.replay.RRWireframe +import com.posthog.internal.replay.capture 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 /** PosthogFlutterPlugin */ class PosthogFlutterPlugin : @@ -29,6 +39,9 @@ class PosthogFlutterPlugin : private lateinit var applicationContext: Context + private val snapshotSender = SnapshotSender() + + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "posthog_flutter") @@ -163,6 +176,12 @@ class PosthogFlutterPlugin : "close" -> { close(result) } + "sendFullSnapshot" -> { + handleSendFullSnapshot(call, result) + } + "sendIncrementalSnapshot" -> { + handleSendIncrementalSnapshot(call, result) + } else -> { result.notImplemented() } @@ -235,8 +254,19 @@ class PosthogFlutterPlugin : "identifiedOnly" -> personProfiles = PersonProfiles.IDENTIFIED_ONLY } } + posthogConfig.getIfNotNull("enableSessionReplay") { + sessionReplay = it + } + + posthogConfig.getIfNotNull>("sessionReplayConfig") { sessionReplayConfig -> + sessionReplayConfig.getIfNotNull("androidDebouncerDelayMs") { + this.sessionReplayConfig.debouncerDelayMs = it + } + } + sdkName = "posthog-flutter" sdkVersion = postHogVersion + } PostHogAndroid.setup(applicationContext, config) } @@ -245,6 +275,28 @@ class PosthogFlutterPlugin : channel.setMethodCallHandler(null) } + private fun handleSendFullSnapshot(call: MethodCall, result: Result) { + val imageBytes = call.argument("imageBytes") + val id = call.argument("id") ?: 1 + if (imageBytes != null) { + snapshotSender.sendFullSnapshot(imageBytes, id) + result.success(null) + } else { + result.error("INVALID_ARGUMENT", "Image bytes are null", null) + } + } + + private fun handleSendIncrementalSnapshot(call: MethodCall, result: Result) { + val imageBytes = call.argument("imageBytes") + val id = call.argument("id") ?: 1 + if (imageBytes != null) { + snapshotSender.sendIncrementalSnapshot(imageBytes, id) + result.success(null) + } else { + result.error("INVALID_ARGUMENT", "Image bytes are null", null) + } + } + private fun getFeatureFlag( call: MethodCall, result: Result, diff --git a/android/src/main/kotlin/com/posthog/posthog_flutter/SnapshotSender.kt b/android/src/main/kotlin/com/posthog/posthog_flutter/SnapshotSender.kt new file mode 100644 index 0000000..88b7efb --- /dev/null +++ b/android/src/main/kotlin/com/posthog/posthog_flutter/SnapshotSender.kt @@ -0,0 +1,86 @@ +package com.posthog.posthog_flutter + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import com.posthog.internal.replay.* +import java.io.ByteArrayOutputStream + +/* +* TEMPORARY CLASS FOR TESTING PURPOSES +* This function sends a screenshot to PostHog. +* It should be removed or refactored in the other version. +*/ +class SnapshotSender { + + fun sendFullSnapshot(imageBytes: ByteArray, id: Int = 1) { + val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + val base64String = bitmapToBase64(bitmap) + + val wireframe = RRWireframe( + id = id, + x = 0, + y = 0, + width = bitmap.width, + height = bitmap.height, + type = "screenshot", + base64 = base64String, + style = RRStyle() + ) + + val snapshotEvent = RRFullSnapshotEvent( + listOf(wireframe), + initialOffsetTop = 0, + initialOffsetLeft = 0, + timestamp = System.currentTimeMillis() + ) + + Log.d("Snapshot", "Sending Full Snapshot") + listOf(snapshotEvent).capture() + } + + fun sendIncrementalSnapshot(imageBytes: ByteArray, id: Int = 1) { + val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + val base64String = bitmapToBase64(bitmap) + + val wireframe = RRWireframe( + id = id, + x = 0, + y = 0, + width = bitmap.width, + height = bitmap.height, + type = "screenshot", + base64 = base64String, + style = RRStyle() + ) + + val mutatedNode = RRMutatedNode(wireframe, parentId = null) + val updatedNodes = listOf(mutatedNode) + + val incrementalMutationData = RRIncrementalMutationData( + adds = null, + removes = null, + updates = updatedNodes + ) + + val incrementalSnapshotEvent = RRIncrementalSnapshotEvent( + mutationData = incrementalMutationData, + timestamp = System.currentTimeMillis() + ) + + Log.d("Snapshot", "Sending Incremental Snapshot") + listOf(incrementalSnapshotEvent).capture() + } + + private fun bitmapToBase64(bitmap: Bitmap): String? { + ByteArrayOutputStream().use { byteArrayOutputStream -> + bitmap.compress( + Bitmap.CompressFormat.JPEG, + 30, + byteArrayOutputStream + ) + val byteArray = byteArrayOutputStream.toByteArray() + return android.util.Base64.encodeToString(byteArray, android.util.Base64.NO_WRAP) + } + } +} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index bb059f0..649438f 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -36,7 +36,7 @@ special context set for you by the time it is initialized. --> + android:value="_6SG-F7I1vCuZ-HdJL3VZQqjBlaSb1_20hDPwqMNnGI" /> @@ -46,7 +46,5 @@ - diff --git a/example/assets/posthog_logo.png b/example/assets/posthog_logo.png new file mode 100644 index 0000000..3d3323b Binary files /dev/null and b/example/assets/posthog_logo.png differ diff --git a/example/assets/training_posthog.png b/example/assets/training_posthog.png new file mode 100644 index 0000000..427d74a Binary files /dev/null and b/example/assets/training_posthog.png differ diff --git a/example/lib/main.dart b/example/lib/main.dart index e525d84..4e091d3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -4,13 +4,19 @@ import 'package:posthog_flutter/posthog_flutter.dart'; Future main() async { // // init WidgetsFlutterBinding if not yet - // WidgetsFlutterBinding.ensureInitialized(); - // final config = - // PostHogConfig('phc_QFbR1y41s5sxnNTZoyKG2NJo2RlsCIWkUfdpawgb40D'); - // config.debug = true; - // config.captureApplicationLifecycleEvents = true; - // config.host = 'https://us.i.posthog.com'; - // await Posthog().setup(config); + /* + WidgetsFlutterBinding.ensureInitialized(); + final config = + PostHogConfig('phc_l9TgCltyBi2JjR5OnCO8tjNeuEhgbvYTuyG7cHgQuRu'); + config.debug = true; + config.captureApplicationLifecycleEvents = true; + config.host = 'https://us.i.posthog.com'; + config.enableSessionReplay = true; + config.postHogSessionReplayConfig.maskAllTextInputs = true; + config.postHogSessionReplayConfig.maskAllImages = true; + config.postHogSessionReplayConfig.androidDebouncerDelay = const Duration(milliseconds: 200); + await Posthog().setup(config); + */ runApp(const MyApp()); } @@ -23,6 +29,31 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return PostHogScreenshotWidget( + child: MaterialApp( + title: 'Flutter App', + home: InitialScreen(), + ), + ); + } +} + +class InitialScreen extends StatefulWidget { + InitialScreen({Key? key}) : super(key: key); + + @override + _InitialScreenState createState() => _InitialScreenState(); +} + +class _InitialScreenState extends State { final _posthogFlutterPlugin = Posthog(); dynamic _result = ""; @@ -33,209 +64,297 @@ class _MyAppState extends State { @override Widget build(BuildContext context) { - return MaterialApp( - navigatorObservers: [ - // The PosthogObserver records screen views automatically - PosthogObserver() - ], - home: Scaffold( - appBar: AppBar( - title: const Text('PostHog Flutter App'), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: Column( - children: [ - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - "Capture", - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton( - onPressed: () { - _posthogFlutterPlugin - .screen(screenName: "my screen", properties: { - "foo": "bar", - }); - }, - child: const Text("Capture Screen manually"), - ), - ElevatedButton( - onPressed: () { - _posthogFlutterPlugin - .capture(eventName: "eventName", properties: { - "foo": "bar", - }); - }, - child: const Text("Capture Event"), - ), - ], + return Scaffold( + appBar: AppBar( + title: const Text('PostHog Flutter App'), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Column( + children: [ + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FirstRoute()), + ); + }, + child: const Text('Go to Second Route'), + ), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "Capture", + style: TextStyle(fontWeight: FontWeight.bold), ), - const Divider(), - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - "Activity", - style: TextStyle(fontWeight: FontWeight.bold), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + onPressed: () { + _posthogFlutterPlugin.screen(screenName: "my screen", properties: { + "foo": "bar", + }); + }, + child: const Text("Capture Screen manually"), + ), + ElevatedButton( + onPressed: () { + _posthogFlutterPlugin.capture(eventName: "eventName", properties: { + "foo": "bar", + }); + }, + child: const Text("Capture Event"), ), + ], + ), + const Divider(), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "Activity", + style: TextStyle(fontWeight: FontWeight.bold), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - ), - onPressed: () { - _posthogFlutterPlugin.disable(); - }, - child: const Text("Disable Capture"), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - ), - onPressed: () { - _posthogFlutterPlugin.enable(); - }, - child: const Text("Enable Capture"), + onPressed: () { + _posthogFlutterPlugin.disable(); + }, + child: const Text("Disable Capture"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, ), - ], - ), - ElevatedButton( - onPressed: () async { - await _posthogFlutterPlugin.register("foo", "bar"); - }, - child: const Text("Register"), - ), - ElevatedButton( - onPressed: () async { - await _posthogFlutterPlugin.unregister("foo"); - }, - child: const Text("Unregister"), - ), - ElevatedButton( - onPressed: () async { - await _posthogFlutterPlugin.group( - groupType: "theType", - groupKey: "theKey", - groupProperties: { - "foo": "bar", - }); - }, - child: const Text("Group"), - ), - ElevatedButton( - onPressed: () async { - await _posthogFlutterPlugin - .identify(userId: "myId", userProperties: { - "foo": "bar", - }, userPropertiesSetOnce: { - "foo1": "bar1", - }); - }, - child: const Text("Identify"), - ), - ElevatedButton( - onPressed: () async { - await _posthogFlutterPlugin.alias(alias: "myAlias"); - }, - child: const Text("Alias"), - ), - ElevatedButton( - onPressed: () async { - await _posthogFlutterPlugin.debug(true); - }, - child: const Text("Debug"), - ), - ElevatedButton( - onPressed: () async { - await _posthogFlutterPlugin.reset(); - }, - child: const Text("Reset"), - ), - ElevatedButton( - onPressed: () async { - await _posthogFlutterPlugin.flush(); - }, - child: const Text("Flush"), - ), - ElevatedButton( - onPressed: () async { - final result = - await _posthogFlutterPlugin.getDistinctId(); - setState(() { - _result = result; - }); - }, - child: const Text("distinctId"), - ), - const Divider(), - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - "Feature flags", - style: TextStyle(fontWeight: FontWeight.bold), + onPressed: () { + _posthogFlutterPlugin.enable(); + }, + child: const Text("Enable Capture"), ), + ], + ), + ElevatedButton( + onPressed: () async { + await _posthogFlutterPlugin.register("foo", "bar"); + }, + child: const Text("Register"), + ), + ElevatedButton( + onPressed: () async { + await _posthogFlutterPlugin.unregister("foo"); + }, + child: const Text("Unregister"), + ), + ElevatedButton( + onPressed: () async { + await _posthogFlutterPlugin.group( + groupType: "theType", + groupKey: "theKey", + groupProperties: { + "foo": "bar", + }); + }, + child: const Text("Group"), + ), + ElevatedButton( + onPressed: () async { + await _posthogFlutterPlugin + .identify(userId: "myId", userProperties: { + "foo": "bar", + }, userPropertiesSetOnce: { + "foo1": "bar1", + }); + }, + child: const Text("Identify"), + ), + ElevatedButton( + onPressed: () async { + await _posthogFlutterPlugin.alias(alias: "myAlias"); + }, + child: const Text("Alias"), + ), + ElevatedButton( + onPressed: () async { + await _posthogFlutterPlugin.debug(true); + }, + child: const Text("Debug"), + ), + ElevatedButton( + onPressed: () async { + await _posthogFlutterPlugin.reset(); + }, + child: const Text("Reset"), + ), + ElevatedButton( + onPressed: () async { + await _posthogFlutterPlugin.flush(); + }, + child: const Text("Flush"), + ), + ElevatedButton( + onPressed: () async { + final result = await _posthogFlutterPlugin.getDistinctId(); + setState(() { + _result = result; + }); + }, + child: const Text("distinctId"), + ), + const Divider(), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "Feature flags", + style: TextStyle(fontWeight: FontWeight.bold), ), - ElevatedButton( - onPressed: () async { - final result = await _posthogFlutterPlugin - .getFeatureFlag("feature_name"); - setState(() { - _result = result; - }); - }, - child: const Text("Get Feature Flag status"), - ), - ElevatedButton( - onPressed: () async { - final result = await _posthogFlutterPlugin - .isFeatureEnabled("feature_name"); - setState(() { - _result = result; - }); - }, - child: const Text("isFeatureEnabled"), - ), - ElevatedButton( - onPressed: () async { - final result = await _posthogFlutterPlugin - .getFeatureFlagPayload("feature_name"); - setState(() { - _result = result; - }); - }, - child: const Text("getFeatureFlagPayload"), + ), + ElevatedButton( + onPressed: () async { + final result = + await _posthogFlutterPlugin.getFeatureFlag("feature_name"); + setState(() { + _result = result; + }); + }, + child: const Text("Get Feature Flag status"), + ), + ElevatedButton( + onPressed: () async { + final result = + await _posthogFlutterPlugin.isFeatureEnabled("feature_name"); + setState(() { + _result = result; + }); + }, + child: const Text("isFeatureEnabled"), + ), + ElevatedButton( + onPressed: () async { + final result = await _posthogFlutterPlugin + .getFeatureFlagPayload("feature_name"); + setState(() { + _result = result; + }); + }, + child: const Text("getFeatureFlagPayload"), + ), + ElevatedButton( + onPressed: () async { + await _posthogFlutterPlugin.reloadFeatureFlags(); + }, + child: const Text("reloadFeatureFlags"), + ), + const Divider(), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "Data result", + style: TextStyle(fontWeight: FontWeight.bold), ), - ElevatedButton( - onPressed: () async { - await _posthogFlutterPlugin.reloadFeatureFlags(); - }, - child: const Text("reloadFeatureFlags"), - ), - const Divider(), - const Padding( - padding: EdgeInsets.all(8.0), - child: Text( - "Data result", - style: TextStyle(fontWeight: FontWeight.bold), + ), + Text(_result.toString()), + ], + ), + ), + ), + ), + ); + } +} + +class FirstRoute extends StatefulWidget { + const FirstRoute({super.key}); + + @override + _FirstRouteState createState() => _FirstRouteState(); +} + +class _FirstRouteState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('First Route'), + ), + body: Center( + child: RepaintBoundary( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + child: const Text('Open route'), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ThirdRoute(), ), - ), - Text(_result.toString()), - ], + ).then((_) {}); + }, ), - ), + const SizedBox(height: 20), + const TextField( + decoration: InputDecoration( + labelText: 'Sensitive Text Input', + hintText: 'Enter sensitive data', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 20), + Image.asset( + 'assets/training_posthog.png', + height: 200, + ), + const SizedBox(height: 20), + ], ), ), ), ); } } + + +class ThirdRoute extends StatelessWidget { + const ThirdRoute({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Third Route'), + ), + body: Padding( + padding: const EdgeInsets.all(8.0), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 10.0, + mainAxisSpacing: 10.0, + ), + itemCount: 16, + itemBuilder: (context, index) { + return Image.asset( + 'assets/posthog_logo.png', + fit: BoxFit.cover, + ); + }, + ), + ), + ); + } +} \ No newline at end of file diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 0cfab4c..99ad242 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -49,6 +49,8 @@ flutter: # included with your application, so that you can use the icons in # the material Icons class. uses-material-design: true + assets: + - assets/ # To add assets to your application, add an assets section, like this: # assets: diff --git a/lib/posthog_flutter.dart b/lib/posthog_flutter.dart index 1e95495..ba1980d 100644 --- a/lib/posthog_flutter.dart +++ b/lib/posthog_flutter.dart @@ -3,3 +3,4 @@ library posthog_flutter; export 'src/posthog.dart'; export 'src/posthog_config.dart'; export 'src/posthog_observer.dart'; +export 'src/replay/posthog_screenshot_widget.dart'; diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 88b34fc..305f6ef 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -7,6 +7,8 @@ class Posthog { static final _instance = Posthog._internal(); + PostHogConfig? _config; + factory Posthog() { return _instance; } @@ -17,7 +19,12 @@ class Posthog { /// Only used for the manual setup /// Requires disabling the automatic init on Android and iOS: /// com.posthog.posthog.AUTO_INIT: false - Future setup(PostHogConfig config) => _posthog.setup(config); + Future setup(PostHogConfig config) { + _config = config; // Store the config + return _posthog.setup(config); + } + + PostHogConfig? get config => _config; Future identify({ required String userId, diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 59d65c4..7fe5d0f 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -15,6 +15,9 @@ class PostHogConfig { var debug = false; var optOut = false; var personProfiles = PostHogPersonProfiles.identifiedOnly; + var enableSessionReplay = false; + + var postHogSessionReplayConfig = PostHogSessionReplayConfig(); /// iOS only var dataMode = PostHogDataMode.any; @@ -38,7 +41,55 @@ class PostHogConfig { 'debug': debug, 'optOut': optOut, 'personProfiles': personProfiles.name, + 'enableSessionReplay':enableSessionReplay, 'dataMode': dataMode.name, + 'sessionReplayConfig': postHogSessionReplayConfig.toMap(), + }; + } +} + +class PostHogSessionReplayConfig { + /// Enable masking of all text input fields + /// Experimental support + /// Default: true + var maskAllTextInputs = true; + + /// Enable masking of all images to a placeholder + /// Experimental support + /// Default: true + var maskAllImages = true; + + /// Enable capturing of logcat as console events + /// Android only + /// Experimental support + /// Default: true + var captureLog = true; + + /// Debouncer delay used to reduce the number of snapshots captured and reduce performance impact + /// This is used for capturing the view as a screenshot + /// The lower the number, the more snapshots will be captured but higher the performance impact + /// Defaults to 1s on iOS + var iOSDebouncerDelay = const Duration(milliseconds: 200); + + /// Debouncer delay used to reduce the number of snapshots captured and reduce performance impact + /// This is used for capturing the view as a screenshot + /// The lower the number, the more snapshots will be captured but higher the performance impact + /// Defaults to 0.3s on Android + var androidDebouncerDelay = const Duration(milliseconds: 200); + + /// Enable capturing network telemetry + /// iOS only + /// Experimental support + /// Default: true + var captureNetworkTelemetry = true; + + Map toMap() { + return { + 'maskAllImages': maskAllImages, + 'captureLog': captureLog, + 'iOSDebouncerDelayMs': iOSDebouncerDelay.inMilliseconds, + 'androidDebouncerDelayMs': androidDebouncerDelay.inMilliseconds, + 'captureNetworkTelemetry': captureNetworkTelemetry, }; } } diff --git a/lib/src/replay/change_detector.dart b/lib/src/replay/change_detector.dart new file mode 100644 index 0000000..5f280b9 --- /dev/null +++ b/lib/src/replay/change_detector.dart @@ -0,0 +1,62 @@ +import 'package:flutter/widgets.dart'; + +/// A class that detects changes in the UI and executes a callback when changes occur. +/// +/// The `ChangeDetector` continuously monitors the Flutter widget tree by scheduling +/// a callback after each frame is rendered. This is useful when you need to perform +/// an action whenever the UI updates. +/// +/// **Usage:** +/// ```dart +/// final changeDetector = ChangeDetector(() { +/// // Code to execute when a UI change is detected. +/// print('UI has updated.'); +/// }); +/// +/// changeDetector.start(); +/// ``` +/// +/// **Note:** Since the `onChange` callback is called after every frame, ensure that +/// the operations performed are efficient to avoid impacting app performance. +class ChangeDetector { + final VoidCallback onChange; + bool _isRunning = false; + + /// Creates a [ChangeDetector] with the given [onChange] callback. + ChangeDetector(this.onChange); + + /// Starts the change detection process. + /// + /// This method schedules the [_onFrameRendered] callback to be called + /// after each frame is rendered. + void start() { + if (!_isRunning) { + _isRunning = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _onFrameRendered(); + }); + } + } + + /// Stops the change detection process. + /// + /// This prevents the [onChange] callback from being called after each frame. + void stop() { + _isRunning = false; + } + + /// Internal method called after each frame is rendered. + /// + /// Executes the [onChange] callback and schedules itself for the next frame + /// if the change detector is still running. + void _onFrameRendered() { + if (!_isRunning) { + return; + } + + onChange(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _onFrameRendered(); + }); + } +} diff --git a/lib/src/replay/element_parsers/element_data.dart b/lib/src/replay/element_parsers/element_data.dart new file mode 100644 index 0000000..542ef8b --- /dev/null +++ b/lib/src/replay/element_parsers/element_data.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +class ElementData { + List? children; + Rect rect; + String type; + + ElementData({ + this.children, + required this.rect, + required this.type, + }); + + void addChildren(ElementData elementData) { + children ??= []; + children!.add(elementData); + } + + //TODO: THIS MAY BE BETTER + List extractRects([bool isRoot = true]) { + List rects = []; + + if (!isRoot) { + rects.add(rect); + } + + if (children != null) { + for (var child in children!) { + if (child.children == null) { + rects.add(child.rect); + continue; + } + if (child.children!.length > 1) { + for (var grandChild in child.children!) { + rects.add(grandChild.rect); + } + } else { + rects.add(child.rect); + } + } + } + return rects; + } +} diff --git a/lib/src/replay/element_parsers/element_data_factory.dart b/lib/src/replay/element_parsers/element_data_factory.dart new file mode 100644 index 0000000..326dd37 --- /dev/null +++ b/lib/src/replay/element_parsers/element_data_factory.dart @@ -0,0 +1,23 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; + +class ElementDataFactory { + /// Creates an ElementData object from an Element + ElementData? createFromElement(Element element, String type) { + final renderObject = element.renderObject; + if (renderObject is RenderBox && renderObject.hasSize) { + final offset = renderObject.localToGlobal(Offset.zero); + return ElementData( + type: type, + rect: Rect.fromLTWH( + offset.dx, + offset.dy, + renderObject.size.width, + renderObject.size.height, + ), + ); + } + return null; + } +} diff --git a/lib/src/replay/element_parsers/element_object_parser.dart b/lib/src/replay/element_parsers/element_object_parser.dart new file mode 100644 index 0000000..45a8d48 --- /dev/null +++ b/lib/src/replay/element_parsers/element_object_parser.dart @@ -0,0 +1,25 @@ +import 'package:flutter/cupertino.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; + +class ElementObjectParser { + ElementData? relateRenderObject( + ElementData activeElementData, + Element element, + ) { + if (element.renderObject is RenderBox) { + final String dataType = element.renderObject.runtimeType.toString(); + + final parser = PostHogMaskController.instance.parsers[dataType]; + if (parser != null) { + final elementData = parser.relate(element, activeElementData); + + if (elementData != null) { + activeElementData.addChildren(elementData); + return elementData; + } + } + } + return null; + } +} diff --git a/lib/src/replay/element_parsers/element_parser.dart b/lib/src/replay/element_parsers/element_parser.dart new file mode 100644 index 0000000..92fa44b --- /dev/null +++ b/lib/src/replay/element_parsers/element_parser.dart @@ -0,0 +1,38 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; + +class ElementParser { + ElementParser(); + + ElementData? relate( + Element element, + ElementData parentElementData, + ) { + final Rect? elementRect = buildElementRect(element, parentElementData.rect); + if (elementRect == null) { + return null; + } + + final thisElementData = ElementData( + type: element.widget.runtimeType.toString(), + rect: elementRect, + ); + + return thisElementData; + } + + Rect? buildElementRect(Element element, Rect? parentRect) { + final renderObject = element.renderObject; + if (renderObject is RenderBox && renderObject.hasSize) { + final Offset offset = renderObject.localToGlobal(Offset.zero); + return Rect.fromLTWH( + offset.dx, + offset.dy, + renderObject.size.width, + renderObject.size.height, + ); + } + return null; + } +} diff --git a/lib/src/replay/element_parsers/element_parser_factory.dart b/lib/src/replay/element_parsers/element_parser_factory.dart new file mode 100644 index 0000000..f6a53e8 --- /dev/null +++ b/lib/src/replay/element_parsers/element_parser_factory.dart @@ -0,0 +1,27 @@ +import 'package:flutter/rendering.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/image_element/position_calculator.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/image_element/render_image_parser.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/image_element/scaler.dart'; + + +abstract class ElementParserFactory { + ElementParser createElementParser(Type type); +} + +class DefaultElementParserFactory implements ElementParserFactory { + @override + ElementParser createElementParser(Type type) { + if (type == RenderImage) { + return RenderImageParser( + scaler: ImageScaler(), + positionCalculator: DefaultPositionCalculator(), + ); + } else if (type == RenderParagraph || type == RenderTransform) { + return ElementParser(); + } + + // Default fallback + return ElementParser(); + } +} diff --git a/lib/src/replay/element_parsers/element_parsers_const.dart b/lib/src/replay/element_parsers/element_parsers_const.dart new file mode 100644 index 0000000..a4397d0 --- /dev/null +++ b/lib/src/replay/element_parsers/element_parsers_const.dart @@ -0,0 +1,31 @@ +import 'package:flutter/rendering.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_parser_factory.dart'; + +class ElementParsersConst { + final ElementParserFactory _factory; + final Map parsersMap = {}; + + ElementParsersConst(this._factory, PostHogSessionReplayConfig config) { + if (config.maskAllImages) { + registerElementParser(); + } + if (config.maskAllTextInputs) { + registerElementParser(); + registerElementParser(); + } + } + + void registerElementParser() { + parsersMap[getRuntimeType()] = _factory.createElementParser(T); + } + + ElementParser? getParserForType(Type type) { + return parsersMap[getRuntimeTypeForType(type)]; + } + + static String getRuntimeType() => T.toString(); + + static String getRuntimeTypeForType(Type type) => type.toString(); +} diff --git a/lib/src/replay/element_parsers/image_element/position_calculator.dart b/lib/src/replay/element_parsers/image_element/position_calculator.dart new file mode 100644 index 0000000..a325a9e --- /dev/null +++ b/lib/src/replay/element_parsers/image_element/position_calculator.dart @@ -0,0 +1,39 @@ +import 'package:flutter/rendering.dart'; + +abstract class PositionCalculator { + double calculateLeftPosition( + AlignmentGeometry alignment, Offset offset, double containerWidth, double renderBoxWidth); + + double calculateTopPosition( + AlignmentGeometry alignment, Offset offset, double containerHeight, double renderBoxHeight); +} + +class DefaultPositionCalculator implements PositionCalculator { + @override + double calculateLeftPosition( + AlignmentGeometry alignment, Offset offset, double containerWidth, double renderBoxWidth) { + if (alignment == Alignment.centerLeft || alignment == Alignment.bottomLeft || alignment == Alignment.topLeft) { + return offset.dx; + } else if (alignment == Alignment.bottomRight || + alignment == Alignment.centerRight || + alignment == Alignment.topRight) { + return offset.dx + containerWidth - renderBoxWidth; + } + + return offset.dx + (containerWidth - renderBoxWidth) / 2; + } + + @override + double calculateTopPosition( + AlignmentGeometry alignment, Offset offset, double containerHeight, double renderBoxHeight) { + if (alignment == Alignment.topLeft || alignment == Alignment.topCenter || alignment == Alignment.topRight) { + return offset.dy; + } else if (alignment == Alignment.bottomRight || + alignment == Alignment.bottomLeft || + alignment == Alignment.bottomCenter) { + return offset.dy + containerHeight - renderBoxHeight; + } + + return offset.dy + (containerHeight - renderBoxHeight) / 2; + } +} diff --git a/lib/src/replay/element_parsers/image_element/render_image_parser.dart b/lib/src/replay/element_parsers/image_element/render_image_parser.dart new file mode 100644 index 0000000..6f22a45 --- /dev/null +++ b/lib/src/replay/element_parsers/image_element/render_image_parser.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart'; + +import 'position_calculator.dart'; +import 'scaler.dart'; + +class RenderImageParser extends ElementParser { + final Scaler _scaler; + final PositionCalculator _positionCalculator; + + RenderImageParser({ + required Scaler scaler, + required PositionCalculator positionCalculator, + }) : _scaler = scaler, + _positionCalculator = positionCalculator; + + @override + Rect? buildElementRect(Element element, Rect? parentRect) { + final RenderImage renderImage = element.renderObject as RenderImage; + if (!renderImage.hasSize) { + return null; + } + + final offset = renderImage.localToGlobal(Offset.zero); + final BoxFit fit = renderImage.fit ?? BoxFit.scaleDown; + + final Size size = _scaler.getScaledSize( + renderImage.image!.width.toDouble(), + renderImage.image!.height.toDouble(), + renderImage.size, + fit, + ); + + final AlignmentGeometry alignment = renderImage.alignment; + + final double left = + _positionCalculator.calculateLeftPosition(alignment, offset, renderImage.size.width, size.width); + final double top = + _positionCalculator.calculateTopPosition(alignment, offset, renderImage.size.height, size.height); + + return Rect.fromLTWH(left, top, size.width, size.height); + } +} diff --git a/lib/src/replay/element_parsers/image_element/scaler.dart b/lib/src/replay/element_parsers/image_element/scaler.dart new file mode 100644 index 0000000..fb33b36 --- /dev/null +++ b/lib/src/replay/element_parsers/image_element/scaler.dart @@ -0,0 +1,37 @@ +import 'package:flutter/rendering.dart'; + +abstract class Scaler { + Size getScaledSize(double originalWidth, double originalHeight, Size targetSize, BoxFit fit); +} + +class ImageScaler implements Scaler { + @override + Size getScaledSize(double originalWidth, double originalHeight, Size targetSize, BoxFit fit) { + final double aspectRatio = originalWidth / originalHeight; + switch (fit) { + case BoxFit.fill: + return Size(targetSize.width, targetSize.height); + case BoxFit.contain: + if (targetSize.width / aspectRatio <= targetSize.height) { + return Size(targetSize.width, targetSize.width / aspectRatio); + } + return Size(targetSize.height * aspectRatio, targetSize.height); + case BoxFit.cover: + if (targetSize.width / aspectRatio >= targetSize.height) { + return Size(targetSize.width, targetSize.width / aspectRatio); + } + return Size(targetSize.height * aspectRatio, targetSize.height); + case BoxFit.fitWidth: + return Size(targetSize.width, targetSize.width / aspectRatio); + case BoxFit.fitHeight: + return Size(targetSize.height * aspectRatio, targetSize.height); + case BoxFit.none: + return Size(originalWidth, originalHeight); + case BoxFit.scaleDown: + if (originalWidth > targetSize.width || originalHeight > targetSize.height) { + return getScaledSize(originalWidth, originalHeight, targetSize, BoxFit.contain); + } + return Size(originalWidth, originalHeight); + } + } +} diff --git a/lib/src/replay/element_parsers/parsers.dart b/lib/src/replay/element_parsers/parsers.dart new file mode 100644 index 0000000..ca1ff74 --- /dev/null +++ b/lib/src/replay/element_parsers/parsers.dart @@ -0,0 +1,3 @@ +library element_parsers; + +export 'element_parsers_const.dart'; diff --git a/lib/src/replay/element_parsers/root_element_provider.dart b/lib/src/replay/element_parsers/root_element_provider.dart new file mode 100644 index 0000000..d8652a4 --- /dev/null +++ b/lib/src/replay/element_parsers/root_element_provider.dart @@ -0,0 +1,17 @@ +import 'package:flutter/widgets.dart'; + +class RootElementProvider { + Element? getRootElement(BuildContext context) { + Element? rootElement; + if (ModalRoute.of(context)?.isActive ?? false) { + Navigator.of(context, rootNavigator: true).context.visitChildElements((element) { + rootElement = element; + }); + } else { + context.visitChildElements((element) { + rootElement = element; + }); + } + return rootElement; + } +} diff --git a/lib/src/replay/mask/image_mask_painter.dart b/lib/src/replay/mask/image_mask_painter.dart new file mode 100644 index 0000000..2f1fab0 --- /dev/null +++ b/lib/src/replay/mask/image_mask_painter.dart @@ -0,0 +1,35 @@ +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; + +class ImageMaskPainter { + Future drawMaskedImage(ui.Image image, List rects, double pixelRatio) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final paint = Paint(); + + canvas.drawImage(image, Offset.zero, paint); + + final rectPaint = Paint() + ..color = Colors.black + ..style = PaintingStyle.fill; + + for (Rect rect in rects) { + Rect scaledRect = Rect.fromLTRB( + rect.left * pixelRatio, + rect.top * pixelRatio, + rect.right * pixelRatio, + rect.bottom * pixelRatio, + ); + canvas.drawRect(scaledRect, rectPaint); + } + + final picture = recorder.endRecording(); + + final maskedImage = await picture.toImage( + (image.width * pixelRatio).round(), + (image.height * pixelRatio).round(), + ); + image.dispose(); + return maskedImage; + } +} diff --git a/lib/src/replay/mask/posthog_mask_controller.dart b/lib/src/replay/mask/posthog_mask_controller.dart new file mode 100644 index 0000000..5391830 --- /dev/null +++ b/lib/src/replay/mask/posthog_mask_controller.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_parser.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_parser_factory.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_parsers_const.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data_factory.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_object_parser.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/root_element_provider.dart'; +import 'package:posthog_flutter/src/replay/mask/widget_elements_decipher.dart'; + +class PostHogMaskController { + late final Map parsers; + + final GlobalKey containerKey = GlobalKey(); + + final WidgetElementsDecipher _widgetScraper; + + PostHogMaskController._privateConstructor(PostHogSessionReplayConfig config) + : _widgetScraper = WidgetElementsDecipher( + elementDataFactory: ElementDataFactory(), + elementObjectParser: ElementObjectParser(), + rootElementProvider: RootElementProvider(), + ) { + parsers = ElementParsersConst(DefaultElementParserFactory(), config).parsersMap; + } + + static final PostHogMaskController instance = + PostHogMaskController._privateConstructor(Posthog().config!.postHogSessionReplayConfig); + + Future?> getCurrentScreenRects() async { + final BuildContext? context = containerKey.currentContext; + + if (context == null) { + return null; + } + final ElementData? widgetElementsTree = _widgetScraper.parseRenderTree(context); + + return widgetElementsTree?.extractRects(); + } +} diff --git a/lib/src/replay/mask/widget_elements_decipher.dart b/lib/src/replay/mask/widget_elements_decipher.dart new file mode 100644 index 0000000..2d639dc --- /dev/null +++ b/lib/src/replay/mask/widget_elements_decipher.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data_factory.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_object_parser.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/root_element_provider.dart'; + +class WidgetElementsDecipher { + late ElementData rootElementData; + + final ElementDataFactory _elementDataFactory; + final ElementObjectParser _elementObjectParser; + final RootElementProvider _rootElementProvider; + + WidgetElementsDecipher({ + required ElementDataFactory elementDataFactory, + required ElementObjectParser elementObjectParser, + required RootElementProvider rootElementProvider, + }) : _elementDataFactory = elementDataFactory, + _elementObjectParser = elementObjectParser, + _rootElementProvider = rootElementProvider; + + ElementData? parseRenderTree( + BuildContext context, + ) { + final rootElement = _rootElementProvider.getRootElement(context); + if (rootElement == null) return null; + + final rootElementData = _elementDataFactory.createFromElement(rootElement, "Root"); + if (rootElementData == null) return null; + + this.rootElementData = rootElementData; + + _parseAllElements( + this.rootElementData, + rootElement, + ); + + return this.rootElementData; + } + + void _parseAllElements( + ElementData activeElementData, + Element element, + ) { + ElementData? newElementData = _elementObjectParser.relateRenderObject(activeElementData, element); + + element.debugVisitOnstageChildren((childElement) { + _parseAllElements( + newElementData ?? activeElementData, + childElement, + ); + }); + } +} diff --git a/lib/src/replay/native_communicator.dart b/lib/src/replay/native_communicator.dart new file mode 100644 index 0000000..0290348 --- /dev/null +++ b/lib/src/replay/native_communicator.dart @@ -0,0 +1,32 @@ +import 'package:flutter/services.dart'; + +/* + * TEMPORARY CLASS FOR TESTING PURPOSES + * This function sends a screenshot to PostHog. + * It should be removed or refactored in the other version. + */ +class NativeCommunicator { + static const MethodChannel _channel = MethodChannel('posthog_flutter'); + + Future sendFullSnapshot(Uint8List imageBytes, {required int id}) async { + try { + await _channel.invokeMethod('sendFullSnapshot', { + 'imageBytes': imageBytes, + 'id': id, + }); + } catch (e) { + print('Error sending full snapshot to native: $e'); + } + } + + Future sendIncrementalSnapshot(Uint8List imageBytes, {required int id}) async { + try { + await _channel.invokeMethod('sendIncrementalSnapshot', { + 'imageBytes': imageBytes, + 'id': id, + }); + } catch (e) { + print('Error sending incremental snapshot to native: $e'); + } + } +} diff --git a/lib/src/replay/posthog_screenshot_widget.dart b/lib/src/replay/posthog_screenshot_widget.dart new file mode 100644 index 0000000..16a6748 --- /dev/null +++ b/lib/src/replay/posthog_screenshot_widget.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; + +import 'change_detector.dart'; +import 'native_communicator.dart'; +import 'screenshot/screenshot_capturer.dart'; + +class PostHogScreenshotWidget extends StatefulWidget { + final Widget child; + + PostHogScreenshotWidget({Key? key, required this.child}) : super(key: key); + + @override + _PostHogScreenshotWidgetState createState() => _PostHogScreenshotWidgetState(); +} + +class _PostHogScreenshotWidgetState extends State { + late final ChangeDetector _changeDetector; + late final ScreenshotCapturer _screenshotCapturer; + late final NativeCommunicator _nativeCommunicator; + + Timer? _debounceTimer; + + Uint8List? _lastImageBytes; + bool _sentFullSnapshot = false; + final int _wireframeId = 1; + + @override + void initState() { + final options = Posthog().config; + + super.initState(); + + if (options!.enableSessionReplay == false) { + return; + } + + _screenshotCapturer = ScreenshotCapturer(); + _nativeCommunicator = NativeCommunicator(); + + _changeDetector = ChangeDetector(_onChangeDetected); + _changeDetector.start(); + } + + // This works as onRootViewsChangedListeners + void _onChangeDetected() { + _debounceTimer?.cancel(); + + _debounceTimer = Timer(_getDebounceDuration(), () { + generateSnapshot(); + }); + } + + Future generateSnapshot() async { + final ui.Image? image = await _screenshotCapturer.captureScreenshot(); + if (image == null) { + print('Error: Failed to capture screenshot.'); + return; + } + + final ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + print('Error: Failed to convert image to byte data.'); + return; + } + + Uint8List pngBytes = byteData.buffer.asUint8List(); + image.dispose(); + + if (!_sentFullSnapshot) { + await _nativeCommunicator.sendFullSnapshot(pngBytes, id: _wireframeId); + _lastImageBytes = pngBytes; + _sentFullSnapshot = true; + } else { + if (_lastImageBytes == null || !listEquals(_lastImageBytes, pngBytes)) { + await _nativeCommunicator.sendIncrementalSnapshot(pngBytes, id: _wireframeId); + _lastImageBytes = pngBytes; + } else { + // Images are the same, do nothing for while + } + } + } + + Duration _getDebounceDuration() { + final options = Posthog().config; + + final sessionReplayConfig = options?.postHogSessionReplayConfig; + + if (Theme.of(context).platform == TargetPlatform.android) { + return sessionReplayConfig?.androidDebouncerDelay ?? const Duration(milliseconds: 200); + } else if (Theme.of(context).platform == TargetPlatform.iOS) { + return sessionReplayConfig?.iOSDebouncerDelay ?? const Duration(milliseconds: 200); + } else { + return const Duration(milliseconds: 500); + } + } + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + key: PostHogMaskController.instance.containerKey, + child: Column( + children: [ + Expanded(child: Container(child: widget.child)), + ], + ), + ); + } + + @override + void dispose() { + _debounceTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/src/replay/screenshot/screenshot_capturer.dart b/lib/src/replay/screenshot/screenshot_capturer.dart new file mode 100644 index 0000000..b33c366 --- /dev/null +++ b/lib/src/replay/screenshot/screenshot_capturer.dart @@ -0,0 +1,66 @@ +import 'dart:math'; +import 'dart:ui' as ui; +import 'package:flutter/rendering.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/replay/mask/image_mask_painter.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; + +class ScreenshotCapturer { + final config = Posthog().config; + final ImageMaskPainter _imageMaskPainter = ImageMaskPainter(); + + ScreenshotCapturer(); + + double _getPixelRatio({ + int? width, + int? height, + required double srcWidth, + required double srcHeight, + }) { + assert((width == null) == (height == null)); + if (width == null || height == null) { + return 1.0; + } + return min(width / srcWidth, height / srcHeight); + } + + Future captureScreenshot() async { + final context = PostHogMaskController.instance.containerKey.currentContext; + if (context == null) { + print('Error: screenshotKey has no context.'); + return null; + } + + final renderObject = context.findRenderObject() as RenderRepaintBoundary?; + if (renderObject == null) { + print('Error: Unable to find RenderRepaintBoundary.'); + return null; + } + + try { + final srcWidth = renderObject.size.width; + final srcHeight = renderObject.size.height; + final pixelRatio = _getPixelRatio(srcWidth: srcWidth, srcHeight: srcHeight); + + final ui.Image image = await renderObject.toImage(pixelRatio: pixelRatio); + + final replayConfig = config!.postHogSessionReplayConfig; + + if (replayConfig.maskAllTextInputs || replayConfig.maskAllImages) { + final screenElementsRects = await PostHogMaskController.instance.getCurrentScreenRects(); + + if (screenElementsRects == null) { + throw Exception('Failed to retrieve the element mask tree.'); + } + + final ui.Image maskedImage = await _imageMaskPainter.drawMaskedImage(image, screenElementsRects, pixelRatio); + return maskedImage; + } + + return image; + } catch (e) { + print('Error capturing image: $e'); + return null; + } + } +}