neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Android: a live CI wall for tablets and TVs
78cc6fd · gmorell · 2026-11-03 19:26
Message
{commit_body(@commit)}
Files changed
modified
android/README.md
+4
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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