From 2b5736f1dfb52b54f248a7df5f2b72a1a67c4bc6 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 21 May 2026 10:34:01 -0400 Subject: [PATCH 01/17] WIP: event bridge wiring and mixpanel_flutter_common package Introduces packages/mixpanel_flutter_common (EventBridge + JSONLogic), wires the native MixpanelEventBridge through to Dart on Android/iOS, and adds forwarding tests. Held on a branch while the repo is restructured into a packages/ monorepo layout; will be rebased onto the new structure. --- .../mixpanel_flutter/android/build.gradle | 16 +- .../MixpanelFlutterPlugin.java | 2 + .../mixpanel_flutter/EventBridgeSubscriber.kt | 62 ++ .../mixpanel_flutter/example/pubspec.lock | 7 + .../ios/mixpanel_flutter.podspec | 3 + .../lib/mixpanel_flutter.dart | 547 ++++++++++++------ .../macos/mixpanel_flutter.podspec | 3 + packages/mixpanel_flutter/pubspec.lock | 7 + packages/mixpanel_flutter/pubspec.yaml | 2 + .../Classes/SwiftMixpanelFlutterPlugin.swift | 30 +- .../test/event_bridge_forwarding_test.dart | 109 ++++ .../analysis_options.yaml | 6 + .../lib/mixpanel_flutter_common.dart | 16 + .../lib/src/event_bridge.dart | 50 ++ .../src/jsonlogic/json_logic_evaluator.dart | 240 ++++++++ .../src/jsonlogic/json_logic_exception.dart | 36 ++ .../lib/src/jsonlogic/json_logic_parser.dart | 196 +++++++ .../lib/src/jsonlogic/json_logic_rule.dart | 112 ++++ .../lib/src/mixpanel_event.dart | 24 + packages/mixpanel_flutter_common/pubspec.lock | 389 +++++++++++++ packages/mixpanel_flutter_common/pubspec.yaml | 17 + .../test/event_bridge_test.dart | 122 ++++ .../jsonlogic/json_logic_edge_case_test.dart | 507 ++++++++++++++++ .../json_logic_extra_edge_case_test.dart | 382 ++++++++++++ .../jsonlogic/json_logic_security_test.dart | 193 ++++++ .../test/jsonlogic/json_logic_test.dart | 85 +++ .../test/jsonlogic/tests.json | 111 ++++ 27 files changed, 3091 insertions(+), 183 deletions(-) create mode 100644 packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt create mode 100644 packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart create mode 100644 packages/mixpanel_flutter_common/analysis_options.yaml create mode 100644 packages/mixpanel_flutter_common/lib/mixpanel_flutter_common.dart create mode 100644 packages/mixpanel_flutter_common/lib/src/event_bridge.dart create mode 100644 packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart create mode 100644 packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_exception.dart create mode 100644 packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_parser.dart create mode 100644 packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_rule.dart create mode 100644 packages/mixpanel_flutter_common/lib/src/mixpanel_event.dart create mode 100644 packages/mixpanel_flutter_common/pubspec.lock create mode 100644 packages/mixpanel_flutter_common/pubspec.yaml create mode 100644 packages/mixpanel_flutter_common/test/event_bridge_test.dart create mode 100644 packages/mixpanel_flutter_common/test/jsonlogic/json_logic_edge_case_test.dart create mode 100644 packages/mixpanel_flutter_common/test/jsonlogic/json_logic_extra_edge_case_test.dart create mode 100644 packages/mixpanel_flutter_common/test/jsonlogic/json_logic_security_test.dart create mode 100644 packages/mixpanel_flutter_common/test/jsonlogic/json_logic_test.dart create mode 100644 packages/mixpanel_flutter_common/test/jsonlogic/tests.json diff --git a/packages/mixpanel_flutter/android/build.gradle b/packages/mixpanel_flutter/android/build.gradle index 5dc7afad..cbf7addd 100644 --- a/packages/mixpanel_flutter/android/build.gradle +++ b/packages/mixpanel_flutter/android/build.gradle @@ -2,6 +2,7 @@ group 'com.mixpanel.mixpanel_flutter' version '1.0' buildscript { + ext.kotlin_version = '1.9.0' repositories { google() mavenCentral() @@ -10,6 +11,7 @@ buildscript { dependencies { // Use a compatible version of Gradle Plugin classpath 'com.android.tools.build:gradle:8.1.0' // Updated to 8.1.0 + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -21,6 +23,7 @@ rootProject.allprojects { } apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' android { // Safely handle 'namespace' property for new Android Gradle plugin versions @@ -40,12 +43,23 @@ android { targetCompatibility JavaVersion.VERSION_17 } + kotlinOptions { + jvmTarget = '17' + } + + sourceSets { + main.kotlin.srcDirs += 'src/main/kotlin' + } + lintOptions { disable 'InvalidPackage' } } dependencies { - // Use the Mixpanel Android SDK + // Use the Mixpanel Android SDK (includes the common EventBridge module + // since 8.7.0 via PR #924, the source of events the subscriber consumes). implementation "com.mixpanel.android:mixpanel-android:8.7.0" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1" } diff --git a/packages/mixpanel_flutter/android/src/main/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java b/packages/mixpanel_flutter/android/src/main/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java index 82afcf8e..d7137ece 100644 --- a/packages/mixpanel_flutter/android/src/main/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java +++ b/packages/mixpanel_flutter/android/src/main/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java @@ -212,6 +212,7 @@ private void initializeMethodChannel() { channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "mixpanel_flutter", new StandardMethodCodec(new MixpanelMessageCodec())); channel.setMethodCallHandler(this); + EventBridgeSubscriber.start(channel); } } @@ -785,6 +786,7 @@ private long readPersistenceTtlMillis(Map policyMap) { @Override public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + EventBridgeSubscriber.stop(); if (channel != null) { channel.setMethodCallHandler(null); channel = null; diff --git a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt new file mode 100644 index 00000000..26b4ebfb --- /dev/null +++ b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt @@ -0,0 +1,62 @@ +package com.mixpanel.mixpanel_flutter + +import android.util.Log +import com.mixpanel.android.eventbridge.MixpanelEventBridge +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.json.JSONException +import org.json.JSONObject + +/** + * Subscribes to the native Mixpanel SDK's [MixpanelEventBridge] (a Kotlin + * `SharedFlow`) and forwards each event to the Dart side via the existing + * Flutter MethodChannel. + * + * The Java plugin calls [start] from `onAttachedToEngine` and [stop] from + * `onDetachedFromEngine`. This object is a singleton because the native + * SharedFlow itself is a singleton — we never want more than one active + * subscription per process. + */ +object EventBridgeSubscriber { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var job: Job? = null + + @JvmStatic + fun start(channel: MethodChannel) { + if (job != null) return + job = scope.launch { + MixpanelEventBridge.events().collect { event -> + val properties = event.properties?.let { safelyConvert(it) } + channel.invokeMethod( + "onMixpanelEvent", + mapOf( + "eventName" to event.eventName, + "properties" to properties, + ) + ) + } + } + } + + @JvmStatic + fun stop() { + job?.cancel() + job = null + } + + private fun safelyConvert(json: JSONObject): Map? = try { + MixpanelFlutterHelper.toMap(json) + } catch (e: JSONException) { + // A malformed properties payload should not abort the whole + // subscription — drop this event's properties and keep collecting. + Log.w("EventBridgeSubscriber", "Failed to convert event properties", e) + null + } +} diff --git a/packages/mixpanel_flutter/example/pubspec.lock b/packages/mixpanel_flutter/example/pubspec.lock index eb8c1f1f..e1e9170e 100644 --- a/packages/mixpanel_flutter/example/pubspec.lock +++ b/packages/mixpanel_flutter/example/pubspec.lock @@ -143,6 +143,13 @@ packages: relative: true source: path version: "2.8.0" + mixpanel_flutter_common: + dependency: transitive + description: + path: "../../mixpanel_flutter_common" + relative: true + source: path + version: "0.1.0" path: dependency: transitive description: diff --git a/packages/mixpanel_flutter/ios/mixpanel_flutter.podspec b/packages/mixpanel_flutter/ios/mixpanel_flutter.podspec index eac3c0a7..eca62856 100644 --- a/packages/mixpanel_flutter/ios/mixpanel_flutter.podspec +++ b/packages/mixpanel_flutter/ios/mixpanel_flutter.podspec @@ -16,6 +16,9 @@ Pod::Spec.new do |s| s.source_files = 'Classes/**/*' s.dependency 'Flutter' s.dependency 'Mixpanel-swift', '6.4.0' + # Explicit dependency (also pulled in transitively by Mixpanel-swift 6.4+) + # so `import MixpanelSwiftCommon` in our plugin resolves reliably. + s.dependency 'MixpanelSwiftCommon', '~> 1.0.0' s.platform = :ios, '12.0' # Flutter.framework does not contain a i386 slice. diff --git a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart index 1f51e73e..56a90def 100644 --- a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart +++ b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/services.dart'; import 'package:mixpanel_flutter/codec/mixpanel_message_codec.dart'; import 'package:mixpanel_flutter/src/version.dart'; +import 'package:mixpanel_flutter_common/mixpanel_flutter_common.dart'; /// Identifies where a served [MixpanelFlagVariant] came from. Non-null on /// every variant the SDK returns: @@ -25,8 +26,9 @@ abstract class MixpanelFlagVariantSource { const MixpanelFlagVariantSource(); const factory MixpanelFlagVariantSource.network() = NetworkSource; - factory MixpanelFlagVariantSource.persistence( - {required DateTime persistedAt}) = PersistenceSource; + factory MixpanelFlagVariantSource.persistence({ + required DateTime persistedAt, + }) = PersistenceSource; const factory MixpanelFlagVariantSource.fallback() = FallbackSource; /// Decodes a source map produced by the platform handlers. Falls back to @@ -41,12 +43,14 @@ abstract class MixpanelFlagVariantSource { final millis = raw is int ? raw : (raw is num ? raw.toInt() : null); if (millis == null) { developer.log( - '`MixpanelFlagVariantSource.fromMap` received persistence source with missing persistedAtMillis, defaulting to fallback', - name: 'Mixpanel'); + '`MixpanelFlagVariantSource.fromMap` received persistence source with missing persistedAtMillis, defaulting to fallback', + name: 'Mixpanel', + ); return const FallbackSource(); } return PersistenceSource( - persistedAt: DateTime.fromMillisecondsSinceEpoch(millis)); + persistedAt: DateTime.fromMillisecondsSinceEpoch(millis), + ); } return const FallbackSource(); } @@ -150,8 +154,9 @@ class MixpanelFlagVariant { final key = map['key'] as String?; if (key == null || key.isEmpty) { developer.log( - '`MixpanelFlagVariant.fromMap` received map with missing or empty key, using empty string as default', - name: 'Mixpanel'); + '`MixpanelFlagVariant.fromMap` received map with missing or empty key, using empty string as default', + name: 'Mixpanel', + ); } return MixpanelFlagVariant( key: key ?? '', @@ -160,7 +165,8 @@ class MixpanelFlagVariant { isExperimentActive: map['isExperimentActive'] as bool?, isQaTester: map['isQaTester'] as bool?, source: MixpanelFlagVariantSource.fromMap( - map['source'] as Map?), + map['source'] as Map?, + ), ); } @@ -245,8 +251,9 @@ abstract class VariantLookupPolicy { /// **Web:** not yet supported by the Mixpanel JS SDK at the time of this /// release. On web this policy is silently treated as [networkOnly] until /// JS SDK support ships. Check the Mixpanel JS docs for availability. - const factory VariantLookupPolicy.persistenceUntilNetworkSuccess( - {Duration persistenceTtl}) = PersistenceUntilNetworkSuccessPolicy; + const factory VariantLookupPolicy.persistenceUntilNetworkSuccess({ + Duration persistenceTtl, + }) = PersistenceUntilNetworkSuccessPolicy; /// Await the network call; fall back to persisted variants (within /// [persistenceTtl]) only on network failure. @@ -275,14 +282,15 @@ class PersistenceUntilNetworkSuccessPolicy extends VariantLookupPolicy { /// Defaults to 24 hours. final Duration persistenceTtl; - const PersistenceUntilNetworkSuccessPolicy( - {this.persistenceTtl = const Duration(hours: 24)}); + const PersistenceUntilNetworkSuccessPolicy({ + this.persistenceTtl = const Duration(hours: 24), + }); @override Map toMap() => { - 'policy': 'persistenceUntilNetworkSuccess', - 'persistenceTtlMillis': persistenceTtl.inMilliseconds, - }; + 'policy': 'persistenceUntilNetworkSuccess', + 'persistenceTtlMillis': persistenceTtl.inMilliseconds, + }; } /// Policy: prefer fresh network values, fall back to persistence only on failure. @@ -295,9 +303,9 @@ class NetworkFirstPolicy extends VariantLookupPolicy { @override Map toMap() => { - 'policy': 'networkFirst', - 'persistenceTtlMillis': persistenceTtl.inMilliseconds, - }; + 'policy': 'networkFirst', + 'persistenceTtlMillis': persistenceTtl.inMilliseconds, + }; } /// Configuration options for feature flags. @@ -338,20 +346,48 @@ class Mixpanel { static final MethodChannel _channel = kIsWeb ? const MethodChannel('mixpanel_flutter') : const MethodChannel( - 'mixpanel_flutter', StandardMethodCodec(MixpanelMessageCodec())); + 'mixpanel_flutter', + StandardMethodCodec(MixpanelMessageCodec()), + ); static final Map _mixpanelProperties = { '\$lib_version': sdkVersion, 'mp_lib': 'flutter', }; + // Eagerly wires the reverse path from the native MixpanelEventBridge into + // the Dart-side [MixpanelEventBridge] the first time `Mixpanel` is touched. + // Web is skipped — the JS SDK has no EventBridge. + // ignore: unused_field + static final bool _eventBridgeWired = _wireEventBridge(); + + static bool _wireEventBridge() { + if (kIsWeb) return false; + _channel.setMethodCallHandler((MethodCall call) async { + if (call.method == 'onMixpanelEvent') { + final args = (call.arguments as Map?)?.cast(); + final eventName = args?['eventName'] as String?; + final properties = (args?['properties'] as Map?) + ?.cast(); + if (eventName != null) { + MixpanelEventBridge.notifyListeners( + eventName: eventName, + properties: properties, + ); + } + } + return null; + }); + return true; + } + final String _token; final People _people; final FeatureFlags _featureFlags; Mixpanel(String token) - : _token = token, - _people = People(token), - _featureFlags = FeatureFlags(token); + : _token = token, + _people = People(token), + _featureFlags = FeatureFlags(token); /// /// Initializes an instance of the API with the given project token. @@ -365,18 +401,26 @@ class Mixpanel { /// * [config] Optional A dictionary of config options to override (WEB ONLY) /// * [featureFlags] Optional Feature flags configuration /// - static Future init(String token, - {bool optOutTrackingDefault = false, - required bool trackAutomaticEvents, - Map? superProperties, - Map? config, - FeatureFlagsConfig? featureFlags}) async { + static Future init( + String token, { + bool optOutTrackingDefault = false, + required bool trackAutomaticEvents, + Map? superProperties, + Map? config, + FeatureFlagsConfig? featureFlags, + }) async { + // Force lazy initialization of the reverse-direction MethodCallHandler + // so any native events tracked after this point reach Dart subscribers. + _eventBridgeWired; var allProperties = {'token': token}; allProperties['optOutTrackingDefault'] = optOutTrackingDefault; allProperties['trackAutomaticEvents'] = trackAutomaticEvents; allProperties['mixpanelProperties'] = _mixpanelProperties; - allProperties['superProperties'] = _MixpanelHelper.ensureSerializableProperties(superProperties); - allProperties['config'] = _MixpanelHelper.ensureSerializableProperties(config); + allProperties['superProperties'] = + _MixpanelHelper.ensureSerializableProperties(superProperties); + allProperties['config'] = _MixpanelHelper.ensureSerializableProperties( + config, + ); if (featureFlags != null) { allProperties['featureFlags'] = featureFlags.toMap(); } @@ -391,11 +435,14 @@ class Mixpanel { /// * [serverURL] the base URL used for Mixpanel API requests void setServerURL(String serverURL) { if (_MixpanelHelper.isValidString(serverURL)) { - _channel.invokeMethod( - 'setServerURL', {'serverURL': serverURL}); + _channel.invokeMethod('setServerURL', { + 'serverURL': serverURL, + }); } else { - developer.log('`setServerURL` failed: serverURL cannot be blank', - name: 'Mixpanel'); + developer.log( + '`setServerURL` failed: serverURL cannot be blank', + name: 'Mixpanel', + ); } } @@ -407,12 +454,14 @@ class Mixpanel { void setLoggingEnabled(bool loggingEnabled) { // ignore: unnecessary_null_comparison if (loggingEnabled != null) { - _channel.invokeMethod('setLoggingEnabled', - {'loggingEnabled': loggingEnabled}); + _channel.invokeMethod('setLoggingEnabled', { + 'loggingEnabled': loggingEnabled, + }); } else { developer.log( - '`setLoggingEnabled` failed: loggingEnabled cannot be blank', - name: 'Mixpanel'); + '`setLoggingEnabled` failed: loggingEnabled cannot be blank', + name: 'Mixpanel', + ); } } @@ -425,13 +474,16 @@ class Mixpanel { // ignore: unnecessary_null_comparison if (useIpAddressForGeolocation != null) { _channel.invokeMethod( - 'setUseIpAddressForGeolocation', { - 'useIpAddressForGeolocation': useIpAddressForGeolocation - }); + 'setUseIpAddressForGeolocation', + { + 'useIpAddressForGeolocation': useIpAddressForGeolocation, + }, + ); } else { developer.log( - '`setUseIpAddressForGeolocation` failed: useIpAddressForGeolocation cannot be blank', - name: 'Mixpanel'); + '`setUseIpAddressForGeolocation` failed: useIpAddressForGeolocation cannot be blank', + name: 'Mixpanel', + ); } } @@ -463,8 +515,9 @@ class Mixpanel { /// and the server. The maximum size is 50; any value over 50 will default to 50. /// * [flushBatchSize] an int representing the number of events sent in a single network request. void setFlushBatchSize(int flushBatchSize) { - _channel.invokeMethod('setFlushBatchSize', - {'flushBatchSize': flushBatchSize}); + _channel.invokeMethod('setFlushBatchSize', { + 'flushBatchSize': flushBatchSize, + }); } /// Associate all future calls to track() with the user identified by @@ -484,11 +537,14 @@ class Mixpanel { /// value is globally unique for each individual user you intend to track. Future identify(String distinctId) async { if (_MixpanelHelper.isValidString(distinctId)) { - await _channel.invokeMethod( - 'identify', {'distinctId': distinctId}); + await _channel.invokeMethod('identify', { + 'distinctId': distinctId, + }); } else { - developer.log('`identify` failed: distinctId cannot be blank', - name: 'Mixpanel'); + developer.log( + '`identify` failed: distinctId cannot be blank', + name: 'Mixpanel', + ); } } @@ -509,12 +565,16 @@ class Mixpanel { return; } if (!_MixpanelHelper.isValidString(distinctId)) { - developer.log('`alias` failed: distinctId cannot be blank', - name: 'Mixpanel'); + developer.log( + '`alias` failed: distinctId cannot be blank', + name: 'Mixpanel', + ); return; } - _channel.invokeMethod( - 'alias', {'alias': alias, 'distinctId': distinctId}); + _channel.invokeMethod('alias', { + 'alias': alias, + 'distinctId': distinctId, + }); } /// Track an event. @@ -531,11 +591,15 @@ class Mixpanel { Map? properties, }) async { if (_MixpanelHelper.isValidString(eventName)) { - await _channel.invokeMethod('track', - {'eventName': eventName, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); + await _channel.invokeMethod('track', { + 'eventName': eventName, + 'properties': _MixpanelHelper.ensureSerializableProperties(properties), + }); } else { - developer.log('`track` failed: eventName cannot be blank', - name: 'Mixpanel'); + developer.log( + '`track` failed: eventName cannot be blank', + name: 'Mixpanel', + ); } } @@ -571,11 +635,13 @@ class Mixpanel { await _channel.invokeMethod('trackWithGroups', { 'eventName': eventName, 'properties': _MixpanelHelper.ensureSerializableProperties(properties), - 'groups': _MixpanelHelper.ensureSerializableProperties(groups) + 'groups': _MixpanelHelper.ensureSerializableProperties(groups), }); } else { - developer.log('`trackWithGroups` failed: eventName cannot be blank', - name: 'Mixpanel'); + developer.log( + '`trackWithGroups` failed: eventName cannot be blank', + name: 'Mixpanel', + ); } } @@ -585,11 +651,15 @@ class Mixpanel { /// * [groupID] The group the user belongs to. void setGroup(String groupKey, dynamic groupID) { if (_MixpanelHelper.isValidString(groupKey)) { - _channel.invokeMethod('setGroup', - {'groupKey': groupKey, 'groupID': _MixpanelHelper.ensureSerializableValue(groupID)}); + _channel.invokeMethod('setGroup', { + 'groupKey': groupKey, + 'groupID': _MixpanelHelper.ensureSerializableValue(groupID), + }); } else { - developer.log('`setGroup` failed: groupKey cannot be blank', - name: 'Mixpanel'); + developer.log( + '`setGroup` failed: groupKey cannot be blank', + name: 'Mixpanel', + ); } } @@ -601,7 +671,11 @@ class Mixpanel { /// return an instance of MixpanelGroup that you can use to update /// records in Mixpanel Group Analytics MixpanelGroup getGroup(String groupKey, dynamic groupID) { - return MixpanelGroup(_token, groupKey, _MixpanelHelper.ensureSerializableValue(groupID)); + return MixpanelGroup( + _token, + groupKey, + _MixpanelHelper.ensureSerializableValue(groupID), + ); } /// Add a group to this user's membership for a particular group key @@ -610,11 +684,15 @@ class Mixpanel { /// * [groupID] The new group the user belongs to. void addGroup(String groupKey, dynamic groupID) { if (_MixpanelHelper.isValidString(groupKey)) { - _channel.invokeMethod('addGroup', - {'groupKey': groupKey, 'groupID': _MixpanelHelper.ensureSerializableValue(groupID)}); + _channel.invokeMethod('addGroup', { + 'groupKey': groupKey, + 'groupID': _MixpanelHelper.ensureSerializableValue(groupID), + }); } else { - developer.log('`addGroup` failed: groupKey cannot be blank', - name: 'Mixpanel'); + developer.log( + '`addGroup` failed: groupKey cannot be blank', + name: 'Mixpanel', + ); } } @@ -624,11 +702,15 @@ class Mixpanel { /// * [groupID] The group value to remove. void removeGroup(String groupKey, dynamic groupID) { if (_MixpanelHelper.isValidString(groupKey)) { - _channel.invokeMethod('removeGroup', - {'groupKey': groupKey, 'groupID': _MixpanelHelper.ensureSerializableValue(groupID)}); + _channel.invokeMethod('removeGroup', { + 'groupKey': groupKey, + 'groupID': _MixpanelHelper.ensureSerializableValue(groupID), + }); } else { - developer.log('`removeGroup` failed: groupKey cannot be blank', - name: 'Mixpanel'); + developer.log( + '`removeGroup` failed: groupKey cannot be blank', + name: 'Mixpanel', + ); } } @@ -641,11 +723,15 @@ class Mixpanel { /// to Group Analytics using the same group value will create and store new values. void deleteGroup(String groupKey, dynamic groupID) { if (_MixpanelHelper.isValidString(groupKey)) { - _channel.invokeMethod('deleteGroup', - {'groupKey': groupKey, 'groupID': _MixpanelHelper.ensureSerializableValue(groupID)}); + _channel.invokeMethod('deleteGroup', { + 'groupKey': groupKey, + 'groupID': _MixpanelHelper.ensureSerializableValue(groupID), + }); } else { - developer.log('`deleteGroup` failed: groupKey cannot be blank', - name: 'Mixpanel'); + developer.log( + '`deleteGroup` failed: groupKey cannot be blank', + name: 'Mixpanel', + ); } } @@ -664,7 +750,11 @@ class Mixpanel { /// * [properties] A Map containing super properties to register Future registerSuperProperties(Map properties) async { await _channel.invokeMethod( - 'registerSuperProperties', {'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); + 'registerSuperProperties', + { + 'properties': _MixpanelHelper.ensureSerializableProperties(properties), + }, + ); } /// Register super properties for events, only if no other super property with the @@ -676,8 +766,12 @@ class Mixpanel { Future registerSuperPropertiesOnce( Map properties, ) async { - await _channel.invokeMethod('registerSuperPropertiesOnce', - {'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); + await _channel.invokeMethod( + 'registerSuperPropertiesOnce', + { + 'properties': _MixpanelHelper.ensureSerializableProperties(properties), + }, + ); } /// Remove a single superProperty, so that it will not be sent with future calls to track(). @@ -689,12 +783,15 @@ class Mixpanel { /// * [propertyName] name of the property to unregister Future unregisterSuperProperty(String propertyName) async { if (_MixpanelHelper.isValidString(propertyName)) { - await _channel.invokeMethod('unregisterSuperProperty', - {'propertyName': propertyName}); + await _channel.invokeMethod( + 'unregisterSuperProperty', + {'propertyName': propertyName}, + ); } else { developer.log( - '`unregisterSuperProperty` failed: propertyName cannot be blank', - name: 'Mixpanel'); + '`unregisterSuperProperty` failed: propertyName cannot be blank', + name: 'Mixpanel', + ); } } @@ -725,11 +822,14 @@ class Mixpanel { /// * [eventName] the name of the event to track with timing. void timeEvent(String eventName) { if (_MixpanelHelper.isValidString(eventName)) { - _channel.invokeMethod( - 'timeEvent', {'eventName': eventName}); + _channel.invokeMethod('timeEvent', { + 'eventName': eventName, + }); } else { - developer.log('`timeEvent` failed: eventName cannot be blank', - name: 'Mixpanel'); + developer.log( + '`timeEvent` failed: eventName cannot be blank', + name: 'Mixpanel', + ); } } @@ -741,7 +841,9 @@ class Mixpanel { Future eventElapsedTime(String eventName) async { if (_MixpanelHelper.isValidString(eventName)) { return await _channel.invokeMethod( - 'eventElapsedTime', {'eventName': eventName}); + 'eventElapsedTime', + {'eventName': eventName}, + ); } else { return 0; } @@ -795,7 +897,9 @@ class People { static final MethodChannel _channel = kIsWeb ? const MethodChannel('mixpanel_flutter') : const MethodChannel( - 'mixpanel_flutter', StandardMethodCodec(MixpanelMessageCodec())); + 'mixpanel_flutter', + StandardMethodCodec(MixpanelMessageCodec()), + ); final String _token; @@ -811,11 +915,15 @@ class People { void set(String prop, dynamic to) { if (_MixpanelHelper.isValidString(prop)) { Map properties = {prop: to}; - _channel.invokeMethod('set', - {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); + _channel.invokeMethod('set', { + 'token': _token, + 'properties': _MixpanelHelper.ensureSerializableProperties(properties), + }); } else { - developer.log('`people set` failed: prop cannot be blank', - name: 'Mixpanel'); + developer.log( + '`people set` failed: prop cannot be blank', + name: 'Mixpanel', + ); } } @@ -826,11 +934,15 @@ class People { void setOnce(String prop, dynamic to) { if (_MixpanelHelper.isValidString(prop)) { Map properties = {prop: to}; - _channel.invokeMethod('setOnce', - {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); + _channel.invokeMethod('setOnce', { + 'token': _token, + 'properties': _MixpanelHelper.ensureSerializableProperties(properties), + }); } else { - developer.log('`people setOnce` failed: prop cannot be blank', - name: 'Mixpanel'); + developer.log( + '`people setOnce` failed: prop cannot be blank', + name: 'Mixpanel', + ); } } @@ -843,11 +955,15 @@ class People { void increment(String prop, double by) { Map properties = {prop: by}; if (_MixpanelHelper.isValidString(prop)) { - _channel.invokeMethod('increment', - {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); + _channel.invokeMethod('increment', { + 'token': _token, + 'properties': _MixpanelHelper.ensureSerializableProperties(properties), + }); } else { - developer.log('`people increment` failed: prop cannot be blank', - name: 'Mixpanel'); + developer.log( + '`people increment` failed: prop cannot be blank', + name: 'Mixpanel', + ); } } @@ -860,18 +976,24 @@ class People { if (_MixpanelHelper.isValidString(name)) { if (kIsWeb || Platform.isIOS || Platform.isMacOS) { Map properties = {name: value}; - _channel.invokeMethod('append', - {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); + _channel.invokeMethod('append', { + 'token': _token, + 'properties': _MixpanelHelper.ensureSerializableProperties( + properties, + ), + }); } else { _channel.invokeMethod('append', { 'token': _token, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value) + 'value': _MixpanelHelper.ensureSerializableValue(value), }); } } else { - developer.log('`people append` failed: name cannot be blank', - name: 'Mixpanel'); + developer.log( + '`people append` failed: name cannot be blank', + name: 'Mixpanel', + ); } } @@ -885,18 +1007,24 @@ class People { if (_MixpanelHelper.isValidString(name)) { if (kIsWeb || Platform.isIOS || Platform.isMacOS) { Map properties = {name: value}; - _channel.invokeMethod('union', - {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); + _channel.invokeMethod('union', { + 'token': _token, + 'properties': _MixpanelHelper.ensureSerializableProperties( + properties, + ), + }); } else { _channel.invokeMethod('union', { 'token': _token, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value) + 'value': _MixpanelHelper.ensureSerializableValue(value), }); } } else { - developer.log('`people union` failed: name cannot be blank', - name: 'Mixpanel'); + developer.log( + '`people union` failed: name cannot be blank', + name: 'Mixpanel', + ); } } @@ -910,18 +1038,24 @@ class People { if (_MixpanelHelper.isValidString(name)) { if (kIsWeb || Platform.isIOS || Platform.isMacOS) { Map properties = {name: value}; - _channel.invokeMethod('remove', - {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); + _channel.invokeMethod('remove', { + 'token': _token, + 'properties': _MixpanelHelper.ensureSerializableProperties( + properties, + ), + }); } else { _channel.invokeMethod('remove', { 'token': _token, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value) + 'value': _MixpanelHelper.ensureSerializableValue(value), }); } } else { - developer.log('`people remove` failed: name cannot be blank', - name: 'Mixpanel'); + developer.log( + '`people remove` failed: name cannot be blank', + name: 'Mixpanel', + ); } } @@ -930,11 +1064,15 @@ class People { /// * [name] name of a property to unset void unset(String name) { if (_MixpanelHelper.isValidString(name)) { - _channel.invokeMethod( - 'unset', {'token': _token, 'name': name}); + _channel.invokeMethod('unset', { + 'token': _token, + 'name': name, + }); } else { - developer.log('`people unset` failed: name cannot be blank', - name: 'Mixpanel'); + developer.log( + '`people unset` failed: name cannot be blank', + name: 'Mixpanel', + ); } } @@ -948,18 +1086,21 @@ class People { _channel.invokeMethod('trackCharge', { 'token': _token, 'amount': amount, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties) + 'properties': _MixpanelHelper.ensureSerializableProperties(properties), }); } else { - developer.log('`people trackCharge` failed: amount cannot be blank', - name: 'Mixpanel'); + developer.log( + '`people trackCharge` failed: amount cannot be blank', + name: 'Mixpanel', + ); } } /// Permanently clear the whole transaction history for the identified people profile. void clearCharges() { - _channel.invokeMethod( - 'clearCharges', {'token': _token}); + _channel.invokeMethod('clearCharges', { + 'token': _token, + }); } /// Permanently deletes the identified user's record from People Analytics. @@ -967,8 +1108,9 @@ class People { /// Calling deleteUser deletes an entire record completely. Any future calls /// to People Analytics using the same distinct id will create and store new values. void deleteUser() { - _channel.invokeMethod( - 'deleteUser', {'token': _token}); + _channel.invokeMethod('deleteUser', { + 'token': _token, + }); } } @@ -980,16 +1122,18 @@ class MixpanelGroup { static final MethodChannel _channel = kIsWeb ? const MethodChannel('mixpanel_flutter') : const MethodChannel( - 'mixpanel_flutter', StandardMethodCodec(MixpanelMessageCodec())); + 'mixpanel_flutter', + StandardMethodCodec(MixpanelMessageCodec()), + ); final String _token; final String _groupKey; final dynamic _groupID; MixpanelGroup(String token, String groupKey, dynamic groupID) - : _token = token, - _groupKey = groupKey, - _groupID = groupID; + : _token = token, + _groupKey = groupKey, + _groupID = groupID; /// Sets a single property with the given name and value for this group. /// The given name and value will be assigned to the user in Mixpanel Group Analytics, @@ -1005,11 +1149,13 @@ class MixpanelGroup { 'token': _token, 'groupKey': _groupKey, 'groupID': _groupID, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties) + 'properties': _MixpanelHelper.ensureSerializableProperties(properties), }); } else { - developer.log('`group set` failed: prop cannot be blank', - name: 'Mixpanel'); + developer.log( + '`group set` failed: prop cannot be blank', + name: 'Mixpanel', + ); } } @@ -1025,11 +1171,13 @@ class MixpanelGroup { 'token': _token, 'groupKey': _groupKey, 'groupID': _groupID, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties) + 'properties': _MixpanelHelper.ensureSerializableProperties(properties), }); } else { - developer.log('`group setOnce` failed: prop cannot be blank', - name: 'Mixpanel'); + developer.log( + '`group setOnce` failed: prop cannot be blank', + name: 'Mixpanel', + ); } } @@ -1042,11 +1190,13 @@ class MixpanelGroup { 'token': _token, 'groupKey': _groupKey, 'groupID': _groupID, - 'propertyName': prop + 'propertyName': prop, }); } else { - developer.log('`group unset` failed: prop cannot be blank', - name: 'Mixpanel'); + developer.log( + '`group unset` failed: prop cannot be blank', + name: 'Mixpanel', + ); } } @@ -1063,11 +1213,13 @@ class MixpanelGroup { 'groupKey': _groupKey, 'groupID': _groupID, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value) + 'value': _MixpanelHelper.ensureSerializableValue(value), }); } else { - developer.log('`group remove` failed: name cannot be blank', - name: 'Mixpanel'); + developer.log( + '`group remove` failed: name cannot be blank', + name: 'Mixpanel', + ); } } @@ -1079,14 +1231,18 @@ class MixpanelGroup { /// * [value] an array of values to add to the property value if not already present void union(String name, List value) { if (!_MixpanelHelper.isValidString(name)) { - developer.log('`group union` failed: name cannot be blank', - name: 'Mixpanel'); + developer.log( + '`group union` failed: name cannot be blank', + name: 'Mixpanel', + ); return; } // ignore: unnecessary_null_comparison if (value == null) { - developer.log('`group union` failed: value cannot be blank', - name: 'Mixpanel'); + developer.log( + '`group union` failed: value cannot be blank', + name: 'Mixpanel', + ); return; } _channel.invokeMethod('groupUnionProperty', { @@ -1094,7 +1250,7 @@ class MixpanelGroup { 'groupKey': _groupKey, 'groupID': _groupID, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value) + 'value': _MixpanelHelper.ensureSerializableValue(value), }); } } @@ -1108,7 +1264,9 @@ class FeatureFlags { static final MethodChannel _channel = kIsWeb ? const MethodChannel('mixpanel_flutter') : const MethodChannel( - 'mixpanel_flutter', StandardMethodCodec(MixpanelMessageCodec())); + 'mixpanel_flutter', + StandardMethodCodec(MixpanelMessageCodec()), + ); final String _token; @@ -1119,7 +1277,9 @@ class FeatureFlags { /// Returns true if flags are loaded and ready, false otherwise. Future areFlagsReady() async { final result = await _channel.invokeMethod( - 'areFlagsReady', {'token': _token}); + 'areFlagsReady', + {'token': _token}, + ); return result ?? false; } @@ -1130,17 +1290,24 @@ class FeatureFlags { /// /// Returns the MixpanelFlagVariant for the flag, or the fallback if not available. Future getVariant( - String flagName, MixpanelFlagVariant fallback) async { + String flagName, + MixpanelFlagVariant fallback, + ) async { if (!_MixpanelHelper.isValidString(flagName)) { - developer.log('`getVariant` failed: flagName cannot be blank', - name: 'Mixpanel'); + developer.log( + '`getVariant` failed: flagName cannot be blank', + name: 'Mixpanel', + ); return fallback; } - final result = await _channel.invokeMethod('getVariant', { - 'token': _token, - 'flagName': flagName, - 'fallback': fallback.toMap(), - }); + final result = await _channel.invokeMethod( + 'getVariant', + { + 'token': _token, + 'flagName': flagName, + 'fallback': fallback.toMap(), + }, + ); if (result != null) { return MixpanelFlagVariant.fromMap(result); } @@ -1153,17 +1320,25 @@ class FeatureFlags { /// * [fallbackValue] A fallback value to use if the flag is not found or not ready /// /// Returns the value of the flag, or the fallback value if not available. - Future getVariantValue(String flagName, dynamic fallbackValue) async { + Future getVariantValue( + String flagName, + dynamic fallbackValue, + ) async { if (!_MixpanelHelper.isValidString(flagName)) { - developer.log('`getVariantValue` failed: flagName cannot be blank', - name: 'Mixpanel'); + developer.log( + '`getVariantValue` failed: flagName cannot be blank', + name: 'Mixpanel', + ); return fallbackValue; } - final result = await _channel.invokeMethod('getVariantValue', { - 'token': _token, - 'flagName': flagName, - 'fallbackValue': _MixpanelHelper.ensureSerializableValue(fallbackValue), - }); + final result = await _channel.invokeMethod( + 'getVariantValue', + { + 'token': _token, + 'flagName': flagName, + 'fallbackValue': _MixpanelHelper.ensureSerializableValue(fallbackValue), + }, + ); return result ?? fallbackValue; } @@ -1179,15 +1354,20 @@ class FeatureFlags { /// Returns true if the flag is enabled, the fallback value otherwise. Future isEnabled(String flagName, bool fallbackValue) async { if (!_MixpanelHelper.isValidString(flagName)) { - developer.log('`isEnabled` failed: flagName cannot be blank', - name: 'Mixpanel'); + developer.log( + '`isEnabled` failed: flagName cannot be blank', + name: 'Mixpanel', + ); return fallbackValue; } - final result = await _channel.invokeMethod('isEnabled', { - 'token': _token, - 'flagName': flagName, - 'fallbackValue': fallbackValue, - }); + final result = await _channel.invokeMethod( + 'isEnabled', + { + 'token': _token, + 'flagName': flagName, + 'fallbackValue': fallbackValue, + }, + ); return result ?? fallbackValue; } @@ -1201,8 +1381,10 @@ class FeatureFlags { /// After setting the new context, the SDK automatically re-fetches flags /// from Mixpanel servers. The returned [Future] completes when the /// re-fetch is done. - Future updateContext(Map context, - {Map? options}) async { + Future updateContext( + Map context, { + Map? options, + }) async { await _channel.invokeMethod('updateFlagsContext', { 'token': _token, 'context': _MixpanelHelper.ensureSerializableProperties(context), @@ -1220,8 +1402,9 @@ class FeatureFlags { /// that fail silently, `loadFlags` propagates errors so developers can /// implement kill-switch scenarios and respond to flag loading failures. Future loadFlags() async { - await _channel.invokeMethod( - 'loadFlags', {'token': _token}); + await _channel.invokeMethod('loadFlags', { + 'token': _token, + }); } /// Asynchronously retrieves all loaded feature flag variants. @@ -1234,7 +1417,9 @@ class FeatureFlags { /// `MIXPANEL_UNINITIALIZED` if called before [Mixpanel.init]. Future> getAllVariants() async { final result = await _channel.invokeMethod( - 'getAllVariants', {'token': _token}); + 'getAllVariants', + {'token': _token}, + ); final variants = {}; if (result == null) return variants; result.forEach((key, value) { @@ -1273,7 +1458,9 @@ class _MixpanelHelper { } /// Converts properties map for web platform - static Map? ensureSerializableProperties(Map? properties) { + static Map? ensureSerializableProperties( + Map? properties, + ) { if (!kIsWeb || properties == null) { return properties; } diff --git a/packages/mixpanel_flutter/macos/mixpanel_flutter.podspec b/packages/mixpanel_flutter/macos/mixpanel_flutter.podspec index d8b909d3..56425e24 100644 --- a/packages/mixpanel_flutter/macos/mixpanel_flutter.podspec +++ b/packages/mixpanel_flutter/macos/mixpanel_flutter.podspec @@ -16,6 +16,9 @@ Pod::Spec.new do |s| s.source_files = 'Classes/**/*' s.dependency 'FlutterMacOS' s.dependency 'Mixpanel-swift', '6.4.0' + # Explicit dependency (also pulled in transitively by Mixpanel-swift 6.4+) + # so `import MixpanelSwiftCommon` in our plugin resolves reliably. + s.dependency 'MixpanelSwiftCommon', '~> 1.0.0' s.platform = :osx, '10.15' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } diff --git a/packages/mixpanel_flutter/pubspec.lock b/packages/mixpanel_flutter/pubspec.lock index 12918ae9..afc9a3b5 100644 --- a/packages/mixpanel_flutter/pubspec.lock +++ b/packages/mixpanel_flutter/pubspec.lock @@ -128,6 +128,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mixpanel_flutter_common: + dependency: "direct main" + description: + path: "../mixpanel_flutter_common" + relative: true + source: path + version: "0.1.0" path: dependency: transitive description: diff --git a/packages/mixpanel_flutter/pubspec.yaml b/packages/mixpanel_flutter/pubspec.yaml index 009f461b..0a6ee4f1 100644 --- a/packages/mixpanel_flutter/pubspec.yaml +++ b/packages/mixpanel_flutter/pubspec.yaml @@ -15,6 +15,8 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter + mixpanel_flutter_common: + path: ../mixpanel_flutter_common dev_dependencies: flutter_test: diff --git a/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift b/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift index 76859c3c..f2fed56c 100644 --- a/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift +++ b/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift @@ -4,18 +4,24 @@ import Flutter import FlutterMacOS #endif import Mixpanel +import MixpanelSwiftCommon #if os(macOS) public typealias MixpanelFlutterPlugin = SwiftMixpanelFlutterPlugin #endif public class SwiftMixpanelFlutterPlugin: NSObject, FlutterPlugin { - + private var instance: MixpanelInstance? var token: String? var mixpanelProperties: [String: String]? let defaultFlushInterval = 60.0 - + + // Holds the long-lived AsyncStream consumer that forwards native + // MixpanelEventBridge events to Dart. Cancelled on deinit so hot-restart + // and engine teardown don't leak the task. + private var eventBridgeTask: Task? + public static func register(with registrar: FlutterPluginRegistrar) { let readWriter = MixpanelReaderWriter() let codec = FlutterStandardMethodCodec(readerWriter: readWriter) @@ -26,6 +32,26 @@ public class SwiftMixpanelFlutterPlugin: NSObject, FlutterPlugin { #endif let instance = SwiftMixpanelFlutterPlugin() registrar.addMethodCallDelegate(instance, channel: channel) + + // Subscribe to the native EventBridge and forward each event back to + // Dart. The channel is captured by the closure, so we only need to + // hold the Task itself on the instance for cancellation. + if #available(iOS 13.0, macOS 10.15, *) { + instance.eventBridgeTask = Task { + for await event in MixpanelEventBridge.shared.eventStream() { + await MainActor.run { + channel.invokeMethod("onMixpanelEvent", arguments: [ + "eventName": event.eventName, + "properties": event.properties, + ]) + } + } + } + } + } + + deinit { + eventBridgeTask?.cancel() } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { diff --git a/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart b/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart new file mode 100644 index 00000000..1e667f0b --- /dev/null +++ b/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixpanel_flutter/codec/mixpanel_message_codec.dart'; +import 'package:mixpanel_flutter/mixpanel_flutter.dart'; +import 'package:mixpanel_flutter_common/mixpanel_flutter_common.dart'; + +/// Verifies the reverse path: when the native plugin invokes +/// `onMixpanelEvent` on the channel, the event surfaces on the Dart-side +/// [MixpanelEventBridge.events] stream. +void main() { + const channel = MethodChannel( + 'mixpanel_flutter', + StandardMethodCodec(MixpanelMessageCodec()), + ); + const codec = StandardMethodCodec(MixpanelMessageCodec()); + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + // Force the static initializer in Mixpanel that registers the + // setMethodCallHandler for 'onMixpanelEvent'. Init touches that path. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + await Mixpanel.init( + 'test token', + optOutTrackingDefault: false, + trackAutomaticEvents: true, + ); + // Now release the mock so the real reverse-direction handler installed + // by Mixpanel.init() can receive simulated native calls. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future simulateNativeEvent({ + required String eventName, + Map? properties, + }) async { + final message = codec.encodeMethodCall( + MethodCall('onMixpanelEvent', { + 'eventName': eventName, + 'properties': properties, + }), + ); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage('mixpanel_flutter', message, (_) {}); + } + + test( + 'native onMixpanelEvent surfaces on MixpanelEventBridge.events', + () async { + final received = []; + final sub = MixpanelEventBridge.events.listen(received.add); + + await simulateNativeEvent( + eventName: 'Button Tapped', + properties: {'\$city': 'Brooklyn', 'count': 7}, + ); + await Future.delayed(Duration.zero); + + expect(received, hasLength(1)); + expect(received.first.eventName, 'Button Tapped'); + expect(received.first.properties, {'\$city': 'Brooklyn', 'count': 7}); + + await sub.cancel(); + }, + ); + + test('null properties from native pass through as null', () async { + final received = []; + final sub = MixpanelEventBridge.events.listen(received.add); + + await simulateNativeEvent(eventName: 'no-props'); + await Future.delayed(Duration.zero); + + expect(received.single.eventName, 'no-props'); + expect(received.single.properties, isNull); + + await sub.cancel(); + }); + + test('malformed payload (missing eventName) is ignored, no throw', () async { + final received = []; + final sub = MixpanelEventBridge.events.listen(received.add); + + final bogus = codec.encodeMethodCall( + const MethodCall('onMixpanelEvent', {'properties': {}}), + ); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage('mixpanel_flutter', bogus, (_) {}); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + await sub.cancel(); + }); + + test('unknown method names are ignored', () async { + final received = []; + final sub = MixpanelEventBridge.events.listen(received.add); + + final bogus = codec.encodeMethodCall(const MethodCall('somethingElse')); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage('mixpanel_flutter', bogus, (_) {}); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + await sub.cancel(); + }); +} diff --git a/packages/mixpanel_flutter_common/analysis_options.yaml b/packages/mixpanel_flutter_common/analysis_options.yaml new file mode 100644 index 00000000..08787b5d --- /dev/null +++ b/packages/mixpanel_flutter_common/analysis_options.yaml @@ -0,0 +1,6 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-raw-types: true diff --git a/packages/mixpanel_flutter_common/lib/mixpanel_flutter_common.dart b/packages/mixpanel_flutter_common/lib/mixpanel_flutter_common.dart new file mode 100644 index 00000000..7689c645 --- /dev/null +++ b/packages/mixpanel_flutter_common/lib/mixpanel_flutter_common.dart @@ -0,0 +1,16 @@ +/// Shared pure-Dart support for the Mixpanel Flutter SDK family. +/// +/// Two pieces: +/// 1. [MixpanelEventBridge] — process-wide stream of tracked events, +/// populated by `mixpanel_flutter`'s native plugins. Consume this in +/// session replay, custom trigger logic, etc. +/// 2. JSONLogic — parser and evaluator for the Event Trigger rule subset +/// aligned across mixpanel-android, mixpanel-swift, and this package. +library mixpanel_flutter_common; + +export 'src/event_bridge.dart'; +export 'src/mixpanel_event.dart'; +export 'src/jsonlogic/json_logic_evaluator.dart'; +export 'src/jsonlogic/json_logic_exception.dart'; +export 'src/jsonlogic/json_logic_parser.dart'; +export 'src/jsonlogic/json_logic_rule.dart'; diff --git a/packages/mixpanel_flutter_common/lib/src/event_bridge.dart b/packages/mixpanel_flutter_common/lib/src/event_bridge.dart new file mode 100644 index 00000000..ce203f07 --- /dev/null +++ b/packages/mixpanel_flutter_common/lib/src/event_bridge.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'mixpanel_event.dart'; + +/// Process-wide bridge for tracked Mixpanel events. +/// +/// Direct Dart analog of the native dispatchers: +/// - Android `MixpanelEventBridge` (Kotlin SharedFlow) +/// - Swift `MixpanelEventBridge.shared.eventStream()` (AsyncStream) +/// +/// `mixpanel_flutter`'s native plugins subscribe to their platform's native +/// bridge and forward each event into [notifyListeners]. Any number of +/// Dart consumers (session replay, custom triggers) subscribe to [events]. +/// +/// ## Late subscribers +/// The stream does not buffer or replay. Events emitted before a listener +/// attaches are dropped. This matches the native `replay = 0` semantics. +/// +/// ## Handler expectations +/// Keep listeners fast and non-blocking — there is no backpressure buffer +/// for slow consumers. A long-running handler will queue microtasks +/// unboundedly. If you need network I/O on each event, buffer the event +/// locally and process asynchronously without awaiting in the listener. +class MixpanelEventBridge { + MixpanelEventBridge._(); + + static final StreamController _controller = + StreamController.broadcast(); + + /// Subscribe to all events tracked by Mixpanel. + /// + /// Returns a broadcast [Stream]; multiple listeners are supported. Each + /// listener sees every event from the moment it subscribes. + static Stream get events => _controller.stream; + + /// Internal entry point — invoked by `mixpanel_flutter`'s plugin after + /// the native SDK has tracked and decorated an event. + /// + /// Application code should never call this directly. It is left public + /// (rather than library-private) so the `mixpanel_flutter` package can + /// reach it without circular imports. + static void notifyListeners({ + required String eventName, + Map? properties, + }) { + _controller.add( + MixpanelEvent(eventName: eventName, properties: properties), + ); + } +} diff --git a/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart new file mode 100644 index 00000000..53ef7b54 --- /dev/null +++ b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart @@ -0,0 +1,240 @@ +import 'json_logic_exception.dart'; +import 'json_logic_rule.dart'; + +/// Evaluates typed [JsonLogicRule] trees against JSON data. +/// +/// The evaluator walks the typed rule tree without string-matching on +/// operator names. +/// +/// Supported operators: `===`, `!==`, `<`, `<=`, `>`, `>=`, `and`, `or`, `in`, +/// `var` +/// +/// ## Operator Assumptions +/// +/// ### Strict Equality (`===`, `!==`) +/// - `null` can only equal `null`; comparing `null` with non-null throws +/// [TypeMismatchException] +/// - Array comparison is not supported; throws [TypeMismatchException] +/// - Numbers are compared by value regardless of int/double subtype +/// (`1 === 1.0` is `true`) +/// - Non-null, non-number operands must be the same type; otherwise throws +/// [TypeMismatchException] +/// +/// ### Numeric Comparison (`>`, `<`, `>=`, `<=`) +/// - Both operands must be numbers; non-numeric operands throw +/// [TypeMismatchException] +/// - `NaN` values are not supported; throws [TypeMismatchException] +/// +/// ### Logic (`and`, `or`) +/// - Requires at least 1 operand; empty operands throw +/// [InvalidExpressionException] +/// - All operands must evaluate to `bool`; non-boolean results throw +/// [TypeMismatchException] +/// - All operands are evaluated (no short-circuit) to ensure type safety +/// +/// ### Membership/Substring (`in`) +/// - Needle must be a `String`; non-string needles throw +/// [TypeMismatchException] +/// - Haystack must be a `String` or array; other types throw +/// [TypeMismatchException] +/// - For string haystack: performs substring check +/// - For array haystack: checks membership using strict equality (all elements +/// validated) +/// +/// ### Data Access (`var`) +/// - Property name is required; empty path throws [InvalidExpressionException] +/// - Dots in property names are not allowed; throws +/// [InvalidExpressionException] +/// - Returns `null` if the property does not exist +class JsonLogicEvaluator { + const JsonLogicEvaluator._(); + + /// Evaluates a JsonLogic rule against event properties. + /// + /// The return type is [Object?] because JsonLogic is dynamically typed and + /// different operations return different types. + static Object? evaluate(JsonLogicRule rule, Map data) { + if (rule is LiteralRule) return rule.value; + if (rule is ArrayRule) { + return rule.elements.map((e) => evaluate(e, data)).toList(); + } + // Comparison + if (rule is StrictEqualsRule) { + return _strictEquals(evaluate(rule.left, data), evaluate(rule.right, data)); + } + if (rule is StrictNotEqualsRule) { + return !_strictEquals(evaluate(rule.left, data), evaluate(rule.right, data)); + } + if (rule is GreaterThanRule) { + return _compareValues(evaluate(rule.left, data), evaluate(rule.right, data)) > 0; + } + if (rule is GreaterThanOrEqualRule) { + return _compareValues(evaluate(rule.left, data), evaluate(rule.right, data)) >= 0; + } + if (rule is LessThanRule) { + return _compareValues(evaluate(rule.left, data), evaluate(rule.right, data)) < 0; + } + if (rule is LessThanOrEqualRule) { + return _compareValues(evaluate(rule.left, data), evaluate(rule.right, data)) <= 0; + } + // Logic + if (rule is AndRule) return _evaluateAnd(rule.operands, data); + if (rule is OrRule) return _evaluateOr(rule.operands, data); + // String/Array + if (rule is InRule) return _evaluateIn(rule, data); + // Data access + if (rule is VarRule) return _evaluateVar(rule, data); + throw StateError('Unhandled JsonLogicRule subtype: ${rule.runtimeType}'); + } + + // =========================================================================== + // Comparison helpers + // =========================================================================== + + /// Strict equality (===) - operands must be the same type. + /// + /// Throws [TypeMismatchException] if types don't match. + static bool _strictEquals(Object? a, Object? b) { + if (a == null && b == null) return true; + if (a == null || b == null) { + throw TypeMismatchException('===', 'operands must be the same type'); + } + + if (a is List || b is List) { + throw TypeMismatchException('===', 'does not support array comparison'); + } + + // bool must be checked before num: Dart does not bridge bool↔num, but we + // still gate on type before falling through to numeric comparison. + if (a is bool || b is bool) { + if (a is! bool || b is! bool) { + throw TypeMismatchException('===', 'operands must be the same type'); + } + return a == b; + } + + if (a is num && b is num) { + return a.toDouble() == b.toDouble(); + } + + if (a.runtimeType != b.runtimeType) { + throw TypeMismatchException('===', 'operands must be the same type'); + } + return a == b; + } + + /// Compares two values numerically for relational operators. + /// + /// Only numbers are supported. Returns negative if ab. + static int _compareValues(Object? a, Object? b) { + if (a is bool || b is bool || a is! num || b is! num) { + throw TypeMismatchException('>, <, >=, <=', 'only support numbers'); + } + final numA = a.toDouble(); + final numB = b.toDouble(); + if (numA.isNaN || numB.isNaN) { + throw TypeMismatchException('>, <, >=, <=', 'do not support NaN'); + } + return numA.compareTo(numB); + } + + // =========================================================================== + // Logic helpers + // =========================================================================== + + static bool _evaluateAnd( + List operands, + Map data, + ) { + if (operands.isEmpty) { + throw InvalidExpressionException('and', 'requires at least 1 argument'); + } + // Evaluate ALL operands (no short-circuit) so a type error in a later + // operand still surfaces. Track the answer in a single bool rather than + // materializing a results list — keeps memory O(1) for huge operand + // lists. + var allTrue = true; + for (final operand in operands) { + final result = evaluate(operand, data); + if (result is! bool) { + throw TypeMismatchException( + 'and', + 'operands must be boolean expressions', + ); + } + if (!result) allTrue = false; + } + return allTrue; + } + + static bool _evaluateOr( + List operands, + Map data, + ) { + if (operands.isEmpty) { + throw InvalidExpressionException('or', 'requires at least 1 argument'); + } + var anyTrue = false; + for (final operand in operands) { + final result = evaluate(operand, data); + if (result is! bool) { + throw TypeMismatchException( + 'or', + 'operands must be boolean expressions', + ); + } + if (result) anyTrue = true; + } + return anyTrue; + } + + // =========================================================================== + // String/Array helpers + // =========================================================================== + + static bool _evaluateIn(InRule rule, Map data) { + final needle = evaluate(rule.needle, data); + if (needle is! String) { + throw TypeMismatchException('in', 'requires a string needle'); + } + final haystack = evaluate(rule.haystack, data); + if (haystack is String) { + return haystack.contains(needle); + } + if (haystack is List) { + // All elements must be strings (validated via _strictEquals). We check + // ALL elements even after finding a match to ensure type safety. + // Track the answer in a single bool to keep memory O(1) for huge + // haystacks. + var found = false; + for (final element in haystack) { + if (_strictEquals(needle, element)) found = true; + } + return found; + } + throw TypeMismatchException('in', 'requires a string or array haystack'); + } + + // =========================================================================== + // Data access helpers + // =========================================================================== + + static Object? _evaluateVar(VarRule rule, Map data) { + final pathValue = evaluate(rule.path, data); + final path = pathValue == null ? '' : pathValue.toString(); + + if (path.isEmpty) { + throw InvalidExpressionException('var', 'property name is required'); + } + + if (path.contains('.')) { + throw InvalidExpressionException( + 'var', + "dots in property names are not supported - '$path'", + ); + } + + return data.containsKey(path) ? data[path] : null; + } +} diff --git a/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_exception.dart b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_exception.dart new file mode 100644 index 00000000..a972464c --- /dev/null +++ b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_exception.dart @@ -0,0 +1,36 @@ +/// Base exception for JsonLogic errors. +abstract class JsonLogicException implements Exception { + JsonLogicException(this.message); + + final String message; + + @override + String toString() => '$runtimeType: $message'; +} + +/// Thrown when an unsupported operator is encountered during parsing. +class UnsupportedOperatorException extends JsonLogicException { + UnsupportedOperatorException(String operator) + : super( + "Unsupported operator: '$operator'. " + 'Try updating to a newer SDK version for possible operator support.', + ); +} + +/// Thrown when a type mismatch occurs during evaluation. +class TypeMismatchException extends JsonLogicException { + TypeMismatchException(String expression, String reason) + : super( + "Type mismatch in '$expression': $reason. " + 'Try updating to a newer SDK version for possible type support.', + ); +} + +/// Thrown when an expression is structurally invalid. +class InvalidExpressionException extends JsonLogicException { + InvalidExpressionException(String expression, String reason) + : super( + "Invalid expression '$expression': $reason. " + 'Try updating to a newer SDK version for possible expression support.', + ); +} diff --git a/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_parser.dart b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_parser.dart new file mode 100644 index 00000000..2f690fa6 --- /dev/null +++ b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_parser.dart @@ -0,0 +1,196 @@ +import 'dart:convert'; + +import 'json_logic_exception.dart'; +import 'json_logic_rule.dart'; + +/// Parser that converts raw JSON into a typed [JsonLogicRule] tree. +/// +/// Supported operators (per Event Trigger alignment decision): +/// - Comparison: ===, !==, <, <=, >, >= +/// - Logic: and, or +/// - String/Array: in +/// - Data Access: var +/// +/// Example: +/// ```dart +/// final rule = JsonLogicParser.parse('{"===":[1,1]}'); +/// ``` +class JsonLogicParser { + const JsonLogicParser._(); + + /// Maximum nesting depth allowed in a rule tree. + /// + /// Bounded to prevent a malicious server-supplied rule from causing a + /// stack overflow in either the parser or the evaluator (which recurses + /// through the parsed tree). 100 is well above any realistic rule — + /// real-world Event Trigger rules are typically 3–5 levels deep. + static const int maxDepth = 100; + + /// Maximum length of attacker-controlled input echoed back in error + /// messages. Prevents megabyte-sized rules from producing megabyte-sized + /// log lines. + static const int _maxErrorEchoLength = 200; + + /// Parses a JSON string into a typed [JsonLogicRule]. + /// + /// Throws [JsonLogicException] if the JSON is malformed, contains + /// unsupported operations, or exceeds [maxDepth]. + static JsonLogicRule parse(String json) { + final trimmed = json.trim(); + if (!trimmed.startsWith('{')) { + throw InvalidExpressionException( + 'parse', + "input must be a JSON object: '${_truncate(trimmed)}'", + ); + } + Object? decoded; + try { + decoded = jsonDecode(trimmed); + } catch (_) { + throw InvalidExpressionException( + 'parse', + "malformed JSON object: '${_truncate(trimmed)}'", + ); + } + if (decoded is! Map) { + throw InvalidExpressionException( + 'parse', + "input must be a JSON object: '${_truncate(trimmed)}'", + ); + } + return parseValue(decoded); + } + + /// Parses any decoded JSON value into a [JsonLogicRule]. + /// + /// Internal use only - use [parse] for parsing JSON strings. + static JsonLogicRule parseValue(Object? value, [int depth = 0]) { + if (depth > maxDepth) { + throw InvalidExpressionException( + 'parse', + 'rule nesting depth exceeds maximum of $maxDepth', + ); + } + if (value == null) return const LiteralRule(null); + if (value is bool) return LiteralRule(value); + if (value is num) return LiteralRule(value); + if (value is String) return LiteralRule(value); + if (value is List) return _parseArray(value, depth); + if (value is Map) return _parseObject(value, depth); + throw TypeMismatchException( + 'value', + 'unsupported type: ${value.runtimeType}', + ); + } + + static JsonLogicRule _parseArray(List array, int depth) { + final elements = array + .map((e) => parseValue(e, depth + 1)) + .toList(growable: false); + final hasRules = elements.any((e) => e is! LiteralRule); + if (hasRules) { + return ArrayRule(elements); + } + return LiteralRule( + elements.map((e) => (e as LiteralRule).value).toList(growable: false), + ); + } + + static JsonLogicRule _parseObject(Map obj, int depth) { + if (obj.isEmpty) { + return const LiteralRule({}); + } + if (obj.length != 1) { + throw InvalidExpressionException( + 'rule', + 'must have exactly one operator, found: ' + '${_truncate(obj.keys.toList().toString())}', + ); + } + + final operator = obj.keys.first.toString(); + final args = obj.values.first; + + return _parseOperator(operator, args, depth); + } + + static JsonLogicRule _parseOperator( + String operator, + Object? args, + int depth, + ) { + final operands = _toOperandList(args, depth); + + switch (operator) { + // Comparison + case '===': + return _requireBinary(operator, operands, (l, r) => StrictEqualsRule(l, r)); + case '!==': + return _requireBinary(operator, operands, (l, r) => StrictNotEqualsRule(l, r)); + case '>': + return _requireBinary(operator, operands, (l, r) => GreaterThanRule(l, r)); + case '>=': + return _requireBinary(operator, operands, (l, r) => GreaterThanOrEqualRule(l, r)); + case '<': + return _requireBinary(operator, operands, (l, r) => LessThanRule(l, r)); + case '<=': + return _requireBinary(operator, operands, (l, r) => LessThanOrEqualRule(l, r)); + + // Logic + case 'and': + return AndRule(operands); + case 'or': + return OrRule(operands); + + // String/Array + case 'in': + return _requireBinary(operator, operands, (l, r) => InRule(l, r)); + + // Data access + case 'var': + return _parseVarRule(operands); + + default: + throw UnsupportedOperatorException(operator); + } + } + + static VarRule _parseVarRule(List operands) { + if (operands.isEmpty) { + return const VarRule(LiteralRule('')); + } + if (operands.length == 1) { + return VarRule(operands[0]); + } + throw InvalidExpressionException('var', 'default values are not supported'); + } + + static List _toOperandList(Object? args, int depth) { + if (args == null) { + return const [LiteralRule(null)]; + } + if (args is List) { + return args.map((e) => parseValue(e, depth + 1)).toList(growable: false); + } + return [parseValue(args, depth + 1)]; + } + + static T _requireBinary( + String operator, + List operands, + T Function(JsonLogicRule, JsonLogicRule) factory, + ) { + if (operands.length != 2) { + throw InvalidExpressionException( + operator, + 'requires exactly 2 arguments, got ${operands.length}', + ); + } + return factory(operands[0], operands[1]); + } + + static String _truncate(String input) { + if (input.length <= _maxErrorEchoLength) return input; + return '${input.substring(0, _maxErrorEchoLength)}...'; + } +} diff --git a/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_rule.dart b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_rule.dart new file mode 100644 index 00000000..2052e024 --- /dev/null +++ b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_rule.dart @@ -0,0 +1,112 @@ +/// Abstract base for all supported JsonLogic operations. +/// +/// Each node holds its operands as typed children, making the full rule tree +/// strongly typed after parsing. +/// +/// Supported operators (per Event Trigger alignment decision): +/// - Comparison: ===, !==, <, <=, >, >= +/// - Logic: and, or +/// - String/Array: in +/// - Data Access: var +abstract class JsonLogicRule { + const JsonLogicRule(); +} + +/// A literal value (string, number, boolean, null, or array of literals). +class LiteralRule extends JsonLogicRule { + const LiteralRule(this.value); + final Object? value; +} + +// ============================================================================= +// Comparison Operations +// ============================================================================= + +/// Strict equality (===) - no type coercion. +class StrictEqualsRule extends JsonLogicRule { + const StrictEqualsRule(this.left, this.right); + final JsonLogicRule left; + final JsonLogicRule right; +} + +/// Strict inequality (!==). +class StrictNotEqualsRule extends JsonLogicRule { + const StrictNotEqualsRule(this.left, this.right); + final JsonLogicRule left; + final JsonLogicRule right; +} + +/// Greater than (>). +class GreaterThanRule extends JsonLogicRule { + const GreaterThanRule(this.left, this.right); + final JsonLogicRule left; + final JsonLogicRule right; +} + +/// Greater than or equal (>=). +class GreaterThanOrEqualRule extends JsonLogicRule { + const GreaterThanOrEqualRule(this.left, this.right); + final JsonLogicRule left; + final JsonLogicRule right; +} + +/// Less than (<) - only 2 arguments supported. +class LessThanRule extends JsonLogicRule { + const LessThanRule(this.left, this.right); + final JsonLogicRule left; + final JsonLogicRule right; +} + +/// Less than or equal (<=) - only 2 arguments supported. +class LessThanOrEqualRule extends JsonLogicRule { + const LessThanOrEqualRule(this.left, this.right); + final JsonLogicRule left; + final JsonLogicRule right; +} + +// ============================================================================= +// Logic Operations +// ============================================================================= + +/// Logical AND - all operands must evaluate to boolean. +class AndRule extends JsonLogicRule { + const AndRule(this.operands); + final List operands; +} + +/// Logical OR - all operands must evaluate to boolean. +class OrRule extends JsonLogicRule { + const OrRule(this.operands); + final List operands; +} + +// ============================================================================= +// String/Array Operations +// ============================================================================= + +/// In - checks if needle is in haystack (string or array). +class InRule extends JsonLogicRule { + const InRule(this.needle, this.haystack); + final JsonLogicRule needle; + final JsonLogicRule haystack; +} + +// ============================================================================= +// Data Access Operations +// ============================================================================= + +/// Variable access (var) - retrieves value from data using path. +class VarRule extends JsonLogicRule { + const VarRule(this.path); + final JsonLogicRule path; +} + +// ============================================================================= +// Internal Types +// ============================================================================= + +/// Array literal that may contain rules (evaluated at runtime). +class ArrayRule extends JsonLogicRule { + const ArrayRule(this.elements); + final List elements; +} diff --git a/packages/mixpanel_flutter_common/lib/src/mixpanel_event.dart b/packages/mixpanel_flutter_common/lib/src/mixpanel_event.dart new file mode 100644 index 00000000..b282503d --- /dev/null +++ b/packages/mixpanel_flutter_common/lib/src/mixpanel_event.dart @@ -0,0 +1,24 @@ +/// A tracked Mixpanel event broadcast through [MixpanelEventBridge]. +/// +/// Shape mirrors the native common modules: +/// - Android `com.mixpanel.android.eventbridge.MixpanelEvent` (Kotlin) +/// - Swift `MixpanelSwiftCommon.MixpanelEvent` +/// +/// [properties] is nullable to match Android's `JSONObject?`. On iOS the +/// native bridge always supplies a (possibly empty) dictionary, but +/// consumers should be prepared for null to preserve cross-platform parity. +class MixpanelEvent { + const MixpanelEvent({required this.eventName, this.properties}); + + /// The name of the tracked event, exactly as the native SDK emitted it. + final String eventName; + + /// The fully-decorated event properties: user-supplied props merged with + /// the native SDK's super properties and automatic properties (`$os`, + /// `$app_version`, `$city`, etc.). May be null when no properties were + /// attached on Android. + final Map? properties; + + @override + String toString() => 'MixpanelEvent($eventName, $properties)'; +} diff --git a/packages/mixpanel_flutter_common/pubspec.lock b/packages/mixpanel_flutter_common/pubspec.lock new file mode 100644 index 00000000..5fcbcbf5 --- /dev/null +++ b/packages/mixpanel_flutter_common/pubspec.lock @@ -0,0 +1,389 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: cd6add6f846f35fb79f3c315296703c1a24f3cfd7f4739d91a74961c1c7e9f1b + url: "https://pub.dev" + source: hosted + version: "100.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "6ba98576948803398b69e3a444df24eacdbe12ed699c7014e120ea38552debbf" + url: "https://pub.dev" + source: hosted + version: "13.0.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + meta: + dependency: transitive + description: + name: meta + sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 + url: "https://pub.dev" + source: hosted + version: "1.18.2" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f + url: "https://pub.dev" + source: hosted + version: "1.31.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + test_core: + dependency: transitive + description: + name: test_core + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 + url: "https://pub.dev" + source: hosted + version: "0.6.18" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/packages/mixpanel_flutter_common/pubspec.yaml b/packages/mixpanel_flutter_common/pubspec.yaml new file mode 100644 index 00000000..504eb548 --- /dev/null +++ b/packages/mixpanel_flutter_common/pubspec.yaml @@ -0,0 +1,17 @@ +name: mixpanel_flutter_common +description: Shared pure-Dart support for the Mixpanel Flutter SDK family — EventBridge interface and JSONLogic evaluator. Consumed by mixpanel_flutter and downstream packages (e.g. mixpanel_flutter_session_replay). +version: 0.1.0 +homepage: https://mixpanel.com +repository: https://github.com/mixpanel/mixpanel-flutter +issue_tracker: https://github.com/mixpanel/mixpanel-flutter/issues + +environment: + # Matches mixpanel_flutter's published SDK floor. JSONLogic is intentionally + # written without Dart 3 features (sealed classes, switch expressions, + # constructor tearoffs) so depending on this package doesn't force + # mixpanel_flutter consumers to bump their Dart version. + sdk: '>=2.12.0 <4.0.0' + +dev_dependencies: + test: ^1.24.0 + lints: ^4.0.0 diff --git a/packages/mixpanel_flutter_common/test/event_bridge_test.dart b/packages/mixpanel_flutter_common/test/event_bridge_test.dart new file mode 100644 index 00000000..ef850dea --- /dev/null +++ b/packages/mixpanel_flutter_common/test/event_bridge_test.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:mixpanel_flutter_common/mixpanel_flutter_common.dart'; + +void main() { + group('MixpanelEventBridge', () { + test('subscriber receives events emitted after listen', () async { + final received = []; + final sub = MixpanelEventBridge.events.listen(received.add); + + MixpanelEventBridge.notifyListeners(eventName: 'A', properties: {'x': 1}); + MixpanelEventBridge.notifyListeners(eventName: 'B'); + + // Stream delivery is async — flush the microtask queue. + await Future.delayed(Duration.zero); + + expect(received, hasLength(2)); + expect(received[0].eventName, 'A'); + expect(received[0].properties, {'x': 1}); + expect(received[1].eventName, 'B'); + expect(received[1].properties, isNull); + + await sub.cancel(); + }); + + test( + 'multiple subscribers each see every event (broadcast semantics)', + () async { + final a = []; + final b = []; + final subA = MixpanelEventBridge.events.listen( + (e) => a.add(e.eventName), + ); + final subB = MixpanelEventBridge.events.listen( + (e) => b.add(e.eventName), + ); + + MixpanelEventBridge.notifyListeners(eventName: 'evt'); + await Future.delayed(Duration.zero); + + expect(a, ['evt']); + expect(b, ['evt']); + + await subA.cancel(); + await subB.cancel(); + }, + ); + + test('late subscribers miss prior events (no replay buffer)', () async { + // Emit before anyone is listening. + MixpanelEventBridge.notifyListeners(eventName: 'lost'); + await Future.delayed(Duration.zero); + + final received = []; + final sub = MixpanelEventBridge.events.listen( + (e) => received.add(e.eventName), + ); + + MixpanelEventBridge.notifyListeners(eventName: 'after'); + await Future.delayed(Duration.zero); + + expect(received, ['after']); // 'lost' never reaches the late subscriber + await sub.cancel(); + }); + + test('nullable properties pass through unchanged', () async { + final received = []; + final sub = MixpanelEventBridge.events.listen(received.add); + + MixpanelEventBridge.notifyListeners(eventName: 'null-props'); + MixpanelEventBridge.notifyListeners( + eventName: 'empty-props', + properties: const {}, + ); + MixpanelEventBridge.notifyListeners( + eventName: 'with-props', + properties: const {'a': 1, 'b': 'two'}, + ); + await Future.delayed(Duration.zero); + + expect(received[0].properties, isNull); + expect(received[1].properties, isEmpty); + expect(received[2].properties, {'a': 1, 'b': 'two'}); + + await sub.cancel(); + }); + + test('exception in one listener does not block other listeners', () async { + // When a broadcast listener throws synchronously, the exception is + // delivered to the surrounding zone's uncaught-error handler rather + // than aborting other subscriptions. runZonedGuarded captures it so + // the test framework doesn't see an unhandled error. + final survivors = []; + final errors = []; + + await runZonedGuarded( + () async { + final boom = MixpanelEventBridge.events.listen((_) { + throw StateError('listener exploded'); + }); + final ok = MixpanelEventBridge.events.listen( + (e) => survivors.add(e.eventName), + ); + + MixpanelEventBridge.notifyListeners(eventName: 'evt'); + await Future.delayed(Duration.zero); + + await boom.cancel(); + await ok.cancel(); + }, + (error, _) { + errors.add(error); + }, + ); + + expect(survivors, ['evt']); + expect(errors, hasLength(1)); + expect(errors.first, isA()); + }); + }); +} diff --git a/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_edge_case_test.dart b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_edge_case_test.dart new file mode 100644 index 00000000..aad4814e --- /dev/null +++ b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_edge_case_test.dart @@ -0,0 +1,507 @@ +import 'package:test/test.dart'; +import 'package:mixpanel_flutter_common/mixpanel_flutter_common.dart'; + +/// Mirrors `JsonLogicEdgeCaseTest.kt` from mixpanel-android. Covers scenarios +/// not exercised by the shared `tests.json` fixture — primarily error paths +/// and the unsupported-operator allowlist. +void main() { + Object? evaluate(String ruleJson, [Map data = const {}]) { + return JsonLogicEvaluator.evaluate(JsonLogicParser.parse(ruleJson), data); + } + + group('var', () { + test('throws for dot in property name', () { + expect( + () => evaluate('{"var":"user.name"}', { + 'user': {'name': 'John'}, + }), + throwsA(isA()), + ); + }); + + test('throws for multiple dots in property name', () { + expect( + () => evaluate('{"var":"a.b.c"}', { + 'a': { + 'b': {'c': 42}, + }, + }), + throwsA(isA()), + ); + }); + + test('accesses property with numeric string key', () { + expect(evaluate('{"var":"0"}', {'0': 'first', '1': 'second'}), 'first'); + }); + + test('accesses property with dollar sign in name', () { + expect(evaluate('{"var":"\$tier"}', {'\$tier': 'premium'}), 'premium'); + }); + + test('throws for default value syntax (parse time)', () { + expect( + () => JsonLogicParser.parse('{"var": ["missing", 0]}'), + throwsA(isA()), + ); + }); + + test('throws for empty path', () { + expect( + () => evaluate('{"var":""}', {'a': 1}), + throwsA(isA()), + ); + }); + + test('throws for null path', () { + expect( + () => evaluate('{"var":null}', {'a': 1}), + throwsA(isA()), + ); + }); + + test('throws for empty array path', () { + expect( + () => evaluate('{"var":[]}', {'a': 1}), + throwsA(isA()), + ); + }); + }); + + group("'in' operator", () { + test('matches string in array', () { + expect( + evaluate('{"in": [{"var": "tier"}, ["a", "b", "c"]]}', {'tier': 'b'}), + isTrue, + ); + }); + + test('returns false when string not in array', () { + expect( + evaluate('{"in": [{"var": "tier"}, ["a", "b", "c"]]}', {'tier': 'x'}), + isFalse, + ); + }); + + test('throws when array contains non-string elements', () { + expect( + () => evaluate('{"in": [{"var": "tier"}, [1, 2, 3]]}', {'tier': '1'}), + throwsA(isA()), + ); + }); + + test('throws when array contains mixed types', () { + expect( + () => + evaluate('{"in": [{"var": "tier"}, ["a", 1, "b"]]}', {'tier': 'x'}), + throwsA(isA()), + ); + }); + + test('throws when array contains null', () { + expect( + () => evaluate('{"in": [{"var": "tier"}, ["a", null]]}', {'tier': 'a'}), + throwsA(isA()), + ); + }); + + test('returns false for empty array', () { + expect(evaluate('{"in": [{"var": "tier"}, []]}', {'tier': 'a'}), isFalse); + }); + + test('matches substring in string', () { + expect( + evaluate('{"in": ["Lou", {"var": "city"}]}', {'city': 'Louisville'}), + isTrue, + ); + }); + + test('returns false when substring not in string', () { + expect( + evaluate('{"in": ["xyz", {"var": "city"}]}', {'city': 'Louisville'}), + isFalse, + ); + }); + + test('throws for number needle', () { + expect( + () => evaluate('{"in": [{"var": "id"}, ["1", "2", "3"]]}', {'id': 2}), + throwsA(isA()), + ); + }); + + test('throws for boolean needle', () { + expect( + () => evaluate('{"in": [{"var": "active"}, ["true", "false"]]}', { + 'active': true, + }), + throwsA(isA()), + ); + }); + + test('throws for null needle', () { + expect( + () => + evaluate('{"in": [{"var": "value"}, ["a", "b"]]}', {'value': null}), + throwsA(isA()), + ); + }); + }); + + group('strict equality', () { + test('=== returns true for matching nulls', () { + expect( + evaluate('{"===": [{"var": "value"}, null]}', {'value': null}), + isTrue, + ); + }); + + test('=== returns true for matching numbers', () { + expect( + evaluate('{"===": [{"var": "count"}, 42]}', {'count': 42}), + isTrue, + ); + }); + + test('!== returns false for matching numbers', () { + expect(evaluate('{"!==": [{"var": "count"}, 1]}', {'count': 1}), isFalse); + }); + + test('!== returns true for different strings', () { + expect( + evaluate('{"!==": [{"var": "greeting"}, "world"]}', { + 'greeting': 'hello', + }), + isTrue, + ); + }); + + test('=== throws for number vs string', () { + expect( + () => evaluate('{"===": [{"var": "count"}, "1"]}', {'count': 1}), + throwsA(isA()), + ); + }); + + test('=== throws for boolean vs string', () { + expect( + () => + evaluate('{"===": [{"var": "active"}, "true"]}', {'active': true}), + throwsA(isA()), + ); + }); + + test('=== throws for boolean vs number', () { + expect( + () => evaluate('{"===": [{"var": "active"}, 1]}', {'active': true}), + throwsA(isA()), + ); + }); + + test('=== throws for null vs number', () { + expect( + () => evaluate('{"===": [{"var": "value"}, 0]}', {'value': null}), + throwsA(isA()), + ); + }); + + test('=== throws for null vs string', () { + expect( + () => evaluate('{"===": [{"var": "value"}, ""]}', {'value': null}), + throwsA(isA()), + ); + }); + + test('!== throws for number vs string', () { + expect( + () => evaluate('{"!==": [{"var": "count"}, "1"]}', {'count': 1}), + throwsA(isA()), + ); + }); + + test('!== throws for null vs number', () { + expect( + () => evaluate('{"!==": [{"var": "value"}, 0]}', {'value': null}), + throwsA(isA()), + ); + }); + + test('!== throws for boolean vs string', () { + expect( + () => + evaluate('{"!==": [{"var": "active"}, "true"]}', {'active': true}), + throwsA(isA()), + ); + }); + }); + + group('array comparison', () { + test('=== throws for array comparison', () { + expect( + () => evaluate('{"===": [{"var": "list"}, [1]]}', { + 'list': [1], + }), + throwsA(isA()), + ); + }); + }); + + group('compound rules', () { + test('and - complex rule with nested operations', () { + expect( + evaluate( + '{"and": [{">=": [{"var": "age"}, 18]}, {"var": "premium"}]}', + {'age': 25, 'premium': true}, + ), + isTrue, + ); + }); + + test('and - returns true when all operands true', () { + expect( + evaluate( + '{"and": [{"===": [{"var": "a"}, 1]}, {"===": [{"var": "b"}, 2]}]}', + {'a': 1, 'b': 2}, + ), + isTrue, + ); + }); + + test('and - returns false when any operand false', () { + expect( + evaluate( + '{"and": [{"===": [{"var": "a"}, 1]}, {"===": [{"var": "b"}, 3]}]}', + {'a': 1, 'b': 2}, + ), + isFalse, + ); + }); + + test('or - returns true when any operand true', () { + expect( + evaluate( + '{"or": [{"===": [{"var": "a"}, 9]}, {"===": [{"var": "b"}, 2]}]}', + {'a': 1, 'b': 2}, + ), + isTrue, + ); + }); + + test('or - returns false when all operands false', () { + expect( + evaluate( + '{"or": [{"===": [{"var": "a"}, 9]}, {"===": [{"var": "b"}, 9]}]}', + {'a': 1, 'b': 2}, + ), + isFalse, + ); + }); + + test('and - throws for number literal operand', () { + expect( + () => evaluate('{"and": [{"var": "active"}, 1]}', {'active': true}), + throwsA(isA()), + ); + }); + + test('or - throws for string literal operand', () { + expect( + () => + evaluate('{"or": [{"var": "active"}, "hello"]}', {'active': false}), + throwsA(isA()), + ); + }); + + test('and - throws for var returning non-boolean', () { + expect( + () => evaluate('{"and": [{"var": "active"}, {"var": "count"}]}', { + 'active': true, + 'count': 5, + }), + throwsA(isA()), + ); + }); + + test('or - throws for null operand', () { + expect( + () => evaluate('{"or": [{"var": "active"}, null]}', {'active': false}), + throwsA(isA()), + ); + }); + + test('and - throws for empty operands', () { + expect( + () => evaluate('{"and": []}'), + throwsA(isA()), + ); + }); + + test('or - throws for empty operands', () { + expect( + () => evaluate('{"or": []}'), + throwsA(isA()), + ); + }); + + test('< throws for 3 args (parse time)', () { + expect( + () => JsonLogicParser.parse('{"<": [1, 5, 10]}'), + throwsA(isA()), + ); + }); + + test('<= throws for 3 args (parse time)', () { + expect( + () => JsonLogicParser.parse('{"<=": [1, 1, 10]}'), + throwsA(isA()), + ); + }); + }); + + group('numeric comparison rejects non-numbers', () { + test('> throws for string operand', () { + expect( + () => evaluate('{">": [{"var": "age"}, 5]}', {'age': '10'}), + throwsA(isA()), + ); + }); + + test('< throws for string operand', () { + expect( + () => evaluate('{"<": [{"var": "age"}, "10"]}', {'age': 5}), + throwsA(isA()), + ); + }); + + test('>= throws for string operands', () { + expect( + () => evaluate('{">=": [{"var": "name"}, "def"]}', {'name': 'abc'}), + throwsA(isA()), + ); + }); + + test('<= throws for string operands', () { + expect( + () => evaluate('{"<=": [{"var": "value"}, "2"]}', {'value': '1'}), + throwsA(isA()), + ); + }); + + test('> throws for null operand', () { + expect( + () => evaluate('{">": [{"var": "value"}, 5]}', {'value': null}), + throwsA(isA()), + ); + }); + + test('< throws for null operand', () { + expect( + () => evaluate('{"<": [{"var": "age"}, {"var": "limit"}]}', { + 'age': 5, + 'limit': null, + }), + throwsA(isA()), + ); + }); + + test('> throws for NaN', () { + // NaN cannot be expressed in JSON, so build the rule tree directly. + final rule = GreaterThanRule(LiteralRule(double.nan), LiteralRule(1)); + expect( + () => JsonLogicEvaluator.evaluate(rule, const {}), + throwsA(isA()), + ); + }); + + test('> throws for boolean operand', () { + expect( + () => evaluate('{">": [true, false]}'), + throwsA(isA()), + ); + }); + }); + + group('unsupported operators (parse-time guardrails)', () { + // Per product decision, only 10 operators are supported. These tests + // prevent accidental reintroduction. + final unsupported = { + '== (loose equals)': '{"==":[1, "1"]}', + '!= (loose not equals)': '{"!=":[1, 2]}', + '! (not)': '{"!":[true]}', + '!! (double bang)': '{"!!":[1]}', + 'if': '{"if":[true, 1, 2]}', + '?: (ternary)': '{"?:":[true, 1, 2]}', + '+ (addition)': '{"+":[1, 2]}', + '- (subtraction)': '{"-":[3, 1]}', + '* (multiplication)': '{"*":[2, 3]}', + '/ (division)': '{"/":[6, 2]}', + '% (modulo)': '{"%":[5, 2]}', + 'min': '{"min":[1, 2, 3]}', + 'max': '{"max":[1, 2, 3]}', + 'cat': '{"cat":["a", "b"]}', + 'substr': '{"substr":["hello", 0, 2]}', + 'map': '{"map":[[1,2,3], {"var":""}]}', + 'filter': '{"filter":[[1,2,3], {"var":""}]}', + 'reduce': '{"reduce":[[1,2,3], {"var":"current"}, 0]}', + 'all': '{"all":[[1,2,3], {"var":""}]}', + 'some': '{"some":[[1,2,3], {"var":""}]}', + 'none': '{"none":[[1,2,3], {"var":""}]}', + 'merge': '{"merge":[[1,2], [3,4]]}', + 'missing': '{"missing":["a", "b"]}', + 'missing_some': '{"missing_some":[1, ["a", "b"]]}', + 'log': '{"log":"test"}', + }; + + for (final entry in unsupported.entries) { + test('${entry.key} throws UnsupportedOperatorException', () { + expect( + () => JsonLogicParser.parse(entry.value), + throwsA(isA()), + ); + }); + } + }); + + group('parse - only JSON objects accepted', () { + test('throws for boolean literal', () { + expect( + () => JsonLogicParser.parse('true'), + throwsA(isA()), + ); + }); + + test('throws for number literal', () { + expect( + () => JsonLogicParser.parse('42'), + throwsA(isA()), + ); + }); + + test('throws for string literal', () { + expect( + () => JsonLogicParser.parse('"hello"'), + throwsA(isA()), + ); + }); + + test('throws for null literal', () { + expect( + () => JsonLogicParser.parse('null'), + throwsA(isA()), + ); + }); + + test('throws for array', () { + expect( + () => JsonLogicParser.parse('["a", "b"]'), + throwsA(isA()), + ); + }); + + test('throws for malformed JSON', () { + expect( + () => JsonLogicParser.parse('{"===" 1, 1]}'), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_extra_edge_case_test.dart b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_extra_edge_case_test.dart new file mode 100644 index 00000000..35c28d5b --- /dev/null +++ b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_extra_edge_case_test.dart @@ -0,0 +1,382 @@ +import 'package:test/test.dart'; +import 'package:mixpanel_flutter_common/mixpanel_flutter_common.dart'; + +/// Additional edge cases beyond the shared Android/Swift test corpus. +/// +/// Covers Dart-specific concerns (int↔double numeric model, `String.contains` +/// behavior with empty needles, const-map handling) plus scenarios where the +/// spec's behavior is implicit rather than explicitly tested upstream: +/// nested var paths, falsy var values, deep boolean nesting, parser quirks. +void main() { + Object? evaluate(String ruleJson, [Map data = const {}]) { + return JsonLogicEvaluator.evaluate(JsonLogicParser.parse(ruleJson), data); + } + + group('numeric edge cases', () { + test('int === double: 1 === 1.0 is true', () { + expect(evaluate('{"===":[1, 1.0]}'), isTrue); + }); + + test('double === int: 2.0 === 2 is true', () { + expect(evaluate('{"===":[2.0, 2]}'), isTrue); + }); + + test('!== distinguishes 1 from 1.5', () { + expect(evaluate('{"!==":[1, 1.5]}'), isTrue); + }); + + test('negative numbers compare correctly', () { + expect(evaluate('{">":[-1, -5]}'), isTrue); + expect(evaluate('{"<":[-10, -1]}'), isTrue); + expect(evaluate('{">=":[-1, -1]}'), isTrue); + }); + + test('mixed int/double comparison works', () { + expect(evaluate('{">":[1.5, 1]}'), isTrue); + expect(evaluate('{"<":[1, 1.5]}'), isTrue); + expect(evaluate('{">=":[2, 2.0]}'), isTrue); + }); + + test('negative zero equals positive zero', () { + expect(evaluate('{"===":[0, -0.0]}'), isTrue); + }); + + test('positive infinity compares as larger than any finite', () { + // double.infinity is not JSON-representable; build the rule directly. + final rule = GreaterThanRule( + const LiteralRule(double.infinity), + const LiteralRule(1e308), + ); + expect(JsonLogicEvaluator.evaluate(rule, const {}), isTrue); + }); + + test('negative infinity compares as smaller than any finite', () { + final rule = LessThanRule( + const LiteralRule(double.negativeInfinity), + const LiteralRule(-1e308), + ); + expect(JsonLogicEvaluator.evaluate(rule, const {}), isTrue); + }); + + test('=== with two NaNs returns false (NaN never equals NaN)', () { + final rule = StrictEqualsRule( + const LiteralRule(double.nan), + const LiteralRule(double.nan), + ); + // Mirrors Kotlin/Swift: numeric strict-equality has no NaN guard, so + // the IEEE-754 rule (NaN != NaN) wins. + expect(JsonLogicEvaluator.evaluate(rule, const {}), isFalse); + }); + + test('!== with two NaNs returns true', () { + final rule = StrictNotEqualsRule( + const LiteralRule(double.nan), + const LiteralRule(double.nan), + ); + expect(JsonLogicEvaluator.evaluate(rule, const {}), isTrue); + }); + + test('<= throws for NaN operand', () { + final rule = LessThanOrEqualRule( + const LiteralRule(double.nan), + const LiteralRule(1), + ); + expect( + () => JsonLogicEvaluator.evaluate(rule, const {}), + throwsA(isA()), + ); + }); + }); + + group('var - falsy values are not "missing"', () { + test('var returns false (not null) when property is false', () { + expect( + evaluate('{"===":[{"var":"flag"}, false]}', {'flag': false}), + isTrue, + ); + }); + + test('var returns 0 (not null) when property is 0', () { + expect(evaluate('{"===":[{"var":"n"}, 0]}', {'n': 0}), isTrue); + }); + + test('var returns empty string (not null) when property is ""', () { + expect(evaluate('{"===":[{"var":"s"}, ""]}', {'s': ''}), isTrue); + }); + + test('var returns null when property is explicitly null', () { + expect(evaluate('{"===":[{"var":"x"}, null]}', {'x': null}), isTrue); + }); + + test('var returns null for missing key vs property set to null ' + '(indistinguishable by design)', () { + expect(evaluate('{"===":[{"var":"missing"}, null]}'), isTrue); + }); + }); + + group('var - dynamic and unusual paths', () { + test('var path that itself is a var expression', () { + // {"var": {"var": "key"}} with data {"key": "actual", "actual": "value"} + // resolves "key" → "actual", then looks up "actual" → "value". + expect( + evaluate('{"var":{"var":"key"}}', {'key': 'actual', 'actual': 'value'}), + 'value', + ); + }); + + test('var path that is a numeric literal coerces to string key', () { + expect(evaluate('{"var":1}', {'1': 'found'}), 'found'); + }); + + test('var path that is a number literal as the only array element', () { + expect(evaluate('{"var":[1]}', {'1': 'found'}), 'found'); + }); + + test('var with property name containing space', () { + expect(evaluate('{"var":"first name"}', {'first name': 'Ada'}), 'Ada'); + }); + + test('var with property name containing colon', () { + expect(evaluate('{"var":"ns:key"}', {'ns:key': 'value'}), 'value'); + }); + + test('var with unicode property name', () { + expect(evaluate('{"var":"café"}', {'café': 'open'}), 'open'); + }); + + test('var resolving to a List can flow into `in` haystack', () { + expect( + evaluate('{"in":["b", {"var":"tags"}]}', { + 'tags': ['a', 'b', 'c'], + }), + isTrue, + ); + }); + + test('var resolving to a List in === throws array TypeMismatch', () { + expect( + () => evaluate('{"===":[{"var":"tags"}, "a"]}', { + 'tags': ['a'], + }), + throwsA(isA()), + ); + }); + }); + + group("string 'in' edge cases", () { + test('empty needle is always contained in any string (Dart semantics)', () { + // String.contains("") returns true in Dart; same as JS, Kotlin, Swift. + expect(evaluate('{"in":["", "hello"]}'), isTrue); + }); + + test('empty needle in empty haystack returns true', () { + expect(evaluate('{"in":["", ""]}'), isTrue); + }); + + test('non-empty needle in empty haystack returns false', () { + expect(evaluate('{"in":["x", ""]}'), isFalse); + }); + + test('case sensitivity is enforced', () { + expect(evaluate('{"in":["lou", "Louisville"]}'), isFalse); + expect(evaluate('{"in":["Lou", "Louisville"]}'), isTrue); + }); + + test('unicode substring matching', () { + expect(evaluate('{"in":["é", "café"]}'), isTrue); + expect(evaluate('{"in":["münchen", "Welcome to münchen!"]}'), isTrue); + }); + + test('needle equal to full haystack matches', () { + expect(evaluate('{"in":["hello", "hello"]}'), isTrue); + }); + }); + + group("array 'in' edge cases", () { + test('single-element array with match', () { + expect(evaluate('{"in":["only", ["only"]]}'), isTrue); + }); + + test('single-element array without match', () { + expect(evaluate('{"in":["other", ["only"]]}'), isFalse); + }); + + test('array contains var-resolved string elements', () { + // The haystack array contains a {"var":"x"} expression which must be + // evaluated before membership is checked. + expect( + evaluate('{"in":["target", [{"var":"x"}, "other"]]}', {'x': 'target'}), + isTrue, + ); + }); + + test('empty string needle against array of strings returns false', () { + // "" is not equal (===) to any non-empty string element. + expect(evaluate('{"in":["", ["a", "b"]]}'), isFalse); + }); + + test('empty string needle against array containing empty string ' + 'returns true', () { + expect(evaluate('{"in":["", ["", "a"]]}'), isTrue); + }); + }); + + group('and/or - nesting and single-operand', () { + test('and with single true operand returns true', () { + expect(evaluate('{"and":[{"===":[1,1]}]}'), isTrue); + }); + + test('or with single false operand returns false', () { + expect(evaluate('{"or":[{"===":[1,2]}]}'), isFalse); + }); + + test('three-level nested and/or', () { + // (a AND (b OR (c AND d))) + const rule = ''' + {"and":[ + {"===":[{"var":"a"}, 1]}, + {"or":[ + {"===":[{"var":"b"}, 99]}, + {"and":[ + {">":[{"var":"c"}, 0]}, + {"<":[{"var":"d"}, 100]} + ]} + ]} + ]} + '''; + expect(evaluate(rule, {'a': 1, 'b': 0, 'c': 5, 'd': 50}), isTrue); + expect(evaluate(rule, {'a': 1, 'b': 0, 'c': 5, 'd': 200}), isFalse); + expect(evaluate(rule, {'a': 2, 'b': 99, 'c': 5, 'd': 50}), isFalse); + }); + + test( + 'and evaluates all operands even after a false (type-safety check)', + () { + // Second operand is malformed type-wise; engine must surface that error + // rather than short-circuit on the first `false`. + expect( + () => evaluate('{"and":[{"===":[1,2]}, {"===":[1, "1"]}]}'), + throwsA(isA()), + ); + }, + ); + + test('or evaluates all operands even after a true (type-safety check)', () { + expect( + () => evaluate('{"or":[{"===":[1,1]}, {"===":[1, "1"]}]}'), + throwsA(isA()), + ); + }); + }); + + group('parser edge cases', () { + test('multi-key object as expression throws', () { + expect( + () => JsonLogicParser.parse('{"===":[1,1], "!==":[1,2]}'), + throwsA(isA()), + ); + }); + + test('parse tolerates surrounding whitespace', () { + expect(evaluate(' {"===":[1,1]} '), isTrue); + }); + + test('parse tolerates internal whitespace and newlines', () { + expect(evaluate('{\n "===": [\n 1,\n 1\n ]\n}'), isTrue); + }); + + test('empty object parses as literal empty map', () { + // Per parity with mixpanel-android: `{}` becomes a LiteralRule({}). + // Evaluating it directly returns the empty map literal. + final result = JsonLogicEvaluator.evaluate( + JsonLogicParser.parse('{}'), + const {}, + ); + expect(result, isA>()); + expect((result! as Map).isEmpty, isTrue); + }); + + test('nested expressions in binary operator args', () { + // {"===":[{"in":["a","abc"]}, true]} — left side resolves to true, + // strict-equals against literal true. + expect(evaluate('{"===":[{"in":["a","abc"]}, true]}'), isTrue); + }); + + test('binary operator with 0 args throws', () { + expect( + () => JsonLogicParser.parse('{"===":[]}'), + throwsA(isA()), + ); + }); + + test('binary operator with 1 arg throws', () { + expect( + () => JsonLogicParser.parse('{"===":[1]}'), + throwsA(isA()), + ); + }); + + test( + 'parse with non-array operand wraps single arg (for non-binary ops)', + () { + // `and` accepts any operand shape; a single non-array arg becomes a + // 1-element operand list. This must still be boolean to evaluate. + expect(evaluate('{"and":[{"===":[1,1]}]}'), isTrue); + }, + ); + }); + + group('strict equality - extra cross-type combinations', () { + test('=== throws for List vs String', () { + expect( + () => evaluate('{"===":[{"var":"l"}, "a"]}', { + 'l': ['a'], + }), + throwsA(isA()), + ); + }); + + test('=== throws for List vs Number', () { + expect( + () => evaluate('{"===":[{"var":"l"}, 1]}', { + 'l': [1], + }), + throwsA(isA()), + ); + }); + + test('=== throws for List vs Bool', () { + expect( + () => evaluate('{"===":[{"var":"l"}, true]}', { + 'l': [true], + }), + throwsA(isA()), + ); + }); + + test('=== throws for List vs null', () { + expect( + () => evaluate('{"===":[{"var":"l"}, null]}', {'l': []}), + throwsA(isA()), + ); + }); + }); + + group('numeric comparison - extra rejections', () { + test('> rejects List operand', () { + expect( + () => evaluate('{">":[{"var":"l"}, 1]}', { + 'l': [1, 2], + }), + throwsA(isA()), + ); + }); + + test('<= rejects bool literal', () { + expect( + () => evaluate('{"<=":[true, 1]}'), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_security_test.dart b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_security_test.dart new file mode 100644 index 00000000..2b806c28 --- /dev/null +++ b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_security_test.dart @@ -0,0 +1,193 @@ +import 'package:test/test.dart'; +import 'package:mixpanel_flutter_common/mixpanel_flutter_common.dart'; + +/// Tests for defensive limits added to guard against malicious server-supplied +/// rules: depth bound (stack-overflow DoS), error-message truncation (log +/// blowup), and correctness of the allocation-free and/or/in walks. +void main() { + group('depth limit (stack-overflow defense)', () { + String nested(int depth) { + // Build {"!==":[{"!==":[ ... {"!==":[1,2]} ... ]}]} N levels deep. + final open = '{"!==":[' * depth; + final close = ',2]}' * depth; + // Innermost left operand must be a literal — 1 with the trailing ,2. + return '$open${1.toString()}$close'; + } + + test('parses successfully at exactly maxDepth', () { + // A tree at exactly the limit must parse — we only care about the + // parser's depth guard here, so evaluation is intentionally skipped + // (the !== chain produces a bool that wouldn't satisfy the outer + // !== against a number). + expect( + () => JsonLogicParser.parse(nested(JsonLogicParser.maxDepth)), + returnsNormally, + ); + }); + + test('throws InvalidExpressionException when depth exceeds maxDepth', () { + expect( + () => JsonLogicParser.parse(nested(JsonLogicParser.maxDepth + 5)), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('nesting depth exceeds maximum'), + ), + ), + ); + }); + + test('depth check applies through array operands', () { + // and/or wrap their operands in a list — make sure the depth counter + // ticks through that path too. + final operand = nested(JsonLogicParser.maxDepth); + expect( + () => JsonLogicParser.parse('{"and":[$operand]}'), + throwsA(isA()), + ); + }); + + test('evaluator survives at maxDepth (no stack overflow)', () { + // Chain `or` so each level returns a bool that the outer `or` accepts. + // Confirms the evaluator can actually walk a tree at the parser limit. + final open = '{"or":[' * (JsonLogicParser.maxDepth - 1); + final close = ']}' * (JsonLogicParser.maxDepth - 1); + final rule = '$open{"===":[1,1]}$close'; + expect( + JsonLogicEvaluator.evaluate(JsonLogicParser.parse(rule), const {}), + isTrue, + ); + }); + }); + + group('error message truncation (log-blowup defense)', () { + test('malformed JSON message is bounded', () { + final huge = '{' * 50000; // 50KB of garbage + try { + JsonLogicParser.parse(huge); + fail('expected exception'); + } on InvalidExpressionException catch (e) { + // Echo capped to ~200 chars plus the surrounding message text. + // Message format is bounded; assert it is dramatically smaller than + // the input. + expect(e.message.length, lessThan(500)); + expect(e.message, contains('...')); + } + }); + + test('non-object input message is bounded', () { + final huge = '"${'a' * 50000}"'; // 50KB string literal + try { + JsonLogicParser.parse(huge); + fail('expected exception'); + } on InvalidExpressionException catch (e) { + expect(e.message.length, lessThan(500)); + expect(e.message, contains('...')); + } + }); + + test('multi-key object message is bounded', () { + // Build a rule with many keys so the rendered key list would be huge. + final keys = List.generate(5000, (i) => '"k$i":1').join(','); + try { + JsonLogicParser.parse('{$keys}'); + fail('expected exception'); + } on InvalidExpressionException catch (e) { + expect(e.message.length, lessThan(500)); + expect(e.message, contains('...')); + } + }); + + test('short input is not truncated', () { + try { + JsonLogicParser.parse('not json'); + fail('expected exception'); + } on InvalidExpressionException catch (e) { + expect(e.message, contains('not json')); + expect(e.message, isNot(contains('...'))); + } + }); + }); + + group('allocation-free and/or/in (memory defense)', () { + // These are correctness tests for the rewritten loops — they ensure the + // type-safety contract (evaluate ALL operands) is preserved after + // dropping the intermediate List materialization. + + test('and: late type error still surfaces after early false', () { + expect( + () => JsonLogicEvaluator.evaluate( + JsonLogicParser.parse( + '{"and":[{"===":[1,2]}, {"===":[1, "1"]}, {"===":[1,1]}]}', + ), + const {}, + ), + throwsA(isA()), + ); + }); + + test('or: late type error still surfaces after early true', () { + expect( + () => JsonLogicEvaluator.evaluate( + JsonLogicParser.parse( + '{"or":[{"===":[1,1]}, {"===":[1, "1"]}, {"===":[1,2]}]}', + ), + const {}, + ), + throwsA(isA()), + ); + }); + + test('in (array): late non-string element still surfaces after match', () { + expect( + () => JsonLogicEvaluator.evaluate( + JsonLogicParser.parse('{"in":["a", ["a", "b", 1]]}'), + const {}, + ), + throwsA(isA()), + ); + }); + + test('and: large operand list evaluates correctly', () { + // 10k operands, all true. Verifies the loop terminates and we don't + // blow the stack/heap on a moderately large input. + final operands = List.generate(10000, (_) => '{"===":[1,1]}').join(','); + expect( + JsonLogicEvaluator.evaluate( + JsonLogicParser.parse('{"and":[$operands]}'), + const {}, + ), + isTrue, + ); + }); + + test('or: large operand list with single trailing true returns true', () { + final operands = [ + ...List.generate(9999, (_) => '{"===":[1,2]}'), + '{"===":[1,1]}', + ].join(','); + expect( + JsonLogicEvaluator.evaluate( + JsonLogicParser.parse('{"or":[$operands]}'), + const {}, + ), + isTrue, + ); + }); + + test('in (array): large haystack with trailing match returns true', () { + final elements = [ + ...List.generate(9999, (i) => '"item$i"'), + '"target"', + ].join(','); + expect( + JsonLogicEvaluator.evaluate( + JsonLogicParser.parse('{"in":["target", [$elements]]}'), + const {}, + ), + isTrue, + ); + }); + }); +} diff --git a/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_test.dart b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_test.dart new file mode 100644 index 00000000..54c66778 --- /dev/null +++ b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_test.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:mixpanel_flutter_common/mixpanel_flutter_common.dart'; + +/// Mirrors `JsonLogicTest.kt` from mixpanel-android: a parameterized fixture +/// runner backed by `tests.json` to keep parity across SDK ports. +void main() { + final fixturePath = 'test/jsonlogic/tests.json'; + final raw = File(fixturePath).readAsStringSync(); + final entries = jsonDecode(raw) as List; + + var currentSection = 'tests'; + for (final entry in entries) { + if (entry is String) { + final trimmed = entry.replaceFirst(RegExp(r'^#\s*'), '').trim(); + if (trimmed.isNotEmpty && !trimmed.split('').every((c) => c == '=')) { + currentSection = trimmed; + } + continue; + } + if (entry is! List || entry.length < 3) continue; + + final rule = entry[0]; + final data = entry[1]; + final expected = entry[2]; + + final name = + 'group: $currentSection, rule: ${jsonEncode(rule)}, ' + 'result: ${jsonEncode(expected)}'; + + test(name, () { + // Data must be a JSON object per the supported subset (var operates on + // dict context only); throw early in the test if the fixture provides + // anything else. + if (data is! Map) { + fail('Test data must be a JSON object, got: ${jsonEncode(data)}'); + } + final parsedRule = JsonLogicParser.parse(jsonEncode(rule)); + final result = JsonLogicEvaluator.evaluate( + parsedRule, + data.cast(), + ); + expect( + _valuesEqual(result, expected), + isTrue, + reason: + 'Expected: $expected (${expected?.runtimeType}), ' + 'Got: $result (${result?.runtimeType})', + ); + }); + } +} + +bool _valuesEqual(Object? actual, Object? expected) { + if (actual == null && expected == null) return true; + if (actual == null || expected == null) return false; + + if (actual is num && expected is num) { + return (actual.toDouble() - expected.toDouble()).abs() < 0.0001; + } + + if (actual is bool && expected is bool) return actual == expected; + if (actual is String && expected is String) return actual == expected; + + if (actual is List && expected is List) { + if (actual.length != expected.length) return false; + for (var i = 0; i < actual.length; i++) { + if (!_valuesEqual(actual[i], expected[i])) return false; + } + return true; + } + + if (actual is Map && expected is Map) { + if (actual.length != expected.length) return false; + for (final key in actual.keys) { + if (!expected.containsKey(key)) return false; + if (!_valuesEqual(actual[key], expected[key])) return false; + } + return true; + } + + return actual == expected; +} diff --git a/packages/mixpanel_flutter_common/test/jsonlogic/tests.json b/packages/mixpanel_flutter_common/test/jsonlogic/tests.json new file mode 100644 index 00000000..58cade7e --- /dev/null +++ b/packages/mixpanel_flutter_common/test/jsonlogic/tests.json @@ -0,0 +1,111 @@ +[ + "# JSONLogic Tests", + "# Supported operators: ===, !==, <, <=, >, >=, in, var, and, or", + + "# ==========================================================================", + "# Strict Equality (===) - operands must be the same type", + "# ==========================================================================", + [ {"===":[1,1]}, {}, true ], + [ {"===":[1,2]}, {}, false ], + [ {"===":[null, null]}, {}, true ], + [ {"===":["", ""]}, {}, true ], + [ {"===":["hello", "hello"]}, {}, true ], + [ {"===":["hello", "world"]}, {}, false ], + [ {"===":[true, true]}, {}, true ], + [ {"===":[true, false]}, {}, false ], + + "# ==========================================================================", + "# Strict Inequality (!==) - operands must be the same type", + "# ==========================================================================", + [ {"!==":[1,2]}, {}, true ], + [ {"!==":[1,1]}, {}, false ], + [ {"!==":[null, null]}, {}, false ], + [ {"!==":["hello", "world"]}, {}, true ], + [ {"!==":["hello", "hello"]}, {}, false ], + [ {"!==":[true, false]}, {}, true ], + [ {"!==":[true, true]}, {}, false ], + + "# ==========================================================================", + "# Greater Than (>) and Greater Than or Equal (>=)", + "# ==========================================================================", + [ {">":[2,1]}, {}, true ], + [ {">":[1,1]}, {}, false ], + [ {">":[1,2]}, {}, false ], + [ {">=":[2,1]}, {}, true ], + [ {">=":[1,1]}, {}, true ], + [ {">=":[1,2]}, {}, false ], + + "# ==========================================================================", + "# Less Than (<) and Less Than or Equal (<=)", + "# ==========================================================================", + [ {"<":[2,1]}, {}, false ], + [ {"<":[1,1]}, {}, false ], + [ {"<":[1,2]}, {}, true ], + [ {"<=":[2,1]}, {}, false ], + [ {"<=":[1,1]}, {}, true ], + [ {"<=":[1,2]}, {}, true ], + + "# ==========================================================================", + "# Logical AND - operands must be boolean expressions", + "# ==========================================================================", + [ {"and":[{"===":[1,1]},{"===":[2,2]}]}, {}, true ], + [ {"and":[{"===":[1,1]},{"===":[1,2]}]}, {}, false ], + [ {"and":[{"===":[1,2]},{"===":[1,1]}]}, {}, false ], + [ {"and":[{"===":[1,2]},{"===":[2,3]}]}, {}, false ], + [ {"and":[{"===":[1,1]},{"===":[2,2]},{"===":[3,3]}]}, {}, true ], + [ {"and":[{"===":[1,1]},{"===":[2,2]},{"===":[3,4]}]}, {}, false ], + [ {"and":[{"===":[1,2]}]}, {}, false ], + [ {"and":[{"===":[1,1]}]}, {}, true ], + + "# ==========================================================================", + "# Logical OR - operands must be boolean expressions", + "# ==========================================================================", + [ {"or":[{"===":[1,1]},{"===":[2,2]}]}, {}, true ], + [ {"or":[{"===":[1,2]},{"===":[1,1]}]}, {}, true ], + [ {"or":[{"===":[1,1]},{"===":[1,2]}]}, {}, true ], + [ {"or":[{"===":[1,2]},{"===":[2,3]}]}, {}, false ], + [ {"or":[{"===":[1,2]},{"===":[2,3]},{"===":[3,3]}]}, {}, true ], + [ {"or":[{"===":[1,2]},{"===":[2,3]},{"===":[3,4]}]}, {}, false ], + [ {"or":[{"===":[1,2]}]}, {}, false ], + [ {"or":[{"===":[1,1]}]}, {}, true ], + + "# ==========================================================================", + "# In Operator - String Contains", + "# ==========================================================================", + [ {"in":["Spring","Springfield"]}, {}, true ], + [ {"in":["i","team"]}, {}, false ], + + "# ==========================================================================", + "# In Operator - Array Membership", + "# ==========================================================================", + [ {"in":["Bart",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, true ], + [ {"in":["Milhouse",["Bart","Homer","Lisa","Marge","Maggie"]]}, {}, false ], + + "# ==========================================================================", + "# Var Operator - Data Access", + "# ==========================================================================", + [ {"var":["a"]},{"a":1},1 ], + [ {"var":["b"]},{"a":1},null ], + [ {"var":["a"]},{},null ], + [ {"var":"a"},{"a":1},1 ], + [ {"var":"b"},{"a":1},null ], + [ {"var":"a"},{},null ], + + "# Missing variable returns null", + [ {"===":[{"var":"x"}, null]}, {}, true ], + [ {"!==":[{"var":"x"}, null]}, {}, false ], + + "# ==========================================================================", + "# Compound Tests", + "# ==========================================================================", + [ {"and":[{">":[3,1]},{"<":[1,3]}]}, {}, true ], + [ {"and":[{">":[3,1]},{">":[1,3]}]}, {}, false ], + [ {"or":[{"<":[3,1]},{"<":[1,3]}]}, {}, true ], + [ {"in":[{"var":"filling"},["apple","cherry"]]},{"filling":"apple"},true ], + + "# Nested and/or with var", + [ {"and":[{"in":[{"var":"city"},["NYC","LA"]]},{">":[{"var":"age"},18]}]}, {"city":"NYC","age":25}, true ], + [ {"or":[{"===":[{"var":"tier"},"premium"]},{"===":[{"var":"tier"},"enterprise"]}]}, {"tier":"premium"}, true ], + + "EOF" +] From 53ef6d10bb53d5d9826a932d3150c27823e663e8 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 21 May 2026 15:19:05 -0400 Subject: [PATCH 02/17] chore(android): move EventBridge subscriber to Dispatchers.IO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MethodChannel.invokeMethod on Android is thread-safe — no need to occupy the UI thread for event fan-out. Co-Authored-By: Claude Opus 4.7 --- .../com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt index 26b4ebfb..52eb7a0e 100644 --- a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt +++ b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt @@ -25,7 +25,10 @@ import org.json.JSONObject */ object EventBridgeSubscriber { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + // MethodChannel.invokeMethod on Android is thread-safe — DartMessenger + // queues from any thread and the Dart handler runs on the platform + // thread regardless. No reason to occupy the UI thread for fan-out. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var job: Job? = null @JvmStatic From 4a0c0209b26ae1037ced1d2897356bb7df09be89 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 21 May 2026 16:00:08 -0400 Subject: [PATCH 03/17] feat(event-bridge): start native subscription lazily on first listener The native MixpanelEventBridge collector (Kotlin SharedFlow on Android, AsyncStream on iOS/macOS) previously spun up eagerly in the plugin's register/attach lifecycle. Apps that never consume events on the Dart side were paying for an idle MethodChannel round-trip on every tracked event. Now the Dart-side broadcast controller's onListen/onCancel drive startEventBridge/stopEventBridge calls into native. The native subscription only exists while at least one Dart consumer is attached to MixpanelEventBridge.events, and is torn down when the last listener cancels. The MethodCallHandler itself stays installed eagerly so events can never race ahead of the dispatcher. --- .../MixpanelFlutterPlugin.java | 19 +++++- .../lib/mixpanel_flutter.dart | 11 ++- .../Classes/SwiftMixpanelFlutterPlugin.swift | 43 +++++++++--- .../test/event_bridge_forwarding_test.dart | 67 ++++++++++++++++-- .../lib/src/event_bridge.dart | 33 ++++++++- .../test/event_bridge_test.dart | 68 +++++++++++++++++++ 6 files changed, 223 insertions(+), 18 deletions(-) diff --git a/packages/mixpanel_flutter/android/src/main/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java b/packages/mixpanel_flutter/android/src/main/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java index d7137ece..10db4668 100644 --- a/packages/mixpanel_flutter/android/src/main/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java +++ b/packages/mixpanel_flutter/android/src/main/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java @@ -201,18 +201,35 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) { case "getAllVariants": handleGetAllVariants(call, result); break; + case "startEventBridge": + handleStartEventBridge(result); + break; + case "stopEventBridge": + handleStopEventBridge(result); + break; default: result.notImplemented(); break; } } + private void handleStartEventBridge(Result result) { + if (channel != null) { + EventBridgeSubscriber.start(channel); + } + result.success(null); + } + + private void handleStopEventBridge(Result result) { + EventBridgeSubscriber.stop(); + result.success(null); + } + private void initializeMethodChannel() { if (channel == null && flutterPluginBinding != null) { channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "mixpanel_flutter", new StandardMethodCodec(new MixpanelMessageCodec())); channel.setMethodCallHandler(this); - EventBridgeSubscriber.start(channel); } } diff --git a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart index 56a90def..e4870b75 100644 --- a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart +++ b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart @@ -354,8 +354,11 @@ class Mixpanel { 'mp_lib': 'flutter', }; - // Eagerly wires the reverse path from the native MixpanelEventBridge into - // the Dart-side [MixpanelEventBridge] the first time `Mixpanel` is touched. + // Wires the reverse path from the native MixpanelEventBridge into the + // Dart-side [MixpanelEventBridge] the first time `Mixpanel` is touched. + // The MethodCallHandler is installed eagerly so a native event can never + // race ahead of the handler, but the native subscription itself is only + // started when a Dart listener attaches — see [setLifecycleCallbacks]. // Web is skipped — the JS SDK has no EventBridge. // ignore: unused_field static final bool _eventBridgeWired = _wireEventBridge(); @@ -377,6 +380,10 @@ class Mixpanel { } return null; }); + MixpanelEventBridge.setLifecycleCallbacks( + onActivate: () => _channel.invokeMethod('startEventBridge'), + onDeactivate: () => _channel.invokeMethod('stopEventBridge'), + ); return true; } diff --git a/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift b/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift index f2fed56c..7debb18a 100644 --- a/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift +++ b/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift @@ -17,9 +17,14 @@ public class SwiftMixpanelFlutterPlugin: NSObject, FlutterPlugin { var mixpanelProperties: [String: String]? let defaultFlushInterval = 60.0 - // Holds the long-lived AsyncStream consumer that forwards native - // MixpanelEventBridge events to Dart. Cancelled on deinit so hot-restart - // and engine teardown don't leak the task. + // Held so `startEventBridge` (invoked lazily from Dart) can fan native + // events back through the same channel that delivers regular method + // calls. Released on `deinit` along with the task. + private var channel: FlutterMethodChannel? + + // The long-lived AsyncStream consumer that forwards native + // MixpanelEventBridge events to Dart. Created on first + // `startEventBridge` and cancelled by `stopEventBridge` / `deinit`. private var eventBridgeTask: Task? public static func register(with registrar: FlutterPluginRegistrar) { @@ -31,13 +36,26 @@ public class SwiftMixpanelFlutterPlugin: NSObject, FlutterPlugin { let channel = FlutterMethodChannel(name: "mixpanel_flutter", binaryMessenger: registrar.messenger, codec: codec) #endif let instance = SwiftMixpanelFlutterPlugin() + instance.channel = channel registrar.addMethodCallDelegate(instance, channel: channel) - // Subscribe to the native EventBridge and forward each event back to - // Dart. The channel is captured by the closure, so we only need to - // hold the Task itself on the instance for cancellation. + // The native EventBridge subscription is started lazily from Dart + // via `startEventBridge` when the first listener attaches to + // `MixpanelEventBridge.events`. Apps that never consume events + // never pay the cost of the AsyncStream consumer task. + } + + deinit { + eventBridgeTask?.cancel() + } + + private func handleStartEventBridge(_ result: @escaping FlutterResult) { + guard eventBridgeTask == nil, let channel = channel else { + result(nil) + return + } if #available(iOS 13.0, macOS 10.15, *) { - instance.eventBridgeTask = Task { + eventBridgeTask = Task { for await event in MixpanelEventBridge.shared.eventStream() { await MainActor.run { channel.invokeMethod("onMixpanelEvent", arguments: [ @@ -48,10 +66,13 @@ public class SwiftMixpanelFlutterPlugin: NSObject, FlutterPlugin { } } } + result(nil) } - deinit { + private func handleStopEventBridge(_ result: @escaping FlutterResult) { eventBridgeTask?.cancel() + eventBridgeTask = nil + result(nil) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -200,6 +221,12 @@ public class SwiftMixpanelFlutterPlugin: NSObject, FlutterPlugin { case "getAllVariants": handleGetAllVariants(call, result: result) break + case "startEventBridge": + handleStartEventBridge(result) + break + case "stopEventBridge": + handleStopEventBridge(result) + break default: result(FlutterMethodNotImplemented) } diff --git a/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart b/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart index 1e667f0b..ae7422ad 100644 --- a/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart +++ b/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart @@ -6,7 +6,9 @@ import 'package:mixpanel_flutter_common/mixpanel_flutter_common.dart'; /// Verifies the reverse path: when the native plugin invokes /// `onMixpanelEvent` on the channel, the event surfaces on the Dart-side -/// [MixpanelEventBridge.events] stream. +/// [MixpanelEventBridge.events] stream. Also verifies the lazy lifecycle — +/// `startEventBridge` fires on first subscribe, `stopEventBridge` on last +/// cancel. void main() { const channel = MethodChannel( 'mixpanel_flutter', @@ -16,18 +18,27 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); + late List outgoingCalls; + setUp(() async { - // Force the static initializer in Mixpanel that registers the - // setMethodCallHandler for 'onMixpanelEvent'. Init touches that path. + outgoingCalls = []; + // Persistent mock that records every Dart→native call. Importantly it + // intercepts the `startEventBridge`/`stopEventBridge` invocations that + // the lazy lifecycle issues on listener add/cancel, otherwise they'd + // throw MissingPluginException in the test environment. TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (_) async => null); + .setMockMethodCallHandler(channel, (call) async { + outgoingCalls.add(call); + return null; + }); await Mixpanel.init( 'test token', optOutTrackingDefault: false, trackAutomaticEvents: true, ); - // Now release the mock so the real reverse-direction handler installed - // by Mixpanel.init() can receive simulated native calls. + }); + + tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); }); @@ -42,6 +53,8 @@ void main() { 'properties': properties, }), ); + // handlePlatformMessage bypasses the mock and hits the real + // MethodCallHandler installed by Mixpanel during init. await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage('mixpanel_flutter', message, (_) {}); } @@ -106,4 +119,46 @@ void main() { expect(received, isEmpty); await sub.cancel(); }); + + group('lazy native subscription', () { + test('first Dart listener invokes startEventBridge on the channel', + () async { + outgoingCalls.clear(); + final sub = MixpanelEventBridge.events.listen((_) {}); + // The mock handler is called synchronously inside invokeMethod's + // future chain; one microtask flush is enough to settle it. + await Future.delayed(Duration.zero); + + expect( + outgoingCalls.map((c) => c.method).toList(), + contains('startEventBridge'), + ); + + await sub.cancel(); + }); + + test('last Dart cancel invokes stopEventBridge on the channel', () async { + final sub = MixpanelEventBridge.events.listen((_) {}); + await Future.delayed(Duration.zero); + + outgoingCalls.clear(); + await sub.cancel(); + await Future.delayed(Duration.zero); + + expect( + outgoingCalls.map((c) => c.method).toList(), + contains('stopEventBridge'), + ); + }); + + test('start is not issued when no Dart listeners are attached', () async { + // Nothing subscribes during this test — only Mixpanel.init() ran in + // setUp, and it must not have triggered the lazy start. + await Future.delayed(Duration.zero); + expect( + outgoingCalls.map((c) => c.method), + isNot(contains('startEventBridge')), + ); + }); + }); } diff --git a/packages/mixpanel_flutter_common/lib/src/event_bridge.dart b/packages/mixpanel_flutter_common/lib/src/event_bridge.dart index ce203f07..0f29d74c 100644 --- a/packages/mixpanel_flutter_common/lib/src/event_bridge.dart +++ b/packages/mixpanel_flutter_common/lib/src/event_bridge.dart @@ -12,6 +12,14 @@ import 'mixpanel_event.dart'; /// bridge and forward each event into [notifyListeners]. Any number of /// Dart consumers (session replay, custom triggers) subscribe to [events]. /// +/// ## Lazy native subscription +/// The native bridge subscription is only activated while at least one Dart +/// listener is attached. `mixpanel_flutter` registers activation/deactivation +/// hooks via [setLifecycleCallbacks] in its initializer; the first listener +/// triggers `onActivate` (which starts the native subscription), and the +/// last cancel triggers `onDeactivate` (which stops it). This keeps the +/// MethodChannel quiet for apps that never consume events. +/// /// ## Late subscribers /// The stream does not buffer or replay. Events emitted before a listener /// attaches are dropped. This matches the native `replay = 0` semantics. @@ -24,8 +32,14 @@ import 'mixpanel_event.dart'; class MixpanelEventBridge { MixpanelEventBridge._(); + static void Function()? _onActivate; + static void Function()? _onDeactivate; + static final StreamController _controller = - StreamController.broadcast(); + StreamController.broadcast( + onListen: () => _onActivate?.call(), + onCancel: () => _onDeactivate?.call(), + ); /// Subscribe to all events tracked by Mixpanel. /// @@ -47,4 +61,21 @@ class MixpanelEventBridge { MixpanelEvent(eventName: eventName, properties: properties), ); } + + /// Registers hooks invoked when the listener count transitions across zero. + /// + /// `onActivate` fires the moment a first listener attaches to a previously + /// empty broadcast stream; `onDeactivate` fires when the last listener + /// cancels. `mixpanel_flutter` uses these to start/stop the native event + /// bridge subscription lazily so the MethodChannel stays idle when no + /// Dart consumer cares about events. + /// + /// Application code should never call this directly. + static void setLifecycleCallbacks({ + void Function()? onActivate, + void Function()? onDeactivate, + }) { + _onActivate = onActivate; + _onDeactivate = onDeactivate; + } } diff --git a/packages/mixpanel_flutter_common/test/event_bridge_test.dart b/packages/mixpanel_flutter_common/test/event_bridge_test.dart index ef850dea..2dd925d6 100644 --- a/packages/mixpanel_flutter_common/test/event_bridge_test.dart +++ b/packages/mixpanel_flutter_common/test/event_bridge_test.dart @@ -86,6 +86,74 @@ void main() { await sub.cancel(); }); + group('lifecycle callbacks', () { + tearDown(() { + // Detach callbacks so they don't bleed into unrelated tests that + // subscribe/cancel through the same singleton controller. + MixpanelEventBridge.setLifecycleCallbacks(); + }); + + test('onActivate fires when first listener subscribes', () async { + var activations = 0; + MixpanelEventBridge.setLifecycleCallbacks( + onActivate: () => activations++, + ); + + final sub = MixpanelEventBridge.events.listen((_) {}); + expect(activations, 1); + + await sub.cancel(); + }); + + test('onActivate fires only on the 0→1 transition', () async { + var activations = 0; + MixpanelEventBridge.setLifecycleCallbacks( + onActivate: () => activations++, + ); + + final a = MixpanelEventBridge.events.listen((_) {}); + final b = MixpanelEventBridge.events.listen((_) {}); + expect(activations, 1); + + await a.cancel(); + await b.cancel(); + }); + + test('onDeactivate fires only when the last listener cancels', () async { + var deactivations = 0; + MixpanelEventBridge.setLifecycleCallbacks( + onDeactivate: () => deactivations++, + ); + + final a = MixpanelEventBridge.events.listen((_) {}); + final b = MixpanelEventBridge.events.listen((_) {}); + + await a.cancel(); + expect(deactivations, 0); + + await b.cancel(); + expect(deactivations, 1); + }); + + test('re-subscribing after cancel re-activates', () async { + var activations = 0; + var deactivations = 0; + MixpanelEventBridge.setLifecycleCallbacks( + onActivate: () => activations++, + onDeactivate: () => deactivations++, + ); + + final first = MixpanelEventBridge.events.listen((_) {}); + await first.cancel(); + final second = MixpanelEventBridge.events.listen((_) {}); + + expect(activations, 2); + expect(deactivations, 1); + + await second.cancel(); + }); + }); + test('exception in one listener does not block other listeners', () async { // When a broadcast listener throws synchronously, the exception is // delivered to the surrounding zone's uncaught-error handler rather From dabae991bd2fb4b18f4a0c7e8e9a8605921f13ff Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Fri, 29 May 2026 13:10:51 -0400 Subject: [PATCH 04/17] updates --- packages/mixpanel_flutter/android/build.gradle | 7 +++++-- .../com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt | 5 +---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/mixpanel_flutter/android/build.gradle b/packages/mixpanel_flutter/android/build.gradle index cbf7addd..b960c427 100644 --- a/packages/mixpanel_flutter/android/build.gradle +++ b/packages/mixpanel_flutter/android/build.gradle @@ -57,9 +57,12 @@ android { } dependencies { - // Use the Mixpanel Android SDK (includes the common EventBridge module - // since 8.7.0 via PR #924, the source of events the subscriber consumes). implementation "com.mixpanel.android:mixpanel-android:8.7.0" + // mixpanel-android:8.7.0 only declares mixpanel-android-common as a + // runtime dependency, so MixpanelEventBridge is not on the compile + // classpath unless we add it explicitly here. EventBridgeSubscriber.kt + // imports it directly. + implementation "com.mixpanel.android:mixpanel-android-common:1.0.1" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1" } diff --git a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt index 52eb7a0e..26b4ebfb 100644 --- a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt +++ b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt @@ -25,10 +25,7 @@ import org.json.JSONObject */ object EventBridgeSubscriber { - // MethodChannel.invokeMethod on Android is thread-safe — DartMessenger - // queues from any thread and the Dart handler runs on the platform - // thread regardless. No reason to occupy the UI thread for fan-out. - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var job: Job? = null @JvmStatic From 05ed1b97eac597f6a45925178849d8adee938a98 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Fri, 29 May 2026 15:11:59 -0400 Subject: [PATCH 05/17] chore(mixpanel_flutter): revert dart format churn in mixpanel_flutter.dart Restores the file to its pre-branch formatting (Dart formatter pre-3.7 style) so the diff against main shows only the event-bridge wiring: new import, _eventBridgeWired field + _wireEventBridge() method, and the one-line _eventBridgeWired reference in init(). --- .../lib/mixpanel_flutter.dart | 521 ++++++------------ 1 file changed, 182 insertions(+), 339 deletions(-) diff --git a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart index e4870b75..62b9eb54 100644 --- a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart +++ b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart @@ -26,9 +26,8 @@ abstract class MixpanelFlagVariantSource { const MixpanelFlagVariantSource(); const factory MixpanelFlagVariantSource.network() = NetworkSource; - factory MixpanelFlagVariantSource.persistence({ - required DateTime persistedAt, - }) = PersistenceSource; + factory MixpanelFlagVariantSource.persistence( + {required DateTime persistedAt}) = PersistenceSource; const factory MixpanelFlagVariantSource.fallback() = FallbackSource; /// Decodes a source map produced by the platform handlers. Falls back to @@ -43,14 +42,12 @@ abstract class MixpanelFlagVariantSource { final millis = raw is int ? raw : (raw is num ? raw.toInt() : null); if (millis == null) { developer.log( - '`MixpanelFlagVariantSource.fromMap` received persistence source with missing persistedAtMillis, defaulting to fallback', - name: 'Mixpanel', - ); + '`MixpanelFlagVariantSource.fromMap` received persistence source with missing persistedAtMillis, defaulting to fallback', + name: 'Mixpanel'); return const FallbackSource(); } return PersistenceSource( - persistedAt: DateTime.fromMillisecondsSinceEpoch(millis), - ); + persistedAt: DateTime.fromMillisecondsSinceEpoch(millis)); } return const FallbackSource(); } @@ -154,9 +151,8 @@ class MixpanelFlagVariant { final key = map['key'] as String?; if (key == null || key.isEmpty) { developer.log( - '`MixpanelFlagVariant.fromMap` received map with missing or empty key, using empty string as default', - name: 'Mixpanel', - ); + '`MixpanelFlagVariant.fromMap` received map with missing or empty key, using empty string as default', + name: 'Mixpanel'); } return MixpanelFlagVariant( key: key ?? '', @@ -165,8 +161,7 @@ class MixpanelFlagVariant { isExperimentActive: map['isExperimentActive'] as bool?, isQaTester: map['isQaTester'] as bool?, source: MixpanelFlagVariantSource.fromMap( - map['source'] as Map?, - ), + map['source'] as Map?), ); } @@ -251,9 +246,8 @@ abstract class VariantLookupPolicy { /// **Web:** not yet supported by the Mixpanel JS SDK at the time of this /// release. On web this policy is silently treated as [networkOnly] until /// JS SDK support ships. Check the Mixpanel JS docs for availability. - const factory VariantLookupPolicy.persistenceUntilNetworkSuccess({ - Duration persistenceTtl, - }) = PersistenceUntilNetworkSuccessPolicy; + const factory VariantLookupPolicy.persistenceUntilNetworkSuccess( + {Duration persistenceTtl}) = PersistenceUntilNetworkSuccessPolicy; /// Await the network call; fall back to persisted variants (within /// [persistenceTtl]) only on network failure. @@ -282,15 +276,14 @@ class PersistenceUntilNetworkSuccessPolicy extends VariantLookupPolicy { /// Defaults to 24 hours. final Duration persistenceTtl; - const PersistenceUntilNetworkSuccessPolicy({ - this.persistenceTtl = const Duration(hours: 24), - }); + const PersistenceUntilNetworkSuccessPolicy( + {this.persistenceTtl = const Duration(hours: 24)}); @override Map toMap() => { - 'policy': 'persistenceUntilNetworkSuccess', - 'persistenceTtlMillis': persistenceTtl.inMilliseconds, - }; + 'policy': 'persistenceUntilNetworkSuccess', + 'persistenceTtlMillis': persistenceTtl.inMilliseconds, + }; } /// Policy: prefer fresh network values, fall back to persistence only on failure. @@ -303,9 +296,9 @@ class NetworkFirstPolicy extends VariantLookupPolicy { @override Map toMap() => { - 'policy': 'networkFirst', - 'persistenceTtlMillis': persistenceTtl.inMilliseconds, - }; + 'policy': 'networkFirst', + 'persistenceTtlMillis': persistenceTtl.inMilliseconds, + }; } /// Configuration options for feature flags. @@ -346,9 +339,7 @@ class Mixpanel { static final MethodChannel _channel = kIsWeb ? const MethodChannel('mixpanel_flutter') : const MethodChannel( - 'mixpanel_flutter', - StandardMethodCodec(MixpanelMessageCodec()), - ); + 'mixpanel_flutter', StandardMethodCodec(MixpanelMessageCodec())); static final Map _mixpanelProperties = { '\$lib_version': sdkVersion, 'mp_lib': 'flutter', @@ -369,8 +360,8 @@ class Mixpanel { if (call.method == 'onMixpanelEvent') { final args = (call.arguments as Map?)?.cast(); final eventName = args?['eventName'] as String?; - final properties = (args?['properties'] as Map?) - ?.cast(); + final properties = + (args?['properties'] as Map?)?.cast(); if (eventName != null) { MixpanelEventBridge.notifyListeners( eventName: eventName, @@ -392,9 +383,9 @@ class Mixpanel { final FeatureFlags _featureFlags; Mixpanel(String token) - : _token = token, - _people = People(token), - _featureFlags = FeatureFlags(token); + : _token = token, + _people = People(token), + _featureFlags = FeatureFlags(token); /// /// Initializes an instance of the API with the given project token. @@ -408,14 +399,12 @@ class Mixpanel { /// * [config] Optional A dictionary of config options to override (WEB ONLY) /// * [featureFlags] Optional Feature flags configuration /// - static Future init( - String token, { - bool optOutTrackingDefault = false, - required bool trackAutomaticEvents, - Map? superProperties, - Map? config, - FeatureFlagsConfig? featureFlags, - }) async { + static Future init(String token, + {bool optOutTrackingDefault = false, + required bool trackAutomaticEvents, + Map? superProperties, + Map? config, + FeatureFlagsConfig? featureFlags}) async { // Force lazy initialization of the reverse-direction MethodCallHandler // so any native events tracked after this point reach Dart subscribers. _eventBridgeWired; @@ -423,11 +412,8 @@ class Mixpanel { allProperties['optOutTrackingDefault'] = optOutTrackingDefault; allProperties['trackAutomaticEvents'] = trackAutomaticEvents; allProperties['mixpanelProperties'] = _mixpanelProperties; - allProperties['superProperties'] = - _MixpanelHelper.ensureSerializableProperties(superProperties); - allProperties['config'] = _MixpanelHelper.ensureSerializableProperties( - config, - ); + allProperties['superProperties'] = _MixpanelHelper.ensureSerializableProperties(superProperties); + allProperties['config'] = _MixpanelHelper.ensureSerializableProperties(config); if (featureFlags != null) { allProperties['featureFlags'] = featureFlags.toMap(); } @@ -442,14 +428,11 @@ class Mixpanel { /// * [serverURL] the base URL used for Mixpanel API requests void setServerURL(String serverURL) { if (_MixpanelHelper.isValidString(serverURL)) { - _channel.invokeMethod('setServerURL', { - 'serverURL': serverURL, - }); + _channel.invokeMethod( + 'setServerURL', {'serverURL': serverURL}); } else { - developer.log( - '`setServerURL` failed: serverURL cannot be blank', - name: 'Mixpanel', - ); + developer.log('`setServerURL` failed: serverURL cannot be blank', + name: 'Mixpanel'); } } @@ -461,14 +444,12 @@ class Mixpanel { void setLoggingEnabled(bool loggingEnabled) { // ignore: unnecessary_null_comparison if (loggingEnabled != null) { - _channel.invokeMethod('setLoggingEnabled', { - 'loggingEnabled': loggingEnabled, - }); + _channel.invokeMethod('setLoggingEnabled', + {'loggingEnabled': loggingEnabled}); } else { developer.log( - '`setLoggingEnabled` failed: loggingEnabled cannot be blank', - name: 'Mixpanel', - ); + '`setLoggingEnabled` failed: loggingEnabled cannot be blank', + name: 'Mixpanel'); } } @@ -481,16 +462,13 @@ class Mixpanel { // ignore: unnecessary_null_comparison if (useIpAddressForGeolocation != null) { _channel.invokeMethod( - 'setUseIpAddressForGeolocation', - { - 'useIpAddressForGeolocation': useIpAddressForGeolocation, - }, - ); + 'setUseIpAddressForGeolocation', { + 'useIpAddressForGeolocation': useIpAddressForGeolocation + }); } else { developer.log( - '`setUseIpAddressForGeolocation` failed: useIpAddressForGeolocation cannot be blank', - name: 'Mixpanel', - ); + '`setUseIpAddressForGeolocation` failed: useIpAddressForGeolocation cannot be blank', + name: 'Mixpanel'); } } @@ -522,9 +500,8 @@ class Mixpanel { /// and the server. The maximum size is 50; any value over 50 will default to 50. /// * [flushBatchSize] an int representing the number of events sent in a single network request. void setFlushBatchSize(int flushBatchSize) { - _channel.invokeMethod('setFlushBatchSize', { - 'flushBatchSize': flushBatchSize, - }); + _channel.invokeMethod('setFlushBatchSize', + {'flushBatchSize': flushBatchSize}); } /// Associate all future calls to track() with the user identified by @@ -544,14 +521,11 @@ class Mixpanel { /// value is globally unique for each individual user you intend to track. Future identify(String distinctId) async { if (_MixpanelHelper.isValidString(distinctId)) { - await _channel.invokeMethod('identify', { - 'distinctId': distinctId, - }); + await _channel.invokeMethod( + 'identify', {'distinctId': distinctId}); } else { - developer.log( - '`identify` failed: distinctId cannot be blank', - name: 'Mixpanel', - ); + developer.log('`identify` failed: distinctId cannot be blank', + name: 'Mixpanel'); } } @@ -572,16 +546,12 @@ class Mixpanel { return; } if (!_MixpanelHelper.isValidString(distinctId)) { - developer.log( - '`alias` failed: distinctId cannot be blank', - name: 'Mixpanel', - ); + developer.log('`alias` failed: distinctId cannot be blank', + name: 'Mixpanel'); return; } - _channel.invokeMethod('alias', { - 'alias': alias, - 'distinctId': distinctId, - }); + _channel.invokeMethod( + 'alias', {'alias': alias, 'distinctId': distinctId}); } /// Track an event. @@ -598,15 +568,11 @@ class Mixpanel { Map? properties, }) async { if (_MixpanelHelper.isValidString(eventName)) { - await _channel.invokeMethod('track', { - 'eventName': eventName, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties), - }); + await _channel.invokeMethod('track', + {'eventName': eventName, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); } else { - developer.log( - '`track` failed: eventName cannot be blank', - name: 'Mixpanel', - ); + developer.log('`track` failed: eventName cannot be blank', + name: 'Mixpanel'); } } @@ -642,13 +608,11 @@ class Mixpanel { await _channel.invokeMethod('trackWithGroups', { 'eventName': eventName, 'properties': _MixpanelHelper.ensureSerializableProperties(properties), - 'groups': _MixpanelHelper.ensureSerializableProperties(groups), + 'groups': _MixpanelHelper.ensureSerializableProperties(groups) }); } else { - developer.log( - '`trackWithGroups` failed: eventName cannot be blank', - name: 'Mixpanel', - ); + developer.log('`trackWithGroups` failed: eventName cannot be blank', + name: 'Mixpanel'); } } @@ -658,15 +622,11 @@ class Mixpanel { /// * [groupID] The group the user belongs to. void setGroup(String groupKey, dynamic groupID) { if (_MixpanelHelper.isValidString(groupKey)) { - _channel.invokeMethod('setGroup', { - 'groupKey': groupKey, - 'groupID': _MixpanelHelper.ensureSerializableValue(groupID), - }); + _channel.invokeMethod('setGroup', + {'groupKey': groupKey, 'groupID': _MixpanelHelper.ensureSerializableValue(groupID)}); } else { - developer.log( - '`setGroup` failed: groupKey cannot be blank', - name: 'Mixpanel', - ); + developer.log('`setGroup` failed: groupKey cannot be blank', + name: 'Mixpanel'); } } @@ -678,11 +638,7 @@ class Mixpanel { /// return an instance of MixpanelGroup that you can use to update /// records in Mixpanel Group Analytics MixpanelGroup getGroup(String groupKey, dynamic groupID) { - return MixpanelGroup( - _token, - groupKey, - _MixpanelHelper.ensureSerializableValue(groupID), - ); + return MixpanelGroup(_token, groupKey, _MixpanelHelper.ensureSerializableValue(groupID)); } /// Add a group to this user's membership for a particular group key @@ -691,15 +647,11 @@ class Mixpanel { /// * [groupID] The new group the user belongs to. void addGroup(String groupKey, dynamic groupID) { if (_MixpanelHelper.isValidString(groupKey)) { - _channel.invokeMethod('addGroup', { - 'groupKey': groupKey, - 'groupID': _MixpanelHelper.ensureSerializableValue(groupID), - }); + _channel.invokeMethod('addGroup', + {'groupKey': groupKey, 'groupID': _MixpanelHelper.ensureSerializableValue(groupID)}); } else { - developer.log( - '`addGroup` failed: groupKey cannot be blank', - name: 'Mixpanel', - ); + developer.log('`addGroup` failed: groupKey cannot be blank', + name: 'Mixpanel'); } } @@ -709,15 +661,11 @@ class Mixpanel { /// * [groupID] The group value to remove. void removeGroup(String groupKey, dynamic groupID) { if (_MixpanelHelper.isValidString(groupKey)) { - _channel.invokeMethod('removeGroup', { - 'groupKey': groupKey, - 'groupID': _MixpanelHelper.ensureSerializableValue(groupID), - }); + _channel.invokeMethod('removeGroup', + {'groupKey': groupKey, 'groupID': _MixpanelHelper.ensureSerializableValue(groupID)}); } else { - developer.log( - '`removeGroup` failed: groupKey cannot be blank', - name: 'Mixpanel', - ); + developer.log('`removeGroup` failed: groupKey cannot be blank', + name: 'Mixpanel'); } } @@ -730,15 +678,11 @@ class Mixpanel { /// to Group Analytics using the same group value will create and store new values. void deleteGroup(String groupKey, dynamic groupID) { if (_MixpanelHelper.isValidString(groupKey)) { - _channel.invokeMethod('deleteGroup', { - 'groupKey': groupKey, - 'groupID': _MixpanelHelper.ensureSerializableValue(groupID), - }); + _channel.invokeMethod('deleteGroup', + {'groupKey': groupKey, 'groupID': _MixpanelHelper.ensureSerializableValue(groupID)}); } else { - developer.log( - '`deleteGroup` failed: groupKey cannot be blank', - name: 'Mixpanel', - ); + developer.log('`deleteGroup` failed: groupKey cannot be blank', + name: 'Mixpanel'); } } @@ -757,11 +701,7 @@ class Mixpanel { /// * [properties] A Map containing super properties to register Future registerSuperProperties(Map properties) async { await _channel.invokeMethod( - 'registerSuperProperties', - { - 'properties': _MixpanelHelper.ensureSerializableProperties(properties), - }, - ); + 'registerSuperProperties', {'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); } /// Register super properties for events, only if no other super property with the @@ -773,12 +713,8 @@ class Mixpanel { Future registerSuperPropertiesOnce( Map properties, ) async { - await _channel.invokeMethod( - 'registerSuperPropertiesOnce', - { - 'properties': _MixpanelHelper.ensureSerializableProperties(properties), - }, - ); + await _channel.invokeMethod('registerSuperPropertiesOnce', + {'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); } /// Remove a single superProperty, so that it will not be sent with future calls to track(). @@ -790,15 +726,12 @@ class Mixpanel { /// * [propertyName] name of the property to unregister Future unregisterSuperProperty(String propertyName) async { if (_MixpanelHelper.isValidString(propertyName)) { - await _channel.invokeMethod( - 'unregisterSuperProperty', - {'propertyName': propertyName}, - ); + await _channel.invokeMethod('unregisterSuperProperty', + {'propertyName': propertyName}); } else { developer.log( - '`unregisterSuperProperty` failed: propertyName cannot be blank', - name: 'Mixpanel', - ); + '`unregisterSuperProperty` failed: propertyName cannot be blank', + name: 'Mixpanel'); } } @@ -829,14 +762,11 @@ class Mixpanel { /// * [eventName] the name of the event to track with timing. void timeEvent(String eventName) { if (_MixpanelHelper.isValidString(eventName)) { - _channel.invokeMethod('timeEvent', { - 'eventName': eventName, - }); + _channel.invokeMethod( + 'timeEvent', {'eventName': eventName}); } else { - developer.log( - '`timeEvent` failed: eventName cannot be blank', - name: 'Mixpanel', - ); + developer.log('`timeEvent` failed: eventName cannot be blank', + name: 'Mixpanel'); } } @@ -848,9 +778,7 @@ class Mixpanel { Future eventElapsedTime(String eventName) async { if (_MixpanelHelper.isValidString(eventName)) { return await _channel.invokeMethod( - 'eventElapsedTime', - {'eventName': eventName}, - ); + 'eventElapsedTime', {'eventName': eventName}); } else { return 0; } @@ -904,9 +832,7 @@ class People { static final MethodChannel _channel = kIsWeb ? const MethodChannel('mixpanel_flutter') : const MethodChannel( - 'mixpanel_flutter', - StandardMethodCodec(MixpanelMessageCodec()), - ); + 'mixpanel_flutter', StandardMethodCodec(MixpanelMessageCodec())); final String _token; @@ -922,15 +848,11 @@ class People { void set(String prop, dynamic to) { if (_MixpanelHelper.isValidString(prop)) { Map properties = {prop: to}; - _channel.invokeMethod('set', { - 'token': _token, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties), - }); + _channel.invokeMethod('set', + {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); } else { - developer.log( - '`people set` failed: prop cannot be blank', - name: 'Mixpanel', - ); + developer.log('`people set` failed: prop cannot be blank', + name: 'Mixpanel'); } } @@ -941,15 +863,11 @@ class People { void setOnce(String prop, dynamic to) { if (_MixpanelHelper.isValidString(prop)) { Map properties = {prop: to}; - _channel.invokeMethod('setOnce', { - 'token': _token, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties), - }); + _channel.invokeMethod('setOnce', + {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); } else { - developer.log( - '`people setOnce` failed: prop cannot be blank', - name: 'Mixpanel', - ); + developer.log('`people setOnce` failed: prop cannot be blank', + name: 'Mixpanel'); } } @@ -962,15 +880,11 @@ class People { void increment(String prop, double by) { Map properties = {prop: by}; if (_MixpanelHelper.isValidString(prop)) { - _channel.invokeMethod('increment', { - 'token': _token, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties), - }); + _channel.invokeMethod('increment', + {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); } else { - developer.log( - '`people increment` failed: prop cannot be blank', - name: 'Mixpanel', - ); + developer.log('`people increment` failed: prop cannot be blank', + name: 'Mixpanel'); } } @@ -983,24 +897,18 @@ class People { if (_MixpanelHelper.isValidString(name)) { if (kIsWeb || Platform.isIOS || Platform.isMacOS) { Map properties = {name: value}; - _channel.invokeMethod('append', { - 'token': _token, - 'properties': _MixpanelHelper.ensureSerializableProperties( - properties, - ), - }); + _channel.invokeMethod('append', + {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); } else { _channel.invokeMethod('append', { 'token': _token, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value), + 'value': _MixpanelHelper.ensureSerializableValue(value) }); } } else { - developer.log( - '`people append` failed: name cannot be blank', - name: 'Mixpanel', - ); + developer.log('`people append` failed: name cannot be blank', + name: 'Mixpanel'); } } @@ -1014,24 +922,18 @@ class People { if (_MixpanelHelper.isValidString(name)) { if (kIsWeb || Platform.isIOS || Platform.isMacOS) { Map properties = {name: value}; - _channel.invokeMethod('union', { - 'token': _token, - 'properties': _MixpanelHelper.ensureSerializableProperties( - properties, - ), - }); + _channel.invokeMethod('union', + {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); } else { _channel.invokeMethod('union', { 'token': _token, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value), + 'value': _MixpanelHelper.ensureSerializableValue(value) }); } } else { - developer.log( - '`people union` failed: name cannot be blank', - name: 'Mixpanel', - ); + developer.log('`people union` failed: name cannot be blank', + name: 'Mixpanel'); } } @@ -1045,24 +947,18 @@ class People { if (_MixpanelHelper.isValidString(name)) { if (kIsWeb || Platform.isIOS || Platform.isMacOS) { Map properties = {name: value}; - _channel.invokeMethod('remove', { - 'token': _token, - 'properties': _MixpanelHelper.ensureSerializableProperties( - properties, - ), - }); + _channel.invokeMethod('remove', + {'token': _token, 'properties': _MixpanelHelper.ensureSerializableProperties(properties)}); } else { _channel.invokeMethod('remove', { 'token': _token, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value), + 'value': _MixpanelHelper.ensureSerializableValue(value) }); } } else { - developer.log( - '`people remove` failed: name cannot be blank', - name: 'Mixpanel', - ); + developer.log('`people remove` failed: name cannot be blank', + name: 'Mixpanel'); } } @@ -1071,15 +967,11 @@ class People { /// * [name] name of a property to unset void unset(String name) { if (_MixpanelHelper.isValidString(name)) { - _channel.invokeMethod('unset', { - 'token': _token, - 'name': name, - }); + _channel.invokeMethod( + 'unset', {'token': _token, 'name': name}); } else { - developer.log( - '`people unset` failed: name cannot be blank', - name: 'Mixpanel', - ); + developer.log('`people unset` failed: name cannot be blank', + name: 'Mixpanel'); } } @@ -1093,21 +985,18 @@ class People { _channel.invokeMethod('trackCharge', { 'token': _token, 'amount': amount, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties), + 'properties': _MixpanelHelper.ensureSerializableProperties(properties) }); } else { - developer.log( - '`people trackCharge` failed: amount cannot be blank', - name: 'Mixpanel', - ); + developer.log('`people trackCharge` failed: amount cannot be blank', + name: 'Mixpanel'); } } /// Permanently clear the whole transaction history for the identified people profile. void clearCharges() { - _channel.invokeMethod('clearCharges', { - 'token': _token, - }); + _channel.invokeMethod( + 'clearCharges', {'token': _token}); } /// Permanently deletes the identified user's record from People Analytics. @@ -1115,9 +1004,8 @@ class People { /// Calling deleteUser deletes an entire record completely. Any future calls /// to People Analytics using the same distinct id will create and store new values. void deleteUser() { - _channel.invokeMethod('deleteUser', { - 'token': _token, - }); + _channel.invokeMethod( + 'deleteUser', {'token': _token}); } } @@ -1129,18 +1017,16 @@ class MixpanelGroup { static final MethodChannel _channel = kIsWeb ? const MethodChannel('mixpanel_flutter') : const MethodChannel( - 'mixpanel_flutter', - StandardMethodCodec(MixpanelMessageCodec()), - ); + 'mixpanel_flutter', StandardMethodCodec(MixpanelMessageCodec())); final String _token; final String _groupKey; final dynamic _groupID; MixpanelGroup(String token, String groupKey, dynamic groupID) - : _token = token, - _groupKey = groupKey, - _groupID = groupID; + : _token = token, + _groupKey = groupKey, + _groupID = groupID; /// Sets a single property with the given name and value for this group. /// The given name and value will be assigned to the user in Mixpanel Group Analytics, @@ -1156,13 +1042,11 @@ class MixpanelGroup { 'token': _token, 'groupKey': _groupKey, 'groupID': _groupID, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties), + 'properties': _MixpanelHelper.ensureSerializableProperties(properties) }); } else { - developer.log( - '`group set` failed: prop cannot be blank', - name: 'Mixpanel', - ); + developer.log('`group set` failed: prop cannot be blank', + name: 'Mixpanel'); } } @@ -1178,13 +1062,11 @@ class MixpanelGroup { 'token': _token, 'groupKey': _groupKey, 'groupID': _groupID, - 'properties': _MixpanelHelper.ensureSerializableProperties(properties), + 'properties': _MixpanelHelper.ensureSerializableProperties(properties) }); } else { - developer.log( - '`group setOnce` failed: prop cannot be blank', - name: 'Mixpanel', - ); + developer.log('`group setOnce` failed: prop cannot be blank', + name: 'Mixpanel'); } } @@ -1197,13 +1079,11 @@ class MixpanelGroup { 'token': _token, 'groupKey': _groupKey, 'groupID': _groupID, - 'propertyName': prop, + 'propertyName': prop }); } else { - developer.log( - '`group unset` failed: prop cannot be blank', - name: 'Mixpanel', - ); + developer.log('`group unset` failed: prop cannot be blank', + name: 'Mixpanel'); } } @@ -1220,13 +1100,11 @@ class MixpanelGroup { 'groupKey': _groupKey, 'groupID': _groupID, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value), + 'value': _MixpanelHelper.ensureSerializableValue(value) }); } else { - developer.log( - '`group remove` failed: name cannot be blank', - name: 'Mixpanel', - ); + developer.log('`group remove` failed: name cannot be blank', + name: 'Mixpanel'); } } @@ -1238,18 +1116,14 @@ class MixpanelGroup { /// * [value] an array of values to add to the property value if not already present void union(String name, List value) { if (!_MixpanelHelper.isValidString(name)) { - developer.log( - '`group union` failed: name cannot be blank', - name: 'Mixpanel', - ); + developer.log('`group union` failed: name cannot be blank', + name: 'Mixpanel'); return; } // ignore: unnecessary_null_comparison if (value == null) { - developer.log( - '`group union` failed: value cannot be blank', - name: 'Mixpanel', - ); + developer.log('`group union` failed: value cannot be blank', + name: 'Mixpanel'); return; } _channel.invokeMethod('groupUnionProperty', { @@ -1257,7 +1131,7 @@ class MixpanelGroup { 'groupKey': _groupKey, 'groupID': _groupID, 'name': name, - 'value': _MixpanelHelper.ensureSerializableValue(value), + 'value': _MixpanelHelper.ensureSerializableValue(value) }); } } @@ -1271,9 +1145,7 @@ class FeatureFlags { static final MethodChannel _channel = kIsWeb ? const MethodChannel('mixpanel_flutter') : const MethodChannel( - 'mixpanel_flutter', - StandardMethodCodec(MixpanelMessageCodec()), - ); + 'mixpanel_flutter', StandardMethodCodec(MixpanelMessageCodec())); final String _token; @@ -1284,9 +1156,7 @@ class FeatureFlags { /// Returns true if flags are loaded and ready, false otherwise. Future areFlagsReady() async { final result = await _channel.invokeMethod( - 'areFlagsReady', - {'token': _token}, - ); + 'areFlagsReady', {'token': _token}); return result ?? false; } @@ -1297,24 +1167,17 @@ class FeatureFlags { /// /// Returns the MixpanelFlagVariant for the flag, or the fallback if not available. Future getVariant( - String flagName, - MixpanelFlagVariant fallback, - ) async { + String flagName, MixpanelFlagVariant fallback) async { if (!_MixpanelHelper.isValidString(flagName)) { - developer.log( - '`getVariant` failed: flagName cannot be blank', - name: 'Mixpanel', - ); + developer.log('`getVariant` failed: flagName cannot be blank', + name: 'Mixpanel'); return fallback; } - final result = await _channel.invokeMethod( - 'getVariant', - { - 'token': _token, - 'flagName': flagName, - 'fallback': fallback.toMap(), - }, - ); + final result = await _channel.invokeMethod('getVariant', { + 'token': _token, + 'flagName': flagName, + 'fallback': fallback.toMap(), + }); if (result != null) { return MixpanelFlagVariant.fromMap(result); } @@ -1327,25 +1190,17 @@ class FeatureFlags { /// * [fallbackValue] A fallback value to use if the flag is not found or not ready /// /// Returns the value of the flag, or the fallback value if not available. - Future getVariantValue( - String flagName, - dynamic fallbackValue, - ) async { + Future getVariantValue(String flagName, dynamic fallbackValue) async { if (!_MixpanelHelper.isValidString(flagName)) { - developer.log( - '`getVariantValue` failed: flagName cannot be blank', - name: 'Mixpanel', - ); + developer.log('`getVariantValue` failed: flagName cannot be blank', + name: 'Mixpanel'); return fallbackValue; } - final result = await _channel.invokeMethod( - 'getVariantValue', - { - 'token': _token, - 'flagName': flagName, - 'fallbackValue': _MixpanelHelper.ensureSerializableValue(fallbackValue), - }, - ); + final result = await _channel.invokeMethod('getVariantValue', { + 'token': _token, + 'flagName': flagName, + 'fallbackValue': _MixpanelHelper.ensureSerializableValue(fallbackValue), + }); return result ?? fallbackValue; } @@ -1361,20 +1216,15 @@ class FeatureFlags { /// Returns true if the flag is enabled, the fallback value otherwise. Future isEnabled(String flagName, bool fallbackValue) async { if (!_MixpanelHelper.isValidString(flagName)) { - developer.log( - '`isEnabled` failed: flagName cannot be blank', - name: 'Mixpanel', - ); + developer.log('`isEnabled` failed: flagName cannot be blank', + name: 'Mixpanel'); return fallbackValue; } - final result = await _channel.invokeMethod( - 'isEnabled', - { - 'token': _token, - 'flagName': flagName, - 'fallbackValue': fallbackValue, - }, - ); + final result = await _channel.invokeMethod('isEnabled', { + 'token': _token, + 'flagName': flagName, + 'fallbackValue': fallbackValue, + }); return result ?? fallbackValue; } @@ -1388,10 +1238,8 @@ class FeatureFlags { /// After setting the new context, the SDK automatically re-fetches flags /// from Mixpanel servers. The returned [Future] completes when the /// re-fetch is done. - Future updateContext( - Map context, { - Map? options, - }) async { + Future updateContext(Map context, + {Map? options}) async { await _channel.invokeMethod('updateFlagsContext', { 'token': _token, 'context': _MixpanelHelper.ensureSerializableProperties(context), @@ -1409,9 +1257,8 @@ class FeatureFlags { /// that fail silently, `loadFlags` propagates errors so developers can /// implement kill-switch scenarios and respond to flag loading failures. Future loadFlags() async { - await _channel.invokeMethod('loadFlags', { - 'token': _token, - }); + await _channel.invokeMethod( + 'loadFlags', {'token': _token}); } /// Asynchronously retrieves all loaded feature flag variants. @@ -1424,9 +1271,7 @@ class FeatureFlags { /// `MIXPANEL_UNINITIALIZED` if called before [Mixpanel.init]. Future> getAllVariants() async { final result = await _channel.invokeMethod( - 'getAllVariants', - {'token': _token}, - ); + 'getAllVariants', {'token': _token}); final variants = {}; if (result == null) return variants; result.forEach((key, value) { @@ -1465,9 +1310,7 @@ class _MixpanelHelper { } /// Converts properties map for web platform - static Map? ensureSerializableProperties( - Map? properties, - ) { + static Map? ensureSerializableProperties(Map? properties) { if (!kIsWeb || properties == null) { return properties; } From 7306d59450c75836d17aa44e2e63a7cf42aa1429 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Fri, 29 May 2026 15:49:13 -0400 Subject: [PATCH 06/17] test(jsonlogic): fail loudly on malformed fixture entries Previously, any entry in tests.json that wasn't a section-marker string or a [rule, data, expected] triple was silently skipped, masking fixture corruption and silently reducing coverage. Throw with the offending index and value instead. --- .../test/jsonlogic/json_logic_test.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_test.dart b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_test.dart index 54c66778..6e32b430 100644 --- a/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_test.dart +++ b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_test.dart @@ -12,7 +12,8 @@ void main() { final entries = jsonDecode(raw) as List; var currentSection = 'tests'; - for (final entry in entries) { + for (var i = 0; i < entries.length; i++) { + final entry = entries[i]; if (entry is String) { final trimmed = entry.replaceFirst(RegExp(r'^#\s*'), '').trim(); if (trimmed.isNotEmpty && !trimmed.split('').every((c) => c == '=')) { @@ -20,7 +21,12 @@ void main() { } continue; } - if (entry is! List || entry.length < 3) continue; + if (entry is! List || entry.length < 3) { + throw StateError( + 'Malformed fixture entry at index $i in $fixturePath: ' + 'expected a [rule, data, expected] triple, got ${jsonEncode(entry)}', + ); + } final rule = entry[0]; final data = entry[1]; From 4193db799db70bcbed8faece7d2d5c126009dbe0 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Fri, 29 May 2026 16:16:17 -0400 Subject: [PATCH 07/17] fix(android): bump Kotlin to 2.1.0 to consume mixpanel-android-common MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mixpanel-android-common:1.0.1 ships with Kotlin 2.0 metadata, which the previous 1.9.0 toolchain can't parse — CI was failing with "Class 'MixpanelEventBridge' was compiled with an incompatible version of Kotlin. The binary version of its metadata is 2.0.0, expected version is 1.8.0." Picking 2.1.0 also satisfies Flutter's upcoming minimum-Kotlin warning. --- packages/mixpanel_flutter/android/build.gradle | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/mixpanel_flutter/android/build.gradle b/packages/mixpanel_flutter/android/build.gradle index b960c427..22d4cede 100644 --- a/packages/mixpanel_flutter/android/build.gradle +++ b/packages/mixpanel_flutter/android/build.gradle @@ -2,7 +2,10 @@ group 'com.mixpanel.mixpanel_flutter' version '1.0' buildscript { - ext.kotlin_version = '1.9.0' + // Must be >= 2.0 to consume mixpanel-android-common:1.0.1, which + // is published with Kotlin 2.0 metadata. 2.1.0 also clears Flutter's + // upcoming minimum-Kotlin-version check. + ext.kotlin_version = '2.1.0' repositories { google() mavenCentral() From af6d8c41ac24892879f0e9a2ec2e22f8f1378efd Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Fri, 29 May 2026 16:19:17 -0400 Subject: [PATCH 08/17] fix(android): use Kotlin 2.0.0 instead of 2.1.0 Minimum version needed to read mixpanel-android-common:1.0.1's Kotlin 2.0 metadata. Flutter's upcoming "at least 2.1.0" recommendation can be addressed separately alongside the example app's Kotlin bump. --- packages/mixpanel_flutter/android/build.gradle | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/mixpanel_flutter/android/build.gradle b/packages/mixpanel_flutter/android/build.gradle index 22d4cede..041d4b60 100644 --- a/packages/mixpanel_flutter/android/build.gradle +++ b/packages/mixpanel_flutter/android/build.gradle @@ -3,9 +3,8 @@ version '1.0' buildscript { // Must be >= 2.0 to consume mixpanel-android-common:1.0.1, which - // is published with Kotlin 2.0 metadata. 2.1.0 also clears Flutter's - // upcoming minimum-Kotlin-version check. - ext.kotlin_version = '2.1.0' + // is published with Kotlin 2.0 metadata. + ext.kotlin_version = '2.0.0' repositories { google() mavenCentral() From 6d205006142c744e0934c9244d98cf57c56ada68 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Fri, 29 May 2026 16:34:17 -0400 Subject: [PATCH 09/17] fix(android): bump example app's Kotlin to 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example app's root buildscript declares the KGP that wins on the shared Gradle classpath, so its version governs how every module's Kotlin code is compiled — including the plugin's. With the example app still on 1.9.22, our plugin's Kotlin code was compiled by a 1.x toolchain that can't read mixpanel-android-common's 2.0 metadata, even though the plugin's own buildscript declares 2.0.0. --- packages/mixpanel_flutter/example/android/build.gradle | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/mixpanel_flutter/example/android/build.gradle b/packages/mixpanel_flutter/example/android/build.gradle index 802640d0..c837306e 100644 --- a/packages/mixpanel_flutter/example/android/build.gradle +++ b/packages/mixpanel_flutter/example/android/build.gradle @@ -1,5 +1,9 @@ buildscript { - ext.kotlin_version = '1.9.22' + // Must be >= 2.0 so the plugin can compile against + // mixpanel-android-common:1.0.1 (Kotlin 2.0 metadata). The example + // app's KGP wins on the shared build classpath, so it must be at + // least as new as the plugin requires. + ext.kotlin_version = '2.0.0' repositories { google() mavenCentral() From b869ae5d888f42f3584c619d6c4b7506f11c35f0 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 1 Jun 2026 11:42:17 -0400 Subject: [PATCH 10/17] fix(android): skip Kotlin metadata version check for plugin compile Even after bumping both the plugin's and example app's declared Kotlin to 2.0.0, Flutter's gradle integration kept handing the plugin module a Kotlin 1.9.x compiler, which refuses to read mixpanel-android-common's 2.0 metadata. The .class files are JVM-forward-compatible (the consumed APIs are just a Flow and a data class), so adding -Xskip-metadata-version-check to the plugin's kotlinOptions unblocks compilation. This is the workaround the Kotlin compiler itself suggests in the error message. --- packages/mixpanel_flutter/android/build.gradle | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/mixpanel_flutter/android/build.gradle b/packages/mixpanel_flutter/android/build.gradle index 041d4b60..ab761f22 100644 --- a/packages/mixpanel_flutter/android/build.gradle +++ b/packages/mixpanel_flutter/android/build.gradle @@ -47,6 +47,15 @@ android { kotlinOptions { jvmTarget = '17' + // mixpanel-android-common:1.0.1 is published with Kotlin 2.0 + // metadata. Flutter's gradle integration picks a KGP version we + // can't reliably control from this module's buildscript, so a + // Kotlin 1.9.x compiler often ends up compiling this plugin and + // chokes on the newer metadata. The bytecode itself is + // forward-compatible (the consumed APIs are just a Flow and a + // data class), so suppressing the metadata version check is the + // canonical workaround. + freeCompilerArgs += ['-Xskip-metadata-version-check'] } sourceSets { From da196bed1cdb8a37cfcb0c261715995756c62fb0 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 1 Jun 2026 11:58:09 -0400 Subject: [PATCH 11/17] revert(android): undo unhelpful Kotlin version bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version bumps in this branch's earlier commits (4193db7, af6d8c4, 6d20500) did not actually fix the CI failure — every iteration kept producing the same "expected version is 1.8.0" metadata error, because Flutter's plugin-loader picks the KGP for plugin subprojects itself and ignores what each plugin's buildscript declares. The metadata error is fully handled by -Xskip-metadata-version-check in the plugin's kotlinOptions (commit b869ae5), which ships inside our plugin and absorbs the mismatch for every consumer transparently. Reverting these bumps keeps the example app representative of a typical customer setup so we don't accidentally signal that consumers need to bump their own Kotlin to use the SDK. --- packages/mixpanel_flutter/android/build.gradle | 4 +--- packages/mixpanel_flutter/example/android/build.gradle | 6 +----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/mixpanel_flutter/android/build.gradle b/packages/mixpanel_flutter/android/build.gradle index ab761f22..c298dd2c 100644 --- a/packages/mixpanel_flutter/android/build.gradle +++ b/packages/mixpanel_flutter/android/build.gradle @@ -2,9 +2,7 @@ group 'com.mixpanel.mixpanel_flutter' version '1.0' buildscript { - // Must be >= 2.0 to consume mixpanel-android-common:1.0.1, which - // is published with Kotlin 2.0 metadata. - ext.kotlin_version = '2.0.0' + ext.kotlin_version = '1.9.0' repositories { google() mavenCentral() diff --git a/packages/mixpanel_flutter/example/android/build.gradle b/packages/mixpanel_flutter/example/android/build.gradle index c837306e..802640d0 100644 --- a/packages/mixpanel_flutter/example/android/build.gradle +++ b/packages/mixpanel_flutter/example/android/build.gradle @@ -1,9 +1,5 @@ buildscript { - // Must be >= 2.0 so the plugin can compile against - // mixpanel-android-common:1.0.1 (Kotlin 2.0 metadata). The example - // app's KGP wins on the shared build classpath, so it must be at - // least as new as the plugin requires. - ext.kotlin_version = '2.0.0' + ext.kotlin_version = '1.9.22' repositories { google() mavenCentral() From 581a14da1f85e50bbf5192c570f9b2fcc098ed03 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 3 Jun 2026 16:01:07 -0400 Subject: [PATCH 12/17] optimization --- .../mixpanel_flutter/EventBridgeSubscriber.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt index 26b4ebfb..f9ff9435 100644 --- a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt +++ b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject @@ -25,7 +26,11 @@ import org.json.JSONObject */ object EventBridgeSubscriber { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + // Collect on Default so the per-event JSONObject → Map conversion + // (which can be expensive for fat property payloads) runs off the main + // thread; only the MethodChannel dispatch itself, which requires the + // platform thread, is hopped back to Main. + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var job: Job? = null @JvmStatic @@ -34,13 +39,13 @@ object EventBridgeSubscriber { job = scope.launch { MixpanelEventBridge.events().collect { event -> val properties = event.properties?.let { safelyConvert(it) } - channel.invokeMethod( - "onMixpanelEvent", - mapOf( - "eventName" to event.eventName, - "properties" to properties, - ) + val args = mapOf( + "eventName" to event.eventName, + "properties" to properties, ) + withContext(Dispatchers.Main) { + channel.invokeMethod("onMixpanelEvent", args) + } } } } From 286723cb0485413827b8413e84696f51b4949a2b Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Wed, 3 Jun 2026 16:16:48 -0400 Subject: [PATCH 13/17] lazy event bridge --- .../mixpanel_flutter/EventBridgeSubscriber.kt | 11 ++-- .../lib/mixpanel_flutter.dart | 27 ++++---- .../lib/src/event_bridge.dart | 46 +++++++++++--- .../test/event_bridge_test.dart | 61 +++++++++++++++++++ 4 files changed, 120 insertions(+), 25 deletions(-) diff --git a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt index f9ff9435..e460a7fd 100644 --- a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt +++ b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt @@ -19,10 +19,13 @@ import org.json.JSONObject * `SharedFlow`) and forwards each event to the Dart side via the existing * Flutter MethodChannel. * - * The Java plugin calls [start] from `onAttachedToEngine` and [stop] from - * `onDetachedFromEngine`. This object is a singleton because the native - * SharedFlow itself is a singleton — we never want more than one active - * subscription per process. + * Lifecycle is driven from Dart: [start] runs when the plugin receives a + * `startEventBridge` MethodChannel call (issued the first time a Dart + * consumer subscribes to `MixpanelEventBridge.events`), and [stop] runs + * on `stopEventBridge` (last cancel) and on `onDetachedFromEngine`. + * + * This object is a singleton because the native SharedFlow itself is a + * singleton — we never want more than one active collector per process. */ object EventBridgeSubscriber { diff --git a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart index 62b9eb54..37c9464d 100644 --- a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart +++ b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart @@ -346,16 +346,12 @@ class Mixpanel { }; // Wires the reverse path from the native MixpanelEventBridge into the - // Dart-side [MixpanelEventBridge] the first time `Mixpanel` is touched. - // The MethodCallHandler is installed eagerly so a native event can never - // race ahead of the handler, but the native subscription itself is only - // started when a Dart listener attaches — see [setLifecycleCallbacks]. - // Web is skipped — the JS SDK has no EventBridge. - // ignore: unused_field - static final bool _eventBridgeWired = _wireEventBridge(); - - static bool _wireEventBridge() { - if (kIsWeb) return false; + // Dart-side [MixpanelEventBridge]. Runs only when a consumer actually + // reads [MixpanelEventBridge.events] — `init()` registers this as a + // one-shot hook via [MixpanelEventBridge.setSourceWiringHook], so apps + // that never subscribe never install the MethodCallHandler and never + // issue start/stopEventBridge over the channel. + static void _wireEventBridge() { _channel.setMethodCallHandler((MethodCall call) async { if (call.method == 'onMixpanelEvent') { final args = (call.arguments as Map?)?.cast(); @@ -375,7 +371,6 @@ class Mixpanel { onActivate: () => _channel.invokeMethod('startEventBridge'), onDeactivate: () => _channel.invokeMethod('stopEventBridge'), ); - return true; } final String _token; @@ -405,9 +400,13 @@ class Mixpanel { Map? superProperties, Map? config, FeatureFlagsConfig? featureFlags}) async { - // Force lazy initialization of the reverse-direction MethodCallHandler - // so any native events tracked after this point reach Dart subscribers. - _eventBridgeWired; + // Defer the reverse-channel wiring until something actually reads + // MixpanelEventBridge.events. Apps that never subscribe pay only the + // stored function reference — no MethodCallHandler, no native subscribe. + // Web is skipped — the JS SDK has no EventBridge. + if (!kIsWeb) { + MixpanelEventBridge.setSourceWiringHook(_wireEventBridge); + } var allProperties = {'token': token}; allProperties['optOutTrackingDefault'] = optOutTrackingDefault; allProperties['trackAutomaticEvents'] = trackAutomaticEvents; diff --git a/packages/mixpanel_flutter_common/lib/src/event_bridge.dart b/packages/mixpanel_flutter_common/lib/src/event_bridge.dart index 0f29d74c..d533301c 100644 --- a/packages/mixpanel_flutter_common/lib/src/event_bridge.dart +++ b/packages/mixpanel_flutter_common/lib/src/event_bridge.dart @@ -12,13 +12,19 @@ import 'mixpanel_event.dart'; /// bridge and forward each event into [notifyListeners]. Any number of /// Dart consumers (session replay, custom triggers) subscribe to [events]. /// +/// ## Lazy wiring +/// `mixpanel_flutter` registers a one-shot wiring hook via +/// [setSourceWiringHook] during `init()`. The hook fires the first time +/// anything reads [events] and installs the MethodChannel handler plus +/// lifecycle callbacks. Apps that never consume events pay only for one +/// stored function reference — no handler is installed and no native +/// subscription is ever started. +/// /// ## Lazy native subscription -/// The native bridge subscription is only activated while at least one Dart -/// listener is attached. `mixpanel_flutter` registers activation/deactivation -/// hooks via [setLifecycleCallbacks] in its initializer; the first listener -/// triggers `onActivate` (which starts the native subscription), and the -/// last cancel triggers `onDeactivate` (which stops it). This keeps the -/// MethodChannel quiet for apps that never consume events. +/// Once the source is wired, the native bridge subscription itself is +/// only activated while at least one Dart listener is attached. The first +/// listener triggers `onActivate` (which starts the native subscription), +/// and the last cancel triggers `onDeactivate` (which stops it). /// /// ## Late subscribers /// The stream does not buffer or replay. Events emitted before a listener @@ -34,6 +40,7 @@ class MixpanelEventBridge { static void Function()? _onActivate; static void Function()? _onDeactivate; + static void Function()? _ensureSourceWired; static final StreamController _controller = StreamController.broadcast( @@ -45,7 +52,17 @@ class MixpanelEventBridge { /// /// Returns a broadcast [Stream]; multiple listeners are supported. Each /// listener sees every event from the moment it subscribes. - static Stream get events => _controller.stream; + static Stream get events { + // Fire the wiring hook at most once. Cleared before invocation so the + // hook can't re-enter itself via `events` from inside `mixpanel_flutter`'s + // setup path. + final hook = _ensureSourceWired; + if (hook != null) { + _ensureSourceWired = null; + hook(); + } + return _controller.stream; + } /// Internal entry point — invoked by `mixpanel_flutter`'s plugin after /// the native SDK has tracked and decorated an event. @@ -78,4 +95,19 @@ class MixpanelEventBridge { _onActivate = onActivate; _onDeactivate = onDeactivate; } + + /// Registers a one-shot hook fired the first time [events] is read. + /// + /// `mixpanel_flutter` uses this to defer installing its MethodChannel + /// handler (and registering [setLifecycleCallbacks]) until a Dart + /// consumer actually asks for the stream. The hook is single-shot — + /// once consumed it is cleared, so the registered setup runs at most + /// once per process unless re-registered. + /// + /// Pass `null` (or no argument) to clear an existing hook. + /// + /// Application code should never call this directly. + static void setSourceWiringHook([void Function()? hook]) { + _ensureSourceWired = hook; + } } diff --git a/packages/mixpanel_flutter_common/test/event_bridge_test.dart b/packages/mixpanel_flutter_common/test/event_bridge_test.dart index 2dd925d6..66836d1b 100644 --- a/packages/mixpanel_flutter_common/test/event_bridge_test.dart +++ b/packages/mixpanel_flutter_common/test/event_bridge_test.dart @@ -186,5 +186,66 @@ void main() { expect(errors, hasLength(1)); expect(errors.first, isA()); }); + + group('source wiring hook', () { + tearDown(() { + MixpanelEventBridge.setSourceWiringHook(); + }); + + test('fires the first time events is read', () { + var calls = 0; + MixpanelEventBridge.setSourceWiringHook(() => calls++); + + // Access alone (no listener) is enough — wiring needs to be in + // place before .listen() triggers onActivate. + // ignore: unused_local_variable + final _ = MixpanelEventBridge.events; + expect(calls, 1); + }); + + test('does not fire on subsequent reads of events', () { + var calls = 0; + MixpanelEventBridge.setSourceWiringHook(() => calls++); + + MixpanelEventBridge.events; + MixpanelEventBridge.events; + MixpanelEventBridge.events; + expect(calls, 1); + }); + + test('does not fire when events is never read', () { + var calls = 0; + MixpanelEventBridge.setSourceWiringHook(() => calls++); + expect(calls, 0); + }); + + test('re-registering after consumption fires again on next read', () { + var calls = 0; + MixpanelEventBridge.setSourceWiringHook(() => calls++); + MixpanelEventBridge.events; // consumes the first hook + MixpanelEventBridge.setSourceWiringHook(() => calls++); + MixpanelEventBridge.events; // consumes the second hook + expect(calls, 2); + }); + + test('hook runs before listeners observe onActivate', () async { + // The wiring hook is `mixpanel_flutter`'s opportunity to install + // its lifecycle callbacks. If onActivate fires before the hook + // runs, the native side never gets a startEventBridge. + final order = []; + MixpanelEventBridge.setSourceWiringHook(() { + order.add('hook'); + MixpanelEventBridge.setLifecycleCallbacks( + onActivate: () => order.add('activate'), + ); + }); + + final sub = MixpanelEventBridge.events.listen((_) {}); + expect(order, ['hook', 'activate']); + + await sub.cancel(); + MixpanelEventBridge.setLifecycleCallbacks(); + }); + }); }); } From dd59d1f68b9c175508dff49eeecbe6b8482a2275 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 4 Jun 2026 09:42:05 -0400 Subject: [PATCH 14/17] pr updates --- .../mixpanel_flutter/EventBridgeSubscriber.kt | 11 ++++++---- .../lib/mixpanel_flutter.dart | 21 ++++++++++++++++--- packages/mixpanel_flutter/pubspec.yaml | 5 +++++ .../Classes/SwiftMixpanelFlutterPlugin.swift | 10 +++++++++ .../lib/src/event_bridge.dart | 5 +++++ .../src/jsonlogic/json_logic_evaluator.dart | 5 +++++ packages/mixpanel_flutter_common/pubspec.lock | 2 +- packages/mixpanel_flutter_common/pubspec.yaml | 3 +++ .../test/event_bridge_test.dart | 10 ++++++++- 9 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt index e460a7fd..7d2b5942 100644 --- a/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt +++ b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import kotlinx.coroutines.withContext import org.json.JSONException import org.json.JSONObject @@ -31,8 +30,12 @@ object EventBridgeSubscriber { // Collect on Default so the per-event JSONObject → Map conversion // (which can be expensive for fat property payloads) runs off the main - // thread; only the MethodChannel dispatch itself, which requires the - // platform thread, is hopped back to Main. + // thread. The MethodChannel dispatch itself, which requires the + // platform thread, is fire-and-forget via `launch(Dispatchers.Main)` + // — using `withContext` here would suspend the collector and + // backpressure into the native SDK's SharedFlow emit (or drop events, + // depending on its overflow policy) whenever the main thread is busy. + // Main dispatcher is FIFO so per-event ordering is preserved. private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var job: Job? = null @@ -46,7 +49,7 @@ object EventBridgeSubscriber { "eventName" to event.eventName, "properties" to properties, ) - withContext(Dispatchers.Main) { + launch(Dispatchers.Main) { channel.invokeMethod("onMixpanelEvent", args) } } diff --git a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart index 37c9464d..86246a8c 100644 --- a/packages/mixpanel_flutter/lib/mixpanel_flutter.dart +++ b/packages/mixpanel_flutter/lib/mixpanel_flutter.dart @@ -359,17 +359,31 @@ class Mixpanel { final properties = (args?['properties'] as Map?)?.cast(); if (eventName != null) { + // mixpanel_flutter is the privileged producer for this bridge — + // acknowledged use of the @internal API on the common package. + // ignore: invalid_use_of_internal_member MixpanelEventBridge.notifyListeners( eventName: eventName, properties: properties, ); } + return null; } - return null; + // Surface unknown inbound methods loudly rather than silently + // returning null — protects future native→Dart push features added + // on this same shared channel from being swallowed here. + throw MissingPluginException( + 'No handler for inbound method ${call.method} on mixpanel_flutter channel', + ); }); + // ignore: invalid_use_of_internal_member MixpanelEventBridge.setLifecycleCallbacks( - onActivate: () => _channel.invokeMethod('startEventBridge'), - onDeactivate: () => _channel.invokeMethod('stopEventBridge'), + // Swallow channel errors (e.g. MissingPluginException during engine + // teardown) — the activate/deactivate signal is best-effort. + onActivate: () => + _channel.invokeMethod('startEventBridge').catchError((_) {}), + onDeactivate: () => + _channel.invokeMethod('stopEventBridge').catchError((_) {}), ); } @@ -405,6 +419,7 @@ class Mixpanel { // stored function reference — no MethodCallHandler, no native subscribe. // Web is skipped — the JS SDK has no EventBridge. if (!kIsWeb) { + // ignore: invalid_use_of_internal_member MixpanelEventBridge.setSourceWiringHook(_wireEventBridge); } var allProperties = {'token': token}; diff --git a/packages/mixpanel_flutter/pubspec.yaml b/packages/mixpanel_flutter/pubspec.yaml index 0a6ee4f1..b58dd38d 100644 --- a/packages/mixpanel_flutter/pubspec.yaml +++ b/packages/mixpanel_flutter/pubspec.yaml @@ -16,6 +16,11 @@ dependencies: flutter_web_plugins: sdk: flutter mixpanel_flutter_common: + # Both `version` and `path` are required so local dev resolves via path + # while `flutter pub publish` resolves via the published version on + # pub.dev (publish rejects path-only deps). Keep these in lockstep with + # ../mixpanel_flutter_common/pubspec.yaml. + version: ^0.1.0 path: ../mixpanel_flutter_common dev_dependencies: diff --git a/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift b/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift index 7debb18a..748020e5 100644 --- a/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift +++ b/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift @@ -49,6 +49,16 @@ public class SwiftMixpanelFlutterPlugin: NSObject, FlutterPlugin { eventBridgeTask?.cancel() } + // FlutterPlugin lifecycle hook — invoked when the engine releases the + // plugin. Tears down the EventBridge task promptly instead of waiting + // for ARC to deallocate the plugin instance, which mirrors Android's + // `onDetachedFromEngine` cleanup. + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + eventBridgeTask?.cancel() + eventBridgeTask = nil + channel = nil + } + private func handleStartEventBridge(_ result: @escaping FlutterResult) { guard eventBridgeTask == nil, let channel = channel else { result(nil) diff --git a/packages/mixpanel_flutter_common/lib/src/event_bridge.dart b/packages/mixpanel_flutter_common/lib/src/event_bridge.dart index d533301c..445c7bb9 100644 --- a/packages/mixpanel_flutter_common/lib/src/event_bridge.dart +++ b/packages/mixpanel_flutter_common/lib/src/event_bridge.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:meta/meta.dart'; + import 'mixpanel_event.dart'; /// Process-wide bridge for tracked Mixpanel events. @@ -70,6 +72,7 @@ class MixpanelEventBridge { /// Application code should never call this directly. It is left public /// (rather than library-private) so the `mixpanel_flutter` package can /// reach it without circular imports. + @internal static void notifyListeners({ required String eventName, Map? properties, @@ -88,6 +91,7 @@ class MixpanelEventBridge { /// Dart consumer cares about events. /// /// Application code should never call this directly. + @internal static void setLifecycleCallbacks({ void Function()? onActivate, void Function()? onDeactivate, @@ -107,6 +111,7 @@ class MixpanelEventBridge { /// Pass `null` (or no argument) to clear an existing hook. /// /// Application code should never call this directly. + @internal static void setSourceWiringHook([void Function()? hook]) { _ensureSourceWired = hook; } diff --git a/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart index 53ef7b54..84c3c60b 100644 --- a/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart +++ b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart @@ -114,6 +114,11 @@ class JsonLogicEvaluator { } if (a is num && b is num) { + // Compare ints directly so 64-bit values above 2^53 don't collapse + // to the same double mantissa (e.g. transaction/session IDs). + // Mixed int+double still coerces, matching JS-style `===` numeric + // semantics where 1 === 1.0. + if (a is int && b is int) return a == b; return a.toDouble() == b.toDouble(); } diff --git a/packages/mixpanel_flutter_common/pubspec.lock b/packages/mixpanel_flutter_common/pubspec.lock index 5fcbcbf5..8e1df9cc 100644 --- a/packages/mixpanel_flutter_common/pubspec.lock +++ b/packages/mixpanel_flutter_common/pubspec.lock @@ -154,7 +154,7 @@ packages: source: hosted version: "0.12.20" meta: - dependency: transitive + dependency: "direct main" description: name: meta sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739 diff --git a/packages/mixpanel_flutter_common/pubspec.yaml b/packages/mixpanel_flutter_common/pubspec.yaml index 504eb548..d57c0309 100644 --- a/packages/mixpanel_flutter_common/pubspec.yaml +++ b/packages/mixpanel_flutter_common/pubspec.yaml @@ -12,6 +12,9 @@ environment: # mixpanel_flutter consumers to bump their Dart version. sdk: '>=2.12.0 <4.0.0' +dependencies: + meta: ^1.8.0 + dev_dependencies: test: ^1.24.0 lints: ^4.0.0 diff --git a/packages/mixpanel_flutter_common/test/event_bridge_test.dart b/packages/mixpanel_flutter_common/test/event_bridge_test.dart index 66836d1b..a513fcf5 100644 --- a/packages/mixpanel_flutter_common/test/event_bridge_test.dart +++ b/packages/mixpanel_flutter_common/test/event_bridge_test.dart @@ -189,7 +189,14 @@ void main() { group('source wiring hook', () { tearDown(() { + // Reset both the wiring hook AND lifecycle callbacks — the + // `hook runs before listeners observe onActivate` test installs + // a lifecycle callback inside the hook, and if its assertion + // fails before the inline reset, the leaked closure would bleed + // into subsequent tests that subscribe through the singleton + // controller. MixpanelEventBridge.setSourceWiringHook(); + MixpanelEventBridge.setLifecycleCallbacks(); }); test('fires the first time events is read', () { @@ -244,7 +251,8 @@ void main() { expect(order, ['hook', 'activate']); await sub.cancel(); - MixpanelEventBridge.setLifecycleCallbacks(); + // Lifecycle callbacks are also reset in the group tearDown — no + // need to reset inline here. }); }); }); From 5dedbbfdc12856685294dbc28eb76dcfbd8bb877 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 4 Jun 2026 11:25:03 -0400 Subject: [PATCH 15/17] docs: cite mixpanel-swift-common as the precedent for === int precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The int-vs-int fast path matches mixpanel-swift-common's JSONLogicEvaluator (which tries Int === Int before falling back to Double coercion). mixpanel-android currently collapses everything to double and loses precision above 2^53 — Flutter intentionally diverges from Android here to match the more accurate iOS contract. Co-Authored-By: Claude Opus 4.7 --- .../lib/src/jsonlogic/json_logic_evaluator.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart index 84c3c60b..dd09e068 100644 --- a/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart +++ b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart @@ -115,9 +115,12 @@ class JsonLogicEvaluator { if (a is num && b is num) { // Compare ints directly so 64-bit values above 2^53 don't collapse - // to the same double mantissa (e.g. transaction/session IDs). - // Mixed int+double still coerces, matching JS-style `===` numeric - // semantics where 1 === 1.0. + // to the same double mantissa (transaction/session IDs, ns + // timestamps). Matches mixpanel-swift-common's JSONLogicEvaluator, + // which also tries Int === Int before falling back to Double + // coercion for mixed int+double cases. (mixpanel-android currently + // coerces everything to double and loses this precision — Flutter + // intentionally diverges to match the more accurate iOS behavior.) if (a is int && b is int) return a == b; return a.toDouble() == b.toDouble(); } From 0e4f50c722e157d17679c876559c130563f5e679 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Thu, 4 Jun 2026 11:44:58 -0400 Subject: [PATCH 16/17] test: cover MissingPluginException, lifecycle error swallowing, and int-precision === MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - event_bridge_forwarding_test: replace the misleadingly-named "unknown method names are ignored" test with one that asserts the null reply envelope (Flutter's wire-level signal for MissingPluginException). Add a test that confirms a throwing channel mock around start/stopEventBridge does not leak an uncaught async error into the surrounding zone — guards the .catchError on the lifecycle invokeMethod calls. - json_logic_edge_case_test: cover the int-vs-int strict-equals fast path with four cases (distinct ints above 2^53, matching ints above 2^53, mixed int/double of equal value, matching doubles) so the Flutter-vs-Android precision divergence stays intentional rather than drifting back via a future refactor. Co-Authored-By: Claude Opus 4.7 --- .../test/event_bridge_forwarding_test.dart | 57 +++++++++++++++---- .../jsonlogic/json_logic_edge_case_test.dart | 47 +++++++++++++++ 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart b/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart index ae7422ad..6e30ce3e 100644 --- a/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart +++ b/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mixpanel_flutter/codec/mixpanel_message_codec.dart'; @@ -107,18 +109,30 @@ void main() { await sub.cancel(); }); - test('unknown method names are ignored', () async { - final received = []; - final sub = MixpanelEventBridge.events.listen(received.add); + test( + 'unknown method names raise MissingPluginException to the caller', + () async { + final received = []; + final sub = MixpanelEventBridge.events.listen(received.add); - final bogus = codec.encodeMethodCall(const MethodCall('somethingElse')); - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .handlePlatformMessage('mixpanel_flutter', bogus, (_) {}); - await Future.delayed(Duration.zero); + // Flutter's MethodChannel protocol uses a null reply envelope to + // signal "method not implemented". The Dart handler must propagate + // this for future native→Dart push features added to the shared + // channel — silently swallowing unknown methods (the prior + // behavior) would mask real bugs. + ByteData? reply; + final bogus = codec.encodeMethodCall(const MethodCall('somethingElse')); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage('mixpanel_flutter', bogus, (data) { + reply = data; + }); + await Future.delayed(Duration.zero); - expect(received, isEmpty); - await sub.cancel(); - }); + expect(reply, isNull, reason: 'null reply signals MissingPluginException'); + expect(received, isEmpty); + await sub.cancel(); + }, + ); group('lazy native subscription', () { test('first Dart listener invokes startEventBridge on the channel', @@ -160,5 +174,28 @@ void main() { isNot(contains('startEventBridge')), ); }); + + test('channel errors from start/stopEventBridge do not escape the zone', + () async { + // Engine teardown ordering, missing platform handlers in unit + // tests, etc. can cause invokeMethod to error after onActivate / + // onDeactivate is dispatched. Those signals are best-effort and + // must be swallowed — otherwise an uncaught async error fails the + // surrounding zone (and unrelated tests). + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + throw PlatformException(code: 'TEST_ERROR', message: call.method); + }); + + final errors = []; + await runZonedGuarded(() async { + final sub = MixpanelEventBridge.events.listen((_) {}); + await Future.delayed(Duration.zero); + await sub.cancel(); + await Future.delayed(Duration.zero); + }, (e, _) => errors.add(e)); + + expect(errors, isEmpty); + }); }); } diff --git a/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_edge_case_test.dart b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_edge_case_test.dart index aad4814e..4f64596c 100644 --- a/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_edge_case_test.dart +++ b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_edge_case_test.dart @@ -162,6 +162,53 @@ void main() { ); }); + // Int-precision fast path: two distinct 64-bit ints above 2^53 must + // not be considered equal. mixpanel-android's evaluator collapses to + // double here and loses precision — Flutter follows mixpanel-swift- + // common which preserves int precision. + test('=== returns false for distinct ints above 2^53', () { + // 2^53 + 1 vs 2^53 + 2 — both round to the same double (2^53 + 2). + expect( + evaluate('{"===": [{"var": "a"}, {"var": "b"}]}', { + 'a': 9007199254740993, + 'b': 9007199254740994, + }), + isFalse, + ); + }); + + test('=== returns true for matching ints above 2^53', () { + expect( + evaluate('{"===": [{"var": "a"}, {"var": "b"}]}', { + 'a': 9007199254740993, + 'b': 9007199254740993, + }), + isTrue, + ); + }); + + test('=== returns true for mixed int and double of equal value', () { + // The mixed-type case still goes through Double coercion so + // `1 === 1.0` keeps returning true (JS-style numeric semantics). + expect( + evaluate('{"===": [{"var": "a"}, {"var": "b"}]}', { + 'a': 1, + 'b': 1.0, + }), + isTrue, + ); + }); + + test('=== returns true for matching doubles', () { + expect( + evaluate('{"===": [{"var": "a"}, {"var": "b"}]}', { + 'a': 1.5, + 'b': 1.5, + }), + isTrue, + ); + }); + test('!== returns false for matching numbers', () { expect(evaluate('{"!==": [{"var": "count"}, 1]}', {'count': 1}), isFalse); }); From 36033fe7692c1fea1c3fb2a5c53e52fdf9def4a3 Mon Sep 17 00:00:00 2001 From: Tyler Roach Date: Mon, 8 Jun 2026 09:14:04 -0400 Subject: [PATCH 17/17] event bridge testing --- .../example/android/app/build.gradle | 2 +- .../mixpanel_flutter/example/ios/Podfile.lock | 5 +- .../example/lib/event_bridge.dart | 172 ++++++++++++++++++ .../mixpanel_flutter/example/lib/main.dart | 14 ++ .../example/macos/Podfile.lock | 5 +- .../mixpanel_flutter/example/pubspec.lock | 2 +- .../mixpanel_flutter/example/pubspec.yaml | 3 + 7 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 packages/mixpanel_flutter/example/lib/event_bridge.dart diff --git a/packages/mixpanel_flutter/example/android/app/build.gradle b/packages/mixpanel_flutter/example/android/app/build.gradle index 4d95b24d..fbb7527d 100644 --- a/packages/mixpanel_flutter/example/android/app/build.gradle +++ b/packages/mixpanel_flutter/example/android/app/build.gradle @@ -45,7 +45,7 @@ android { applicationId "com.example.mixpanel_example" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion 21 + minSdkVersion flutter.minSdkVersion targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName diff --git a/packages/mixpanel_flutter/example/ios/Podfile.lock b/packages/mixpanel_flutter/example/ios/Podfile.lock index 38124567..c71a0ac0 100644 --- a/packages/mixpanel_flutter/example/ios/Podfile.lock +++ b/packages/mixpanel_flutter/example/ios/Podfile.lock @@ -10,9 +10,10 @@ PODS: - Mixpanel-swift/Complete (6.4.0): - jsonlogic (~> 1.2.0) - MixpanelSwiftCommon (~> 1.0.0) - - mixpanel_flutter (2.7.0): + - mixpanel_flutter (2.8.0): - Flutter - Mixpanel-swift (= 6.4.0) + - MixpanelSwiftCommon (~> 1.0.0) - MixpanelSwiftCommon (1.0.1) DEPENDENCIES: @@ -37,7 +38,7 @@ SPEC CHECKSUMS: json-enum: 57ad746d2f0d7852796e9aa50267bd84a778222e jsonlogic: 006f892470384401b8ca5b5d8d4cdadb3a0d5c9b Mixpanel-swift: 9eb2ea2d0463970687c984e07040669f787b7a49 - mixpanel_flutter: ab9b5c729fe429cd185832ceac24b11a91ef0da9 + mixpanel_flutter: ee6f4b6940103f1c487d3ecbf351d97941328319 MixpanelSwiftCommon: 6fc461403945422a2e1d0989d712c0db2c26ecdb PODFILE CHECKSUM: a57f30d18f102dd3ce366b1d62a55ecbef2158e5 diff --git a/packages/mixpanel_flutter/example/lib/event_bridge.dart b/packages/mixpanel_flutter/example/lib/event_bridge.dart new file mode 100644 index 00000000..4a0e5999 --- /dev/null +++ b/packages/mixpanel_flutter/example/lib/event_bridge.dart @@ -0,0 +1,172 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:mixpanel_flutter/mixpanel_flutter.dart'; +import 'package:mixpanel_flutter_common/mixpanel_flutter_common.dart'; +import 'package:mixpanel_flutter_example/widget.dart'; + +import 'analytics.dart'; + +/// Manual test harness for the MixpanelEventBridge. +/// +/// Supports any number of independent listeners. Use in combination with +/// the native platform logs (`Mixpanel/EventBridge` tag) to verify that: +/// - The native bridge stays idle until the first subscriber attaches. +/// - Every active listener sees every event (broadcast fan-out). +/// - The native bridge tears down only when the LAST listener cancels. +/// - Tracking events with zero listeners does not forward through the +/// bridge. +class EventBridgeScreen extends StatefulWidget { + const EventBridgeScreen({Key? key}) : super(key: key); + + @override + State createState() => _EventBridgeScreenState(); +} + +class _EventBridgeScreenState extends State { + late final Mixpanel _mixpanel; + final List<_Listener> _listeners = []; + int _nextId = 1; + + @override + void initState() { + super.initState(); + _initMixpanel(); + } + + Future _initMixpanel() async { + _mixpanel = await MixpanelManager.init(); + } + + @override + void dispose() { + for (final l in _listeners) { + l.subscription.cancel(); + } + super.dispose(); + } + + void _addListener() { + final id = _nextId++; + late final _Listener listener; + final subscription = MixpanelEventBridge.events.listen((event) { + setState(() { + listener.count++; + listener.lastEvent = event.eventName; + }); + }); + listener = _Listener(id: id, subscription: subscription); + setState(() => _listeners.add(listener)); + } + + void _cancelListener(_Listener listener) { + listener.subscription.cancel(); + setState(() => _listeners.remove(listener)); + } + + void _cancelAll() { + for (final l in _listeners) { + l.subscription.cancel(); + } + setState(() => _listeners.clear()); + } + + void _track() { + _mixpanel.track('Bridge Test Event', properties: { + 'source': 'EventBridgeScreen', + 'timestamp': DateTime.now().toIso8601String(), + }); + } + + @override + Widget build(BuildContext context) { + final count = _listeners.length; + return Scaffold( + appBar: AppBar( + backgroundColor: const Color(0xff4f44e0), + title: const Text('Event Bridge'), + ), + body: Column( + children: [ + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + count == 0 + ? 'No listeners — native bridge should be idle.' + : '$count listener${count == 1 ? '' : 's'} active — native bridge running.', + style: TextStyle( + fontSize: 14, + color: count == 0 ? Colors.grey[700] : Colors.green[700], + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: MediaQuery.of(context).size.width * 0.65, + child: MixpanelButton( + text: 'Add Listener', + onPressed: _addListener, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: MediaQuery.of(context).size.width * 0.65, + child: MixpanelButton( + text: 'Track Test Event', + onPressed: _track, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: MediaQuery.of(context).size.width * 0.65, + child: MixpanelButton( + text: 'Cancel All Listeners', + onPressed: count == 0 ? () {} : _cancelAll, + ), + ), + const Divider(height: 24), + Expanded( + child: _listeners.isEmpty + ? const Center( + child: Text( + 'No active listeners.', + style: TextStyle(color: Colors.grey), + ), + ) + : ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _listeners.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) { + final l = _listeners[i]; + return ListTile( + dense: true, + title: Text('Listener #${l.id}'), + subtitle: Text( + '${l.count} event${l.count == 1 ? '' : 's'}' + '${l.lastEvent == null ? '' : ' • last: ${l.lastEvent}'}', + ), + trailing: IconButton( + icon: const Icon(Icons.close), + tooltip: 'Cancel this listener', + onPressed: () => _cancelListener(l), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _Listener { + _Listener({required this.id, required this.subscription}); + + final int id; + final StreamSubscription subscription; + int count = 0; + String? lastEvent; +} diff --git a/packages/mixpanel_flutter/example/lib/main.dart b/packages/mixpanel_flutter/example/lib/main.dart index 9795e524..f2230d71 100644 --- a/packages/mixpanel_flutter/example/lib/main.dart +++ b/packages/mixpanel_flutter/example/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:mixpanel_flutter_example/widget.dart'; import 'event.dart'; +import 'event_bridge.dart'; import 'feature_flags.dart'; import 'gdpr.dart'; import 'group.dart'; @@ -32,6 +33,7 @@ class _MyAppState extends State { '/gdpr': (context) => GDPRScreen(), '/group': (context) => GroupScreen(), '/feature_flags': (context) => FeatureFlagsScreen(), + '/event_bridge': (context) => EventBridgeScreen(), }, ); } @@ -108,6 +110,18 @@ class FirstScreen extends StatelessWidget { }, ), ), + SizedBox( + height: 20, + ), + SizedBox( + width: MediaQuery.of(context).size.width * 0.65, + child: MixpanelButton( + text: 'EVENT BRIDGE', + onPressed: () { + Navigator.pushNamed(context, '/event_bridge'); + }, + ), + ), ], )), ); diff --git a/packages/mixpanel_flutter/example/macos/Podfile.lock b/packages/mixpanel_flutter/example/macos/Podfile.lock index 36f1d6f7..dbac4512 100644 --- a/packages/mixpanel_flutter/example/macos/Podfile.lock +++ b/packages/mixpanel_flutter/example/macos/Podfile.lock @@ -10,9 +10,10 @@ PODS: - Mixpanel-swift/Complete (6.4.0): - jsonlogic (~> 1.2.0) - MixpanelSwiftCommon (~> 1.0.0) - - mixpanel_flutter (2.6.2): + - mixpanel_flutter (2.8.0): - FlutterMacOS - Mixpanel-swift (= 6.4.0) + - MixpanelSwiftCommon (~> 1.0.0) - MixpanelSwiftCommon (1.0.1) DEPENDENCIES: @@ -37,7 +38,7 @@ SPEC CHECKSUMS: json-enum: 57ad746d2f0d7852796e9aa50267bd84a778222e jsonlogic: 006f892470384401b8ca5b5d8d4cdadb3a0d5c9b Mixpanel-swift: 9eb2ea2d0463970687c984e07040669f787b7a49 - mixpanel_flutter: 6921df15bfe7eaba0e817ab40b4ce6221c06a956 + mixpanel_flutter: 2f448af61f1a6153e0214aabb1d708bd6f83172e MixpanelSwiftCommon: 6fc461403945422a2e1d0989d712c0db2c26ecdb PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/packages/mixpanel_flutter/example/pubspec.lock b/packages/mixpanel_flutter/example/pubspec.lock index e1e9170e..d872eb87 100644 --- a/packages/mixpanel_flutter/example/pubspec.lock +++ b/packages/mixpanel_flutter/example/pubspec.lock @@ -144,7 +144,7 @@ packages: source: path version: "2.8.0" mixpanel_flutter_common: - dependency: transitive + dependency: "direct main" description: path: "../../mixpanel_flutter_common" relative: true diff --git a/packages/mixpanel_flutter/example/pubspec.yaml b/packages/mixpanel_flutter/example/pubspec.yaml index 76af8653..0f4a5f28 100644 --- a/packages/mixpanel_flutter/example/pubspec.yaml +++ b/packages/mixpanel_flutter/example/pubspec.yaml @@ -13,6 +13,9 @@ dependencies: mixpanel_flutter: path: ../ + mixpanel_flutter_common: + path: ../../mixpanel_flutter_common + cupertino_icons: ^1.0.0 dev_dependencies: