Android: watch a run and get told when it finishes

91c020b · Gabriel Morell · 2026-09-26 04:19

21 files +380 -0
Message
{commit_body(@commit)}

Files changed

modified android/app/build.gradle.kts
+1 −0
@@ -97,6 +97,7 @@ dependencies {
97 97 implementation(libs.androidx.activity.compose)
98 98 implementation(libs.androidx.navigation.compose)
99 99 implementation(libs.androidx.security.crypto)
100 + implementation(libs.androidx.work)
100 101
101 102 implementation(platform(libs.compose.bom))
102 103 implementation(libs.compose.ui)
modified android/app/src/main/AndroidManifest.xml
+12 −0
@@ -3,6 +3,9 @@
3 3
4 4 <uses-permission android:name="android.permission.INTERNET"/>
5 5 <uses-permission android:name="android.permission.CAMERA"/>
6 + <!-- Asked for when a run is first watched, not at launch: the app
7 + has nothing to say until then. -->
8 + <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
6 9 <uses-feature android:name="android.hardware.camera" android:required="false"/>
7 10
8 11 <application
@@ -37,6 +40,15 @@
37 40 <category android:name="android.intent.category.BROWSABLE"/>
38 41 <data android:scheme="gitgud" android:host="pair"/>
39 42 </intent-filter>
43 +
44 + <!-- Where a "run finished" notification lands: straight at
45 + the run it is about. -->
46 + <intent-filter android:autoVerify="false">
47 + <action android:name="android.intent.action.VIEW"/>
48 + <category android:name="android.intent.category.DEFAULT"/>
49 + <category android:name="android.intent.category.BROWSABLE"/>
50 + <data android:scheme="gitgud" android:host="run"/>
51 + </intent-filter>
40 52 </activity>
41 53 </application>
42 54 </manifest>
modified android/app/src/main/java/org/neiam/gitgud/app/MainActivity.kt
+37 −0
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
8 8 import androidx.compose.material3.MaterialTheme
9 9 import androidx.compose.material3.Surface
10 10 import androidx.compose.runtime.Composable
11 +import androidx.compose.runtime.LaunchedEffect
11 12 import androidx.compose.runtime.getValue
12 13 import androidx.compose.runtime.mutableStateOf
13 14 import androidx.compose.runtime.remember
@@ -52,6 +53,7 @@ class MainActivity : ComponentActivity() {
52 53 val tokens = TokenStore(this)
53 54 val prefs = PrefsStore(this)
54 55 val initialPair = parseIntent(intent)
56 + val initialRun = parseRunLink(intent)
55 57
56 58 setContent {
57 59 // Nothing stored means follow the OS, the same default as the
@@ -71,6 +73,7 @@ class MainActivity : ComponentActivity() {
71 73 tokens = tokens,
72 74 prefs = prefs,
73 75 initialPair = initialPair,
76 + initialRunRoute = initialRun,
74 77 currentTheme = theme,
75 78 currentThemeKey = themeKey,
76 79 onPickTheme = { picked ->
@@ -87,6 +90,28 @@ class MainActivity : ComponentActivity() {
87 90 if (intent?.action != Intent.ACTION_VIEW) return null
88 91 return PairPayload.parse(intent.data?.toString())
89 92 }
93 +
94 + /**
95 + * `gitgud://run/<owner>/<name>/<number>` — where a "run finished"
96 + * notification points. Returns the route to open, or null for
97 + * anything else, including a malformed one: a notification tap
98 + * should land somewhere sensible rather than crash.
99 + */
100 + private fun parseRunLink(intent: Intent?): String? {
101 + if (intent?.action != Intent.ACTION_VIEW) return null
102 + val uri = intent.data ?: return null
103 + if (!uri.scheme.equals("gitgud", ignoreCase = true)) return null
104 + if (!uri.host.equals("run", ignoreCase = true)) return null
105 +
106 + val segments = uri.pathSegments.orEmpty()
107 + if (segments.size != 3) return null
108 +
109 + val (owner, name) = segments[0] to segments[1]
110 + val number = segments[2].toIntOrNull() ?: return null
111 + if (owner.isBlank() || name.isBlank() || number <= 0) return null
112 +
113 + return "repo/$owner/$name/run/$number"
114 + }
90 115 }
91 116
92 117 @Composable
@@ -94,11 +119,23 @@ fun GitGudApp(
94 119 tokens: TokenStore,
95 120 prefs: PrefsStore,
96 121 initialPair: PairPayload?,
122 + initialRunRoute: String? = null,
97 123 currentTheme: AppTheme,
98 124 currentThemeKey: String,
99 125 onPickTheme: (String) -> Unit,
100 126 ) {
101 127 val nav = rememberNavController()
128 +
129 + // A notification tap opens the run it is about, once the graph is
130 + // up. Not a start destination: Back should still reach the repo
131 + // list rather than leaving the app.
132 + LaunchedEffect(initialRunRoute) {
133 + if (initialRunRoute != null && tokens.load() != null) {
134 + nav.navigate("repos")
135 + nav.navigate(initialRunRoute)
136 + }
137 + }
138 +
102 139 val start = remember(tokens, initialPair) {
103 140 // A pairing link always wins, even on an already-paired device:
104 141 // tapping one is how you move a phone to a different forge, or
modified android/app/src/main/java/org/neiam/gitgud/app/ui/RunScreen.kt
+62 −0
@@ -16,6 +16,8 @@ import androidx.compose.foundation.lazy.items
16 16 import androidx.compose.material.icons.Icons
17 17 import androidx.compose.material.icons.automirrored.filled.ArrowBack
18 18 import androidx.compose.material.icons.filled.Cancel
19 +import androidx.compose.material.icons.filled.NotificationsActive
20 +import androidx.compose.material.icons.filled.NotificationsNone
19 21 import androidx.compose.material.icons.filled.Refresh
20 22 import androidx.compose.material.icons.filled.Replay
21 23 import androidx.compose.material.icons.filled.Terminal
@@ -31,8 +33,13 @@ import androidx.compose.material3.SnackbarHostState
31 33 import androidx.compose.material3.Text
32 34 import androidx.compose.material3.TextButton
33 35 import androidx.compose.material3.TopAppBar
36 +import android.Manifest
37 +import android.os.Build
38 +import androidx.activity.compose.rememberLauncherForActivityResult
39 +import androidx.activity.result.contract.ActivityResultContracts
34 40 import androidx.compose.runtime.Composable
35 41 import androidx.compose.runtime.LaunchedEffect
42 +import androidx.compose.runtime.collectAsState
36 43 import androidx.compose.runtime.getValue
37 44 import androidx.compose.runtime.mutableStateOf
38 45 import androidx.compose.runtime.remember
@@ -40,6 +47,7 @@ import androidx.compose.runtime.rememberCoroutineScope
40 47 import androidx.compose.runtime.setValue
41 48 import androidx.compose.ui.Alignment
42 49 import androidx.compose.ui.Modifier
50 +import androidx.compose.ui.platform.LocalContext
43 51 import androidx.compose.ui.text.style.TextOverflow
44 52 import androidx.compose.ui.unit.dp
45 53 import kotlinx.coroutines.delay
@@ -48,6 +56,7 @@ import org.neiam.gitgud.app.data.GitGudClient
48 56 import org.neiam.gitgud.app.data.Job
49 57 import org.neiam.gitgud.app.data.RunDetailResponse
50 58 import org.neiam.gitgud.app.data.TokenStore
59 +import org.neiam.gitgud.app.watch.RunWatch
51 60
52 61 /**
53 62 * One workflow run: its jobs, each job's steps, and the actions.
@@ -59,6 +68,11 @@ import org.neiam.gitgud.app.data.TokenStore
59 68 * Re-running a whole run creates a *new* run (a new number), so that
60 69 * action navigates away to the new one. Re-running a single job and
61 70 * cancelling both mutate this run in place, so those just refresh.
71 + *
72 + * Watching a run hands it to [RunWatch], which keeps asking after this
73 + * screen is gone and posts a notification when the answer changes. The
74 + * screen's own poll stops when the screen does — that is the whole
75 + * difference between the two, and the reason the toggle exists.
62 76 */
63 77 @OptIn(ExperimentalMaterial3Api::class)
64 78 @Composable
@@ -124,6 +138,14 @@ fun RunScreen(
124 138 }
125 139 },
126 140 actions = {
141 + WatchToggle(
142 + owner = owner,
143 + name = name,
144 + number = number,
145 + // Only while there is something to wait for: a
146 + // finished run has already told you what it did.
147 + enabled = detail?.run?.status?.let { it in ACTIVE_STATUSES } ?: false,
148 + )
127 149 IconButton(onClick = { reloadKey++ }) {
128 150 Icon(Icons.Default.Refresh, contentDescription = "Refresh")
129 151 }
@@ -218,6 +240,46 @@ fun RunScreen(
218 240 }
219 241 }
220 242
243 +/**
244 + * Start or stop watching, and ask for the notification permission at
245 + * the moment it is first needed — a permission dialog on launch, for a
246 + * thing the user hasn't asked for yet, is how apps train people to say
247 + * no.
248 + */
249 +@Composable
250 +private fun WatchToggle(owner: String, name: String, number: Int, enabled: Boolean) {
251 + val context = LocalContext.current
252 + val watching by RunWatch.isWatching(context, owner, name, number)
253 + .collectAsState(initial = false)
254 +
255 + val permission =
256 + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
257 + // Declined: don't start a watch that can't tell anyone. The
258 + // screen's own polling still shows the run finishing.
259 + if (granted) RunWatch.start(context, owner, name, number)
260 + }
261 +
262 + if (!enabled && !watching) return
263 +
264 + IconButton(
265 + onClick = {
266 + when {
267 + watching -> RunWatch.stop(context, owner, name, number)
268 +
269 + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ->
270 + permission.launch(Manifest.permission.POST_NOTIFICATIONS)
271 +
272 + else -> RunWatch.start(context, owner, name, number)
273 + }
274 + },
275 + ) {
276 + Icon(
277 + if (watching) Icons.Default.NotificationsActive else Icons.Default.NotificationsNone,
278 + contentDescription = if (watching) "Stop watching this run" else "Notify me when this run finishes",
279 + )
280 + }
281 +}
282 +
221 283 @Composable
222 284 private fun RunHeader(d: RunDetailResponse) {
223 285 val run = d.run
added android/app/src/main/java/org/neiam/gitgud/app/watch/RunWatch.kt
+250 −0
@@ -0,0 +1,250 @@
1 +package org.neiam.gitgud.app.watch
2 +
3 +import android.Manifest
4 +import android.app.NotificationChannel
5 +import android.app.NotificationManager
6 +import android.app.PendingIntent
7 +import android.content.Context
8 +import android.content.Intent
9 +import android.content.pm.PackageManager
10 +import android.net.Uri
11 +import android.os.Build
12 +import androidx.core.app.NotificationCompat
13 +import androidx.core.app.NotificationManagerCompat
14 +import androidx.core.content.ContextCompat
15 +import androidx.work.Constraints
16 +import androidx.work.CoroutineWorker
17 +import androidx.work.Data
18 +import androidx.work.NetworkType
19 +import androidx.work.ExistingWorkPolicy
20 +import androidx.work.OneTimeWorkRequestBuilder
21 +import androidx.work.WorkInfo
22 +import androidx.work.WorkManager
23 +import androidx.work.WorkerParameters
24 +import androidx.work.workDataOf
25 +import java.util.concurrent.TimeUnit
26 +import kotlinx.coroutines.delay
27 +import kotlinx.coroutines.flow.Flow
28 +import kotlinx.coroutines.flow.map
29 +import org.neiam.gitgud.app.MainActivity
30 +import org.neiam.gitgud.app.R
31 +import org.neiam.gitgud.app.data.GitGudClient
32 +import org.neiam.gitgud.app.data.TokenStore
33 +
34 +/**
35 + * "Tell me when this run finishes."
36 + *
37 + * A background poll, not a push. Push would mean a service to push
38 + * *from*: Firebase is out of the question for an app distributed on
39 + * F-Droid — it needs Play Services and would make the build
40 + * non-free — and UnifiedPush would mean the forge learning to hold
41 + * device registrations and deliver to a distributor. Both are real
42 + * options later; neither is needed to answer "is my build done yet",
43 + * which the phone can find out by asking.
44 + *
45 + * So: one worker per watched run, polling the endpoint the screen
46 + * already polls, and a notification when the answer stops being
47 + * "running".
48 + *
49 + * ## Why it hands off to a successor
50 + *
51 + * WorkManager stops a worker after ten minutes. A CI run can take
52 + * longer, so each pass polls for a little under that and then enqueues
53 + * the next one rather than being killed mid-wait. `attempt` counts the
54 + * hand-offs so a run that never finishes — a hung job, a runner that
55 + * went away — stops being watched after [MAX_ATTEMPTS] rather than
56 + * polling this phone's battery until the heat death of the universe.
57 + *
58 + * ## What it costs when the phone is asleep
59 + *
60 + * Doze defers the successor, so a notification can arrive later than
61 + * the run finished — minutes late, not lost. Punctuality while the
62 + * screen is off is exactly what a push service buys, and is the reason
63 + * to add one if this proves too slack.
64 + */
65 +class RunWatchWorker(context: Context, params: WorkerParameters) :
66 + CoroutineWorker(context, params) {
67 +
68 + override suspend fun doWork(): Result {
69 + val owner = inputData.getString(KEY_OWNER) ?: return Result.failure()
70 + val name = inputData.getString(KEY_NAME) ?: return Result.failure()
71 + val number = inputData.getInt(KEY_NUMBER, 0).takeIf { it > 0 } ?: return Result.failure()
72 + val attempt = inputData.getInt(KEY_ATTEMPT, 0)
73 +
74 + val creds = TokenStore(applicationContext).load() ?: return Result.failure()
75 + val api = GitGudClient.authenticated(creds)
76 + val deadline = System.currentTimeMillis() + PASS_MILLIS
77 +
78 + while (System.currentTimeMillis() < deadline) {
79 + val detail =
80 + try {
81 + api.run(owner, name, number)
82 + } catch (e: Exception) {
83 + // A flaky network shouldn't end the watch; WorkManager
84 + // backs the retry off and keeps the unique name.
85 + return Result.retry()
86 + }
87 +
88 + val run = detail.run
89 + if (run.status !in ACTIVE_STATUSES) {
90 + RunWatch.notifyFinished(
91 + context = applicationContext,
92 + owner = owner,
93 + name = name,
94 + number = number,
95 + workflow = run.workflow_name ?: run.workflow_path ?: "workflow",
96 + status = if (run.status == "completed") run.conclusion ?: run.status else run.status,
97 + )
98 + return Result.success()
99 + }
100 +
101 + delay(POLL_MILLIS)
102 + }
103 +
104 + if (attempt + 1 < MAX_ATTEMPTS) {
105 + RunWatch.enqueuePass(applicationContext, owner, name, number, attempt + 1)
106 + }
107 +
108 + return Result.success()
109 + }
110 +
111 + companion object {
112 + internal val ACTIVE_STATUSES = setOf("queued", "running")
113 +
114 + /** Comfortably inside WorkManager's ten-minute execution limit. */
115 + private val PASS_MILLIS = TimeUnit.MINUTES.toMillis(8)
116 + private val POLL_MILLIS = TimeUnit.SECONDS.toMillis(30)
117 +
118 + /** Eight-minute passes, so about two hours of watching. */
119 + private const val MAX_ATTEMPTS = 15
120 +
121 + internal const val KEY_OWNER = "owner"
122 + internal const val KEY_NAME = "name"
123 + internal const val KEY_NUMBER = "number"
124 + internal const val KEY_ATTEMPT = "attempt"
125 + }
126 +}
127 +
128 +object RunWatch {
129 + private const val CHANNEL_ID = "ci_runs"
130 +
131 + /** One watch per run, so tapping Watch twice doesn't poll twice. */
132 + fun workName(owner: String, name: String, number: Int) = "watch-run:$owner/$name#$number"
133 +
134 + fun start(context: Context, owner: String, name: String, number: Int) {
135 + ensureChannel(context)
136 + enqueuePass(context, owner, name, number, attempt = 0, policy = ExistingWorkPolicy.KEEP)
137 + }
138 +
139 + fun stop(context: Context, owner: String, name: String, number: Int) {
140 + WorkManager.getInstance(context).cancelUniqueWork(workName(owner, name, number))
141 + }
142 +
143 + /** Whether this run is being watched, for the toggle to reflect. */
144 + fun isWatching(context: Context, owner: String, name: String, number: Int): Flow<Boolean> =
145 + WorkManager.getInstance(context)
146 + .getWorkInfosForUniqueWorkFlow(workName(owner, name, number))
147 + .map { infos -> infos.any { !it.state.isFinished } }
148 +
149 + internal fun enqueuePass(
150 + context: Context,
151 + owner: String,
152 + name: String,
153 + number: Int,
154 + attempt: Int,
155 + policy: ExistingWorkPolicy = ExistingWorkPolicy.REPLACE,
156 + ) {
157 + val data: Data =
158 + workDataOf(
159 + RunWatchWorker.KEY_OWNER to owner,
160 + RunWatchWorker.KEY_NAME to name,
161 + RunWatchWorker.KEY_NUMBER to number,
162 + RunWatchWorker.KEY_ATTEMPT to attempt,
163 + )
164 +
165 + // No network, nothing to ask: the constraint is what keeps a
166 + // phone in a tunnel from waking up every thirty seconds to fail.
167 + val request =
168 + OneTimeWorkRequestBuilder<RunWatchWorker>()
169 + .setInputData(data)
170 + .setConstraints(
171 + Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
172 + )
173 + .build()
174 +
175 + WorkManager.getInstance(context)
176 + .enqueueUniqueWork(workName(owner, name, number), policy, request)
177 + }
178 +
179 + internal fun notifyFinished(
180 + context: Context,
181 + owner: String,
182 + name: String,
183 + number: Int,
184 + workflow: String,
185 + status: String,
186 + ) {
187 + ensureChannel(context)
188 +
189 + // Android 13+ can refuse: the user may have declined the
190 + // permission since the watch started, and posting anyway throws.
191 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
192 + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
193 + PackageManager.PERMISSION_GRANTED
194 + ) {
195 + return
196 + }
197 +
198 + val intent =
199 + Intent(context, MainActivity::class.java).apply {
200 + action = Intent.ACTION_VIEW
201 + data = Uri.parse("gitgud://run/$owner/$name/$number")
202 + }
203 +
204 + val pending =
205 + PendingIntent.getActivity(
206 + context,
207 + workName(owner, name, number).hashCode(),
208 + intent,
209 + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
210 + )
211 +
212 + val notification =
213 + NotificationCompat.Builder(context, CHANNEL_ID)
214 + .setSmallIcon(R.drawable.ic_stat_run)
215 + .setContentTitle("#$number $workflow ${phrase(status)}")
216 + .setContentText("$owner/$name")
217 + .setContentIntent(pending)
218 + .setAutoCancel(true)
219 + .setPriority(NotificationCompat.PRIORITY_DEFAULT)
220 + .build()
221 +
222 + NotificationManagerCompat.from(context)
223 + .notify(workName(owner, name, number).hashCode(), notification)
224 + }
225 +
226 + // "#12 build failed" reads; "#12 build failure" doesn't.
227 + private fun phrase(status: String) =
228 + when (status) {
229 + "success" -> "succeeded"
230 + "failure" -> "failed"
231 + "cancelled" -> "was cancelled"
232 + "timed_out" -> "timed out"
233 + "skipped" -> "was skipped"
234 + else -> status
235 + }
236 +
237 + private fun ensureChannel(context: Context) {
238 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
239 +
240 + val channel =
241 + NotificationChannel(
242 + CHANNEL_ID,
243 + "Workflow runs",
244 + NotificationManager.IMPORTANCE_DEFAULT,
245 + )
246 + .apply { description = "A watched run finished." }
247 +
248 + context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(channel)
249 + }
250 +}
added android/app/src/main/res/drawable/ic_stat_run.xml
+16 −0
@@ -0,0 +1,16 @@
1 +<!-- The notification small icon: a status bar glyph, so white on
2 + transparent and no interior detail — Android draws it as a mask and
3 + anything finer than this disappears at 24dp. -->
4 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
5 + android:width="24dp"
6 + android:height="24dp"
7 + android:viewportWidth="24"
8 + android:viewportHeight="24"
9 + android:tint="#FFFFFFFF">
10 + <path
11 + android:fillColor="#FFFFFFFF"
12 + android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z"/>
13 + <path
14 + android:fillColor="#FFFFFFFF"
15 + android:pathData="M10,16.5l6,-4.5 -6,-4.5z"/>
16 +</vector>
modified android/gradle/libs.versions.toml
+2 −0
@@ -14,6 +14,7 @@ retrofit-kotlinx-serialization = "1.0.0"
14 14 security-crypto = "1.1.0-alpha06"
15 15 zxing-embedded = "4.3.0"
16 16 coroutines = "1.8.1"
17 +work = "2.9.1"
17 18
18 19 [libraries]
19 20 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
@@ -22,6 +23,7 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
22 23 androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
23 24 androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
24 25 androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "security-crypto" }
26 +androidx-work = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
25 27
26 28 compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
27 29 compose-ui = { group = "androidx.compose.ui", name = "ui" }

Parents: 576380e