flutter_app_doctor 0.1.0
flutter_app_doctor: ^0.1.0 copied to clipboard
Runtime health checks for Flutter apps. Diagnoses connectivity, DNS, captive portals, API reachability, latency, storage, permissions, emulators, build mode, jank and clock skew into one structured, s [...]
flutter_app_doctor #
Runtime health checks for Flutter apps.
flutter doctor inspects a developer's machine. flutter_app_doctor inspects the
machine your app is running on — the user's device, right now — and turns
what it finds into one structured report you can print, render, or attach to a
support ticket.
final DoctorReport report = await FlutterDoctor.run();
debugPrint(report.summary);
Flutter Doctor — unhealthy (18 checks in 1.4 s, 2026-08-24T14:32:10Z)
Network
[✓] Internet connection — Reachable — connected to Cloudflare DNS in 41 ms
[✗] DNS resolution — No hostname resolved — name resolution is broken
→ The device has a route to the internet but cannot resolve names.
Usual causes: a captive portal that has not been signed in to, a VPN
or Private DNS profile pointing at a dead resolver, or an IPv6-only
network without DNS64.
[✓] Captive portal — No interception — probe returned 204 as expected
[✓] Network latency — Responsive — median 62 ms (best 48 ms, worst 91 ms)
[–] API · Core API — Not run because "DNS resolution" failed
Device
[✓] Platform — android · 14 · 8 cores
[✓] Physical device — Running on physical hardware
[!] Free storage — Low on space — 412 MB free of 64 GB (0.6%)
→ Trim cached media and old database snapshots before the device
reaches the point where writes fail.
App
[✓] App version — Acme · 2.4.1+1043
[!] Build mode — Debug build
→ Debug builds are unoptimised, run with assertions on, and are
commonly several times slower than release.
1 failure, 2 warnings and 1 skipped.
Why this is not another device_info wrapper #
A wrapper hands you facts. This hands you a diagnosis.
Checks depend on each other, and the report says so. Probing raw internet
connectivity, DNS, captive portals and your API as four separate steps is what
lets the report say "your API was not reached because DNS is broken" instead
of listing four failures and leaving you to guess which one is the cause. In
the example above the API check is skipped, not failed — because blaming
your backend for a DNS outage is worse than saying nothing.
Every unhealthy result carries a next step. Not SocketException: Connection failed, but what that means and who has to fix it. A TLS
handshake failure points at the device clock first, because that is what it
usually is.
Failures are classified, not lumped together. No internet, no DNS, captive portal, connection refused, TLS failure, timeout, HTTP 503 — every one of these reaches an app as "the request failed", and every one has a different owner.
No plugin dependencies. Not one. Everything answerable in pure Dart is: connectivity, DNS, captive-portal detection, latency, clock skew, Android emulator fingerprints, jailbreak signals, frame timings, memory, and free space on desktop and web. Everything that genuinely needs a platform plugin — app version, manufacturer and model, permission grants, mobile free space — is a small provider interface you implement in a few lines against whatever you already use. Checks without a provider skip with an explanation; they never pretend the answer is fine.
Reports are redacted by default. Hostnames, IPs, device names and URLs are masked unless you opt in, so a report is safe to paste into a ticket without thinking about it.
Install #
dependencies:
flutter_app_doctor: ^0.1.0
Nothing else. No native build changes, no Podfile edits, no manifest
permissions.
Usage #
One-off #
final DoctorReport report = await FlutterDoctor.run();
print(report.summary); // flutter-doctor-style text
print(report.headline); // "flutter_app_doctor: degraded — 2 warnings"
print(report.toMarkdown()); // paste into a GitHub issue
jsonEncode(report.toJson()); // ship to your crash reporter
if (!report.isHealthy) {
for (final DiagnosticResult issue in report.issues) {
print('${issue.title}: ${issue.message} → ${issue.remedy}');
}
}
Configure once at startup #
void main() {
WidgetsFlutterBinding.ensureInitialized();
FrameStatsCollector.instance.start();
FlutterDoctor.configure(
DoctorConfig(
endpoints: <ApiEndpoint>[
ApiEndpoint(Uri.parse('https://api.acme.com/health'), name: 'Core API'),
ApiEndpoint(
Uri.parse('https://cdn.acme.com/ping'),
name: 'CDN',
isCritical: false, // a failure here warns instead of failing
),
],
appInfoProvider: const StaticAppInfoProvider(
AppInfo(version: '2.4.1', buildNumber: '1043'),
),
),
);
runApp(const MyApp());
}
Every later FlutterDoctor.run() picks that up.
Show it to a user #
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const FlutterDoctorScreen()),
);
FlutterDoctorScreen runs a diagnostic and renders it, with expandable details
and copy-to-clipboard. DoctorReportView is the same widget over a report you
already have — pass showPassing: false to show only what needs attention.
Attach it to an error report #
FlutterError.onError = (FlutterErrorDetails details) async {
final DoctorReport report = await FlutterDoctor.run();
await crashReporter.report(details, context: report.toJson());
};
Progressive UI #
final FlutterDoctor doctor = FlutterDoctor();
await for (final DiagnosticResult result in doctor.watch()) {
setState(() => _results.add(result)); // results as they land
}
doctor.dispose();
What it checks #
| Category | Id | What it tells you | Needs |
|---|---|---|---|
| Network | network.internet |
Whether a route to the internet exists at all, probed by literal IP so DNS cannot confuse the answer | — |
| Network | network.dns |
Whether name resolution works, separately from connectivity | — |
| Network | network.captive_portal |
Whether hotel/airport/guest Wi-Fi is intercepting traffic | — |
| Network | network.latency |
Round-trip time, sampled and classified | — |
| Network | network.api.<name> |
Per-backend reachability, with DNS/TCP/TLS/timeout/status classification | — |
| Device | device.platform |
OS, version, CPU count | — |
| Device | device.hardware |
Manufacturer, model, OS build | DeviceInfoProvider |
| Device | device.emulator |
Emulator and simulator detection from QEMU fingerprints and simulator env vars | — |
| Device | device.storage |
Free space, with warn/fail thresholds | StorageProvider on mobile |
| App | app.build_mode |
Debug / profile / release, and whether assertions are on | — |
| App | app.version |
Version, build number, package id, installer | AppInfoProvider |
| App | app.locale |
Locale, time zone, UTC offset | — |
| Runtime | runtime.memory |
Resident and peak memory | — |
| Runtime | runtime.frames |
Jank ratio, p50/p95/worst frame time | FrameStatsCollector |
| Runtime | runtime.clock_skew |
Device clock drift against a server Date header |
— |
| Security | security.integrity |
Root and jailbreak heuristics | — |
| Security | security.debugger |
Whether a VM service is attached | — |
| Permissions | permissions.grants |
Which required permissions are granted, and whether a prompt can still appear | PermissionProvider |
Two checks worth calling out #
Clock skew. A device clock that is minutes wrong silently breaks TLS
certificate validation, expires freshly issued JWTs, and gets signed requests
rejected as replays. The user reports a login bug. This check names the actual
cause, measured against a server's Date header with the round trip accounted
for.
Captive portal. A portal returns a 200 with an HTML login page where your
JSON should be. Apps parse that as a malformed API response and report a
backend error. A probe URL that must answer 204 makes it unambiguous.
Wiring the providers #
Each is a one-method interface. Implement it against whatever you already depend on; the package stays out of the way.
App version — with or without package_info_plus
No plugin, using --dart-define:
appInfoProvider: const StaticAppInfoProvider(
AppInfo(
version: String.fromEnvironment('APP_VERSION', defaultValue: 'dev'),
buildNumber: String.fromEnvironment('BUILD_NUMBER'),
),
),
With package_info_plus:
appInfoProvider: CallbackAppInfoProvider(() async {
final PackageInfo info = await PackageInfo.fromPlatform();
return AppInfo(
appName: info.appName,
packageName: info.packageName,
version: info.version,
buildNumber: info.buildNumber,
installerStore: info.installerStore,
);
}),
Device info — with device_info_plus
deviceInfoProvider: CallbackDeviceInfoProvider(() async {
final DeviceInfoPlugin plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
final AndroidDeviceInfo a = await plugin.androidInfo;
return DeviceInfo(
manufacturer: a.manufacturer,
brand: a.brand,
model: a.model,
systemVersion: a.version.release,
sdkInt: a.version.sdkInt,
isPhysicalDevice: a.isPhysicalDevice,
buildTags: a.tags, // "test-keys" here is a rooting signal
supportedAbis: a.supportedAbis,
);
}
final IosDeviceInfo i = await plugin.iosInfo;
return DeviceInfo(
manufacturer: 'Apple',
model: i.utsname.machine,
deviceName: i.name,
systemVersion: i.systemVersion,
isPhysicalDevice: i.isPhysicalDevice,
);
}),
Permissions — with permission_handler
permissionProvider: CallbackPermissionProvider(() async {
const Map<Permission, bool> needed = <Permission, bool>{
Permission.camera: true, // required
Permission.notification: false, // optional
};
final List<PermissionReport> out = <PermissionReport>[];
for (final MapEntry<Permission, bool> e in needed.entries) {
final PermissionStatus s = await e.key.status;
out.add(
PermissionReport(
name: e.key.toString().split('.').last,
isRequired: e.value,
state: switch (s) {
PermissionStatus.granted => PermissionState.granted,
PermissionStatus.limited => PermissionState.limited,
PermissionStatus.provisional => PermissionState.provisional,
PermissionStatus.permanentlyDenied => PermissionState.permanentlyDenied,
PermissionStatus.restricted => PermissionState.restricted,
PermissionStatus.denied => PermissionState.denied,
},
),
);
}
return out;
}),
Free space on mobile
Desktop (df, Get-PSDrive) and web (navigator.storage.estimate()) are
handled with no plugin. Android and iOS expose no unprivileged, plugin-free API
for free space, so supply one:
storageProvider: CallbackStorageProvider(() async {
final double? mb = await DiskSpacePlus().getFreeDiskSpace;
if (mb == null) return null;
return StorageSnapshot(
freeBytes: (mb * 1024 * 1024).round(),
source: 'disk_space_plus',
);
}),
Adding your own checks #
Anything you can await, you can check. Custom checks are scheduled, timed, ordered and rendered exactly like the built-in ones.
class LicenceServerCheck extends DiagnosticCheck {
const LicenceServerCheck();
@override
String get id => 'app.licence';
@override
String get title => 'Licence';
@override
DiagnosticCategory get category => DiagnosticCategory.app;
// Runs after connectivity, and skips automatically if it failed.
@override
Set<String> get dependencies => const <String>{NetworkCheckIds.internet};
@override
Future<CheckOutcome> perform(DoctorContext context) async {
final Licence licence = await licences.current();
if (licence.isValid) {
return CheckOutcome.pass('Valid until ${licence.expiresAt}');
}
return CheckOutcome.fail(
'Licence expired ${licence.expiresAt}',
remedy: 'Renew in Settings → Subscription, then restart the app.',
details: <String, Object?>{'expiresAt': licence.expiresAt.toString()},
);
}
}
await FlutterDoctor.run(extraChecks: <DiagnosticCheck>[const LicenceServerCheck()]);
Return a failing outcome for a negative finding; throw only when the check
itself broke. The runner records that as error — "I could not tell" — which
is a different thing from "it is broken", and the report keeps them apart.
Configuration #
DoctorConfig(
// What to probe
endpoints: <ApiEndpoint>[...],
socketTargets: <SocketTarget>[SocketTarget('1.1.1.1', 443)],
dnsProbeHosts: <String>['cloudflare.com'],
captivePortalProbe: Uri.parse('https://acme.com/generate_204'),
// Budgets
checkTimeout: Duration(seconds: 10),
networkTimeout: Duration(seconds: 5),
maxConcurrency: 4,
// What counts as unhealthy
latencyThresholds: LatencyThresholds(slow: Duration(milliseconds: 350)),
storageThresholds: StorageThresholds(warnBelowBytes: 500 * 1024 * 1024),
performanceThresholds: PerformanceThresholds(warnJankRatio: 0.10),
clockSkewThresholds: ClockSkewThresholds(warnAbove: Duration(seconds: 30)),
// Scope
categories: <DiagnosticCategory>{DiagnosticCategory.network},
disabledCheckIds: <String>{'security.integrity'},
// Privacy — off by default
includeSensitiveDetails: false,
)
By default the connectivity probes dial Cloudflare and Google DNS, and the
captive-portal probe calls connectivitycheck.gstatic.cn. Point
socketTargets and captivePortalProbe at your own infrastructure if you
would rather not.
Platform support #
| Android | iOS | macOS | Windows | Linux | Web | |
|---|---|---|---|---|---|---|
| Connectivity, DNS, latency | ✓ | ✓ | ✓ | ✓ | ✓ | partial¹ |
| API reachability | ✓ | ✓ | ✓ | ✓ | ✓ | ✓² |
| Captive portal | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Emulator detection | ✓ | ✓ | — | — | — | — |
| Free storage | provider | provider | ✓ | ✓ | ✓ | ✓ |
| Root / jailbreak | ✓ | ✓ | — | — | — | — |
| Memory | ✓ | ✓ | ✓ | ✓ | ✓ | — |
| Frames, clock skew, build mode, locale | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
¹ Browsers expose no resolver or raw sockets, so the connectivity check falls
back to navigator.onLine and the DNS check skips.
² The Fetch API refuses to say why a cross-origin request failed, so a CORS
rejection and an unreachable host are indistinguishable. The report says so
rather than guessing.
Anything a platform cannot answer is reported as skipped with the reason —
never as a pass.
Design notes #
- Nothing throws.
FlutterDoctor.run()always returns a report. A check that throws or overruns its deadline becomes oneerrorresult; the rest of the run continues. A diagnostic must never be the thing that takes an app down. - Independent checks run concurrently, bounded by
maxConcurrency; dependent ones wait. Results are ordered deterministically so two runs of the same configuration produce comparable output. - Everything is injectable.
PlatformAdapter,NetworkProbeand every provider are interfaces, so checks are unit-testable with no device and no network. The package's own suite runs entirely offline. - Cost. A full run makes a handful of small requests — two TCP handshakes, a few DNS lookups, one 204 probe, three latency samples and one request per configured endpoint. Run it on user request or when something has already gone wrong, not on every app launch.
Licence #
MIT — see LICENSE.