An app's own log: a devlog contract, and a Runtime tab for an APK component

Android forbids one app reading another's logcat, so an APK this server
delivers has had no way to say what it did to somebody holding the phone
and nothing else. It can now expose its own bounded log through a
ContentProvider at `<applicationId>.devlog`, guarded by a permission
declared here; README.md's "An app's own log" is the whole contract, and
any project this server delivers can implement it.

The phone reads that provider while the Runtime tab is open and forwards
what is new into the component's runtime log on this machine, so the tab
renders from the same store a service's does and the history outlives the
phone. `LogKind::Runtime` stays one kind with two sources rather than
growing a third, and this server parses nothing -- what arrives is one
line of text each, appended, exactly as a service's stdout is.

The log button is now unconditional, like the gear beside it: with the
tab able to say which of several reasons there is nothing to read, its
absence was the one thing that could not say anything at all.

Supersedes ai-app posting its ring to ai-server over the tunnel, which
put a phone's lines under the wrong component and only ever worked for
that one project.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-07 16:43:42 -04:00
1 parent 3727c7c67b
commit 013d7116d7
11 files changed
+772 -44

No files matched your search

+67
View File
@@ -534,6 +534,73 @@ an override anyway. It stays this server's business rather than the build
script's because it is a property of *this* hop to a phone: a project
built without dev-updater has no reason to strip.
## An app's own log
A `Server` component's runtime log is what its service script reports. An
`Apk` runs on the phone instead, where Android forbids one app reading
another's `logcat` — so an app delivered here has no way to say what it
did to somebody holding the phone and nothing else. The way out is for the
app to keep a bounded copy of its own recent log and hand it over on the
device: the two are already on the same phone, so this needs no tunnel, no
token and no second enrolment.
That is a **contract any app served here can implement**, not a feature
for one project. Implement it and that component gets a **Runtime** tab
beside its build log, showing the same view a service's runtime log gets.
Expose a `ContentProvider`, exported and read-only:
| | |
|---|---|
| authority | `<applicationId>.devlog` |
| read permission | `dev.updater.permission.READ_DEVLOG` (`android:readPermission`) |
| `content://<authority>/lines?since=<seq>` | every held line with `seq >= since`, ascending: `seq INTEGER, t_ms INTEGER, level TEXT, target TEXT, message TEXT` |
| `content://<authority>/status` | one row: `held INTEGER, dropped INTEGER, newest_seq INTEGER` |
`insert`, `update` and `delete` throw — there is nothing for anyone else to
change. `t_ms` is unix milliseconds from the app's own clock, because a
line is timestamped when it happened rather than when it was read.
`dropped` is what the app's own bound discarded, counted rather than
inferred, so "the log starts here" and "the log was cut off here" can be
told apart. `newest_seq` is `-1` for a log nothing has been written to,
and it is also what makes a restart visible: an in-memory log starts again
at zero, and a reader whose stored cursor is now past the newest sequence
starts again from the beginning rather than silently skipping everything
since.
The authority is derived from the `applicationId` rather than written
down, so a project's debug and release builds — installed side by side
under different ids — each get their own and cannot read each other's.
The permission is declared by Dev Updater at `protectionLevel="normal"`.
`signature` is not available: the updater and the apps it delivers are
built on one machine but signed with *different* locally generated keys,
so a signature permission would be held by nothing at all. The cost of
`normal` is real and worth saying plainly — on a phone with Dev Updater
installed, any app that requests `dev.updater.permission.READ_DEVLOG` by
name can read another app's dev log. These are development builds on a
development phone, and the alternative was no log at all.
Dev Updater's side of it: while the Runtime tab is open it asks the
provider once a second for what it has not seen, and forwards those lines
to this server, into that component's runtime log. It is a poll rather
than a `ContentObserver` because the contract does not oblige a provider
to call `notifyChange` — implementing it should be cheap. Forwarding
rather than rendering straight from the provider is what makes the history
outlive the phone and what lets one tab render both kinds. The lines
arrive here already formatted, one string each; this server appends them
and parses nothing, exactly as it does with a service's stdout, so an app
can change its own log format without anything here being taught about it.
A component with no provider still gets the tab, and the tab says which of
the several reasons there is nothing to read — the app is not installed,
it exposes no devlog, its provider refused us, or it simply has not logged
anything yet.
The reference implementation is iris's
`iris/android-app/app/src/main/java/dev/iris/android/demo/DevLogProvider.java`
in `~/repos/ai-app-2` (the `rustify` branch), over a ring in Rust.
## Testing
The Kotlin half has no unit tests, but it does have two checks that