diff --git a/CHANGELOG.md b/CHANGELOG.md index c1aa1080..a246cb3b 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 `getFeatureFlagResult` API ([#279](https://github.com/PostHog/posthog-flutter/pull/279)) # 5.13.0 diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 885c3e34..c5455418 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -177,6 +177,12 @@ class PosthogFlutterPlugin : "isSessionReplayActive" -> { result.success(isSessionReplayActive()) } + "startSessionRecording" -> { + startSessionRecording(call, result) + } + "stopSessionRecording" -> { + stopSessionRecording(result) + } "getSessionId" -> { getSessionId(result) } @@ -194,6 +200,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 d14d3722..f4e1d498 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 ac42ada1..b54f184d 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -225,6 +225,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": @@ -457,6 +461,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 @@ -672,8 +698,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 09cd7fcb..29846816 100644 --- a/lib/posthog_flutter_web.dart +++ b/lib/posthog_flutter_web.dart @@ -294,4 +294,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 04d0c588..bdd10a3c 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -1,10 +1,11 @@ -import 'package:meta/meta.dart'; +import 'package:flutter/foundation.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; import 'feature_flag_result.dart'; import 'posthog_config.dart'; import 'posthog_flutter_platform_interface.dart'; +import 'posthog_internal_events.dart'; import 'posthog_observer.dart'; class Posthog { @@ -43,6 +44,10 @@ class Posthog { Future setup(PostHogConfig config) { _config = config; // Store the config + if (config.sessionReplay) { + PostHogInternalEvents.sessionRecordingActive.value = true; + } + _installFlutterIntegrations(config); return _posthog.setup(config); @@ -314,6 +319,7 @@ class Posthog { Future close() { _config = null; _currentScreen = null; + PostHogInternalEvents.sessionRecordingActive.value = false; PosthogObserver.clearCurrentContext(); // Uninstall Flutter integrations @@ -325,5 +331,28 @@ class Posthog { /// Returns the session Id if a session is active 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}) async { + await _posthog.startSessionRecording(resumeCurrent: resumeCurrent); + PostHogInternalEvents.sessionRecordingActive.value = true; + } + + /// Stops the current session recording if one is in progress. + /// + /// This method will have no effect if PostHog is not enabled. + Future stopSessionRecording() async { + await _posthog.stopSessionRecording(); + PostHogInternalEvents.sessionRecordingActive.value = false; + } + + /// 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 12f090c7..96938d97 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -682,4 +682,45 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { printIfDebug('Exception on openUrl: $exception'); } } + + @override + Future startSessionRecording({bool resumeCurrent = true}) async { + if (!isSupportedPlatform() || isMacOS()) { + return; + } + + try { + await _methodChannel.invokeMethod('startSessionRecording', resumeCurrent); + } on PlatformException catch (exception) { + printIfDebug('Exception on startSessionRecording: $exception'); + } + } + + @override + Future stopSessionRecording() async { + if (!isSupportedPlatform() || isMacOS()) { + return; + } + + try { + await _methodChannel.invokeMethod('stopSessionRecording'); + } on PlatformException catch (exception) { + printIfDebug('Exception on stopSessionRecording: $exception'); + } + } + + @override + Future isSessionReplayActive() async { + if (!isSupportedPlatform() || isMacOS()) { + 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 841d6d33..13fbf4b5 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -161,5 +161,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 1259aa98..8f1874d2 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -36,6 +36,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 @@ -491,9 +504,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/posthog_internal_events.dart b/lib/src/posthog_internal_events.dart new file mode 100644 index 00000000..98992efc --- /dev/null +++ b/lib/src/posthog_internal_events.dart @@ -0,0 +1,8 @@ +import 'package:flutter/foundation.dart'; + +@internal +class PostHogInternalEvents { + PostHogInternalEvents._(); // private init + + static final sessionRecordingActive = ValueNotifier(false); +} diff --git a/lib/src/posthog_widget.dart b/lib/src/posthog_widget.dart index 80bf3aa7..72e9174e 100644 --- a/lib/src/posthog_widget.dart +++ b/lib/src/posthog_widget.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_internal_events.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; import 'replay/change_detector.dart'; @@ -31,19 +32,52 @@ class PostHogWidgetState extends State { super.initState(); final config = Posthog().config; - if (config == null || !config.sessionReplay) { + if (config == null) { return; } - _throttleDuration = config.sessionReplayConfig.throttleDelay; + if (config.sessionReplay) { + _initComponents(config); + _changeDetector?.start(); + } + + // start listening for session recording toggles + PostHogInternalEvents.sessionRecordingActive + .addListener(_onSessionRecordingChanged); + } + void _initComponents(PostHogConfig config) { + _throttleDuration = config.sessionReplayConfig.throttleDelay; _screenshotCapturer = ScreenshotCapturer(config); _nativeCommunicator = NativeCommunicator(); - _changeDetector = ChangeDetector(_onChangeDetected); + } + + void _onSessionRecordingChanged() { + if (PostHogInternalEvents.sessionRecordingActive.value) { + _startRecording(); + } else { + _stopRecording(); + } + } + + void _startRecording() { + final config = Posthog().config; + if (config == null) { + return; + } + + if (_changeDetector == null) { + _initComponents(config); + } + _changeDetector?.start(); } + void _stopRecording() { + _changeDetector?.stop(); + } + // This works as onRootViewsChangedListeners void _onChangeDetected() { if (_isThrottling) { @@ -97,6 +131,9 @@ class PostHogWidgetState extends State { @override void dispose() { + PostHogInternalEvents.sessionRecordingActive + .removeListener(_onSessionRecordingChanged); + _throttleTimer?.cancel(); _throttleTimer = null; _changeDetector?.stop(); diff --git a/lib/src/replay/change_detector.dart b/lib/src/replay/change_detector.dart index ed1a6787..5f280b96 100644 --- a/lib/src/replay/change_detector.dart +++ b/lib/src/replay/change_detector.dart @@ -1,5 +1,4 @@ import 'package:flutter/widgets.dart'; -import 'package:posthog_flutter/posthog_flutter.dart'; /// A class that detects changes in the UI and executes a callback when changes occur. /// @@ -51,11 +50,6 @@ class ChangeDetector { /// Executes the [onChange] callback and schedules itself for the next frame /// if the change detector is still running. void _onFrameRendered() { - final config = Posthog().config; - if (config == null || !config.sessionReplay) { - return; - } - if (!_isRunning) { return; } 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; } } diff --git a/lib/src/util/platform_io_real.dart b/lib/src/util/platform_io_real.dart index 94295579..c6da3748 100644 --- a/lib/src/util/platform_io_real.dart +++ b/lib/src/util/platform_io_real.dart @@ -7,3 +7,7 @@ bool isSupportedPlatform() { } return !(Platform.isLinux || Platform.isWindows); } + +bool isMacOS() { + return Platform.isMacOS; +} diff --git a/lib/src/util/platform_io_stub.dart b/lib/src/util/platform_io_stub.dart index 658d07d5..33472076 100644 --- a/lib/src/util/platform_io_stub.dart +++ b/lib/src/util/platform_io_stub.dart @@ -3,3 +3,7 @@ import 'package:flutter/foundation.dart'; bool isSupportedPlatform() { return kIsWeb; } + +bool isMacOS() { + return false; +}