From 473a29cdab9e3e79b79ac70ee0f231b3474f6f62 Mon Sep 17 00:00:00 2001 From: "William N. Green" Date: Thu, 5 Jun 2025 09:25:51 -0700 Subject: [PATCH 1/6] feat: add onFeatureFlags callback --- CHANGELOG.md | 2 + .../posthog/flutter/PosthogFlutterPlugin.kt | 29 +++ ios/Classes/PosthogFlutterPlugin.swift | 37 +++- lib/src/posthog.dart | 47 +++++ lib/src/posthog_flutter_io.dart | 52 ++++++ .../posthog_flutter_platform_interface.dart | 12 ++ test/posthog_flutter_io_test.dart | 173 ++++++++++++++++++ ...sthog_flutter_platform_interface_fake.dart | 6 + test/posthog_test.dart | 29 +++ 9 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 test/posthog_flutter_io_test.dart create mode 100644 test/posthog_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b3755fb6..5fbb3f09 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. ([#YOUR_PR_NUMBER_HERE]) + ## 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..837933fa 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 @@ -27,6 +30,8 @@ class PosthogFlutterPlugin : private lateinit var applicationContext: Context + private val mainHandler = Handler(Looper.getMainLooper()) + private val snapshotSender = SnapshotSender() override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { @@ -257,12 +262,36 @@ class PosthogFlutterPlugin : posthogConfig.getIfNotNull("sessionReplay") { sessionReplay = it } + posthogConfig.getIfNotNull("dataMode") { + // Assuming DataMode is an enum or similar, handle appropriately + } this.sessionReplayConfig.captureLogcat = false sdkName = "posthog-flutter" sdkVersion = postHogVersion + + onFeatureFlags = PostHogOnFeatureFlags { + try { + Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") + val arguments = mapOf( + "flags" to emptyList(), + "flagVariants" to emptyMap(), + "errorsLoading" to false + ) + mainHandler.post { channel.invokeMethod("onFeatureFlagsCallback", arguments) } + } catch (e: Exception) { + Log.e("PostHogFlutter", "Error in onFeatureFlags signalling: ${e.message}", e) + val errorArguments = mapOf( + "flags" to emptyList(), + "flagVariants" to emptyMap(), + "errorsLoading" to true + ) + mainHandler.post { 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/src/posthog.dart b/lib/src/posthog.dart index 6b23e033..c57cc04c 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -127,5 +127,52 @@ class Posthog { Future getSessionId() => _posthog.getSessionId(); + /// Sets a callback to be invoked when feature flags are loaded from the PostHog server. + /// + /// The behavior of this callback differs slightly between platforms: + /// + /// **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). + /// + /// **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. + /// 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. + /// + /// **Common Parameters:** + /// - `errorsLoading` (optional named parameter): A boolean indicating if an error occurred during the request to load the feature flags. + /// This is `true` if the request timed out or if there was an error. It will be `false` if the request was successful. + /// + /// This is particularly useful on the first app load to ensure flags are available before you try to access them. + /// + /// Example: + /// ```dart + /// Posthog().onFeatureFlags((flags, flagVariants, {errorsLoading}) { + /// if (errorsLoading == true) { + /// // Handle error, e.g. flags might be stale or unavailable + /// print('Error loading feature flags!'); + /// return; + /// } + /// // On Web, you can iterate through flags and flagVariants directly. + /// // On Mobile, flags and flagVariants will be empty here. + /// // After this callback, you can safely query specific flags: + /// final isNewFeatureEnabled = await Posthog().isFeatureEnabled('new-feature'); + /// if (isNewFeatureEnabled) { + /// // Implement logic for 'new-feature' + /// } + /// final variantValue = await Posthog().getFeatureFlag('multivariate-flag'); + /// if (variantValue == 'test-variant') { + /// // Implement logic for 'test-variant' of 'multivariate-flag' + /// } + /// }); + /// ``` + void onFeatureFlags(OnFeatureFlagsCallback callback) => + _posthog.onFeatureFlags(callback); + Posthog._internal(); } diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index a65668e4..5dbcc5bd 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -12,6 +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 + final flags = + (args['flags'] as List?)?.cast() ?? []; + final flagVariants = + (args['flagVariants'] as Map?) + ?.map((k, v) => MapEntry(k.toString(), v)) ?? + {}; + final errorsLoading = args['errorsLoading'] as bool?; + + _onFeatureFlagsCallback!(flags, flagVariants, + errorsLoading: errorsLoading); + } catch (e, s) { + printIfDebug('Error processing onFeatureFlagsCallback: $e\n$s'); + // Invoke callback with empty/default values and errorsLoading: true + // to signal that an attempt was made but failed due to data issues. + if (_onFeatureFlagsCallback != null) { + _onFeatureFlagsCallback!([], {}, + errorsLoading: true); + } + } + } + break; + default: + break; + } + } + + @override + void onFeatureFlags(OnFeatureFlagsCallback callback) { + if (!isSupportedPlatform()) { + return; + } + _ensureMethodCallHandlerInitialized(); + _onFeatureFlagsCallback = callback; + } + @override Future setup(PostHogConfig config) async { if (!isSupportedPlatform()) { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index ee4525d9..0447ac49 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); @@ -124,5 +132,9 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('getSessionId() not implemented'); } + void onFeatureFlags(OnFeatureFlagsCallback callback) { + throw UnimplementedError('onFeatureFlags() has not been implemented.'); + } + // TODO: missing capture with more parameters } diff --git a/test/posthog_flutter_io_test.dart b/test/posthog_flutter_io_test.dart new file mode 100644 index 00000000..a381be77 --- /dev/null +++ b/test/posthog_flutter_io_test.dart @@ -0,0 +1,173 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.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; + + // 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; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + group('PosthogFlutterIO onFeatureFlags', () { + test('registers callback and initializes method call handler only once', () { + posthogFlutterIO.onFeatureFlags(emptyCallback); + + // Converted from variable to function declaration + void anotherCallback(List flags, Map flagVariants, {bool? errorsLoading}) {} + posthogFlutterIO.onFeatureFlags(anotherCallback); + }); + + test('invokes callback when native sends onFeatureFlagsCallback event with valid data', () async { + List? receivedFlags; + Map? receivedVariants; + bool? receivedErrorState; + + // Converted from variable to function declaration + void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { + receivedFlags = flags; + receivedVariants = flagVariants; + receivedErrorState = errorsLoading; + } + + posthogFlutterIO.onFeatureFlags(testCallback); + + 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 empty data when native sends onFeatureFlagsCallback event with empty/null data', () async { + List? receivedFlags; + Map? receivedVariants; + bool? receivedErrorState; + + // Converted from variable to function declaration + void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { + receivedFlags = flags; + receivedVariants = flagVariants; + receivedErrorState = errorsLoading; + } + + posthogFlutterIO.onFeatureFlags(testCallback); + + final Map mockNativeArgs = { + 'flags': [], + 'flagVariants': {}, + 'errorsLoading': null, + }; + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgs)), + (ByteData? data) {}, + ); + + expect(receivedFlags, isEmpty); + expect(receivedVariants, isEmpty); + expect(receivedErrorState, isNull); + }); + + test('invokes callback with errorsLoading true when native sends onFeatureFlagsCallback event with errorsLoading true', () async { + List? receivedFlags; + Map? receivedVariants; + bool? receivedErrorState; + + // Converted from variable to function declaration + void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { + receivedFlags = flags; + receivedVariants = flagVariants; + receivedErrorState = errorsLoading; + } + + posthogFlutterIO.onFeatureFlags(testCallback); + + final Map mockNativeArgs = { + 'flags': [], + 'flagVariants': {}, + 'errorsLoading': true, + }; + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgs)), + (ByteData? data) {}, + ); + + expect(receivedFlags, isEmpty); + expect(receivedVariants, isEmpty); + expect(receivedErrorState, isTrue); + }); + + test('handles onFeatureFlagsCallback with malformed data gracefully (e.g. wrong types)', () async { + List? receivedFlags; + Map? receivedVariants; + bool? receivedErrorState; + bool callbackInvoked = false; + + // Converted from variable to function declaration + void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { + callbackInvoked = true; + receivedFlags = flags; + receivedVariants = flagVariants; + receivedErrorState = errorsLoading; + } + + posthogFlutterIO.onFeatureFlags(testCallback); + + final Map mockNativeArgsMalformed = { + 'flags': 'not_a_list', + 'flagVariants': ['not_a_map'], + 'errorsLoading': 'not_a_bool', + }; + + 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"); + expect(receivedVariants, isEmpty, reason: "Variants should default to empty on parse error"); + expect(receivedErrorState, isTrue, reason: "errorsLoading should be true on parse error."); + }); + + }); +} diff --git a/test/posthog_flutter_platform_interface_fake.dart b/test/posthog_flutter_platform_interface_fake.dart index fd679082..c29ac391 100644 --- a/test/posthog_flutter_platform_interface_fake.dart +++ b/test/posthog_flutter_platform_interface_fake.dart @@ -2,6 +2,7 @@ import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { String? screenName; + OnFeatureFlagsCallback? registeredOnFeatureFlagsCallback; @override Future screen({ @@ -10,4 +11,9 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { }) async { this.screenName = screenName; } + + @override + void onFeatureFlags(OnFeatureFlagsCallback callback) { + registeredOnFeatureFlagsCallback = callback; + } } diff --git a/test/posthog_test.dart b/test/posthog_test.dart new file mode 100644 index 00000000..7b42caa3 --- /dev/null +++ b/test/posthog_test.dart @@ -0,0 +1,29 @@ +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('onFeatureFlags registers callback with platform interface', () { + // ignore: prefer_function_declarations_over_variables + final OnFeatureFlagsCallback testCallback = + (flags, flagVariants, {errorsLoading}) {}; + + Posthog().onFeatureFlags(testCallback); + + expect(fakePlatformInterface.registeredOnFeatureFlagsCallback, + equals(testCallback)); + }); + }); +} From 14b6850c4ade4d9c48ebe5b318ed0bfba2a7ade3 Mon Sep 17 00:00:00 2001 From: "William N. Green" Date: Fri, 6 Jun 2025 07:53:11 -0700 Subject: [PATCH 2/6] Update CHANGELOG.md Co-authored-by: Ioannis J --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fbb3f09..893e9144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## 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. ([#YOUR_PR_NUMBER_HERE]) +- 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 From 8125e29a15020ee941bc2ec92df8957576d2a798 Mon Sep 17 00:00:00 2001 From: "William N. Green" Date: Fri, 6 Jun 2025 08:18:08 -0700 Subject: [PATCH 3/6] fixing based on maintainer feedback Android try-catch and arguments (Comments 1 & 5): The Android native code in PosthogFlutterPlugin.kt now sends an empty map for the onFeatureFlagsCallback. The try-catch block remains to handle potential errors during the invokeMethod call itself but no longer sends different argument structures. Callback signature/location and parameter handling (Comments 2, 7, 8): The OnFeatureFlagsCallback is now defined in PostHogConfig and passed during Posthog().setup(). The Dart callback signature void Function(List flags, Map flagVariants, {bool? errorsLoading}) is maintained. For mobile (Android/iOS), this callback will be invoked with an empty list for flags, an empty map for flagVariants, and errorsLoading will be null (if the native call is successful) or true (if a Dart-side processing error occurs in _handleMethodCall). This aligns with the native SDKs not providing these details directly in their "flags loaded" callbacks. For web, the callback will receive the actual flags, flagVariants from posthog-js, and errorsLoading will be false (as the JS callback firing implies success). Dart redundant null check (Comment 3): The redundant null check in lib/src/posthog_flutter_io.dart's _handleMethodCall has been removed. Android dataMode (Comment 4): The unused dataMode configuration has been removed from the Android setupPostHog method in PosthogFlutterPlugin.kt. Android main thread invocation (Comment 6): The mainHandler.post for channel.invokeMethod in Android is kept, as it's good practice for Flutter platform channel calls. All relevant Dart files, the Android Kotlin plugin file, and their corresponding test files have been updated to reflect these changes. --- .../posthog/flutter/PosthogFlutterPlugin.kt | 15 +-- lib/posthog_flutter_web.dart | 51 +++++++++- lib/src/posthog.dart | 93 +++++++++---------- lib/src/posthog_config.dart | 11 ++- lib/src/posthog_flutter_io.dart | 22 ++--- .../posthog_flutter_platform_interface.dart | 4 - lib/src/posthog_flutter_web_handler.dart | 1 + test/posthog_flutter_io_test.dart | 93 +++++++++++-------- ...sthog_flutter_platform_interface_fake.dart | 9 +- test/posthog_test.dart | 10 +- 10 files changed, 180 insertions(+), 129 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 837933fa..e35ecdb8 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -262,9 +262,6 @@ class PosthogFlutterPlugin : posthogConfig.getIfNotNull("sessionReplay") { sessionReplay = it } - posthogConfig.getIfNotNull("dataMode") { - // Assuming DataMode is an enum or similar, handle appropriately - } this.sessionReplayConfig.captureLogcat = false @@ -274,19 +271,11 @@ class PosthogFlutterPlugin : onFeatureFlags = PostHogOnFeatureFlags { try { Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") - val arguments = mapOf( - "flags" to emptyList(), - "flagVariants" to emptyMap(), - "errorsLoading" to false - ) + val arguments = emptyMap() mainHandler.post { channel.invokeMethod("onFeatureFlagsCallback", arguments) } } catch (e: Exception) { Log.e("PostHogFlutter", "Error in onFeatureFlags signalling: ${e.message}", e) - val errorArguments = mapOf( - "flags" to emptyList(), - "flagVariants" to emptyMap(), - "errorsLoading" to true - ) + val errorArguments = emptyMap() mainHandler.post { channel.invokeMethod("onFeatureFlagsCallback", errorArguments) } } } 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 c57cc04c..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); @@ -127,52 +165,5 @@ class Posthog { Future getSessionId() => _posthog.getSessionId(); - /// Sets a callback to be invoked when feature flags are loaded from the PostHog server. - /// - /// The behavior of this callback differs slightly between platforms: - /// - /// **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). - /// - /// **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. - /// 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. - /// - /// **Common Parameters:** - /// - `errorsLoading` (optional named parameter): A boolean indicating if an error occurred during the request to load the feature flags. - /// This is `true` if the request timed out or if there was an error. It will be `false` if the request was successful. - /// - /// This is particularly useful on the first app load to ensure flags are available before you try to access them. - /// - /// Example: - /// ```dart - /// Posthog().onFeatureFlags((flags, flagVariants, {errorsLoading}) { - /// if (errorsLoading == true) { - /// // Handle error, e.g. flags might be stale or unavailable - /// print('Error loading feature flags!'); - /// return; - /// } - /// // On Web, you can iterate through flags and flagVariants directly. - /// // On Mobile, flags and flagVariants will be empty here. - /// // After this callback, you can safely query specific flags: - /// final isNewFeatureEnabled = await Posthog().isFeatureEnabled('new-feature'); - /// if (isNewFeatureEnabled) { - /// // Implement logic for 'new-feature' - /// } - /// final variantValue = await Posthog().getFeatureFlag('multivariate-flag'); - /// if (variantValue == 'test-variant') { - /// // Implement logic for 'test-variant' of 'multivariate-flag' - /// } - /// }); - /// ``` - void onFeatureFlags(OnFeatureFlagsCallback callback) => - _posthog.onFeatureFlags(callback); - Posthog._internal(); } 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 5dbcc5bd..3960a113 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -29,24 +29,22 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { 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'); - // Invoke callback with empty/default values and errorsLoading: true - // to signal that an attempt was made but failed due to data issues. - if (_onFeatureFlagsCallback != null) { - _onFeatureFlagsCallback!([], {}, - errorsLoading: true); - } + _onFeatureFlagsCallback!([], {}, + errorsLoading: true); } } break; @@ -56,18 +54,14 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } @override - void onFeatureFlags(OnFeatureFlagsCallback callback) { + Future setup(PostHogConfig config) async { if (!isSupportedPlatform()) { return; } - _ensureMethodCallHandlerInitialized(); - _onFeatureFlagsCallback = callback; - } - @override - Future setup(PostHogConfig config) async { - if (!isSupportedPlatform()) { - return; + _onFeatureFlagsCallback = config.onFeatureFlags; + if (_onFeatureFlagsCallback != null) { + _ensureMethodCallHandlerInitialized(); } try { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 0447ac49..2e26a860 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -132,9 +132,5 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('getSessionId() not implemented'); } - void onFeatureFlags(OnFeatureFlagsCallback callback) { - throw UnimplementedError('onFeatureFlags() 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 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 index a381be77..90b8f9af 100644 --- a/test/posthog_flutter_io_test.dart +++ b/test/posthog_flutter_io_test.dart @@ -1,5 +1,6 @@ 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 @@ -9,6 +10,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late PosthogFlutterIO posthogFlutterIO; + late PostHogConfig testConfig; // For testing method calls final List log = []; @@ -24,6 +26,10 @@ void main() { if (methodCall.method == 'isFeatureEnabled') { return true; } + // Simulate setup call success + if (methodCall.method == 'setup') { + return null; + } return null; }); }); @@ -33,13 +39,24 @@ void main() { .setMockMethodCallHandler(channel, null); }); - group('PosthogFlutterIO onFeatureFlags', () { - test('registers callback and initializes method call handler only once', () { - posthogFlutterIO.onFeatureFlags(emptyCallback); + 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); - // Converted from variable to function declaration - void anotherCallback(List flags, Map flagVariants, {bool? errorsLoading}) {} - posthogFlutterIO.onFeatureFlags(anotherCallback); + // 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 { @@ -47,14 +64,14 @@ void main() { Map? receivedVariants; bool? receivedErrorState; - // Converted from variable to function declaration + // Converted to function declaration void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { receivedFlags = flags; receivedVariants = flagVariants; receivedErrorState = errorsLoading; } - - posthogFlutterIO.onFeatureFlags(testCallback); + testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback); + await posthogFlutterIO.setup(testConfig); final Map mockNativeArgs = { 'flags': ['flag1', 'feature-abc'], @@ -73,25 +90,22 @@ void main() { expect(receivedErrorState, isFalse); }); - test('invokes callback with empty data when native sends onFeatureFlagsCallback event with empty/null data', () async { + test('invokes callback with default/empty data when native sends onFeatureFlagsCallback with empty map (mobile behavior)', () async { List? receivedFlags; Map? receivedVariants; bool? receivedErrorState; - // Converted from variable to function declaration + // 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); - posthogFlutterIO.onFeatureFlags(testCallback); - - final Map mockNativeArgs = { - 'flags': [], - 'flagVariants': {}, - 'errorsLoading': null, - }; + // Simulate mobile sending an empty map + final Map mockNativeArgs = {}; await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( channel.name, @@ -101,32 +115,32 @@ void main() { expect(receivedFlags, isEmpty); expect(receivedVariants, isEmpty); - expect(receivedErrorState, isNull); + expect(receivedErrorState, isNull); // errorsLoading will be null as it's not in the map }); - test('invokes callback with errorsLoading true when native sends onFeatureFlagsCallback event with errorsLoading true', () async { + test('invokes callback with errorsLoading true if Dart side processing fails, even with empty native args', () async { List? receivedFlags; Map? receivedVariants; bool? receivedErrorState; - // Converted from variable to function declaration + // 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); - posthogFlutterIO.onFeatureFlags(testCallback); - - final Map mockNativeArgs = { - 'flags': [], - 'flagVariants': {}, - 'errorsLoading': true, - }; + // 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', mockNativeArgs)), + channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgsMalformed)), (ByteData? data) {}, ); @@ -135,26 +149,26 @@ void main() { expect(receivedErrorState, isTrue); }); - test('handles onFeatureFlagsCallback with malformed data gracefully (e.g. wrong types)', () async { + 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 from variable to function declaration + // Converted to function declaration void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { callbackInvoked = true; receivedFlags = flags; receivedVariants = flagVariants; receivedErrorState = errorsLoading; } - - posthogFlutterIO.onFeatureFlags(testCallback); + testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback); + await posthogFlutterIO.setup(testConfig); final Map mockNativeArgsMalformed = { - 'flags': 'not_a_list', - 'flagVariants': ['not_a_map'], - 'errorsLoading': 'not_a_bool', + '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( @@ -164,10 +178,9 @@ void main() { ); expect(callbackInvoked, isTrue, reason: "Callback should still be invoked on parse error."); - expect(receivedFlags, isEmpty, reason: "Flags should default to empty on parse error"); - expect(receivedVariants, isEmpty, reason: "Variants should default to empty on parse error"); - expect(receivedErrorState, isTrue, reason: "errorsLoading should be true 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 c29ac391..b14d7a2c 100644 --- a/test/posthog_flutter_platform_interface_fake.dart +++ b/test/posthog_flutter_platform_interface_fake.dart @@ -1,8 +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({ @@ -13,7 +15,10 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { } @override - void onFeatureFlags(OnFeatureFlagsCallback callback) { - registeredOnFeatureFlagsCallback = callback; + 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 index 7b42caa3..4c02327a 100644 --- a/test/posthog_test.dart +++ b/test/posthog_test.dart @@ -15,13 +15,19 @@ void main() { PosthogFlutterPlatformInterface.instance = fakePlatformInterface; }); - test('onFeatureFlags registers callback with platform interface', () { + 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, + ); - Posthog().onFeatureFlags(testCallback); + await Posthog().setup(config); + expect(fakePlatformInterface.receivedConfig, equals(config)); expect(fakePlatformInterface.registeredOnFeatureFlagsCallback, equals(testCallback)); }); From 91cf32fd661ea932d98229d95ad9319eeb9ff8d6 Mon Sep 17 00:00:00 2001 From: "William N. Green" Date: Fri, 6 Jun 2025 10:21:03 -0700 Subject: [PATCH 4/6] Revert "fixing based on maintainer feedback" This reverts commit 8125e29a15020ee941bc2ec92df8957576d2a798. --- .../posthog/flutter/PosthogFlutterPlugin.kt | 15 ++- lib/posthog_flutter_web.dart | 51 +--------- lib/src/posthog.dart | 93 ++++++++++--------- lib/src/posthog_config.dart | 11 +-- lib/src/posthog_flutter_io.dart | 22 +++-- .../posthog_flutter_platform_interface.dart | 4 + lib/src/posthog_flutter_web_handler.dart | 1 - test/posthog_flutter_io_test.dart | 93 ++++++++----------- ...sthog_flutter_platform_interface_fake.dart | 9 +- test/posthog_test.dart | 10 +- 10 files changed, 129 insertions(+), 180 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index e35ecdb8..837933fa 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -262,6 +262,9 @@ class PosthogFlutterPlugin : posthogConfig.getIfNotNull("sessionReplay") { sessionReplay = it } + posthogConfig.getIfNotNull("dataMode") { + // Assuming DataMode is an enum or similar, handle appropriately + } this.sessionReplayConfig.captureLogcat = false @@ -271,11 +274,19 @@ class PosthogFlutterPlugin : onFeatureFlags = PostHogOnFeatureFlags { try { Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") - val arguments = emptyMap() + val arguments = mapOf( + "flags" to emptyList(), + "flagVariants" to emptyMap(), + "errorsLoading" to false + ) mainHandler.post { channel.invokeMethod("onFeatureFlagsCallback", arguments) } } catch (e: Exception) { Log.e("PostHogFlutter", "Error in onFeatureFlags signalling: ${e.message}", e) - val errorArguments = emptyMap() + val errorArguments = mapOf( + "flags" to emptyList(), + "flagVariants" to emptyMap(), + "errorsLoading" to true + ) mainHandler.post { channel.invokeMethod("onFeatureFlagsCallback", errorArguments) } } } diff --git a/lib/posthog_flutter_web.dart b/lib/posthog_flutter_web.dart index c4728d8f..5c8b4e72 100644 --- a/lib/posthog_flutter_web.dart +++ b/lib/posthog_flutter_web.dart @@ -1,12 +1,9 @@ // 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'; @@ -23,52 +20,8 @@ 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); } - @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); - } - } + Future handleMethodCall(MethodCall call) => + handleWebMethodCall(call); } diff --git a/lib/src/posthog.dart b/lib/src/posthog.dart index 46869299..c57cc04c 100644 --- a/lib/src/posthog.dart +++ b/lib/src/posthog.dart @@ -18,48 +18,10 @@ class Posthog { String? _currentScreen; - /// 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. + /// 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 Future setup(PostHogConfig config) { _config = config; // Store the config return _posthog.setup(config); @@ -165,5 +127,52 @@ class Posthog { Future getSessionId() => _posthog.getSessionId(); + /// Sets a callback to be invoked when feature flags are loaded from the PostHog server. + /// + /// The behavior of this callback differs slightly between platforms: + /// + /// **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). + /// + /// **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. + /// 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. + /// + /// **Common Parameters:** + /// - `errorsLoading` (optional named parameter): A boolean indicating if an error occurred during the request to load the feature flags. + /// This is `true` if the request timed out or if there was an error. It will be `false` if the request was successful. + /// + /// This is particularly useful on the first app load to ensure flags are available before you try to access them. + /// + /// Example: + /// ```dart + /// Posthog().onFeatureFlags((flags, flagVariants, {errorsLoading}) { + /// if (errorsLoading == true) { + /// // Handle error, e.g. flags might be stale or unavailable + /// print('Error loading feature flags!'); + /// return; + /// } + /// // On Web, you can iterate through flags and flagVariants directly. + /// // On Mobile, flags and flagVariants will be empty here. + /// // After this callback, you can safely query specific flags: + /// final isNewFeatureEnabled = await Posthog().isFeatureEnabled('new-feature'); + /// if (isNewFeatureEnabled) { + /// // Implement logic for 'new-feature' + /// } + /// final variantValue = await Posthog().getFeatureFlag('multivariate-flag'); + /// if (variantValue == 'test-variant') { + /// // Implement logic for 'test-variant' of 'multivariate-flag' + /// } + /// }); + /// ``` + void onFeatureFlags(OnFeatureFlagsCallback callback) => + _posthog.onFeatureFlags(callback); + Posthog._internal(); } diff --git a/lib/src/posthog_config.dart b/lib/src/posthog_config.dart index a0119d35..57132804 100644 --- a/lib/src/posthog_config.dart +++ b/lib/src/posthog_config.dart @@ -1,5 +1,3 @@ -import 'posthog_flutter_platform_interface.dart'; - enum PostHogPersonProfiles { never, always, identifiedOnly } enum PostHogDataMode { wifi, cellular, any } @@ -30,17 +28,10 @@ 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, { - this.onFeatureFlags, - }); + PostHogConfig(this.apiKey); Map toMap() { return { diff --git a/lib/src/posthog_flutter_io.dart b/lib/src/posthog_flutter_io.dart index 3960a113..5dbcc5bd 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -29,22 +29,24 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { 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); + // Invoke callback with empty/default values and errorsLoading: true + // to signal that an attempt was made but failed due to data issues. + if (_onFeatureFlagsCallback != null) { + _onFeatureFlagsCallback!([], {}, + errorsLoading: true); + } } } break; @@ -54,14 +56,18 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } @override - Future setup(PostHogConfig config) async { + void onFeatureFlags(OnFeatureFlagsCallback callback) { if (!isSupportedPlatform()) { return; } + _ensureMethodCallHandlerInitialized(); + _onFeatureFlagsCallback = callback; + } - _onFeatureFlagsCallback = config.onFeatureFlags; - if (_onFeatureFlagsCallback != null) { - _ensureMethodCallHandlerInitialized(); + @override + Future setup(PostHogConfig config) async { + if (!isSupportedPlatform()) { + return; } try { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 2e26a860..0447ac49 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -132,5 +132,9 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('getSessionId() not implemented'); } + void onFeatureFlags(OnFeatureFlagsCallback callback) { + throw UnimplementedError('onFeatureFlags() 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 0c246cd0..35ca49ff 100644 --- a/lib/src/posthog_flutter_web_handler.dart +++ b/lib/src/posthog_flutter_web_handler.dart @@ -25,7 +25,6 @@ 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 index 90b8f9af..a381be77 100644 --- a/test/posthog_flutter_io_test.dart +++ b/test/posthog_flutter_io_test.dart @@ -1,6 +1,5 @@ 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 @@ -10,7 +9,6 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late PosthogFlutterIO posthogFlutterIO; - late PostHogConfig testConfig; // For testing method calls final List log = []; @@ -26,10 +24,6 @@ void main() { if (methodCall.method == 'isFeatureEnabled') { return true; } - // Simulate setup call success - if (methodCall.method == 'setup') { - return null; - } return null; }); }); @@ -39,24 +33,13 @@ void main() { .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); + group('PosthogFlutterIO onFeatureFlags', () { + test('registers callback and initializes method call handler only once', () { + posthogFlutterIO.onFeatureFlags(emptyCallback); - // 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); + // Converted from variable to function declaration + void anotherCallback(List flags, Map flagVariants, {bool? errorsLoading}) {} + posthogFlutterIO.onFeatureFlags(anotherCallback); }); test('invokes callback when native sends onFeatureFlagsCallback event with valid data', () async { @@ -64,14 +47,14 @@ void main() { Map? receivedVariants; bool? receivedErrorState; - // Converted to function declaration + // Converted from variable 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); + + posthogFlutterIO.onFeatureFlags(testCallback); final Map mockNativeArgs = { 'flags': ['flag1', 'feature-abc'], @@ -90,22 +73,25 @@ void main() { expect(receivedErrorState, isFalse); }); - test('invokes callback with default/empty data when native sends onFeatureFlagsCallback with empty map (mobile behavior)', () async { + test('invokes callback with empty data when native sends onFeatureFlagsCallback event with empty/null data', () async { List? receivedFlags; Map? receivedVariants; bool? receivedErrorState; - // Converted to function declaration + // Converted from variable 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 = {}; + posthogFlutterIO.onFeatureFlags(testCallback); + + final Map mockNativeArgs = { + 'flags': [], + 'flagVariants': {}, + 'errorsLoading': null, + }; await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( channel.name, @@ -115,32 +101,32 @@ void main() { expect(receivedFlags, isEmpty); expect(receivedVariants, isEmpty); - expect(receivedErrorState, isNull); // errorsLoading will be null as it's not in the map + expect(receivedErrorState, isNull); }); - test('invokes callback with errorsLoading true if Dart side processing fails, even with empty native args', () async { + test('invokes callback with errorsLoading true when native sends onFeatureFlagsCallback event with errorsLoading true', () async { List? receivedFlags; Map? receivedVariants; bool? receivedErrorState; - // Converted to function declaration + // Converted from variable 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 - }; + posthogFlutterIO.onFeatureFlags(testCallback); + + final Map mockNativeArgs = { + 'flags': [], + 'flagVariants': {}, + 'errorsLoading': true, + }; await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( channel.name, - channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgsMalformed)), + channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgs)), (ByteData? data) {}, ); @@ -149,26 +135,26 @@ void main() { expect(receivedErrorState, isTrue); }); - test('handles onFeatureFlagsCallback with malformed data gracefully (e.g. wrong types in maps/lists)', () async { + test('handles onFeatureFlagsCallback with malformed data gracefully (e.g. wrong types)', () async { List? receivedFlags; Map? receivedVariants; bool? receivedErrorState; bool callbackInvoked = false; - // Converted to function declaration + // Converted from variable 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); + + posthogFlutterIO.onFeatureFlags(testCallback); 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 + 'flags': 'not_a_list', + 'flagVariants': ['not_a_map'], + 'errorsLoading': 'not_a_bool', }; await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( @@ -178,9 +164,10 @@ void main() { ); 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."); + expect(receivedFlags, isEmpty, reason: "Flags should default to empty on parse error"); + expect(receivedVariants, isEmpty, reason: "Variants should default to empty on parse error"); + expect(receivedErrorState, isTrue, reason: "errorsLoading should be true on parse error."); }); + }); } diff --git a/test/posthog_flutter_platform_interface_fake.dart b/test/posthog_flutter_platform_interface_fake.dart index b14d7a2c..c29ac391 100644 --- a/test/posthog_flutter_platform_interface_fake.dart +++ b/test/posthog_flutter_platform_interface_fake.dart @@ -1,10 +1,8 @@ -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({ @@ -15,10 +13,7 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { } @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(); + void onFeatureFlags(OnFeatureFlagsCallback callback) { + registeredOnFeatureFlagsCallback = callback; } } diff --git a/test/posthog_test.dart b/test/posthog_test.dart index 4c02327a..7b42caa3 100644 --- a/test/posthog_test.dart +++ b/test/posthog_test.dart @@ -15,19 +15,13 @@ void main() { PosthogFlutterPlatformInterface.instance = fakePlatformInterface; }); - test('setup passes config and onFeatureFlags callback to platform interface', () async { + test('onFeatureFlags registers callback with platform interface', () { // ignore: prefer_function_declarations_over_variables final OnFeatureFlagsCallback testCallback = (flags, flagVariants, {errorsLoading}) {}; - - final config = PostHogConfig( - 'test_api_key', - onFeatureFlags: testCallback, - ); - await Posthog().setup(config); + Posthog().onFeatureFlags(testCallback); - expect(fakePlatformInterface.receivedConfig, equals(config)); expect(fakePlatformInterface.registeredOnFeatureFlagsCallback, equals(testCallback)); }); From 18548ac4af6ecd461fc175bec30682f3e184876a Mon Sep 17 00:00:00 2001 From: "William N. Green" Date: Fri, 6 Jun 2025 10:31:55 -0700 Subject: [PATCH 5/6] Reapply "fixing based on maintainer feedback" This reverts commit 91cf32fd661ea932d98229d95ad9319eeb9ff8d6. --- .../posthog/flutter/PosthogFlutterPlugin.kt | 15 +-- lib/posthog_flutter_web.dart | 51 +++++++++- lib/src/posthog.dart | 93 +++++++++---------- lib/src/posthog_config.dart | 11 ++- lib/src/posthog_flutter_io.dart | 22 ++--- .../posthog_flutter_platform_interface.dart | 4 - lib/src/posthog_flutter_web_handler.dart | 1 + test/posthog_flutter_io_test.dart | 93 +++++++++++-------- ...sthog_flutter_platform_interface_fake.dart | 9 +- test/posthog_test.dart | 10 +- 10 files changed, 180 insertions(+), 129 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 837933fa..e35ecdb8 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -262,9 +262,6 @@ class PosthogFlutterPlugin : posthogConfig.getIfNotNull("sessionReplay") { sessionReplay = it } - posthogConfig.getIfNotNull("dataMode") { - // Assuming DataMode is an enum or similar, handle appropriately - } this.sessionReplayConfig.captureLogcat = false @@ -274,19 +271,11 @@ class PosthogFlutterPlugin : onFeatureFlags = PostHogOnFeatureFlags { try { Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") - val arguments = mapOf( - "flags" to emptyList(), - "flagVariants" to emptyMap(), - "errorsLoading" to false - ) + val arguments = emptyMap() mainHandler.post { channel.invokeMethod("onFeatureFlagsCallback", arguments) } } catch (e: Exception) { Log.e("PostHogFlutter", "Error in onFeatureFlags signalling: ${e.message}", e) - val errorArguments = mapOf( - "flags" to emptyList(), - "flagVariants" to emptyMap(), - "errorsLoading" to true - ) + val errorArguments = emptyMap() mainHandler.post { channel.invokeMethod("onFeatureFlagsCallback", errorArguments) } } } 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 c57cc04c..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); @@ -127,52 +165,5 @@ class Posthog { Future getSessionId() => _posthog.getSessionId(); - /// Sets a callback to be invoked when feature flags are loaded from the PostHog server. - /// - /// The behavior of this callback differs slightly between platforms: - /// - /// **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). - /// - /// **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. - /// 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. - /// - /// **Common Parameters:** - /// - `errorsLoading` (optional named parameter): A boolean indicating if an error occurred during the request to load the feature flags. - /// This is `true` if the request timed out or if there was an error. It will be `false` if the request was successful. - /// - /// This is particularly useful on the first app load to ensure flags are available before you try to access them. - /// - /// Example: - /// ```dart - /// Posthog().onFeatureFlags((flags, flagVariants, {errorsLoading}) { - /// if (errorsLoading == true) { - /// // Handle error, e.g. flags might be stale or unavailable - /// print('Error loading feature flags!'); - /// return; - /// } - /// // On Web, you can iterate through flags and flagVariants directly. - /// // On Mobile, flags and flagVariants will be empty here. - /// // After this callback, you can safely query specific flags: - /// final isNewFeatureEnabled = await Posthog().isFeatureEnabled('new-feature'); - /// if (isNewFeatureEnabled) { - /// // Implement logic for 'new-feature' - /// } - /// final variantValue = await Posthog().getFeatureFlag('multivariate-flag'); - /// if (variantValue == 'test-variant') { - /// // Implement logic for 'test-variant' of 'multivariate-flag' - /// } - /// }); - /// ``` - void onFeatureFlags(OnFeatureFlagsCallback callback) => - _posthog.onFeatureFlags(callback); - Posthog._internal(); } 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 5dbcc5bd..3960a113 100644 --- a/lib/src/posthog_flutter_io.dart +++ b/lib/src/posthog_flutter_io.dart @@ -29,24 +29,22 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { 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'); - // Invoke callback with empty/default values and errorsLoading: true - // to signal that an attempt was made but failed due to data issues. - if (_onFeatureFlagsCallback != null) { - _onFeatureFlagsCallback!([], {}, - errorsLoading: true); - } + _onFeatureFlagsCallback!([], {}, + errorsLoading: true); } } break; @@ -56,18 +54,14 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } @override - void onFeatureFlags(OnFeatureFlagsCallback callback) { + Future setup(PostHogConfig config) async { if (!isSupportedPlatform()) { return; } - _ensureMethodCallHandlerInitialized(); - _onFeatureFlagsCallback = callback; - } - @override - Future setup(PostHogConfig config) async { - if (!isSupportedPlatform()) { - return; + _onFeatureFlagsCallback = config.onFeatureFlags; + if (_onFeatureFlagsCallback != null) { + _ensureMethodCallHandlerInitialized(); } try { diff --git a/lib/src/posthog_flutter_platform_interface.dart b/lib/src/posthog_flutter_platform_interface.dart index 0447ac49..2e26a860 100644 --- a/lib/src/posthog_flutter_platform_interface.dart +++ b/lib/src/posthog_flutter_platform_interface.dart @@ -132,9 +132,5 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('getSessionId() not implemented'); } - void onFeatureFlags(OnFeatureFlagsCallback callback) { - throw UnimplementedError('onFeatureFlags() 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 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 index a381be77..90b8f9af 100644 --- a/test/posthog_flutter_io_test.dart +++ b/test/posthog_flutter_io_test.dart @@ -1,5 +1,6 @@ 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 @@ -9,6 +10,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); late PosthogFlutterIO posthogFlutterIO; + late PostHogConfig testConfig; // For testing method calls final List log = []; @@ -24,6 +26,10 @@ void main() { if (methodCall.method == 'isFeatureEnabled') { return true; } + // Simulate setup call success + if (methodCall.method == 'setup') { + return null; + } return null; }); }); @@ -33,13 +39,24 @@ void main() { .setMockMethodCallHandler(channel, null); }); - group('PosthogFlutterIO onFeatureFlags', () { - test('registers callback and initializes method call handler only once', () { - posthogFlutterIO.onFeatureFlags(emptyCallback); + 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); - // Converted from variable to function declaration - void anotherCallback(List flags, Map flagVariants, {bool? errorsLoading}) {} - posthogFlutterIO.onFeatureFlags(anotherCallback); + // 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 { @@ -47,14 +64,14 @@ void main() { Map? receivedVariants; bool? receivedErrorState; - // Converted from variable to function declaration + // Converted to function declaration void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { receivedFlags = flags; receivedVariants = flagVariants; receivedErrorState = errorsLoading; } - - posthogFlutterIO.onFeatureFlags(testCallback); + testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback); + await posthogFlutterIO.setup(testConfig); final Map mockNativeArgs = { 'flags': ['flag1', 'feature-abc'], @@ -73,25 +90,22 @@ void main() { expect(receivedErrorState, isFalse); }); - test('invokes callback with empty data when native sends onFeatureFlagsCallback event with empty/null data', () async { + test('invokes callback with default/empty data when native sends onFeatureFlagsCallback with empty map (mobile behavior)', () async { List? receivedFlags; Map? receivedVariants; bool? receivedErrorState; - // Converted from variable to function declaration + // 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); - posthogFlutterIO.onFeatureFlags(testCallback); - - final Map mockNativeArgs = { - 'flags': [], - 'flagVariants': {}, - 'errorsLoading': null, - }; + // Simulate mobile sending an empty map + final Map mockNativeArgs = {}; await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( channel.name, @@ -101,32 +115,32 @@ void main() { expect(receivedFlags, isEmpty); expect(receivedVariants, isEmpty); - expect(receivedErrorState, isNull); + expect(receivedErrorState, isNull); // errorsLoading will be null as it's not in the map }); - test('invokes callback with errorsLoading true when native sends onFeatureFlagsCallback event with errorsLoading true', () async { + test('invokes callback with errorsLoading true if Dart side processing fails, even with empty native args', () async { List? receivedFlags; Map? receivedVariants; bool? receivedErrorState; - // Converted from variable to function declaration + // 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); - posthogFlutterIO.onFeatureFlags(testCallback); - - final Map mockNativeArgs = { - 'flags': [], - 'flagVariants': {}, - 'errorsLoading': true, - }; + // 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', mockNativeArgs)), + channel.codec.encodeMethodCall(MethodCall('onFeatureFlagsCallback', mockNativeArgsMalformed)), (ByteData? data) {}, ); @@ -135,26 +149,26 @@ void main() { expect(receivedErrorState, isTrue); }); - test('handles onFeatureFlagsCallback with malformed data gracefully (e.g. wrong types)', () async { + 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 from variable to function declaration + // Converted to function declaration void testCallback(List flags, Map flagVariants, {bool? errorsLoading}) { callbackInvoked = true; receivedFlags = flags; receivedVariants = flagVariants; receivedErrorState = errorsLoading; } - - posthogFlutterIO.onFeatureFlags(testCallback); + testConfig = PostHogConfig('test_api_key', onFeatureFlags: testCallback); + await posthogFlutterIO.setup(testConfig); final Map mockNativeArgsMalformed = { - 'flags': 'not_a_list', - 'flagVariants': ['not_a_map'], - 'errorsLoading': 'not_a_bool', + '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( @@ -164,10 +178,9 @@ void main() { ); expect(callbackInvoked, isTrue, reason: "Callback should still be invoked on parse error."); - expect(receivedFlags, isEmpty, reason: "Flags should default to empty on parse error"); - expect(receivedVariants, isEmpty, reason: "Variants should default to empty on parse error"); - expect(receivedErrorState, isTrue, reason: "errorsLoading should be true 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 c29ac391..b14d7a2c 100644 --- a/test/posthog_flutter_platform_interface_fake.dart +++ b/test/posthog_flutter_platform_interface_fake.dart @@ -1,8 +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({ @@ -13,7 +15,10 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { } @override - void onFeatureFlags(OnFeatureFlagsCallback callback) { - registeredOnFeatureFlagsCallback = callback; + 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 index 7b42caa3..4c02327a 100644 --- a/test/posthog_test.dart +++ b/test/posthog_test.dart @@ -15,13 +15,19 @@ void main() { PosthogFlutterPlatformInterface.instance = fakePlatformInterface; }); - test('onFeatureFlags registers callback with platform interface', () { + 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, + ); - Posthog().onFeatureFlags(testCallback); + await Posthog().setup(config); + expect(fakePlatformInterface.receivedConfig, equals(config)); expect(fakePlatformInterface.registeredOnFeatureFlagsCallback, equals(testCallback)); }); From c44a7e752138a4b7d86f42d50cad3135589a4c73 Mon Sep 17 00:00:00 2001 From: "William N. Green" Date: Tue, 10 Jun 2025 07:40:20 -0700 Subject: [PATCH 6/6] removed main thread invocation --- .../main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index e35ecdb8..9d9ea749 100644 --- a/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -30,8 +30,6 @@ class PosthogFlutterPlugin : private lateinit var applicationContext: Context - private val mainHandler = Handler(Looper.getMainLooper()) - private val snapshotSender = SnapshotSender() override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { @@ -272,11 +270,11 @@ class PosthogFlutterPlugin : try { Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") val arguments = emptyMap() - mainHandler.post { channel.invokeMethod("onFeatureFlagsCallback", arguments) } + channel.invokeMethod("onFeatureFlagsCallback", arguments) } catch (e: Exception) { Log.e("PostHogFlutter", "Error in onFeatureFlags signalling: ${e.message}", e) val errorArguments = emptyMap() - mainHandler.post { channel.invokeMethod("onFeatureFlagsCallback", errorArguments) } + channel.invokeMethod("onFeatureFlagsCallback", errorArguments) } } }