Open Source / iLEAPP #2521

Per-app runtime from the BatteryUI property lists

A sysdiagnose keeps per-app battery runtime in property lists that no iLEAPP artifact read. The numbers carry no units and the bucket width changes between files, so reading them correctly meant testing the unit against the data and reporting the width as stored.

  • Daniel Addai, SectraTEK Lab
  • Merged
  • CC BY 4.0

View the pull request on GitHub (opens in a new tab)

Summary

  • A sysdiagnose carries BatteryUI property lists that hold per-app foreground and background runtime and energy values, by time bucket. Before this contribution no iLEAPP artifact read them.
  • The pull request adds three artifacts: one row per app entry of each bucket, one row per bucket, and one row per file with the query time and, for an upgrade file, the old and new build.
  • The unit of the runtimes was tested on the files, not assumed. The stored bucket width differs between the iOS 26.6 and the iOS 27.0 file, so the report shows the width as stored on every row.
  • A row is a runtime figure the file records for a bucket. It does not show that a person opened the app, and an app without a row is not evidence that it did not run.
  • 3artifacts added
  • 699per-app rows from the iOS 27.0 image (72 buckets, 2 files)
  • 272per-app rows from the iOS 26.6 image (36 buckets, 1 file)
  • 12 of 12registered test cases passing

Cite as

Addai, D., SectraTEK Lab (2026). Per-app runtime from the BatteryUI property lists: an iLEAPP contribution. iLEAPP pull request #2521, merged 20 October 2026. https://github.com/abrignoni/iLEAPP/pull/2521

Methodology note

Drafted with AI assistance and reviewed by the author before publication.

The issue

On a phone, the battery screen answers a plain question: which apps used the device, and for how long? A sysdiagnose keeps the data behind that screen in property lists under logs/BatteryUIPlist. After an operating system update it also keeps an upgrade file whose name carries the old and the new build. Until this contribution, the only mention of BatteryUI in iLEAPP was a table name in the power log reader; the files themselves were not read.

Opening one of them by hand shows why. The numbers carry no units. A foreground runtime of 1200.0 could be seconds or minutes. Bucket boundaries are bare epoch values. The file stores a bucket width, but as Figure 4 shows, the width is not the same from one file to the next, so a reader who assumes an hour per bucket can be wrong by a factor of 24. Figure 1 shows one entry as stored and as reported.

A BatteryUI bucket entry as stored and as reportedFive stored keys with an unknown unit and time zone are shown next to the report columns that give UTC times, the bucket width in seconds and the runtime in seconds.One bucket entry for one app (synthetic example)As stored in the property listPLBatteryUIBucketStartKey = 1700006400PLBatteryUIBucketEndKey = 1700010000PLBatteryUIAppBundleIDKey = com.example.app01PLBatteryUIAppForegroundRuntimeKey = 1200.0PLBatteryUIAppEnergyValueKey = 42Unit of the runtime? Time zone of the epoch?Bucket width? The file does not say.In the reportBucket Start (UTC)2023-11-15 00:00:00Bucket End (UTC)2023-11-15 01:00:00Bucket Seconds3600Range (as stored)DayTapKeyApp Identifiercom.example.app01Foreground Runtime (s)1200.0Energy Value (as stored)42readerThe runtime is labeled in seconds because the unit was tested on the files; the energy value stays "as stored".
Show this figure as a table
Stored key and report column
Stored keyReport column
PLBatteryUIBucketStartKeyBucket Start (UTC), read as Unix epoch seconds
PLBatteryUIBucketEndKeyBucket End (UTC); Bucket Seconds is end minus start
PLBatteryUIAppBundleIDKeyApp Identifier (as stored)
PLBatteryUIAppForegroundRuntimeKeyForeground Runtime (s)
PLBatteryUIAppEnergyValueKeyEnergy Value (as stored)
Figure 1The stored record carries numbers without units; the report states the unit only where it was proved. Source: key names from the diff (scripts/artifacts/batteryUISysdiagnose.py); invented values (synthetic example).

The risk is practical. A runtime read as minutes understates an hour of use sixty-fold. A runtime read against the wrong bucket width places activity in the wrong period. Neither mistake raises an error, so the report would look sound.

The essence

The three artifacts read the BatteryUI lists and report what is stored, with the unit stated only where we proved it. The first has a row for each app entry in each bucket of each of the four range entries, with the bucket's start and end as UTC times in the first columns so the rows reach the timeline. The second has a row for each bucket, including buckets with no app entries. The third has one row per file.

The three artifacts
ArtifactA row isMain columns
Battery UI - Per-App RuntimeOne app entry of one bucket of one range entryBucket start and end (UTC); bucket seconds; range; app identifier and name; foreground and background runtime (s); plugged-in runtimes and call off-screen time as stored; energy values as stored
Battery UI - Time BucketsOne bucketForeground and background time; energy totals; error code; number of app entries, all as stored
Battery UI - Snapshots and Upgrade MarkerOne fileQuery time; last charge time and level; for an upgrade file, the old and new build taken from the file name; data duration as stored

The notes shipped with the artifacts say what each column is. Energy values are labeled "as stored" because no unit is established. The range entries overlap, so rows of different ranges are not added together. An upgrade file is a lead that the build changed, not a statement of when.

Our methodology

The reader was written from the layout of the files. A Breakdown section holds four range entries; two hold one bucket each and two hold a list. Every bucket has stored start and end keys, and a list of app entries. The reader walks that tree and yields a row at each level (Figure 2). Where a key is missing or not a number, the cell stays blank rather than receiving a guess.

The unit was the hard part, so we tested it against the data instead of assuming it. Four tests, run on the iOS 27.0 and the iOS 26.6 files:

  • no foreground runtime in a tap bucket exceeds the bucket's stored width in seconds (on the wider corpus, one value does, by 8.6 seconds in an upgrade file);
  • in the iOS 27.0 file, ten values equal exactly 3,600.0, the signature of an hour cap on a value in seconds;
  • in the iOS 26.6 file the largest value in an hour-wide bucket is 2,635.4, far more than an hour holds in minutes;
  • each bucket's foreground and background time equals the sum of its app values.
From the BatteryUI files to three reportsA BatteryUI property list holds a Breakdown of four range entries, each with buckets, each bucket with app entries. File, bucket and app entry become one report row each.BatteryUI filesBatteryUISysdiagnose.plistBatteryUIUpgrade_<old>_<new>.plistBreakdownFour range entries:DayKey, DayTapKey,WeekKey, WeekTapKeyBucketsStart and end (epoch),foreground and backgroundtime, energy totalsApp entriesIdentifier and name,runtimes, plugged-inruntimes, energySnapshotsOne row per file:query time, last charge,old and new buildTime BucketsOne row per bucket,including empty onesPer-App RuntimeOne row per app entryof each bucketRange entries overlap:rows of different ranges arenot added together.
Figure 2Three levels of the file each become a report: the file, the bucket and the app entry within a bucket. Source: scripts/artifacts/batteryUISysdiagnose.py in the pull request (diagram).

Figure 3 puts the first three on one scale. Together they support seconds. They do not prove it for every file, and the notes say which files were read. Time values are read as Unix epoch seconds; in both source files the bucket boundaries fall on whole hours, and the main file's last bucket ends at the local midnight after the date in the sysdiagnose folder name.

Tested unit of the stored runtimesBars on a scale from 0 to 4,000: the stored bucket width of 3,600 seconds, the largest runtime in an hour-wide bucket of 2,635.4, ten values equal to 3,600.0, and 60, the most an hour could hold if the unit were minutes.01,0002,0003,0004,000Stored bucket width (iOS 26.6)3,600 sLargest hour-wide value (iOS 26.6)2,635.4Ten values in the iOS 27.0 file3,600.0 exactlyWhat an hour holds in minutes60Value in seconds (unit of the stored runtime, as tested)
Show this figure as a table
Unit evidence
ObservationValue
Stored width of the iOS 26.6 DayTapKey bucket3,600 s
Largest value in an hour-wide bucket, iOS 26.6 file2,635.4
Values equal to exactly 3,600.0, iOS 27.0 file10; none larger
What one hour holds if the unit were minutes60
Figure 3A value of 2,635.4 in an hour-wide bucket is far beyond 60, so the unit is not minutes; ten values sit exactly at the one-hour cap. Source: pull request text (unit check on the iOS 26.6 and iOS 27.0 files); real values, not synthetic.

Counts were checked with separate code. On the iOS 27.0 image the reader gave 699 per-app rows in 72 buckets from two files, 2 snapshot rows and 701 timeline rows; on the iOS 26.6 image, 272 rows in 36 buckets from one file. Each count equals one taken straight from the property lists with no iLEAPP code, and the HTML, TSV, LAVA and timeline outputs match.

During review the maintainer asked for more data, so the reader was run on five older sysdiagnoses from a public test corpus (iOS 13.3.1 to 26.6). Four gave rows: 443 per-app rows in 36 buckets on iOS 14.3, 173 in 36 on iOS 17.3, 1,473 in 144 on one iOS 26 image and 156 in 108 on another. The iOS 13.3.1 image holds only an older BatteryUI.plist, which the reader names as not read. That run also showed where the files differ: no stored bucket times on iOS 14.3 and boundaries off the hour on iOS 17.3. The notes were extended to say so. On one image the figures did not line up with the power log for the same hours (of 39 app-hours, 5 were shared), so the notes warn that one does not stand in for the other.

Test data is synthetic or sanitized. The unit tests build a tree with invented com.example.* apps and formula-built numbers in both bucket shapes. The registered cases add two copies of the real structure with every identifying value replaced, all times shifted by one constant offset of whole weeks, and runtimes and energy values scaled by fixed factors while the stored relations are kept. The replacement mapping is not in the repository.

The value

The artifact lets an examiner ask which apps accounted for foreground and background runtime in a given period, from a sysdiagnose alone, without a full file system extraction. The time columns put the rows on the same timeline as other sources. The bucket width and range are on every row, so the reader can see the resolution of what they are looking at.

Figure 4 is the main caution. In the iOS 27.0 file the DayTapKey and WeekKey buckets are stored 86,400 seconds wide, the same as the other entries, while ten DayTapKey values still sit at the one-hour cap and the WeekKey values exceed the stored width. The stored times of those entries therefore do not bound the values. We report the width as stored and say so in the notes; what produces the day-wide shape is not established.

Stored bucket width by range entry in two filesA grid of four range entries against two files. DayKey and WeekTapKey are 86,400 seconds in both. DayTapKey is 3,600 in the iOS 26.6 file and 86,400 in the iOS 27.0 file. WeekKey is 864,000 and 86,400.Stored width of each bucket, in secondsRange entryiOS 26.6 fileiOS 27.0 fileSame?DayKey86,40086,400YesDayTapKey3,60086,400NoWeekKey864,00086,400NoWeekTapKey86,40086,400YesIn the iOS 27.0 file the WeekKey values exceed the stored width, so stored bucket times do not bound the values.Read Bucket Seconds as stored; do not assume a width.
Show this figure as a table
Stored bucket width in seconds
Range entryiOS 26.6 fileiOS 27.0 file
DayKey86,40086,400
DayTapKey3,60086,400
WeekKey864,00086,400
WeekTapKey86,40086,400
Figure 4Two of four range entries are stored with a different width in the iOS 27.0 file; the artifact reports the stored width on every row. Source: pull request text and artifact notes (widths of the two source files); real values, not synthetic.

A row is a runtime figure kept for a bucket. It shows that the power-logging service summarised that much runtime for the app, not what the app did, and not when within a wide bucket. The file covers a limited window, and its stored data duration has no stated unit (twelve days if it is seconds). An empty result is not evidence of no activity. Only two source files were available for the unit tests, so the findings are about those files and the corpus runs, not about every device.

Where this sits in the Lab's research

This contribution supports LAB-TR-0011, Log Compression and Archive Structure in the iOS 27.0 Sysdiagnose: An Early Observation Tested Against Ten Archives (version 1.2, preprint, not peer reviewed; doi:10.5281/zenodo.23720069). Section 4.3 tests the unit of the runtimes again across ten archives and shows the day-wide bucket shape on the iOS 27.0 archive only among them. The report is on the Lab site at LAB-TR-0011.

LAB-TR-0010, the report on reading new sysdiagnose files, is in preparation and has no public page yet. The method above rests on the pull request.

Sources

Every number and claim above, with where it came from. PR means the pull request text, its diff or its review thread.

Sources for the numbers and claims
Number or claimWhere it came from
Three artifacts; four range entries; three file levelsPR text; scripts/artifacts/batteryUISysdiagnose.py
699 per-app rows, 72 buckets, 2 files, 2 snapshot rows, 701 timeline rows (iOS 27.0); 272 rows, 36 buckets, 1 file (iOS 26.6)PR text, section Tested
Counts equal an independent count from the plists; HTML, TSV, LAVA and timeline matchPR text, section Tested
12 of 12 registered test cases passPR text, section Tested
Ten values equal 3,600.0 (iOS 27.0); largest value 2,635.4 in an hour-wide bucket (iOS 26.6)PR text, paragraph on the unit check; LAB-TR-0011 section 4.3
Bucket widths 3,600 / 864,000 (iOS 26.6) and 86,400 everywhere (iOS 27.0)PR text; artifact notes
One corpus value exceeds its width by 8.6 s (3,608.6 in a 3,600 s bucket)Artifact notes in the diff
Corpus runs 443/36, 173/36, 1,473/144, 156/108 (rows/buckets); iOS 13.3.1 gives nonePR text, section Corpus run (added in review)
5 app-hours shared with the power log of 39Artifact notes in the diff
Stored data duration: twelve days if it is in secondsPR text, section What the data does and does not show
Sanitized copies: times shifted by one offset of whole weeks, values scaled, mapping not in the repositoryPR text, section Real-structure test fixtures
Merged 20 October 2026GitHub pull request metadata (mergedAt)