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) |
| 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 |
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
widget/
CiWidgetProvider.kt # launcher events + circle / refresh taps
CiWidgetRenderer.kt # RemoteViews from the cached snapshot
CiWidgetWorker.kt # the poll, and CiWidgetUpdater's scheduling
CiWidgetModel.kt # watched → circles → repos, as in the plasmoid
CiWidgetConfigActivity.kt # what a widget watches
WidgetStore.kt # per-widget config + shared snapshot
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 is what Android checks on every update. The index key lives in OpenBao’s transit engine and never comes near CI. See docs/fdroid-registry.md for why they are kept apart.
The release keystore
Generate it once. Android verifies this certificate on every update, so losing it means every install has to be uninstalled and redone by hand — there is no recovery and no rotation.
keytool -genkeypair -v \
-keystore release.jks \
-storetype PKCS12 \
-alias gitgud \
-keyalg RSA -keysize 4096 \
-validity 10000 \
-dname "CN=GitGud, O=neiam, C=US"
It prompts for the password rather than taking -storepass, which
keeps it out of shell history. PKCS12 uses one password for the store
and the key, which is what android/Dockerfile assumes — --ks-pass
and --key-pass read the same file.
Back it up before it exists anywhere else
So there is never a window where your laptop holds the only copy:
export BAO_TOKEN=<root token>
E() { kubectl -n openbao exec -i statefulset/openbao -- env BAO_TOKEN="$BAO_TOKEN" "$@"; }
E bao secrets enable -path=kv kv-v2 # once
E bao kv put kv/gitgud/android \
keystore="$(base64 -w0 release.jks)" \
password='<the password>'
kv-v2 versions writes, so a later overwrite doesn’t destroy this one.
Recover with:
E bao kv get -field=keystore kv/gitgud/android | base64 -d > release.jks
Hand it to CI
On the repository: Settings → Actions → Secrets.
| Secret | Value |
|---|---|
ANDROID_KEYSTORE |
base64 -w0 release.jks |
ANDROID_KEYSTORE_PASS |
the password |
The android job skips cleanly while these are unset, so nothing
breaks before you get to it. It refuses to fall back to a debug-signed
APK: that key is shared by everyone with an SDK, so publishing one
would let anyone ship an “update” to it.
Why CI doesn’t read this out of OpenBao
The backup above is for a human with kubectl, not for the build.
apksigner has no external-signer hook — no PKCS#11, no callback — so
the APK key has to reach the build container whichever store it comes
from. Bao would buy central custody, audit and revocation; it would not
buy the “key never leaves” property the index key has, because that
property is not available here at all.
Against that, the runner is outside the cluster and OpenBao has no ingress on purpose. Reaching it would mean an ingress path and a second auth mechanism, for a secret whose exposure at the moment of use is identical either way. So: bao holds the durable copy, the Forgejo secret is the operational one.
Worth revisiting if more secrets need centralising — one doesn’t justify the ingress, several might.
Versioning
versionCode |
the CI run number (GITHUB_RUN_NUMBER), 1 locally |
versionName |
0.1.0-dev-<unix seconds>, or $RELEASE_VERSION verbatim when set |
Both are generated rather than committed. versionCode has to increase
or Android refuses the update and the registry refuses the upload;
versionName carries a timestamp so a list of builds sorts and reads at
a glance, where bare run numbers don’t.
The commit isn’t in the name because it doesn’t need to be: the versionCode is the CI run number, and a run knows its own commit.
The timestamp comes from SOURCE_DATE_EPOCH (or BUILD_TIMESTAMP)
when set — the workflow computes one stamp per run, so every artifact
from a run agrees even if a step is retried. A reproducible-build
harness that already sets SOURCE_DATE_EPOCH gets a reproducible
versionName for free.
A container build inherits nothing from the runner, so these are passed
as --build-arg in android/Dockerfile. They’re declared in the build
stage rather than at the top, so changing them doesn’t invalidate the
SDK and dependency layers — which are most of the build time.
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.