battery_insights 0.1.1
battery_insights: ^0.1.1 copied to clipboard
Measure real battery drain of your Flutter app on Android — per flow (checkout, camera, calls) and for every user across foreground and background — with no drain of its own.
battery_insights #
Find out what your Flutter app really costs in battery, on your users' real phones.
battery_insights measures battery drain in mAh from the phone's fuel gauge and attributes it to what the app was doing. It covers three jobs:
| What you get | How | |
|---|---|---|
| 1. Flow-wise tracking | "Checkout costs 4.2 mAh per visit; the video call 310 mA" | startFlow() / finish(), BatteryFlowScope, or BatteryFlowRouteObserver → one battery_flow event per visit |
| 2. Every-user tracking | "Our app drains 3 %/h in the foreground and 0.4 %/h in the background, per model, per release" | Automatic: battery_session events for every foreground/background stretch, battery_segment events split by flow, charging, screen, network…, and on-device daily totals |
| 3. Battery health | "This phone's battery holds 82 % of its original charge, after 312 cycles" | BatteryInsights.instance.batteryHealth: full-charge capacity worked out from the phone's own readings, compared with its design capacity |
It adds no drain of its own: no timers, no wake locks, no battery listeners. It reads only when your app is already awake.
📖 Full integration guide: https://aashish-dhiman.github.io/battery_insights/
Contents #
- Install
- Quick start
- 1. Flow-wise tracking
- 2. Every-user tracking
- 3. Battery health
- Sending events to analytics
- Configuration & remote rollout
- Event reference
- Debug tools
- How it stays at zero drain
- Accuracy & limitations
- Platform support
Install #
flutter pub add battery_insights
No permissions to request. The plugin declares only ACCESS_NETWORK_STATE (a normal, install-time permission) to tag segments with wifi/cell.
Quick start #
import 'package:battery_insights/battery_insights.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await BatteryInsights.instance.init(
sink: (name, params) => analytics.logEvent(name: name, parameters: params),
config: const BatteryInsightsConfig(enabled: true),
);
runApp(const MyApp());
}
That alone gives you every-user tracking: foreground/background sessions and segments, reported for every user. Add flows next.
1. Flow-wise tracking #
A flow is anything whose battery cost you want to know: a screen, a feature, a background job. Pick whichever of the three styles fits — they can be mixed.
a) In code — get the cost back #
final run = BatteryInsights.instance.startFlow('video_call');
await callController.runUntilHangUp();
final report = await run.finish();
print('${report?.drainMah} mAh over ${report?.duration}, '
'${report?.avgMa} mA average, max ${report?.maxTempC} °C');
finish() returns a BatteryRunReport (or null while reporting is off, or when the device gave no reading), and a battery_flow event goes to your sink.
b) By route — one mapping for the whole app #
MaterialApp(
navigatorObservers: [
BatteryFlowRouteObserver(
flowForRoute: (route) => switch (route.settings.name) {
'/checkout' => 'checkout',
'/scan' => 'scan',
_ => null, // not a flow: attribution stays with what is underneath
},
),
],
);
c) By widget — for tabs, sheets, drawers, anything that is not a route #
BatteryFlowScope(
flow: 'map_tab',
child: MapTab(),
);
Flows nest. Opening payment inside checkout attributes segments to payment until it closes, then back to checkout. The checkout run still covers the whole visit, payment included.
Add your own dimensions for state that changes inside a flow — camera on/off, a download running, GPS tracking:
BatteryInsights.instance.setDimension('camera', 'preview'); // where it starts
BatteryInsights.instance.setDimension('camera', 'off'); // where it stops
Every change closes the current segment, so drain is split by camera state within each flow — the difference between "the scan flow is expensive" and "the camera is". Up to maxDimensions (default 4) at once.
2. Every-user tracking #
Once enabled, the whole life of the app is measured with no further code:
- Sessions — every foreground stretch and every background stretch becomes a
battery_sessionevent: how long, how many mAh, the average mA, how hot. This is your "how much battery does our app use" number, for every user. - Segments — time is split wherever any state changes (flow, foreground/background, charging, screen on/off, power saver, brightness band, network, your dimensions) and each piece becomes a
battery_segmentevent. Aggregate them to answer "what does the screen cost?", "does background location hurt?", "did release 4.2 regress?". - On-device totals — the last 14 days by default (set
usageDays), by app state and by flow, readable in the app:
final today = BatteryInsights.instance.usageToday;
if (today != null) {
print('Today: ${today.total.drainMah.toStringAsFixed(0)} mAh '
'(foreground ${today.foreground.drainMah.toStringAsFixed(0)}, '
'background ${today.background.drainMah.toStringAsFixed(0)})');
for (final e in today.flows.entries) {
print(' ${e.key}: ${e.value.drainMah.toStringAsFixed(1)} mAh');
}
}
Useful for an in-app "battery usage" screen, a support page, or your own backend — no analytics needed.
Choose how much history is kept with usageDays (default 14, up to 366). 0 turns the on-device totals off and deletes what was stored. You can set it from remote config, or let your app's users decide in a settings screen:
// 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));
}
The choice is persisted like the rest of the config. Lowering it drops the oldest days at once, and raising it lets history grow from what is already kept. Listen to BatteryInsights.instance.debugChanges to rebuild when totals change, or to BatteryInsights.instance.reports for each finished flow run and session.
Keep long stretches sampled. Transitions always read the battery. To also catch plugging in, screen toggles and long idle stretches, call recordSample() from callbacks your app already runs — location updates, a heartbeat, a sync. It is throttled to one read per minSampleInterval (60 s), so calling it often is fine:
locationStream.listen((pos) {
BatteryInsights.instance.recordSample();
// ...
});
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:
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 × 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
How the capacity is worked out. Each reading gives an estimate of charge ÷ battery %. One estimate jitters by several percent, so the package keeps only the cleanest ones — on battery, at 50 % or above, one per 1 % step — and reports the median of the last 25. It needs 3 before it answers (usually within a day of normal use), remembers them across restarts, and follows the battery as it wears or is replaced.
Good to know:
- Estimates build up only while reporting is active (
enabled: trueand in thesamplePctcohort). CallprobeNow()to fill in the Android-reported fields (design, cycles, status) without reporting. - Design capacity comes from a hidden Android setting. A few phones don't expose it; there
healthPctis null, and you can comparecapacityMahwith other phones of the same model instead. - A few percent over 100 % is normal on new batteries and on phones whose displayed battery % is rescaled by the manufacturer. Under about 80 % is a worn battery.
- The same values reach your analytics as device properties through
deviceSink(see below).
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 segment drain dashboards by battery wear.
Sending events to analytics #
The sink receives (eventName, params). Params are flat String/num maps built to fit Firebase Analytics' limits (≤ 25 params, ≤ 40-char names), so they drop into any SDK.
Firebase Analytics
await BatteryInsights.instance.init(
sink: (name, params) =>
FirebaseAnalytics.instance.logEvent(name: name, parameters: params),
// Rarely-changing device facts (capacity, cycles, health) as user properties:
deviceSink: (name, value) =>
FirebaseAnalytics.instance.setUserProperty(name: name, value: value),
);
With the BigQuery export on, see the BigQuery analysis guide for a curated table, correct time-weighted metrics and ready-made dashboard queries.
Anything else (Mixpanel, Amplitude, PostHog, your own API):
sink: (name, params) => mixpanel.track(name, properties: params),
No sink at all? Leave it out and use usage, reports and finish() locally.
Configuration & remote rollout #
Reporting is off by default. Pass a config to init, or call configure whenever your remote config arrives — it is persisted, so a cold start follows the last known switch.
BatteryInsights.instance.configure(BatteryInsightsConfig.fromJson(
jsonDecode(remoteConfig.getString('battery_insights')),
));
| Field | Default | Meaning |
|---|---|---|
enabled |
false |
Master switch |
samplePct |
100 |
Share of installs that report (0–100). Each install draws a fixed bucket once, so the cohort is stable |
rollAfter |
15 min | A segment older than this is closed at the next sample |
minSegment |
5 s | Shorter segments, runs and sessions are not sent |
minSampleInterval |
60 s | Throttle for recordSample() |
segmentEvents |
true |
Send battery_segment |
flowEvents |
true |
Send battery_flow |
sessionEvents |
true |
Send battery_session |
maxDimensions |
4 |
Host dimensions allowed at once (4 keeps segments within Firebase's 25 params) |
usageDays |
14 |
Days of on-device totals to keep (0–366). 0 turns them off and deletes stored totals |
JSON keys: enabled, samplePct, rollAfterS, minSegmentS, minSampleIntervalS, segmentEvents, flowEvents, sessionEvents, maxDimensions, usageDays. Missing or mistyped keys fall back to defaults.
Every method is safe to call before init, while disabled, and repeatedly. None throws.
Event reference #
A value the device does not report is left out, never sent as 0 — a missing param means "unknown".
battery_flow — one per flow visit #
| Param | Type | Meaning |
|---|---|---|
flow |
string | Flow name |
duration_s |
int | Visit length |
drain_mah |
double | Charge used (negative if net charged) |
avg_ma |
double | drain_mah per hour — compare visits 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+): 0 none … 6 shutdown |
charging |
'1'/'0' |
Plugged in at any reading of the visit — exclude from drain analysis |
samples |
int | Readings the visit is built from |
battery_session — one per foreground or background stretch #
Same params as battery_flow, with app_state (fg/bg) in place of flow.
battery_segment — one per stretch of constant state #
| Param | Type | Meaning |
|---|---|---|
flow |
string | Innermost open flow; none outside every flow |
app_state |
string | fg / bg / unknown |
charging |
'1'/'0' |
Plugged in |
screen_on, power_save |
'1'/'0' |
Screen interactive, battery saver on |
brightness |
string | auto, or manual backlight low / mid / high |
network |
string | wifi / cell / ethernet / other / none |
| your dimensions | string | From setDimension |
duration_s |
int | Segment length |
level_start, level_end |
int | Battery % |
drain_mah, avg_ma |
double | Charge used, and per hour |
current_ma_avg |
double | Mean instantaneous current |
temp_start_c, temp_max_c |
double | Battery temperature, °C |
thermal_status_max |
int | Worst OS thermal status (Android 10+) |
thermal_headroom_max |
double | Highest thermal headroom (Android 11+); 1.0 = severe throttling |
est_capacity_mah |
int | Full-charge capacity estimate |
end_reason |
string | What closed it: the dimension that changed, or roll, start, process_death |
end_lag_s |
int | Seconds between the segment's end and its logging (only after a process death) |
Device properties (deviceSink) #
Sent once per process, and again only when they change.
| Property | Meaning | Availability |
|---|---|---|
battery_capacity_mah |
Full-charge capacity today (stable median estimate, 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, cold |
All |
Battery wear = battery_capacity_mah / battery_design_mah — the same healthPct that batteryHealth gives in the app.
Debug tools #
For development builds — you decide where they are reachable.
// A full debug screen: status, live reading, heat, usage today, recent flow
// runs and sessions, the segment in progress, every event sent or dropped.
Scaffold(
appBar: AppBar(title: const Text('Battery')),
body: const BatteryInsightsDebugView(),
);
// A draggable floating HUD over the whole app (level · mA · °C · flow),
// toggled from the debug view or with setOverlayEnabled(true).
MaterialApp(
builder: (context, child) => BatteryInsightsOverlay(child: child!),
);
Both refresh every 5 s while visible, with a display-only read that never touches what is reported.
How it stays at zero drain #
- No timers, wake locks, alarms or battery receivers. The Android side reads the sticky
ACTION_BATTERY_CHANGEDintent the OS already caches, plus a fewBatteryManager/PowerManagerproperties — one binder call each. - Reads only when the app is awake anyway: at a state transition, or from
recordSample(), which you call from work that already runs. - Off by default, sampled by install. Installs outside
samplePctnever read at all.
Accuracy & limitations #
- Charge counter, not percent. Drain comes from the fuel gauge's charge counter (µAh), far finer than battery %. Some gauges only step it in ~1 % increments; there a short visit can read 0 while totals across many are still right.
- Devices without a charge counter report
level_*only; filter ondrain_mahbeing present. - Emulators report a fake battery. Test on a real device.
- Readings only while awake. A change while the app sleeps is noticed at the next read.
- External activities. When a flow hands off to the system camera or a picker, your app goes to the background: that time is
app_state = bgwithin the flow. - Process death. An open segment is persisted and reported at the next launch (
end_reason = process_death). Open flow runs and sessions are not. - Thermal. Android exposes no CPU/skin temperature to apps; thermal status/headroom need a thermal HAL, which some devices lack. Battery temperature is always available.
- Capacity is estimated (charge ÷ level, median of 25 clean readings), since apps cannot read the gauge's full-charge value. Expect it within a few percent of the phone's own figure — consistently, so comparisons within a model hold. Cycle count is Android's own figure (Android 14+), not estimated.
Platform support #
| Android | iOS | Web / desktop |
|---|---|---|
| ✅ API 21+ | ➖ no-op | ➖ no-op |
On unsupported platforms every call is a safe no-op: finish() returns null, no events are sent, nothing throws — so you can call it from shared code. iOS exposes only battery % at 1–5 % steps and no charge counter, too coarse for per-flow numbers; contributions welcome.
Example #
example/ is a runnable app showing route, widget and in-code flows, today's usage and the event log.
Contributing #
Issues and pull requests are welcome at github.com/aashish-dhiman/battery_insights. Please run flutter analyze and flutter test before opening a PR.
License #
MIT © Aashish Dhiman