7.2 KiB · text History 7f6cefe

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 3 — auth, the canvas list, a read-only render with pan and zoom, and live updates over the Phoenix channel. Editing is phase 4.

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.

Not yet shipped

Editing (phase 4), 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 and the reconciliation rules, but no emulator or device has been available, so neither a rendered diagram nor a live connection has been confirmed against a real server.

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.