diff --git a/CHANGELOG.md b/CHANGELOG.md index b3755fb6..893e9144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Next +- feat: Add `onFeatureFlags` callback to `Posthog()` to listen for feature flag load events. On Web, this callback provides all flags and variants. On mobile (Android/iOS), it serves as a signal that flags have been loaded by the native SDK; the `flags` and `flagVariants` parameters will be empty in the callback, and developers should use `Posthog.getFeatureFlag()` or `Posthog.isFeatureEnabled()` to retrieve specific flag values. This allows developers to ensure flags are loaded before checking them, especially on the first app run. ([#183](https://github.com/PostHog/posthog-flutter/pull/183)) + ## 5.0.0 - chore: support flutter web wasm builds ([#112](https://github.com/PostHog/posthog-flutter/pull/112)) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 0e4c3781..9d9ea749 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -2,6 +2,8 @@ package com.posthog.flutter import android.content.Context import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log import com.posthog.PersonProfiles import com.posthog.PostHog @@ -9,6 +11,7 @@ import com.posthog.PostHogConfig import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig import com.posthog.android.internal.getApplicationInfo +import com.posthog.PostHogOnFeatureFlags import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -262,7 +265,20 @@ class PosthogFlutterPlugin : sdkName = "posthog-flutter" sdkVersion = postHogVersion + + onFeatureFlags = PostHogOnFeatureFlags { + try { + Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") + val arguments = emptyMap() + channel.invokeMethod("onFeatureFlagsCallback", arguments) + } catch (e: Exception) { + Log.e("PostHogFlutter", "Error in onFeatureFlags signalling: ${e.message}", e) + val errorArguments = emptyMap() + channel.invokeMethod("onFeatureFlagsCallback", errorArguments) + } + } } + PostHogAndroid.setup(applicationContext, config) } diff --git a/ios/Classes/PosthogFlutterPlugin.swift b/ios/Classes/PosthogFlutterPlugin.swift index 61eec2fc..28f88dd4 100644 --- a/ios/Classes/PosthogFlutterPlugin.swift +++ b/ios/Classes/PosthogFlutterPlugin.swift @@ -8,15 +8,46 @@ import PostHog #endif public class PosthogFlutterPlugin: NSObject, FlutterPlugin { + private var channel: FlutterMethodChannel? + + override init() { + super.init() + NotificationCenter.default.addObserver( + self, + selector: #selector(featureFlagsDidUpdate), + name: PostHogSDK.didReceiveFeatureFlags, + object: nil + ) + } + public static func register(with registrar: FlutterPluginRegistrar) { + let methodChannel: FlutterMethodChannel #if os(iOS) - let channel = FlutterMethodChannel(name: "posthog_flutter", binaryMessenger: registrar.messenger()) + methodChannel = FlutterMethodChannel(name: "posthog_flutter", binaryMessenger: registrar.messenger()) #elseif os(macOS) - let channel = FlutterMethodChannel(name: "posthog_flutter", binaryMessenger: registrar.messenger) + methodChannel = FlutterMethodChannel(name: "posthog_flutter", binaryMessenger: registrar.messenger) #endif let instance = PosthogFlutterPlugin() + instance.channel = methodChannel + initPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) + registrar.addMethodCallDelegate(instance, channel: methodChannel) + } + + @objc func featureFlagsDidUpdate() { + let flags: [String] = [] + let flagVariants: [String: Any] = [:] + + guard let channel = self.channel else { + print("PosthogFlutterPlugin: FlutterMethodChannel is nil in featureFlagsDidUpdate.") + return + } + + channel.invokeMethod("onFeatureFlagsCallback", arguments: [ + "flags": flags, + "flagVariants": flagVariants, + "errorsLoading": false + ]) } private let dispatchQueue = DispatchQueue(label: "com.posthog.PosthogFlutterPlugin", diff --git a/lib/posthog_flutter_web.dart b/lib/posthog_flutter_web.dart index 5c8b4e72..c4728d8f 100644 --- a/lib/posthog_flutter_web.dart +++ b/lib/posthog_flutter_web.dart @@ -1,9 +1,12 @@ // In order to *not* need this ignore, consider extracting the "web" version // of your plugin as a separate package, instead of inlining it in the same // package as the core of your plugin. +import 'dart:js_interop'; + import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'src/posthog_config.dart'; import 'src/posthog_flutter_platform_interface.dart'; import 'src/posthog_flutter_web_handler.dart'; @@ -20,8 +23,52 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { ); final PosthogFlutterWeb instance = PosthogFlutterWeb(); channel.setMethodCallHandler(instance.handleMethodCall); + PosthogFlutterPlatformInterface.instance = instance; + } + + Future handleMethodCall(MethodCall call) async { + // The 'setup' call is now handled by the setup method override. + // Other method calls are delegated to handleWebMethodCall. + if (call.method == 'setup') { + // This case should ideally not be hit if Posthog().setup directly calls the overridden setup. + // However, to be safe, we can log or ignore. + // For now, let's assume direct call to overridden setup handles it. + return null; + } + return handleWebMethodCall(call); } - Future handleMethodCall(MethodCall call) => - handleWebMethodCall(call); + @override + Future setup(PostHogConfig config) async { + // It's assumed posthog-js is initialized by the user in their HTML. + // This setup primarily hooks into the existing posthog-js instance. + + // If apiKey and host are in config, and posthog.init is to be handled by plugin: + // This is an example if we wanted the plugin to also call posthog.init() + // final jsOptions = { + // 'api_host': config.host, + // // Add other relevant options from PostHogConfig if needed for JS init + // }.jsify(); + // posthog?.callMethod('init'.toJS, config.apiKey.toJS, jsOptions); + + + if (config.onFeatureFlags != null && posthog != null) { + final dartCallback = config.onFeatureFlags!; + + final jsCallback = (JSArray jsFlags, JSObject jsFlagVariants) { + final List flags = jsFlags.toDart.whereType().toList(); + + Map flagVariants = {}; + final dartVariantsMap = jsFlagVariants.dartify() as Map?; + if (dartVariantsMap != null) { + flagVariants = dartVariantsMap.map((key, value) => MapEntry(key.toString(), value)); + } + + // When posthog-js onFeatureFlags fires, it implies successful loading. + dartCallback(flags, flagVariants, errorsLoading: false); + }.toJS; + + posthog!.onFeatureFlags(jsCallback); + } + } } diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 6b23e033..46869299 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -18,10 +18,48 @@ class Posthog { String? _currentScreen; - /// Android and iOS only - /// Only used for the manual setup - /// Requires disabling the automatic init on Android and iOS: - /// com.posthog.posthog.AUTO_INIT: false + /// Initializes the PostHog SDK. + /// + /// This method sets up the connection to your PostHog instance and prepares the SDK for tracking events and feature flags. + /// + /// - [config]: The [PostHogConfig] object containing your API key, host, and other settings. + /// To listen for feature flag load events, provide an `onFeatureFlags` callback in the [PostHogConfig]. + /// + /// **Behavior of `onFeatureFlags` callback (when provided in `PostHogConfig`):** + /// + /// **Web:** + /// The callback will receive: + /// - `flags`: A list of active feature flag keys (List). + /// - `flagVariants`: A map of feature flag keys to their variant values (Map). + /// - `errorsLoading`: Will be `false` as the callback firing implies success. + /// + /// **Mobile (Android/iOS):** + /// The callback serves primarily as a notification that the native PostHog SDK + /// has finished loading feature flags. In this case: + /// - `flags`: Will be an empty list. + /// - `flagVariants`: Will be an empty map. + /// - `errorsLoading`: Will be `null` if the native call was successful but contained no error info, or `true` if an error occurred during Dart-side processing of the callback. + /// After this callback is invoked, you can reliably use `Posthog().getFeatureFlag('your-flag-key')` + /// or `Posthog().isFeatureEnabled('your-flag-key')` to get the values of specific flags. + /// + /// **Example with `onFeatureFlags` in `PostHogConfig`:** + /// ```dart + /// final config = PostHogConfig( + /// apiKey: 'YOUR_API_KEY', + /// host: 'YOUR_POSTHOG_HOST', + /// onFeatureFlags: (flags, flagVariants, {errorsLoading}) { + /// if (errorsLoading == true) { + /// print('Error loading feature flags!'); + /// return; + /// } + /// // ... process flags ... + /// }, + /// ); + /// await Posthog().setup(config); + /// ``` + /// + /// For Android and iOS, if you are performing a manual setup, + /// ensure `com.posthog.posthog.AUTO_INIT: false` is set in your native configuration. Future setup(PostHogConfig config) { _config = config; // Store the config return _posthog.setup(config); diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index 57132804..a0119d35 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -1,3 +1,5 @@ +import 'posthog_flutter_platform_interface.dart'; + enum PostHogPersonProfiles { never, always, identifiedOnly } enum PostHogDataMode { wifi, cellular, any } @@ -28,10 +30,17 @@ class PostHogConfig { /// iOS only var dataMode = PostHogDataMode.any; + /// Callback to be invoked when feature flags are loaded. + /// See [Posthog.onFeatureFlags] for more details on behavior per platform. + final OnFeatureFlagsCallback? onFeatureFlags; + // TODO: missing getAnonymousId, propertiesSanitizer, captureDeepLinks // onFeatureFlags, integrations - PostHogConfig(this.apiKey); + PostHogConfig( + this.apiKey, { + this.onFeatureFlags, + }); Map toMap() { return { diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index a65668e4..3960a113 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -12,12 +12,58 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { /// The method channel used to interact with the native platform. final _methodChannel = const MethodChannel('posthog_flutter'); + OnFeatureFlagsCallback? _onFeatureFlagsCallback; + bool _methodCallHandlerInitialized = false; + + void _ensureMethodCallHandlerInitialized() { + if (!_methodCallHandlerInitialized) { + _methodChannel.setMethodCallHandler(_handleMethodCall); + _methodCallHandlerInitialized = true; + } + } + + Future _handleMethodCall(MethodCall call) async { + switch (call.method) { + case 'onFeatureFlagsCallback': + if (_onFeatureFlagsCallback != null) { + try { + final args = call.arguments as Map; + // Ensure correct types from native + // For mobile, args will be an empty map. Callback expects optional params. + final flags = + (args['flags'] as List?)?.cast() ?? []; + final flagVariants = + (args['flagVariants'] as Map?) + ?.map((k, v) => MapEntry(k.toString(), v)) ?? + {}; + // For mobile, errorsLoading is not explicitly sent, so it will be null here. + final errorsLoading = args['errorsLoading'] as bool?; + + _onFeatureFlagsCallback!(flags, flagVariants, + errorsLoading: errorsLoading); + } catch (e, s) { + printIfDebug('Error processing onFeatureFlagsCallback: $e\n$s'); + _onFeatureFlagsCallback!([], {}, + errorsLoading: true); + } + } + break; + default: + break; + } + } + @override Future setup(PostHogConfig config) async { if (!isSupportedPlatform()) { return; } + _onFeatureFlagsCallback = config.onFeatureFlags; + if (_onFeatureFlagsCallback != null) { + _ensureMethodCallHandlerInitialized(); + } + try { await _methodChannel.invokeMethod('setup', config.toMap()); } on PlatformException catch (exception) { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index ee4525d9..2e26a860 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -3,6 +3,14 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'posthog_config.dart'; import 'posthog_flutter_io.dart'; +/// Defines the callback signature for when feature flags are loaded. +/// [flags] is a list of active feature flag keys. +/// [flagVariants] is a map of feature flag keys to their variant values (String or bool). +/// [errorsLoading] is true if there was an error loading flags, otherwise false or null. +typedef OnFeatureFlagsCallback = void Function( + List flags, Map flagVariants, + {bool? errorsLoading}); + abstract class PosthogFlutterPlatformInterface extends PlatformInterface { /// Constructs a PosthogFlutterPlatform. PosthogFlutterPlatformInterface() : super(token: _token); diff --git a/lib/src/posthog_flutter_web_handler.dart b/lib/src/posthog_flutter_web_handler.dart index 35ca49ff..0c246cd0 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -25,6 +25,7 @@ extension PostHogExtension on PostHog { external void register(JSAny properties); external void unregister(JSAny key); external JSAny? get_session_id(); + external void onFeatureFlags(JSFunction callback); } // Accessing PostHog from the window object diff --git a/test/posthog_flutter_io_test.dart b/test/posthog_flutter_io_test.dart new file mode 100644 index 00000000..90b8f9af --- /dev/null +++ b/test/posthog_flutter_io_test.dart @@ -0,0 +1,186 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_config.dart'; +import 'package:posthog_flutter/src/posthog_flutter_io.dart'; + +// Converted from variable to function declaration +void emptyCallback(List flags, Map flagVariants, {bool? errorsLoading}) {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late PosthogFlutterIO posthogFlutterIO; + late PostHogConfig testConfig; + + // For testing method calls + final List log = []; + const MethodChannel channel = MethodChannel('posthog_flutter'); + + setUp(() { + posthogFlutterIO = PosthogFlutterIO(); + log.clear(); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + if (methodCall.method == 'isFeatureEnabled') { + return true; + } + // Simulate setup call success + if (methodCall.method == 'setup') { + return null; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('PosthogFlutterIO onFeatureFlags via setup', () { + test('setup initializes method call handler and registers callback if provided', () async { + bool callbackInvoked = false; + // Converted to function declaration + void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { + callbackInvoked = true; + } + testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback); + await posthogFlutterIO.setup(testConfig); + + // To verify handler is set, we trigger the callback from native side + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall('onFeatureFlagsCallback', {})), + (ByteData? data) {}, + ); + expect(callbackInvoked, isTrue); + expect(log.any((call) => call.method == 'setup'), isTrue); + }); + + test('invokes callback when native sends onFeatureFlagsCallback event with valid data', () async { + List? receivedFlags; + Map? receivedVariants; + bool? receivedErrorState; + + // Converted to function declaration + void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { + receivedFlags = flags; + receivedVariants = flagVariants; + receivedErrorState = errorsLoading; + } + testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback); + await posthogFlutterIO.setup(testConfig); + + final Map mockNativeArgs = { + 'flags': ['flag1', 'feature-abc'], + 'flagVariants': {'flag1': true, 'feature-abc': 'variant-x'}, + 'errorsLoading': false, + }; + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgs)), + (ByteData? data) {}, + ); + + expect(receivedFlags, equals(['flag1', 'feature-abc'])); + expect(receivedVariants, equals({'flag1': true, 'feature-abc': 'variant-x'})); + expect(receivedErrorState, isFalse); + }); + + test('invokes callback with default/empty data when native sends onFeatureFlagsCallback with empty map (mobile behavior)', () async { + List? receivedFlags; + Map? receivedVariants; + bool? receivedErrorState; + + // Converted to function declaration + void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { + receivedFlags = flags; + receivedVariants = flagVariants; + receivedErrorState = errorsLoading; + } + testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback); + await posthogFlutterIO.setup(testConfig); + + // Simulate mobile sending an empty map + final Map mockNativeArgs = {}; + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgs)), + (ByteData? data) {}, + ); + + expect(receivedFlags, isEmpty); + expect(receivedVariants, isEmpty); + expect(receivedErrorState, isNull); // errorsLoading will be null as it's not in the map + }); + + test('invokes callback with errorsLoading true if Dart side processing fails, even with empty native args', () async { + List? receivedFlags; + Map? receivedVariants; + bool? receivedErrorState; + + // Converted to function declaration + void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { + receivedFlags = flags; + receivedVariants = flagVariants; + receivedErrorState = errorsLoading; + } + testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback); + await posthogFlutterIO.setup(testConfig); + + // Simulate native sending an argument that will cause a cast error in _handleMethodCall (before the fix) + // For the current code, this test will verify the catch block sets errorsLoading: true + final Map mockNativeArgsMalformed = { + 'flags': 123, // Invalid type, will cause cast error and trigger catch + }; + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgsMalformed)), + (ByteData? data) {}, + ); + + expect(receivedFlags, isEmpty); + expect(receivedVariants, isEmpty); + expect(receivedErrorState, isTrue); + }); + + test('handles onFeatureFlagsCallback with malformed data gracefully (e.g. wrong types in maps/lists)', () async { + List? receivedFlags; + Map? receivedVariants; + bool? receivedErrorState; + bool callbackInvoked = false; + + // Converted to function declaration + void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { + callbackInvoked = true; + receivedFlags = flags; + receivedVariants = flagVariants; + receivedErrorState = errorsLoading; + } + testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback); + await posthogFlutterIO.setup(testConfig); + + final Map mockNativeArgsMalformed = { + 'flags': 'not_a_list', // This will be handled by the ?? [] for flags + 'flagVariants': ['not_a_map'], // This will be handled by the ?? {} for variants + 'errorsLoading': 'not_a_bool', // This will be handled by `as bool?` resulting in null or catch block + }; + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgsMalformed)), + (ByteData? data) {}, + ); + + expect(callbackInvoked, isTrue, reason: "Callback should still be invoked on parse error."); + expect(receivedFlags, isEmpty, reason: "Flags should default to empty on parse error due to type mismatch."); + expect(receivedVariants, isEmpty, reason: "Variants should default to empty on parse error due to type mismatch."); + expect(receivedErrorState, isTrue, reason: "errorsLoading should be true on parse error in catch block."); + }); + }); +} diff --git a/test/posthog_flutter_platform_interface_fake.dart b/test/posthog_flutter_platform_interface_fake.dart index fd679082..b14d7a2c 100644 --- a/test/posthog_flutter_platform_interface_fake.dart +++ b/test/posthog_flutter_platform_interface_fake.dart @@ -1,7 +1,10 @@ +import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { String? screenName; + OnFeatureFlagsCallback? registeredOnFeatureFlagsCallback; + PostHogConfig? receivedConfig; @override Future screen({ @@ -10,4 +13,12 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { }) async { this.screenName = screenName; } + + @override + Future setup(PostHogConfig config) async { + receivedConfig = config; + registeredOnFeatureFlagsCallback = config.onFeatureFlags; + // Simulate async operation if needed, but for fake, direct assignment is often enough. + return Future.value(); + } } diff --git a/test/posthog_test.dart b/test/posthog_test.dart new file mode 100644 index 00000000..4c02327a --- /dev/null +++ b/test/posthog_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Posthog', () { + late PosthogFlutterPlatformFake fakePlatformInterface; + + setUp(() { + fakePlatformInterface = PosthogFlutterPlatformFake(); + PosthogFlutterPlatformInterface.instance = fakePlatformInterface; + }); + + test('setup passes config and onFeatureFlags callback to platform interface', () async { + // ignore: prefer_function_declarations_over_variables + final OnFeatureFlagsCallback testCallback = + (flags, flagVariants, {errorsLoading}) {}; + + final config = PostHogConfig( + 'test_api_key', + onFeatureFlags: testCallback, + ); + + await Posthog().setup(config); + + expect(fakePlatformInterface.receivedConfig, equals(config)); + expect(fakePlatformInterface.registeredOnFeatureFlagsCallback, + equals(testCallback)); + }); + }); +}