Waxx Android client — design doc
Status: draft, pre-implementation. Owner: gmp@h4xmb.org. Goal: native Android client with full feature parity to the web LiveView, sharing the existing Phoenix backend.
This doc is the contract for the work. Implementation lands in phases (see Phase plan); each phase has its own PR(s) and updates this doc with what shipped.
1. Goals & non-goals
Goals
- Native Kotlin/Compose Android app, distributed via Play Store eventually.
- Full parity with the web UI: boards, kanban DnD, templates, settings, invites, history, subboards, themes, label/field/assignee management.
- Real-time multi-device updates — a drag on web is visible on Android within the same second a drag on web is visible to another web tab.
- Two auth paths into the app: tap-an-email-link (magic-link via Android
App Links) or scan-a-QR generated by the logged-in web user (mirrors
the pairing flow in
../dms). Either alone is sufficient — most users will use the QR path on self-hosted installs where SMTP isn’t wired up. - Server stays the source of truth for transition enforcement, drift rules, and propagation logic — none of those rules get reimplemented on the client.
Non-goals (v1)
- iOS. The architecture should not preclude it, but no iOS work in v1.
- Offline-first writes. Read caching is fine; mutations require a live connection. Adding queued offline writes is post-v1.
- Push notifications. Possible later via FCM, not in this scope.
- A public/third-party API. The endpoints exist for the first-party Android client only — stability guarantees are weak until v2.
2. Architecture overview
+-------------------+ HTTPS/JSON +-----------------------------+
| Android client | <---------------> | WaxxWeb.Api.* (Phase 0) |
| (Kotlin/Compose) | | - Plug pipeline :api |
| | WSS/Channels | - bearer-token auth |
| | <---------------> | WaxxWeb.BoardChannel |
+-------------------+ | (Phase 3) |
+--------------+--------------+
|
v
+-----------------------------+
| Waxx.{Accounts,Kanban, |
| Workflows} contexts |
| (UNCHANGED business logic)|
+-----------------------------+
The server gains two new surfaces — an /api/v1 HTTP scope and a
/socket user socket with a BoardChannel. Both call into the existing
contexts. No business logic moves; only thin presentation/transport
layers are added.
The Android app is a single Gradle project under /android, structured
as a few feature modules around a shared :core module that owns the
HTTP/socket client, models, and auth state.
3. HTTP API design
Conventions
-
Scope:
/api/v1/.... Major version bumps for breaking changes; additive fields are not breaking. -
Auth:
Authorization: Bearer <token>on every request except the session-redemption endpoints. Tokens are minted in theWaxx.Accounts.UserTokentable with a new context"api". -
Content type:
application/jsonon requests and responses. snake_case field names (matches the existing schemas). -
Timestamps: ISO-8601 UTC strings (
"2026-05-24T10:30:00Z"). -
IDs: UUIDv7 strings as-is — the client treats them as opaque.
-
Errors: a uniform shape, never a bare 500 with HTML.
{ "error": { "code": "invalid_transition", "message": "Cards in 'Done' cannot move to 'Backlog'", "details": { "from_stage_id": "...", "to_stage_id": "..." } } }Standard codes:
unauthenticated,forbidden,not_found,validation_failed(details = changeset errors),invalid_transition,conflict(e.g. stale-write),rate_limited. -
Pagination: cursor-based (
?cursor=<opaque>&limit=50); response includesnext_cursor(null when done). UUIDv7 IDs makeid > cursorpagination trivially time-ordered. -
Concurrency: writes that mutate a card include
If-Match: <card_id>:<lock_version>once we add alock_versioncolumn (Phase 4). Phase 2 read-only is fine without it.
Endpoints (v1 surface)
Auth
POST /api/v1/sessions/request_magic_link{email}→204POST /api/v1/sessions/redeem{token}→200 {api_token, user}DELETE /api/v1/sessions/current→204GET /api/v1/users/me→200 {id, email, preferences}PATCH /api/v1/users/me/preferences{key, value}→200
Boards
GET /api/v1/boards→ list user’s boards (with role, last activity)POST /api/v1/boards{template_id, name}→201 boardGET /api/v1/boards/:id→ board metadata + memberships + settingsGET /api/v1/boards/:id/workflow→ stages + transitions + labels + fields + subboards (the static structure)GET /api/v1/boards/:id/cards→ cards in the board (with assignees, labels, field values, stage_id, subboard_id, position)PATCH /api/v1/boards/:id→ settings (name, archive days, etc.)DELETE /api/v1/boards/:idGET /api/v1/boards/:id/history?cursor=...GET /api/v1/boards/:id/membershipsPOST /api/v1/boards/:id/invites{role}→ returns invite URLDELETE /api/v1/boards/:id/memberships/:user_id
Cards
POST /api/v1/boards/:id/cards→ createPATCH /api/v1/cards/:id→ title/description/field values/assignees/labelsPOST /api/v1/cards/:id/move{stage_id, subboard_id, position}→ callsKanban.move_card/3; returns422 invalid_transitionif illegalDELETE /api/v1/cards/:idPOST /api/v1/cards/:id/notes{body}→ card notes (chat thread)GET /api/v1/cards/:id→ full card with notes, history hints
Templates
GET /api/v1/workflow_templatesPOST /api/v1/workflow_templates{name}GET /api/v1/workflow_templates/:id→ stages, transitions, labels, fieldsPATCH /api/v1/workflow_templates/:idDELETE /api/v1/workflow_templates/:idPOST /api/v1/workflow_templates/:id/stagesPATCH /api/v1/workflow_templates/:id/stages/:stage_idDELETE /api/v1/workflow_templates/:id/stages/:stage_id- (same shape for transitions, labels, fields)
Invites / accounts admin
GET /api/v1/users/invitesPOST /api/v1/users/invites{email}→201 {invite_url}DELETE /api/v1/users/invites/:id
View modules
Phoenix views are JSON-only here. Layout in code:
lib/waxx_web/
api/
auth.ex # bearer-token plug + scope assign
error_handler.ex # falls back FallbackController for {:error, _}
fallback_controller.ex
json/ # render helpers; one module per resource
board_json.ex
card_json.ex
workflow_json.ex
...
v1/
session_controller.ex
board_controller.ex
card_controller.ex
template_controller.ex
...
Router additions (sketch):
pipeline :api do
plug :accepts, ["json"]
plug WaxxWeb.Api.Auth # parses Bearer, assigns :current_scope
end
pipeline :api_public do
plug :accepts, ["json"]
end
scope "/api/v1", WaxxWeb.Api.V1, as: :api_v1 do
pipe_through :api_public
post "/sessions/request_magic_link", SessionController, :request_magic_link
post "/sessions/redeem", SessionController, :redeem
end
scope "/api/v1", WaxxWeb.Api.V1, as: :api_v1 do
pipe_through :api
delete "/sessions/current", SessionController, :delete
resources "/boards", BoardController, except: [:new, :edit] do
get "/workflow", BoardController, :workflow
get "/cards", BoardController, :cards
get "/history", BoardController, :history
resources "/memberships", MembershipController, only: [:index, :delete]
resources "/invites", BoardInviteController, only: [:create]
post "/cards", CardController, :create, as: :card
end
patch "/cards/:id", CardController, :update
delete "/cards/:id", CardController, :delete
post "/cards/:id/move", CardController, :move
# ...templates, invites, users
end
4. Auth flows
Two independent paths get an api-context bearer token onto the device.
Both produce the same kind of token — once the app holds it, every
subsequent request looks identical.
4a. Magic link with Android App Links
[Android] [Phoenix server] [Mail / browser]
| | |
|-- POST /api/v1/sessions/ | |
| request_magic_link (email) | |
|----------------------------->|--- send mail w/ link ------->|
| 204 | |
|<-----------------------------| |
| | |
| | user taps link in mail |
| | | |
| Android opens via App Link (https://waxx.example/m/<tok>) |
|<--- intent (data URI) ---------------------------------------|
| | |
|-- POST /api/v1/sessions/ | |
| redeem {token} | |
|----------------------------->| |
| 200 {api_token, user} | |
|<-----------------------------| |
| | |
|-- everything after carries | |
| Authorization: Bearer ... | |
Key details:
- The magic-link URL becomes
https://waxx.example/m/<token>(new route, not the existing/users/log-in/<token>web flow — the web flow logs the user in directly to a browser session, we want a redirect that’s also claimable by the Android intent filter). - Server-side,
GET /m/:tokendoes an HTML redirect to the web login flow if the request comes from a browser (no Android handoff happened). For the Android path, the intent filter on the app intercepts before the browser ever loads anything, and the app immediately callsPOST /api/v1/sessions/redeem. redeeminvokes the existingWaxx.Accounts.login_user_by_magic_link/1to consume the magic-link token, then mints a newUserTokenwith context"api"(long-lived; 90-day rolling validity, renewed on use).- API tokens are revocable individually from
/users/settings(“Connected devices” section, new in Phase 1) and bulk-revoked when the user changes email/password.
Why not the web /users/log-in/<token> route?
That route creates a Plug session cookie and redirects to a HEEx page.
It assumes a browser. Reusing it for Android would mean parsing
HTML or hijacking the redirect. A purpose-built /m/:token + POST /redeem
pair is cleaner and lets the web fallback continue working untouched.
App Links setup
- Domain: TBD (user has a domain; needs to be the same one Phoenix
serves the API from, e.g.
waxx.example.com). - File:
https://waxx.example.com/.well-known/assetlinks.json, served by Phoenix as a static asset (add topriv/static/.well-known/or serve via a controller). - Contents:
[{ "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.waxx.app", "sha256_cert_fingerprints": ["AB:CD:..."] } }] - Two fingerprints listed: the debug keystore and the release keystore.
- Keystore generation (Phase 0): we generate both, store the release
keystore + password in a secret manager (1Password / Bitwarden), and
commit only the debug one to
android/keys/.
Token lifecycle
apitokens: 90 day inactivity expiry, refreshed (inserted_atbumped) on every successful authenticated request.- Revocation: server-side delete from
users_tokens; any in-flight request gets401 unauthenticated. - The Android app stores the token in EncryptedSharedPreferences (AES via the Android Keystore).
4b. Pair via QR (mirrors ../dms)
The web user — already authenticated in a browser session — generates an
API token from /users/settings (“Connected devices” panel). The
server renders a QR encoding everything the app needs (server URL +
token), the phone scans it, and the phone is paired. No email, no SMTP,
no App Links required.
[Browser, logged in] [Phoenix server] [Phone]
| | |
|-- POST /users/settings/api_tokens | |
| (LiveView form: label, ttl) | |
|-------------------------------------->| |
| |-- Accounts.create_api_ |
| | token/2 |
| | |
| 200 (LiveView re-renders with the | |
| "token created" panel) | |
|<--------------------------------------| |
| | |
| panel shows QR + URI + raw token | |
| (only time the raw token is shown) | |
| | |
| user opens app on phone, | |
| taps "Scan QR" on Pair screen ----->|------------------------->|
| | |
| phone parses payload, calls | |
| GET /api/v1/users/me with bearer | |
| ------------------------------------|<-------------------------|
| | 200 {user} |
| |------------------------->|
| | |
| | phone stores (base, |
| | token), lands on app |
QR payload format
waxx://pair?base=<url-encoded server URL>&token=<url-encoded token>
baseis the full origin the app should talk to, e.g.https://waxx.example.comorhttp://192.168.1.50:4000for LAN.tokenis the raw bearer token (same string the user would paste intoAuthorization: Bearer …).- The custom
waxx://scheme also lets the user tap the URI from a note or chat to deep-link straight into the Pair screen with both fields prefilled (intent filter onwaxx://pairin the manifest).
Server-side base URL derivation
Mirrors derive_base_url/2 in dms/src/handlers/api_tokens.rs so
self-hosted installs behind a reverse proxy don’t bake a bind address
into the QR. Priority:
WAXX_PUBLIC_URLenv (explicit override).X-Forwarded-Proto+X-Forwarded-Host(standard proxy headers).- The request’s
Hostheader (covers LAN access on0.0.0.0). - The endpoint config as a last resort.
Web “Connected devices” panel
New LiveView surface inside the existing /users/settings page (mounts
into UserLive.Settings as another section). Lists active API tokens
with sent_to, authenticated_at, inserted_at, and a Revoke action.
The Generate form sits at the top; submitting it inserts the token row
and re-renders with the QR panel inline — the same idiom as dms’s
api_tokens/created.html template.
Server dependencies
- QR rendering: add
:eqrcodetomix.exs(pure-Elixir, SVG output). Roughly:{:eqrcode, "~> 0.2.1"}. - No other new deps. The token-minting path (
Accounts.create_api_token/1) already exists from Phase 0.
Android dependencies and screen
- Scanner:
journeyapps:zxing-android-embedded(same lib as dms), via theScanContractactivity-result API. - Pair screen:
feature:auth/PairScreen.kt— three states (Idle, Verifying, Success/Error). Scan button launches the scanner; manual base+token fields below it for paste/typing fallbacks. On submit, callsGET /api/v1/users/mewith the candidate bearer; on success, writes toTokenStore(EncryptedSharedPreferences) and navigates to the Boards list.
Why both 4a and 4b?
- QR (4b) is the easier UX when the user already has a logged-in web session and physical access to the screen, especially for self-hosted Waxx without SMTP wired up. No App Links setup needed.
- Magic link (4a) is the answer for “I’m only on my phone, I’ve never logged in on a desktop.” Requires the SMTP path and App Links for the nicest UX (or falls back to the custom scheme).
The Android Login screen offers both as top-level buttons; users pick whichever fits their situation. The backend treats both identically once a bearer token is in hand.
5. Real-time channel
Server: WaxxWeb.UserSocket + BoardChannel
WaxxWeb.UserSocket
/socket connect: ?token=<api_token> → verifies via UserToken (context "api")
channel "board:*", BoardChannel
WaxxWeb.BoardChannel
join("board:<id>"):
- assert membership via Kanban.member?/2
- Kanban.subscribe(id) # joins the existing PubSub topic
- push("snapshot", workflow + cards)
handle_info({:cards_changed, _}, socket):
push("cards_changed", %{board_id: ...}) # client refetches /cards
handle_info({:workflow_changed, _}, socket):
push("workflow_changed", %{board_id: ...}) # client refetches /workflow
Channel pushes are intentionally lightweight notifications, not deltas — the client re-fetches the affected resource. Rationale:
- The existing PubSub events (
:cards_changed,:workflow_changed) already match this contract. - Avoids re-serialising state into channel payloads with their own schema drift.
- Re-fetch latency is small (one HTTP round-trip) and we can move to delta payloads later without changing the channel API surface.
The Android client uses [phoenix-channels-client (kotlin)] or rolls a small wrapper around OkHttp WebSocket; decision deferred to Phase 3.
Channel auth
UserSocket.connect/3 takes ?token=..., verifies it through
Waxx.Accounts.UserToken with context "api", assigns the user_id to
the socket. The board channel re-checks membership on every join.
6. Android app architecture
Stack
- Language: Kotlin 2.x
- UI: Jetpack Compose + Material 3
- DI: Hilt
- HTTP: Retrofit + OkHttp + Moshi (codegen)
- QR scanner:
com.journeyapps:zxing-android-embedded(matches dms;ScanContractactivity-result API) - WebSocket / Channels: phoenix-channels-client (Kotlin) — if mature
enough; otherwise a thin wrapper over OkHttp’s
WebSocket(decided Phase 3). - Navigation: Compose Navigation with type-safe routes
- State: ViewModel + StateFlow; no Redux/MVI framework
- Persistence: Room for caching boards/cards/workflow; auth token in EncryptedSharedPreferences
- Image loading: Coil (for user avatars if/when added)
- Build: Gradle 8.x with Kotlin DSL, version catalog
(
gradle/libs.versions.toml) - Min SDK: 26 (Android 8). Target SDK: current.
Module layout
android/
app/ # Entry point, Application, navigation host
core/
network/ # Retrofit interfaces, OkHttp interceptors,
# auth header injection, channel client
auth/ # Token storage (EncryptedSharedPreferences),
# magic-link redemption + QR pairing flows,
# ApplinkActivity (https://host/m/:token) +
# PairActivity (waxx://pair?...)
model/ # Data classes mirroring API JSON
db/ # Room database, DAOs, mappers
ui/ # Shared composables, theme (mirrors daisyUI
# palettes), error surfaces
feature/
boards/ # /boards list + create
board/ # /boards/:id kanban view + DnD + card sheet
template/ # Template editor (tabs: workflow/labels/fields)
settings/ # Board settings, user settings, invites
history/ # Per-board history
build-logic/ # Convention plugins for shared Gradle config
keys/ # Debug keystore (committed). Release keystore
# NOT committed.
Navigation graph
Login
├─ Email me a link (magic-link, App Link redemption)
└─ Scan QR / paste (QR pairing, opens PairScreen)
│
v
Boards list (root after auth)
├─ Board detail (kanban grid)
│ ├─ Card detail sheet
│ ├─ Board settings
│ ├─ Board history
│ └─ Board members & invites
├─ Workflow templates list
│ └─ Template editor (workflow / labels / fields tabs)
└─ User settings
├─ Email change
├─ Connected devices (list active API tokens, revoke)
└─ App invites
(The “Connected devices” panel also exists on the web under
/users/settings — that’s where the QR generator lives, since the
phone is the consumer side of the pairing.)
Drag and drop
Compose has no first-class kanban DnD. Realistic options:
reorderablelibrary (sh-no-rebola) — built for kanban-style reordering, supports cross-list drops.- Custom drag detection with
pointerInput+ a shared overlay layer for the drag avatar.
Plan: try (1) first; fall back to (2) if the constraints don’t fit (green/red drop highlighting tied to workflow edges, drag avatars that respect the card-chip layout). Decision in Phase 4.
The transition rules come from the workflow snapshot fetched at board
load and refreshed on workflow_changed. The client greys out illegal
drop targets, but the server is still authoritative — POST /cards/:id/move will return 422 invalid_transition if the client is
wrong (stale workflow, bug), and the UI rolls back optimistically.
Theming
Port the eight daisyUI themes (six neiam-co + blueprint + default) to
Material 3 ColorSchemes. Lives in core/ui/Themes.kt. Same persistence
model as web — preferences blob in users.preferences under the
theme_default key, synced on login.
Error handling
- Network errors surface as Compose snackbars with a Retry action.
401: drop the stored token, navigate to Login.403: snackbar “you don’t have access”.422 invalid_transition: silent (DnD already blocked it client-side; if we see this, log to Sentry — it’s a workflow-snapshot drift bug).5xx: snackbar “server error” + Sentry breadcrumb.
7. Build & release
Local dev
./gradlew :app:installDebugfrom/android.- API base URL is a
BuildConfigfield, defaulting tohttp://10.0.2.2:4000for the emulator pointing at the host’smix phx.server.
CI
- Add an
androidjob to.gitlab-ci.ymlthat runs on changes under/android/**:./gradlew :app:assembleDebug :app:testDebugUnitTest. - Release builds (Play Store) come later — out of scope for the design doc, but the keystore + signing-config story needs to be settled before we ship publicly.
Versioning
- Android
versionCode/versionNamedecoupled from the Phoenix release tag. The app advertisesX-Waxx-Client: android/<versionName>so the server can log/refuse old clients if needed.
8. Phase plan
Each phase is a separate PR (or small stack of PRs). The doc gets updated with what shipped before moving on.
Phase 0 — Backend foundation (no Android code) ✅ shipped
WaxxWeb.Api.Authplug +:api_authenticatedpipeline.UserTokengains"api"context with mint/verify/refresh/revoke.WaxxWeb.Api.FallbackController+ErrorJSONenvelope.WaxxWeb.Api.V1.SessionControllerwithrequest_magic_link,redeem,delete./m/:tokenroute handing off to the web login flow (Android intent filter intercepts before this fires).- 55 tests passing.
Phase 1a — Web “Connected devices” + QR generation ✅ shipped
:eqrcode ~> 0.2.0added tomix.exs.- Migration
20260524120000_add_label_to_users_tokensadds nullablelabel :stringtousers_tokens. Waxx.Accounts.UserToken.build_api_token/2andAccounts.create_api_token/2accept an optional label (trimmed, empty→nil, truncated to 80 chars).Accounts.list_api_tokens/1returns it.WaxxWeb.PublicUrl.derive/1implements the four-tier precedence (WAXX_PUBLIC_URL→X-Forwarded-*→ conn host/scheme/port → endpoint URL). Two-arity dispatch: takes aPlug.Connor aLiveView.Socket. The socket path requires:uri+:x_headersin the endpointconnect_info, and must be called frommount/3(LiveView destroys connect_info after mount), so callers stash the result in assigns.WaxxWeb.QR.svg/2wrapsEQRCodeand returns an SVG string suitable for inlineraw/1rendering.UserLive.Settingsgrew a “Connected devices” section: label form → Generate → one-time QR panel (SVG + base URL + raw token + thewaxx://pair?...URI) → list of active tokens with Revoke buttons. Sudo mode is required (inherited from the page’s on_mount).- 22 new tests covering: label handling on Accounts, derive precedence
on
Plug.Conn, QR output shape, LiveView interactions (empty state, generate, revoke, dismiss). ConnCase.log_in_user/2helper added for future LiveView tests.
Phase 1b — Android scaffold + both login paths ✅ shipped
/androidGradle project underorg.neiam.waxx.app(single-module to match../dms/android’s shape; multi-module split deferred until the screen count justifies it). Kotlin 2.0 · Compose · AGP 8.5.2 · Gradle 9.0 · minSdk 26 · targetSdk 34.:app:assembleDebugproduces an 18 MB APK with no warnings.GET /api/v1/users/meendpoint (WaxxWeb.Api.V1.UserController) pulled forward from Phase 2 as the Pair-screen / Boards-stub probe..well-known/assetlinks.jsonserved from Phoenix (priv/static/.well-known/, added toWaxxWeb.static_paths). Placeholders for debug + release SHA-256 fingerprints; instructions inandroid/README.md.- Manifest registers two intent filters:
waxx://pair?base=…&token=…(custom scheme, noautoVerify— works in any debug build immediately).https://${appLinksHost}/m/<token>(autoVerify=true— needs a real domain + matchingassetlinks.jsonto take effect; falls back to the OS chooser otherwise).
- Three screens:
LoginScreen(two-button picker),PairScreen(QR scan + paste fallback, ports the dms equivalent),MagicLinkScreen(send + paste-link / wait-for-deep-link). BoardsScreenis a stub that calls/api/v1/users/meto confirm the token is good end-to-end; Phase 2 replaces it with the boards list.TokenStoreuses EncryptedSharedPreferences (AES via Android Keystore);data_extraction_rules.xmlexcludes the secure-prefs file from cloud backup + device transfer.- README documents the local dev workflow (emulator →
10.0.2.2:4000,WAXX_PUBLIC_URLoverride for QR), keystore generation, and the App Links handover process. - Backend now at 81 tests (+4 for
/users/meand assetlinks serving).
Deferred from Phase 1b:
- Release keystore (documented; not generated — needs an out-of-repo secret store).
- GitLab CI job for
:app:assembleDebug+ tests (needs an Android-SDK image; deferred to a dedicated CI PR). - Hilt / multi-module split (revisit when screen count grows).
Phase 2 — Read-only boards & kanban ✅ shipped
WaxxWeb.Api.V1.BoardControllerwith five actions backed by the existingKanbancontext — every action runsKanban.get_board_for_user/2first, which 404s for non-members (no leak of board existence):GET /api/v1/boards→ list with role + timestampsGET /api/v1/boards/:id→ board metadata + membersGET /api/v1/boards/:id/workflow→ stages, transitions, labels, fields, subboards (the static structure)GET /api/v1/boards/:id/cards→ cards with assignee_ids, label_ids, field_values (already runs the archive-terminal filter)GET /api/v1/boards/:id/history?limit=N→ activities newest-first, limit clamped to [1, 500], default 200
WaxxWeb.Api.V1.BoardJSON— pure serialization, takes Ecto structs and returns plain maps. HandlesEcto.Association.NotLoadedso a missing preload doesn’t crash.Waxx.KanbanFixtures(test/support) —template_fixture,board_fixture,card_fixtureso controller tests don’t have to rebuild graphs inline.- Android replaces the v1b stub with a three-screen flow:
BoardsListScreen— calls/boards, taps openBoardScreen.BoardScreen— calls/board/:id,/workflow,/cardsin parallel-fetch fashion; renders a horizontally-scrolling kanban ofStageColumns with card chips. Top-right History button opensHistoryScreen. Card taps surface aCardSheet(ModalBottomSheet).HistoryScreen— calls/history, renders newest-first.
- Activity
meta(jsonb) is intentionally dropped on the Android side for now —ignoreUnknownKeys = trueon the kotlinx.serialization config makes this safe. The web LiveView still renders it per-action; porting that formatter is deferred. - Backend: 10 new controller tests, 91 tests total green.
- Android:
:app:assembleDebugclean (no warnings).
Deferred from Phase 2 (tracked):
- Room caching with stale-while-revalidate: every screen currently re-fetches on entry. The data model is small enough (single board ≤ ~1 MB JSON) that the perceived latency is mostly the request itself, not parsing. Add Room when there’s evidence — e.g., users tapping back/forward across boards in poor connectivity.
- Subboards in the kanban view: the API returns
subboard_idon cards and the workflow’ssubboardslist, butBoardScreendoesn’t group rows yet. Add when Phase 6 lands. - Activity
metarendering: needs a per-action formatter ported fromWaxxWeb.BoardLive.History. Deferred to a polish PR.
Phase 3 — Real-time ✅ shipped
WaxxWeb.UserSocket— token-authenticated Phoenix socket; the sameapi-context bearer used by HTTP. Connect rejects bad/missing tokens;id/1returns"user_socket:#{user_id}"so future server- sideEndpoint.broadcast("user_socket:<id>", "disconnect", %{})calls can force-evict a logged-out device.WaxxWeb.BoardChannel— topic"board:<id>". Join asserts the caller has aMembershiprow on the board; non-members get{:error, "forbidden"}. On join the channel subscribes to the existingKanbanPubSub topic and forwards:cards_changed/:workflow_changedas lightweight channel pushes (no payload deltas — the client refetches the affected resource via HTTP).- Endpoint registers
socket "/socket", WaxxWeb.UserSocketalongside the existing LiveView socket. - 6 channel tests: connect (good/bad/missing token), join (member /
non-member), end-to-end push forwarding via
card_fixture/3. - Android adds
com.github.dsrees:JavaPhoenixClient:0.3.4(jitpack; newer 1.x versions all show “Error” on jitpack right now). WaxxSocket.subscribeBoard(creds, boardId)returns aFlow<BoardEvent>:Connected | Joined | Disconnected | CardsChanged | WorkflowChanged | Error. One socket per Flow keeps lifecycle simple (the lib’s 0.3.x API doesn’t exposeoff()on socket-level listeners).BoardScreenruns aDisposableEffectthat launches the subscription; refetches on cards_changed / workflow_changed; tracks connection state for the new top banner (“Connecting…” / “Offline — changes won’t sync until reconnect.”) shown when not Live.
Deferred:
- Force-disconnect on token revocation. Hook
Endpoint.broadcast("user_socket:#{user_id}", "disconnect", %{})intoAccounts.delete_api_token/2so revoking a device from /users/settings kicks the live socket too. Currently the revoked bearer becomes 401 on next HTTP call but the existing channel stays open until reconnect. - Per-channel resubscribe metrics / Sentry breadcrumbs. Worth doing when we have telemetry plumbed but not load-bearing now.
Phase 4 — Card writes (core) ✅ shipped
(Scope trimmed — see Phase 4b for the rest.)
WaxxWeb.Api.V1.CardControllerwith four actions, all gated on owner/editor role on the card’s board:POST /api/v1/boards/:board_id/cards{title, description?, board_stage_id?, subboard_id?, position?}PATCH /api/v1/cards/:id{title?, description?}POST /api/v1/cards/:id/move{board_stage_id, position?}DELETE /api/v1/cards/:id
- 404 for non-members, 403 for members without edit permission, 422 for validation / invalid_transition / invalid_stage.
- Fixed a latent bug in
Kanban.delete_card/2: it was logging an activity withcard_id: <just-deleted id>which violated the FK. Switched to logging withcard_id: nil;meta.titlealready carries the human-readable string. No existing test triggered this path — the LiveView UI would have crashed on first delete in prod. WaxxWeb.Api.V1.BoardJSON.card_response/1— single-card envelope so mutation endpoints share the JSON shape with the list endpoint.- 11 new controller tests (create / update / move / delete × happy path + auth + role + transition).
- Android writes:
WaxxApi.createCard / updateCard / moveCard / deleteCard(+ matching request body models). BoardScreengains a FAB +CreateCardDialog(title + optional description); on success refetches cards immediately (the channel push also fires).CardSheetgains three actions (Edit / Move / Delete):- Edit swaps into an in-place title/description editor.
- Move shows a stage picker built from the workflow’s
transitions(only valid targets surface; server still authoritative via 422invalid_transition). - Delete prompts a confirm dialog, then dismisses the sheet.
Phase 4b — Labels, fields, assignees, notes ✅ shipped
-
WaxxWeb.Api.V1.CardControllergains five actions:GET /api/v1/cards/:id— single card withnotespreloadedPOST /api/v1/cards/:id/labels/:label_id/toggle— toggle on/offPUT /api/v1/cards/:id/fields/:field_id{value}— set or clearPOST /api/v1/cards/:id/assignees{user_id}— must already be a board memberDELETE /api/v1/cards/:id/assignees/:user_id
-
New
WaxxWeb.Api.V1.NoteController:POST /api/v1/cards/:card_id/notes{body, kind?}PATCH /api/v1/notes/:id{body?, done?}DELETE /api/v1/notes/:id
-
All gated on owner/editor. Member validation on assignees (
fetch_board_member/2) prevents assigning users outside the board’s membership list — mirrors the LiveViewassignable_usersguard. -
Labels from a different board map to
:not_found(no leak). Invalidselectfield options map tovalidation_failed. -
BoardJSON.card_detail_response/1includesnotes(intentionally not on the list payload — would bloat every/cardsfetch).BoardJSON.note/1+note_response/1for the note JSON envelope. -
Test fixtures:
board_label_fixture/2,board_field_fixture/2for direct board-scoped seeding. -
14 new controller tests; backend now at 122 total.
-
Android: 8 new
WaxxApimethods + matching request bodies and theCardDetail/CardNoteresponse models. -
BoardScreenalso surfacesboard.membershipsvia a newonMembersLoadedcallback so the sheet can render assignee chips without re-fetching. -
CardSheetrewritten as a scrollableLazyColumnwith sections:- Title / Edit / Move / Delete (existing)
- Labels —
FilterChiptoggles for every label on the board. - Fields — per-field row with kind-aware editor (text input or select option list); Save / Clear / Cancel.
- Assignees —
FilterChipper board member, toggles assignment. - Notes — list of notes (with todo-style checkbox when
kind == "todo") + inline add form (free-form or todo).
-
Card sheet fetches
GET /api/v1/cards/:idon open for the freshest state + notes; mutations re-fetch the detail before re-rendering.
Phase 4c — Drag-and-drop kanban ✅ shipped
org.neiam.waxx.app.ui.DragState— shared state container for the kanban DnD:start / update / cancel / dropAt(workflow)lifecyclesetStageBounds / clearStageBoundsfor column hit-testisValidTarget(stageId, workflow)for column highlighting
BoardScreenowns one instance and passes it down. The drop handler reads the dragged card before clearing the session, then calls/api/v1/cards/:id/movewith the target stage id. Refetches cards on success (channel push fires too).- Long-press starts a drag on a card chip (
detectDragGesturesAfterLongPress). The chip stays in its original column (no snapshot avatar yet — user sees a blue border on the source chip while dragging). - Each
StageColumnregisters its window-space bounds viaModifier.onGloballyPositioned. During a drag, columns paint a translucent overlay:- grey (
#33888888) for the source column - green (
#6680FF80) for valid targets (transition exists) - red (
#66FF8080) for forbidden targets
- grey (
- The server is still authoritative: a drop that would be valid but
has stale workflow data still gets a
422 invalid_transitionand the chip snaps back via the next refresh.
Phase 4c remainder — still deferred
- Within-column reorder via DnD — the move endpoint accepts a
positionargument; the chip doesn’t expose insertion-line UI yet. Wait until users ask. - Auto-scroll during drag — boards with more than ~3 columns don’t fit on a phone in landscape; today the user has to release+rescroll. Add when columns commonly exceed the screen width.
lock_versionoptimistic locking — concurrent-edit collisions follow web LiveView’s last-write-wins model today. PubSub fan-out narrows the window; revisit when there’s a real case of clobbering.- Subboards (2-D grid) — covered by the API but the kanban view doesn’t group rows yet. Will land alongside Phase 6.
Phase 5 — Board settings, members, invites ✅ shipped
(Template editor + user preferences pulled out into Phase 5b; this turn keeps the daily-driver pieces.)
-
Endpoints (all editor-or-owner enforced as appropriate):
PATCH /api/v1/boards/:id— rename, description, archive days (owner-only)DELETE /api/v1/boards/:id— owner-onlyPUT /api/v1/boards/:board_id/memberships/:user_id{role}(owner-only)DELETE /api/v1/boards/:board_id/memberships/:user_id— owner-only; refuses the last-owner removalGET /api/v1/boards/:board_id/invites— owner-onlyPOST /api/v1/boards/:board_id/invites{role?, note?, expires_in_days?}(owner-only)DELETE /api/v1/boards/:board_id/invites/:id— owner-onlyGET /api/v1/users/invitesPOST /api/v1/users/invites{note?, expires_in_days?}DELETE /api/v1/users/invites/:id— caller must own the invite
-
Each invite endpoint returns a precomputed
redemption_urlbuilt fromWaxxWeb.PublicUrl.derive(conn)— board invites point at/b/<token>, app invites at/users/register?invite=<token>. Self-hosted installs get the LAN URL baked in automatically. -
Fixed
Waxx.Accounts.create_invite/2: it was putting:created_by_id(atom) onto an attrs map of string keys, which Ecto rejects as mixed keys. Switched to the same normalize-to-string-keys patterncreate_system_invite/1already uses. The LiveView caller passes no attrs so this wasn’t a regression in prod. -
Membership deletion
guard_last_owner/1refuses to remove the only remaining owner — the LiveView relies on at-least-one-owner being an invariant elsewhere. -
12 new controller tests; backend now at 134 total.
-
Android: 10 new
WaxxApimethods + matching request/response models. -
BoardSettingsScreen— tabbed (Settings / Members / Invites), reachable from the new gear icon in theBoardScreentop bar:- Settings — name, description, archive-after-days, Delete board with confirm. Non-owners see disabled fields + an explanatory line.
- Members — list with role chips and a Remove button; owners can promote/demote between owner / editor / viewer.
- Invites — owner-only “Generate” panel (role / note / TTL), each existing invite renders with the redemption URL, Copy-to- clipboard, and Revoke buttons; consumed invites show who took them.
-
AppInvitesScreen— reachable from the new overflow menu onBoardsListScreen(“Registration invites”). Same generate / list / copy / revoke flow against/api/v1/users/invites. -
BoardsListScreentop-right icon changed to aMoreVertoverflow menu hosting “Registration invites” + “Sign out”.
Phase 5b — Template editor ✅ shipped
WaxxWeb.Api.V1.TemplateControllercovers template CRUD + nested stage/transition/label/field CRUD. All authenticated users can read/write (matches the LiveView surface at/workflow-templates).POST /api/v1/boards{template_id, name, ...}clones a template the same way the LiveView creator does.- Each mutation cascades to existing boards via the existing
Waxx.Workflows.*propagation logic. - Android:
TemplatesListScreen(reached from BoardsListScreen overflow) +TemplateEditorScreenwith three tabs (Workflow / Labels / Fields). BoardsListScreen gains a FAB +CreateBoardDialogfor spinning up boards from templates without going to the web. - 8 new controller tests; backend at 154 total.
Polish shipped alongside Phase 5b
- Force-disconnect on token revocation —
Accounts.delete_api_token/2broadcasts"disconnect"on the user’s socket topic; the revoked device’s reconnect 401s, other devices reconnect cleanly. - Subboard reordering —
Kanban.reorder_subboard/2,PATCH /api/v1/subboards/:id {position}; Android Rows tab has up/down arrows. - Within-column DnD reorder —
DragState.dropAtreturns aDrop(target, index?); same-cell drops compute insertion index from chips’ vertical centers. Server’s existingmove_card/4target_indexhandled it. - Auto-scroll during drag — Kanban
LaunchedEffectwatches the drag pointer;animateScrollByruns while the pointer sits within 80dp of either horizontal edge. - GitLab CI — new
android-debugjob runs:app:assembleDebugon changes under/android/**, usescimg/android:2024.07.1, caches the gradle directory, publishes the debug APK as an artifact.
Still deferred
- User preferences sync —
PATCH /api/v1/users/me/preferencesfor theme + per-board label-text toggle. Theme picker uses local storage today. - Default invite role / note suggestions — bare form today.
- Per-token socket id (so revoking one token only takes down that connection, not all the user’s sockets). Acceptable as-is.
Phase 6 — Subboards (2-D grid) ✅ shipped
-
POST /api/v1/cards/:id/movenow accepts an optionalsubboard_idin the body; the server distinguishes “absent” (leave the row alone) from “null” (clear to default row) viaMap.has_key?. After the stage move succeeds,Kanban.set_card_subboard/3runs in the same request — same serial pattern the LiveView uses. -
New
WaxxWeb.Api.V1.SubboardController:POST /api/v1/boards/:board_id/subboards{name}DELETE /api/v1/subboards/:idOwner-only. Cards previously assigned fall back to the default row via the FK’son_delete: :nilify_all.
-
BoardJSON.subboard_response/1returns the{subboard: …}envelope. -
Foreign-board subboard ids on a move are rejected as
422 invalid_subboard—Kanban.set_card_subboard/3already enforces this, the API just surfaces it. -
7 new controller tests; backend now at 141 total.
-
Android wire-format:
MoveCardBodystays for stage-only moves; a parallelmoveCardWithSubboardextension uses a sealedSubboardChange(Leave | Clear | Set(id)) plus a free-formJsonObjectbody to control which fields actually appear on the wire —kotlinx.serializationdefaults can’t distinguish “absent field” from “null field” otherwise. -
DragStatereworked to track cells by(stageId, subboardId?)instead of just stageId. Same shape covers 1-D (subboardId = null everywhere) and 2-D layouts. -
BoardScreenKanban dispatches:- 1-D (no subboards): existing horizontal Row of stage columns.
- 2-D (subboards present): vertical Column of subboard rows (Default first, then sorted subboards), each a horizontal Row of stage cells. A header strip across the top labels the columns.
-
DnD across the 2-D grid uses
moveCardWithSubboard. Dropping in(target_stage, target_subboard)moves stage and re-assigns row in one call; same-row drops omitsubboard_idto leave it alone. -
CardSheetgot a new “Row” section above Labels: a row ofFilterChips forDefault+ every subboard. Selecting one calls the same move endpoint (with the current stage_id) to re-assign. -
BoardSettingsScreengot a fourth tab — Rows — with a Compose form to add a row (name) and a list to delete existing ones. Owner-only.
Phase 6 remainder — deferred (optional polish)
- Within-column reorder via DnD (still deferred from Phase 4c).
- Auto-scroll during drag for wide boards.
- Reorder subboard rows — Kanban context has
positionbut no helper to swap; LiveView doesn’t expose it either. Wait for ask. - Empty-state polish / error retry UX / accessibility audit / in-app theme picker / push notifications. Out of scope for parity.
9. Open questions
- Domain & TLS for App Links. Need the exact domain Phoenix will be
served from in production. Affects
assetlinks.jsonand the intent filter manifest. Less urgent now that QR pairing covers the self-hosted “no SMTP, no public domain” case — App Links can wait until we ship the magic-link path publicly. - Play Store distribution. Internal track only? Closed beta? Public? Affects when we need the privacy policy URL and Data Safety form filled out.
- Lock version. Adding
lock_versiontocardsis a migration + touches every mutation. Worth doing for Android (and good hygiene for web), but should it be a separate prerequisite PR before Phase 4? - CORS. REST endpoints are same-origin from web LiveView’s POV (no
CORS needed for the web app, which uses LiveView, not the API). But
if we ever want JS to call
/api/v1we’ll need CorsPlug. Punt until needed. - Rate limiting. No rate limit on the existing auth endpoints. Add
hammer-backed limit at least to/api/v1/sessions/request_magic_linkbefore any public exposure of the API. mix phx.gen.inviteparity. Should app invites flow through the API too, or stay CLI/web only? Probably API-too (so admins can invite from their phone), but it’s not on the critical path — covered in Phase 5.API token labels.Resolved in Phase 1a:labelcolumn added, trimmed/truncated server-side. Per-token TTL still uses the global 90-day window — revisit if users ask for custom expiries.QR generator deps.Resolved::eqrcode ~> 0.2.0compiles cleanly on the current toolchain and is in use viaWaxxWeb.QR.- Scanner camera permission UX. ZXing’s embedded scanner activity handles the runtime permission prompt itself; we may want a custom pre-prompt explaining why we need the camera before the OS dialog fires. Decide in Phase 1b.
- Token list precision.
authenticated_atis bumped at most once per 24h (bymaybe_touch_api_token/1), so the “Last seen” timestamp in the device list has up to a day of lag. Fine for a coarse heads-up-display; if we want per-request precision, lift the refresh window or maintain a separatelast_seen_atcolumn.
10. Glossary
- Drift — a board entity (stage/label/field) whose name no longer matches the template it came from. Template edits skip drifted entities.
- Workflow snapshot — the static structure of a board (stages,
transitions, labels, fields, subboards) as returned by
GET /api/v1/boards/:id/workflow. Refreshed only onworkflow_changed. - Card snapshot — the dynamic content of a board (cards + their
fields/labels/assignees/positions), returned by
/cards. Refreshed oncards_changed.