diff --git a/CHANGELOG.md b/CHANGELOG.md index 9683479a..f8b4d5b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Next +- feat: add manual session recording control APIs ([#256](https://github.com/PostHog/posthog-flutter/pull/256)) + - `startSessionRecording({bool resumeCurrent = true})` Start session recording, optionally starting a new session + - `stopSessionRecording()` Stop the current session recording + - `isSessionReplayActive()` Check if session replay is currently active - feat: add `beforeSend` callback to `PostHogConfig` for dropping or modifying events before they are sent to PostHog ([#255](https://github.com/PostHog/posthog-flutter/pull/255)) - **Limitation**: - Does NOT intercept native-initiated events such as: diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index fc5685c8..dd028d52 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -173,6 +173,12 @@ class PosthogFlutterPlugin : "isSessionReplayActive" -> { result.success(isSessionReplayActive()) } + "startSessionRecording" -> { + startSessionRecording(call, result) + } + "stopSessionRecording" -> { + stopSessionRecording(result) + } "getSessionId" -> { getSessionId(result) } @@ -190,6 +196,20 @@ class PosthogFlutterPlugin : private fun isSessionReplayActive(): Boolean = PostHog.isSessionReplayActive() + private fun startSessionRecording( + call: MethodCall, + result: Result, + ) { + val resumeCurrent = call.arguments as? Boolean ?: true + PostHog.startSessionReplay(resumeCurrent) + result.success(null) + } + + private fun stopSessionRecording(result: Result) { + PostHog.stopSessionReplay() + result.success(null) + } + private fun handleMetaEvent( call: MethodCall, result: Result, diff --git a/example/lib/main.dart b/example/lib/main.dart index 9092b410..96873510 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -293,6 +293,96 @@ class InitialScreenState extends State { child: Text("distinctId"), )), const Divider(), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text( + "Session Recording", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + ), + onPressed: () async { + await _posthogFlutterPlugin.startSessionRecording(); + if (mounted && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Session recording started (resume current)'), + duration: Duration(seconds: 2), + ), + ); + } + }, + child: const Text("Start Recording"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + ), + onPressed: () async { + await _posthogFlutterPlugin.startSessionRecording( + resumeCurrent: false); + if (mounted && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Session recording started (new session)'), + duration: Duration(seconds: 2), + ), + ); + } + }, + child: const Text("Start New Session"), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + onPressed: () async { + await _posthogFlutterPlugin.stopSessionRecording(); + if (mounted && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Session recording stopped'), + duration: Duration(seconds: 2), + ), + ); + } + }, + child: const Text("Stop Recording"), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + ), + onPressed: () async { + final isActive = + await _posthogFlutterPlugin.isSessionReplayActive(); + if (mounted && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Session replay active: $isActive'), + duration: const Duration(seconds: 2), + ), + ); + } + }, + child: const Text("Check Active"), + ), + ], + ), + const Divider(), const Padding( padding: EdgeInsets.all(8.0), child: Text( diff --git a/ios/Classes/.swiftformat b/ios/Classes/.swiftformat new file mode 100644 index 00000000..c15a1c08 --- /dev/null +++ b/ios/Classes/.swiftformat @@ -0,0 +1,109 @@ +# Disabled rules +--disable docComments +--disable noForceUnwrapInTests +--disable hoistTry +--disable redundantAsync +--disable redundantThrows +--disable redundantProperty +--disable redundantReturn +--disable redundantClosure +--disable redundantType +--disable wrapPropertyBodies +--disable blankLinesBetweenScopes + +--acronyms ID,URL,UUID +--allman false +--anonymousforeach convert +--assetliterals visual-width +--asynccapturing +--beforemarks +--binarygrouping none +--callsiteparen default +--categorymark "MARK: %c" +--classthreshold 0 +--closingparen balanced +--closurevoid remove +--commas always +--complexattrs preserve +--computedvarattrs preserve +--condassignment after-property +--conflictmarkers reject +--dateformat system +--decimalgrouping ignore +--doccomments before-declarations +--elseposition same-line +--emptybraces no-space +--enumnamespaces always +--enumthreshold 0 +--exponentcase lowercase +--exponentgrouping disabled +--extensionacl on-extension +--extensionlength 0 +--extensionmark "MARK: - %t + %c" +--fractiongrouping disabled +--fragment false +--funcattributes preserve +--generictypes +--groupedextension "MARK: %c" +--guardelse auto +--header ignore +--hexgrouping 4,8 +--hexliteralcase uppercase +--ifdef indent +--importgrouping alpha +--indent 4 +--indentcase false +--indentstrings false +--initcodernil false +--lifecycle +--lineaftermarks true +--linebreaks lf +--markcategories true +--markextensions always +--marktypes always +--maxwidth none +--modifierorder +--nevertrailing +--nilinit remove +--noncomplexattrs +--nospaceoperators +--nowrapoperators +--octalgrouping none +--onelineforeach ignore +--operatorfunc spaced +--organizationmode visibility +--organizetypes actor,class,enum,struct +--patternlet hoist +--ranges spaced +--redundanttype infer-locals-only +--self remove +--selfrequired +--semicolons never +--shortoptionals except-properties +--smarttabs enabled +--someany true +--storedvarattrs preserve +--stripunusedargs always +--structthreshold 0 +--tabwidth unspecified +--throwcapturing +--timezone system +--trailingclosures +--trimwhitespace always +--typeattributes preserve +--typeblanklines remove +--typedelimiter space-after +--typemark "MARK: - %t" +--voidtype void +--wraparguments preserve +--wrapcollections preserve +--wrapconditions preserve +--wrapeffects preserve +--wrapenumcases always +--wrapparameters default +--wrapreturntype preserve +--wrapternary default +--wraptypealiases preserve +--xcodeindentation disabled +--yodaswap always +--hexgrouping ignore \ No newline at end of file diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 918edbbd..8bfffddb 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -223,6 +223,10 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { sendFullSnapshot(call, result: result) case "isSessionReplayActive": isSessionReplayActive(result: result) + case "startSessionRecording": + startSessionRecording(call, result: result) + case "stopSessionRecording": + stopSessionRecording(result: result) case "getSessionId": getSessionId(result: result) case "openUrl": @@ -455,6 +459,28 @@ extension PosthogFlutterPlugin { #endif } + private func startSessionRecording( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + #if os(iOS) + let resumeCurrent = call.arguments as? Bool ?? true + PostHogSDK.shared.startSessionRecording(resumeCurrent: resumeCurrent) + result(nil) + #else + result(nil) + #endif + } + + private func stopSessionRecording(result: @escaping FlutterResult) { + #if os(iOS) + PostHogSDK.shared.stopSessionRecording() + result(nil) + #else + result(nil) + #endif + } + private func openUrl( _ call: FlutterMethodCall, result: @escaping FlutterResult @@ -645,8 +671,7 @@ extension PosthogFlutterPlugin { } } - private func reloadFeatureFlags(_ result: @escaping FlutterResult - ) { + private func reloadFeatureFlags(_ result: @escaping FlutterResult) { PostHogSDK.shared.reloadFeatureFlags() result(nil) } diff --git a/lib/posthog_flutter_web.dart b/lib/posthog_flutter_web.dart index 99849151..6c6a00a1 100644 --- a/lib/posthog_flutter_web.dart +++ b/lib/posthog_flutter_web.dart @@ -281,4 +281,23 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { printIfDebug('Exception in captureException: $exception'); } } + + @override + Future startSessionRecording({bool resumeCurrent = true}) async { + return handleWebMethodCall(MethodCall('startSessionRecording', { + 'resumeCurrent': resumeCurrent, + })); + } + + @override + Future stopSessionRecording() async { + return handleWebMethodCall(const MethodCall('stopSessionRecording')); + } + + @override + Future isSessionReplayActive() async { + final result = + await handleWebMethodCall(const MethodCall('isSessionReplayActive')); + return result as bool? ?? false; + } } diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 6ac98230..aabde069 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -210,5 +210,23 @@ class Posthog { Future getSessionId() => _posthog.getSessionId(); + /// Starts session recording. + /// + /// This method will have no effect if PostHog is not enabled, or if session + /// replay is disabled in your project settings. + /// + /// [resumeCurrent] - If true (default), resumes recording of the current session. + /// If false, starts a new session and begins recording. + Future startSessionRecording({bool resumeCurrent = true}) => + _posthog.startSessionRecording(resumeCurrent: resumeCurrent); + + /// Stops the current session recording if one is in progress. + /// + /// This method will have no effect if PostHog is not enabled. + Future stopSessionRecording() => _posthog.stopSessionRecording(); + + /// Returns whether session replay is currently active. + Future isSessionReplayActive() => _posthog.isSessionReplayActive(); + Posthog._internal(); } diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index c2187e88..495f9aa8 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -658,4 +658,45 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { printIfDebug('Exception on openUrl: $exception'); } } + + @override + Future startSessionRecording({bool resumeCurrent = true}) async { + if (!isSupportedPlatform()) { + return; + } + + try { + await _methodChannel.invokeMethod('startSessionRecording', resumeCurrent); + } on PlatformException catch (exception) { + printIfDebug('Exception on startSessionRecording: $exception'); + } + } + + @override + Future stopSessionRecording() async { + if (!isSupportedPlatform()) { + return; + } + + try { + await _methodChannel.invokeMethod('stopSessionRecording'); + } on PlatformException catch (exception) { + printIfDebug('Exception on stopSessionRecording: $exception'); + } + } + + @override + Future isSessionReplayActive() async { + if (!isSupportedPlatform()) { + return false; + } + + try { + final result = await _methodChannel.invokeMethod('isSessionReplayActive'); + return result as bool? ?? false; + } on PlatformException catch (exception) { + printIfDebug('Exception on isSessionReplayActive: $exception'); + return false; + } + } } diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 3a5223f3..bb343357 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -152,5 +152,27 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('getSessionId() not implemented'); } + /// Starts session recording. + /// + /// [resumeCurrent] - If true, resumes recording of the current session. + /// If false, starts a new session and begins recording. + /// Defaults to true. + Future startSessionRecording({bool resumeCurrent = true}) { + throw UnimplementedError( + 'startSessionRecording() has not been implemented.'); + } + + /// Stops the current session recording if one is in progress. + Future stopSessionRecording() { + throw UnimplementedError( + 'stopSessionRecording() has not been implemented.'); + } + + /// Returns whether session replay is currently active. + Future isSessionReplayActive() { + throw UnimplementedError( + 'isSessionReplayActive() has not been implemented.'); + } + // TODO: missing capture with more parameters } diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index e7a0d4b4..5655a12d 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -35,6 +35,19 @@ extension PostHogExtension on PostHog { // ignore: non_constant_identifier_names external JSAny? get_session_id(); external void onFeatureFlags(JSFunction callback); + external void startSessionRecording(); + external void stopSessionRecording(); + external bool sessionRecordingStarted(); + external SessionManager? get sessionManager; +} + +// SessionManager JS interop +@JS() +@staticInterop +class SessionManager {} + +extension SessionManagerExtension on SessionManager { + external void resetSessionId(); } // Accessing PostHog from the window object @@ -481,9 +494,18 @@ Future handleWebMethodCall(MethodCall call) async { // Flutter Web uses the JS SDK for Session replay break; case 'isSessionReplayActive': - // not supported on Web - // Flutter Web uses the JS SDK for Session replay - return false; + return posthog?.sessionRecordingStarted() ?? false; + case 'startSessionRecording': + final resumeCurrent = args as bool? ?? true; + if (!resumeCurrent) { + // Reset session ID to start a new session + posthog?.sessionManager?.resetSessionId(); + } + posthog?.startSessionRecording(); + break; + case 'stopSessionRecording': + posthog?.stopSessionRecording(); + break; case 'openUrl': // not supported on Web break; diff --git a/lib/src/replay/native_communicator.dart b/lib/src/replay/native_communicator.dart index 4c0d22df..9cb70c61 100644 --- a/lib/src/replay/native_communicator.dart +++ b/lib/src/replay/native_communicator.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/services.dart'; import 'package:posthog_flutter/src/util/logging.dart'; @@ -34,10 +35,14 @@ class NativeCommunicator { } Future isSessionReplayActive() async { + if (kIsWeb) { + // Flutter doesn't capture screenshots on web, JS SDK handles session replay + return false; + } try { return await _channel.invokeMethod('isSessionReplayActive'); } catch (e) { - printIfDebug('Error sending full snapshot to native: $e'); + printIfDebug('Error checking session replay status: $e'); return false; } }