Android client phase 3: live canvas over the channel

7f6cefe · Gabriel Morell · 2026-09-14 22:38

33 files +5652 -100
Message
{commit_body(@commit)}

Files changed

modified android/README.md
+41 −18
@@ -6,8 +6,8 @@ Native Android client for [Diogramos](../). Talks to the JSON API at
6 6 typography, so the two feel like one product family.
7 7
8 8 The design doc and phase plan live in [docs/android.md](../docs/android.md).
9 −Shipped so far: **phase 2** — auth, the canvas list, and a read-only
10 −render of a canvas with pan and zoom. Realtime is phase 3; editing is
9 +Shipped so far: **phase 3** — auth, the canvas list, a read-only render
10 +with pan and zoom, and live updates over the Phoenix channel. Editing is
11 11 phase 4.
12 12
13 13 ## Stack
@@ -115,26 +115,43 @@ authored in `forest` render in `forest` colors while the app chrome stays
115 115 in whatever the user picked — and it is why these values are copied from
116 116 the CSS rather than eyeballed.
117 117
118 −## Connector geometry
118 +## The two ported modules
119 119
120 −`canvas/ConnectorGeometry.kt` is a port of
121 −`lib/diogramos/diagrams/connector_geometry.ex`. Routing has to run on the
122 −device — a connector re-routes on every frame of a drag — so the same
123 −maths necessarily exists twice, and two copies drift silently: nothing
124 −throws, the diagram is just subtly wrong on one side.
120 +`canvas/ConnectorGeometry.kt` and `canvas/DocumentOps.kt` are ports of
121 +`connector_geometry.ex` and `Document.apply_op/2`. Both have to run on
122 +the device: connectors re-route on every frame of a drag, and an edit
123 +applies optimistically before the server replies. So the same rules
124 +necessarily exist twice, and two copies drift silently — nothing throws,
125 +the phone just draws or stores something the server wouldn't.
125 126
126 −So the Elixir module is the authority and the port is pinned to it:
127 +The Elixir modules are the authority and the ports are pinned to them:
127 128
128 129 ```sh
129 130 mix diogramos.android_fixtures # from the repo root
130 131 ```
131 132
132 −dumps the server's output for 360 documents into
133 −`app/src/test/resources/connector_geometry.json`, and
134 −`ConnectorGeometryTest` asserts this port reproduces every one of them,
135 −SVG path string included. After a deliberate change to the Elixir side,
136 −regenerate and commit; a failure for any other reason means the port is
137 −wrong.
133 +writes `connector_geometry.json` (360 cases) and `document_ops.json` (32
134 +cases) into `app/src/test/resources/`, and the matching tests assert this
135 +code reproduces every one — SVG path strings and rejection reasons
136 +included. After a deliberate change to the Elixir side, regenerate and
137 +commit; a failure for any other reason means the port is wrong.
138 +
139 +## Realtime
140 +
141 +`CanvasSocket.subscribe(canvasId)` returns a `Flow<CanvasEvent>`.
142 +`CanvasSession` holds the document and the version, and decides what each
143 +incoming op means:
144 +
145 +- our own op echoing back (matched on `actor_ref`) — already applied, so
146 + only the version moves;
147 +- the next version in sequence — applied;
148 +- anything else — drift, and the screen refetches the canvas.
149 +
150 +There is no OT and no CRDT, because the server has none either:
151 +`Authority.apply_op/4` takes no client version and applies ops in arrival
152 +order, so concurrent edits to one field are last-writer-wins exactly as
153 +on the web. Drift after a reconnect is normal and heals silently — the
154 +rejoin delivers a fresh snapshot.
138 155
139 156 ## Tests
140 157
@@ -146,9 +163,15 @@ directly.
146 163
147 164 ## Not yet shipped
148 165
149 −The realtime channel (phase 3), editing (phase 4), export and the theme
150 −picker (phase 5), and the launcher icon and release/F-Droid pipeline
151 −(phase 6). See [docs/android.md](../docs/android.md).
166 +Editing (phase 4), export and the theme picker (phase 5), and the
167 +launcher icon and release/F-Droid pipeline (phase 6). See
168 +[docs/android.md](../docs/android.md).
169 +
170 +Nothing has yet been seen running on a screen: the unit tests cover the
171 +geometry, op application, the parser, the viewport maths and the
172 +reconciliation rules, but no emulator or device has been available, so
173 +neither a rendered diagram nor a live connection has been confirmed
174 +against a real server.
152 175
153 176 Nothing has yet been seen running on a screen: the unit tests cover the
154 177 geometry, the parser and the viewport maths, but no emulator or device
modified android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasDocument.kt
+0 −7
@@ -6,8 +6,6 @@ import kotlinx.serialization.json.JsonObject
6 6 import kotlinx.serialization.json.JsonPrimitive
7 7 import kotlinx.serialization.json.booleanOrNull
8 8 import kotlinx.serialization.json.doubleOrNull
9 −import kotlinx.serialization.json.jsonObject
10 −import kotlinx.serialization.json.jsonPrimitive
11 9
12 10 /**
13 11 * A read model over the canvas document.
@@ -251,8 +249,3 @@ private fun JsonElement?.asDoubleOrNull(): Double? =
251 249
252 250 private fun JsonElement?.asBooleanOrNull(): Boolean? =
253 251 (this as? JsonPrimitive)?.booleanOrNull
254 −
255 −internal fun JsonElement.objectOrNull(): JsonObject? = runCatching { jsonObject }.getOrNull()
256 −
257 −internal fun JsonElement.primitiveOrNull(): JsonPrimitive? =
258 − runCatching { jsonPrimitive }.getOrNull()
modified android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasScreen.kt
+197 −53
@@ -1,11 +1,15 @@
1 1 package org.neiam.diogramos.app.canvas
2 2
3 3 import androidx.compose.foundation.Canvas
4 +import androidx.compose.foundation.background
4 5 import androidx.compose.foundation.gestures.detectTapGestures
5 6 import androidx.compose.foundation.gestures.detectTransformGestures
7 +import androidx.compose.foundation.layout.Arrangement
6 8 import androidx.compose.foundation.layout.Box
7 9 import androidx.compose.foundation.layout.Column
10 +import androidx.compose.foundation.layout.Row
8 11 import androidx.compose.foundation.layout.fillMaxSize
12 +import androidx.compose.foundation.layout.fillMaxWidth
9 13 import androidx.compose.foundation.layout.padding
10 14 import androidx.compose.material.icons.Icons
11 15 import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -18,31 +22,48 @@ import androidx.compose.material3.IconButton
18 22 import androidx.compose.material3.MaterialTheme
19 23 import androidx.compose.material3.Scaffold
20 24 import androidx.compose.material3.Text
25 +import androidx.compose.material3.TopAppBar
21 26 import androidx.compose.runtime.Composable
27 +import androidx.compose.runtime.DisposableEffect
22 28 import androidx.compose.runtime.LaunchedEffect
23 29 import androidx.compose.runtime.getValue
30 +import androidx.compose.runtime.mutableIntStateOf
24 31 import androidx.compose.runtime.mutableStateOf
25 32 import androidx.compose.runtime.remember
33 +import androidx.compose.runtime.rememberCoroutineScope
26 34 import androidx.compose.runtime.saveable.rememberSaveable
27 35 import androidx.compose.runtime.setValue
28 36 import androidx.compose.ui.Alignment
29 37 import androidx.compose.ui.Modifier
30 38 import androidx.compose.ui.geometry.Size
39 +import androidx.compose.ui.graphics.Color
31 40 import androidx.compose.ui.input.pointer.pointerInput
32 41 import androidx.compose.ui.text.rememberTextMeasurer
33 42 import androidx.compose.ui.unit.dp
43 +import kotlinx.coroutines.Job
44 +import kotlinx.coroutines.flow.collectLatest
45 +import kotlinx.coroutines.launch
34 46 import org.neiam.diogramos.app.data.Canvas as CanvasMeta
47 +import org.neiam.diogramos.app.data.CanvasEvent
48 +import org.neiam.diogramos.app.data.CanvasSocket
35 49 import org.neiam.diogramos.app.data.DiogramosClient
50 +import org.neiam.diogramos.app.data.Peer
36 51 import org.neiam.diogramos.app.data.TokenStore
37 52 import org.neiam.diogramos.app.data.describeApiFailure
38 53 import org.neiam.diogramos.app.ui.AppBarTitle
39 54 import org.neiam.diogramos.app.ui.theme.appThemeByKey
40 55
56 +private enum class LiveState { Connecting, Live, Offline }
57 +
41 58 /**
42 − * The canvas itself: pan, zoom, and a faithful render of the document.
59 + * The canvas: pan, zoom, and a live view of the document.
60 + *
61 + * The channel pushes ops rather than "something changed" notifications,
62 + * so peers' edits land here as deltas. [CanvasSession] owns the
63 + * reconciliation; this screen only decides what to do with the outcome,
64 + * which for drift means refetching the canvas.
43 65 *
44 − * Read-only for now — phase 4 adds selection and editing. Phase 3 adds
45 − * the channel, at which point the document stops being a one-shot fetch.
66 + * Still read-only — phase 4 adds selection and editing on top.
46 67 */
47 68 @OptIn(ExperimentalMaterial3Api::class)
48 69 @Composable
@@ -52,37 +73,108 @@ fun CanvasScreen(
52 73 onBack: () -> Unit,
53 74 ) {
54 75 var meta by remember { mutableStateOf<CanvasMeta?>(null) }
55 − var document by remember { mutableStateOf(CanvasDocument.EMPTY) }
56 76 var error by remember { mutableStateOf<String?>(null) }
57 77 var loading by remember { mutableStateOf(true) }
58 78 var viewSize by remember { mutableStateOf(Size.Zero) }
79 + var live by remember { mutableStateOf(LiveState.Connecting) }
80 + var peers by remember { mutableStateOf<List<Peer>>(emptyList()) }
81 +
82 + // The session holds the document; this counter is what tells Compose
83 + // it changed, since JsonObject is not a Compose-observable type.
84 + val session = remember { CanvasSession() }
85 + var revision by remember { mutableIntStateOf(0) }
86 + val document = remember(revision) { CanvasDocument.parse(session.document) }
59 87
60 88 val viewport = rememberSaveable(saver = Viewport.Saver) { Viewport() }
61 89 val credentials = remember { tokens.load() }
90 + val scope = rememberCoroutineScope()
62 91
63 − LaunchedEffect(slug, credentials) {
64 − val creds = credentials ?: return@LaunchedEffect
92 + suspend fun load(): Boolean {
93 + val creds = credentials ?: return false
65 94
66 − runCatching { DiogramosClient.authenticated(creds).canvas(slug) }
67 − .onSuccess {
68 − meta = it.data
69 − document = CanvasDocument.parse(it.data.document)
95 + return runCatching { DiogramosClient.authenticated(creds).canvas(slug) }
96 + .map { response ->
97 + meta = response.data
98 + session.onSnapshot(
99 + response.data.document ?: kotlinx.serialization.json.JsonObject(emptyMap()),
100 + response.data.version.toLong(),
101 + )
102 + revision++
70 103 error = null
104 + true
105 + }
106 + .getOrElse {
107 + error = describeApiFailure(it)
108 + false
71 109 }
72 − .onFailure { error = describeApiFailure(it) }
110 + }
73 111
112 + LaunchedEffect(slug, credentials) {
113 + load()
74 114 loading = false
75 115 }
76 116
77 − // The canvas's own theme, not the app's: a diagram authored in
78 − // `forest` renders in forest colors wherever it is opened.
117 + // Subscribe once the canvas id is known — the channel topic is keyed
118 + // by id, while the route carries the slug.
119 + val canvasId = meta?.id
120 + DisposableEffect(canvasId, credentials) {
121 + val creds = credentials
122 + if (canvasId == null || creds == null) return@DisposableEffect onDispose { }
123 +
124 + val job: Job = scope.launch {
125 + CanvasSocket(creds).subscribe(canvasId).collectLatest { event ->
126 + when (event) {
127 + is CanvasEvent.Connected -> Unit // Wait for the join.
128 +
129 + is CanvasEvent.Disconnected -> {
130 + live = LiveState.Offline
131 + peers = emptyList()
132 + }
133 +
134 + is CanvasEvent.Joined -> {
135 + // A join after a reconnect is also how a drifted
136 + // document heals: the snapshot is authoritative.
137 + session.onSnapshot(event.document, event.version)
138 + revision++
139 + live = LiveState.Live
140 + }
141 +
142 + is CanvasEvent.Op -> {
143 + when (session.onRemoteOp(event.op, event.version, event.actorRef)) {
144 + CanvasSession.Outcome.Applied -> revision++
145 +
146 + // Already on screen; only the version moved.
147 + CanvasSession.Outcome.Echo -> Unit
148 +
149 + // We missed something. Refetching is cheap and
150 + // silent — this is expected after a reconnect.
151 + CanvasSession.Outcome.Drift,
152 + is CanvasSession.Outcome.Unapplicable,
153 + -> load()
154 + }
155 + }
156 +
157 + is CanvasEvent.Peers -> peers = event.peers
158 +
159 + is CanvasEvent.Failed -> {
160 + live = LiveState.Offline
161 + // A failure here costs liveness, not the diagram,
162 + // so the fetched document stays on screen.
163 + }
164 + }
165 + }
166 + }
167 +
168 + onDispose { job.cancel() }
169 + }
170 +
79 171 val canvasTheme = appThemeByKey(meta?.theme)
80 172 val textMeasurer = rememberTextMeasurer()
81 173 val renderer = remember(canvasTheme) { Renderer(canvasTheme, textMeasurer) }
82 174
83 − // Frame the content once it is known, and only once, so a later
84 − // recomposition doesn't yank the view back from where it was panned.
85 − LaunchedEffect(document, viewSize) {
175 + // Frame the content once, so a later op doesn't yank the view back
176 + // from wherever it has been panned to.
177 + LaunchedEffect(revision, viewSize) {
86 178 if (!viewport.hasFitted && viewSize != Size.Zero && document.elements.isNotEmpty()) {
87 179 viewport.fit(document.bounds, viewSize)
88 180 }
@@ -90,7 +182,7 @@ fun CanvasScreen(
90 182
91 183 Scaffold(
92 184 topBar = {
93 − androidx.compose.material3.TopAppBar(
185 + TopAppBar(
94 186 title = { AppBarTitle(meta?.title ?: slug) },
95 187 navigationIcon = {
96 188 IconButton(onClick = onBack) {
@@ -107,51 +199,103 @@ fun CanvasScreen(
107 199 }
108 200 },
109 201 ) { padding ->
110 − Box(
111 − modifier = Modifier
112 − .fillMaxSize()
113 − .padding(padding),
114 − ) {
115 − when {
116 − loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
117 −
118 − error != null -> Column(Modifier.align(Alignment.Center).padding(24.dp)) {
119 − Text(
202 + Column(modifier = Modifier.fillMaxSize().padding(padding)) {
203 + LiveBanner(live, peers, meta?.canWrite ?: true)
204 +
205 + Box(modifier = Modifier.fillMaxSize()) {
206 + when {
207 + loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
208 +
209 + error != null -> Text(
120 210 error!!,
211 + modifier = Modifier.align(Alignment.Center).padding(24.dp),
121 212 color = MaterialTheme.colorScheme.error,
122 213 style = MaterialTheme.typography.bodyMedium,
123 214 )
124 − }
125 215
126 − else -> Canvas(
127 − modifier = Modifier
128 − .fillMaxSize()
129 − .pointerInput(Unit) {
130 − detectTransformGestures { centroid, panChange, zoomChange, _ ->
131 − viewport.pan(panChange.x, panChange.y)
132 − if (zoomChange != 1f) viewport.zoomAround(centroid, zoomChange)
216 + else -> Canvas(
217 + modifier = Modifier
218 + .fillMaxSize()
219 + .pointerInput(Unit) {
220 + detectTransformGestures { centroid, pan, zoom, _ ->
221 + viewport.pan(pan.x, pan.y)
222 + if (zoom != 1f) viewport.zoomAround(centroid, zoom)
223 + }
133 224 }
134 − }
135 − .pointerInput(document) {
136 − detectTapGestures(
137 − onDoubleTap = { viewport.fit(document.bounds, viewSize) },
138 − )
139 − },
140 − ) {
141 − viewSize = size
142 − drawRect(canvasTheme.base100, size = size)
143 − renderer.draw(this, document, viewport)
225 + .pointerInput(revision) {
226 + detectTapGestures(
227 + onDoubleTap = { viewport.fit(document.bounds, viewSize) },
228 + )
229 + },
230 + ) {
231 + viewSize = size
232 + drawRect(canvasTheme.base100, size = size)
233 + renderer.draw(this, document, viewport)
234 + drawPeerCursors(peers, viewport)
235 + }
144 236 }
145 − }
146 237
147 − if (!loading && error == null && document.elements.isEmpty()) {
148 − Text(
149 − "This canvas is empty.",
150 − modifier = Modifier.align(Alignment.Center),
151 − style = MaterialTheme.typography.bodyMedium,
152 − color = MaterialTheme.colorScheme.onSurfaceVariant,
153 − )
238 + if (!loading && error == null && document.elements.isEmpty()) {
239 + Text(
240 + "This canvas is empty.",
241 + modifier = Modifier.align(Alignment.Center),
242 + style = MaterialTheme.typography.bodyMedium,
243 + color = MaterialTheme.colorScheme.onSurfaceVariant,
244 + )
245 + }
154 246 }
155 247 }
156 248 }
157 249 }
250 +
251 +@Composable
252 +private fun LiveBanner(live: LiveState, peers: List<Peer>, canWrite: Boolean) {
253 + val message = when {
254 + live == LiveState.Connecting -> "Connecting…"
255 + live == LiveState.Offline -> "Offline — this view won't update until it reconnects."
256 + !canWrite -> "Read-only" + peersSuffix(peers)
257 + peers.isNotEmpty() -> peers.joinToString(", ") { it.name } + " also here"
258 + else -> null
259 + } ?: return
260 +
261 + val background = when (live) {
262 + LiveState.Live -> MaterialTheme.colorScheme.surfaceVariant
263 + else -> MaterialTheme.colorScheme.errorContainer
264 + }
265 +
266 + Row(
267 + modifier = Modifier.fillMaxWidth().background(background).padding(8.dp),
268 + horizontalArrangement = Arrangement.Center,
269 + ) {
270 + Text(message, style = MaterialTheme.typography.bodySmall)
271 + }
272 +}
273 +
274 +private fun peersSuffix(peers: List<Peer>): String =
275 + if (peers.isEmpty()) "" else " · " + peers.joinToString(", ") { it.name } + " also here"
276 +
277 +/**
278 + * Ghost cursors for everyone else on the canvas. Drawn last so a peer's
279 + * pointer is never hidden behind a shape.
280 + */
281 +private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawPeerCursors(
282 + peers: List<Peer>,
283 + viewport: Viewport,
284 +) {
285 + peers.forEach { peer ->
286 + val x = peer.cursorX ?: return@forEach
287 + val y = peer.cursorY ?: return@forEach
288 + val position = viewport.toScreen(x, y)
289 + val color = parseColor(peer.color) ?: return@forEach
290 +
291 + drawCircle(color, radius = 7f, center = position)
292 + drawCircle(Color.White, radius = 7f, center = position, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2f))
293 + }
294 +}
295 +
296 +private fun parseColor(hex: String): Color? {
297 + val text = hex.removePrefix("#")
298 + if (text.length != 6) return null
299 + val value = text.toLongOrNull(16) ?: return null
300 + return Color(0xFF000000 or value)
301 +}
added android/app/src/main/java/org/neiam/diogramos/app/canvas/CanvasSession.kt
+133 −0
@@ -0,0 +1,133 @@
1 +package org.neiam.diogramos.app.canvas
2 +
3 +import kotlinx.serialization.json.JsonObject
4 +
5 +/**
6 + * Keeps the local document in step with the authority.
7 + *
8 + * Deliberately free of Compose and Android types: reconciliation is the
9 + * part that is easy to get subtly wrong and impossible to eyeball, so it
10 + * is a plain class with unit tests around it.
11 + *
12 + * ## The rules
13 + *
14 + * `Authority.apply_op/4` takes no client version — ops apply in arrival
15 + * order and the server owns the counter. There is no OT and no CRDT;
16 + * concurrent edits to one field are last-writer-wins, exactly as the web
17 + * editor behaves. So:
18 + *
19 + * * An op we sent comes back as an echo, recognised by `actorRef`. It
20 + * was already applied optimistically, so it is dropped — but its
21 + * version is adopted.
22 + * * Any other op applies when its version is exactly one past ours.
23 + * * Anything else means we have missed an op — [Outcome.Drift] — and the
24 + * caller refetches the canvas. Expected after a reconnect, and silent.
25 + *
26 + * Local edits apply immediately and are rolled back if the server
27 + * refuses them, because a phone edit that waits for a round trip feels
28 + * broken under a finger.
29 + */
30 +class CanvasSession {
31 +
32 + var document: JsonObject = JsonObject(emptyMap())
33 + private set
34 +
35 + var version: Long = 0
36 + private set
37 +
38 + /** Ops sent and not yet acknowledged, oldest first. */
39 + private val pending = LinkedHashMap<String, JsonObject>()
40 +
41 + private var actorCounter = 0L
42 +
43 + val pendingCount: Int get() = pending.size
44 +
45 + sealed interface Outcome {
46 + /** Applied; the document changed. */
47 + data object Applied : Outcome
48 +
49 + /** Our own op coming back. Already applied; version adopted. */
50 + data object Echo : Outcome
51 +
52 + /** We have missed something — refetch the canvas. */
53 + data object Drift : Outcome
54 +
55 + /**
56 + * The op is well-formed but doesn't apply to the document we
57 + * hold. Treated as drift: the server accepted it, so our copy is
58 + * what's wrong.
59 + */
60 + data class Unapplicable(val reason: String) : Outcome
61 + }
62 +
63 + data class LocalEdit(val actorRef: String, val document: JsonObject)
64 +
65 + fun onSnapshot(document: JsonObject, version: Long) {
66 + this.document = document
67 + this.version = version
68 + // A snapshot is the authority's current state, so anything we
69 + // were still waiting on is either in it or was rejected. Keeping
70 + // the pending ops would roll back edits that already landed.
71 + pending.clear()
72 + }
73 +
74 + fun onRemoteOp(op: JsonObject, version: Long, actorRef: String?): Outcome {
75 + if (actorRef != null && pending.remove(actorRef) != null) {
76 + // Ours. Already on screen; just move the counter forward.
77 + this.version = version
78 + return Outcome.Echo
79 + }
80 +
81 + if (version != this.version + 1) return Outcome.Drift
82 +
83 + return when (val result = DocumentOps.apply(document, op)) {
84 + is DocumentOps.Result.Ok -> {
85 + document = result.document
86 + this.version = version
87 + Outcome.Applied
88 + }
89 +
90 + is DocumentOps.Result.Err -> Outcome.Unapplicable(result.reason)
91 + }
92 + }
93 +
94 + /**
95 + * Applies an edit locally and returns the ref to send with it, or
96 + * null if the op doesn't apply — in which case nothing changed and
97 + * the caller must not send it either.
98 + */
99 + fun localOp(op: JsonObject): LocalEdit? {
100 + val snapshot = document
101 +
102 + return when (val result = DocumentOps.apply(document, op)) {
103 + is DocumentOps.Result.Ok -> {
104 + val actorRef = "a${actorCounter++}"
105 + document = result.document
106 + pending[actorRef] = snapshot
107 + LocalEdit(actorRef, result.document)
108 + }
109 +
110 + is DocumentOps.Result.Err -> null
111 + }
112 + }
113 +
114 + /** The server refused one of our ops. */
115 + fun rollback(actorRef: String): Boolean {
116 + val snapshot = pending.remove(actorRef) ?: return false
117 +
118 + // Only the newest op can be rolled back to its own snapshot —
119 + // undoing an older one would also undo everything sent after it.
120 + // With anything else outstanding, a resync is the honest answer.
121 + if (pending.isNotEmpty()) return false
122 +
123 + document = snapshot
124 + return true
125 + }
126 +
127 + /** The server accepted one of our ops. */
128 + fun acknowledge(actorRef: String, version: Long) {
129 + if (pending.remove(actorRef) != null) {
130 + this.version = maxOf(this.version, version)
131 + }
132 + }
133 +}
added android/app/src/main/java/org/neiam/diogramos/app/canvas/DocumentOps.kt
+358 −0
@@ -0,0 +1,358 @@
1 +package org.neiam.diogramos.app.canvas
2 +
3 +import kotlinx.serialization.json.JsonArray
4 +import kotlinx.serialization.json.JsonElement
5 +import kotlinx.serialization.json.JsonObject
6 +import kotlinx.serialization.json.JsonPrimitive
7 +import kotlinx.serialization.json.buildJsonObject
8 +
9 +/**
10 + * Port of `Document.apply_op/2`.
11 + *
12 + * Ops are applied locally for two reasons: an op arriving over the
13 + * channel is a delta, not a new document, and a local edit applies
14 + * optimistically before the server has replied. Both need the same rules
15 + * the server enforces — including the rejections, since an op the server
16 + * would refuse must not appear to work on the phone.
17 + *
18 + * `mix diogramos.android_fixtures` dumps the Elixir module's result for
19 + * every op shape, and `DocumentOpsTest` asserts this file reproduces
20 + * them. The Elixir module is the authority.
21 + *
22 + * Operates on the raw [JsonObject] rather than the parsed [CanvasDocument]
23 + * so fields this client doesn't understand survive: a patch is merged
24 + * into the stored element, not into a lossy view of it.
25 + */
26 +object DocumentOps {
27 +
28 + sealed interface Result {
29 + data class Ok(val document: JsonObject) : Result
30 + data class Err(val reason: String) : Result
31 + }
32 +
33 + private val ELEMENT_TYPES = setOf("rect", "rounded", "circle", "text")
34 + private val CONNECTOR_ROUTINGS = setOf("straight", "orthogonal", "curve")
35 + private val ANCHORS = setOf("auto", "n", "e", "s", "w")
36 + private val DASH_PRESETS = setOf("solid", "dotted", "dashed", "dash-dot")
37 + private val END_MARKERS = setOf("none", "arrow", "filled-arrow", "diamond", "circle")
38 + private val FONT_FAMILIES = setOf("sans", "serif", "mono", "b612", "b612-mono")
39 + private val LABEL_POSITIONS = setOf("tl", "tc", "tr", "ml", "center", "mr", "bl", "bc", "br")
40 + private val LABEL_SIZES = setOf("sm", "md", "lg", "xl")
41 + private val COLOR_TOKENS = setOf(
42 + "transparent",
43 + "base-100", "base-200", "base-300", "base-content",
44 + "primary", "secondary", "accent",
45 + "info", "success", "warning", "error",
46 + )
47 +
48 + fun apply(document: JsonObject, op: JsonObject): Result {
49 + val elements = document["elements"] as? JsonObject ?: JsonObject(emptyMap())
50 + val connectors = document["connectors"] as? JsonObject ?: JsonObject(emptyMap())
51 + val order = (document["order"] as? JsonArray).orEmpty()
52 +
53 + return when (op["type"]?.stringOrNull()) {
54 + "insert_element" -> {
55 + val element = op["element"] as? JsonObject ?: return Result.Err("invalid_element")
56 + val normalized = validateElement(element) ?: return Result.Err(elementError(element))
57 + val id = normalized["id"]!!.stringOrNull()!!
58 + if (elements.containsKey(id)) return Result.Err("duplicate_id")
59 +
60 + Result.Ok(
61 + document.with(
62 + "elements" to elements.put(id, normalized),
63 + "order" to JsonArray(order + JsonPrimitive(id)),
64 + ),
65 + )
66 + }
67 +
68 + "update_element" -> {
69 + val id = op["id"]?.stringOrNull() ?: return Result.Err("unknown_op")
70 + val patch = op["patch"] as? JsonObject ?: return Result.Err("unknown_op")
71 + val element = elements[id] as? JsonObject ?: return Result.Err("not_found")
72 +
73 + if (patch.containsKey("id") && patch["id"] != element["id"]) {
74 + return Result.Err("id_immutable")
75 + }
76 + if (patch.containsKey("type") && patch["type"] != element["type"]) {
77 + return Result.Err("type_immutable")
78 + }
79 +
80 + val merged = deepMerge(element, patch)
81 + if (!validGeometry(merged)) return Result.Err("invalid_element_geometry")
82 + styleError(merged["style"] as? JsonObject)?.let { return Result.Err(it) }
83 +
84 + Result.Ok(document.with("elements" to elements.put(id, merged)))
85 + }
86 +
87 + "delete_element" -> {
88 + val id = op["id"]?.stringOrNull() ?: return Result.Err("unknown_op")
89 + if (!elements.containsKey(id)) return Result.Err("not_found")
90 +
91 + // A connector cannot outlive either endpoint, so the
92 + // server drops them in the same op — without this, the
93 + // phone keeps drawing lines to a shape that is gone.
94 + val remaining = connectors.filterNot { (_, value) ->
95 + val connector = value as? JsonObject ?: return@filterNot false
96 + val from = (connector["from"] as? JsonObject)?.get("element")?.stringOrNull()
97 + val to = (connector["to"] as? JsonObject)?.get("element")?.stringOrNull()
98 + from == id || to == id
99 + }
100 +
101 + Result.Ok(
102 + document.with(
103 + "elements" to JsonObject(elements - id),
104 + "order" to JsonArray(order.filterNot { it.stringOrNull() == id }),
105 + "connectors" to JsonObject(remaining),
106 + ),
107 + )
108 + }
109 +
110 + "insert_connector" -> {
111 + val connector = op["connector"] as? JsonObject
112 + ?: return Result.Err("invalid_connector")
113 + val normalized = validateConnector(connector, elements)
114 + ?: return Result.Err(connectorError(connector, elements))
115 + val id = normalized["id"]!!.stringOrNull()!!
116 + if (connectors.containsKey(id)) return Result.Err("duplicate_id")
117 +
118 + Result.Ok(document.with("connectors" to connectors.put(id, normalized)))
119 + }
120 +
121 + "update_connector" -> {
122 + val id = op["id"]?.stringOrNull() ?: return Result.Err("unknown_op")
123 + val patch = op["patch"] as? JsonObject ?: return Result.Err("unknown_op")
124 + val connector = connectors[id] as? JsonObject ?: return Result.Err("not_found")
125 +
126 + if (patch.containsKey("id") && patch["id"] != connector["id"]) {
127 + return Result.Err("id_immutable")
128 + }
129 +
130 + val merged = deepMerge(connector, patch)
131 + val normalized = validateConnector(merged, elements)
132 + ?: return Result.Err(connectorError(merged, elements))
133 +
134 + Result.Ok(document.with("connectors" to connectors.put(id, normalized)))
135 + }
136 +
137 + "delete_connector" -> {
138 + val id = op["id"]?.stringOrNull() ?: return Result.Err("unknown_op")
139 + if (!connectors.containsKey(id)) return Result.Err("not_found")
140 +
141 + Result.Ok(document.with("connectors" to JsonObject(connectors - id)))
142 + }
143 +
144 + "set_z_order" -> {
145 + val proposed = (op["order"] as? JsonArray) ?: return Result.Err("unknown_op")
146 + val ids = proposed.mapNotNull { it.stringOrNull() }
147 +
148 + // Must be exactly the existing set: anything else would
149 + // silently drop or invent a shape.
150 + if (ids.toSet() != elements.keys || ids.size != elements.size) {
151 + return Result.Err("order_mismatch")
152 + }
153 +
154 + Result.Ok(document.with("order" to JsonArray(ids.map { JsonPrimitive(it) })))
155 + }
156 +
157 + else -> Result.Err("unknown_op")
158 + }
159 + }
160 +
161 + // --- Validation --------------------------------------------------------
162 +
163 + private fun validateElement(element: JsonObject): JsonObject? {
164 + val id = element["id"]?.stringOrNull() ?: return null
165 + if (id.isEmpty()) return null
166 + val type = element["type"]?.stringOrNull() ?: return null
167 + if (type !in ELEMENT_TYPES) return null
168 + if (!validGeometry(element)) return null
169 + if (styleError(element["style"] as? JsonObject) != null) return null
170 +
171 + // The server fills these in on insert, so a client that doesn't
172 + // ends up with a document that differs from the server's by two
173 + // keys — and every later comparison is then a false mismatch.
174 + val withDefaults = buildJsonObject {
175 + put("label", element["label"] ?: JsonPrimitive(""))
176 + put("style", element["style"] ?: JsonObject(emptyMap()))
177 + element.forEach { (key, value) -> put(key, value) }
178 + }
179 +
180 + return withDefaults
181 + }
182 +
183 + private fun elementError(element: JsonObject): String {
184 + val type = element["type"]?.stringOrNull()
185 + if (element["id"]?.stringOrNull().isNullOrEmpty()) return "invalid_element"
186 + if (type == null || type !in ELEMENT_TYPES) return "invalid_element"
187 + if (!validGeometry(element)) return "invalid_element_geometry"
188 + return styleError(element["style"] as? JsonObject) ?: "invalid_element"
189 + }
190 +
191 + private fun validGeometry(element: JsonObject): Boolean {
192 + return when (element["type"]?.stringOrNull()) {
193 + "circle" -> {
194 + val cx = element["cx"]?.doubleOrNull()
195 + val cy = element["cy"]?.doubleOrNull()
196 + val r = element["r"]?.doubleOrNull()
197 + cx != null && cy != null && r != null && r > 0
198 + }
199 +
200 + "rect", "rounded", "text" -> {
201 + val x = element["x"]?.doubleOrNull()
202 + val y = element["y"]?.doubleOrNull()
203 + val w = element["w"]?.doubleOrNull()
204 + val h = element["h"]?.doubleOrNull()
205 + x != null && y != null && w != null && h != null && w > 0 && h > 0
206 + }
207 +
208 + else -> false
209 + }
210 + }
211 +
212 + private fun styleError(style: JsonObject?): String? {
213 + style ?: return null
214 +
215 + style["fill"]?.stringOrNull()?.let { if (it !in COLOR_TOKENS) return "invalid_color" }
216 + style["stroke"]?.stringOrNull()?.let { if (it !in COLOR_TOKENS) return "invalid_color" }
217 + style["label_color"]?.stringOrNull()
218 + ?.let { if (it !in COLOR_TOKENS) return "invalid_label_color" }
219 +
220 + style["dash"]?.stringOrNull()?.let { if (it !in DASH_PRESETS) return "invalid_dash" }
221 + style["stroke_width"]?.let {
222 + val width = it.doubleOrNull()
223 + if (width == null || width <= 0) return "invalid_stroke_width"
224 + }
225 +
226 + style["label_position"]?.stringOrNull()
227 + ?.let { if (it !in LABEL_POSITIONS) return "invalid_label_position" }
228 + style["label_size"]?.stringOrNull()
229 + ?.let { if (it !in LABEL_SIZES) return "invalid_label_size" }
230 + style["label_font_family"]?.stringOrNull()
231 + ?.let { if (it !in FONT_FAMILIES) return "invalid_label_font_family" }
232 + style["font_family"]?.stringOrNull()
233 + ?.let { if (it !in FONT_FAMILIES) return "invalid_font_family" }
234 +
235 + style["font_size"]?.let {
236 + val size = it.doubleOrNull()
237 + if (size == null || size < 4 || size > 256) return "invalid_font_size"
238 + }
239 +
240 + return null
241 + }
242 +
243 + private fun validateConnector(connector: JsonObject, elements: JsonObject): JsonObject? {
244 + val id = connector["id"]?.stringOrNull()?.takeIf { it.isNotEmpty() } ?: return null
245 + val from = connector["from"] as? JsonObject ?: return null
246 + val to = connector["to"] as? JsonObject ?: return null
247 + val fromId = from["element"]?.stringOrNull() ?: return null
248 + val toId = to["element"]?.stringOrNull() ?: return null
249 +
250 + if (!elements.containsKey(fromId) || !elements.containsKey(toId)) return null
251 + if (!validAnchor(from) || !validAnchor(to)) return null
252 +
253 + val routing = connector["routing"]?.stringOrNull() ?: "orthogonal"
254 + val dash = connector["dash"]?.stringOrNull() ?: "solid"
255 + val markerStart = connector["marker_start"]?.stringOrNull() ?: "none"
256 + val markerEnd = connector["marker_end"]?.stringOrNull() ?: "arrow"
257 + val startGap = connector["start_gap"]?.doubleOrNull() ?: 6.0
258 + val endGap = connector["end_gap"]?.doubleOrNull() ?: 6.0
259 + val strokeWidth = connector["stroke_width"]?.doubleOrNull() ?: 2.0
260 + val color = connector["color"]?.stringOrNull() ?: "base-content"
261 +
262 + if (routing !in CONNECTOR_ROUTINGS) return null
263 + if (dash !in DASH_PRESETS) return null
264 + if (color !in COLOR_TOKENS) return null
265 + if (markerStart !in END_MARKERS || markerEnd !in END_MARKERS) return null
266 + if (startGap < 0 || endGap < 0 || strokeWidth <= 0) return null
267 +
268 + return buildJsonObject {
269 + connector.forEach { (key, value) -> put(key, value) }
270 + put("routing", JsonPrimitive(routing))
271 + put("dash", JsonPrimitive(dash))
272 + put("marker_start", JsonPrimitive(markerStart))
273 + put("marker_end", JsonPrimitive(markerEnd))
274 + put("start_gap", numberOf(connector["start_gap"], startGap))
275 + put("end_gap", numberOf(connector["end_gap"], endGap))
276 + put("stroke_width", numberOf(connector["stroke_width"], strokeWidth))
277 + put("color", JsonPrimitive(color))
278 + if (!connector.containsKey("label")) put("label", JsonPrimitive(""))
279 + }
280 + }
281 +
282 + private fun connectorError(connector: JsonObject, elements: JsonObject): String {
283 + val from = connector["from"] as? JsonObject
284 + val to = connector["to"] as? JsonObject
285 + val fromId = from?.get("element")?.stringOrNull()
286 + val toId = to?.get("element")?.stringOrNull()
287 +
288 + if (connector["id"]?.stringOrNull().isNullOrEmpty() || fromId == null || toId == null) {
289 + return "invalid_connector"
290 + }
291 + if (!elements.containsKey(fromId) || !elements.containsKey(toId)) return "missing_endpoint"
292 + if (!validAnchor(from) || !validAnchor(to)) return "invalid_anchor"
293 +
294 + connector["routing"]?.stringOrNull()
295 + ?.let { if (it !in CONNECTOR_ROUTINGS) return "invalid_routing" }
296 + connector["dash"]?.stringOrNull()?.let { if (it !in DASH_PRESETS) return "invalid_dash" }
297 + connector["color"]?.stringOrNull()?.let { if (it !in COLOR_TOKENS) return "invalid_color" }
298 + connector["marker_start"]?.stringOrNull()
299 + ?.let { if (it !in END_MARKERS) return "invalid_marker" }
300 + connector["marker_end"]?.stringOrNull()
301 + ?.let { if (it !in END_MARKERS) return "invalid_marker" }
302 +
303 + val startGap = connector["start_gap"]?.doubleOrNull()
304 + val endGap = connector["end_gap"]?.doubleOrNull()
305 + if ((startGap != null && startGap < 0) || (endGap != null && endGap < 0)) {
306 + return "invalid_gap"
307 + }
308 +
309 + val strokeWidth = connector["stroke_width"]?.doubleOrNull()
310 + if (strokeWidth != null && strokeWidth <= 0) return "invalid_stroke_width"
311 +
312 + return "invalid_connector"
313 + }
314 +
315 + private fun validAnchor(anchor: JsonObject): Boolean {
316 + val side = anchor["anchor"]?.stringOrNull() ?: return true
317 + if (side in ANCHORS) return true
318 + // A "point" anchor is only valid with coordinates attached.
319 + return side == "point" &&
320 + anchor["x"]?.doubleOrNull() != null &&
321 + anchor["y"]?.doubleOrNull() != null
322 + }
323 +
324 + // --- JSON helpers ------------------------------------------------------
325 +
326 + /** Matches `Document.deep_merge/2`: maps merge, everything else replaces. */
327 + internal fun deepMerge(left: JsonObject, right: JsonObject): JsonObject = buildJsonObject {
328 + left.forEach { (key, value) -> put(key, value) }
329 + right.forEach { (key, value) ->
330 + val existing = left[key]
331 + if (existing is JsonObject && value is JsonObject) {
332 + put(key, deepMerge(existing, value))
333 + } else {
334 + put(key, value)
335 + }
336 + }
337 + }
338 +
339 + // Preserves an integer that arrived as an integer: re-encoding 6 as
340 + // 6.0 makes a document that is equal in meaning compare unequal.
341 + private fun numberOf(original: JsonElement?, fallback: Double): JsonPrimitive =
342 + (original as? JsonPrimitive)?.takeIf { !it.isString }
343 + ?: JsonPrimitive(if (fallback == Math.floor(fallback)) fallback.toLong() else fallback)
344 +
345 + private fun JsonObject.put(key: String, value: JsonElement): JsonObject =
346 + JsonObject(this + (key to value))
347 +
348 + private fun JsonObject.with(vararg pairs: Pair<String, JsonElement>): JsonObject =
349 + JsonObject(this + pairs.toMap())
350 +
351 + private fun JsonArray?.orEmpty(): List<JsonElement> = this ?: emptyList()
352 +
353 + private fun JsonElement.stringOrNull(): String? =
354 + (this as? JsonPrimitive)?.takeIf { it.isString }?.content
355 +
356 + private fun JsonElement.doubleOrNull(): Double? =
357 + (this as? JsonPrimitive)?.takeIf { !it.isString }?.content?.toDoubleOrNull()
358 +}
added android/app/src/main/java/org/neiam/diogramos/app/data/CanvasSocket.kt
+178 −0

Click to load diff…

added android/app/src/test/java/org/neiam/diogramos/app/CanvasSessionTest.kt
+194 −0

Click to load diff…

added android/app/src/test/java/org/neiam/diogramos/app/DocumentOpsTest.kt
+144 −0

Click to load diff…

added android/app/src/test/resources/document_ops.json
+4140 −0

Click to load diff…

modified docs/android.md
+20 −3

Click to load diff…

modified lib/mix/tasks/diogramos.android_fixtures.ex
+247 −19

Click to load diff…

Parents: 99f6896