neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Android: watch a run and get told when it finishes
91c020b · Gabriel Morell · 2026-09-26 04:19
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