Show an ongoing notification while the app is working
Everything this app asks for is minutes of the build machine's time
followed by a download, and all of it runs in the list screen's own
coroutines -- so with the app in the background the process is an
ordinary cached one, and Android is free to take it away halfway
through a build. A foreground service is the only way to say otherwise,
and its notification is both what that costs and what it is for: the
work is visible from the shade while it runs, and one tap comes back to
the card that started it.
The work itself stays where it is. WorkNoticeService runs nothing --
it collects a list of what is happening and reposts one notification,
stopping as soon as the list is empty -- and that list is *derived* by
workItems from the same projectStates/componentStates the cards are
drawn from, so there is nothing to forget to post and nothing to forget
to take back. The words are shared rather than written twice:
componentWorkLine and buildLine are now read by the card's own bars and
by the notification alike.
Measured on API 36 rather than assumed: the collapsed row drops the
content text the moment there is a progress bar, so one thing running
puts both the component and what is happening to it in the title
("Test Tablet / tablet: building 14/30"); several get a line each in
the expanded view and an indeterminate bar, there being no honest
single number for two builds at once. With the app at the home screen
the process sits at fg-service-act rather than cached, and the builds
it started landed while it was there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d00fe1b60
commit
bb65526230
9 files changed
+583
-48
No files matched your search
@@ -845,6 +845,53 @@ mutable at runtime from the phone.
|
|||||||
Reading the output means splitting on carriage returns as well as
|
Reading the output means splitting on carriage returns as well as
|
||||||
newlines: a tool redrawing a counter in place puts several updates and
|
newlines: a tool redrawing a counter in place puts several updates and
|
||||||
then real output inside one `\n`-delimited line.
|
then real output inside one `\n`-delimited line.
|
||||||
|
- **Work in flight holds a foreground service, and the notification is
|
||||||
|
what that costs and what it is for** (`WorkNotice.kt`). Everything this
|
||||||
|
app asks for is minutes of the build machine's time followed by a
|
||||||
|
download, and all of it runs in the list screen's own coroutines -- so
|
||||||
|
with the app in the background the process is an ordinary cached one and
|
||||||
|
Android is free to take it away in the middle of a build. A foreground
|
||||||
|
service is the only way to say otherwise; measured on the emulator, the
|
||||||
|
process sits at `fg-service-act` (oom adj 50) with the app at the home
|
||||||
|
screen, where it would otherwise be cached, and the builds it started
|
||||||
|
landed while it was there.
|
||||||
|
The work itself stays in the screen. The service runs nothing: it
|
||||||
|
collects a list of what is running and reposts one notification, and
|
||||||
|
stops as soon as the list is empty. That list is **derived** by
|
||||||
|
`workItems` from the same `projectStates`/`componentStates` the cards
|
||||||
|
are drawn from, rather than posted by each action that starts something,
|
||||||
|
so there is nothing to forget to post and nothing to forget to take
|
||||||
|
back, and the shade cannot come to claim something the card does not.
|
||||||
|
The words are shared rather than written twice: `componentWorkLine` and
|
||||||
|
`buildLine` are read by the card's own bars and by the notification, for
|
||||||
|
the reason the note about two words for one measurement gives.
|
||||||
|
Three things measured on API 36 rather than assumed. **The collapsed row
|
||||||
|
drops the content text the moment there is a progress bar**, so with one
|
||||||
|
thing running the title carries both the component and what is happening
|
||||||
|
to it (`Test Tablet / tablet: building 14/30`) and nothing is set
|
||||||
|
beside it, which expanded would be the same words twice. **Several at
|
||||||
|
once get an indeterminate bar** and a line each in the expanded view:
|
||||||
|
there is no honest single number for two builds, and averaging them
|
||||||
|
draws something that moves like a measurement without being one.
|
||||||
|
**An app may only start a foreground service while it is in front of
|
||||||
|
somebody**, which is ordinarily where this one is -- work begins with a
|
||||||
|
button, and the service outlives the press; everything after the start
|
||||||
|
is an update to a `StateFlow` the service is already collecting, so a
|
||||||
|
build that finishes while the phone is in a pocket still reports. What
|
||||||
|
is left is work *reappearing* after the list has been empty for a moment
|
||||||
|
with the app by then behind something else, and Android answers that
|
||||||
|
with an exception rather than a return value -- so the start is asked
|
||||||
|
for whenever there is work and nothing showing it, the refusal is
|
||||||
|
caught, and the next thing the run reports asks again. Stops go through
|
||||||
|
`stopSelf(lastStart)` rather than `stopSelf()`, so work starting in the
|
||||||
|
moment between deciding to stop and stopping is not taken away with it.
|
||||||
|
`POST_NOTIFICATIONS` is asked for at startup beside the local-network
|
||||||
|
one, and refusing it costs the watching rather than the work: the
|
||||||
|
service still runs, and there is simply nothing to draw. The type is
|
||||||
|
`dataSync`, whose 6-hour daily budget on Android 15+ arrives as
|
||||||
|
`Service.onTimeout` and is answered by stopping -- unreachable for a
|
||||||
|
build, but a build machine that says "building" and never stops is
|
||||||
|
polled for as long as it keeps saying it.
|
||||||
- **Discovery must stay side-effect free.** It runs on every manifest
|
- **Discovery must stay side-effect free.** It runs on every manifest
|
||||||
request and every suggestion scan.
|
request and every suggestion scan.
|
||||||
- **An app in the list is a *project*, and what it produces is its
|
- **An app in the list is a *project*, and what it produces is its
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ Better still, let the project carry that itself — see [below](#letting-a-proje
|
|||||||
Set `gitPull: false` on a checkout that should never be moved from a
|
Set `gitPull: false` on a checkout that should never be moved from a
|
||||||
phone. Paths accept `~`, and are shown that way.
|
phone. Paths accept `~`, and are shown that way.
|
||||||
|
|
||||||
|
While anything is building, downloading or installing, the app keeps an
|
||||||
|
ongoing notification saying what is running and how far along it is — one
|
||||||
|
line per component, with each build's own count where the command reports
|
||||||
|
one. It is there to be watched, and it is also what keeps the work alive:
|
||||||
|
without it the app is an ordinary backgrounded process, and Android is
|
||||||
|
free to reclaim it partway through a build. It goes away by itself when
|
||||||
|
the last thing finishes. Refusing the notification permission costs the
|
||||||
|
watching and not the work.
|
||||||
|
|
||||||
### Updating this server itself
|
### Updating this server itself
|
||||||
|
|
||||||
The **Dev Updater** card pulls and builds like any other, and is
|
The **Dev Updater** card pulls and builds like any other, and is
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ dependencies {
|
|||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
// NotificationCompat and ServiceCompat, which are how the
|
||||||
|
// version-specific parts of a foreground service get written once
|
||||||
|
// rather than behind a check per call. Declared rather than inherited
|
||||||
|
// transitively, since this module calls it directly.
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.zxing.embedded)
|
implementation(libs.zxing.embedded)
|
||||||
// The half of this app that is the same as ai-app's: pinned TLS, the
|
// The half of this app that is the same as ai-app's: pinned TLS, the
|
||||||
// enrollment store, and the scanner.
|
// enrollment store, and the scanner.
|
||||||
|
|||||||
@@ -26,6 +26,24 @@
|
|||||||
MainActivity.kt's runtime request. -->
|
MainActivity.kt's runtime request. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||||
|
|
||||||
|
<!-- A build takes minutes on the machine at the other end, and this
|
||||||
|
app follows it from its own coroutines, so with the app in the
|
||||||
|
background the process is an ordinary cached one and the system
|
||||||
|
may take it away mid build. WorkNotice.kt holds a foreground
|
||||||
|
service for as long as something is running, which is the only
|
||||||
|
way to say that on Android, and POST_NOTIFICATIONS is what lets
|
||||||
|
its progress actually be seen (API 33+; requested at startup in
|
||||||
|
MainActivity). Denied, the work still runs; there is simply
|
||||||
|
nothing in the shade to watch it with.
|
||||||
|
|
||||||
|
The type is dataSync because that is what this is: a transfer
|
||||||
|
between this phone and the build machine. It carries a 6-hour
|
||||||
|
daily budget on Android 15+, reported through Service.onTimeout,
|
||||||
|
which WorkNoticeService answers by stopping. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- An app this updater delivers can expose its own recent log through
|
<!-- An app this updater delivers can expose its own recent log through
|
||||||
a ContentProvider, which is the only way its lines can reach a
|
a ContentProvider, which is the only way its lines can reach a
|
||||||
phone with no adb: Android forbids one app reading another's
|
phone with no adb: Android forbids one app reading another's
|
||||||
@@ -117,6 +135,15 @@
|
|||||||
android:theme="@style/zxing_CaptureTheme"
|
android:theme="@style/zxing_CaptureTheme"
|
||||||
android:windowSoftInputMode="stateAlwaysHidden" />
|
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||||
|
|
||||||
|
<!-- Started only while something is actually running, and
|
||||||
|
stopped the moment nothing is; it runs no work of its own,
|
||||||
|
see WorkNotice.kt. Not exported: nothing outside this app has
|
||||||
|
any business starting it. -->
|
||||||
|
<service
|
||||||
|
android:name=".WorkNoticeService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<!-- Exposes downloaded APKs (private app storage, see
|
<!-- Exposes downloaded APKs (private app storage, see
|
||||||
ApkInstaller.kt's downloadApk()) to the system package
|
ApkInstaller.kt's downloadApk()) to the system package
|
||||||
installer without making them world-readable. -->
|
installer without making them world-readable. -->
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
private var settingsVersion by mutableStateOf(0)
|
private var settingsVersion by mutableStateOf(0)
|
||||||
|
|
||||||
// Registered up front since permission launchers must be registered
|
// Registered up front since permission launchers must be registered
|
||||||
// before the activity reaches STARTED.
|
// before the activity reaches STARTED. One launcher for the lot of
|
||||||
private val requestLocalNetworkPermission =
|
// them, so the ones below are asked for as a list rather than as two
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
// launches racing each other for the same dialog.
|
||||||
|
private val requestPermissions =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -74,9 +76,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
// on is the one it was measured on. Nothing local can check it --
|
// on is the one it was measured on. Nothing local can check it --
|
||||||
// the emulator this is developed against is API 36, where the
|
// the emulator this is developed against is API 36, where the
|
||||||
// permission is not enforced at all.
|
// permission is not enforced at all.
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
//
|
||||||
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
// POST_NOTIFICATIONS is asked for beside it rather than when work
|
||||||
}
|
// starts, since a dialog over the list the instant somebody
|
||||||
|
// presses Update is a question about what they have just asked
|
||||||
|
// for. Denied, the work and the service keeping it alive run
|
||||||
|
// exactly as before; what is lost is watching from the shade.
|
||||||
|
val wanted =
|
||||||
|
listOfNotNull(
|
||||||
|
Manifest.permission.ACCESS_LOCAL_NETWORK.takeIf {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
|
||||||
|
},
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS.takeIf {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (wanted.isNotEmpty()) requestPermissions.launch(wanted.toTypedArray())
|
||||||
|
|
||||||
// Before the first composition, so the screens can call the server
|
// Before the first composition, so the screens can call the server
|
||||||
// straight away rather than racing a load.
|
// straight away rather than racing a load.
|
||||||
|
|||||||
@@ -1578,6 +1578,17 @@ private fun AppListScreen(
|
|||||||
onDispose { context.unregisterReceiver(receiver) }
|
onDispose { context.unregisterReceiver(receiver) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the shade says while any of this is running, and the
|
||||||
|
// foreground service under it that keeps the process alive to finish
|
||||||
|
// -- a build is minutes of somebody else's machine, and a cached
|
||||||
|
// process is one Android may take away in the middle of it.
|
||||||
|
val running = workItems(loadedEntries, projectStates, componentStates)
|
||||||
|
LaunchedEffect(running) { WorkNotice.post(context, running) }
|
||||||
|
// Leaving the composition cancels the work with it -- all of it runs
|
||||||
|
// in this screen's own scope -- so the notification goes too, rather
|
||||||
|
// than reporting on something that has already stopped.
|
||||||
|
DisposableEffect(Unit) { onDispose { WorkNotice.post(context, emptyList()) } }
|
||||||
|
|
||||||
// How much room the floating button takes at the foot of the list,
|
// How much room the floating button takes at the foot of the list,
|
||||||
// measured from the button rather than written down: a card's own
|
// measured from the button rather than written down: a card's own
|
||||||
// controls run to the bottom edge of the last card, and a number
|
// controls run to the bottom edge of the last card, and a number
|
||||||
@@ -2824,36 +2835,9 @@ private const val DETACHED_HEAD = "HEAD"
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ApkProgress(state: ComponentState?) {
|
private fun ApkProgress(state: ComponentState?) {
|
||||||
when (state) {
|
when (state) {
|
||||||
is ComponentState.Preparing -> BuildProgress("Building for phone", state.status)
|
// The project's phase rather than this component's step, which is
|
||||||
|
// drawn in the component's own row by ComponentBuildProgress.
|
||||||
is ComponentState.Fetching -> {
|
is ComponentState.Preparing -> BuildProgress(BUILDING_LABEL, state.status)
|
||||||
ProgressBar()
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text("Preparing the download...")
|
|
||||||
}
|
|
||||||
|
|
||||||
is ComponentState.Busy -> {
|
|
||||||
ProgressBar()
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text("${state.what}...")
|
|
||||||
}
|
|
||||||
|
|
||||||
is ComponentState.Downloading -> {
|
|
||||||
val progress = state.progress
|
|
||||||
if (progress == null) {
|
|
||||||
ProgressBar()
|
|
||||||
} else {
|
|
||||||
ProgressBar(fraction = { progress })
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
if (progress == null) {
|
|
||||||
"Downloading..."
|
|
||||||
} else {
|
|
||||||
"Downloading... ${(progress * 100).toInt()}%"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Downloaded, and waiting for the installer to be free. No bar:
|
// Downloaded, and waiting for the installer to be free. No bar:
|
||||||
// nothing is happening to it, and a bar would say otherwise for as
|
// nothing is happening to it, and a bar would say otherwise for as
|
||||||
@@ -2861,11 +2845,78 @@ private fun ApkProgress(state: ComponentState?) {
|
|||||||
is ComponentState.ReadyToInstall -> Text("Downloaded, waiting to install...")
|
is ComponentState.ReadyToInstall -> Text("Downloaded, waiting to install...")
|
||||||
|
|
||||||
// A pull is the project's, not the APK's, and is drawn at the foot
|
// A pull is the project's, not the APK's, and is drawn at the foot
|
||||||
// of the card. Everything else here has nothing to show.
|
// of the card. Everything else this phone could be doing is one
|
||||||
else -> {}
|
// line and a bar, in the words componentWorkLine decides -- the
|
||||||
|
// same ones the notification is using for it at the same moment.
|
||||||
|
else -> {
|
||||||
|
val line = componentWorkLine(state, null) ?: return
|
||||||
|
val fraction = line.fraction
|
||||||
|
if (fraction == null) {
|
||||||
|
ProgressBar()
|
||||||
|
} else {
|
||||||
|
ProgressBar(fraction = { fraction })
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(line.text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val BUILDING_LABEL = "Building for phone"
|
||||||
|
|
||||||
|
/** One line about work in flight, and how far through it is where something measured that. */
|
||||||
|
private data class WorkLine(val text: String, val fraction: Float?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is being done to one component right now, or null if nothing is.
|
||||||
|
*
|
||||||
|
* The one place those words are decided, read by the component's own row and by the ongoing
|
||||||
|
* notification, because a second vocabulary for a state the reader has already been told about
|
||||||
|
* reads as a second, weaker signal rather than as the same one said twice.
|
||||||
|
*
|
||||||
|
* [build] is the build machine's own account of it and wins where there is one: while a command is
|
||||||
|
* running, its step and its count are the most specific thing anybody has about it. The rest is
|
||||||
|
* what this phone is doing -- a transfer, or a short call to the server.
|
||||||
|
*
|
||||||
|
* Deliberately not here is the pair of states where nothing is running: a download waiting for the
|
||||||
|
* installer, and a failure. Both are things to read rather than things to wait for.
|
||||||
|
*/
|
||||||
|
private fun componentWorkLine(state: ComponentState?, build: ComponentBuild?): WorkLine? {
|
||||||
|
build?.progressText()?.let {
|
||||||
|
return WorkLine(it, build.fraction())
|
||||||
|
}
|
||||||
|
return when (state) {
|
||||||
|
// The fallback is the moment between the press and the server
|
||||||
|
// reporting a phase, which the card draws nothing for.
|
||||||
|
is ComponentState.Preparing ->
|
||||||
|
WorkLine(buildLine(BUILDING_LABEL, state.status) ?: "$BUILDING_LABEL...", null)
|
||||||
|
is ComponentState.Fetching -> WorkLine("Preparing the download...", null)
|
||||||
|
is ComponentState.Busy -> WorkLine("${state.what}...", null)
|
||||||
|
is ComponentState.Downloading -> {
|
||||||
|
val progress = state.progress
|
||||||
|
WorkLine(
|
||||||
|
if (progress == null) "Downloading..."
|
||||||
|
else "Downloading... ${(progress * 100).toInt()}%",
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This component's step and the count it reported, as its row and the notification both say it. */
|
||||||
|
private fun ComponentBuild.progressText(): String? {
|
||||||
|
val step = step ?: return null
|
||||||
|
val counted = counted() ?: return step
|
||||||
|
return "$step ${counted.done}/${counted.total}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far through that step it is, where the command reported a count of its own. */
|
||||||
|
private fun ComponentBuild.fraction(): Float? = counted()?.let { it.done.toFloat() / it.total }
|
||||||
|
|
||||||
|
/** The count, when there is one worth dividing by. */
|
||||||
|
private fun ComponentBuild.counted(): BuildProgressCount? = progress?.takeIf { it.total > 0 }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install / Update / Reinstall / Retry: the one control here that puts something on this phone.
|
* Install / Update / Reinstall / Retry: the one control here that puts something on this phone.
|
||||||
*
|
*
|
||||||
@@ -4228,12 +4279,22 @@ private fun BuildProgress(label: String, status: BuildStatus?) {
|
|||||||
// same thing again in the one place that cannot say which component it
|
// same thing again in the one place that cannot say which component it
|
||||||
// means. Between pressing the button and the first component starting
|
// means. Between pressing the button and the first component starting
|
||||||
// there is no phase either, and a bar for that moment would flash.
|
// there is no phase either, and a bar for that moment would flash.
|
||||||
val phase = status?.phase ?: return
|
val line = buildLine(label, status) ?: return
|
||||||
ProgressBar()
|
ProgressBar()
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text("$label: $phase")
|
Text(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a whole project is doing, in one line, for the card and for the notification alike.
|
||||||
|
*
|
||||||
|
* Null while the server has reported no phase, which is the moment between the press and the run
|
||||||
|
* starting. The card draws nothing for it; the notification, which has to say something for as long
|
||||||
|
* as it is up, says the action's own word instead.
|
||||||
|
*/
|
||||||
|
private fun buildLine(label: String, status: BuildStatus?): String? =
|
||||||
|
status?.phase?.let { "$label: $it" }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One component's own progress, inside its row.
|
* One component's own progress, inside its row.
|
||||||
*
|
*
|
||||||
@@ -4255,19 +4316,16 @@ private fun BuildProgress(label: String, status: BuildStatus?) {
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ComponentBuildProgress(build: ComponentBuild) {
|
private fun ComponentBuildProgress(build: ComponentBuild) {
|
||||||
val counted = build.progress?.takeIf { it.total > 0 }
|
val text = build.progressText() ?: return
|
||||||
val step = build.step ?: return
|
val fraction = build.fraction()
|
||||||
if (counted == null) {
|
if (fraction == null) {
|
||||||
ProgressBar()
|
ProgressBar()
|
||||||
} else {
|
} else {
|
||||||
ProgressBar(fraction = { counted.done.toFloat() / counted.total })
|
ProgressBar(fraction = { fraction })
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
buildString {
|
text,
|
||||||
append(step)
|
|
||||||
if (counted != null) append(" ${counted.done}/${counted.total}")
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -4282,6 +4340,39 @@ private fun ComponentBuildProgress(build: ComponentBuild) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything running right now, a line each, for the ongoing notification.
|
||||||
|
*
|
||||||
|
* A second reading of the two state maps the cards are drawn from rather than a record of its own,
|
||||||
|
* which is what keeps the shade from ever claiming something the screen does not: there is nothing
|
||||||
|
* to forget to post, and nothing to forget to take back.
|
||||||
|
*
|
||||||
|
* Order is decided here, because the maps have none and lines that reshuffled themselves between
|
||||||
|
* updates would be unreadable: the manifest's own order, each project's line before its components.
|
||||||
|
*/
|
||||||
|
private fun workItems(
|
||||||
|
entries: List<ManifestEntry>?,
|
||||||
|
projectStates: Map<String, ProjectState>,
|
||||||
|
componentStates: Map<String, Map<String, ComponentState>>,
|
||||||
|
): List<WorkItem> =
|
||||||
|
entries.orEmpty().flatMap { entry ->
|
||||||
|
val projectState = projectStates[entry.key]
|
||||||
|
val project =
|
||||||
|
(projectState as? ProjectState.Working)?.let {
|
||||||
|
// No fraction: what a pull or a checkout reports is a
|
||||||
|
// phase, which is a word rather than a count of anything.
|
||||||
|
WorkItem(entry.label, null, buildLine(it.what, it.status) ?: "${it.what}...", null)
|
||||||
|
}
|
||||||
|
val components =
|
||||||
|
entry.components.mapNotNull { component ->
|
||||||
|
val state = componentStates[entry.key]?.get(component.name)
|
||||||
|
componentWorkLine(state, componentBuild(projectState, state, component.name))?.let {
|
||||||
|
WorkItem(entry.label, component.name, it.text, it.fraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listOfNotNull(project) + components
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the build machine is producing a new build for this card.
|
* Whether the build machine is producing a new build for this card.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package com.example.devupdater
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.ServiceCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One thing being worked on right now, as the notification says it.
|
||||||
|
*
|
||||||
|
* [fraction] is only ever a count something actually reported -- a build's own done/total, or the
|
||||||
|
* bytes of a download against the length the server sent. There is no estimate to fall back on, for
|
||||||
|
* the same reason the card's own bar has none: a bar drawn from how long the last run took looks
|
||||||
|
* exactly like one drawn from real work, and the person watching it has no way to tell which they
|
||||||
|
* are looking at.
|
||||||
|
*/
|
||||||
|
data class WorkItem(
|
||||||
|
/** Whose work it is: the project's label, which is what the card is called. */
|
||||||
|
val project: String,
|
||||||
|
/** Which component of it, or null for work on the whole checkout. */
|
||||||
|
val part: String?,
|
||||||
|
/** What it is doing, in the words that component's own row is using for it. */
|
||||||
|
val line: String,
|
||||||
|
val fraction: Float?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ongoing notification for whatever this app has been asked to do, and the foreground service
|
||||||
|
* that keeps the process alive while it happens.
|
||||||
|
*
|
||||||
|
* Everything here is minutes of a build machine's time followed by a download, and all of it runs
|
||||||
|
* in the list screen's own coroutines -- so with the app in the background the process is an
|
||||||
|
* ordinary cached one, and the system is free to take it away halfway through. A foreground service
|
||||||
|
* is the only way on Android to say "there is work here"; its notification is both the price of
|
||||||
|
* that and the point of it, since the work is then visible from the shade while it runs and one tap
|
||||||
|
* comes back to the card that started it.
|
||||||
|
*
|
||||||
|
* The work itself deliberately stays where it is. Every card already reports its own progress and
|
||||||
|
* its own failures through one path, and moving the running of it into the service would be a
|
||||||
|
* second path to keep in step with that one -- the thing this project avoids everywhere else. What
|
||||||
|
* crosses over is a summary, derived from the same two state maps the cards are drawn from, so the
|
||||||
|
* notification cannot come to say something the card does not.
|
||||||
|
*/
|
||||||
|
object WorkNotice {
|
||||||
|
private val work = MutableStateFlow<List<WorkItem>>(emptyList())
|
||||||
|
internal val items: StateFlow<List<WorkItem>> = work.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the service is up, so that [post] asks for a start exactly when nothing is showing
|
||||||
|
* what is running. Written by the service's callbacks and read from the composition, both on
|
||||||
|
* the main thread.
|
||||||
|
*/
|
||||||
|
internal var showing = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Says what is running now. An empty list ends the notification, and the service with it.
|
||||||
|
*
|
||||||
|
* Everything after the first start is an update to [items], which the service is already
|
||||||
|
* collecting, so a build that finishes while the phone is in a pocket still reports.
|
||||||
|
*/
|
||||||
|
fun post(context: Context, items: List<WorkItem>) {
|
||||||
|
work.value = items
|
||||||
|
if (items.isEmpty() || showing) return
|
||||||
|
val app = context.applicationContext
|
||||||
|
try {
|
||||||
|
ContextCompat.startForegroundService(app, Intent(app, WorkNoticeService::class.java))
|
||||||
|
} catch (refused: IllegalStateException) {
|
||||||
|
// Android refuses a foreground service started from the
|
||||||
|
// background, and throws rather than answering. Ordinarily
|
||||||
|
// this is nowhere near it -- work begins with a button -- but
|
||||||
|
// work reappearing after the list has been empty for a moment
|
||||||
|
// can land there, and it must not be a crash. Nothing else is
|
||||||
|
// owed: the work runs regardless, and the next thing it
|
||||||
|
// reports asks again, so the notification appears as soon as
|
||||||
|
// somebody comes back to the app.
|
||||||
|
Log.i(TAG, "not in the foreground, so nothing will show this run: $refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "WorkNotice"
|
||||||
|
|
||||||
|
/** One notification, replaced in place as the work moves on rather than added to. */
|
||||||
|
private const val NOTICE_ID = 1
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "work"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many steps a determinate bar has.
|
||||||
|
*
|
||||||
|
* A notification's progress is an integer out of a maximum, so the maximum is what its resolution
|
||||||
|
* is: 100 would quantise a download to whole percent, which is visible as a bar that moves in
|
||||||
|
* steps.
|
||||||
|
*/
|
||||||
|
private const val PROGRESS_STEPS = 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the process alive for as long as [WorkNotice] says something is running, and draws what
|
||||||
|
* that is.
|
||||||
|
*
|
||||||
|
* It runs nothing itself: it collects the list and reposts the notification, and stops as soon as
|
||||||
|
* the list is empty. Named for the notice rather than for the work because "service" already means
|
||||||
|
* something else throughout this project -- the unit a `Server` component is driven through on the
|
||||||
|
* build machine -- and two meanings for one word costs more than the longer name does.
|
||||||
|
*/
|
||||||
|
class WorkNoticeService : Service() {
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
|
||||||
|
private var watching: Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent start, so that a stop cannot take away work that arrived after it was decided
|
||||||
|
* on. Written and read on the main thread only, which is where both service callbacks and the
|
||||||
|
* collector below run.
|
||||||
|
*/
|
||||||
|
private var lastStart = 0
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
lastStart = startId
|
||||||
|
// Has to happen within seconds of the start whatever the list says
|
||||||
|
// by now, or the system kills the process for not having done it.
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
this,
|
||||||
|
NOTICE_ID,
|
||||||
|
notice(WorkNotice.items.value),
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
WorkNotice.showing = true
|
||||||
|
if (watching == null) {
|
||||||
|
watching = scope.launch {
|
||||||
|
WorkNotice.items.collect { items ->
|
||||||
|
// stopSelf(lastStart) rather than stopSelf(): work
|
||||||
|
// starting again in the moment between deciding to
|
||||||
|
// stop and stopping hands out a newer start id, and
|
||||||
|
// this call is then refused rather than leaving a
|
||||||
|
// started service that has already died.
|
||||||
|
if (items.isEmpty()) stopSelf(lastStart) else show(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 6-hour daily budget Android 15 puts on a `dataSync` service, reported before it is
|
||||||
|
* enforced.
|
||||||
|
*
|
||||||
|
* Nothing here should come anywhere near it -- a build and a download are minutes -- but a
|
||||||
|
* build machine that says "building" and never stops is polled for as long as it keeps saying
|
||||||
|
* it, and that is a wedge nobody would notice from the phone. Stopping is the whole of what is
|
||||||
|
* owed here; ignoring it is an app the system kills instead.
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
WorkNotice.showing = false
|
||||||
|
scope.cancel()
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun show(items: List<WorkItem>) {
|
||||||
|
// Notifications refused is not a reason to stop: the service is
|
||||||
|
// what keeps the build alive, and that runs either way -- there is
|
||||||
|
// simply nothing to draw. Asked rather than left to notify(), which
|
||||||
|
// does nothing at all without it and says so only in the log.
|
||||||
|
//
|
||||||
|
// The version is half the question: below API 33 the permission
|
||||||
|
// does not exist, and asking about one the platform has never heard
|
||||||
|
// of answers "denied" -- which would take the notification away on
|
||||||
|
// every older phone to guard against something that cannot happen
|
||||||
|
// there.
|
||||||
|
if (
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NotificationManagerCompat.from(this).notify(NOTICE_ID, notice(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the shade shows for [items].
|
||||||
|
*
|
||||||
|
* One of them is a title saying which component and what is happening to it, and a bar filled
|
||||||
|
* from that component's own count. Several are a title naming the projects, a line each when
|
||||||
|
* the notification is opened, and an indeterminate bar.
|
||||||
|
*/
|
||||||
|
private fun notice(items: List<WorkItem>): Notification {
|
||||||
|
val builder =
|
||||||
|
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_updating)
|
||||||
|
.setContentIntent(openApp())
|
||||||
|
.setOngoing(true)
|
||||||
|
// The work moves every second or so; alerting on each of
|
||||||
|
// those would make a build a stream of interruptions.
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
// Only between the last thing finishing and this service stopping,
|
||||||
|
// which is milliseconds -- but the notification has to say something
|
||||||
|
// for that moment, and it must not be a claim that work is running.
|
||||||
|
if (items.isEmpty()) return builder.setContentTitle(getString(R.string.work_done)).build()
|
||||||
|
|
||||||
|
val single = items.singleOrNull()
|
||||||
|
if (single == null) {
|
||||||
|
builder
|
||||||
|
.setContentTitle(items.map { it.project }.distinct().joinToString(", "))
|
||||||
|
.setContentText(items.joinToString(" \u00b7 ") { it.line })
|
||||||
|
// Expanded, each one says which part of which project it is
|
||||||
|
// about, counts included -- which is the question somebody
|
||||||
|
// opens this for while two things are building at once.
|
||||||
|
val style = NotificationCompat.InboxStyle()
|
||||||
|
for (item in items) {
|
||||||
|
style.addLine(item.part?.let { "$it: ${item.line}" } ?: item.line)
|
||||||
|
}
|
||||||
|
builder.setStyle(style)
|
||||||
|
} else {
|
||||||
|
// One line, and it is the title: the collapsed row drops the
|
||||||
|
// text under it the moment there is a bar to draw (measured on
|
||||||
|
// API 36), so what is happening has to be said in the line
|
||||||
|
// that survives. Nothing beside it, since expanded that would
|
||||||
|
// be the same words twice.
|
||||||
|
builder.setContentTitle("${heading(single)}: ${single.line}")
|
||||||
|
}
|
||||||
|
// A count only where one thing reported one. Several at once get an
|
||||||
|
// indeterminate bar: there is no honest single number for two
|
||||||
|
// builds, and averaging them or picking one draws something that
|
||||||
|
// moves like a measurement without being one.
|
||||||
|
val fraction = single?.fraction
|
||||||
|
if (fraction == null) {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(PROGRESS_STEPS, (fraction * PROGRESS_STEPS).toInt(), false)
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which card, and which row of it, the work is in: the project's label and the component's. */
|
||||||
|
private fun heading(item: WorkItem): String =
|
||||||
|
item.part?.let { "${item.project} / $it" } ?: item.project
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low importance, so a build does not arrive as a sound and a heads-up every time somebody
|
||||||
|
* presses Update. It is an ongoing report of something they just asked for, not news.
|
||||||
|
*/
|
||||||
|
private fun createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
getSystemService(NotificationManager::class.java)
|
||||||
|
.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.work_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
.apply { setShowBadge(false) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back to the list, and to the card that is doing the work.
|
||||||
|
*
|
||||||
|
* The launcher's own intent rather than a bare component one, so it resumes the task that is
|
||||||
|
* already there instead of putting a second copy of the screen on top of it.
|
||||||
|
*/
|
||||||
|
private fun openApp(): PendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java)
|
||||||
|
.setAction(Intent.ACTION_MAIN)
|
||||||
|
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
.setFlags(
|
||||||
|
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
|
||||||
|
),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- The small icon on the ongoing work notification. A drawable rather
|
||||||
|
than one of the Nerd Fonts glyphs this app draws its other icons with:
|
||||||
|
a notification icon is handed to the system, which scales and tints a
|
||||||
|
silhouette, and there is nothing there to draw text into.
|
||||||
|
|
||||||
|
An arrow into a line, because what all of this work ends in is a build
|
||||||
|
arriving on this phone, and that is the shape the platform itself uses
|
||||||
|
for it. Drawn in white with everything transparent around it, which is
|
||||||
|
the only thing a notification icon may be: Android takes the alpha
|
||||||
|
channel and throws the colour away. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M11,3h2v7h3.5L12,15.5L8.5,10H11Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M5,18h14v2.5H5Z" />
|
||||||
|
</vector>
|
||||||
@@ -8,4 +8,11 @@
|
|||||||
<string name="devlog_permission_description">Lets this app read the recent log lines that
|
<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
|
another locally-built app is keeping about itself, so they can be shown and sent to the
|
||||||
build machine.</string>
|
build machine.</string>
|
||||||
|
<!-- The notification channel the ongoing "this is being built /
|
||||||
|
downloaded" notification belongs to. Named for what the person
|
||||||
|
sees in it rather than for the foreground service underneath. -->
|
||||||
|
<string name="work_channel_name">Work in progress</string>
|
||||||
|
<!-- Shown for the moment between the last thing finishing and the
|
||||||
|
notification going away. -->
|
||||||
|
<string name="work_done">Finished</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in new issue
Block a user