battery_insights
Flutter · Android · MIT

See what your app costs in battery, on real phones.

battery_insights reads the phone's fuel gauge and attributes every mAh to what your app was doing. It uses no timers, wake locks or listeners, so measuring doesn't drain the battery itself.

flutter pub add battery_insights Get started →
Motive 1

Flow-wise tracking

Measure the cost of each visit to checkout, the camera, a video call or a sync job, and get the number back in code.

“A checkout visit costs a median 4.2 mAh; the scan flow draws 310 mA.”
Motive 2

Every-user tracking

Measure the app's whole life automatically: every foreground and background session for every user, split by state, with daily totals kept on the device.

“Release 4.2 drains 3.1 %/h in the foreground and 0.4 %/h in the background, up from 2.6.”
Bonus

Battery health

Android doesn't give apps battery health, so the package works it out from each phone's own charge readings and compares it with the factory capacity.

“This battery holds 82 % of its original charge, after 312 cycles.”

Install & initialise

1

Add the package

shell
flutter pub add battery_insights

You don't need to request any permissions. The plugin declares only ACCESS_NETWORK_STATE, a normal permission granted at install, so segments can be tagged wifi or cell. Requires Android API 21+.

2

Initialise once at startup

dart
import 'package:battery_insights/battery_insights.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await BatteryInsights.instance.init(
    // Where events go: your analytics SDK (see "Sending to analytics").
    sink: (name, params) => analytics.logEvent(name: name, parameters: params),
    // Reporting is off by default. Turn it on here, or later from remote config.
    config: const BatteryInsightsConfig(enabled: true),
  );

  runApp(const MyApp());
}

That's all you need for every-user tracking: sessions, segments and daily totals start straight away. The next step adds flows.

3

Mark your flows

Choose whichever style below fits each flow, then check on a real device with the debug view.

1 · Flow-wise tracking

A flow is anything whose battery cost you want to know: a screen, a feature or a background job. There are three ways to mark one, and you can mix them in the same app.

Use this when you want the number back, for example to log it, assert it in a benchmark or show it in a dev menu.

dart
final run = BatteryInsights.instance.startFlow('video_call');
await callController.runUntilHangUp();
final report = await run.finish();   // BatteryRunReport?

if (report != null) {
  debugPrint('video_call: ${report.drainMah} mAh in ${report.duration}, '
      '${report.avgMa} mA avg, max ${report.maxTempC} °C');
}

finish() returns null when reporting is off or the device gave no reading (for example on iOS), so it's safe to call from shared code. You can call it more than once; later calls return the same report.

Flows nest

If payment opens inside checkout, segments are attributed to payment until it closes and then go back to checkout. The checkout run still covers the whole visit, including payment.

battery_flow checkout · 4.2 mAh (whole visit) payment · 1.8 mAh battery_segment none checkout payment payment+cam checkout none A flow run is one row per visit. Segments split the same time wherever anything changes, such as the camera turning on.

2 · Every-user tracking

Once reporting is on, the whole life of the app is measured without any further code:

WhatEventAnswers
Sessions: each foreground and each background stretchbattery_sessionHow much battery does our app use in the foreground and in the background, per user, model and release?
Segments: stretches of constant statebattery_segmentWhat does the screen cost? Does background location hurt? Which state regressed?
On-device totals: 14 days by default (configurable), by app state and flownone (kept locally)An in-app "battery usage" screen, support diagnostics, or your own backend

Read today's usage in the app

dart
ListenableBuilder(
  listenable: BatteryInsights.instance.debugChanges,  // fires when totals change
  builder: (context, _) {
    final today = BatteryInsights.instance.usageToday;
    if (today == null) return const Text('Collecting…');
    return Column(children: [
      Text('Today: ${today.total.drainMah.toStringAsFixed(0)} mAh'),
      Text('Foreground: ${today.foreground.drainMah.toStringAsFixed(0)} mAh '
           'over ${today.foreground.duration.inMinutes} min'),
      Text('Background: ${today.background.drainMah.toStringAsFixed(0)} mAh'),
      for (final e in today.flows.entries)
        Text('${e.key}: ${e.value.drainMah.toStringAsFixed(1)} mAh'),
    ]);
  },
)

BatteryInsights.instance.usage has the last usageDays days (14 by default), newest first. Charging time is counted as time but not as drain. Totals come from closed segments, so the segment in progress shows up once it closes.

Let users choose how much history is kept

usageDays sets how many days of totals stay on the phone, from 0 to 366. Setting it to 0 turns the ledger off and deletes what was stored. It's persisted with the rest of the config, so you can offer it as an app setting:

dart
// e.g. a "Keep battery history" setting: Off / 7 / 30 days
void setHistoryDays(int days) {
  final insights = BatteryInsights.instance;
  insights.configure(insights.config.copyWith(usageDays: days));
}

Lowering it drops the oldest days at once. Raising it keeps what's there and lets history grow.

Get every run as a stream

dart
BatteryInsights.instance.reports.listen((r) {
  // r.kind is BatteryRunKind.flow or BatteryRunKind.session
  myBackend.post('/battery', r.toParams());
});

3 · Battery health

Android doesn't give apps the battery's health. The package works it out from the phone's own charge readings as it's used, and pairs it with what Android does report:

dart
final h = BatteryInsights.instance.batteryHealth;

h.capacityMah;  // what a full charge holds today, e.g. 4100 mAh (worked out)
h.designMah;    // factory-rated capacity, e.g. 5000 mAh (from Android)
h.healthPct;    // capacity / design x 100, e.g. 82.0
h.isWorn();     // true under 80 % (pass threshold: to change)
h.cycleCount;   // charge cycles (Android 14+)
h.status;       // Android's verdict: good, overheat, dead, ...
h.samples;      // estimates the capacity rests on
FieldSourceAvailable on
capacityMahWorked out by the package from this phone's readingsPhones with a charge counter (most)
designMahAndroid (OEM power profile)Most phones
healthPctcapacity ÷ designWhere both are known
cycleCountAndroid, as reportedAndroid 14+
statusAndroid, as reportedAll phones

How the capacity is worked out

Each reading gives an estimate of charge ÷ battery %. A single estimate jitters by several percent, so the package only keeps the cleanest ones: taken on battery, at 50 % or above, and one per 1 % step. It reports the median of the last 25. It needs 3 before it gives an answer, which usually takes about a day of normal use. The estimates are kept across restarts, so the number follows the battery as it wears or is replaced.

Estimates only build up while reporting is active. To fill in the fields Android reports (design capacity, cycles, status) without reporting, call probeNow().

  • No design capacity? A few phones don't expose it. There healthPct is null, so compare capacityMah with other phones of the same model instead.
  • Slightly over 100 % is normal for new batteries and for phones whose manufacturer rescales the displayed battery %. Under about 80 % means the battery is worn.
  • In analytics, the same values arrive as device properties through deviceSink.

Use it to answer "is it our app, or a worn-out battery?" in support tickets, to show users their battery's condition, or to split drain dashboards by battery wear.

Your own dimensions

For state that changes inside a flow, such as the camera, a download, GPS tracking or a video playing, set a dimension where the state really changes:

dart
// where the camera controller starts
BatteryInsights.instance.setDimension('camera', 'preview');
// where it stops
BatteryInsights.instance.setDimension('camera', 'off');

Every change closes the current segment, so drain is split by camera state within each flow. That's how you tell "the scan flow is expensive" apart from "the camera is". You can set up to maxDimensions at once (4 by default, which keeps Firebase events within 25 parameters). Seed them with an idle value such as 'off' at startup so every segment carries them.

Keeping long stretches sampled

The battery is always read at transitions. To also catch plugging in, screen toggles and long idle stretches, call recordSample() from callbacks your app already runs. It's throttled to one read per minSampleInterval (60 s), so calling it often is harmless.

dart
locationStream.listen((position) {
  BatteryInsights.instance.recordSample();
  // …your code
});

Don't add a timer just to call recordSample(). Waking the app to measure the battery costs battery. If your app has no periodic work, transitions alone still give correct totals; segments are just longer.

Sending to analytics

The sink receives (eventName, params). Params are flat String/num maps built within Firebase Analytics' limits (at most 25 params and 40-character names), so they work with any SDK.

dart
await BatteryInsights.instance.init(
  sink: (name, params) =>
      FirebaseAnalytics.instance.logEvent(name: name, parameters: params),
  // Rarely-changing facts (capacity, cycles, health) as user properties,
  // so every event carries them without spending event params.
  deviceSink: (name, value) =>
      FirebaseAnalytics.instance.setUserProperty(name: name, value: value),
);

Config & remote rollout

Reporting is off by default. Roll it out from remote config: configure() persists the config, so a cold start follows the last known switch before your remote config arrives.

dart
// e.g. Firebase Remote Config key "battery_insights":
// {"enabled": true, "samplePct": 10, "rollAfterS": 900}
BatteryInsights.instance.configure(BatteryInsightsConfig.fromJson(
  jsonDecode(remoteConfig.getString('battery_insights')),
));
FieldJSON keyDefaultMeaning
enabledenabledfalseMaster switch
samplePctsamplePct100Share of installs that report. Each install draws a fixed bucket once, so the cohort stays stable across sessions
rollAfterrollAfterS15 minA segment older than this closes at the next sample
minSegmentminSegmentS5 sShorter segments, runs and sessions aren't sent
minSampleIntervalminSampleIntervalS60 sThrottle for recordSample()
segmentEventssegmentEventstrueSend battery_segment
flowEventsflowEventstrueSend battery_flow
sessionEventssessionEventstrueSend battery_session
maxDimensionsmaxDimensions4How many host dimensions can be set at once
usageDaysusageDays14Days of on-device totals to keep (0–366). 0 turns them off and deletes stored totals

Missing or mistyped JSON keys fall back to their defaults. Every method is safe to call before init, while disabled, and repeatedly, and none of them throws.

Event reference

If the device doesn't report a value, it's left out rather than sent as 0, so a missing param means "unknown".

battery_flow and battery_session

ParamTypeMeaning
flow or app_statestringFlow name, or fg/bg for a session
duration_sintLength
drain_mahdoubleCharge used, from the charge counter (negative if the battery gained charge overall)
avg_madoubledrain_mah per hour, for comparing runs of different lengths
current_ma_avgdoubleMean instantaneous current
level_start, level_endintBattery %
temp_start_c, temp_max_cdoubleBattery temperature, °C
thermal_status_maxintWorst OS thermal status (Android 10+), from 0 (none) to 6 (shutdown)
charging'1'/'0'Plugged in at any reading. Leave these rows out of drain analysis
samplesintHow many readings the run is built from

battery_segment

ParamTypeMeaning
flowstringInnermost open flow, or none outside every flow
app_statestringfg, bg or unknown
charging, screen_on, power_save'1'/'0'Plugged in, screen interactive, battery saver on
brightnessstringauto, or the manual backlight level: low, mid or high
networkstringwifi, cell, ethernet, other or none
your dimensionsstringFrom setDimension
duration_s, level_start, level_endintLength and battery %
drain_mah, avg_ma, current_ma_avgdoubleAs above
temp_start_c, temp_max_cdoubleBattery temperature
thermal_status_max, thermal_headroom_maxint, doubleOS thermal state. Headroom (Android 11+) reaches 1.0 when severe throttling starts
est_capacity_mahintEstimated full-charge capacity
end_reasonstringWhat closed it: the dimension that changed, or roll, start or process_death
end_lag_sintOnly after a process death: seconds between the segment's end and when it was logged

Device properties (deviceSink)

The same values batteryHealth gives in the app, sent once per process and again only when they change.

PropertyMeaningAvailability
battery_capacity_mahFull-charge capacity today (stable median, rounded to 50 mAh)Devices with a charge counter
battery_design_mahFactory-rated capacityMost devices
battery_cycle_countCharge cyclesAndroid 14+
battery_healthgood, overheat, dead, over_voltage, failure or coldAll

BigQuery & dashboards

If Firebase's BigQuery export is on, the BigQuery analysis guide covers a curated, partitioned segment table with a daily refresh, correct time-weighted metrics, quality filters, and ready-made queries for flows, sessions, heat, battery health and charging.

Always time-weight. Use SUM(drain_mah) / SUM(duration_s / 3600) and never AVG(avg_ma). A plain average gives a 10-second segment the same weight as a 2-hour one.

Debug tools

These are meant for development builds, and you decide where they can be reached from.

dart
// A full debug screen: status, live reading, heat, today's usage, recent
// flow runs and sessions, the open segment, and every event sent or dropped.
Scaffold(
  appBar: AppBar(title: const Text('Battery')),
  body: const BatteryInsightsDebugView(),
);

// A draggable floating HUD (level · mA · °C · flow) over the whole app,
// toggled from the debug view or with setOverlayEnabled(true).
MaterialApp(
  builder: (context, child) => BatteryInsightsOverlay(child: child!),
);

Both refresh every 5 s while they're visible. Those reads are display-only and never change what gets reported.

How it works, and why it adds no drain

  • No timers, wake locks, alarms or battery receivers. The Android side reads the sticky ACTION_BATTERY_CHANGED intent that the OS already caches, plus a few BatteryManager and PowerManager properties. Each is a single binder call.
  • It only reads while the app is awake anyway: at a state transition, or when you call recordSample() from work that's already running.
  • It measures the charge counter, not the percentage. The fuel gauge's µAh counter is far finer than battery %, so even a two-minute flow has a measurable cost.
  • Attribution happens on the device. Each event already holds the drain for exactly one state, so your warehouse only has to add them up.
  • It survives process death. The open segment is persisted and reported at the next launch with end_reason = process_death.

Accuracy & limits

  • Test on a real device. Emulators report a fake battery.
  • Coarse gauges. Some phones only step the charge counter in increments of about 1 %. On those a short visit can read 0 mAh, but totals across many visits are still correct.
  • No charge counter. A few devices report battery % only, so drain_mah is missing there. Filter on it.
  • System activities. When a flow hands off to the system camera or a file picker, your app goes to the background. That time counts as app_state = bg within the flow.
  • Thermal. Apps can't read CPU or skin temperature. Thermal status and headroom need a thermal HAL, which some devices don't have. Battery temperature is always available.
  • Platforms. Only Android is supported. On iOS, web and desktop every call is a safe no-op, so shared code runs unchanged. iOS only exposes battery % in 1–5 % steps, which is too coarse for per-flow numbers.

Integration checklist

Step
☐init() in main(), with a sink and optionally a deviceSink
☐Reporting enabled, from remote config with a small samplePct at first
☐Main flows mapped with the route observer; tabs and sheets wrapped in BatteryFlowScope
☐Costly features (camera, GPS, playback) set as dimensions, seeded with an idle value
☐recordSample() called from existing periodic callbacks, if any
☐Debug view reachable from a dev menu; flows and events checked on a real phone
☐Dashboards built with time-weighted metrics, with charging rows filtered out