8.3 KiB · text History 731ef80

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

  1. Start Phoenix (mix phx.server); it binds http://localhost:4000.
  2. 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.
  3. 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 with DIOGRAMOS_PUBLIC_URL=http://10.0.2.2:4000 or the QR will point at localhost, 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/mailbox and paste it into the second field.

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.

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.