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.
Not yet shipped
Export and the theme picker (phase 5), and the launcher icon and release/F-Droid pipeline (phase 6). 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.