- Kotlin 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .forgejo/workflows | ||
| .idea | ||
| app | ||
| gradle | ||
| screenshots | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
GitGay
A native Android client for git.gay, a queer-run Git hosting platform built on Forgejo. GitGay is a dark Material 3 app with a selection of colorful themes, and it talks directly to the Forgejo REST API.
This is the mobile companion for git.gay. Anything you can do in the web interface, you can do from your phone: browse repositories, read code, open issues and pull requests, watch your notifications, and manage your account.
Screenshots
| Home | Explore | Repository |
|---|---|---|
![]() |
![]() |
![]() |
| Search | Settings | Themes |
|---|---|---|
![]() |
![]() |
![]() |
Features
- Sign in with your git.gay username, password and two-factor code, the same three fields the web login asks for
- A home feed with trending and recent repositories, plus your own repos
- Repository browsing: file tree, source files with light syntax highlighting, commit history and diffs, issues and pull requests, star, fork and watch
- Issues and pull requests: read, create, comment, and reply
- Organizations and user profiles, members, followers and following
- Notifications
- Search for repositories, users, and organizations
- Account management: SSH keys, GPG keys, access tokens, webhooks, packages, action runners, organizations, and blocked users
- In-app updates: checks the project's own releases and installs a downloaded APK through the system installer
- An About screen with dependency licenses
Themes
GitGay is dark-only by design, but ships with several accent themes. You can switch between them any time from Settings, and the app picks up the change on the spot.
| Theme | Accent |
|---|---|
| Pink (git.gay Dark) | #FF69B4 |
| Monochrome | #D7D7D7 |
| Green | #93E0A8 |
| Blue | #8CC8FF |
| Purple | #D3BDFF |
| Teal | #86E7DA |
| Yellow | #EECA4F |
| Orange | #FFB77B |
| Red | #FFB4AB |
| Material You | follows the system |
The Monochrome theme uses true OLED black for its background, with only slightly brighter elevated surfaces to keep the UI readable on AMOLED panels.
Tech stack
- Kotlin 2.2 and Jetpack Compose (Material 3, BOM 2026.02)
- Android Gradle Plugin 9.3 with Gradle 9.5
- Retrofit, OkHttp and Gson for the API layer
- Coil for images, Markwon for Markdown, AndroidSVG for vector rendering
- DataStore, Android Security Crypto, and Navigation Compose
- minSdk 28, targetSdk 37, dark-only UI
Building
The project uses the standard Android Gradle build. With the Android SDK
installed and local.properties (or ANDROID_HOME) pointing at it:
./gradlew :app:assembleDebug # debug build, signed with the debug key
./gradlew :app:assembleRelease # release build
The release APK lands in app/build/outputs/apk/release/.
Signing
Release signing keys are kept private to the project. The keystore is never committed; instead it is stored as a Forgejo Actions secret and reconstructed by the build workflow, so every published APK is signed with the same key and no one can slip in a release built with a different one.
The CI signing secrets are:
| Secret | Value |
|---|---|
ANDROID_KEYSTORE_B64 |
the keystore file as base64 |
ANDROID_KEYSTORE_PATH |
/opt/signing.keystore |
ANDROID_KEYSTORE_PASSWORD |
the keystore password |
ANDROID_KEY_ALIAS |
the key alias |
ANDROID_KEY_PASSWORD |
the key password |
To set this up, generate a keystore outside the repo and set the secrets:
keytool -genkeypair -keystore signing.keystore -alias gitgay \
-keyalg RSA -keysize 2048 -validity 10000 \
-dname "CN=GitGay Release, O=GitGay"
# set the passwords you used as ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_PASSWORD
base64 -w0 signing.keystore # put the output in ANDROID_KEYSTORE_B64
Keep the keystore file and passwords safe and back them up. Losing them means you can no longer sign updates for an already-published app.
Locally, ./gradlew :app:assembleRelease signs with the debug keystore so it
works out of the box. To use a real key locally, create a keystore.properties
in the project root (gitignored) with storeFile, storePassword, keyAlias
and keyPassword, or set the ANDROID_KEYSTORE_* environment variables.
Sign-in
GitGay signs in with your git.gay username, password, and — if your account uses two-factor authentication — the current six-digit code from your authenticator. One form, the same three fields the web login asks for.
How the code is used once, and then never again
This is the part that decides the whole design, so it is worth being explicit.
git.gay validates the two-factor code on every HTTP Basic request
(services/auth/method/basic.go → validateTOTP, reached from reqBasicAuth on
each call). A six-digit code is only good for a 30-second window, so an app that
kept using Basic auth would be locked out the moment the code rolled over. Storing
the code cannot fix this: HMAC-SHA1 is a pseudorandom function, so the code for the
next window cannot be derived from the code you already have. The TOTP secret is no
help either — git.gay only ever renders it on the enrolment page (twofa_enroll.tmpl
is the sole template that does), so for an account that is already enrolled there is
nowhere to read it back from.
The web UI does not have this problem, because it does not re-check anything after login — typing the code once yields a session cookie. GitGay does the same thing one step later. Sign-in makes a single request:
POST /api/v1/users/{username}/tokens
Authorization: Basic base64(user:password)
X-Gitea-OTP: 123456
That route accepts Basic auth (reqBasicOrRevProxyAuth → reqBasicAuth), so the code
is validated on that one request and the response is a long-lived access token. The
app stores the token and every later request sends Authorization: token ….
AccessToken.Verify goes straight from the token lookup to AuthenticationSuccess and
never calls validateTOTP, so two-factor is not re-checked and nothing expires.
Net effect: you type the code once, exactly like on the web, and stay signed in. The code is never stored — it is meaningless seconds later — and neither is the password. Only the token is kept.
Accounts without two-factor leave the code field empty; git.gay skips the check entirely for those rather than rejecting the request.
Why not browser sign-in or a hand-made token
Both were tried and both are worse:
- OAuth needs a redirect URI. Forgejo's registration form validates redirect URIs
with
ValidUrlList→IsValidURL(modules/validation/helpers.go), which permits onlyhttp/https, so the usualgitgay://oauth/callbackcustom scheme cannot be registered. A loopbackhttp://127.0.0.1/redirect does work, but it needs the app to stay alive across a browser round trip, and it gains nothing over the form above since the password is needed either way. - Asking users to paste a token defeats two-factor and is a poor thing to ship.
There is no cookie option either: Forgejo's API auth group
(routers/api/shared/middleware.go) registers no session method and passes a nil
session store, so a browser session cannot authenticate /api/v1/* at all. Only the
web router registers one, and the web frontend never calls the JSON API.
The token
Sign-in creates one access token named GitGay Mobile with the all scope, so the
app can do everything the web UI can. It appears in Settings → Applications on
git.gay, where it can be revoked — revoking it signs the app out, and signing in again
creates a fresh one. The stored token is cleared on sign-out.
Sign-in is idempotent. git.gay enforces unique token names and answers a duplicate with
400 access token name has been used already, so a second sign-in would fail outright.
The old token also cannot be reused, because the list endpoint returns only
token_last_eight and never the secret. So sign-in lists the account's tokens, deletes
any that are named exactly GitGay Mobile, and creates a replacement — all within the
same sign-in, so the one-time code is still valid. Tokens the user created in git.gay's
own settings are matched by exact name and never touched.
This fails safe: if the delete succeeds but the create does not, the next attempt finds no token of ours and creates one, so there is no state that needs manual recovery beyond re-entering the code.
The token is a bearer credential, so it is worth the usual care: it is a full account
login that does not re-check two-factor. That is the same trust the web places in its
session cookie, and it is stored in EncryptedSharedPreferences.
Continuous integration
Two Forgejo Actions workflows keep the project building and versioned automatically.
Build and release
.forgejo/workflows/release.yml runs on every push to main. It provisions
the Android SDK, uses a cached Gradle home, signs the release APK with the
keystore restored from secrets, and then:
- reads the current
versionNamefromapp/build.gradle.kts, - creates the tag
v<versionName>and pushes it (skipping it if it already exists), - publishes a release on the releases page with a single asset,
app.apk.
So merging dev into main is all you need: the workflow tags the merge with
the version that dev already bumped to, and uploads the APK under that tag.
Automatic version bumps
.forgejo/workflows/version-bump.yml runs on every push to the dev branch.
It counts the commits that changed anything other than the version file, then
sets versionCode to 100000 + n, where n is that count, and commits the bump
back to dev. versionName is left untouched: it names the release a build
belongs to, so it is set when a release is cut rather than on every push. Because
the bump itself only touches the version file, it never counts itself, so the
workflow cannot loop.
In-app updates
GitGay checks its own repository for a newer release and offers to install it, either from a pill over the bottom navigation or manually under Settings → Check for updates.
Releases are identified by versionCode, not versionName. release.yml tags each
build v<versionCode>, and the app parses that tag back into a build number and offers
the update when it is greater than its own. Comparing one integer means no
name-normalisation rules, and a tag that is not a build number is skipped rather than
guessed at. versionName is not part of the update path and is not maintained by the
bump workflow.
It compares versionName against the latest published release, ignoring drafts and
prereleases. Downloading and release checks deliberately bypass the authenticated HTTP
client: the app should reach its public repository even when the stored token is stale
or was issued for a different instance, and the BODY-level logging used for API
debugging would otherwise try to buffer a whole APK in memory.
A downloaded APK is verified before it is offered to the system installer — the file
must be a readable ZIP/APK and its manifest must carry this app's own package name, so
a wrong or tampered asset is rejected rather than handed to the installer. Installation
itself goes through the system installer, which shows its own confirmation; GitGay
cannot install silently, and REQUEST_INSTALL_PACKAGES means the user has to grant
permission the first time.
Releases
Releases are cut automatically. The version is bumped on dev and, when you
merge dev into main, the build workflow tags the merge as v<version> and
attaches app.apk to a new release. There is no need to tag by hand:
git checkout main
git merge dev
git push origin main # build job tags v<version> and publishes the release
Each release contains exactly one attached APK named app.apk, signed with the
project's private key.
Tests
./gradlew :app:testDebugUnitTest # JVM unit tests
./gradlew :app:connectedDebugAndroidTest # instrumentation tests, needs a device
License
GitGay is free software released under the GNU General Public License, version 3. See LICENSE for the full text.





