Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2b5736f
WIP: event bridge wiring and mixpanel_flutter_common package
tylerjroach May 21, 2026
53ef6d1
chore(android): move EventBridge subscriber to Dispatchers.IO
tylerjroach May 21, 2026
4a0c020
feat(event-bridge): start native subscription lazily on first listener
tylerjroach May 21, 2026
dabae99
updates
tylerjroach May 29, 2026
05ed1b9
chore(mixpanel_flutter): revert dart format churn in mixpanel_flutter…
tylerjroach May 29, 2026
7306d59
test(jsonlogic): fail loudly on malformed fixture entries
tylerjroach May 29, 2026
4193db7
fix(android): bump Kotlin to 2.1.0 to consume mixpanel-android-common
tylerjroach May 29, 2026
af6d8c4
fix(android): use Kotlin 2.0.0 instead of 2.1.0
tylerjroach May 29, 2026
6d20500
fix(android): bump example app's Kotlin to 2.0.0
tylerjroach May 29, 2026
b869ae5
fix(android): skip Kotlin metadata version check for plugin compile
tylerjroach Jun 1, 2026
da196be
revert(android): undo unhelpful Kotlin version bumps
tylerjroach Jun 1, 2026
5c2ac31
Merge branch 'main' into feat/event-bridge-common-package
tylerjroach Jun 2, 2026
581a14d
optimization
tylerjroach Jun 3, 2026
286723c
lazy event bridge
tylerjroach Jun 3, 2026
dd59d1f
pr updates
tylerjroach Jun 4, 2026
5dedbbf
docs: cite mixpanel-swift-common as the precedent for === int precision
tylerjroach Jun 4, 2026
0e4f50c
test: cover MissingPluginException, lifecycle error swallowing, and i…
tylerjroach Jun 4, 2026
36033fe
event bridge testing
tylerjroach Jun 8, 2026
06efb5b
Merge branch 'main' into feat/event-bridge-common-package
tylerjroach Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion packages/mixpanel_flutter/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ group 'com.mixpanel.mixpanel_flutter'
version '1.0'

buildscript {
ext.kotlin_version = '1.9.0'
repositories {
google()
mavenCentral()
Expand All @@ -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"
}
}

Expand All @@ -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
Expand All @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -790,6 +808,7 @@ private long readPersistenceTtlMillis(Map<String, Object> policyMap) {

@Override
public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) {
EventBridgeSubscriber.stop();
if (channel != null) {
channel.setMethodCallHandler(null);
channel = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Any?>? = 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
}
}
2 changes: 1 addition & 1 deletion packages/mixpanel_flutter/example/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/mixpanel_flutter/example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
172 changes: 172 additions & 0 deletions packages/mixpanel_flutter/example/lib/event_bridge.dart
Original file line number Diff line number Diff line change
@@ -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<EventBridgeScreen> createState() => _EventBridgeScreenState();
}

class _EventBridgeScreenState extends State<EventBridgeScreen> {
late final Mixpanel _mixpanel;
final List<_Listener> _listeners = [];
int _nextId = 1;

@override
void initState() {
super.initState();
_initMixpanel();
}

Future<void> _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<MixpanelEvent> subscription;
int count = 0;
String? lastEvent;
}
Loading
Loading