10.8 KiB · text History 413e987

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

  1. Sign in on the web and go to Settings → API tokens.
  2. Tick Mobile app, name the token, and create it.
  3. A QR appears next to the plaintext token. It encodes gitgud://pair?base=<server>&token=<ggp_…>.
  4. In the app, tap Scan QR and point at it. The app verifies the token against GET /api/mobile/v1/me before 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.

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:write exists 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 into app/src/main/res/ and point the manifest at it.