Moin! 👋
In March this year I took a closer look at batterystats-daily.xml and also wrote a parser for it in ALEAPP – but I never got around to writing a blog post about it. Time to change that.
Back in March I found almost nothing about this file. Mattia Epifani briefly touches on it in his post A first look at Android 14 forensics, but without going into detail.
So here is what I learned about it – including one thing I got wrong in the ALEAPP parser I wrote.
Where Is It?
/data/system/batterystats-daily.xml
In the images I looked at, Android 11 to 13 stored the file as plain XML (Josh Hickman's public images and the MSAB CTF image), Android 14 to 16 as ABX (Android Binary XML – Josh Hickman's Android 14 image and my own FP4, Samsung S24 and Pixel 8a). The ALEAPP parser handles both.
What Does It Look Like?
The file holds one <item> per day. Here is a real example, shortened, from Josh Hickman's public Android 14 image:
<daily-items>
<item start="1721286235254"
end="1721375770462">
<dis n="31">
<s v="fiF-a-35ce3f"/>
...
</dis>
<chg n="97">
<s v="fF-64-15f11"/>
...
</chg>
<upd pkg="com.waze"
ver="1030478"/>
...
</item>
...
</daily-items>
start/end: Unix epoch in milliseconds, UTC – the only real points in time in the whole file. Here: 2024-07-18 07:03:55 UTC to 2024-07-19 07:56:10 UTC.<dis>/<chg>: discharge and charge steps,nis the number of steps.<upd>: package changes – more on these below.
In Josh Hickman's Android 14 image and on my FP4 and Pixel 8a, the file contained exactly 10 day entries. In Josh Hickman's image they cover 18 to 28 July 2024. So expect roughly the last ten days.
Reading a Step
Each <s v="…"/> is one step of the battery level. The value has three parts:
fiOI-1f-376fa9
fiOI mode letters
1f level (hex)
= 31 %
376fa9 duration (hex, ms)
= 3,633,065 ms
= ~60.5 min
Three things to keep in mind when reading these by hand:
- Both numbers are hex.
fF-64-15f11is 100 %, not 64 %. - The duration is a duration, not a timestamp. You know how long each step took, but not when it happened.
- A battery level of 0 is written as an empty string. The encoder (
appendHexin AOSPBatteryStats.java) skips leading zeros, so 0 produces nothing at all. In Josh Hickman's data there is a stepfiF--14b0ac– 0 %, right after steps going down from 8 %. The phone simply ran empty.
Newest Step First
The steps are stored in reverse order. In the AOSP code (LevelStepTracker.addLevelSteps) every new step is inserted at index 0, and the data shows the same: in a <dis> block the battery level rises from top to bottom, in a <chg> block it falls. The top entry is always the most recent one.
The same method explains another pattern: when the level jumps by more than one percent between two updates, the elapsed time is split evenly across the steps. That is why you see pairs like fF-64-25d45 / fF-64-25d45 or fF-63-2812b / fF-63-2812a – one interval, divided into two steps.
The Mode Letters
The letters come from encodeEntryAt in AOSP BatteryStats.java. Lowercase letters describe the state at the start of the step, uppercase letters come from the modified mode bits.
| Letter | State at step start |
|---|---|
f | screen off |
o | screen on |
d | screen doze |
z | screen doze suspend |
p | power save active |
i | device idle active |
For power save and device idle the uppercase letter is a simple flag: P or I means the mode changed during the step.
F, O, D and Z Are Not "Display Changed To …"
My first version of the parser labelled F as "display changed to OFF", O as "display changed to ON" and so on. It looked plausible and seemed to fit the data – oF reads nicely as "on at the start, then switched off".
It is wrong.
Every single step carries exactly one of F, O, D or Z. If these letters meant "the display changed to …", the screen would have changed in every step – including 80 consecutive charging steps.
The AOSP code (BatteryStatsImpl.java, screen state handling) shows what is actually stored. The screen state is encoded as Display.STATE_* - 1 (off = 0, on = 1, doze = 2, doze suspend = 3), and on every screen change Android does:
mModStepMode |=
(mCurStepMode
& STEP_LEVEL_MODE_SCREEN_STATE)
^ stepState;
So the modified bits are the XOR of old and new state, OR-ed over all changes within the step. The uppercase letter is just this two-bit value written as a letter:
| Letter | Bits | Meaning |
|---|---|---|
F | 0 | no screen change during the step |
O | 1 | bit 0 flipped, e.g. on ↔ off |
D | 2 | bit 1 flipped, e.g. off ↔ doze |
Z | 3 | both bits, e.g. on ↔ doze – or several changes in one step |
The takeaway: the letter tells you whether the screen state changed. It does not reliably tell you which transition happened, because several changes are merged into one value.
Three Devices, One Model
This is where comparing data sets helps.
In Josh Hickman's image only F and O occur – no D, no Z. That fits a device going straight from on to off (on ↔ off = O). There are also long runs of oF while charging: screen on at the start, no change – a phone charging with the screen on.
My FP4 (Android 14) looks the same: 977 steps over ten days, every single one with exactly one of the four letters, and only F (559) and O (418) – again no doze, again no D or Z.
On my own Google Pixel 8a with always-on display, Z dominates the discharge steps. When locked, the screen goes from on to doze instead of off – on ↔ doze = Z. Overnight charging shows almost only fF: screen off, no change.
Devices with different display behaviour, and all patterns fit the same model. None of them fits "display changed to …".
App Updates (Package Changes)
Each day entry also contains <upd> elements with package name and version code:
<upd pkg="com.waze" ver="1030478"/>
<upd pkg="com.waze" ver="1030478"/>
<upd pkg="com.waze" ver="1030478"/>
They are written from notePackageInstalledLocked in BatteryStatsImpl.java, so for installs and updates.
Josh Hickman documents what he did on his test devices, so this can be checked. According to the documentation of his Android 14 image
- Waze was installed on 24 July 2024,
- AllTrails and BeReal were installed, WhatsApp and Signal updated on 26 July 2024,
- 23 apps were updated on 27 July 2024 – Discord, Instagram, Telegram, Threema, TikTok, Zoom and many more.
Every single one of them shows up in the matching day entry of batterystats-daily.xml, with the updated version code – e.g. MEGA 13.5 as ver="241780257", exactly the build number in the documentation. Installs and updates look the same, the file does not tell them apart.
A normal install or update usually shows up three times in a row, sometimes four. I have not figured out why yet.
Uninstalls Show Up as ver="0"
The Android code that writes the file actually has its own element for removed packages: <rem>. It does not show up in any of the files I looked at for this post, though – and the code explains why. In notePackageUninstalledLocked (android14-release) the package change is created with mUpdate = true and without a version code. An uninstall is therefore written as <upd> with ver="0", not as <rem>.
The data fits this:
com.google.android.trichromelibrary_<version>withver="0"shows up regularly around Chrome and WebView updates – old versions of the shared Trichrome library being removed.- On one of my own devices, a package appears with a regular version code and later in the same day entry with
ver="0".
I have not verified this with a controlled install/uninstall test. If you do – I would be very interested in the result.
What Does It Prove – and What Not?
A day entry shows
- that the battery was charged or discharged in steps of the given duration within the day's time range,
- the battery level of each step,
- the screen, power save and idle state at the start of each step,
- whether power save, idle and the screen state changed during the step,
- which packages were installed or updated (and probably uninstalled) within the day's time range.
It does not show
- when exactly a step or a package change happened – only the day entry has timestamps,
- which screen transition happened when
O,DorZis set.
Parsing It
ALEAPP parses the file as Android Battery Stats Daily (batterystats.py). Current state:
- Discharge and charge steps are parsed, including the state at start and the change flags.
- Since early October,
F/O/D/Zare no longer labelled as "display changed to …" but as the value of the modified screen-state bits. <upd>entries are not parsed yet.- A battery level of 0 (empty string) comes out empty instead of 0.
A Note on Validation
To be honest: I did not work cleanly here in March.
I had tested the parser against real data from several Android versions before it was merged. For one of my test devices I even kept a manual log of when it was plugged into the charger, when it was unplugged and when apps were used – and the charge and discharge steps matched that log.
What the log did not contain: what the screen did. So the labels for F/O/D/Z were never checked against anything – and I still took the correct decoding as confirmation for them as well. It was not. Real data alone cannot tell you what a letter means. You need to know what the screen actually did, or ask the right question about the data. I didn't ask it.
In early October this year the parser in ALEAPP was adjusted by the maintainers: the labels for F/O/D/Z were changed from "display changed to …" to the plain bit value, based on the AOSP code. When I went through my notes again for this post, I checked that against my data.
The question that settled it took two minutes: does every step carry one of these letters?
The takeaway: if you read a field as a "change" flag, count whether there are entries without it.
Further Reading
Happy examining. 🐢
No comments:
Post a Comment