Android: a live CI wall for tablets and TVs

78cc6fd · gmorell · 2026-11-03 19:26

37 files +892 -2
Message
{commit_body(@commit)}

Files changed

modified android/README.md
+4 −0
@@ -16,6 +16,7 @@ B612-mono shell, so they feel like one product family.
16 16 | **Issues** | List by state, read with server-rendered markdown, comment, quote-reply, close and reopen |
17 17 | **Pull requests** | List by state (`merged` is its own state), read description + reviews + discussion + line comments, comment, approve / request changes, merge — with the repo's merge blockers spelled out |
18 18 | **Wiki** | Page index and rendered pages (read-only) |
19 +| **Live CI wall** | Every job running right now, a card each, tiled to fill a tablet or TV the way the web's `/users/ci/live` does: up to six across, then the newest columns stack. Each card has its repo, job, clock, step bar and the tail of its log. Yours or one organization's; oldest or newest first (the chips, or R). Keeps the screen on. A TV lists the app on its home screen and opens straight onto the wall; elsewhere it's in the repo list's menu, or long-press the launcher icon |
19 20 | **Home-screen widget** | The Plasma widget's strip on a phone: the logo and one status circle per watched repository, or one per organization folded into a single circle. Stretch it taller and the recent runs list underneath — tap a circle to filter them, the logo for every run, a run to open it. Polls every 15 minutes, every 30 s while a run is in flight |
20 21
21 22 The summary and description F-Droid shows come from
@@ -65,6 +66,9 @@ android/
65 66 PullsTab.kt # PR list
66 67 PullScreen.kt # PR + reviews + comments + review/merge dialogs
67 68 WikiTab.kt # page index + WikiPageScreen
69 + wall/
70 + CiWallScreen.kt # the live CI wall: polls /ci/live, tiles the cards
71 + WallTiling.kt # the web wall's column rule
68 72 Composer.kt # comment box + quote-reply helper
69 73 RichText.kt # renders the API's body_html
70 74 StatusChip.kt # CI status + issue/PR state vocabulary
modified android/app/src/main/AndroidManifest.xml
+23 −0
@@ -7,11 +7,17 @@
7 7 has nothing to say until then. -->
8 8 <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
9 9 <uses-feature android:name="android.hardware.camera" android:required="false"/>
10 + <!-- A TV, for the live CI wall: no touchscreen, and a home screen of
11 + its own (the leanback launcher) to be found on. Neither is
12 + required, so phones and tablets install it as before. -->
13 + <uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
14 + <uses-feature android:name="android.software.leanback" android:required="false"/>
10 15
11 16 <application
12 17 android:label="@string/app_name"
13 18 android:icon="@mipmap/ic_launcher"
14 19 android:roundIcon="@mipmap/ic_launcher"
20 + android:banner="@drawable/tv_banner"
15 21 android:allowBackup="false"
16 22 android:dataExtractionRules="@xml/data_extraction_rules"
17 23 android:supportsRtl="true"
@@ -28,6 +34,23 @@
28 34 <category android:name="android.intent.category.LAUNCHER"/>
29 35 </intent-filter>
30 36
37 + <!-- A TV's home screen. Opens straight onto the CI wall. -->
38 + <intent-filter>
39 + <action android:name="android.intent.action.MAIN"/>
40 + <category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
41 + </intent-filter>
42 +
43 + <!-- gitgud://wall — the "Live CI wall" launcher shortcut. -->
44 + <intent-filter android:autoVerify="false">
45 + <action android:name="android.intent.action.VIEW"/>
46 + <category android:name="android.intent.category.DEFAULT"/>
47 + <data android:scheme="gitgud" android:host="wall"/>
48 + </intent-filter>
49 +
50 + <meta-data
51 + android:name="android.app.shortcuts"
52 + android:resource="@xml/shortcuts"/>
53 +
31 54 <!--
32 55 gitgud://pair?base=<server>&token=<api-token> — scanned from
33 56 Settings → API tokens after minting a token with the "Mobile
modified android/app/src/main/java/org/neiam/gitgud/app/MainActivity.kt
+28 −2
@@ -1,5 +1,6 @@
1 1 package org.neiam.gitgud.app
2 2
3 +import android.app.Activity
3 4 import android.content.Intent
4 5 import android.os.Bundle
5 6 import androidx.activity.ComponentActivity
@@ -14,6 +15,7 @@ import androidx.compose.runtime.mutableStateOf
14 15 import androidx.compose.runtime.remember
15 16 import androidx.compose.runtime.setValue
16 17 import androidx.compose.ui.Modifier
18 +import androidx.compose.ui.platform.LocalContext
17 19 import androidx.navigation.NavType
18 20 import androidx.navigation.compose.NavHost
19 21 import androidx.navigation.compose.composable
@@ -31,6 +33,7 @@ import org.neiam.gitgud.app.ui.RepoListScreen
31 33 import org.neiam.gitgud.app.ui.RepoScreen
32 34 import org.neiam.gitgud.app.ui.RunScreen
33 35 import org.neiam.gitgud.app.ui.WikiPageScreen
36 +import org.neiam.gitgud.app.ui.wall.CiWallScreen
34 37 import org.neiam.gitgud.app.ui.theme.AppTheme
35 38 import org.neiam.gitgud.app.ui.theme.GitGudTheme
36 39 import org.neiam.gitgud.app.ui.theme.SYSTEM_THEME_KEY
@@ -58,6 +61,8 @@ class MainActivity : ComponentActivity() {
58 61 val prefs = PrefsStore(this)
59 62 val initialPair = parseIntent(intent)
60 63 link = parseLink(intent)
64 + // Opened from a TV's home screen: a TV is there to be the wall.
65 + val startOnWall = intent?.hasCategory(Intent.CATEGORY_LEANBACK_LAUNCHER) == true
61 66
62 67 setContent {
63 68 // Nothing stored means follow the OS, the same default as the
@@ -78,6 +83,7 @@ class MainActivity : ComponentActivity() {
78 83 prefs = prefs,
79 84 initialPair = initialPair,
80 85 link = link,
86 + startOnWall = startOnWall,
81 87 currentTheme = theme,
82 88 currentThemeKey = themeKey,
83 89 onPickTheme = { picked ->
@@ -114,6 +120,9 @@ class MainActivity : ComponentActivity() {
114 120 val uri = intent.data ?: return null
115 121 if (!uri.scheme.equals("gitgud", ignoreCase = true)) return null
116 122
123 + // gitgud://wall — the launcher shortcut to the live CI wall.
124 + if (uri.host.equals("wall", ignoreCase = true)) return DeepLink("wall")
125 +
117 126 val segments = uri.pathSegments.orEmpty()
118 127 val owner = segments.getOrNull(0)?.takeIf { it.isNotBlank() } ?: return null
119 128 val name = segments.getOrNull(1)?.takeIf { it.isNotBlank() } ?: return null
@@ -142,6 +151,7 @@ fun GitGudApp(
142 151 prefs: PrefsStore,
143 152 initialPair: PairPayload?,
144 153 link: DeepLink? = null,
154 + startOnWall: Boolean = false,
145 155 currentTheme: AppTheme,
146 156 currentThemeKey: String,
147 157 onPickTheme: (String) -> Unit,
@@ -161,15 +171,20 @@ fun GitGudApp(
161 171 // A pairing link always wins, even on an already-paired device:
162 172 // tapping one is how you move a phone to a different forge, or
163 173 // re-pair after revoking a token.
164 − if (initialPair != null || tokens.load() == null) "pair" else "repos"
174 + when {
175 + initialPair != null || tokens.load() == null -> "pair"
176 + startOnWall -> "wall"
177 + else -> "repos"
178 + }
165 179 }
180 + val activity = LocalContext.current as? Activity
166 181
167 182 NavHost(navController = nav, startDestination = start) {
168 183 composable("pair") {
169 184 PairScreen(
170 185 prefilled = initialPair,
171 186 tokens = tokens,
172 − onPaired = { nav.navigate("repos") { popUpTo(0) } },
187 + onPaired = { nav.navigate(if (startOnWall) "wall" else "repos") { popUpTo(0) } },
173 188 )
174 189 }
175 190
@@ -181,6 +196,7 @@ fun GitGudApp(
181 196 currentThemeKey = currentThemeKey,
182 197 onPickTheme = onPickTheme,
183 198 onOpenRepo = { owner, name -> nav.navigate("repo/$owner/$name") },
199 + onOpenWall = { nav.navigate("wall") },
184 200 onUnpair = {
185 201 tokens.clear()
186 202 nav.navigate("pair") { popUpTo(0) }
@@ -188,6 +204,16 @@ fun GitGudApp(
188 204 )
189 205 }
190 206
207 + composable("wall") {
208 + CiWallScreen(
209 + tokens = tokens,
210 + prefs = prefs,
211 + // The wall is a TV's first screen: Back from there leaves
212 + // the app rather than finding nothing to pop.
213 + onBack = { if (!nav.popBackStack()) activity?.finish() },
214 + )
215 + }
216 +
191 217 composable(
192 218 route = "repo/{owner}/{name}",
193 219 arguments = repoArgs(),
modified android/app/src/main/java/org/neiam/gitgud/app/data/GitGudClient.kt
+11 −0
@@ -38,6 +38,17 @@ interface GitGudApi {
38 38
39 39 // --- CI ---
40 40
41 + /** Every job running now that the token's owner cares about, for the wall. */
42 + @GET("api/mobile/v1/ci/live")
43 + suspend fun ciWall(@Query("lines") lines: Int? = null): WallResponse
44 +
45 + /** One organization's running jobs, as far as the token's owner may see them. */
46 + @GET("api/mobile/v1/orgs/{org}/ci/live")
47 + suspend fun orgCiWall(
48 + @Path("org") org: String,
49 + @Query("lines") lines: Int? = null,
50 + ): WallResponse
51 +
41 52 @GET("api/mobile/v1/repos/{owner}/{name}/runs")
42 53 suspend fun runs(
43 54 @Path("owner") owner: String,
modified android/app/src/main/java/org/neiam/gitgud/app/data/Models.kt
+33 −0
@@ -190,6 +190,39 @@ data class Artifact(
190 190 @Serializable
191 191 data class RunsResponse(val runs: List<Run> = emptyList())
192 192
193 +// ── the live CI wall ─────────────────────────────────────────────────
194 +
195 +@Serializable
196 +data class WallRepo(
197 + val owner: String? = null,
198 + val name: String? = null,
199 + val full_name: String? = null,
200 +)
201 +
202 +@Serializable
203 +data class WallRunner(val name: String? = null, val architecture: String? = null)
204 +
205 +/**
206 + * One running job on the wall, with everything its card draws: the
207 + * repository and run it belongs to, the runner it's on, and the tail of
208 + * its log as plain lines (no colour codes; a progress bar's last frame).
209 + */
210 +@Serializable
211 +data class WallCard(
212 + val job: Job,
213 + val run: Run,
214 + val repository: WallRepo = WallRepo(),
215 + val runner: WallRunner? = null,
216 + val log: List<String> = emptyList(),
217 +)
218 +
219 +/** `GET /ci/live` and `/orgs/{org}/ci/live`: oldest-started first. */
220 +@Serializable
221 +data class WallResponse(
222 + val jobs: List<WallCard> = emptyList(),
223 + val polled_at: String? = null,
224 +)
225 +
193 226 @Serializable
194 227 data class RunDetailResponse(
195 228 val run: Run,
modified android/app/src/main/java/org/neiam/gitgud/app/data/PrefsStore.kt
+16 −0

Click to load diff…

modified android/app/src/main/java/org/neiam/gitgud/app/ui/RepoListScreen.kt
+10 −0

Click to load diff…

added android/app/src/main/java/org/neiam/gitgud/app/ui/wall/CiWallScreen.kt
+638 −0

Click to load diff…

added android/app/src/main/java/org/neiam/gitgud/app/ui/wall/WallTiling.kt
+36 −0

Click to load diff…

added android/app/src/main/res/drawable/tv_banner.xml
+13 −0

Click to load diff…

modified android/app/src/main/res/values/strings.xml
+3 −0

Click to load diff…

added android/app/src/main/res/xml/shortcuts.xml
+17 −0

Click to load diff…

added android/app/src/test/java/org/neiam/gitgud/app/ui/wall/WallTilingTest.kt
+60 −0

Click to load diff…

Parents: 2b51ebb