neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Android client phase 3: live canvas over the channel
7f6cefe · Gabriel Morell · 2026-09-14 22:38
Message
{commit_body(@commit)}
Files changed
modified
android/README.md
+41
−18
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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
| 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
| 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