A finished build keeps its notification, with an Install button on it
Handing an APK to the system installer means starting an activity, and Android refuses one from the background: fired anyway it does nothing at all, so a download that finished while the phone was in a pocket read as an update that never landed. `updateComponent` now stops at 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 drawn as a row of its own: 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. Which is why the drawing moves from the service into WorkNotice: that row outlives the last running thing, and something still alive has to take it down. The list screen is that something. The service is now the keep-alive and the one notification it is held up by, nothing else. Two things had to be true for the row to come down again, and neither was. continuePendingInstalls now begins by forgetting a download whose install has already happened -- the installed copy's lastUpdateTime against the downloaded file's mtime, rather than listening for an install, since the same broadcast reports the removal 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 ADDED half of a replace and the REPLACED that follows it, not only the REMOVED half the guard was written for, so an in-place install reached nothing here until the screen next resumed. Verified on the emulator: Install pressed, app sent to the home screen, build and download finish in the background, the row turns into "Downloaded, waiting to install..." with the button, the service stops, Install from the shade puts up the system installer, and the row goes away by itself once the install lands -- with the app still in the background, and with no second installer when it is opened again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ded6df559d
commit
adaeb0c0ad
6 files changed
+449
-212
No files matched your search
@@ -897,6 +897,44 @@ mutable at runtime from the phone.
|
|||||||
caught, and the next thing the run reports asks again. Stops go through
|
caught, and the next thing the run reports asks again. Stops go through
|
||||||
`stopSelf(lastStart)` rather than `stopSelf()`, so work starting in the
|
`stopSelf(lastStart)` rather than `stopSelf()`, so work starting in the
|
||||||
moment between deciding to stop and stopping is not taken away with it.
|
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
|
`POST_NOTIFICATIONS` is asked for at startup beside the local-network
|
||||||
one, and refusing it costs the watching rather than the work: the
|
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
|
service still runs, and there is simply nothing to draw. The type is
|
||||||
|
|||||||
@@ -126,11 +126,16 @@ phone. Paths accept `~`, and are shown that way.
|
|||||||
While anything is building, downloading or installing, the app keeps an
|
While anything is building, downloading or installing, the app keeps an
|
||||||
ongoing notification saying what is running and how far along it is — one
|
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
|
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:
|
command reports one. It is there to be watched, and it is also what keeps
|
||||||
without it the app is an ordinary backgrounded process, and Android is
|
the work alive: without it the app is an ordinary backgrounded process,
|
||||||
free to reclaim it partway through a build. It goes away by itself when
|
and Android is free to reclaim it partway through a build. Refusing the
|
||||||
the last thing finishes. Refusing the notification permission costs the
|
notification permission costs the watching and not the work.
|
||||||
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
|
### Updating this server itself
|
||||||
|
|
||||||
|
|||||||
@@ -83,8 +83,22 @@ fun registerPackageChangeReceiver(
|
|||||||
// ADDED, with EXTRA_REPLACING marking the pair. Reporting
|
// ADDED, with EXTRA_REPLACING marking the pair. Reporting
|
||||||
// the first would put "this package is gone" on screen for
|
// the first would put "this package is gone" on screen for
|
||||||
// the moment between them, which is a state nothing here
|
// the moment between them, which is a state nothing here
|
||||||
// is in.
|
// is in -- so that half is dropped, and only that half.
|
||||||
if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return
|
//
|
||||||
|
// 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)
|
onPackageChanged(packageName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -990,7 +990,8 @@ private fun AppListScreen(
|
|||||||
*
|
*
|
||||||
* [offerInstall] false stops at the downloaded file, leaving it in the component's own row for
|
* [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
|
* [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
|
* 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
|
* 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))
|
setComponent(entry.key, component, ComponentState.WrongKey(file, mismatch))
|
||||||
return@run false
|
return@run false
|
||||||
}
|
}
|
||||||
if (!offerInstall) {
|
// Waiting its turn at the installer, said in this component's
|
||||||
// Downloaded and waiting its turn at the installer, said
|
// own row rather than by a dialog covering the card the other
|
||||||
// in this component's own row rather than by a dialog
|
// install is about -- and, when this screen is not in front of
|
||||||
// covering the card the other install is about.
|
// 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))
|
setComponent(entry.key, component, ComponentState.ReadyToInstall(file))
|
||||||
return@run false
|
return@run false
|
||||||
}
|
}
|
||||||
@@ -1284,6 +1290,37 @@ private fun AppListScreen(
|
|||||||
scope.launch { updateComponent(entry, component) }
|
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.
|
* 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.
|
* offer exactly as it was, which is what a cancelled dialog should do.
|
||||||
*/
|
*/
|
||||||
fun continuePendingInstalls() {
|
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
|
// One at a time, for the reason each of these states exists: the
|
||||||
// installer is modal and takes the screen, so offering two puts
|
// installer is modal and takes the screen, so offering two puts
|
||||||
// the second over the first and loses it.
|
// the second over the first and loses it.
|
||||||
@@ -1572,7 +1611,10 @@ private fun AppListScreen(
|
|||||||
}
|
}
|
||||||
?.let(::updateInstalledState)
|
?.let(::updateInstalledState)
|
||||||
// The removal this app asked for, arriving before the
|
// 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()
|
continuePendingInstalls()
|
||||||
}
|
}
|
||||||
onDispose { context.unregisterReceiver(receiver) }
|
onDispose { context.unregisterReceiver(receiver) }
|
||||||
@@ -2842,7 +2884,7 @@ private fun ApkProgress(state: ComponentState?) {
|
|||||||
// 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
|
||||||
// long as the other install takes.
|
// 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
|
// 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
|
// 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"
|
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. */
|
/** 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?)
|
private data class WorkLine(val text: String, val fraction: Float?)
|
||||||
|
|
||||||
@@ -4366,14 +4417,32 @@ private fun workItems(
|
|||||||
entry.label,
|
entry.label,
|
||||||
null,
|
null,
|
||||||
buildLine(it.what, it.status) ?: "${it.what}...",
|
buildLine(it.what, it.status) ?: "${it.what}...",
|
||||||
null,
|
WorkKind.Running(null),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val components =
|
val components =
|
||||||
entry.components.mapNotNull { component ->
|
entry.components.mapNotNull { component ->
|
||||||
val state = componentStates[entry.key]?.get(component.name)
|
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 {
|
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
|
listOfNotNull(project) + components
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import java.io.File
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -29,14 +30,38 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.launch
|
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
|
* Two cases rather than one row with a nullable count, because they differ in everything -- whether
|
||||||
* bytes of a download against the length the server sent. There is no estimate to fall back on, for
|
* there is a bar, whether there is a button, whether the row can be dismissed, and whether the
|
||||||
* the same reason the card's own bar has none: a bar drawn from how long the last run took looks
|
* process is being kept alive for it. A fraction that is null for two different reasons is exactly
|
||||||
* exactly like one drawn from real work, and the person watching it has no way to tell which they
|
* the state this shape makes unsayable.
|
||||||
* are looking at.
|
|
||||||
*/
|
*/
|
||||||
|
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(
|
data class WorkItem(
|
||||||
/** Which card's work it is. The key rather than the label, since two projects may share one. */
|
/** Which card's work it is. The key rather than the label, since two projects may share one. */
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -46,7 +71,7 @@ data class WorkItem(
|
|||||||
val part: String?,
|
val part: String?,
|
||||||
/** What it is doing, in the words that component's own row is using for it. */
|
/** What it is doing, in the words that component's own row is using for it. */
|
||||||
val line: String,
|
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
|
* 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
|
* The notifications for whatever this app has been asked to do, and the foreground service that
|
||||||
* that keeps the process alive while it happens.
|
* 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
|
* Work in flight is minutes of a build machine's time followed by a download, and all of it runs in
|
||||||
* in the list screen's own coroutines -- so with the app in the background the process is an
|
* the list screen's own coroutines -- so with the app in the background the process is an ordinary
|
||||||
* ordinary cached one, and the system is free to take it away halfway through. A foreground service
|
* cached one, and the system is free to take it away halfway through. A foreground service is the
|
||||||
* is the only way on Android to say "there is work here"; the notification is both the price of
|
* only way on Android to say "there is work here"; the notification is both the price of that and
|
||||||
* that and the point of it, since the work is then visible from the shade while it runs and one tap
|
* the point of it, since the work is then visible from the shade while it runs and one tap comes
|
||||||
* comes back to the card that started it.
|
* 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
|
* 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.
|
* row down while its sibling carries on.
|
||||||
*
|
*
|
||||||
* The work itself deliberately stays where it is. Every card already reports its own progress and
|
* The drawing lives here rather than in the service because the service is not the thing that
|
||||||
* its own failures through one path, and moving the running of it into the service would be a
|
* outlives the work: a build that has arrived and is waiting to be installed keeps its notification
|
||||||
* second path to keep in step with that one -- the thing this project avoids everywhere else. What
|
* after the last running thing is over, and something still alive has to take that one down when it
|
||||||
* crosses over is a summary, derived from the same two state maps the cards are drawn from, so the
|
* is installed. The list screen is alive whenever any of this changes, since the states being drawn
|
||||||
* notification cannot come to say something the card does not.
|
* 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 {
|
object WorkNotice {
|
||||||
private val work = MutableStateFlow<List<WorkItem>>(emptyList())
|
private val work = MutableStateFlow<List<WorkItem>>(emptyList())
|
||||||
internal val items: StateFlow<List<WorkItem>> = work.asStateFlow()
|
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
|
* Whether the service is up, so that [post] asks for a start exactly when nothing is holding
|
||||||
* what is running. Written by the service's callbacks and read from the composition, both on
|
* the process for work that is running. Written by the service's callbacks and read from the
|
||||||
* the main thread.
|
* 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
|
* A row is posted only when what it says has changed, which is what lets a notification that
|
||||||
* collecting, so a build that finishes while the phone is in a pocket still reports.
|
* 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<String, WorkItem>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<WorkItem>) {
|
fun post(context: Context, items: List<WorkItem>) {
|
||||||
work.value = items
|
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
|
val app = context.applicationContext
|
||||||
try {
|
try {
|
||||||
ContextCompat.startForegroundService(app, Intent(app, WorkNoticeService::class.java))
|
ContextCompat.startForegroundService(app, Intent(app, WorkNoticeService::class.java))
|
||||||
@@ -107,14 +150,92 @@ object WorkNotice {
|
|||||||
// Android refuses a foreground service started from the
|
// Android refuses a foreground service started from the
|
||||||
// background, and throws rather than answering. Ordinarily
|
// background, and throws rather than answering. Ordinarily
|
||||||
// this is nowhere near it -- work begins with a button -- but
|
// this is nowhere near it -- work begins with a button -- but
|
||||||
// work reappearing after the list has been empty for a moment
|
// work reappearing after nothing was running for a moment can
|
||||||
// can land there, and it must not be a crash. Nothing else is
|
// land there, and it must not be a crash. Nothing else is
|
||||||
// owed: the work runs regardless, and the next thing it
|
// owed: the work runs regardless, and the next thing it
|
||||||
// reports asks again, so the notification appears as soon as
|
// reports asks again, so the notification appears as soon as
|
||||||
// somebody comes back to the app.
|
// somebody comes back to the app.
|
||||||
Log.i(TAG, "not in the foreground, so nothing will show this run: $refused")
|
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<WorkItem>) {
|
||||||
|
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"
|
private const val TAG = "WorkNotice"
|
||||||
@@ -145,14 +266,153 @@ private const val CHANNEL_ID = "work"
|
|||||||
private const val PROGRESS_STEPS = 1000
|
private const val PROGRESS_STEPS = 1000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps the process alive for as long as [WorkNotice] says something is running, and draws what
|
* One row of the shade, about one thing.
|
||||||
* that is.
|
|
||||||
*
|
*
|
||||||
* It runs nothing itself: it collects the list, draws a row for each thing in it under one summary,
|
* The title says both which component it is and what is happening to it, because a collapsed row
|
||||||
* and stops as soon as the list is empty. Named for the notice rather than for the work because
|
* drops the text under it the moment there is a bar to draw (measured on API 36) -- so anything
|
||||||
* "service" already means something else throughout this project -- the unit a `Server` component
|
* said there is said only to somebody who has already opened it. Nothing is set beside it for that
|
||||||
* is driven through on the build machine -- and two meanings for one word costs more than the
|
* reason as well: expanded it would be the same words twice.
|
||||||
* longer name does.
|
*/
|
||||||
|
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<WorkItem>): 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() {
|
class WorkNoticeService : Service() {
|
||||||
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
|
||||||
@@ -165,33 +425,20 @@ class WorkNoticeService : Service() {
|
|||||||
*/
|
*/
|
||||||
private var lastStart = 0
|
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<String>()
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
lastStart = startId
|
lastStart = startId
|
||||||
|
createChannel(this)
|
||||||
// Has to happen within seconds of the start whatever the list says
|
// 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.
|
// by now, or the system kills the process for not having done it.
|
||||||
ServiceCompat.startForeground(
|
ServiceCompat.startForeground(
|
||||||
this,
|
this,
|
||||||
FOREGROUND_ID,
|
FOREGROUND_ID,
|
||||||
foregroundNotice(WorkNotice.items.value),
|
foregroundNotice(this, WorkNotice.items.value.filter { it.kind is WorkKind.Running }),
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
)
|
)
|
||||||
WorkNotice.showing = true
|
WorkNotice.serviceStarted()
|
||||||
if (watching == null) {
|
if (watching == null) {
|
||||||
watching = scope.launch {
|
watching = scope.launch {
|
||||||
WorkNotice.items.collect { items ->
|
WorkNotice.items.collect { items ->
|
||||||
@@ -200,7 +447,7 @@ class WorkNoticeService : Service() {
|
|||||||
// stop and stopping hands out a newer start id, and
|
// stop and stopping hands out a newer start id, and
|
||||||
// this call is then refused rather than leaving a
|
// this call is then refused rather than leaving a
|
||||||
// started service that has already died.
|
// 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() {
|
override fun onDestroy() {
|
||||||
WorkNotice.showing = false
|
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
// Rows are ordinary notifications: stopping the service takes
|
WorkNotice.serviceStopped(this)
|
||||||
// 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()
|
|
||||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
super.onDestroy()
|
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
|
|
||||||
}
|
|
||||||
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<WorkItem>): 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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -15,4 +15,8 @@
|
|||||||
<!-- Shown for the moment between the last thing finishing and the
|
<!-- Shown for the moment between the last thing finishing and the
|
||||||
notification going away. -->
|
notification going away. -->
|
||||||
<string name="work_done">Finished</string>
|
<string name="work_done">Finished</string>
|
||||||
|
<!-- The button on the notification for a build that has arrived and
|
||||||
|
is waiting to be installed. The same word the card's own button
|
||||||
|
uses for it. -->
|
||||||
|
<string name="work_install">Install</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in new issue
Block a user