DMS Android
Companion app for the DMS web service. First slice: pair an instance via QR.
Open in Android Studio
- Install Android Studio Hedgehog (2023.1.1) or newer — Iguana / Koala / Ladybug all work.
File → Open→ select thisandroid/directory.- Studio will prompt to “Trust” the project, then sync Gradle. First sync downloads dependencies (~5 min).
- Hit
Runagainst an emulator or device. Min SDK 26 (Android 8.0).
If sync complains about a missing Gradle wrapper jar: gradle wrapper --gradle-version 8.10
inside android/ (or let Studio offer to install one).
Pairing flow
- On the web service, log in → Devices → generate an API token. The page renders
a QR encoding
dms://pair?base=<server>&token=<api-token>. - In the Android app, tap Scan QR.
- The app stores
(base_url, token)inEncryptedSharedPreferences, callsGET /api/profileto verify, and lands on the home screen showing the connected user.
The same QR can also be opened from the phone’s browser; the app’s dms://pair
intent filter forwards it to the pair screen with the fields pre-filled.
What works today
- QR scan → pair → token + base URL persisted (encrypted)
GET /api/profileround-trip for verification- Home grid: paginated upload thumbnails from
/files/<path>.thumb.<ext>with original-file fallback; videos use.thumb.jpgwith the.poster.jpgframe as fallback, and carry a play badge - Search: free-text + tag filter (server parses
ns:valueandns:*wildcards) - Search: free-text query, per-source toggles (file name / item name / description / user tags / ocr / desc / other namespaces) with full parity to the web
/searchpage; matched tags surface as chips on each result card - Detail: full image — or, for a video, an ExoPlayer that streams it from
/files/looping and muted (with an unmute toggle) like the web page — metadata, tag chips that pivot to a tag-filtered search, plus an “Add tags” input that writes via the newPOST /api/uploads/{id}/tags - Share-in: receive one or many images from the share sheet, add tags, upload sequentially via multipart, with a live progress indicator
- Share-out: from the detail screen, download the upload to cache and hand it to another app via FileProvider
- Pull-to-refresh + infinite scroll on home and search
- Themes: stock Material Light plus the OKLCh palettes from the Casabeza Android apps (Her, After Dark, Forest, Sky, Clays, Stones, Dark). B612 mono typography matches the web. Picker lives under the home screen’s overflow menu; selection persisted in EncryptedSharedPreferences.
- Re-pair / unpair (clears stored creds)
What’s next
The original ANDROIDAPP TODO is fully shipped. See ../TODO.md for any further polish ideas.
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 \
--progress=plain \
--output type=local,dest=./apk --file android/Dockerfile android
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.
.forgejo/workflows/android.yml runs that on every push to master
and POSTs the APK to /pkg/fdroid/<owner>/<repo>/upload with the
job’s own token — there is no publishing credential to create.
versionCode comes from the CI run number and versionName from the
commit, so every published build sorts after the last one. Local builds
stay at versionCode = 1 / …+dev; they are never published.
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
One entry covers every Android app the owner publishes, this one included.
Two different keys, deliberately. The APK key below is what Android checks on every update. The index key is per-owner, lives in the forge’s OpenBao transit engine, and never comes near CI.
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 dms \
-keyalg RSA -keysize 4096 \
-validity 10000 \
-dname "CN=DMS, 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 — it passes the
one password file to --ks-pass and lets the key password default to
it.
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 kv put kv/dms/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/dms/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.
CI does not read the keystore out of OpenBao. apksigner has no
external-signer hook, so the key has to reach the build container
whichever store it comes from, and the runner is outside the cluster
while OpenBao has no ingress on purpose. Bao holds the durable copy;
the Forgejo secret is the operational one.