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.
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.
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.
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.
Install & initialise
Add the package
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+.
Initialise once at startup
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.
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.
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.
Map route names to flow names once, in one place.
MaterialApp(
navigatorObservers: [
BatteryFlowRouteObserver(
flowForRoute: (route) => switch (route.settings.name) {
'/checkout' => 'checkout',
'/scan' => 'scan',
_ => null, // not a flow: attribution stays with what's underneath
},
),
],
);Works with go_router and auto_route too: add the observer to their observers list and map on route.settings.name.
Use this for tabs, bottom sheets, drawers or anything else that isn't a route. The flow is measured while the widget is mounted.
BatteryFlowScope( flow: 'map_tab', child: MapTab(), )
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.
2 · Every-user tracking
Once reporting is on, the whole life of the app is measured without any further code:
| What | Event | Answers |
|---|---|---|
| Sessions: each foreground and each background stretch | battery_session | How much battery does our app use in the foreground and in the background, per user, model and release? |
| Segments: stretches of constant state | battery_segment | What does the screen cost? Does background location hurt? Which state regressed? |
| On-device totals: 14 days by default (configurable), by app state and flow | none (kept locally) | An in-app "battery usage" screen, support diagnostics, or your own backend |
Read today's usage in the app
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:
// 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
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:
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
| Field | Source | Available on |
|---|---|---|
capacityMah | Worked out by the package from this phone's readings | Phones with a charge counter (most) |
designMah | Android (OEM power profile) | Most phones |
healthPct | capacity ÷ design | Where both are known |
cycleCount | Android, as reported | Android 14+ |
status | Android, as reported | All 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
healthPctis null, so comparecapacityMahwith 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:
// 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.
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.
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),
);await BatteryInsights.instance.init( sink: (name, params) => mixpanel.track(name, properties: params), deviceSink: (name, value) => mixpanel.getPeople().set(name, value), ); // Amplitude: amplitude.track(BaseEvent(name, eventProperties: params))
await BatteryInsights.instance.init(
sink: (name, params) => eventQueue.add({'event': name, ...params}),
);
// Batch-upload eventQueue with your existing sync. Don't open a
// connection per event.await BatteryInsights.instance.init( config: const BatteryInsightsConfig(enabled: true), ); // Use startFlow()/finish(), usage and reports locally.
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.
// e.g. Firebase Remote Config key "battery_insights":
// {"enabled": true, "samplePct": 10, "rollAfterS": 900}
BatteryInsights.instance.configure(BatteryInsightsConfig.fromJson(
jsonDecode(remoteConfig.getString('battery_insights')),
));| Field | JSON key | Default | Meaning |
|---|---|---|---|
enabled | enabled | false | Master switch |
samplePct | samplePct | 100 | Share of installs that report. Each install draws a fixed bucket once, so the cohort stays stable across sessions |
rollAfter | rollAfterS | 15 min | A segment older than this closes at the next sample |
minSegment | minSegmentS | 5 s | Shorter segments, runs and sessions aren't sent |
minSampleInterval | minSampleIntervalS | 60 s | Throttle for recordSample() |
segmentEvents | segmentEvents | true | Send battery_segment |
flowEvents | flowEvents | true | Send battery_flow |
sessionEvents | sessionEvents | true | Send battery_session |
maxDimensions | maxDimensions | 4 | How many host dimensions can be set at once |
usageDays | usageDays | 14 | Days 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
| Param | Type | Meaning |
|---|---|---|
flow or app_state | string | Flow name, or fg/bg for a session |
duration_s | int | Length |
drain_mah | double | Charge used, from the charge counter (negative if the battery gained charge overall) |
avg_ma | double | drain_mah per hour, for comparing runs of different lengths |
current_ma_avg | double | Mean instantaneous current |
level_start, level_end | int | Battery % |
temp_start_c, temp_max_c | double | Battery temperature, °C |
thermal_status_max | int | Worst 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 |
samples | int | How many readings the run is built from |
battery_segment
| Param | Type | Meaning |
|---|---|---|
flow | string | Innermost open flow, or none outside every flow |
app_state | string | fg, bg or unknown |
charging, screen_on, power_save | '1'/'0' | Plugged in, screen interactive, battery saver on |
brightness | string | auto, or the manual backlight level: low, mid or high |
network | string | wifi, cell, ethernet, other or none |
| your dimensions | string | From setDimension |
duration_s, level_start, level_end | int | Length and battery % |
drain_mah, avg_ma, current_ma_avg | double | As above |
temp_start_c, temp_max_c | double | Battery temperature |
thermal_status_max, thermal_headroom_max | int, double | OS thermal state. Headroom (Android 11+) reaches 1.0 when severe throttling starts |
est_capacity_mah | int | Estimated full-charge capacity |
end_reason | string | What closed it: the dimension that changed, or roll, start or process_death |
end_lag_s | int | Only 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.
| Property | Meaning | Availability |
|---|---|---|
battery_capacity_mah | Full-charge capacity today (stable median, rounded to 50 mAh) | Devices with a charge counter |
battery_design_mah | Factory-rated capacity | Most devices |
battery_cycle_count | Charge cycles | Android 14+ |
battery_health | good, overheat, dead, over_voltage, failure or cold | All |
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.
// 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_CHANGEDintent that the OS already caches, plus a fewBatteryManagerandPowerManagerproperties. 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_mahis 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 = bgwithin 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 |