neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Android client phase 0: JSON API and canvas channel
bfa2c00 · Gabriel Morell · 2026-09-14 22:02
Message
{commit_body(@commit)}
Files changed
modified
config/config.exs
+2
−0
| 105 | 105 | # upload its own dashboards on boot, and the dashboards for this fleet are |
| 106 | 106 | # committed in deployment/grafana and pushed from there. `metrics_server: |
| 107 | 107 | # :disabled` because that option is Cowboy-based — see Diogramos.MetricsPlug. |
| 108 | +config :diogramos, :metrics_port, 9568 | |
| 109 | + | |
| 108 | 110 | config :diogramos, Diogramos.PromEx, |
| 109 | 111 | disabled: false, |
| 110 | 112 | manual_metrics_start_delay: :no_delay, |
modified
config/test.exs
+4
−0
| 17 | 17 | pool: Ecto.Adapters.SQL.Sandbox, |
| 18 | 18 | pool_size: System.schedulers_online() * 2 |
| 19 | 19 | |
| 20 | +# No metrics listener in test — nothing scrapes it, and binding a fixed | |
| 21 | +# port makes the suite fail whenever a dev server is running. | |
| 22 | +config :diogramos, :metrics_port, nil | |
| 23 | + | |
| 20 | 24 | # We don't run a server during test. If one is required, |
| 21 | 25 | # you can enable the server option below. |
| 22 | 26 | config :diogramos, DiogramosWeb.Endpoint, |
added
docs/android.md
+542
−0
| 1 | +# Diogramos Android client — design doc | |
| 2 | + | |
| 3 | +Status: **draft**, pre-implementation. | |
| 4 | +Owner: gmp@h4xmb.org. | |
| 5 | +Goal: native Android client for viewing and lightly editing canvases, | |
| 6 | +sharing the existing Phoenix backend. | |
| 7 | + | |
| 8 | +This doc is the contract for the work. Implementation lands in phases (see | |
| 9 | +[Phase plan](#8-phase-plan)); each phase has its own commit(s) and updates | |
| 10 | +this doc with what shipped. | |
| 11 | + | |
| 12 | +Sister app to `../waxx/android` and `../camerite/android` — same Compose / | |
| 13 | +OKLCh palette / B612-mono shell, so they read as one product family. Where | |
| 14 | +waxx already solved a problem (bearer tokens, QR pairing, F-Droid release), | |
| 15 | +this doc points at that solution rather than inventing a second one. | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## 1. Goals & non-goals | |
| 20 | + | |
| 21 | +### Goals | |
| 22 | +- Native Kotlin/Compose Android app for the case the web UI serves worst: | |
| 23 | + **looking at a diagram on a phone**, and making the small fix you notice | |
| 24 | + while you're there. | |
| 25 | +- Correct rendering. A canvas must look like the same diagram it is on the | |
| 26 | + web — same shapes, same theme colors, same connector routing. | |
| 27 | +- Real-time. An edit on web appears on the phone in the same second it | |
| 28 | + appears in another web tab, and vice versa. | |
| 29 | +- Two auth paths: scan-a-QR from a logged-in web session, or magic-link by | |
| 30 | + email. Either alone is sufficient; QR is the primary path for self-hosted | |
| 31 | + installs with no SMTP. | |
| 32 | +- Server stays authoritative. `Diagrams.Document` validation, | |
| 33 | + `Diagrams.Permissions` authorization, and the `Diagrams.Authority` | |
| 34 | + op pipeline are not reimplemented on the client. | |
| 35 | + | |
| 36 | +### Non-goals (v1) | |
| 37 | +- **Connector authoring.** Connectors are *rendered* but not created or | |
| 38 | + re-anchored on the phone. Picking an anchor on a 6" screen is its own | |
| 39 | + design problem and it is not the thing mobile users are blocked on. | |
| 40 | +- Folder management, permission/share-link administration, embed-token | |
| 41 | + settings, canvas import. All remain web-only; the app links out to the | |
| 42 | + web UI for them. | |
| 43 | +- iOS. The architecture should not preclude it; no iOS work in v1. | |
| 44 | +- Offline-first writes. Read caching is fine; mutations require a live | |
| 45 | + connection. | |
| 46 | +- Push notifications. | |
| 47 | +- A public/third-party API. `/api/v1` exists for the first-party Android | |
| 48 | + client; stability guarantees are weak until v2. | |
| 49 | + | |
| 50 | +### v1 editing surface, precisely | |
| 51 | + | |
| 52 | +The app emits exactly these ops: | |
| 53 | + | |
| 54 | +| Gesture | Op | | |
| 55 | +|---|---| | |
| 56 | +| Drag a shape | `update_element` (geometry) | | |
| 57 | +| Drag a resize handle | `update_element` (geometry) | | |
| 58 | +| Edit label / text body | `update_element` (`label`, `style`) | | |
| 59 | +| Fill / stroke / label color pick | `update_element` (`style`) | | |
| 60 | +| Add shape from FAB | `insert_element` | | |
| 61 | +| Delete from the selection sheet | `delete_element` | | |
| 62 | +| Bring to front / send to back | `set_z_order` | | |
| 63 | + | |
| 64 | +`insert_connector`, `update_connector`, and `delete_connector` are not sent | |
| 65 | +in v1 — but they arrive over the channel from web peers and must render. | |
| 66 | +Note that `delete_element` on the server also drops every connector | |
| 67 | +referencing that element (`Document.apply_op/2`), so the client must accept | |
| 68 | +a document where connectors vanished without a matching connector op. | |
| 69 | + | |
| 70 | +--- | |
| 71 | + | |
| 72 | +## 2. Architecture overview | |
| 73 | + | |
| 74 | +``` | |
| 75 | ++-------------------+ HTTPS/JSON +------------------------------+ | |
| 76 | +| Android client | <---------------> | DiogramosWeb.Api.* (Phase 0)| | |
| 77 | +| (Kotlin/Compose) | | - Plug pipeline :api | | |
| 78 | +| | WSS/Channels | - bearer-token auth | | |
| 79 | +| | <---------------> | DiogramosWeb.CanvasChannel | | |
| 80 | ++-------------------+ | (Phase 0) | | |
| 81 | + +---------------+--------------+ | |
| 82 | + | | |
| 83 | + v | |
| 84 | + +------------------------------+ | |
| 85 | + | Diogramos.{Accounts,Diagrams}| | |
| 86 | + | Authority / Document / | | |
| 87 | + | Permissions / Canvases | | |
| 88 | + | (UNCHANGED business logic) | | |
| 89 | + +------------------------------+ | |
| 90 | +``` | |
| 91 | + | |
| 92 | +The server gains two surfaces — an `/api/v1` HTTP scope and a `/socket` | |
| 93 | +user socket with a `CanvasChannel`. Both call into the existing contexts. | |
| 94 | +No business logic moves; only thin transport layers are added. | |
| 95 | + | |
| 96 | +One pre-existing gap was closed alongside them, because the phone makes it | |
| 97 | +much more visible (more ops per edit, more reconnects): `Diagrams.CanvasOp` | |
| 98 | +described itself as an append-only WAL for crash recovery, but nothing | |
| 99 | +wrote to it, so an authority crash lost every op since the last snapshot — | |
| 100 | +up to 25. `Authority` now appends a row per accepted op, replays rows newer | |
| 101 | +than the snapshot on load, and prunes them when a snapshot is written. | |
| 102 | + | |
| 103 | +The Android app is a single Gradle module under `/android`, matching | |
| 104 | +`../waxx/android` (single-activity, no DI framework, dependencies | |
| 105 | +constructed inline). | |
| 106 | + | |
| 107 | +--- | |
| 108 | + | |
| 109 | +## 3. HTTP API design | |
| 110 | + | |
| 111 | +### Conventions | |
| 112 | +- Scope: `/api/v1/...`. Major version bumps for breaking changes; additive | |
| 113 | + fields are not breaking. | |
| 114 | +- Auth: `Authorization: Bearer <token>` on every request except session | |
| 115 | + redemption. Tokens live in `users_tokens` with a new context `"api"`. | |
| 116 | +- Content type: `application/json`, snake_case fields (matches the schemas). | |
| 117 | +- Timestamps: ISO-8601 UTC strings. | |
| 118 | +- Canvases are addressed by **slug** in URLs (`Canvases.get_by_slug/2`), | |
| 119 | + matching the web routes. The numeric `id` is returned in payloads because | |
| 120 | + the channel topic is keyed by it. | |
| 121 | +- Errors: `{"error": {"code": "...", "message": "..."}}`. | |
| 122 | + `unauthenticated` (401), `forbidden` (403), `not_found` (404), | |
| 123 | + `unprocessable` (422, with `details` for changeset errors). | |
| 124 | + | |
| 125 | +### Endpoints (v1 surface) | |
| 126 | + | |
| 127 | +Unauthenticated (`pipe_through :api`): | |
| 128 | + | |
| 129 | +| Method | Path | Notes | | |
| 130 | +|---|---|---| | |
| 131 | +| POST | `/sessions/request_magic_link` | `{email}` → 204 always (no account enumeration) | | |
| 132 | +| POST | `/sessions/redeem` | `{token}` → `{token, user}`; mints an `"api"` token from a magic-link token | | |
| 133 | + | |
| 134 | +Authenticated (`pipe_through :api_authenticated`): | |
| 135 | + | |
| 136 | +| Method | Path | Notes | | |
| 137 | +|---|---|---| | |
| 138 | +| DELETE | `/sessions/current` | revokes the calling token | | |
| 139 | +| GET | `/users/me` | `{id, email, confirmed_at}`; also the QR-pair probe | | |
| 140 | +| GET | `/folders` | flat list; used to group the canvas list | | |
| 141 | +| GET | `/canvases` | `Canvases.list/1` — id, slug, title, theme, folder_id, version, updated_at, role. **No document.** | | |
| 142 | +| POST | `/canvases` | `{slug, title, theme, folder_id}` → canvas | | |
| 143 | +| GET | `/canvases/:slug` | full canvas **including** `document` and `version` | | |
| 144 | +| PATCH | `/canvases/:slug` | metadata only (`title`, `theme`, `folder_id`) via `Canvas.metadata_changeset/2` | | |
| 145 | +| DELETE | `/canvases/:slug` | owner/admin only | | |
| 146 | +| GET | `/canvases/:slug/export.json` | same payload as `Canvases.export/2` | | |
| 147 | +| GET | `/canvases/:slug/export.mermaid` | `MermaidExport.to_mermaid/1`, `text/plain` | | |
| 148 | +| GET | `/themes` | `{themes: [...], color_tokens: [...]}` — lets the client validate a color pick without hardcoding `Themes.color_tokens/0` | | |
| 149 | + | |
| 150 | +**Ops do not have an HTTP endpoint.** Every mutation to `document` goes | |
| 151 | +through the channel, because `Authority` is the only thing allowed to | |
| 152 | +sequence ops and bump `version`. An HTTP op endpoint would be a second | |
| 153 | +write path into the same GenServer with no ordering story. | |
| 154 | + | |
| 155 | +### JSON shape | |
| 156 | + | |
| 157 | +`document` is passed through **verbatim** as the jsonb map the server | |
| 158 | +stores — `%{"elements" => %{id => el}, "order" => [id], "connectors" => %{id => c}}`. | |
| 159 | +It is not remapped into a client-flavored schema. The client's Kotlin | |
| 160 | +models mirror `Document`'s validation rules 1:1: | |
| 161 | + | |
| 162 | +- element types: `rect`, `rounded`, `circle`, `text` | |
| 163 | +- geometry: `x/y/w/h` for rect/rounded/text, `cx/cy/r` for circle | |
| 164 | +- `label`, `style` (`fill`, `stroke`, `shadow`, `dash`, `stroke_width`, | |
| 165 | + `font_family`, `font_size`, `font_bold`, `font_italic`, `label_position`, | |
| 166 | + `label_size`, `label_color`, `label_font_family`, `label_bold`, | |
| 167 | + `label_italic`), `links[]` | |
| 168 | +- connectors: `from`/`to` `{element, anchor, x?, y?}` with anchors | |
| 169 | + `auto|n|e|s|w|point`; `routing` `straight|orthogonal|curve`; `dash`, | |
| 170 | + `marker_start`, `marker_end`, `start_gap`, `end_gap`, `stroke_width`, | |
| 171 | + `color`, `label` | |
| 172 | + | |
| 173 | +Unknown keys are preserved on round-trip: an `update_element` patch is a | |
| 174 | +**partial** map (the server deep-merges), so the client never has to send | |
| 175 | +back fields it didn't understand. | |
| 176 | + | |
| 177 | +### View modules | |
| 178 | + | |
| 179 | +`DiogramosWeb.Api.V1.CanvasJSON` etc., alongside a shared | |
| 180 | +`DiogramosWeb.Api.FallbackController` and `DiogramosWeb.Api.ErrorJSON` — | |
| 181 | +lifted from `../waxx/lib/waxx_web/api/`. | |
| 182 | + | |
| 183 | +--- | |
| 184 | + | |
| 185 | +## 4. Auth flows | |
| 186 | + | |
| 187 | +Both paths end with the app holding a raw bearer token. The backend treats | |
| 188 | +them identically after that. | |
| 189 | + | |
| 190 | +### Token lifecycle | |
| 191 | +- `"api"` tokens: 90-day inactivity expiry, `authenticated_at` refreshed at | |
| 192 | + most once per day on a successful authenticated request. | |
| 193 | +- Revocation: delete the row from `users_tokens`; in-flight requests get | |
| 194 | + `401 unauthenticated` and the socket reconnect fails. | |
| 195 | +- Stored on-device in `EncryptedSharedPreferences` (AES via the Keystore). | |
| 196 | + | |
| 197 | +New `Accounts` functions, mirroring waxx: `create_api_token/2`, | |
| 198 | +`fetch_user_by_api_token/1`, `list_api_tokens/1`, `delete_api_token/2`; | |
| 199 | +new `UserToken` functions `build_api_token/2`, `verify_api_token_query/1`, | |
| 200 | +`api_token_needs_refresh?/1`. | |
| 201 | + | |
| 202 | +### 4a. Pair via QR (primary) | |
| 203 | + | |
| 204 | +The web user, already logged in, generates a token from a "Connected | |
| 205 | +devices" panel added to `UserLive.Settings`. The panel renders a QR | |
| 206 | +encoding server URL + token; the phone scans it and is paired. No email, | |
| 207 | +no SMTP, no App Links. | |
| 208 | + | |
| 209 | +QR payload: | |
| 210 | + | |
| 211 | +``` | |
| 212 | +diogramos://pair?base=<url-encoded origin>&token=<url-encoded token> | |
| 213 | +``` | |
| 214 | + | |
| 215 | +The raw token is displayed exactly once, at generation time. The panel | |
| 216 | +otherwise lists `sent_to`, `authenticated_at`, `inserted_at`, and a Revoke | |
| 217 | +action per token. | |
| 218 | + | |
| 219 | +Base URL derivation, in priority order — so a self-hosted install behind a | |
| 220 | +reverse proxy doesn't bake a bind address into the QR: | |
| 221 | + | |
| 222 | +1. `DIOGRAMOS_PUBLIC_URL` env (explicit override) | |
| 223 | +2. `X-Forwarded-Proto` + `X-Forwarded-Host` | |
| 224 | +3. the request's `Host` header | |
| 225 | +4. endpoint config | |
| 226 | + | |
| 227 | +Server dep: `{:eqrcode, "~> 0.2.1"}` (pure Elixir, SVG output). | |
| 228 | +Client dep: `journeyapps:zxing-android-embedded` via `ScanContract`. | |
| 229 | + | |
| 230 | +The `diogramos://pair` intent filter also makes the URI tappable from a | |
| 231 | +note or chat, deep-linking into the Pair screen with both fields filled. | |
| 232 | + | |
| 233 | +### 4b. Magic link | |
| 234 | + | |
| 235 | +For "I'm only on my phone and have never logged in on a desktop." | |
| 236 | +`POST /sessions/request_magic_link` reuses | |
| 237 | +`Accounts.deliver_login_instructions/2`; the email link carries a | |
| 238 | +`/m/:token` route that content-negotiates — native clients redeem it via | |
| 239 | +`POST /sessions/redeem`, browsers keep falling through to the existing | |
| 240 | +`/users/log-in/:token` LiveView. | |
| 241 | + | |
| 242 | +App Links (`autoVerify` + `/.well-known/assetlinks.json` with both debug | |
| 243 | +and release SHA-256 fingerprints) make the tap-through seamless; without | |
| 244 | +them the user pastes the link into the app, which still works. | |
| 245 | + | |
| 246 | +### Registration | |
| 247 | + | |
| 248 | +Diogramos registration is invite-gated (`Accounts.Invite`, | |
| 249 | +`UserLive.Invites`). The app does **not** implement registration — an | |
| 250 | +unregistered user is told to open the invite link in a browser. Revisit if | |
| 251 | +that proves annoying. | |
| 252 | + | |
| 253 | +--- | |
| 254 | + | |
| 255 | +## 5. Real-time channel | |
| 256 | + | |
| 257 | +### Server | |
| 258 | + | |
| 259 | +``` | |
| 260 | +DiogramosWeb.UserSocket | |
| 261 | + /socket connect: ?token=<api_token> → Accounts.fetch_user_by_api_token/1 | |
| 262 | + channel "canvas:*", DiogramosWeb.CanvasChannel | |
| 263 | + | |
| 264 | +DiogramosWeb.CanvasChannel | |
| 265 | + join("canvas:<id>"): | |
| 266 | + - load canvas, Permissions.authorize(scope, :read, canvas) | |
| 267 | + - PubSub.subscribe(Authority.topic(id)) | |
| 268 | + - CanvasPresence.track(...) | |
| 269 | + - reply {:ok, %{canvas: meta, document: doc, version: v, role: role}} | |
| 270 | + from Authority.snapshot/1 | |
| 271 | + handle_in("op", %{"op" => op, "actor_ref" => ref}): | |
| 272 | + - Authority.apply_op(id, scope, op, ref) | |
| 273 | + - reply {:ok, %{version: v}} | {:error, %{code: ...}} | |
| 274 | + handle_in("cursor", %{"x" => x, "y" => y}): | |
| 275 | + - CanvasPresence.update(...) | |
| 276 | + handle_info({:canvas_op, payload}): | |
| 277 | + - push("op", %{op:, version:, actor_ref:}) | |
| 278 | + handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}): | |
| 279 | + - push through | |
| 280 | +``` | |
| 281 | + | |
| 282 | +Unlike waxx — where channel pushes are bare "refetch me" notifications — | |
| 283 | +**this channel pushes the ops themselves**. The reasons are specific to | |
| 284 | +this backend: | |
| 285 | + | |
| 286 | +- `Authority` already broadcasts `{:canvas_op, %{op, version, actor_ref}}` | |
| 287 | + as a delta with a monotonic version. The contract exists; wrapping it is | |
| 288 | + cheaper than inventing a notification. | |
| 289 | +- The document is one blob. "Refetch the affected resource" means refetching | |
| 290 | + the whole canvas on every keystroke-sized op. | |
| 291 | +- `actor_ref` is already there for echo suppression, which is exactly what | |
| 292 | + an optimistic client needs. | |
| 293 | + | |
| 294 | +### Client reconciliation | |
| 295 | + | |
| 296 | +`Authority.apply_op/4` does **not** take a client version — it applies ops | |
| 297 | +in arrival order and increments `version` itself. There is no OT and no | |
| 298 | +CRDT; concurrent edits to the same field are last-writer-wins, same as the | |
| 299 | +web editor today. So the client rule is simple: | |
| 300 | + | |
| 301 | +1. Apply the op locally (optimistically) and send it with a fresh | |
| 302 | + `actor_ref`. | |
| 303 | +2. On an incoming `op` push whose `actor_ref` is ours, drop it — already | |
| 304 | + applied — but adopt its `version`. | |
| 305 | +3. On any other incoming op: if `version == local_version + 1`, apply and | |
| 306 | + advance. **Otherwise the client has drifted** — re-`GET /canvases/:slug` | |
| 307 | + for a fresh document + version and resync. Drift is expected after a | |
| 308 | + reconnect and should be silent. | |
| 309 | +4. On an `{:error, ...}` reply to our own op, roll back the optimistic | |
| 310 | + change and resync. | |
| 311 | + | |
| 312 | +The client must not assume its op landed until the reply arrives; a | |
| 313 | +`:forbidden` reply is normal for a viewer-role user whose role changed | |
| 314 | +mid-session. | |
| 315 | + | |
| 316 | +### Presence | |
| 317 | + | |
| 318 | +`CanvasPresence` already carries `:name`, `:color`, `:cursor`, `:lock` per | |
| 319 | +peer, with a color palette and generated two-word names. The app renders | |
| 320 | +ghost cursors and ghosted elements the same way the web editor does, and | |
| 321 | +publishes its own cursor on drag (throttled to ~20 Hz, and only while a | |
| 322 | +drag is in flight — a phone has no hover). | |
| 323 | + | |
| 324 | +### Channel auth | |
| 325 | +`UserSocket.connect/3` verifies the `"api"` token and assigns `user_id`. | |
| 326 | +`CanvasChannel.join/3` re-checks `Permissions.authorize/3` on every join; | |
| 327 | +write authorization is re-checked per op inside `Authority.do_apply_op/4`, | |
| 328 | +so a role change takes effect without a rejoin. | |
| 329 | + | |
| 330 | +--- | |
| 331 | + | |
| 332 | +## 6. Android app architecture | |
| 333 | + | |
| 334 | +### Stack | |
| 335 | + | |
| 336 | +Identical to waxx, deliberately — one toolchain across the family. | |
| 337 | + | |
| 338 | +| Layer | Choice | | |
| 339 | +|---|---| | |
| 340 | +| Language / UI | Kotlin 2.0 · Jetpack Compose · Material 3 | | |
| 341 | +| HTTP | Retrofit · OkHttp · kotlinx.serialization | | |
| 342 | +| Realtime | `com.github.dsrees:JavaPhoenixClient` (0.3.4, jitpack) | | |
| 343 | +| Storage | EncryptedSharedPreferences | | |
| 344 | +| Scanner | `com.journeyapps:zxing-android-embedded` | | |
| 345 | +| Build | Gradle 9.0 · AGP 8.5.2 · JDK 17 | | |
| 346 | +| minSdk | 26 · targetSdk 34 | | |
| 347 | + | |
| 348 | +### Module layout | |
| 349 | + | |
| 350 | +``` | |
| 351 | +android/app/src/main/java/org/neiam/diogramos/app/ | |
| 352 | + MainActivity.kt # nav host, deep-link dispatch, theme provider | |
| 353 | + data/ | |
| 354 | + TokenStore.kt # bearer + base URL (https-coerced) | |
| 355 | + ThemeStore.kt # selected UI theme key | |
| 356 | + DiogramosClient.kt # Retrofit interfaces + factories | |
| 357 | + CanvasSocket.kt # channel wrapper → Flow<CanvasEvent> | |
| 358 | + PairPayload.kt # diogramos://pair parser | |
| 359 | + Models.kt # Document / Element / Connector DTOs | |
| 360 | + DocumentStore.kt # in-memory doc + version + op application | |
| 361 | + auth/ | |
| 362 | + LoginScreen.kt · PairScreen.kt · MagicLinkScreen.kt | |
| 363 | + canvas/ | |
| 364 | + CanvasListScreen.kt # canvases grouped by folder | |
| 365 | + CanvasScreen.kt # the viewport | |
| 366 | + Viewport.kt # pan/zoom/fit transform state | |
| 367 | + Renderer.kt # Compose Canvas draw of elements + connectors | |
| 368 | + ConnectorGeometry.kt # port of the Elixir module | |
| 369 | + SelectionSheet.kt # bottom sheet: label, colors, z-order, delete | |
| 370 | + AddShapeSheet.kt | |
| 371 | + Peers.kt # ghost cursors from presence | |
| 372 | + ui/theme/ | |
| 373 | + AppTheme.kt · Theme.kt · Typography.kt # lifted from waxx | |
| 374 | + CanvasTokens.kt # daisyUI token → Color, per canvas theme | |
| 375 | +``` | |
| 376 | + | |
| 377 | +### Rendering | |
| 378 | + | |
| 379 | +The renderer is the heart of the app and the part with no waxx precedent. | |
| 380 | + | |
| 381 | +- Draw into a single Compose `Canvas` under a pan/zoom transform. Elements | |
| 382 | + in `order` (back-to-front), then connectors on top, then presence ghosts. | |
| 383 | +- `ConnectorGeometry.kt` is a **direct port** of | |
| 384 | + `lib/diogramos/diagrams/connector_geometry.ex` — anchor resolution | |
| 385 | + (`auto`/cardinal/`point`), gap shrinking, and the three routings. It is | |
| 386 | + pure math over the document, so the port is mechanical. | |
| 387 | + - **Keeping it honest:** a mix task dumps golden fixtures (document + | |
| 388 | + expected path `d` strings) from the Elixir tests into | |
| 389 | + `android/app/src/test/resources/`, and a Kotlin unit test asserts the | |
| 390 | + port produces the same paths. Without this the two implementations | |
| 391 | + drift silently and diagrams render subtly wrong. | |
| 392 | +- `style.fill` / `style.stroke` / connector `color` are daisyUI tokens | |
| 393 | + (`base-100`, `primary`, `error`, …), not hex. `CanvasTokens.kt` resolves | |
| 394 | + them against the *canvas's* theme (`canvas.theme`), which is independent | |
| 395 | + of the app chrome theme the user picked. A canvas authored in `forest` | |
| 396 | + renders in `forest` colors even if the app is in `light`. | |
| 397 | +- Fonts: `sans`/`serif`/`mono`/`b612`/`b612-mono`. B612 ships in | |
| 398 | + `../waxx/android/app/src/main/res/font/` already. | |
| 399 | +- Hit testing walks `order` in reverse so the topmost element wins. | |
| 400 | + | |
| 401 | +### Interaction model | |
| 402 | + | |
| 403 | +| Gesture | Effect | | |
| 404 | +|---|---| | |
| 405 | +| One-finger drag on empty canvas | pan | | |
| 406 | +| Pinch | zoom (clamped; double-tap = fit-to-content) | | |
| 407 | +| Tap an element | select (handles + bottom sheet) | | |
| 408 | +| Drag a selected element | move → `update_element`, one op on gesture end plus throttled intermediates | | |
| 409 | +| Drag a corner handle | resize → `update_element` | | |
| 410 | +| Long-press empty canvas | add-shape sheet at that point | | |
| 411 | +| Tap a link badge | open the link (external URL or another canvas) | | |
| 412 | + | |
| 413 | +Intermediate drag ops are throttled to ~10/s. Each op is a | |
| 414 | +`GenServer.call` into the canvas's `Authority` plus a PubSub broadcast to | |
| 415 | +every peer, and a snapshot write to Postgres every 25 ops, so a firehose of | |
| 416 | +sub-pixel updates is real server load — throttling is not just a network | |
| 417 | +nicety. | |
| 418 | + | |
| 419 | +A viewer-role user gets no handles, no FAB, and a "read-only" chip. | |
| 420 | + | |
| 421 | +### Error handling | |
| 422 | + | |
| 423 | +A top banner mirrors waxx's: "Connecting…" / "Offline — changes won't sync | |
| 424 | +until reconnect." outside the joined state. Rejected ops roll back with a | |
| 425 | +snackbar naming the reason (`forbidden`, `invalid_color`, …, mapped from | |
| 426 | +`Document`'s error atoms). | |
| 427 | + | |
| 428 | +--- | |
| 429 | + | |
| 430 | +## 7. Build & release | |
| 431 | + | |
| 432 | +Straight copies of the waxx setup, which is already proven: | |
| 433 | + | |
| 434 | +- `android/Dockerfile` builds and signs the release APK in a container with | |
| 435 | + the keystore as a BuildKit secret — it never reaches a Gradle script, | |
| 436 | + cache, or image layer. | |
| 437 | +- CI (Forgejo style) | |
| 438 | + builds on push to `master` and publishes to the F-Droid repo. | |
| 439 | +- `versionCode` from the CI run number, `versionName` from the commit. | |
| 440 | + Local builds stay at `versionCode = 1` / `…+dev` and are never published. | |
| 441 | +- Release keystore generated once, backed up to OpenBao before it exists | |
| 442 | + anywhere else. Losing it means every install has to be uninstalled and | |
| 443 | + redone by hand. | |
| 444 | + | |
| 445 | +--- | |
| 446 | + | |
| 447 | +## 8. Phase plan | |
| 448 | + | |
| 449 | +### Phase 0 — Backend foundation (no Android code) ✅ shipped | |
| 450 | +- `"api"` token context: `UserToken.build_api_token/2`, | |
| 451 | + `verify_api_token_query/1`, `api_token_needs_refresh?/1`, plus a | |
| 452 | + `label` column on `users_tokens`; `Accounts.create_api_token/2`, | |
| 453 | + `fetch_user_by_api_token/1`, `list_api_tokens/1`, `delete_api_token/2`. | |
| 454 | +- `DiogramosWeb.Api.Auth` plug, `:api_authenticated` pipeline, | |
| 455 | + `FallbackController`, `ErrorJSON`. | |
| 456 | +- `/api/v1` controllers: sessions, users, folders, canvases, themes. | |
| 457 | +- `DiogramosWeb.UserSocket` + `CanvasChannel`; `socket "/socket"` in the | |
| 458 | + endpoint. | |
| 459 | +- `canvas_ops` WAL now actually written and replayed (see below). | |
| 460 | +- 41 tests: token expiry/refresh/revocation, authz on every endpoint | |
| 461 | + (viewer vs editor vs stranger), channel join/op/reject, echo | |
| 462 | + suppression, presence, and crash recovery. | |
| 463 | + | |
| 464 | +Shippable on its own: the API is useful to `curl` and to any script. | |
| 465 | + | |
| 466 | +Two things worth knowing about what shipped: | |
| 467 | + | |
| 468 | +- **The channel's topic is already the authority's topic.** | |
| 469 | + `Authority.topic(id)` is `"canvas:<id>"`, which is also the channel | |
| 470 | + topic, so joining subscribes us — an explicit `PubSub.subscribe` there | |
| 471 | + would deliver every op twice. For the same reason presence diffs arrive | |
| 472 | + as *outgoing* broadcasts, so the channel declares | |
| 473 | + `intercept ["presence_diff"]` and collapses the diff into the peer list | |
| 474 | + in `handle_out/3`. | |
| 475 | +- **`GET /canvases/:slug` reads through the authority, not the row.** A | |
| 476 | + canvas being actively edited holds up to 24 unpersisted ops, so the DB | |
| 477 | + copy can be far behind. The controller falls back to the row only when | |
| 478 | + no authority can be started. | |
| 479 | + | |
| 480 | +### Phase 1a — Web "Connected devices" + QR | |
| 481 | +- `eqrcode` dep, public-URL derivation, token panel in `UserLive.Settings`, | |
| 482 | + revoke action. | |
| 483 | + | |
| 484 | +### Phase 1b — Android scaffold + both auth paths | |
| 485 | +- Gradle project, theme/typography lifted from waxx, `TokenStore`, | |
| 486 | + `PairScreen` (scan + paste), `MagicLinkScreen`, `/users/me` probe. | |
| 487 | + | |
| 488 | +### Phase 2 — Canvas list + read-only render | |
| 489 | +- List grouped by folder; `CanvasScreen` with pan/zoom, full element and | |
| 490 | + connector rendering, `ConnectorGeometry` port + golden-fixture tests, | |
| 491 | + theme token resolution. **This is the phase that fixes the actual | |
| 492 | + complaint**, and it's worth landing before any write path. | |
| 493 | + | |
| 494 | +### Phase 3 — Realtime | |
| 495 | +- `CanvasSocket`, op application, version reconciliation, resync-on-drift, | |
| 496 | + presence ghosts, connection banner. | |
| 497 | + | |
| 498 | +### Phase 4 — Light editing | |
| 499 | +- Selection, move, resize, label/text edit, color picks, add shape, delete, | |
| 500 | + z-order. Optimistic apply + rollback. | |
| 501 | + | |
| 502 | +### Phase 5 — Polish | |
| 503 | +- Export (JSON + mermaid) to the share sheet, canvas create/rename/theme, | |
| 504 | + app theme picker, launcher icon, App Links + `assetlinks.json`. | |
| 505 | + | |
| 506 | +### Phase 6 — Release pipeline | |
| 507 | +- `android/Dockerfile`, CI job, keystore, F-Droid publication. | |
| 508 | + | |
| 509 | +--- | |
| 510 | + | |
| 511 | +## 9. Open questions | |
| 512 | + | |
| 513 | +- **Text rendering fidelity.** The web renders text elements as SVG/HTML | |
| 514 | + with daisyUI typography; Compose text metrics will not match exactly. | |
| 515 | + Wrapping and vertical centering are the likely visible differences. Do we | |
| 516 | + accept "close", or does the server need to hand back measured line boxes? | |
| 517 | +- **`set_z_order` races.** The op replaces the entire `order` list, so two | |
| 518 | + peers reordering at once means one silently wins with a stale list. Same | |
| 519 | + bug exists on web today; worth noting, not worth fixing in this scope. | |
| 520 | +- **Connector creation.** Deferred out of v1, but the eventual gesture | |
| 521 | + (drag from an edge handle to a target, anchor defaulting to `auto`) is | |
| 522 | + worth prototyping early enough that the renderer doesn't foreclose it. | |
| 523 | +- **Theme sync.** The app theme is local-only, like waxx's. A | |
| 524 | + `PATCH /api/v1/users/me/preferences` would let it follow the user across | |
| 525 | + devices — Diogramos has no `users.preferences` column yet. | |
| 526 | +- **Embed/share-link viewing.** `/embed/:token` and `/s/:token` are | |
| 527 | + anonymous read paths. Should the app open a shared canvas without an | |
| 528 | + account? Cheap to add (`GET /api/v1/embed/:token`), but it means an | |
| 529 | + unauthenticated channel join. | |
| 530 | + | |
| 531 | +--- | |
| 532 | + | |
| 533 | +## 10. Glossary | |
| 534 | + | |
| 535 | +| Term | Meaning | | |
| 536 | +|---|---| | |
| 537 | +| **Authority** | `Diagrams.Authority` — the Horde-registered GenServer owning one canvas's document and version. Every write goes through it. | | |
| 538 | +| **op** | One of the seven mutations in `Document.apply_op/2`. | | |
| 539 | +| **version** | Monotonic counter on `canvases`, incremented per accepted op. | | |
| 540 | +| **actor_ref** | Opaque binary a client attaches to its own ops to recognize the echo. | | |
| 541 | +| **token (color)** | A daisyUI color name from `Themes.color_tokens/0`, resolved per theme at render time — never a hex value in the document. | | |
| 542 | +| **canvas theme** | `canvases.theme`; how the document's color tokens resolve. Distinct from the app's chrome theme. | |
modified
lib/diogramos/accounts.ex
+96
−0
| 60 | 60 | """ |
| 61 | 61 | def get_user!(id), do: Repo.get!(User, id) |
| 62 | 62 | |
| 63 | + @doc """ | |
| 64 | + Gets a single user, or nil when there is no such id. | |
| 65 | + | |
| 66 | + The bang version is the one to use when the id came from a trusted | |
| 67 | + path; this one exists for callers holding an id that may have been | |
| 68 | + deleted since, such as a socket outliving its user. | |
| 69 | + """ | |
| 70 | + def get_user(id), do: Repo.get(User, id) | |
| 71 | + | |
| 63 | 72 | ## User registration |
| 64 | 73 | |
| 65 | 74 | @doc """ |
| 347 | 356 | UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token)) |
| 348 | 357 | end |
| 349 | 358 | |
| 359 | + ## API tokens | |
| 360 | + | |
| 361 | + @doc """ | |
| 362 | + Mints an API token for a native client and returns the encoded string. | |
| 363 | + Hand it to the client exactly once — only the hash is stored. | |
| 364 | + | |
| 365 | + `attrs` may carry a `:label` (or `"label"`) shown in the device list. | |
| 366 | + """ | |
| 367 | + def create_api_token(%User{} = user, attrs \\ %{}) do | |
| 368 | + {encoded_token, user_token} = UserToken.build_api_token(user, attrs) | |
| 369 | + Repo.insert!(user_token) | |
| 370 | + encoded_token | |
| 371 | + end | |
| 372 | + | |
| 373 | + @doc """ | |
| 374 | + Looks up a user by API token. Returns `{user, token_id}`, or `nil` when | |
| 375 | + the token is missing, malformed, expired, or revoked. | |
| 376 | + | |
| 377 | + On success it opportunistically refreshes `authenticated_at` so an | |
| 378 | + active client never ages out. The token id comes back so the auth plug | |
| 379 | + can assign it — `DELETE /api/v1/sessions/current` revokes that specific | |
| 380 | + device with it. | |
| 381 | + """ | |
| 382 | + def fetch_user_by_api_token(token) when is_binary(token) do | |
| 383 | + with {:ok, query} <- UserToken.verify_api_token_query(token), | |
| 384 | + {user, token_struct} <- Repo.one(query) do | |
| 385 | + maybe_touch_api_token(token_struct) | |
| 386 | + {user, token_struct.id} | |
| 387 | + else | |
| 388 | + _ -> nil | |
| 389 | + end | |
| 390 | + end | |
| 391 | + | |
| 392 | + def fetch_user_by_api_token(_), do: nil | |
| 393 | + | |
| 394 | + defp maybe_touch_api_token(%UserToken{} = token_struct) do | |
| 395 | + if UserToken.api_token_needs_refresh?(token_struct) do | |
| 396 | + Repo.update_all( | |
| 397 | + from(t in UserToken, where: t.id == ^token_struct.id), | |
| 398 | + set: [authenticated_at: DateTime.utc_now(:second)] | |
| 399 | + ) | |
| 400 | + end | |
| 401 | + | |
| 402 | + :ok | |
| 403 | + end | |
| 404 | + | |
| 405 | + @doc "Lists a user's active API tokens, most recently used first." | |
| 406 | + def list_api_tokens(%User{id: id}) do | |
| 407 | + from(t in UserToken, | |
| 408 | + where: t.user_id == ^id and t.context == "api", | |
| 409 | + order_by: [desc: t.authenticated_at], | |
| 410 | + select: %{ | |
| 411 | + id: t.id, | |
| 412 | + label: t.label, | |
| 413 | + sent_to: t.sent_to, | |
| 414 | + authenticated_at: t.authenticated_at, | |
| 415 | + inserted_at: t.inserted_at | |
| 416 | + } | |
| 417 | + ) | |
| 418 | + |> Repo.all() | |
| 419 | + end | |
| 420 | + | |
| 421 | + @doc """ | |
| 422 | + Revokes one API token by id, scoped to its owner. | |
| 423 | + | |
| 424 | + Also broadcasts `"disconnect"` on the user's socket topic so live | |
| 425 | + channels are torn down immediately. That kicks *all* of this user's | |
| 426 | + sockets: the revoked token's reconnect 401s, the others reconnect | |
| 427 | + cleanly. Brief disruption is the trade-off for not threading per-token | |
| 428 | + socket ids through `UserSocket.id/1`. | |
| 429 | + """ | |
| 430 | + def delete_api_token(%User{id: user_id}, token_id) do | |
| 431 | + {count, _} = | |
| 432 | + Repo.delete_all( | |
| 433 | + from(t in UserToken, | |
| 434 | + where: t.id == ^token_id and t.user_id == ^user_id and t.context == "api" | |
| 435 | + ) | |
| 436 | + ) | |
| 437 | + | |
| 438 | + if count == 1 do | |
| 439 | + DiogramosWeb.Endpoint.broadcast("user_socket:#{user_id}", "disconnect", %{}) | |
| 440 | + :ok | |
| 441 | + else | |
| 442 | + {:error, :not_found} | |
| 443 | + end | |
| 444 | + end | |
| 445 | + | |
| 350 | 446 | @doc """ |
| 351 | 447 | Deletes the signed token with the given context. |
| 352 | 448 | """ |
modified
lib/diogramos/accounts/user_token.ex
+80
−0
| 12 | 12 | @change_email_validity_in_days 7 |
| 13 | 13 | @session_validity_in_days 14 |
| 14 | 14 | |
| 15 | + # API tokens (context "api") issued to native clients. Expiry is measured | |
| 16 | + # against `authenticated_at`, which is refreshed at most once a day on | |
| 17 | + # use, so an active device never gets logged out. | |
| 18 | + @api_token_validity_in_days 90 | |
| 19 | + @api_token_refresh_after_seconds 24 * 60 * 60 | |
| 20 | + | |
| 15 | 21 | schema "users_tokens" do |
| 16 | 22 | field :token, :binary |
| 17 | 23 | field :context, :string |
| 18 | 24 | field :sent_to, :string |
| 19 | 25 | field :authenticated_at, :utc_datetime |
| 26 | + field :label, :string | |
| 20 | 27 | belongs_to :user, Diogramos.Accounts.User |
| 21 | 28 | |
| 22 | 29 | timestamps(type: :utc_datetime, updated_at: false) |
| 153 | 160 | defp by_token_and_context_query(token, context) do |
| 154 | 161 | from UserToken, where: [token: ^token, context: ^context] |
| 155 | 162 | end |
| 163 | + | |
| 164 | + ## API tokens --------------------------------------------------------- | |
| 165 | + | |
| 166 | + @doc """ | |
| 167 | + Builds an API token for native clients. Returns `{encoded_token, struct}` | |
| 168 | + — the caller inserts the struct and hands `encoded_token` to the client | |
| 169 | + over a secure channel exactly once. Only the hash is stored. | |
| 170 | + | |
| 171 | + Accepts an optional `label` (e.g. "Pixel 7, kitchen") to tell tokens | |
| 172 | + apart in the device list. | |
| 173 | + """ | |
| 174 | + def build_api_token(user, attrs \\ %{}) do | |
| 175 | + token = :crypto.strong_rand_bytes(@rand_size) | |
| 176 | + hashed_token = :crypto.hash(@hash_algorithm, token) | |
| 177 | + now = DateTime.utc_now(:second) | |
| 178 | + | |
| 179 | + {Base.url_encode64(token, padding: false), | |
| 180 | + %UserToken{ | |
| 181 | + token: hashed_token, | |
| 182 | + context: "api", | |
| 183 | + sent_to: user.email, | |
| 184 | + label: attrs |> normalize_label() |> truncate_label(), | |
| 185 | + authenticated_at: now, | |
| 186 | + user_id: user.id | |
| 187 | + }} | |
| 188 | + end | |
| 189 | + | |
| 190 | + defp normalize_label(attrs) do | |
| 191 | + case Map.get(attrs, :label) || Map.get(attrs, "label") do | |
| 192 | + str when is_binary(str) -> str |> String.trim() |> nil_if_empty() | |
| 193 | + _ -> nil | |
| 194 | + end | |
| 195 | + end | |
| 196 | + | |
| 197 | + defp nil_if_empty(""), do: nil | |
| 198 | + defp nil_if_empty(str), do: str | |
| 199 | + | |
| 200 | + defp truncate_label(nil), do: nil | |
| 201 | + defp truncate_label(str), do: String.slice(str, 0, 80) | |
| 202 | + | |
| 203 | + @doc """ | |
| 204 | + Looks up an API token. Returns `{:ok, query}` selecting `{user, token}` | |
| 205 | + if the encoded token decodes, `:error` otherwise. The query enforces the | |
| 206 | + @api_token_validity_in_days window against `authenticated_at`. | |
| 207 | + """ | |
| 208 | + def verify_api_token_query(token) when is_binary(token) do | |
| 209 | + case Base.url_decode64(token, padding: false) do | |
| 210 | + {:ok, decoded_token} -> | |
| 211 | + hashed_token = :crypto.hash(@hash_algorithm, decoded_token) | |
| 212 | + | |
| 213 | + query = | |
| 214 | + from token in by_token_and_context_query(hashed_token, "api"), | |
| 215 | + join: user in assoc(token, :user), | |
| 216 | + where: token.authenticated_at > ago(@api_token_validity_in_days, "day"), | |
| 217 | + select: {user, token} | |
| 218 | + | |
| 219 | + {:ok, query} | |
| 220 | + | |
| 221 | + :error -> | |
| 222 | + :error | |
| 223 | + end | |
| 224 | + end | |
| 225 | + | |
| 226 | + def verify_api_token_query(_), do: :error | |
| 227 | + | |
| 228 | + @doc """ | |
| 229 | + True when the token's `authenticated_at` is stale enough to warrant a | |
| 230 | + refresh write, so the auth plug can skip the DB write on the common path | |
| 231 | + where the token was used minutes ago. | |
| 232 | + """ | |
| 233 | + def api_token_needs_refresh?(%UserToken{context: "api", authenticated_at: ts}) do | |
| 234 | + DateTime.diff(DateTime.utc_now(:second), ts, :second) >= @api_token_refresh_after_seconds | |
| 235 | + end | |
| 156 | 236 | end |
modified
lib/diogramos/application.ex
+14
−2
Click to load diff…
modified
lib/diogramos/diagrams.ex
+1
−0
Click to load diff…
modified
lib/diogramos/diagrams/authority.ex
+75
−8
Click to load diff…
modified
lib/diogramos/diagrams/canvases.ex
+23
−1
Click to load diff…
added
lib/diogramos_web/api/auth.ex
+44
−0
Click to load diff…
added
lib/diogramos_web/api/error_json.ex
+17
−0
Click to load diff…
added
lib/diogramos_web/api/fallback_controller.ex
+55
−0
Click to load diff…
added
lib/diogramos_web/api/v1/canvas_controller.ex
+120
−0
Click to load diff…
added
lib/diogramos_web/api/v1/canvas_json.ex
+45
−0
Click to load diff…
added
lib/diogramos_web/api/v1/folder_controller.ex
+20
−0
Click to load diff…
added
lib/diogramos_web/api/v1/session_controller.ex
+83
−0
Click to load diff…
added
lib/diogramos_web/api/v1/theme_controller.ex
+22
−0
Click to load diff…
added
lib/diogramos_web/api/v1/user_controller.ex
+17
−0
Click to load diff…
added
lib/diogramos_web/channels/canvas_channel.ex
+188
−0
Click to load diff…
added
lib/diogramos_web/channels/user_socket.ex
+32
−0
Click to load diff…
modified
lib/diogramos_web/endpoint.ex
+7
−0
Click to load diff…
modified
lib/diogramos_web/router.ex
+37
−4
Click to load diff…
added
priv/repo/migrations/20260914120000_add_label_to_users_tokens.exs
+12
−0
Click to load diff…
added
test/diogramos/accounts_api_token_test.exs
+117
−0
Click to load diff…
added
test/diogramos/diagrams/authority_wal_test.exs
+153
−0
Click to load diff…
added
test/diogramos_web/api/v1/canvas_api_test.exs
+234
−0
Click to load diff…
added
test/diogramos_web/channels/canvas_channel_test.exs
+185
−0
Click to load diff…
added
test/support/channel_case.ex
+22
−0
Click to load diff…
Parents: 2852f94