GitGud Android client
Native Android client for GitGud. Talks to the mobile JSON API
at /api/mobile/v1/..., authenticated with a Personal Access Token
scanned from a QR on the forge’s own settings page.
Sister app to ../../waxx/android — same Compose / OKLCh palette /
B612-mono shell, so they feel like one product family.
What it does
| Surface | |
|---|---|
| Repos | Every repo the token can reach — owned, org, team-granted — with the latest CI status per row, filterable by namespace |
| CI | Runs, jobs, steps and logs; re-run a whole run, re-run one job in place, cancel a run in flight. Polls while anything is queued or running |
| Issues | List by state, read with server-rendered markdown, comment, quote-reply, close and reopen |
| 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 |
| Wiki | Page index and rendered pages (read-only) |
Stack
| Layer | Choice |
|---|---|
| Language / UI | Kotlin 2.0 · Jetpack Compose · Material 3 |
| HTTP | Retrofit · OkHttp · kotlinx.serialization |
| Storage | EncryptedSharedPreferences (AES via the Android Keystore) |
| Scanner | com.journeyapps:zxing-android-embedded (ScanContract) |
| Build | Gradle 9.0 · AGP 8.5.2 · JDK 17+ |
| minSdk | 26 (Android 8) · targetSdk 34 |
Single-activity, single-module, no DI framework — dependencies are constructed inline. Adding Hilt when the screen count justifies it is a fine follow-up.
Layout
android/
app/src/main/
AndroidManifest.xml
java/org/neiam/gitgud/app/
MainActivity.kt # nav host, gitgud://pair dispatch, theme provider
data/
TokenStore.kt # bearer + base URL (https-coerced)
ThemeStore.kt # selected theme key
PairPayload.kt # gitgud://pair URI parser
GitGudClient.kt # Retrofit interface + factory + error unwrapping
Models.kt # all the JSON DTOs
auth/
PairScreen.kt # QR scan + paste fallback + scope check
ui/
RepoListScreen.kt # home: repos + namespace filter + theme picker
RepoScreen.kt # one repo, four tabs
CiTab.kt # run list (polls while active)
RunScreen.kt # jobs + steps + re-run / cancel
LogScreen.kt # step-grouped logs, no wrapping
IssuesTab.kt # issue list + shared state filter
IssueScreen.kt # issue + comments + composer; CommentCard
PullsTab.kt # PR list
PullScreen.kt # PR + reviews + comments + review/merge dialogs
WikiTab.kt # page index + WikiPageScreen
Composer.kt # comment box + quote-reply helper
RichText.kt # renders the API's body_html
StatusChip.kt # CI status + issue/PR state vocabulary
Common.kt # AppBarTitle, empty states, relative times
theme/ # 9 palettes ported from assets/css/app.css
res/font/ # B612 regular + bold
Building
cd android
echo "sdk.dir=$HOME/Android/Sdk" > local.properties # or set ANDROID_HOME
./gradlew :app:assembleDebug
Produces app/build/outputs/apk/debug/app-debug.apk. Install it:
./gradlew :app:installDebug
# or:
adb install -r app/build/outputs/apk/debug/app-debug.apk
Pairing
- Sign in on the web and go to Settings → API tokens.
- Tick Mobile app, name the token, and create it.
- A QR appears next to the plaintext token. It encodes
gitgud://pair?base=<server>&token=<ggp_…>. - In the app, tap Scan QR and point at it. The app verifies the
token against
GET /api/mobile/v1/mebefore storing anything, and warns if the token is missing part of the mobile scope bundle.
There’s no separate “connected devices” list: a paired device is its token, so revoking it on the tokens page signs that phone out.
The QR carries a bearer token in plaintext. Treat a screenshot of it the way you’d treat a screenshot of a password.
Running against a local dev server
The emulator reaches the host as http://10.0.2.2:4000; a physical
device on the same LAN needs the host’s LAN IP. GitGudWeb.Endpoint.url()
is what gets encoded into the QR, so set the endpoint’s :url config to
match before generating one — otherwise the QR points at localhost,
which on a phone means the phone.
TokenStore coerces http://<fqdn> to https://<fqdn> on save and
load. IPs, localhost and unqualified hostnames are left alone, so the
LAN and emulator paths still work.
Scopes
The Mobile app checkbox is a bundle, not a scope. It ticks:
repo:read ci:read ci:write issues:read issues:write
pulls:read pulls:write wiki:read
A paired phone carries those individually, so you can revoke one
surface later — mint a token without pulls:write, say, and the PR
screen loses its merge and review buttons while everything else keeps
working. Endpoints check the scope the action needs and the user’s
permission on the repository: a scope says what the token may ask for,
never what its owner is allowed to do.
Themes
⋮ on the repo list → Theme offers the same nine palettes as the web
(Dark, Light, Her, After Dark, Forest, Sky, Clays, Stones, Blueprint).
The OKLCh values are ported from the @plugin "daisyui-theme" blocks in
assets/css/app.css and converted at runtime, so adding a theme on the
web is a copy of three numbers rather than a trip through a color
picker.
CI status colors (green / red / amber) are deliberately not per-theme — “this build failed” has to read the same on every palette.
Selection persists in gitgud_prefs, a separate
EncryptedSharedPreferences file from credentials, so signing out doesn’t
clobber it.
Rendering
Issue bodies, comments, PR descriptions and wiki pages arrive as both
body (markdown source) and body_html (rendered server-side). The app
displays the HTML through HtmlCompat in a TextView, because the
forge already renders GFM with cross-references, alerts, footnotes and
syntax highlighting — shipping a second markdown dialect on the phone
would mean two things that disagree about the same text.
The cost: tables and images flatten to text. Every repo screen has an “open on the web” action for those.
Release builds
CI builds and signs the release APK in a container (android/Dockerfile)
and publishes it to the forge’s own F-Droid repository — nothing is
installed on the runner, and the same command reproduces it locally:
docker buildx build --target android-export \
--secret id=keystore,src=./release.jks \
--secret id=keystore_password,src=./keystore.pass \
--output type=local,dest=./apk --file android/Dockerfile .
The keystore is a BuildKit secret, so it never reaches a Gradle script, a Gradle cache, or an image layer. Signing is a separate step from Gradle for the same reason.
Install from the repository by adding it in F-Droid:
<forge>/pkg/fdroid/<owner>/_all?fingerprint=<fingerprint>
curl -fsSL <forge>/pkg/_keys/fdroid/<owner> # prints the fingerprint
Two different keys, deliberately. The APK key lives in CI secrets and is what Android checks on every update — lose it and every install has to be removed and redone by hand. The index key lives in OpenBao and never comes near CI. See docs/fdroid-registry.md.
What’s not shipped yet
- Wiki editing — the wiki is a git repo of markdown files, and
editing one on a phone means resolving conflicts on a phone.
wiki:writeexists as a scope for whenever that’s worth doing. - Opening issues and PRs — the app comments on and closes existing ones. Opening a PR needs a branch picker and a diff preview to be anything but a guess.
- Diff view — line comments are listed with their file and line rather than anchored to a diff. The code review screen is the one place the web’s extra width genuinely earns its keep.
- Push notifications — no FCM wiring. “Is anything red” is a pull today; the repo list answers it in one screen.
- Launcher icon — using the framework default. Drop an IconKitchen
mipmap-*set intoapp/src/main/res/and point the manifest at it.