5.9 KiB · text History 772eeea

DMS Android

Companion app for the DMS web service. First slice: pair an instance via QR.

Open in Android Studio

  1. Install Android Studio Hedgehog (2023.1.1) or newer — Iguana / Koala / Ladybug all work.
  2. File → Open → select this android/ directory.
  3. Studio will prompt to “Trust” the project, then sync Gradle. First sync downloads dependencies (~5 min).
  4. Hit Run against 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

  1. On the web service, log in → Devices → generate an API token. The page renders a QR encoding dms://pair?base=<server>&token=<api-token>.
  2. In the Android app, tap Scan QR.
  3. The app stores (base_url, token) in EncryptedSharedPreferences, calls GET /api/profile to 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/profile round-trip for verification
  • Home grid: paginated upload thumbnails from /files/<path>.thumb.<ext> with original-file fallback
  • Search: free-text + tag filter (server parses ns:value and ns:* 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 /search page; matched tags surface as chips on each result card
  • Detail: full image, metadata, tag chips that pivot to a tag-filtered search, plus an “Add tags” input that writes via the new POST /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.