Diogramos Android client
Native Android client for Diogramos. Talks to the JSON API at
/api/v1/... and the realtime channel at /socket. Sister app to
../../waxx/android — same Compose shell, OKLCh palettes and B612-mono
typography, so the two feel like one product family.
The design doc and phase plan live in docs/android.md. Shipped so far: phase 4 — auth, the canvas list, a live render with pan and zoom, and light editing: move, resize, text, colours, add shape, delete, z-order.
Stack
| Layer | Choice |
|---|---|
| Language / UI | Kotlin 2.0 · Jetpack Compose · Material 3 |
| HTTP | Retrofit · OkHttp · kotlinx.serialization |
| Realtime | com.github.dsrees:JavaPhoenixClient (phase 3) |
| 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.
Building
cd android
./gradlew :app:assembleDebug # → app/build/outputs/apk/debug/app-debug.apk
./gradlew :app:testDebugUnitTest # JVM unit tests, no emulator needed
./gradlew :app:installDebug # onto a running emulator or device
The SDK location comes from local.properties (sdk.dir=…), which is
not committed. The first build downloads Gradle and a few hundred MB of
dependencies; later ones are seconds.
JDK: build with 17–21. AGP 8.5 does not support JDK 25, and lint throws on it with only the version string as the error message, so a build on a modern default JDK fails for reasons that have nothing to do with this code:
JAVA_HOME=/path/to/jdk21 ./gradlew :app:assembleDebug
Running against a local dev server
- Start Phoenix (
mix phx.server); it bindshttp://localhost:4000. - The emulator reaches the host as
http://10.0.2.2:4000. A physical device on the same LAN uses the host’s LAN IP. - Pair with one of:
- QR — sign in on the web, Settings → Connected devices →
Generate, scan the code. The server picks the URL it encodes from
DIOGRAMOS_PUBLIC_URL/X-Forwarded-*/Host, so for emulator testing start Phoenix withDIOGRAMOS_PUBLIC_URL=http://10.0.2.2:4000or the QR will point atlocalhost, which on the emulator is the emulator. - Magic link — enter the same URL and your email, then read the
link out of the dev mailbox at
/dev/mailboxand paste it into the second field.
- QR — sign in on the web, Settings → Connected devices →
Generate, scan the code. The server picks the URL it encodes from
TokenStore upgrades http://<real domain> to https:// on save and
load; IPs, localhost and unqualified hostnames are left alone, so the
LAN and emulator paths keep working.
Auth
Two paths, because they fail in different situations:
- QR pairing needs a logged-in browser and physical access to its screen. No mail server, no App Links — which is why it leads on self-hosted installs.
- Magic link is the answer for “I’ve only ever used the phone”. It needs the server to send mail, and App Links for the seamless version.
Either yields a bearer token, and the backend treats them identically
afterwards. The token is verified with GET /api/v1/users/me before
being stored: a scanned QR can name a server this phone cannot reach,
and storing it unverified produces an app that fails later with no clue
why.
App Links
The https://<host>/m/<token> intent filter is autoVerify="true",
which needs /.well-known/assetlinks.json on the server declaring this
app’s SHA-256 signing fingerprints (debug and release). Without it the
link still works — Android falls back to the default-handler chooser,
and the paste box on the magic-link screen always works. The host comes
from the appLinksHost placeholder in app/build.gradle.kts, currently
diogramos.example.com.
Read the fingerprints with:
keytool -list -v -keystore ~/.android/debug.keystore \
-storepass android -alias androiddebugkey | grep SHA256
keytool -list -v -keystore release.jks -alias diogramos | grep SHA256
Themes
ui/theme/AppTheme.kt carries all nine daisyUI themes from
assets/css/app.css, token for token, with keys matching
Diogramos.Themes.all/0.
It holds every token in Themes.color_tokens/0, not just the ones
Material needs, because a canvas stores colors as token names and
resolves them against the canvas’s own theme. That is what lets a diagram
authored in forest render in forest colors while the app chrome stays
in whatever the user picked — and it is why these values are copied from
the CSS rather than eyeballed.
The two ported modules
canvas/ConnectorGeometry.kt and canvas/DocumentOps.kt are ports of
connector_geometry.ex and Document.apply_op/2. Both have to run on
the device: connectors re-route on every frame of a drag, and an edit
applies optimistically before the server replies. So the same rules
necessarily exist twice, and two copies drift silently — nothing throws,
the phone just draws or stores something the server wouldn’t.
The Elixir modules are the authority and the ports are pinned to them:
mix diogramos.android_fixtures # from the repo root
writes connector_geometry.json (360 cases) and document_ops.json (32
cases) into app/src/test/resources/, and the matching tests assert this
code reproduces every one — SVG path strings and rejection reasons
included. After a deliberate change to the Elixir side, regenerate and
commit; a failure for any other reason means the port is wrong.
Realtime
CanvasSocket.subscribe(canvasId) returns a Flow<CanvasEvent>.
CanvasSession holds the document and the version, and decides what each
incoming op means:
- our own op echoing back (matched on
actor_ref) — already applied, so only the version moves; - the next version in sequence — applied;
- anything else — drift, and the screen refetches the canvas.
There is no OT and no CRDT, because the server has none either:
Authority.apply_op/4 takes no client version and applies ops in arrival
order, so concurrent edits to one field are last-writer-wins exactly as
on the web. Drift after a reconnect is normal and heals silently — the
rejoin delivers a fresh snapshot.
Tests
app/src/test/ holds plain JVM tests — no emulator, no Robolectric. That
is why PairPayload parses with java.net.URI instead of
android.net.Uri: the pairing parser is the one place where a mistake
silently stores the wrong server, so it is worth being able to test it
directly.
Editing
Editing is a distinct mode: the pencil in the app bar turns it on, the check turns it off. In view mode the canvas only pans, zooms and follows link badges, so a pan that starts on a shape stays a pan. In edit mode tap selects, tapping the selection opens the sheet, dragging moves, and the corner handles resize. Connectors are rendered but not authored here — picking an anchor on a phone is its own design problem, and it is not what mobile users are blocked on.
Edits apply on screen before the server answers, because an edit that waits for a round trip feels broken under a finger. If the server refuses one it is rolled back — unless a later edit is already in flight, in which case undoing it would also undo what came after, and the screen resyncs instead.
A drag doesn’t touch the document while the finger is down: each op costs a server round trip, a broadcast to every peer and a WAL row, so the in-flight geometry is a render-time override and ops go out about ten times a second plus once at the end.
Releases
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 android
The keystore is a BuildKit secret, so it never reaches a Gradle script, a
Gradle cache, or an image layer — and signing is a separate step from
Gradle for the same reason. versionCode comes from the CI run number
and versionName from a per-run timestamp, so every published build
sorts after the last one; local builds stay at versionCode = 1 and are
never published.
The android job skips cleanly while ANDROID_KEYSTORE and
ANDROID_KEYSTORE_PASS are unset, and 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.
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 diogramos \
-keyalg RSA -keysize 4096 \
-validity 10000 \
-dname "CN=Diogramos, 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 the Dockerfile assumes.
Back it up before it exists anywhere else:
export BAO_TOKEN=<root token>
E() { kubectl -n openbao exec -i statefulset/openbao -- env BAO_TOKEN="$BAO_TOKEN" "$@"; }
E bao kv put kv/diogramos/android \
keystore="$(base64 -w0 release.jks)" \
password='<the password>'
Then hand it to CI as repository secrets ANDROID_KEYSTORE (the base64)
and ANDROID_KEYSTORE_PASS. CI does not read it 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. Bao holds the durable copy; the CI secret is the
operational one.
Installing from the repository, once a build has published:
<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. Note that the APK signing key above and the F-Droid index key are different keys: the index key is per-owner, lives in OpenBao, and never comes near CI.
Not yet shipped
Export and the theme picker (phase 5), and the launcher icon. See docs/android.md.
Nothing has yet been seen running on a screen: the unit tests cover the geometry, op application, the parser, the viewport maths, the reconciliation rules and the editing maths, but no emulator or device has been available. Neither a rendered diagram, a live connection nor a gesture has been confirmed against a real server — which is the main thing worth doing before trusting the write path.
Nothing has yet been seen running on a screen: the unit tests cover the geometry, the parser and the viewport maths, but no emulator or device was available to confirm a rendered diagram looks right.