auto_interop
Type-safe Dart bindings for any native package
Consume npm, CocoaPods, SPM, and Gradle packages from Flutter with zero boilerplate. Configure once, generate forever.
Overview
auto_interop is a build_runner-based code generator that lets Flutter developers consume any native package by auto-generating type-safe Dart bindings. You declare which packages you need in a YAML config file, and auto_interop parses their public APIs, creates an intermediate Unified Type Schema (UTS), and emits platform-specific glue code plus clean Dart interfaces.
Unlike manual platform channel wiring, auto_interop handles serialization, error normalization, native object lifecycle, and async dispatch automatically. When the native package updates, regenerate to get updated bindings instantly.
The generator supports dual parsing modes (regex + AST), incremental builds with SHA-256 checksums, and a cloud registry of pre-built type definitions for popular packages.
Architecture
Key Features
Multi-Platform
npm, CocoaPods, SPM, and Gradle — iOS, Android, macOS, and Web from one config.
Dual Parsing
Regex parsers for speed, AST parsers for accuracy. Automatic fallback chain.
Incremental Builds
SHA-256 checksums + dependency graph. Only rebuilds what changed.
Cloud Registry
Pre-built type definitions for popular packages. Instant generation, no parsing needed.
Schema Overrides
Override any parsed schema with custom .uts.json files. Project-level or global.
Glue Routing
Swift files auto-routed to ios/Runner, Kotlin to android/app. Xcode project patched automatically.
Batch Calls
N method calls in 1 round-trip via batchInvoke. Reduces platform channel overhead.
Native Object Lifecycle
Opaque handles with Finalizer safety net. Deterministic or GC-driven cleanup.
Platform Support
| Source | Language | Platform | Installer |
|---|---|---|---|
| npm | TypeScript / JS | Web | npm install |
| CocoaPods | Swift | iOS / macOS | Podfile |
| SPM | Swift | iOS / macOS | Package.swift |
| Gradle | Kotlin | Android | build.gradle |
Getting Started
Prerequisites
- Flutter 3.10+ and Dart 3.0+
- For AST parsing (optional): Node.js (npm), Xcode command-line tools (Swift), Kotlin compiler (Gradle)
1. Install dependencies
# pubspec.yaml
dependencies:
auto_interop: ^0.1.0
dev_dependencies:
auto_interop_generator: ^0.1.0
build_runner: ^2.4.0
2. Configure native packages
# auto_interop.yaml
native_packages:
- source: npm
package: "date-fns"
version: "^3.6.0"
imports:
- "format"
- "addDays"
- source: cocoapods
package: "Alamofire"
version: "~> 5.9"
- source: gradle
package: "com.squareup.okhttp3:okhttp"
version: "4.12.0"
3. Generate bindings
# Using build_runner (recommended for CI/CD)
dart run build_runner build
# Or using the CLI directly
dart run auto_interop_generator:generate
4. Use the generated code
import 'package:auto_interop/auto_interop.dart';
import 'generated/date_fns.dart';
void main() async {
await AutoInteropLifecycle.instance.initialize();
final formatted = await DateFns.instance.format(
DateTime.now(), 'yyyy-MM-dd',
);
print(formatted); // 2024-01-15
}
What gets generated
| Source | Dart Binding | Platform Glue |
|---|---|---|
| npm | lib/generated/date_fns.dart | JS interop (inline) |
| CocoaPods / SPM | lib/generated/alamofire.dart | ios/Runner/AlamofirePlugin.swift |
| Gradle | lib/generated/okhttp.dart | android/app/.../OkhttpPlugin.kt |
Configuration
All configuration lives in auto_interop.yaml at your project root. The file declares which native packages to bind and how to resolve them.
Full annotated format
# auto_interop.yaml
native_packages:
- source: npm # npm | cocoapods | spm | gradle
package: "date-fns" # Package name (npm name / pod name / Maven coordinate)
version: "^3.6.0" # Version constraint
imports: # (Optional) Selective imports — only these APIs
- "format"
- "addDays"
- "differenceInDays"
source_path: "./local/date-fns" # (Optional) Local source directory
source_url: "https://..." # (Optional) Custom download URL
custom_types: # (Optional) Map unrecognized types to Dart
FormatOptions: "Map<String, dynamic>"
- source: cocoapods
package: "Alamofire"
version: "~> 5.9"
- source: gradle
package: "com.squareup.okhttp3:okhttp"
version: "4.12.0"
maven_repositories: # (Optional) Additional Maven repos
- "https://maven.google.com"
- "https://jitpack.io"
# (Optional) Global config
overrides_dir: "auto_interop_overrides" # Directory for .uts.json override files
Config options
| Field | Required | Description |
|---|---|---|
source | Yes | Package source: npm, cocoapods, spm, or gradle |
package | Yes | Package identifier (npm name, pod name, or Maven coordinate) |
version | Yes | Version constraint (semver range, CocoaPods operator, etc.) |
imports | No | Selective import list — only generate bindings for these symbols |
source_path | No | Local directory containing the native source files |
source_url | No | Custom URL to download source files from |
custom_types | No | Map of unrecognized type names to Dart types |
maven_repositories | No | Additional Maven repository URLs for Gradle packages |
overrides_dir | No | Directory for project-level .uts.json schema overrides |
Per-platform examples
npm (Web)
- source: npm
package: "lodash"
version: "^4.17.0"
imports: ["debounce", "throttle", "cloneDeep"]
CocoaPods (iOS/macOS)
- source: cocoapods
package: "SDWebImage"
version: "~> 5.18"
SPM (iOS/macOS)
- source: spm
package: "Alamofire"
version: "5.9.0"
source_url: "https://github.com/Alamofire/Alamofire"
Gradle (Android)
- source: gradle
package: "com.squareup.okhttp3:okhttp"
version: "4.12.0"
maven_repositories:
- "https://maven.google.com"
CLI Reference
The CLI is invoked via dart run auto_interop_generator:generate followed by a command and options.
Commands overview
| Command | Description |
|---|---|
generate | Generate Dart bindings from auto_interop.yaml (default command) |
parse | Parse native source files into a .uts.json type definition |
list | List cached parsed type definitions |
add | Add a native package to auto_interop.yaml |
registry | Manage the cloud definition registry |
setup | Check toolchains and pre-compile AST helpers |
clean | Clear cache (forces rebuild on next generate) |
delete | Clean + remove generated Dart files |
purge | Delete + remove routed Swift/Kotlin glue files |
help | Show help message |
generate
The default command. Reads auto_interop.yaml, resolves schemas, and generates Dart bindings + platform glue.
| Flag | Description |
|---|---|
--config <path> | Path to config file (default: auto_interop.yaml) |
--output <dir> | Output directory (default: lib/generated) |
--only <packages> | Generate only specified packages (comma-separated) |
--force | Force regeneration of all packages (ignore cache) |
--dry-run | Preview what would be generated without writing files |
--verbose | Show detailed output (checksums, cache state, file lists) |
--no-analyze | Skip API surface analysis |
--no-download | Skip auto-downloading native packages |
--no-registry | Skip cloud registry lookup |
--no-ast | Skip AST-based parsing (use regex parsers only) |
--override <files> | Load user-provided .uts.json files (comma-separated) |
parse
Parse native source files into a standalone .uts.json type definition. Useful for inspecting what the generator sees.
| Flag | Description |
|---|---|
--package <name> | Package name (required) |
--version <ver> | Package version (default: 0.0.0) |
--source <type> | Parser to use: cocoapods, spm, gradle, npm (auto-detected) |
--output <path> | Write output to file instead of stdout |
--save | Save result to parse cache |
--no-analyze | Skip API surface analysis |
--no-ast | Skip AST-based parsing |
add
# Usage: add <source> <package> <version>
dart run auto_interop_generator:generate add npm date-fns ^3.0.0
dart run auto_interop_generator:generate add cocoapods Alamofire "~> 5.9"
dart run auto_interop_generator:generate add gradle com.squareup.okhttp3:okhttp 4.12.0
registry
# List available packages in the cloud registry
dart run auto_interop_generator:generate registry list
# Force-fetch a specific definition
dart run auto_interop_generator:generate registry fetch npm/date-fns 3.6.0
clean / delete / purge
# Clear cache for a specific package
dart run auto_interop_generator:generate clean date-fns
# Clear all caches
dart run auto_interop_generator:generate clean --all
# Clean + remove generated Dart files
dart run auto_interop_generator:generate delete --all
# Delete + remove routed Swift/Kotlin glue files
dart run auto_interop_generator:generate purge --all
Common workflows
# Add a package and generate in one go
dart run auto_interop_generator:generate add npm lodash ^4.17.0
dart run auto_interop_generator:generate
# Regenerate only Alamofire with verbose output
dart run auto_interop_generator:generate generate --only Alamofire --force --verbose
# Preview what would change without writing files
dart run auto_interop_generator:generate generate --dry-run
# Parse a local Swift package and inspect the schema
dart run auto_interop_generator:generate parse --package MyLib --source cocoapods --output my_lib.uts.json
# Pre-warm AST helper caches (avoids 30s delay on first generate)
dart run auto_interop_generator:generate setup
Architecture
Generation pipeline
Schema resolution priority
When resolving the type schema for a package, auto_interop checks these sources in order:
- CLI override —
--override my_overrides.uts.json - Project overrides —
<overrides_dir>/<package>.uts.json - Global overrides —
~/.auto_interop/overrides/<source>/<package>.uts.json - Registry cache — locally cached cloud definitions (7-day TTL)
- Registry fetch — download from GitHub-hosted registry
- Source parsing — download + parse the native source code
Package structure
auto_interop/
packages/
auto_interop/ # Runtime library
lib/src/
channel_manager.dart # AutoInteropChannel (method channels)
async_bridge.dart # AutoInteropEventChannel (event channels)
lifecycle.dart # AutoInteropLifecycle (init/dispose)
error_handler.dart # AutoInteropException + ErrorHandler
callback_manager.dart # CallbackManager (Dart→native callbacks)
native_object.dart # NativeObject<T> (opaque handles)
type_converter.dart # TypeConverter (serialization)
auto_interop_generator/ # Code generator
lib/src/
cli/ # CLI runner, Xcode project patcher
config/ # YAML config parser
parsers/ # Regex parsers (npm, Swift, Gradle)
parsers/ast/ # AST parsers (TypeScript, Swift, Kotlin)
schema/ # Unified Type Schema definitions
generators/ # Dart, Swift, Kotlin, JS code generators
resolver/ # Schema resolver, registry client, overrides
cache/ # Build cache, parse cache, checksums
analyzer/ # API surface analyzer, dependency resolver
type_mapping/ # Language-specific type mapping tables
Unified Type Schema
The Unified Type Schema (UTS) is the intermediate representation that sits between parsing and code generation. Every native API — regardless of source language — is normalized into UTS before any Dart or glue code is emitted.
Type kinds
| Kind | Description | Example |
|---|---|---|
primitive | Built-in scalar types | int, String, bool, double |
object | Class/struct instance | FormatOptions, Request |
list | Ordered collection | List<String> |
map | Key-value collection | Map<String, dynamic> |
callback | Function type (Dart→native) | void Function(String) |
stream | Async event sequence | Stream<int> |
future | Single async result | Future<String> |
nativeObject | Opaque handle to platform object | NativeObject<Session> |
enumType | Enumeration | HTTPMethod |
voidType | No return value | void |
dynamic | Untyped / any | dynamic |
Class kinds
| Kind | Description |
|---|---|
concreteClass | Regular instantiable class with methods and constructor |
abstractClass | Cannot be instantiated — only used as a type reference |
dataClass | Value type (struct/record) — serialized as a Map over the channel |
sealedClass | Sealed hierarchy with known subclasses |
Schema classes
The core schema is built from these types:
- UnifiedTypeSchema — top-level container:
package,source,version,classes,functions,types,enums,nativeImports,nativeFields - UtsClass —
name,kind,fields,methods,superclass,interfaces,constructorParameters,documentation - UtsMethod —
name,isStatic,isAsync,parameters,returnType,documentation,nativeBody - UtsParameter —
name,type,isOptional,isNamed,defaultValue,nativeLabel,nativeType - UtsField —
name,type,nullable,isReadOnly,defaultValue - UtsType —
kind,name,nullable,ref,typeArguments,parameterTypes,returnType - UtsEnum —
name,values(list ofUtsEnumValuewithnameand optionalrawValue)
JSON example (abridged)
{
"package": "Alamofire",
"source": "cocoapods",
"version": "5.9.0",
"classes": [
{
"name": "Session",
"kind": "concreteClass",
"methods": [
{
"name": "request",
"isAsync": false,
"parameters": [
{
"name": "url",
"type": { "kind": "primitive", "name": "String" },
"nativeLabel": "_"
},
{
"name": "method",
"type": { "kind": "primitive", "name": "String" }
},
{
"name": "headers",
"type": { "kind": "map", "name": "Map", "nullable": true },
"nativeType": "HTTPHeaders"
}
],
"returnType": { "kind": "nativeObject", "name": "DataRequest" }
},
{
"name": "download",
"isAsync": true,
"nativeBody": {
"swift": "try await instance.download(url, to: destination).serializingData().value"
},
"parameters": [ ... ],
"returnType": { "kind": "primitive", "name": "String" }
}
]
}
],
"nativeImports": {
"swift": ["Alamofire"]
}
}
Type Mappings
auto_interop maps native types to Dart types using language-specific mapping tables. These tables handle primitives, generics, nullability, and special types.
TypeScript/JS → Dart
| TypeScript | Dart | Notes |
|---|---|---|
number | double | |
string | String | |
boolean | bool | |
Date | DateTime | ISO 8601 encoding |
void / null / undefined | void | |
any / unknown | dynamic | |
Buffer / Uint8Array | Uint8List | Byte array encoding |
URL | Uri | |
Array<T> / T[] | List<T> | |
Promise<T> | Future<T> | |
ReadableStream<T> | Stream<T> | |
Map<K, V> / Record<K, V> | Map<K, V> | |
Set<T> | List<T> | Sets mapped to lists |
Swift → Dart
| Swift | Dart | Notes |
|---|---|---|
Int, Int8–64, UInt | int | |
Double, Float, CGFloat | double | |
String, UUID, NSString | String | |
Bool | bool | |
Date | DateTime | ISO 8601 encoding |
Data | Uint8List | Byte array encoding |
URL | Uri | |
T? / Optional<T> | T? | Nullable |
[T] / Array<T> | List<T> | |
[K: V] / Dictionary<K, V> | Map<K, V> | |
Set<T> | List<T> | |
Result<S, F> | Future<S> | |
AsyncStream<T> | Stream<T> | |
Void | void | |
Any / AnyObject | dynamic |
Kotlin → Dart
| Kotlin | Dart | Notes |
|---|---|---|
Int, Short, Byte, Long | int | |
Double, Float | double | |
String, CharSequence | String | |
Boolean | bool | |
ByteArray | Uint8List | |
URI / URL | Uri | |
Duration | Duration | |
T? | T? | Nullable |
List<T> / MutableList<T> | List<T> | |
Map<K, V> | Map<K, V> | |
Set<T> | List<T> | |
Flow<T> | Stream<T> | |
Deferred<T> | Future<T> | |
Unit | void | |
Any | dynamic |
Java → Dart
| Java | Dart | Notes |
|---|---|---|
int / Integer, long / Long, short, byte | int | |
double / Double, float / Float | double | |
String, CharSequence | String | |
boolean / Boolean | bool | |
byte[] | Uint8List | |
URI / URL | Uri | |
Optional<T> | T? | Nullable |
List<T> / ArrayList<T> | List<T> | |
Map<K, V> / HashMap<K, V> | Map<K, V> | |
Set<T> / HashSet<T> | List<T> | |
void / Void | void | |
Object | dynamic |
Channel encoding strategies
| Dart Type | Channel Encoding |
|---|---|
int, double, String, bool | Pass-through (standard codec) |
DateTime | ISO 8601 string |
Duration | Microseconds (int) |
Uri | String |
Uint8List | Byte array (standard codec) |
List<T> | List (recursive conversion) |
Map<K, V> | Map (recursive conversion) |
| Data classes | Map<String, dynamic> via toMap/fromMap |
| Native objects | String handle ID |
| Callbacks | String callback ID |
Customization
auto_interop provides several escape hatches for when the parsed schema doesn't match what you need. These are specified in .uts.json override files or directly in the schema.
nativeBody
Override the generated method body with verbatim platform code. The generator emits your code exactly as-is instead of the default dispatch logic.
{
"name": "download",
"isAsync": true,
"nativeBody": {
"swift": "try await instance.download(url, to: dest).serializingData().value",
"kotlin": "withContext(Dispatchers.IO) { instance.download(url, dest) }",
"js": "await fetch(url).then(r => r.blob())"
}
}
For stream methods, additional keys are available:
swift_onListen/swift_onCancel— EventChannel listen/cancel handlerskotlin_onListen/kotlin_onCancel— EventChannel listen/cancel handlers
nativeLabel
Swift external parameter labels. Set to "_" for unlabeled parameters, or a custom label string.
{
"name": "url",
"type": { "kind": "primitive", "name": "String" },
"nativeLabel": "_"
}
This generates instance.request(_ url: String) instead of instance.request(url: url) in Swift.
nativeType
Wrap a parameter with a native type conversion. The glue code will wrap the channel value with the specified constructor.
{
"name": "headers",
"type": { "kind": "map", "name": "Map" },
"nativeType": "HTTPHeaders"
}
Generated Swift: let headers = HTTPHeaders(args["headers"] as! [String: String])
nativeImports / nativeFields
Add platform-specific imports and plugin instance fields to the generated glue code.
{
"nativeImports": {
"swift": ["Alamofire", "Foundation"],
"kotlin": ["com.squareup.okhttp3.*"]
},
"nativeFields": {
"swift": ["private var cache: NSCache<NSString, AnyObject> = NSCache()"],
"kotlin": ["private val cache = LruCache<String, Any>(100)"]
}
}
Schema overrides
Override any parsed schema with custom .uts.json files. The override is deep-merged with the parsed schema.
Project-level overrides
Place files in the overrides_dir (default: auto_interop_overrides/):
auto_interop_overrides/
alamofire.uts.json # Matches package "Alamofire"
date_fns.uts.json # Matches package "date-fns"
com_squareup_okhttp3.uts.json
Global overrides
~/.auto_interop/overrides/
cocoapods/Alamofire.uts.json
npm/date-fns.uts.json
gradle/com.squareup.okhttp3:okhttp.uts.json
CLI override
dart run auto_interop_generator:generate generate --override my_alamofire.uts.json
Override resolution priority
- CLI
--overridefile - Project overrides directory
- Global overrides directory
- Registry cache (7-day TTL)
- Registry fetch (GitHub-hosted)
- Source parsing (download + parse)
Runtime API
The auto_interop runtime package provides the foundation that generated bindings use to communicate with native code.
AutoInteropChannel
Manages platform method channels for native bindings. Each native package gets its own channel identified by a unique name.
class AutoInteropChannel {
AutoInteropChannel(String name);
/// Invoke a method and return the result, cast to T.
/// Optional timeout throws AutoInteropException with code 'TIMEOUT'.
Future<T> invoke<T>(String method,
[Map<String, dynamic>? arguments, Duration? timeout]);
/// Invoke a method that returns a List.
Future<List<T>> invokeList<T>(String method,
[Map<String, dynamic>? arguments, Duration? timeout]);
/// Invoke a method that returns a Map.
Future<Map<K, V>> invokeMap<K, V>(String method,
[Map<String, dynamic>? arguments, Duration? timeout]);
/// Invoke multiple methods in a single round-trip.
/// Returns List<BatchResult> in the same order as calls.
Future<List<BatchResult>> batchInvoke(List<BatchCall> calls,
{Duration? timeout});
}
AutoInteropEventChannel
Provides Stream support via Flutter's EventChannel. Used for APIs that return streams (Kotlin Flow, Swift AsyncSequence, JS ReadableStream).
class AutoInteropEventChannel {
AutoInteropEventChannel(String name);
/// Returns a broadcast stream of events from the native side.
Stream<T> receiveStream<T>({
String? method,
Map<String, dynamic>? arguments,
});
}
AutoInteropLifecycle
Singleton that manages initialization and disposal of the entire native bridge runtime.
class AutoInteropLifecycle {
static AutoInteropLifecycle get instance;
bool get isInitialized;
/// Must be called before using any native bridge channels.
/// Safe to call multiple times (no-ops after first).
Future<void> initialize();
/// Disposes all native bridge resources.
Future<void> dispose();
/// Releases a native object handle.
Future<void> releaseObject(String channelName, String handle);
}
AutoInteropException
Exception thrown when a native method call fails. The code field contains a normalized error code.
class AutoInteropException implements Exception {
final String code; // e.g. "IO_ERROR", "TIMEOUT", "NETWORK_ERROR"
final String? message;
final dynamic details;
String get nativeExceptionType; // Alias for code
// Convenience boolean getters:
bool get isMissingPlugin;
bool get isTimeout;
bool get isNetworkError;
bool get isIoError;
bool get isPermissionDenied;
bool get isCancelled;
bool get isInvalidArgument;
bool get isNotFound;
}
CallbackManager
Manages Dart-to-native callbacks. When a Dart function is passed as a callback parameter, it's assigned a unique ID and the native side invokes it via the callback channel.
class CallbackManager {
static CallbackManager get instance;
/// Registers a callback and returns its unique ID.
String register(Function callback);
/// Unregisters a callback by its ID.
void unregister(String callbackId);
/// Unregisters all callbacks.
void clear();
int get count;
bool isRegistered(String callbackId);
}
NativeObject<T>
An opaque handle to a native object living on the platform side. Has a Finalizer safety net for GC-driven cleanup.
class NativeObject<T> {
final String handle; // Opaque handle ID
final String channelName; // Channel this object belongs to
bool get isDisposed;
/// Disposes this native object, releasing the native-side resource.
Future<void> dispose();
/// Throws StateError if disposed.
void ensureNotDisposed();
}
TypeConverter
Converts Dart types to platform channel-compatible types and back.
class TypeConverter {
/// Convert Dart value to platform channel format.
/// DateTime → ISO 8601 string, Duration → microseconds,
/// Uri → string, List/Map → recursive conversion.
static Object? toPlatform(Object? value);
/// Convert platform channel value back to Dart type.
static Object? fromPlatform(Object? value, {String? dartType});
static String dateTimeToString(DateTime dt);
static DateTime stringToDateTime(String s);
}
BatchCall / BatchResult
class BatchCall {
final String method;
final Map<String, dynamic>? arguments;
const BatchCall(this.method, [this.arguments]);
}
class BatchResult {
final dynamic value;
final String? errorCode;
final String? errorMessage;
bool get isSuccess;
bool get isError;
const BatchResult.success(dynamic value);
const BatchResult.error(String code, [String? message]);
}
Generated Code Examples
These are real golden file outputs from the test suite — exactly what the generator produces.
Dart binding (date-fns)
// GENERATED CODE — DO NOT EDIT
// Generated by auto_interop_generator
//
// Package: date-fns@3.6.0
// Source: npm
import 'package:auto_interop/auto_interop.dart';
class FormatOptions {
final String? locale;
final double? weekStartsOn;
FormatOptions({this.locale, this.weekStartsOn});
factory FormatOptions.fromMap(Map<String, dynamic> map) {
return FormatOptions(
locale: map['locale'] as String?,
weekStartsOn: map['weekStartsOn'] as double?,
);
}
Map<String, dynamic> toMap() => {
if (locale != null) 'locale': locale,
if (weekStartsOn != null) 'weekStartsOn': weekStartsOn,
};
}
abstract interface class DateFnsInterface {
Future<String> format(DateTime date, String formatStr);
Future<DateTime> addDays(DateTime date, double amount);
Future<double> differenceInDays(DateTime dateLeft, DateTime dateRight);
}
class DateFns implements DateFnsInterface {
static final _channel = AutoInteropChannel('date_fns');
static final DateFns instance = DateFns._();
DateFns._();
@override
Future<String> format(DateTime date, String formatStr) async {
final result = await _channel.invoke<String>('format', {
'date': date.toIso8601String(),
'formatStr': formatStr,
});
return result;
}
@override
Future<DateTime> addDays(DateTime date, double amount) async {
final result = await _channel.invoke<String>('addDays', {
'date': date.toIso8601String(),
'amount': amount,
});
return DateTime.parse(result);
}
@override
Future<double> differenceInDays(
DateTime dateLeft, DateTime dateRight) async {
final result = await _channel.invoke<double>('differenceInDays', {
'dateLeft': dateLeft.toIso8601String(),
'dateRight': dateRight.toIso8601String(),
});
return result;
}
}
Swift glue (Alamofire)
// GENERATED CODE — DO NOT EDIT
// Generated by auto_interop_generator
// Swift platform channel handler for Alamofire
#if os(macOS)
import FlutterMacOS
#else
import Flutter
import UIKit
#endif
import Alamofire
public class AlamofirePlugin: NSObject, FlutterPlugin {
private var channel: FlutterMethodChannel!
private var instances: [String: AnyObject] = [:]
private var nextHandle: Int = 0
public static func register(with registrar: FlutterPluginRegistrar) {
#if os(macOS)
let messenger = registrar.messenger
#else
let messenger = registrar.messenger()
#endif
let channel = FlutterMethodChannel(
name: "auto_interop/alamofire",
binaryMessenger: messenger)
let instance = AlamofirePlugin()
instance.channel = channel
registrar.addMethodCallDelegate(instance, channel: channel)
}
public func handle(_ call: FlutterMethodCall,
result: @escaping FlutterResult) {
let args = (call.arguments as? [String: Any]) ?? [:]
switch call.method {
case "Session._create":
let instance = Session()
result(createHandle(instance))
case "Session.request":
let url = args["url"] as! String
let method = args["method"] as! String
let headers = args["headers"] as? [String: String]
let handle = args["_handle"] as! String
let instance = instances[handle] as! Session
let nativeResult = instance.request(
url: url, method: method, headers: headers)
result(createHandle(nativeResult))
case "Session.download":
let handle = args["_handle"] as! String
let instance = instances[handle] as! Session
Task {
do {
let nativeResult = try await instance.download(...)
DispatchQueue.main.async { result(nativeResult) }
} catch {
DispatchQueue.main.async {
result(FlutterError(
code: normalizeErrorCode(error),
message: error.localizedDescription,
details: String(describing: error)))
}
}
}
case "_dispose":
releaseHandle(args["_handle"] as! String)
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
private func normalizeErrorCode(_ error: Error) -> String {
let nsError = error as NSError
switch nsError.domain {
case NSURLErrorDomain:
switch nsError.code {
case NSURLErrorTimedOut: return "TIMEOUT"
case NSURLErrorCancelled: return "CANCELLED"
default: return "NETWORK_ERROR"
}
case NSCocoaErrorDomain:
return "IO_ERROR"
default:
return String(describing: type(of: error))
}
}
}
Kotlin glue (OkHttp)
// GENERATED CODE — DO NOT EDIT
// Generated by auto_interop_generator
// Kotlin platform channel handler for com.squareup.okhttp3:okhttp
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import kotlinx.coroutines.*
import com.squareup.okhttp3.*
class ComSquareupOkhttp3OkhttpPlugin :
FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
private val instances = mutableMapOf<String, Any>()
private var nextHandle = 0
private val scope = CoroutineScope(
Dispatchers.Main + SupervisorJob())
override fun onAttachedToEngine(
binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger,
"auto_interop/com_squareup_okhttp3_okhttp")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"OkHttpClient._create" -> {
val instance = OkHttpClient()
result.success(createHandle(instance))
}
"OkHttpClient.newCall" -> {
val request = call.argument<Map>("request")!!
val handle = call.argument<String>("_handle")!!
val instance = instances[handle] as OkHttpClient
val nativeResult = instance.newCall(
request = decodeRequest(request))
result.success(createHandle(nativeResult))
}
"Call.execute" -> {
val handle = call.argument<String>("_handle")!!
val instance = instances[handle] as Call
scope.launch {
try {
val nativeResult = instance.execute()
withContext(Dispatchers.Main) {
result.success(encode(nativeResult))
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
result.error(normalizeErrorCode(e),
e.message, e.stackTraceToString())
}
}
}
}
"_dispose" -> {
releaseHandle(
call.argument<String>("_handle")!!)
result.success(null)
}
else -> result.notImplemented()
}
}
private fun normalizeErrorCode(e: Exception): String {
return when (e) {
is java.io.IOException -> "IO_ERROR"
is java.net.SocketTimeoutException -> "TIMEOUT"
is SecurityException -> "PERMISSION_DENIED"
is CancellationException -> "CANCELLED"
else -> e::class.simpleName ?: "UNKNOWN"
}
}
}
Platform Guides
iOS / macOS (Swift)
For CocoaPods or SPM packages, auto_interop generates a *Plugin.swift file that implements FlutterPlugin.
Glue routing
Generated Swift files are automatically routed to ios/Runner/ or macos/Runner/ (whichever exists). The Xcode project file (.pbxproj) is patched to include the new file.
Plugin registration
A RegisterAutoInteropPlugins.swift file is generated to register all plugins:
// RegisterAutoInteropPlugins.swift (auto-generated)
import Flutter
func registerAutoInteropPlugins(with registry: FlutterPluginRegistry) {
AlamofirePlugin.register(with: registry.registrar(forPlugin: "AlamofirePlugin"))
SDWebImagePlugin.register(with: registry.registrar(forPlugin: "SDWebImagePlugin"))
}
CocoaPods setup
Add the native dependency to your Podfile:
# ios/Podfile
pod 'Alamofire', '~> 5.9'
Swift async support
Async methods use Swift Task { } blocks with DispatchQueue.main.async to dispatch results back to the Flutter engine's main thread.
Android (Kotlin)
For Gradle packages, auto_interop generates a *Plugin.kt file that implements FlutterPlugin and MethodCallHandler.
Glue routing
Generated Kotlin files are routed to android/app/src/main/kotlin/.
Gradle setup
Add the native dependency to your build.gradle:
# android/app/build.gradle
dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
}
Coroutine support
Async methods use a CoroutineScope(Dispatchers.Main + SupervisorJob()) with scope.launch { }. The scope is cancelled in onDetachedFromEngine.
If your Gradle package requires additional Maven repositories (e.g., Google Maven, JitPack), add them via the maven_repositories config field.
Web (JavaScript)
For npm packages, auto_interop generates JS interop code using dart:js_interop. No platform channels are needed — the Dart code calls JavaScript directly.
npm setup
# Install the npm package in your web directory
cd web && npm install date-fns
The JS generator currently does not support nativeBody overrides, callbacks, or streams. These features are planned for a future release.
Advanced Topics
Caching & incremental builds
auto_interop uses SHA-256 checksums to track changes and skip unnecessary regeneration:
- Config checksum — if
auto_interop.yamlchanges, all packages rebuild - Per-package checksum — computed from schema JSON +
customTypes+imports - Dependency graph — cross-package type references trigger transitive invalidation
- Output checksums — stored in
.auto_interop_cache.jsonfor file-level diffing
Use --force to bypass caching, or --verbose to inspect cache state.
Cloud registry
The cloud registry provides pre-built UTS definitions for popular packages, eliminating the need to download and parse source code.
- GitHub-hosted — definitions stored in a public repository
- 7-day TTL — cached definitions expire after 7 days
- SHA-256 verified — integrity checking on fetched definitions
- ETag conditional fetch — avoids re-downloading unchanged definitions
- Stale cache fallback — if the registry is unreachable, stale cached definitions are used
# List available pre-built definitions
dart run auto_interop_generator:generate registry list
# Force-fetch a specific definition
dart run auto_interop_generator:generate registry fetch npm/date-fns 3.6.0
AST vs regex parsers
auto_interop ships with two parsing backends. AST parsing is the default and provides near-100% reliability for well-formed source code:
| Regex Parsers | AST Parsers (Default) | |
|---|---|---|
| Speed | Fast (no toolchain needed) | Slower first run (~30s), fast after cache |
| Accuracy | Good for common patterns | Near-100% for well-formed code |
| Requirements | None | Node.js / Xcode / kotlinc |
| Fallback | Automatic when AST unavailable | Used by default when toolchain detected |
| Extensions | Basic support | Full folding into classes |
| Overloads | All kept (may break Dart) | Deduplicated (first-wins) |
| Throws | Basic detection | Full support including typed throws |
| Default exports | Not supported | Fully supported |
The generator automatically detects available toolchains and uses AST parsers when possible, falling back to regex. Use --no-ast to force regex-only parsing. See the AST Parsing section for full details.
Multi-package schemas
When multiple packages reference each other's types (cross-package type references), auto_interop:
- Detects references via the dependency graph
- Performs topological sort to determine generation order
- Propagates invalidation transitively (if A depends on B, changing B rebuilds A)
build_runner integration
auto_interop integrates with Dart's build_runner for automated builds:
# build.yaml
targets:
$default:
builders:
auto_interop_generator:
enabled: true
When auto_interop.yaml changes, build_runner triggers regeneration automatically. Generated files go to lib/generated/.
AST-Based Parsing
Since v0.2.0, auto_interop uses real compiler APIs as the default parsing backend. This provides near-100% accuracy for well-formed source code, with automatic fallback to regex parsers when toolchains are unavailable.
How it works
Each AST parser invokes a native helper script/binary that uses the language's own compiler frontend to parse source code into a structured AST, then emits a UTS-compatible JSON schema.
| Language | AST Parser | Compiler API | Toolchain Required |
|---|---|---|---|
| Swift | AstSwiftParser | SwiftSyntax (compiled binary) | Swift compiler (Xcode) |
| Kotlin | AstGradleParser | Kotlin PSI via kotlinc -script | kotlinc |
| TypeScript | AstNpmParser | TypeScript Compiler API | Node.js |
Pre-warming caches
On first use, AST helpers need to download dependencies or compile binaries (~30 seconds). Pre-warm to avoid this delay:
# Pre-compile Swift binary + download Kotlin Maven deps
dart run auto_interop_generator:generate setup
Caches are stored at ~/.auto_interop/tools/ and automatically invalidated when source files change:
- Swift: Mtime-based — recompiles when
Package.swiftorSources/main.swiftare newer than the cached binary - Kotlin: Content-comparison — rewrites the cached script only when the patched content differs
- TypeScript: Always fresh (runs via Node.js, no compilation step)
Swift AST parser
Uses SwiftSyntax to parse Swift source code with full fidelity. The helper is compiled on first use and cached as a binary.
The Swift helper uses #if compiler(>=6.0) conditional compilation to support Swift 5.9, 6.0, and 6.2+ with the correct SwiftSyntax version (510 or 600+).
Features:
- Classes, structs, protocols, enums (simple + associated values)
- Extensions folded into base classes
async/awaitmethods →Future<T>throwsand typedthrows(ErrorType)→Future<T>withisAsync: true- Closures →
Functiontypes - Optionals, generics, static/class methods
///doc comments and/** ... */blocks preserved- Access control filtering (
private,fileprivate,internalexcluded)
Example — Swift throwing function:
// Input: Swift source
public func fetchData(from url: String) throws(NetworkError) -> String {
// ...
}
// Output: Generated Dart binding
static Future<String> fetchData(String url) async {
final result = await _channel.invoke<String>('fetchData', {
'url': url,
});
return result;
}
Kotlin AST parser
Uses Kotlin PSI (the Kotlin compiler's parser) via kotlinc -script with kotlin-compiler-embeddable. The @file:DependsOn version is automatically matched to the installed kotlinc.
Features:
- Classes, data classes, sealed classes, enum classes, interfaces, object declarations
- Extension functions — folded into matching classes or emitted as top-level with receiver as first parameter
- Overload deduplication — only the first overload per function name is kept (Dart doesn't support overloading)
suspendfunctions →Future<T>- Kotlin Flow →
Stream<T> - Companion object methods → static methods
- Nullable types, default values, KDoc comments
- Access control filtering (
private/internalexcluded)
Example — Kotlin extension functions:
// Input: Kotlin source
class StringUtils {
fun isEmpty(str: String): Boolean = str.isEmpty()
}
fun StringUtils.reverse(str: String): String = str.reversed()
// Output: Generated Dart binding
// The extension is folded into StringUtils
class StringUtils {
static final _channel = AutoInteropChannel('string_utils');
Future<bool> isEmpty(String str) async { ... }
Future<String> reverse(String str) async { ... } // extension folded in
}
Example — Kotlin overload deduplication:
// Input: three overloads
fun process(data: String): String { ... }
fun process(data: Int): Int { ... } // skipped (duplicate name)
fun process(data: List<String>): String { ... } // skipped
// Output: only the first overload
static Future<String> process(String data) async { ... }
Mixed Kotlin/Java handling
When a Gradle package contains both .kt and .java files, the AST parser splits them:
.ktfiles → Kotlin PSI (AST).javafiles → Regex parser (Kotlin PSI cannot parse Java)
Both results are merged into a single UnifiedTypeSchema. This ensures Kotlin gets full AST accuracy while Java declarations are still included.
TypeScript AST parser
Uses the TypeScript Compiler API via Node.js to parse .d.ts declaration files with full accuracy.
Features:
- All exported declarations: functions, classes, interfaces, type aliases, enums
export default— correctly identifies default-exported classes, interfaces, types, and enums- Generics:
Array<T>,Promise<T>,Map<K, V> - Callback types:
(value: string) => void - Optional parameters, union types, JSDoc comments
Example — TypeScript default export:
// Input: TypeScript .d.ts
export default interface ApiClient {
fetch(url: string): Promise<Response>;
cancel(): void;
}
// Output: Generated Dart binding
abstract interface class ApiClientInterface {
Future<Response> fetch(String url);
Future<void> cancel();
}
class ApiClient implements ApiClientInterface {
static final _channel = AutoInteropChannel('api_client');
@override
Future<Response> fetch(String url) async { ... }
@override
Future<void> cancel() async { ... }
}
Disabling AST parsing
To force regex-only parsing (e.g., in CI without toolchains):
# Via CLI flag
dart run auto_interop_generator:generate generate --no-ast
# Via parse command
dart run auto_interop_generator:generate parse --no-ast --package MyLib lib/src/*.swift
You don't need to disable AST manually. If a toolchain is unavailable (e.g., no kotlinc installed), the generator silently falls back to the regex parser for that language. The fallback is per-language — Swift can use AST while Kotlin falls back to regex if kotlinc is missing.
Known Limitations
Fundamental (cannot be solved)
These are inherent to Flutter's platform channel architecture.
- Async-only channels — every native call is a
Future. No synchronous calls possible. Real-time APIs (audio DSP, GPU compute) will have unacceptable latency. - No direct memory sharing — native objects are referenced by opaque string handles. No pointers, no zero-copy. A 10MB image must be fully copied through the codec.
- No bidirectional object graph — Dart can call native and native can invoke Dart callbacks, but you can't pass a Dart object into native as a first-class native object.
- Hot restart invalidates handles — when Flutter hot-restarts, Dart VM resets but native state persists. All
NativeObjecthandles become stale. - One-shot method calls — each
invoke()is independent. No connection state, session affinity, or request pipelining.
Very hard (major engineering effort)
- No generics on generated classes —
fromJson<T>()becomesfromJson() → dynamic. - No inheritance / protocol conformance — generated classes are flat, no
extendsorimplements. - Single EventChannel per package — multiple simultaneous streams within one package are not supported.
- No constructor overloads — each class gets exactly one
_createmethod. - Kotlin Builder pattern assumption — assumes Java Builder pattern; modern Kotlin constructors may produce broken code.
- JS generator has no escape hatch — unlike Swift/Kotlin, the JS generator ignores
nativeBody['js']. - No cross-platform type unification — Alamofire and OkHttp generate completely separate Dart classes for similar concepts.
Hard but solvable (all fixed)
These issues have been resolved:
Parser fragility— AST-based parsing is now the default (SwiftSyntax, Kotlin PSI, TypeScript Compiler API) with regex as automatic fallback. See AST Parsing.Shallow container serialization— recursive codegen-aware serializationNo per-call timeout—timeoutparameter added to all invoke methodsCallbacks with non-void return ignored— two-way callback protocolNo Map with non-String keys— key-type-aware conversionDuration/Uri not handled in containers— extended TypeConverterMaven Central only— configurablemaven_repositoriesNo macOS in compatibility checker— macOS added to platform mapping
Summary
| Category | Count | Status |
|---|---|---|
| Never solvable (Flutter arch) | 5 | Inherent |
| Very hard (months) | 7 | Future work |
| Hard (weeks) | 8 | All fixed |
| Moderate (days) | 6 | All fixed |
The "never solvable" items are inherent to Flutter platform channels. The only way around them is dart:ffi (C interop) or Dart's NativePort, which would be a fundamentally different package.
FAQ
How do I update bindings when a native package updates?
Update the version field in auto_interop.yaml and re-run dart run auto_interop_generator:generate. The generator detects the version change via checksums and regenerates affected bindings. Use --force if the cache doesn't pick up the change.
Can I use auto_interop without build_runner?
Yes. The CLI is standalone: dart run auto_interop_generator:generate. You don't need build_runner at all. The build_runner integration is optional and simply triggers the same CLI when auto_interop.yaml changes.
How do I handle unrecognized types?
Two approaches:
custom_typesinauto_interop.yaml— maps type names to Dart types (e.g.,FormatOptions: "Map<String, dynamic>")nativeBodyoverrides in a.uts.jsonfile — provides verbatim platform code for methods that need special handling
Unresolved types are listed in lib/generated/_unresolved_types.yaml after generation.
What happens if the registry is offline?
The generator uses a stale cache fallback. If a cached definition exists (even past the 7-day TTL), it's used when the registry is unreachable. If no cache exists, the generator falls back to downloading and parsing the native source code directly.
Can I use local or private packages?
Yes. Use the source_path field to point to a local directory containing the native source files:
- source: cocoapods
package: "MyPrivateLib"
version: "1.0.0"
source_path: "../my_private_lib/Sources"
How do I debug generated code?
Several tools available:
--verbose— shows checksums, cache state, and file lists during generation--dry-run— preview what would be generated without writing filesparsecommand — inspect the raw UTS schema for a package- Parse cache — cached schemas stored in
.auto_interop_parse_cache/
What's the performance overhead?
Each invoke() call is one platform channel round-trip (~0.1-0.5ms on modern devices). For bulk operations, use batchInvoke() to send N calls in 1 round-trip. Data classes are serialized as Maps, adding minimal overhead. Native objects use string handles with no serialization of the actual object.
Does auto_interop support hot reload?
Hot reload works fine — only the Dart code reloads and native state persists. However, hot restart invalidates all native object handles because the Dart VM resets. You'll need to re-initialize after a hot restart.
Why is the first generation slow?
On first use, the AST parsers need to set up their toolchains:
- Swift: Compiles the SwiftSyntax helper binary (~10s)
- Kotlin: Downloads Maven dependencies for
kotlin-compiler-embeddable(~30s) - TypeScript: No setup needed (uses Node.js directly)
Run dart run auto_interop_generator:generate setup to pre-warm all caches. Subsequent runs are fast (<5s).
Do I need Xcode / kotlinc / Node.js installed?
No. AST parsing is optional and enabled automatically when toolchains are detected. If a toolchain is missing, the generator silently falls back to regex parsing for that language. You can also force regex-only with --no-ast.
For best results:
- Swift packages: Install Xcode (provides the Swift compiler and SwiftSyntax)
- Kotlin packages: Install
kotlinc(via SDKMAN! or Homebrew) - npm packages: Install Node.js (v16+)
How does the generator handle Kotlin extension functions?
Extension functions are folded into matching classes when possible. If a class StringUtils exists in the same package and an extension fun StringUtils.reverse() is found, the method is added to the StringUtils class in the generated Dart code.
If no matching class exists, the extension is emitted as a top-level function with the receiver type as the first parameter (e.g., fun String.isEmail() becomes Future<bool> isEmail(String self)).
What happens with Swift typed throws?
Swift 6.0 introduced typed throws: func fetch() throws(NetworkError) -> String. The AST parser correctly identifies the return type as String (not Void) and generates Future<String> in Dart. The error type itself is not exposed in the Dart API — errors are caught and wrapped in AutoInteropException.
Requires SwiftSyntax 600+ (automatically selected on Swift 6.x compilers).
How do I clear the AST helper cache?
AST helper caches are stored at ~/.auto_interop/tools/. To clear them:
# Remove all cached AST helpers
rm -rf ~/.auto_interop/tools/
# Or just clear a specific one
rm ~/.auto_interop/tools/swift_ast_helper # Swift binary
rm ~/.auto_interop/tools/kt_ast_helper_*.main.kts # Kotlin scripts
rm ~/.auto_interop/tools/kt_ast_helper_*.warm # Kotlin warm stamps
The caches are rebuilt automatically on the next run.