dartmole_plugin

Lets Dartmole inspect and mock your Flutter app's HTTP traffic. Add it to the app under test, pair the device with Dartmole by scanning a QR code, and the app's Dart HTTP calls go through Dartmole's proxy.

  • TLS stays on. The plugin adds Dartmole's certificate authority to the trusted ones. It never turns certificate checking off.
  • Off by default. Until a device is paired and interception is switched on, the HttpClient from createHttpClient() behaves as if the plugin weren't there.
  • Nothing is compiled in. The certificate authority arrives at pairing time, after someone scans the QR code, and goes away on Forget.

The example is a small weather app with everything below wired up.

Install

Install the Dartmole app on your Mac from dartmole.dev, then add the plugin to your app:

flutter pub add dartmole_plugin

iOS

The QR scanner needs the camera. Without this key, iOS terminates the app the moment the scanner opens, so add it to ios/Runner/Info.plist:

<key>NSCameraUsageDescription</key>
<string>Scans the QR code in Dartmole to pair with it.</string>

iOS also asks for local network access, which the app needs to reach your Mac: at the first pairing in a release build, and already at launch in debug and profile builds, because Flutter's tooling uses the local network too. The prompt appears with or without a key, but this one tells testers why it is there, so add it too:

<key>NSLocalNetworkUsageDescription</key>
<string>Connects to Dartmole on this network to inspect the app's traffic.</string>

Until the tester taps Allow, every connection fails. The panel keeps trying for 30 seconds, so pairing finishes once they do. After Don't Allow, pairing cannot reach the Mac until the app is switched on under Settings → Privacy & Security → Local Network.

Android

Flutter's templates only grant INTERNET to debug and profile builds. If a release-mode build (an internal build for testers, say) should pair, add it to android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET"/>

The camera permission comes with the scanner.

Pairing, and the certificate page behind Also intercept native traffic, use plain HTTP on your local network. A network security config that sets cleartextTrafficPermitted="false" (or a targetSdk of 28 or higher, where that is the default) does not get in the way: Dart's dart:io does not apply that policy, and the certificate page opens in the browser, which is a separate app with its own rules. Chrome marks the page "Not secure" and loads it. Checked on Android 17 with the policy set to false.

Route your HTTP through it

Dartmole().createHttpClient() returns a dart:io HttpClient. Give it to whatever does your networking, once. It follows Dartmole from then on: pair later, switch interception off, or tap Forget, and the next request goes the new way. Requests already underway finish as they started. Create as many clients as you like, whenever you like; none of them leaves a listener behind.

Dio

dio.httpClientAdapter = IOHttpClientAdapter(
  createHttpClient: Dartmole().createHttpClient,
);

Do this for every Dio you create, including ones you recreate later, on an environment switch say.

package:http

final client = IOClient(Dartmole().createHttpClient());

HttpClient

Use Dartmole().createHttpClient() where you would use HttpClient().

Whatever you set on the client (timeouts, userAgent, credentials, badCertificateCallback and so on) applies however Dartmole changes. A findProxy of your own applies only while Dartmole is off, because its proxy has to win while it is on.

Upgrading from 0.1.0: remove the Dartmole().addListener(...) that swapped your adapter or client. It still works, but it is no longer needed.

Add the pairing panel

DartmoleSettings pairs, shows which machine the device is paired with, and switches interception on and off. Put it somewhere testers can reach and store users can't, like a debug menu:

Scaffold(
  appBar: AppBar(title: const Text('Dartmole')),
  body: const SingleChildScrollView(
    padding: EdgeInsets.all(24),
    child: DartmoleSettings(),
  ),
);

To pair, open Dartmole on your Mac and go to Devices, then on the phone tap Scan QR code. Simulators and devices without a camera can type the host and port shown in Dartmole instead. The phone and the Mac must be on the same network.

If pairing says it cannot reach Dartmole at an address that is right, open http://<address>/certificate in the phone's browser. If that fails too, the network keeps the two apart. A common cause is the Wi-Fi 6E (6 GHz) band: on the Mac, set System Settings → Wi-Fi → Details… → Wi-Fi 6E mode to Off. Client isolation or a guest network on the router does the same. On Android, adb reverse tcp:50080 tcp:50080 and adb reverse tcp:8080 tcp:8080 let you pair over USB with host 127.0.0.1.

Remember the pairing

Without help, every launch starts unpaired. One call at launch remembers it:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Dartmole().rememberPairing();
  runApp(const MyApp());
}

It reconnects to the machine paired last time, then stores every change: a new pairing, the intercept switch, and Forget, which deletes it. It waits at most two seconds (wait:) for the machine, so a Dartmole that isn't around never holds up your app; one that answers later is still picked up.

  • Nothing is trusted until that machine answers. A remembered pairing behaves like no pairing wherever Dartmole isn't running.

  • The certificate is pinned. If the machine answers with a different certificate authority, the device stays unpaired until someone scans again. The stored pairing is kept, so the next launch tries again.

  • It is stored where only your app can write: a file in the app's private storage, left out of device backups, since it holds the certificate authority your app trusts. Apps that clear their own preferences, on logout or an environment switch, no longer clear the pairing with them.

  • It never throws. What goes wrong goes to Dartmole.onLog, which prints with debugPrint unless you route it into your own logger:

    Dartmole.onLog = (message, [error, stackTrace]) =>
        logger.warning(message, error, stackTrace);
    

To keep the pairing somewhere else, pass your own PairingStorage. For full control, Pairing.toJson/fromJson and Dartmole().reconnect(pairing) are what rememberPairing uses.

Native traffic (optional)

All of the above covers Dart's HttpClient. Code that uses the platform's own networking doesn't go through it: URLSession, OkHttp, cupertino_http, cronet_http, WebViews, and native SDKs. Dartmole can show that traffic too, if the device sends it to the proxy and trusts Dartmole's certificate authority.

Once a device is paired, the panel shows Also intercept native traffic. It opens a page served by Dartmole with step-by-step instructions written for testers. Your part is making sure the build can use the result:

  • iOS: nothing to do. A certificate profile the user installs and fully trusts applies to every app.

  • Android: since Android 7, an app ignores certificates the user installs unless its network security config opts in. Opt in for debug and profile builds only, so release builds keep trusting the system's authorities alone.

    android/app/src/debug/res/xml/network_security_config.xml, and the same file in src/profile:

    <?xml version="1.0" encoding="utf-8"?>
    <network-security-config>
        <base-config>
            <trust-anchors>
                <certificates src="system" />
                <certificates src="user" />
            </trust-anchors>
        </base-config>
    </network-security-config>
    

    In android/app/src/debug/AndroidManifest.xml, and the same in src/profile:

    <application android:networkSecurityConfig="@xml/network_security_config"/>
    

    Use base-config rather than debug-overrides: profile builds aren't debuggable, and Android ignores debug-overrides in them.

The plugin checks this itself. In a build that doesn't opt in, the panel offers no button, and tells the tester the build can't show its native traffic. The debug console also prints what to add. UserCertificateTrust.check() gives you the same answer in code.

Is it working?

The panel doesn't just show the switch. It sends a request down each path and says what happened: whether the app's Dart HTTP actually reaches Dartmole, and whether native traffic does. If not, it says what's missing: no proxy on the device, a proxy pointing somewhere else, or a certificate that isn't trusted yet. It checks again when the panel opens, and when the app comes back to the foreground, so a tester returning from the device's Settings sees the result straight away. For anything that changes while the app stays open, like Dartmole's proxy restarting or another Wi-Fi network, the refresh button next to the machine checks on demand, and the row says when it last checked. The debug console prints each result in full.

In code, Dartmole().checkDartTraffic() and checkNativeTraffic() return an InterceptionCheck. Both return null when not paired, or when paired with a Dartmole too old to answer the check.

Testing your app

Dartmole() is a singleton, so your tests can put it in any state without a Dartmole on the network:

import 'package:dartmole_plugin/dartmole_plugin.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  tearDown(() => Dartmole().debugReset());

  test('the API client goes through Dartmole once paired', () async {
    Dartmole().debugSetPairing(
      Pairing(
        host: '127.0.0.1',
        port: 50080,
        proxyPort: proxy.port, // a local HttpServer standing in for Dartmole
        machineName: 'Test Mac',
        machineId: 'test',
        certificateAuthority: testCaPem,
      ),
    );
    // ...make a request with your app's client and check the proxy saw it.
  });
}

debugSetPairing trusts the certificate authority exactly as pairing does, so the PEM must be a real certificate. Pass enabled: false for paired but switched off, or null for unpaired. debugReset unpairs and forgets that rememberPairing was called, so tests don't leak into each other. Dartmole().revision goes up on every change, if you need to tell that one happened.

Keep it out of store builds

Anyone with a build can pair it with their own Dartmole. If that matters, keep the panel out of builds that leave your team, with a separate entry point such as main_internal.dart.

A separate entry point leaves the panel's Dart code out of the store build, but not mobile_scanner's native code and camera permission: Flutter links every plugin in pubspec.yaml into every flavor.

Security

  • The device gets the certificate authority over plain HTTP at pairing time. Someone able to intercept your local network at that moment could substitute their own. Pair on a network you trust.
  • An app that remembers a pairing keeps trusting that Mac whenever it's reachable, until someone taps Forget.
  • Intercepting native traffic installs Dartmole's authority on the whole device, for every app, until it's removed. Dartmole's setup page tells testers how to remove it.
  • Anyone with a build can pair it with their own Dartmole. See Keep it out of store builds.

Libraries

dartmole_plugin