diff --git a/AGENTS.md b/AGENTS.md index 3b9faab..5b40036 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -897,6 +897,44 @@ mutable at runtime from the phone. 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. + **A build that finishes while nobody is looking keeps its notification, + and that notification carries the install.** Handing an APK to the + system installer is starting an activity, which Android refuses from the + background -- fired anyway it does nothing at all, so a download that + worked read as an update that never landed. So `updateComponent` stops + at `ComponentState.ReadyToInstall` whenever this screen is not in front + of anybody, exactly as it already did for the second APK of a + project-wide update, and that state is a `WorkKind.Waiting` row: no bar, + dismissible, out of the group, and carrying an **Install** action whose + `PendingIntent` is the install intent itself. An activity is the one + press a notification may make from any state; a broadcast back into this + app would have to start the installer itself, which is the thing that + cannot be done from there. + Which is why the drawing lives in `WorkNotice` rather than in the + service: that row outlives the last running thing, and something still + alive has to take it down. The list screen is that something, since the + states being drawn are its own. + **Two things had to be true for it to come down again**, and neither was. + `continuePendingInstalls` now begins by forgetting every download whose + install has already happened -- compared as the installed copy's + `lastUpdateTime` against the downloaded file's mtime, rather than by + listening for an install, because the same broadcast reports removals + and a removal is what the wrong-key case is *waiting* for. Without it, + pressing Install on the notification and then returning to the app ran + the whole install a second time (measured, not feared). And + `registerPackageChangeReceiver` dropped **every** arrival that was an + update: `EXTRA_REPLACING` marks the REMOVED half of a replace, the + ADDED half, *and* the REPLACED that follows, so testing it without the + action threw the lot away and an in-place install reached nothing here + until the screen next resumed. Invisible while the resume was the only + thing waiting on it; the moment a notification depended on it, it was + the difference between the row coming down by itself and sitting there + after the install it was for. + What is left is a row that outlives the process: nothing cancels it if + the install happens with this app long since reclaimed. It is + `autoCancel`, so the tap that opens the app takes it away, and the app + is where the truth about that build is -- which is the cheap answer to a + case that costs a stale line in the shade. `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 diff --git a/README.md b/README.md index 0975bce..c18906a 100644 --- a/README.md +++ b/README.md @@ -126,11 +126,16 @@ 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 per thing, grouped together, each with its own build's 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. +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. Refusing the +notification permission costs the watching and not the work. + +A build that finishes while you are somewhere else leaves its notification +up with an **Install** button on it, because Android will not let the app +put the installer on screen from the background. Pressing it installs, and +the notification goes away by itself — as the rest do when their work is +over. ### Updating this server itself diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/InstalledBuilds.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/InstalledBuilds.kt index 5145583..d14c629 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/InstalledBuilds.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/InstalledBuilds.kt @@ -83,8 +83,22 @@ fun registerPackageChangeReceiver( // ADDED, with EXTRA_REPLACING marking the pair. Reporting // the first would put "this package is gone" on screen for // the moment between them, which is a state nothing here - // is in. - if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return + // is in -- so that half is dropped, and only that half. + // + // The flag is on the ADDED half and on the REPLACED that + // follows it as well, so testing it without the action + // threw away every arrival that was an update, which is + // nearly all of them: an in-place install reached nothing + // here until the screen next resumed. Invisible while the + // resume was the only thing waiting on it; the moment a + // notification was, it left one up for a build that had + // just been installed from the notification itself. + if ( + intent.action == Intent.ACTION_PACKAGE_REMOVED && + intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) + ) { + return + } onPackageChanged(packageName) } } diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt index 319caf9..debf969 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt @@ -990,7 +990,8 @@ private fun AppListScreen( * * [offerInstall] false stops at the downloaded file, leaving it in the component's own row for * [continuePendingInstalls] to offer once the installer is free. Only ever false for the second - * and later APKs of one press. + * and later APKs of one press; a download that lands with this screen in the background stops + * there too, whatever it was asked for, since nothing can start the installer from there. * * Answers whether it handed something to the installer, which is how a project-wide Update * knows the installer is taken. Working that out by reading the component states instead cannot @@ -1081,10 +1082,15 @@ private fun AppListScreen( setComponent(entry.key, component, ComponentState.WrongKey(file, mismatch)) return@run false } - if (!offerInstall) { - // Downloaded and waiting its turn at the installer, said - // in this component's own row rather than by a dialog - // covering the card the other install is about. + // Waiting its turn at the installer, said in this component's + // own row rather than by a dialog covering the card the other + // install is about -- and, when this screen is not in front of + // anybody, waiting for somebody to come back to it or to press + // the button on its notification. An install intent is an + // activity, and Android refuses one started from the + // background: fired anyway it does nothing at all, which is a + // download that worked reading as an update that never landed. + if (!offerInstall || !foreground) { setComponent(entry.key, component, ComponentState.ReadyToInstall(file)) return@run false } @@ -1284,6 +1290,37 @@ private fun AppListScreen( scope.launch { updateComponent(entry, component) } } + /** + * Forgets a download whose install has already happened. + * + * The press may have been on the notification rather than on anything here -- that is what the + * button on it is for -- and then nothing on this side was told. Left alone, the download sits + * in [ComponentState.ReadyToInstall] for ever: offered again by [continuePendingInstalls] every + * time this screen comes back, which puts the system installer up for a build that is already + * on the phone. Measured, not feared: pressing Install on the notification and then returning + * to the app ran the whole install a second time. + * + * Decided by comparing what is installed against the file that was downloaded, rather than by + * listening for the install: the package broadcast reports removals too, and a download waiting + * to be installed is exactly what somebody removing the old app first is waiting to install. + * The times are both this device's, and an install can only be later than the download it came + * from. + */ + fun forgetInstalledDownloads() { + val entries = (manifestState as? ManifestState.Loaded)?.manifest?.entries ?: return + for (entry in entries) { + for (component in entry.components) { + val state = componentStates[entry.key]?.get(component.name) + if (state !is ComponentState.ReadyToInstall) continue + val installed = + component.apk?.packageName?.let { installedLastUpdateTimeMillis(context, it) } + if (installed != null && installed >= state.file.lastModified()) { + setComponent(entry.key, component.name, null) + } + } + } + } + /** * Installs a download that was waiting for the old app to be removed, now that it is gone. * @@ -1297,6 +1334,8 @@ private fun AppListScreen( * offer exactly as it was, which is what a cancelled dialog should do. */ fun continuePendingInstalls() { + // Whatever has already been installed is not waiting for anything. + forgetInstalledDownloads() // One at a time, for the reason each of these states exists: the // installer is modal and takes the screen, so offering two puts // the second over the first and loses it. @@ -1572,7 +1611,10 @@ private fun AppListScreen( } ?.let(::updateInstalledState) // The removal this app asked for, arriving before the - // resume above when somebody is quick about it. + // resume above when somebody is quick about it -- and the + // arrival of an install somebody pressed on a + // notification, which is what `continuePendingInstalls` + // starts by forgetting. continuePendingInstalls() } onDispose { context.unregisterReceiver(receiver) } @@ -2842,7 +2884,7 @@ private fun ApkProgress(state: ComponentState?) { // Downloaded, and waiting for the installer to be free. No bar: // nothing is happening to it, and a bar would say otherwise for as // long as the other install takes. - is ComponentState.ReadyToInstall -> Text("Downloaded, waiting to install...") + is ComponentState.ReadyToInstall -> Text(WAITING_TO_INSTALL) // A pull is the project's, not the APK's, and is drawn at the foot // of the card. Everything else this phone could be doing is one @@ -2864,6 +2906,15 @@ private fun ApkProgress(state: ComponentState?) { private const val BUILDING_LABEL = "Building for phone" +/** + * A download that has arrived and is waiting for somebody to install it. + * + * Said here rather than at each of the places that say it, because the notification for one of + * these carries the same words as the row: an Install button under a different sentence would read + * as a different state. + */ +private const val WAITING_TO_INSTALL = "Downloaded, waiting to install..." + /** 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?) @@ -4366,14 +4417,32 @@ private fun workItems( entry.label, null, buildLine(it.what, it.status) ?: "${it.what}...", - null, + WorkKind.Running(null), ) } val components = entry.components.mapNotNull { component -> val state = componentStates[entry.key]?.get(component.name) + // A download that has arrived is the one row still there + // once everything has stopped, and the one with something + // to press rather than something to watch. + if (state is ComponentState.ReadyToInstall) { + return@mapNotNull WorkItem( + entry.key, + entry.label, + component.name, + WAITING_TO_INSTALL, + WorkKind.Waiting(state.file), + ) + } componentWorkLine(state, componentBuild(projectState, state, component.name))?.let { - WorkItem(entry.key, entry.label, component.name, it.text, it.fraction) + WorkItem( + entry.key, + entry.label, + component.name, + it.text, + WorkKind.Running(it.fraction), + ) } } listOfNotNull(project) + components diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt index a0c7f9a..eb96f26 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/WorkNotice.kt @@ -18,6 +18,7 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -29,14 +30,38 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** - * One thing being worked on right now, as the notification says it. + * What a row is about: something happening, or something that has finished and is waiting to be + * pressed. * - * [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. + * Two cases rather than one row with a nullable count, because they differ in everything -- whether + * there is a bar, whether there is a button, whether the row can be dismissed, and whether the + * process is being kept alive for it. A fraction that is null for two different reasons is exactly + * the state this shape makes unsayable. */ +sealed class WorkKind { + /** + * Work in flight. + * + * [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 Running(val fraction: Float?) : WorkKind() + + /** + * Finished: [apk] is on the phone and nothing else happens to it until somebody installs it. + * + * Which is where a build that lands while the app is in a pocket ends up. Android refuses an + * activity started from the background, so the installer cannot be put on screen at that + * moment, and without a notification carrying the press the work would have finished with + * nothing to show for it. + */ + data class Waiting(val apk: File) : WorkKind() +} + +/** One thing this app has been asked to do, as the notification says it. */ data class WorkItem( /** Which card's work it is. The key rather than the label, since two projects may share one. */ val key: String, @@ -46,7 +71,7 @@ data class WorkItem( 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?, + val kind: WorkKind, ) { /** * What this row's notification is filed under, so an update replaces the row it is about and @@ -60,46 +85,64 @@ data class WorkItem( } /** - * The ongoing notification for whatever this app has been asked to do, and the foreground service - * that keeps the process alive while it happens. + * The notifications for whatever this app has been asked to do, and the foreground service that + * keeps the process alive while any of it is still running. * - * 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"; the 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. + * Work in flight 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"; the 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. * - * There is one of those per thing running rather than one for all of them, grouped so they arrive + * There is one notification per thing rather than one for all of them, grouped so they arrive * together -- which is what the shade is for, and what lets a component that finishes take its own * row down while its sibling carries on. * - * 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. + * The drawing lives here rather than in the service because the service is not the thing that + * outlives the work: a build that has arrived and is waiting to be installed keeps its notification + * after the last running thing is over, and something still alive has to take that one down when it + * is installed. The list screen is alive whenever any of this changes, since the states being drawn + * are its own. + * + * The work itself deliberately stays where it is, too. Every card already reports its own progress + * and its own failures through one path, and moving the running of it in here would be a second + * path to keep in step with that one. What crosses over is a summary, derived from the same two + * state maps the cards are drawn from, so a notification cannot come to say something the card does + * not. */ object WorkNotice { private val work = MutableStateFlow>(emptyList()) internal val items: StateFlow> = 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. + * Whether the service is up, so that [post] asks for a start exactly when nothing is holding + * the process for work that is running. Written by the service's callbacks and read from the + * composition, both on the main thread. */ - internal var showing = false + private var showing = false /** - * Says what is running now. An empty list ends the notification, and the service with it. + * What is drawn, and as what. * - * 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. + * A row is posted only when what it says has changed, which is what lets a notification that + * somebody dismissed stay dismissed: repost it because a sibling moved and it comes straight + * back, which is the behaviour nobody forgives. + */ + private var drawn = emptyMap() + + /** + * Says what is happening now: what is running, and what has finished and is waiting to be + * installed. + * + * The service is started while anything is running and only then. Everything after that 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) { work.value = items - if (items.isEmpty() || showing) return + draw(context, items) + if (showing || items.none { it.kind is WorkKind.Running }) return val app = context.applicationContext try { ContextCompat.startForegroundService(app, Intent(app, WorkNoticeService::class.java)) @@ -107,14 +150,92 @@ object WorkNotice { // 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 + // work reappearing after nothing was running 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") } } + + /** Called by the service as it starts, since that is what posts the first notification. */ + internal fun serviceStarted() { + showing = true + } + + /** + * Called as the service stops, which takes the notification it was held up by with it. + * + * Rows for work that was still running go too: nothing is left to update them, and one left + * behind is a bar that will sit at whatever it last said for ever. Anything waiting to be + * installed stays, which is the whole reason it is not the service's to draw. + */ + internal fun serviceStopped(context: Context) { + showing = false + val kept = drawn.values.filter { it.kind is WorkKind.Waiting } + val notifications = NotificationManagerCompat.from(context) + // Taken down here rather than left to `stopForeground`, which was + // measured not to remove it: one this side has posted an update to + // is no longer the notification the service can take away, and + // what was left behind sat there saying "Finished" for ever. + notifications.cancel(FOREGROUND_ID) + (drawn.values - kept.toSet()).forEach { notifications.cancel(it.tag, ROW_ID) } + drawn = kept.associateBy { it.tag } + } + + /** What the shade should show for [items], against what it is showing now. */ + private fun draw(context: Context, items: List) { + createChannel(context) + // Notifications refused is not a reason to stop: the service is + // what keeps a 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( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + val notifications = NotificationManagerCompat.from(context) + val running = items.filter { it.kind is WorkKind.Running } + // The one the service is held up by. Posted here only while there + // is a service holding it -- posted at any other time it would be + // a notification with nothing left to take it down. Nothing is + // said for the moment after the last thing finishes, either: the + // service is on its way out by then and takes this with it, where + // an update would be one more thing drawn and then undrawn. + if (showing && running.isNotEmpty()) { + notifications.notify(FOREGROUND_ID, foregroundNotice(context, running)) + } + // One thing running has no row of its own -- the notification + // above is that row. A group of one is drawn as its header in the + // shade, which is the line of text with the bar left out of it. + val waiting = items.filter { it.kind is WorkKind.Waiting } + val rows = (if (running.size > 1) running else emptyList()) + waiting + rows.forEach { item -> + if (drawn[item.tag] == item) return@forEach + val into = base(context) + // Running rows are grouped under the summary above. One + // waiting to be installed is not: the group is the service's + // and outlives neither it nor, at one row, its own collapsing + // -- and a collapsed group hides the button this row is for. + if (item.kind is WorkKind.Running) into.setGroup(GROUP_KEY) + notifications.notify(item.tag, ROW_ID, row(context, item, into)) + } + val tags = rows.map { it.tag }.toSet() + (drawn.keys - tags).forEach { notifications.cancel(it, ROW_ID) } + drawn = rows.associateBy { it.tag } + } } private const val TAG = "WorkNotice" @@ -145,14 +266,153 @@ private const val CHANNEL_ID = "work" private const val PROGRESS_STEPS = 1000 /** - * Keeps the process alive for as long as [WorkNotice] says something is running, and draws what - * that is. + * One row of the shade, about one thing. * - * It runs nothing itself: it collects the list, draws a row for each thing in it under one summary, - * 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. + * The title says both which component it is and what is happening to it, because a collapsed row + * drops the text under it the moment there is a bar to draw (measured on API 36) -- so anything + * said there is said only to somebody who has already opened it. Nothing is set beside it for that + * reason as well: expanded it would be the same words twice. + */ +private fun row(context: Context, item: WorkItem, into: NotificationCompat.Builder): Notification { + into.setContentTitle("${heading(item)}: ${item.line}") + when (val kind = item.kind) { + // Not dismissible, because it is about something this app is doing + // rather than something it is offering, and it comes down by + // itself when that is over. A bar it can fill only where this + // component's own command reported a count; otherwise it says that + // something is happening and nothing about how far along, which is + // all anybody measured. + is WorkKind.Running -> { + into.setOngoing(true) + if (kind.fraction == null) { + into.setProgress(0, 0, true) + } else { + into.setProgress(PROGRESS_STEPS, (kind.fraction * PROGRESS_STEPS).toInt(), false) + } + } + // No bar: nothing is happening to it, and a bar would say + // otherwise for as long as it sits there. The button is what this + // row is for -- it hands the APK straight to the system installer, + // which is a thing a notification may do from anywhere, where the + // app itself may not start that screen from the background. + is WorkKind.Waiting -> + into + .setAutoCancel(true) + .addAction( + R.drawable.ic_updating, + context.getString(R.string.work_install), + installPress(context, item, kind.apk), + ) + } + return into.build() +} + +/** + * The one the service is held up by: the single row while one thing is running, and the summary its + * rows are grouped under once there are more. + * + * As a summary it names the projects and draws no bar of its own, because the rows underneath carry + * the counts and a bar here could only be an average of theirs -- something that moves like a + * measurement while being nobody's. + */ +private fun foregroundNotice(context: Context, running: List): Notification { + val builder = + base(context) + .setOngoing(true) + .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 (running.isEmpty()) { + return builder.setContentTitle(context.getString(R.string.work_done)).build() + } + val single = running.singleOrNull() + if (single != null) return row(context, single, builder) + return builder + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setContentTitle(running.map { it.project }.distinct().joinToString(", ")) + .build() +} + +/** What every one of them is: the same icon, and the same tap back to the card it is about. */ +private fun base(context: Context): NotificationCompat.Builder = + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_updating) + .setContentIntent(openApp(context)) + // 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) + +/** 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 + +/** + * The system installer, from the shade. + * + * An activity rather than anything of this app's own, because that is the one kind of press Android + * lets a notification make from any state: a broadcast back into this app would then have to start + * the installer itself, which is the background activity start that made this button necessary in + * the first place. + * + * The request code is the row's, so two builds waiting at once cannot come to share one of these. + */ +private fun installPress(context: Context, item: WorkItem, apk: File): PendingIntent = + PendingIntent.getActivity( + context, + item.tag.hashCode(), + installApkIntent(context, apk), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + +/** + * 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(context: Context): PendingIntent = + PendingIntent.getActivity( + context, + 0, + Intent(context, 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, + ) + +private var channelMade = false + +/** + * 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(context: Context) { + if (channelMade || Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + context + .getSystemService(NotificationManager::class.java) + .createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.work_channel_name), + NotificationManager.IMPORTANCE_LOW, + ) + .apply { setShowBadge(false) } + ) + channelMade = true +} + +/** + * Keeps the process alive for as long as [WorkNotice] says something is running. + * + * It draws nothing but the one notification it is held up by, which it has to post itself; the rest + * is [WorkNotice]'s, for the reason given there. 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()) @@ -165,33 +425,20 @@ class WorkNoticeService : Service() { */ private var lastStart = 0 - /** - * The rows drawn as of the last update, so that one whose work is over can be taken down. - * - * The path out. Rows are keyed by what they are about rather than by position, so nothing - * removes them by itself: a component that finished while others carry on would otherwise sit - * in the shade reporting a build that is over, at whatever it last said. - */ - private var drawn = emptySet() - 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 + createChannel(this) // 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, FOREGROUND_ID, - foregroundNotice(WorkNotice.items.value), + foregroundNotice(this, WorkNotice.items.value.filter { it.kind is WorkKind.Running }), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ) - WorkNotice.showing = true + WorkNotice.serviceStarted() if (watching == null) { watching = scope.launch { WorkNotice.items.collect { items -> @@ -200,7 +447,7 @@ class WorkNoticeService : Service() { // 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) + if (items.none { it.kind is WorkKind.Running }) stopSelf(lastStart) } } } @@ -222,149 +469,9 @@ class WorkNoticeService : Service() { } override fun onDestroy() { - WorkNotice.showing = false scope.cancel() - // Rows are ordinary notifications: stopping the service takes - // away the one it was held up by and leaves them where they are, - // describing work that has stopped with it. - val notifications = NotificationManagerCompat.from(this) - drawn.forEach { notifications.cancel(it, ROW_ID) } - drawn = emptySet() + WorkNotice.serviceStopped(this) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) super.onDestroy() } - - private fun show(items: List) { - // 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 - } - val notifications = NotificationManagerCompat.from(this) - // The summary before the rows: posted the other way round, each - // row arrives as a group of one and is drawn on its own for the - // frame before the summary gathers them up. - notifications.notify(FOREGROUND_ID, foregroundNotice(items)) - // One thing running has no row of its own -- the notification - // above is that row. A group of one is drawn as its header in the - // shade, which is the line of text with the bar left out of it. - val rows = if (items.size > 1) items else emptyList() - rows.forEach { - notifications.notify(it.tag, ROW_ID, row(it, ongoing().setGroup(GROUP_KEY))) - } - val tags = rows.map { it.tag }.toSet() - (drawn - tags).forEach { notifications.cancel(it, ROW_ID) } - drawn = tags - } - - /** - * One row of the shade, about one thing being worked on. - * - * The title says both which component it is and what is happening to it, because the collapsed - * row drops the text under it the moment there is a bar to draw (measured on API 36) -- so - * anything said there is said only to somebody who has already opened the group. Nothing is set - * beside it for that reason as well: expanded it would be the same words twice. - */ - private fun row(item: WorkItem, into: NotificationCompat.Builder): Notification { - into.setContentTitle("${heading(item)}: ${item.line}") - // A bar it can fill only where this component's own command - // reported a count; otherwise it says that something is happening - // and nothing about how far along, which is all anybody measured. - val fraction = item.fraction - if (fraction == null) { - into.setProgress(0, 0, true) - } else { - into.setProgress(PROGRESS_STEPS, (fraction * PROGRESS_STEPS).toInt(), false) - } - return into.build() - } - - /** - * The one the service is held up by: the single row while one thing is running, and the summary - * its rows are grouped under once there are more. - * - * As a summary it names the projects and draws no bar of its own, because the rows underneath - * carry the counts and a bar here could only be an average of theirs -- something that moves - * like a measurement while being nobody's. - */ - private fun foregroundNotice(items: List): Notification { - val builder = - ongoing().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) return row(single, builder) - return builder - .setGroup(GROUP_KEY) - .setGroupSummary(true) - .setContentTitle(items.map { it.project }.distinct().joinToString(", ")) - .build() - } - - /** - * What every one of them is: the same icon, the same tap, and not dismissible while it runs. - */ - private fun ongoing(): NotificationCompat.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) - - /** 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, - ) } diff --git a/app/androidApp/src/main/res/values/strings.xml b/app/androidApp/src/main/res/values/strings.xml index 049de75..90f1b4f 100644 --- a/app/androidApp/src/main/res/values/strings.xml +++ b/app/androidApp/src/main/res/values/strings.xml @@ -15,4 +15,8 @@ Finished + + Install