Summary
Android 16 keeps a record of which app used the camera, microphone, location and other protected resources in two small SQLite databases under system/appops/. This contribution adds the first ALEAPP artifacts that read them, and a companion artifact that lists rows that exist only before the write-ahead log is applied.
- App Ops Access History (Database) reads the
app_op_accessestable with the write-ahead log applied: package, uid, Android user, op name and code, access time in UTC, process state, flags, durations and counts. - App Ops Access History (Database File Only) lists rows that are in the main database file but not in the log-applied view, with a Row Status column.
- On five real extractions the first artifact read 328,519 rows, and an independent recount and a row-by-row comparison found no mismatch.
- Status: In review. The pull request is open as a draft and no maintainer review has been posted yet.
- 2artifacts added
- 29files changed
- 3,609lines added
- 328,519rows reproduced on five images
Methodology note
Drafted with AI assistance and reviewed by the author before publication. The pull request was also prepared with AI assistance, as its description states.
The issue
An examiner is asked whether an app used the microphone during the week before a phone was seized. On Android 16 the answer is stored more than once. A small XML file, appops_accesses.xml, gives the last access time for each app and op. ALEAPP already reads it, and that file is a good place to start. But it keeps only the latest value per key. It cannot say how often, or whether there was earlier use.
The history sits in two SQLite databases, one for a short list of sensitive ops and one for the rest. Until this change no artifact read them, so the examiner saw the last time and none of the days behind it. A second gap is less obvious. These databases use write-ahead logging, so the live view of the database and the bytes of the main file can differ. Rows the device had already removed can still sit in the main file, and an examiner who opens only one file sees only one of the two.
The parser did not read these databases; now it does. The XML artifacts are unchanged, and the older history/*.xml files remain unread.
Show this figure as a table
| File or record | Before | With this change |
|---|---|---|
| appops_accesses.xml | App Ops Recent Accesses | Unchanged |
| appops.xml | App Ops Permission Modes | Unchanged |
| appops/discrete | DiscreteNative | Unchanged |
| app_op_history_short_interval.db | No artifact | Access History (Database) |
| app_op_history_long_interval.db | No artifact | Access History (Database) |
| rows only in the main file | No artifact | Database File Only |
| appops/history/*.xml | No artifact | No artifact (not touched) |
The essence
The main artifact lists one report row for each row of app_op_accesses, in time order, after SQLite has applied the log. It turns the stored numbers into names an examiner can read and keeps the raw values beside them. The companion artifact reads the main file alone and reports any row that the log-applied view lacks or changes.
A row is an aggregate, not one access. By the Android source it covers a time window, holds the time of the first access in that window and counts the rest. The report keeps that meaning in the column names and notes.
| Report column | Stored column | How it is shown |
|---|---|---|
| Access Time | access_time | Milliseconds since the Unix epoch, shown in UTC; the stored value is kept in a second column |
| Package Name, UID | package_name, uid | As stored; one uid can have several packages |
| Android User | uid | uid divided by 100000, so a second user or a Secure Folder is in the same table |
| Permission, Op Code | op_code | Short name from the public Android table, with the number kept |
| App State At Access | uid_state | Named state, such as TOP or BACKGROUND; unknown values shown as stored |
| Access Flag, Attribution Flags | op_flags, attribution_flags | Names of the set bits; unknown bits as a number |
| Access and Total Duration | access_duration, total_duration | As stored; -1 means no duration, 0 a start |
| Access Count, Reject Count | access_count, reject_count | As stored |
| Interval | file name | Short, Long, or Not aggregated, taken from the database file name |
Our methodology
Find the format. We read the platform source at pinned branches to learn the file names, the table, the window lengths, the retention defaults and the meaning of each column, then compared the table definition with the databases on real images. Three layouts are told apart by file name: the two aggregated databases, and the single-file layout of the Android 16.0 release, which was built from source and tested on a constructed database only.
Check it on real data, and without our code. On five Android 16 Samsung extractions the main artifact gave 2,940, 8,587, 286,819, 23,269 and 6,904 rows, 328,519 in all. Counts made with plain sqlite3 and the log applied gave the same numbers, and a row-by-row comparison of the exported file against the source tables found no mismatch on any of the 328,519 rows. Running the reader over two copies of two images doubled every artifact exactly, as a per-root reader should. User 150 appears on three of the five images, on 777 rows.
Check the unit against a second record. Of 3,180 access times in appops_accesses.xml that fall inside the span of the database on the same image, 1,717 equal a database Access Time for the same package and op to the millisecond. That confirms the unit and the epoch. The rest are expected to differ, because the XML holds the last access and the database holds the first access of a window.
Break it on purpose. Synthetic fixtures with invented values cover empty tables, a database without the table, a zero-filled file beside a good one, random bytes, a zero-length file, a log with no shared-memory file, a log that belongs to another database, text and negative and out-of-range times, and counts of two to the fortieth power. Three defects came out of this: a database error from the shared reading routine ended the whole artifact and lost the good sibling file; sorting in SQL on the long database failed when the temporary folder was missing; and negative times converted differently on Windows and Linux. Each is fixed and has a regression case.
Compare with the base. The run-test script gave 162 passes on the branch against 150 on an unchanged copy of the base, the difference being the 12 new cases. The same 180 unit tests failed by name on both, for Windows path and permission reasons unrelated to this work.
Show this figure as a table
| Image | Database rows (log applied) | File-only rows |
|---|---|---|
| Image 1 | 2,940 | 703 |
| Image 2 | 8,587 | 0 |
| Image 3 | 286,819 | 0 |
| Image 4 | 23,269 | 0 |
| Image 5 | 6,904 | 238 |
| All | 328,519 | 941 |
The value
An examiner can now ask a question the XML file cannot answer: in which windows did this package use this op, and how many times, back to the retention limit? On the images tested, the camera, microphone and the two location ops were in the short databases only. Of the 328,519 rows, the largest single image held 286,819.
The file-only artifact helps in a different way. On two images, 703 and 238 rows were in the main file but not in the log-applied view, and every such row was older than the oldest row left in the live view. This is consistent with a retention purge. The database does not record why a row was removed, and the notes say so. It indicates that the main file can hold history the live view no longer shows. An examiner who copies the database without its log, or the log without the database, reaches a different table from the one the device saw.
Equality between the XML time and a database time is the wrong test. The XML time should be matched to a window (Figure 4). That is how the two records are used together without reading a mismatch as a conflict.
What it does not show. A row records that the platform noted an op for a package. It does not say what the app did with it, whether anything was captured or whether the user knew. A row is an aggregate and not a count of accesses unless the count columns are read with the window length. A missing row is not evidence that an app did not use an op, because records are kept for a limited time. The observed spans differed from the Android defaults on some images, and the cause was not determined.
What is pending. The pull request is open as a draft, no maintainer review has been posted and continuous integration had not run when it was written. It lists three unverified points: other Android versions and vendors (a Pixel check would be welcome), the single-file layout of the Android 16.0 release, which is tested on a constructed database only, and the names of ops 156 to 169, which come from the Android source and were not confirmed on a device.
Show this figure as a table
| Group | Times | Share |
|---|---|---|
| Equal database Access Time | 1,717 | 54.0 percent |
| Differ, inside a window with count above 1 | 1,182 | 37.2 percent |
| Not explained by this mechanism | 281 | 8.8 percent |
| All in-span XML times | 3,180 | 100 percent |
Where this sits in the Lab's research
This contribution is the reader behind Lab Technical Report LAB-TR-0003, Where Android Records Who Used What: App Ops History in Databases, XML Files and Bug Reports (version 1.2, preprint, not peer reviewed; DOI 10.5281/zenodo.23720064 on Zenodo, concept DOI 10.5281/zenodo.23717924). The report relates the three forms of one access, which are the databases, the XML file and the bug report dump. It also carries the verification of the 328,519 rows, the test of why the XML and database times differ, and the three defects described above. Read it at Lab Technical Report LAB-TR-0003.
The bug report form is covered by the merged contribution for permissions and app ops in bug reports.
Sources
| Claim or number | Where it comes from |
|---|---|
| Two artifacts; fields, files and path pattern read | PR body (What this changes); diff, scripts/artifacts/appOpsHistoryDb.py |
| 3,609 lines added, 0 removed, 29 files changed, 2 commits; opened 25 October 2026; draft; no reviews or comments | PR metadata (gh pr view, 5 November 2026) |
| 328,519 rows; 2,940 / 8,587 / 286,819 / 23,269 / 6,904 per image | PR body (checklist); LAB-TR-0003 Section 5.1 |
| File-only rows 703 and 238 (941 in all); older than the oldest live row | PR body (checklist); diff notes of appops_history_db_file_only; LAB-TR-0003 Section 5.6 |
| No mismatch on any of the 328,519 rows; independent sqlite3 counts | PR body (checklist); LAB-TR-0003 Section 5.2 |
| Two roots double every artifact; user 150 on 3 of 5 images, 777 rows | PR body (checklist); diff notes of appops_history_db |
| 1,717 of 3,180 XML times equal a database time; 1,182 inside a window with count above 1; 281 unexplained | PR body (checklist: 1,717 of 3,180); LAB-TR-0003 Sections 5.3 and 5.4 (1,182 and 281) |
| 12 new unit tests, 12 new cases; PASS=162 against 150 on the base; the same 180 failing names | PR body (Test record) |
| Adversarial inputs and three defects found | PR body (Test record: adversarial inputs; Anything reviewers should know); LAB-TR-0003 Table 9 |
| Pixel 8 Pro research images, Android 16 and 17; Android 14 image holds none | Diff notes of appops_history_db and appops_history_db_file_only (added after the PR body was written) |
| Camera, microphone and location ops in the short databases only | Diff notes of appops_history_db |
| Unverified: other versions and vendors; single-file layout; op names 156 to 169 | PR body (checklist: unverified on real data) |
| LAB-TR-0003 title, version 1.2, DOIs | LAB-TR-0003 front page; Lab DOI list |