diff --git a/packages/mixpanel_flutter/android/build.gradle b/packages/mixpanel_flutter/android/build.gradle index 5dc7afad..c298dd2c 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,35 @@ android { targetCompatibility JavaVersion.VERSION_17 } + 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 { + main.kotlin.srcDirs += 'src/main/kotlin' + } + lintOptions { disable 'InvalidPackage' } } dependencies { - // Use the Mixpanel Android SDK 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/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java b/packages/mixpanel_flutter/android/src/main/java/com/mixpanel/mixpanel_flutter/MixpanelFlutterPlugin.java index 08e1a373..b0993a07 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,12 +201,30 @@ 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", @@ -790,6 +808,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..7d2b5942 --- /dev/null +++ b/packages/mixpanel_flutter/android/src/main/kotlin/com/mixpanel/mixpanel_flutter/EventBridgeSubscriber.kt @@ -0,0 +1,73 @@ +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. + * + * 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 { + + // Collect on Default so the per-event JSONObject → Map conversion + // (which can be expensive for fat property payloads) runs off the 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 + + @JvmStatic + fun start(channel: MethodChannel) { + if (job != null) return + job = scope.launch { + MixpanelEventBridge.events().collect { event -> + val properties = event.properties?.let { safelyConvert(it) } + val args = mapOf( + "eventName" to event.eventName, + "properties" to properties, + ) + launch(Dispatchers.Main) { + channel.invokeMethod("onMixpanelEvent", args) + } + } + } + } + + @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/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 eb8c1f1f..d872eb87 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: "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/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: diff --git a/packages/mixpanel_flutter/ios/mixpanel_flutter.podspec b/packages/mixpanel_flutter/ios/mixpanel_flutter.podspec index 6e404be9..6ec712f2 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, '13.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 8cab880d..1677f238 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: @@ -344,6 +345,48 @@ class Mixpanel { 'mp_lib': 'flutter', }; + // Wires the reverse path from the native MixpanelEventBridge into the + // 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(); + final eventName = args?['eventName'] as String?; + 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; + } + // 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( + // 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((_) {}), + ); + } + final String _token; final People _people; final FeatureFlags _featureFlags; @@ -374,6 +417,14 @@ class Mixpanel { Map? config, FeatureFlagsConfig? featureFlags, String? serverURL}) async { + // 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) { + // ignore: invalid_use_of_internal_member + MixpanelEventBridge.setSourceWiringHook(_wireEventBridge); + } var allProperties = {'token': token}; allProperties['optOutTrackingDefault'] = optOutTrackingDefault; allProperties['trackAutomaticEvents'] = trackAutomaticEvents; 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..b58dd38d 100644 --- a/packages/mixpanel_flutter/pubspec.yaml +++ b/packages/mixpanel_flutter/pubspec.yaml @@ -15,6 +15,13 @@ dependencies: sdk: flutter 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: flutter_test: diff --git a/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift b/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift index 13161ae2..430701fb 100644 --- a/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift +++ b/packages/mixpanel_flutter/swift/Classes/SwiftMixpanelFlutterPlugin.swift @@ -4,18 +4,29 @@ 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 - + + // 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) { let readWriter = MixpanelReaderWriter() let codec = FlutterStandardMethodCodec(readerWriter: readWriter) @@ -25,7 +36,53 @@ 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) + + // 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() + } + + // 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) + return + } + if #available(iOS 13.0, macOS 10.15, *) { + eventBridgeTask = Task { + for await event in MixpanelEventBridge.shared.eventStream() { + await MainActor.run { + channel.invokeMethod("onMixpanelEvent", arguments: [ + "eventName": event.eventName, + "properties": event.properties, + ]) + } + } + } + } + result(nil) + } + + private func handleStopEventBridge(_ result: @escaping FlutterResult) { + eventBridgeTask?.cancel() + eventBridgeTask = nil + result(nil) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { @@ -174,6 +231,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 new file mode 100644 index 00000000..6e30ce3e --- /dev/null +++ b/packages/mixpanel_flutter/test/event_bridge_forwarding_test.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +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. Also verifies the lazy lifecycle — +/// `startEventBridge` fires on first subscribe, `stopEventBridge` on last +/// cancel. +void main() { + const channel = MethodChannel( + 'mixpanel_flutter', + StandardMethodCodec(MixpanelMessageCodec()), + ); + const codec = StandardMethodCodec(MixpanelMessageCodec()); + + TestWidgetsFlutterBinding.ensureInitialized(); + + late List outgoingCalls; + + setUp(() async { + 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, (call) async { + outgoingCalls.add(call); + return null; + }); + await Mixpanel.init( + 'test token', + optOutTrackingDefault: false, + trackAutomaticEvents: true, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + Future simulateNativeEvent({ + required String eventName, + Map? properties, + }) async { + final message = codec.encodeMethodCall( + MethodCall('onMixpanelEvent', { + 'eventName': eventName, + 'properties': properties, + }), + ); + // handlePlatformMessage bypasses the mock and hits the real + // MethodCallHandler installed by Mixpanel during init. + 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 raise MissingPluginException to the caller', + () async { + final received = []; + final sub = MixpanelEventBridge.events.listen(received.add); + + // 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(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', + () 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')), + ); + }); + + 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/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..445c7bb9 --- /dev/null +++ b/packages/mixpanel_flutter_common/lib/src/event_bridge.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +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]. +/// +/// ## 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 +/// 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 +/// 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 void Function()? _onActivate; + static void Function()? _onDeactivate; + static void Function()? _ensureSourceWired; + + static final StreamController _controller = + StreamController.broadcast( + onListen: () => _onActivate?.call(), + onCancel: () => _onDeactivate?.call(), + ); + + /// 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 { + // 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. + /// + /// 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, + }) { + _controller.add( + 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. + @internal + static void setLifecycleCallbacks({ + void Function()? onActivate, + void Function()? onDeactivate, + }) { + _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. + @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 new file mode 100644 index 00000000..dd09e068 --- /dev/null +++ b/packages/mixpanel_flutter_common/lib/src/jsonlogic/json_logic_evaluator.dart @@ -0,0 +1,248 @@ +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) { + // Compare ints directly so 64-bit values above 2^53 don't collapse + // 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(); + } + + 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..8e1df9cc --- /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: "direct main" + 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..d57c0309 --- /dev/null +++ b/packages/mixpanel_flutter_common/pubspec.yaml @@ -0,0 +1,20 @@ +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' + +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 new file mode 100644 index 00000000..a513fcf5 --- /dev/null +++ b/packages/mixpanel_flutter_common/test/event_bridge_test.dart @@ -0,0 +1,259 @@ +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(); + }); + + 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 + // 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()); + }); + + 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', () { + 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(); + // Lifecycle callbacks are also reset in the group tearDown — no + // need to reset inline here. + }); + }); + }); +} 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..4f64596c --- /dev/null +++ b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_edge_case_test.dart @@ -0,0 +1,554 @@ +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, + ); + }); + + // 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); + }); + + 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..6e32b430 --- /dev/null +++ b/packages/mixpanel_flutter_common/test/jsonlogic/json_logic_test.dart @@ -0,0 +1,91 @@ +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 (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 == '=')) { + currentSection = trimmed; + } + 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]; + 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" +]