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

@@ -26,6 +26,28 @@
MainActivity.kt's runtime request. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- An app this updater delivers can expose its own recent log through
a ContentProvider, which is the only way its lines can reach a
phone with no adb: Android forbids one app reading another's
logcat. This app defines the permission that guards it and holds
it itself; the contract is in README.md under "An app's own log".
protectionLevel="normal" rather than "signature", deliberately, and with a cost. These apps are built and signed on one machine but
with *different* locally generated keys: the updater's own, and
each project's. so a signature permission would be held by
nothing at all and the feature could not exist. What normal means
is that any app on the phone that requests this permission by name
can read another app's dev log. That is a development phone
carrying development builds, and the alternative was no log; the
reasoning is in AGENTS.md and in ai-app's docs/DECISIONS.md
(2026-09-07). -->
<permission
android:name="dev.updater.permission.READ_DEVLOG"
android:label="@string/devlog_permission_label"
android:description="@string/devlog_permission_description"
android:protectionLevel="normal" />
<uses-permission android:name="dev.updater.permission.READ_DEVLOG" />
<!-- Package visibility (API 30+): without this, PackageManager.getPackageInfo()
for another app throws NameNotFoundException even when it's
installed, see InstalledBuilds.kt, which queries lastUpdateTime to
@@ -365,6 +365,27 @@ fun componentLog(
)
}
/**
* Forwards lines this phone read out of an installed app's devlog provider into that APK
* component's runtime log on the build machine.
*
* Already rendered, one string per line (see [DevLogLine.render]). The server appends the bytes and
* parses nothing, which is what it also does with a service's stdout -- so an app is free to change
* its own log format without anything on the build machine being taught about it.
*
* An APK runs on this phone, so this phone is the only thing that can read what it wrote. Sending
* it is what makes the tab render from the same store a service's runtime log does, and what makes
* the history outlive the phone.
*/
fun postRuntimeLog(key: String, component: String, lines: List<String>) {
val body = JSONObject().put("lines", JSONArray(lines)).toString()
requestFromServer(
"/apps/$key/components/$component/runtime-log",
method = "POST",
jsonBody = body,
) {}
}
// ---------------------------------------------------------------------------
// The rescue contract.
//
@@ -30,6 +30,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -45,6 +46,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
/** What the line field means when it is empty or zero: as much as the server will read. */
@@ -66,6 +69,21 @@ private val FOOT_BUTTON_PADDING = PaddingValues(horizontal = 8.dp)
/** Wide enough for four digits, which is more lines than anybody asks for by hand. */
private val LINES_FIELD_WIDTH = 76.dp
/**
* How often an installed app's devlog provider is asked for what it has said since last time.
*
* A second, and only while this tab is open — the poll stops with the dialog. An app's log is
* something somebody is watching happen, so anything slower reads as a tab that is not working;
* anything faster is a binder round trip and a request to the build machine per frame, for a
* process that logs a handful of lines a minute.
*
* It is a poll rather than a `ContentObserver` because the contract does not require a provider to
* call `notifyChange` — an app whose ring is filled from arbitrary threads would have to add that
* plumbing to be readable at all, and the point of the contract is that implementing it is cheap. A
* provider that does notify loses nothing by this.
*/
private const val DEVLOG_POLL_MS = 1000L
/**
* The log's own panel, a step *down* the surface ladder rather than up.
*
@@ -110,19 +128,41 @@ fun ComponentLogDialog(
var requestedLines by remember { mutableStateOf(DEFAULT_LINES) }
val submitLines = { requestedLines = lines.toIntOrNull() ?: ALL_LINES }
var generation by remember { mutableStateOf(0) }
// Only a server runs here, so only a server can have a runtime log to
// switch to. An APK gets no tab row rather than a row of one, which
// would be a control that cannot do anything.
val hasBothKinds = component.kind == "server"
// Both kinds for every component. A server's runtime log is what its
// service script reports; an APK's is what this phone reads out of
// that app's own devlog provider and forwards. The tab is drawn even
// where there is neither, because its absence would be the signal --
// "this app exposes no log" and "nobody has looked" are different
// things, and only one of them is worth acting on.
// What it is doing now is the usual question, so the runtime log is
// the default -- except for the component a build stopped at, where
// the thing worth reading is why it stopped, and for an APK, which
// has no runtime here and so no tab to escape to.
// the thing worth reading is why it stopped.
var kind by remember {
mutableStateOf(
if (!hasBothKinds || component.buildFailed) LogKind.Build else LogKind.Runtime
)
mutableStateOf(if (component.buildFailed) LogKind.Build else LogKind.Runtime)
}
// What this phone can find out about the app this component installs.
// Null while it is being asked, which is its own state: a binder round
// trip is fast but not instant, and "we have not looked yet" must not
// draw as "there is nothing here".
var source by remember { mutableStateOf<DevLogSource?>(null) }
// Bumped by each poll that actually forwarded something, which is what
// re-reads the log below. Only when there was something, so an idle
// app does not cost a request a second to the build machine.
var forwarded by remember { mutableIntStateOf(0) }
// Whether the build machine has anything stored for this component
// yet. It starts as what the manifest said and turns true the moment
// this tab forwards a line, because the card's snapshot predates that.
var storedRuntime by remember { mutableStateOf(component.hasRuntimeLogs) }
// Kept apart from the read's own failure below: what is on screen came
// back fine and what the app is saying now is not getting through, so
// clearing one on the other's success would hide whichever failed
// second.
var forwardFailure by remember { mutableStateOf<String?>(null) }
// What the app's own ring last reported about itself. Shown because
// it is the only thing on screen that says the provider is answering
// *now* -- a stored log that has stopped growing looks the same
// whether the app is quiet or this phone has lost sight of it.
var ring by remember { mutableStateOf<DevLogStatus?>(null) }
var log by remember { mutableStateOf<ComponentLog?>(null) }
// Rendered once per read rather than per recomposition, and kept out
// here so Copy can reach it: what goes on the clipboard is
@@ -136,12 +176,53 @@ fun ComponentLogDialog(
val available =
when (kind) {
LogKind.Build -> component.hasBuildLogs
LogKind.Runtime -> component.hasRuntimeLogs
LogKind.Runtime -> storedRuntime
}
// Asked once per component, not per tab switch: the answer is about
// what is installed on this phone, which the dialog cannot change.
LaunchedEffect(component.name, component.apk?.packageName) {
source =
if (component.isServer) null
else withContext(Dispatchers.IO) { devLogSource(context, component.apk?.packageName) }
}
// The poll, and the only thing that writes this component's runtime
// log. It runs while the Runtime tab is open and stops when it closes
// or the dialog goes -- a cancelled LaunchedEffect is the whole of
// that, so there is nothing left running behind a dialog nobody can
// see.
val readable = source as? DevLogSource.Available
LaunchedEffect(entryKey, component.name, kind, readable) {
val authority = readable?.authority ?: return@LaunchedEffect
if (kind != LogKind.Runtime) return@LaunchedEffect
while (isActive) {
try {
val poll =
withContext(Dispatchers.IO) {
forwardDevLog(context, authority, entryKey, component.name)
}
ring = poll.status
if (poll.sent > 0) {
storedRuntime = true
forwarded += 1
}
forwardFailure = null
} catch (e: DownloadServerException) {
// Reported here rather than swallowed: with the send
// failing, what is on screen stops being what the app is
// saying, and nothing else would say so. The poll keeps
// going -- the cursor did not move, so the next one sends
// the same lines.
forwardFailure = e.message ?: "Couldn't send this app's log to the build machine"
}
delay(DEVLOG_POLL_MS)
}
}
// Re-read whenever any control moves. Keyed rather than driven by a
// callback so the two cannot disagree about what is on screen.
LaunchedEffect(entryKey, component.name, requestedLines, generation, kind) {
LaunchedEffect(entryKey, component.name, requestedLines, generation, kind, forwarded) {
failure = null
// Nothing to fetch, and asking anyway would come back as a failure
// in red -- which is the wrong thing to say about a log that
@@ -175,7 +256,7 @@ fun ComponentLogDialog(
title = { Text("${component.name} · log") },
text = {
Column {
if (hasBothKinds) {
run {
// Primary rather than the plain `TabRow`, which is
// deprecated in favour of the two that say where they
// sit -- these are this dialog's top-level
@@ -225,7 +306,7 @@ fun ComponentLogDialog(
ProgressBar()
Spacer(Modifier.height(8.dp))
}
failure?.let {
listOfNotNull(failure, forwardFailure).forEach {
OutputText(it)
Spacer(Modifier.height(8.dp))
}
@@ -239,12 +320,22 @@ fun ComponentLogDialog(
when (kind) {
LogKind.Build ->
"No build log yet — this component hasn't been built from here."
LogKind.Runtime -> "This component reports no runtime log."
LogKind.Runtime -> runtimeAbsence(component, source)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
ring?.let { held ->
if (kind == LogKind.Runtime) {
Text(
"the app is holding ${held.held} line(s)" +
if (held.dropped > 0) ", ${held.dropped} dropped" else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
log?.let { loaded ->
if (loaded.truncated) {
Text(
@@ -370,3 +461,35 @@ private fun copyToClipboard(context: Context, label: String, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
}
/**
* What the Runtime tab says when there is nothing stored for this component yet.
*
* Five different things, because they are five different situations and only one of them is worth
* acting on. A component that runs on the build machine reports through its service script, and a
* script that offers no log is an ordinary script rather than a failure. A component that runs on
* this phone reports through the devlog contract, and there the question is what this phone found
* when it asked: nothing to ask about, nothing installed, an app that implements no provider, or an
* app whose provider refused us — which is the only one that is a fault, and the only one that
* names its cause.
*
* Never one sentence covering several: a check that came back empty and a check that could not be
* made must not share a wording, or nothing on screen ever says which happened.
*/
private fun runtimeAbsence(component: ProjectComponent, source: DevLogSource?): String =
when {
component.isServer -> "This component reports no runtime log."
source == null -> "Asking this phone what this app exposes…"
source is DevLogSource.NoPackage ->
"This component hasn't been built, so there is no app on this phone to read."
source is DevLogSource.NotInstalled ->
"${source.packageName} isn't installed on this phone, so there is no log to read."
source is DevLogSource.NoProvider ->
"${source.packageName} exposes no devlog, so this phone can't read what it logs. " +
"See the contract in dev-updater's README."
source is DevLogSource.Refused ->
"${source.packageName} has a devlog and refused this app: ${source.reason}"
// Readable, and nothing forwarded yet -- the poll is running and
// the app has said nothing since this phone started watching.
else -> "Nothing logged yet. This app's log appears here as it arrives."
}
@@ -0,0 +1,250 @@
package com.example.devupdater
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import androidx.core.content.edit
import androidx.core.net.toUri
/*
* Reading an installed app's own recent log, on this phone.
*
* Android forbids one app reading another's `logcat`, so an app that a
* person can only reach through this updater -- no `adb`, no terminal --
* has no way to say what it did. The way out is for the app to carry a
* bounded copy of its own log and expose it, and the cheapest place to
* hand it over is the phone the two are already on: no tunnel, no token,
* no second enrolment.
*
* So this is a *contract*, not a feature for one app. Any app this server
* delivers can implement it and get a Runtime tab; the whole of it is in
* the README under "An app's own log", and the reading half is here. What
* this app then does with the lines is forward them to the build machine,
* so the tab renders from the same store a service's runtime log does and
* the history outlives the phone.
*
* Read access is guarded by `dev.updater.permission.READ_DEVLOG`, which
* this app defines and holds -- declared once, in `AndroidManifest.xml`,
* where the reason it is `normal` rather than `signature` is written down.
*/
/** Where an app's devlog provider lives, derived from the package rather than declared anywhere. */
fun devLogAuthority(packageName: String) = "$packageName.devlog"
/** One line of an app's own log, as its provider reports it. */
data class DevLogLine(
val seq: Long,
val atMillis: Long,
val level: String,
val target: String,
val message: String,
) {
/**
* `12:34:56.789 INFO some::target: the message` — one line of text, which is what gets
* forwarded.
*
* Rendered here rather than on the build machine, deliberately: the server appends bytes and
* knows nothing about levels or targets, exactly as it knows nothing about what a service
* prints to stdout. Teaching it this shape would make every managed app's log format something
* to keep in step over there.
*
* UTC, because the phone's offset is not what the log is read against -- the build machine's
* own log is, and it is in UTC too.
*/
fun render(): String {
val ms = atMillis % 1000
val secondsOfDay = (atMillis / 1000) % 86_400
val clock =
String.format(
java.util.Locale.ROOT,
"%02d:%02d:%02d.%03d",
secondsOfDay / 3600,
(secondsOfDay % 3600) / 60,
secondsOfDay % 60,
ms,
)
return String.format(java.util.Locale.ROOT, "%s %-5s %s: %s", clock, level, target, message)
}
}
/**
* What an app's provider says about its ring as a whole.
*
* [dropped] is what the ring's own bound discarded, which the app counts rather than this app
* inferring from a gap: "the log starts here" and "the log was cut off here" are different things
* to be told. [newestSeq] is -1 for a ring nothing has been written to, and it is also what makes a
* restart detectable -- see [forwardDevLog].
*/
data class DevLogStatus(val held: Long, val dropped: Long, val newestSeq: Long)
/**
* What this phone can find out about one component's devlog.
*
* Four answers rather than a nullable authority, because the dialog has something different to say
* about each and three of them are not faults. The one that would be a fault -- a provider that is
* there and refuses us -- is [Refused], which is the state a missing permission produces and the
* one worth naming.
*/
sealed interface DevLogSource {
/** The provider answered, so there is something to read. */
data class Available(val authority: String) : DevLogSource
/** This component has never been built, so there is no package to ask about. */
data object NoPackage : DevLogSource
/** The app this component installs is not on this phone. */
data class NotInstalled(val packageName: String) : DevLogSource
/** It is installed and exposes no devlog provider — the ordinary case for most apps. */
data class NoProvider(val packageName: String) : DevLogSource
/** There is a provider and it would not let this app read it. */
data class Refused(val packageName: String, val reason: String) : DevLogSource
}
private fun statusUri(authority: String): Uri = "content://$authority/status".toUri()
private fun linesUri(authority: String, since: Long): Uri =
"content://$authority/lines?since=$since".toUri()
/**
* Asks the provider outright rather than looking it up in `PackageManager`.
*
* A resolved `ProviderInfo` says a provider is declared; a successful query says it answers, which
* is the thing actually being reported. It is also the only way to tell a provider that refuses
* this app from one that isn't there -- both look identical from the metadata.
*
* Blocking (a binder round trip): invoke from a background dispatcher.
*/
fun devLogSource(context: Context, packageName: String?): DevLogSource {
if (packageName == null) return DevLogSource.NoPackage
if (!isInstalled(context, packageName)) return DevLogSource.NotInstalled(packageName)
val authority = devLogAuthority(packageName)
return try {
context.contentResolver.query(statusUri(authority), null, null, null, null).use { cursor ->
if (cursor == null) DevLogSource.NoProvider(packageName)
else DevLogSource.Available(authority)
}
} catch (e: SecurityException) {
DevLogSource.Refused(packageName, e.message ?: "the provider refused this app")
}
}
/** The provider's `/status` row, or null if it stopped answering since it was resolved. */
private fun devLogStatus(resolver: ContentResolver, authority: String): DevLogStatus? =
resolver.query(statusUri(authority), null, null, null, null).use { cursor ->
if (cursor == null || !cursor.moveToFirst()) return null
DevLogStatus(
held = cursor.getLong(cursor.getColumnIndexOrThrow("held")),
dropped = cursor.getLong(cursor.getColumnIndexOrThrow("dropped")),
newestSeq = cursor.getLong(cursor.getColumnIndexOrThrow("newest_seq")),
)
}
/** Every line the provider holds from [since] on, oldest first. */
private fun devLogLines(
resolver: ContentResolver,
authority: String,
since: Long,
): List<DevLogLine> =
resolver.query(linesUri(authority, since), null, null, null, null).use { cursor ->
if (cursor == null) return emptyList()
val seq = cursor.getColumnIndexOrThrow("seq")
val atMillis = cursor.getColumnIndexOrThrow("t_ms")
val level = cursor.getColumnIndexOrThrow("level")
val target = cursor.getColumnIndexOrThrow("target")
val message = cursor.getColumnIndexOrThrow("message")
buildList {
while (cursor.moveToNext()) {
add(
DevLogLine(
seq = cursor.getLong(seq),
atMillis = cursor.getLong(atMillis),
level = cursor.getString(level).orEmpty(),
target = cursor.getString(target).orEmpty(),
message = cursor.getString(message).orEmpty(),
)
)
}
}
}
/**
* Reads whatever the provider has that this phone has not forwarded yet, sends it to the build
* machine, and answers how many lines that was.
*
* Zero is the ordinary answer between two polls and means nothing needs redrawing.
*
* The cursor is stored on this device rather than being asked for each time, so reopening the tab
* does not post the whole ring again. **It is reset when the app's own sequence has gone
* backwards**: the ring is in memory, so an app that restarted starts again at zero, and a cursor
* left where it was would skip everything that app has said since -- silently, which is the failure
* worth guarding rather than the one worth reporting.
*
* Blocking on both halves: invoke from a background dispatcher.
*/
fun forwardDevLog(
context: Context,
authority: String,
key: String,
component: String,
): DevLogForward {
val resolver = context.contentResolver
val status = devLogStatus(resolver, authority) ?: return DevLogForward(null, 0)
val stored = devLogCursor(context, key, component)
val since = if (status.newestSeq >= 0 && status.newestSeq + 1 < stored) 0 else stored
val lines = devLogLines(resolver, authority, since)
if (lines.isEmpty()) {
// Still worth writing back, so a reset is not re-decided every
// second while an app that restarted says nothing.
setDevLogCursor(context, key, component, since)
return DevLogForward(status, 0)
}
// The ring is bounded, so an app that logged faster than this poll
// reads has already thrown some away. Said in the log rather than
// left as a jump in the sequence numbers nobody reads: a log missing
// its middle looks exactly like one that was quiet.
val missed = lines.first().seq - since
val rendered =
if (missed > 0) listOf(gapLine(missed)) + lines.map(DevLogLine::render)
else lines.map(DevLogLine::render)
postRuntimeLog(key, component, rendered)
// Only after the post: a failed send must be retried from the same
// place, exactly as the app's own uploader retries from its cursor.
setDevLogCursor(context, key, component, lines.last().seq + 1)
return DevLogForward(status, lines.size)
}
/** What one poll found: the ring as a whole, and how many lines it forwarded. */
data class DevLogForward(val status: DevLogStatus?, val sent: Int)
private fun gapLine(missed: Long) =
"-- $missed line(s) were dropped from this app's own log before the next one --"
private const val CURSORS_PREFS = "devlog-cursors"
/** Keyed like every other per-component preference here; see `VariantChoice.kt`. */
private fun slot(key: String, component: String) = "$key/$component"
private fun devLogCursor(context: Context, key: String, component: String): Long =
context
.getSharedPreferences(CURSORS_PREFS, Context.MODE_PRIVATE)
.getLong(slot(key, component), 0)
private fun setDevLogCursor(context: Context, key: String, component: String, seq: Long) {
context.getSharedPreferences(CURSORS_PREFS, Context.MODE_PRIVATE).edit {
putLong(slot(key, component), seq)
}
}
/**
* Forgets where this phone had got to in every component of a project being removed.
*
* The path out for what [forwardDevLog] writes, and the same one [forgetVariants] is: a key reused
* by a later project would otherwise inherit a cursor nobody set, and skip that app's log up to
* whatever number it happened to be at.
*/
fun forgetDevLogCursors(context: Context, key: String) {
val prefs = context.getSharedPreferences(CURSORS_PREFS, Context.MODE_PRIVATE)
prefs.edit { prefs.all.keys.filter { it == key || it.startsWith("$key/") }.forEach(::remove) }
}
@@ -1743,6 +1743,7 @@ private fun AppListScreen(
},
onRemove = {
forgetVariants(context, entry.key)
forgetDevLogCursors(context, entry.key)
manage(entry, removes = true) {
removeApp(entry.key)
}
@@ -3392,9 +3393,13 @@ private fun ComponentCard(
// whatever the text does. Unconditional, so the settings
// button sits in the same place whether or not there is a
// log beside it -- the log's own absence must not move it.
if (component.hasLogs) {
IconGlyphButton(LOG_GLYPH, "Show ${component.name}'s log") { showingLog = true }
}
// Unconditional like the gear beside it, and for the
// same reason: a component always has both kinds of log
// to ask about, and a button that comes and goes makes
// its own absence the answer. There is nothing here yet
// and nobody has looked are different things, and the
// dialog is where the difference gets said.
IconGlyphButton(LOG_GLYPH, "Show ${component.name}'s log") { showingLog = true }
// Always drawn, including for a component with a single
// build mode and nothing to strip: what a component can be
// told is part of what it is, and a control that comes and
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- The permission declared in AndroidManifest.xml. Android shows
these to a person deciding whether to install an app that asks
for it, so they say what it gives away rather than naming the
mechanism. -->
<string name="devlog_permission_label">Read a development app\'s own log</string>
<string name="devlog_permission_description">Lets this app read the recent log lines that
another locally-built app is keeping about itself, so they can be shown and sent to the
build machine.</string>
</resources>