Files
iris-aiandClaude Opus 5 bb65526230 Show an ongoing notification while the app is working
Everything this app asks for is minutes of the build machine's time
followed by a download, and all of it runs in the list screen's own
coroutines -- so with the app in the background the process is an
ordinary cached one, and Android is free to take it away halfway
through a build. A foreground service is the only way to say otherwise,
and its notification is both what that costs and what it is for: the
work is visible from the shade while it runs, and one tap comes back to
the card that started it.

The work itself stays where it is. WorkNoticeService runs nothing --
it collects a list of what is happening and reposts one notification,
stopping as soon as the list is empty -- and that list is *derived* by
workItems from the same projectStates/componentStates the cards are
drawn from, so there is nothing to forget to post and nothing to forget
to take back. The words are shared rather than written twice:
componentWorkLine and buildLine are now read by the card's own bars and
by the notification alike.

Measured on API 36 rather than assumed: the collapsed row drops the
content text the moment there is a progress bar, so one thing running
puts both the component and what is happening to it in the title
("Test Tablet / tablet: building  14/30"); several get a line each in
the expanded view and an indeterminate bar, there being no honest
single number for two builds at once. With the app at the home screen
the process sits at fg-service-act rather than cached, and the builds
it started landed while it was there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:36:03 -04:00

132 lines
4.8 KiB
Kotlin

plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.ktfmt)
}
// See the root build script for why this style and not ktfmt's default.
ktfmt { kotlinLangStyle() }
// The CA this app pins is baked in at build time from the certificates on
// the machine doing the build -- `$XDG_CONFIG_HOME/dev-updater/certs/ca.pem`,
// which the server generates on first start. DEV_UPDATER_CA overrides it.
//
// Reading it rather than keeping a pasted copy in the source means the
// trust anchor follows the build machine, the CA's private key never has
// to exist anywhere near this repo, and regenerating a CA needs a rebuild
// instead of a paste -- so a stale constant can't quietly disagree with
// the server the app is trying to reach.
val pinnedCaPath: String =
System.getenv("DEV_UPDATER_CA")
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
"/dev-updater/certs/ca.pem"
abstract class GeneratePinnedCert : DefaultTask() {
/** Where the certificate is looked for, reported in failures. */
@get:Input abstract val caPath: Property<String>
/**
* The certificate itself, set only when it exists -- so a missing one produces this task's own
* instructions rather than Gradle's "no such input file", which doesn't say what to run.
*/
@get:InputFile
@get:Optional
@get:PathSensitive(PathSensitivity.NONE)
abstract val caCertificate: RegularFileProperty
/** Wired by AGP through `addGeneratedSourceDirectory`. */
@get:OutputDirectory abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val path = caPath.get()
val ca = File(path)
if (!ca.isFile) {
throw GradleException(
"No CA certificate at $path.\n" +
"Start the server once on this machine first -- it generates the CA the " +
"app pins, and the certificate has to exist before an APK can embed it.\n" +
"Set DEV_UPDATER_CA=/path/to/ca.pem to build against a different one."
)
}
val pem = ca.readText().trim()
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
throw GradleException("$path is not a PEM certificate.")
}
// The PEM must start immediately after the opening quotes: a
// leading newline costs Android's CertificateFactory its
// "-----BEGIN" sniff, so it tries DER instead and fails at runtime
// with an ASN.1 decode error, nowhere near this file.
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
file.parentFile.mkdirs()
file.writeText(
"""
|// Generated from $path by the generatePinnedCert task. Do not edit.
|package com.example.devupdater
|
|const val PINNED_CA_PEM = ""${'"'}$pem
|""${'"'}
|
"""
.trimMargin()
)
}
}
val generatePinnedCert =
tasks.register<GeneratePinnedCert>("generatePinnedCert") {
val ca = file(pinnedCaPath)
caPath.set(pinnedCaPath)
if (ca.isFile) {
caCertificate.set(ca)
}
}
// AGP 9 wants generated sources registered through the variant API rather
// than added to a source set, so the task dependency is carried properly.
androidComponents {
onVariants { variant ->
variant.sources.java?.addGeneratedSourceDirectory(
generatePinnedCert,
GeneratePinnedCert::outputDir,
)
}
}
android {
namespace = "com.example.devupdater"
compileSdk = 37
defaultConfig {
applicationId = "com.example.devupdater"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
buildTypes { getByName("release") { isMinifyEnabled = false } }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
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.
implementation(project(":link"))
}