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