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:
1 parent
3727c7c67b
commit
013d7116d7
11 files changed
+772
-44
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user