From c689d94dea0acaee536d4c69409cba7ab8cae335 Mon Sep 17 00:00:00 2001 From: kgetman Date: Fri, 16 Oct 2026 10:21:08 +0200 Subject: [PATCH 01/11] docs: location stamping and upload rules from the legacy app Record the legacy last-known-location fallback for mobile measurements, that the app uploads mobile measurements only (AB3 SD sync excepted), and the drain-before-finish rule that removes the finished_at open decision. CLAUDE.md: take undocumented business logic from the legacy app. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 11 +++++++ docs/API_V3.md | 17 ++++++----- docs/DOMAIN_KNOWLEDGE.md | 30 +++++++++++++++++++ .../upload/MobileMeasurementPayload.kt | 0 .../upload/MobileMeasurementPayloadTest.kt | 0 5 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayload.kt create mode 100644 shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayloadTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index c3a0b0a..8e4bd1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,17 @@ one. The source of truth for both is the OpenAPI spec at — the Swagger UI is JS-rendered, so fetch `.../api-docs/swagger.yaml` instead. +## Business logic: docs first, then the legacy app + +This app is a KMP rewrite of the legacy Android app — GitHub +`HabitatMap/AircastingAndroid`, usually checked out next to this repo at +`../AircastingAndroid`. When a business rule (how a measurement gets its location, what +gets uploaded, retry/sync behavior, edge cases) is **not covered by `/docs/`**, take it +from the legacy app's code — don't invent one. Then record what you found in `/docs/` +(usually `DOMAIN_KNOWLEDGE.md`), with the legacy class it came from, so the next person +doesn't have to dig. Where the legacy app has several variants of a rule, prefer the +newest, most robust one (typically the AirBeam Mini V2 path) and say so. + ## Core directive: propose the code in chat, and teach it The developers on this project are **experienced Android Kotlin developers** learning diff --git a/docs/API_V3.md b/docs/API_V3.md index 6f4b57b..ff083eb 100644 --- a/docs/API_V3.md +++ b/docs/API_V3.md @@ -184,18 +184,21 @@ cleared via `UploadOutbox.acknowledge` (a `DELETE` — the data row is untouched insert-only, per `DOMAIN_KNOWLEDGE.md` rule #4). The traps above shape how it may be used: 1. **Only data recorded on this phone is enqueued.** Fixed sessions are uploaded by the AirBeam itself - (with `session_token`); downloaded, followed or Explore-saved sessions belong to the server (often - another user). Those paths call `insertAll`, which never touches the outbox — so the safe default is + (with `session_token`) — except the AB3 SD-card sync edge case, which is not built yet and would + need the 9-byte fixed frame; downloaded, followed or Explore-saved sessions belong to the server (often + another user). Every enqueued mobile measurement already has a location — it is stamped at insert + with a last-known-location fallback (`DOMAIN_KNOWLEDGE.md` §3) — so the uploader never invents one. Those paths call `insertAll`, which never touches the outbox — so the safe default is "not uploaded". Only the recording path calls `insertRecorded`, which stores **and** enqueues in one transaction, enqueuing only rows actually newly stored (an `OR IGNORE`-skipped duplicate was enqueued when first recorded). 2. **Acknowledged means "accepted in a `200` batch", not "stored".** See trap 1. So it is only truthful if the uploader **never sends a frame the server will silently drop**: - - **Split against `finished_at` before sending** (trap 3). Frames after the session's server-side - `finished_at` are never stored — don't send them, don't acknowledge them. They also must not sit in - the outbox forever. **Open decision:** bound `pending` by `finished_at`, or add a `state` column to - `pending_uploads` (e.g. "unuploadable"). Either way the change stays in the outbox; the - measurement itself is never deleted. + - **Drain before finish** (trap 3) — *decided 2026-10-16.* Stopping a session is a user action: + the app stores it locally at once (`end_time`) but calls `POST .../finish` only **after that + session's outbox is empty**. The server stamps `finished_at` with *its own* clock when `/finish` + arrives, so finishing first would silently drop the last frames whenever the phone clock runs + ahead of the server's. Uploading first means no batch can straddle `finished_at`, and the + uploader never needs to read it. 3. **Sync the clock before the first batch** (trap 2, empty body → `X-Server-Time`). On mobile one out-of-range timestamp fails the **whole batch** with `400 invalid_epoch`, and a naive "retry the oldest pending rows" loop then resends the same poisoned batch forever. The uploader must detect diff --git a/docs/DOMAIN_KNOWLEDGE.md b/docs/DOMAIN_KNOWLEDGE.md index 18e8ce0..42a21de 100644 --- a/docs/DOMAIN_KNOWLEDGE.md +++ b/docs/DOMAIN_KNOWLEDGE.md @@ -155,6 +155,29 @@ Three distinct paths coexist today (the rewrite should unify onto one Flow pipel - **Path C — Fixed sessions:** device POSTs to backend autonomously; phone **downloads** via a 60s polling service + on card-expand. +### Measurement location stamping (mobile) — **KEEP** + +Every **mobile** measurement gets a location **when it is stored** ("stamping"), never at +upload time — the v3 mobile upload frame has no "no location" value. If the phone's +location is unknown at that moment, the measurement **copies the last known location**. +The rewrite follows the newest legacy variant (`V2MobileMeasurementsInserter` + +`AirBeamMiniV2StateRepository.getClosestLocation`, used for Mini V2 and SD sync): + +1. **Tracked location closest in time** to the measurement (binary search over the + `tracked_locations` buffer) — matters for backfilled/synced records, whose timestamps are + in the past. +2. else **last known location in this session** — the newest stored measurement of the + session with non-null lat/lng (`MeasurementDao.lastKnownLatLng`). +3. else **the phone's last known location** (`LocationHelper.lastLocation()`). +4. else `DEFAULT_LOCATION = (40.7128, -74.0060)` (New York) — last resort, should be rare. + +Special case: **maps disabled / location-less** session → every measurement gets +`FAKE_LOCATION = (200.0, 200.0)` (`Session.Location.get(location, locationless = true)`). +Path A (live V1) is the older variant: it stamps `LocationHelper.lastLocation()`, else +`DEFAULT_LOCATION`, refreshed once per full set of streams so a set shares one location. + +Fixed-session measurements carry no per-measurement location (it lives on the session). + UI consumes the DB reactively: Room `LiveData` → repository projections → `SessionsObserver` (diff engine) → dashboard cards. DB is the intended single source of truth. @@ -440,3 +463,10 @@ Mini V2 mobile. Level banding lives in `Measurement.getLevel`. 7. **Two measurement "drain" flags for V2 finish-while-syncing** (V2 guide §9). 8. **V2→V1 fallback disconnect-path guards** — keep the service/EventBus alive across the failed V2 leg (V2 guide §9, Nordic disconnect hazard). +9. **Mobile measurements are stamped with a location at insert, falling back to the last + known location** (§3 "Measurement location stamping"). A stored mobile measurement never + has a null location. +10. **The app uploads mobile measurements only.** Fixed-session measurements are uploaded by + the AirBeam itself (with `session_token`); the app downloads them. Single exception: + **AB3 SD-card sync** can upload fixed measurements read off the card + (legacy `SDCardUploadFixedMeasurementsService`). diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayload.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayload.kt new file mode 100644 index 0000000..e69de29 diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayloadTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayloadTest.kt new file mode 100644 index 0000000..e69de29 From c4e76f97fe2cf75123868b84f9f2661eb31212a9 Mon Sep 17 00:00:00 2001 From: kgetman Date: Fri, 16 Oct 2026 10:23:42 +0200 Subject: [PATCH 02/11] feat(Session recording > networking): add MobileMeasurementPayload binary encoder Add binary payload encoder and test suite for mobile measurement session uploads. - Introduce `MobileFrame` data model and `MobileMeasurementPayload.encode` for encoding frames into the expected binary format (magic header `0xABBA`, 16-bit count, 25 bytes per frame, and trailing XOR checksum). - Enforce validation rules: batch size capped at 3000 frames, non-empty list required, and `sensorTypeId` bounded to uint8. - Add `MobileMeasurementPayloadTest` to verify byte-by-byte encoding accuracy, timestamp truncation to whole seconds, frame limits, and checksum generation. --- .../upload/MobileMeasurementPayload.kt | 39 ++++++++++ .../upload/MobileMeasurementPayloadTest.kt | 71 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayload.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayload.kt index e69de29..773a686 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayload.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayload.kt @@ -0,0 +1,39 @@ +package pl.llp.aircasting.data.network.upload + +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import kotlinx.io.writeDouble +import kotlinx.io.writeFloat +import pl.llp.aircasting.domain.GeoLocation +import kotlin.time.Instant + +data class MobileFrame( + val time: Instant, + val sensorTypeId: Int, + val value: Double, + val location: GeoLocation, +) + +object MobileMeasurementPayload { + const val MaxFrames = 3000 + + fun encode(frames: List): ByteArray { + require(frames.isNotEmpty()) { "No frames — time sync sends an empty body instead" } + require(frames.size <= MaxFrames) { "${frames.size} frames, server accepts at most $MaxFrames" } + + val buffer = Buffer() + buffer.writeByte(0xAB.toByte()) + buffer.writeByte(0xBA.toByte()) + buffer.writeShort(frames.size.toShort()) + frames.forEach { frame -> + require(frame.sensorTypeId in 1..255) { "sensor_type_id ${frame.sensorTypeId} is not a uint8 id" } + buffer.writeInt(frame.time.epochSeconds.toInt()) + buffer.writeByte(frame.sensorTypeId.toByte()) + buffer.writeFloat(frame.value.toFloat()) + buffer.writeDouble(frame.location.latitude) + buffer.writeDouble(frame.location.longitude) + } + val body = buffer.readByteArray() + return body + body.fold(0) { checksum, byte -> checksum xor byte.toInt() }.toByte() + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayloadTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayloadTest.kt index e69de29..181a259 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayloadTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementPayloadTest.kt @@ -0,0 +1,71 @@ +package pl.llp.aircasting.data.network.upload + +import pl.llp.aircasting.domain.GeoLocation +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.time.Instant + +class MobileMeasurementPayloadTest { + private val frame = MobileFrame( + time = Instant.parse("2024-01-01T00:00:00Z"), // 1704067200 = 0x65920080 + sensorTypeId = 3, + value = 12.5, // float32 0x41480000 + location = GeoLocation(latitude = 40.0, longitude = -74.0), + ) + + @Test + fun encodes_a_single_frame_byte_for_byte() { + val expected = bytes( + 0xAB, 0xBA, // magic + 0x00, 0x01, // count = 1 + 0x65, 0x92, 0x00, 0x80, // timestamp, uint32 BE + 0x03, // sensor_type_id + 0x41, 0x48, 0x00, 0x00, // value, float32 BE + 0x40, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // latitude, float64 BE + 0xC0, 0x52, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, // longitude, float64 BE + 0x7B, // XOR checksum + ) + assertContentEquals(expected, MobileMeasurementPayload.encode(listOf(frame))) + } + + @Test + fun count_is_big_endian_and_size_is_header_plus_25_bytes_per_frame() { + val payload = MobileMeasurementPayload.encode(List(300) { frame }) + + assertEquals(0x01, payload[2].toInt() and 0xFF) // 300 = 0x012C + assertEquals(0x2C, payload[3].toInt() and 0xFF) + assertEquals(4 + 300 * 25 + 1, payload.size) + } + + @Test + fun milliseconds_are_truncated_to_whole_seconds() { + val withMillis = frame.copy(time = Instant.parse("2024-01-01T00:00:00.999Z")) + + assertContentEquals( + MobileMeasurementPayload.encode(listOf(frame)), + MobileMeasurementPayload.encode(listOf(withMillis)), + ) + } + + @Test + fun empty_list_is_rejected() { + assertFailsWith { MobileMeasurementPayload.encode(emptyList()) } + } + @Test + fun more_than_3000_frames_is_rejected() { + assertFailsWith { + MobileMeasurementPayload.encode(List(MobileMeasurementPayload.MaxFrames + 1) { frame }) + } + } + + @Test + fun sensor_type_id_that_does_not_fit_a_byte_is_rejected() { + assertFailsWith { + MobileMeasurementPayload.encode(listOf(frame.copy(sensorTypeId = 256))) + } + } + + private fun bytes(vararg values: Int) = ByteArray(values.size) { values[it].toByte() } +} \ No newline at end of file From 623d8ffdb20f14c1d70da6ffea3a65e4cb03825f Mon Sep 17 00:00:00 2001 From: kgetman Date: Fri, 16 Oct 2026 12:48:10 +0200 Subject: [PATCH 03/11] docs(api): client sends finished_at for mobile sessions BE agreed to accept the client's finished_at on mobile /finish. Every frame is stamped by the same phone clock at or before end_time, so upload and /finish may run in any order and the uploader never reads finished_at back. Replaces the drain-before-finish rule. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/API_V3.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/API_V3.md b/docs/API_V3.md index ff083eb..3ffa8a3 100644 --- a/docs/API_V3.md +++ b/docs/API_V3.md @@ -56,7 +56,7 @@ AirBeam mobile (moving) sessions. | GET | `/api/v3/mobile_sessions/{uuid}` | — | show **[A]** | | PATCH | `/api/v3/mobile_sessions/{uuid}` | partial JSON; empty `tag_list` clears tags | update **[A]** | | DELETE | `/api/v3/mobile_sessions/{uuid}` | — | delete **[A]** | -| POST | `/api/v3/mobile_sessions/{uuid}/finish` | — | stamps `finished_at` **[A]** | +| POST | `/api/v3/mobile_sessions/{uuid}/finish` | `finished_at` ⏳ | sets `finished_at` to the client's stop time **[A]** | | GET | `/api/v3/mobile_sessions/{uuid}/measurements` | `sensor_name` (req), `start_time`, `end_time` | read **[A]** | | POST | `/api/v3/mobile_sessions/{uuid}/measurements` | **binary** (below) | upload **[A]** | | GET · POST | `/api/v3/mobile_sessions/{uuid}/notes` | — · JSON | list · add **[A]** | @@ -77,6 +77,11 @@ type (`AirBeam-PM2.5`). Built-in `sensor_type_id`s are **1–99**, globally stab Call create once at the start of a recording, then stream measurements to `.../measurements`. +**⏳ Planned change to `/finish` (agreed with BE 2026-10-16, not in the spec yet):** the mobile +`/finish` will accept the **client's** `finished_at` (the moment the user stopped recording, from the +phone clock) instead of stamping the server's clock on arrival. Body shape still to be confirmed — +expect epoch ms, like `finished_at` in the response. Until it lands, the server stamps its own time. + --- ## Fixed sessions — tag `Mobile app: Fixed sessions` @@ -170,6 +175,7 @@ The two uploads look symmetric and are not. Three traps, in the order they bite: `finished_at` are stored, later ones dropped, response `200` either way, with no count of what was kept. The only way to know is to **read `finished_at` off the session** and partition locally before sending. + *Mobile:* defused once the client sends its own `finished_at` — see outbox rule 2 below. Everything else is forgiving: resends are free, so retry on any transport error rather than trying to reason about what got through. @@ -186,19 +192,20 @@ insert-only, per `DOMAIN_KNOWLEDGE.md` rule #4). The traps above shape how it ma 1. **Only data recorded on this phone is enqueued.** Fixed sessions are uploaded by the AirBeam itself (with `session_token`) — except the AB3 SD-card sync edge case, which is not built yet and would need the 9-byte fixed frame; downloaded, followed or Explore-saved sessions belong to the server (often - another user). Every enqueued mobile measurement already has a location — it is stamped at insert - with a last-known-location fallback (`DOMAIN_KNOWLEDGE.md` §3) — so the uploader never invents one. Those paths call `insertAll`, which never touches the outbox — so the safe default is + another user). Those paths call `insertAll`, which never touches the outbox — so the safe default is "not uploaded". Only the recording path calls `insertRecorded`, which stores **and** enqueues in one transaction, enqueuing only rows actually newly stored (an `OR IGNORE`-skipped duplicate was - enqueued when first recorded). + enqueued when first recorded). Every enqueued mobile measurement already has a location — it is + stamped at insert with a last-known-location fallback (`DOMAIN_KNOWLEDGE.md` §3) — so the uploader + never invents one. 2. **Acknowledged means "accepted in a `200` batch", not "stored".** See trap 1. So it is only truthful if the uploader **never sends a frame the server will silently drop**: - - **Drain before finish** (trap 3) — *decided 2026-10-16.* Stopping a session is a user action: - the app stores it locally at once (`end_time`) but calls `POST .../finish` only **after that - session's outbox is empty**. The server stamps `finished_at` with *its own* clock when `/finish` - arrives, so finishing first would silently drop the last frames whenever the phone clock runs - ahead of the server's. Uploading first means no batch can straddle `finished_at`, and the - uploader never needs to read it. + - **The client owns `finished_at`** (trap 3) — *decided 2026-10-16, pending the BE change above.* + Stopping is a user action: the app stores the stop locally (`sessions.end_time`) and sends that same + instant to `POST .../finish`. Every recorded frame is stamped by the same phone clock at or before + `end_time`, so none can land after `finished_at` — **upload and `/finish` may happen in any order**, + and the uploader never reads `finished_at` back. (With server-side stamping, a phone clock running + ahead of the server's would silently drop the last frames; sending our own time removes that.) 3. **Sync the clock before the first batch** (trap 2, empty body → `X-Server-Time`). On mobile one out-of-range timestamp fails the **whole batch** with `400 invalid_epoch`, and a naive "retry the oldest pending rows" loop then resends the same poisoned batch forever. The uploader must detect From 2e266fc26c6caffd8b39328472dc94208e13dcf6 Mon Sep 17 00:00:00 2001 From: kgetman Date: Fri, 16 Oct 2026 14:17:45 +0200 Subject: [PATCH 04/11] docs(api): every mobile stream must be sent in the create call v3 cannot add a stream to an existing session, and frames need the sensor_type_id from the create response. Streams without one are skipped by the uploader and logged. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/API_V3.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/API_V3.md b/docs/API_V3.md index 3ffa8a3..2244c1f 100644 --- a/docs/API_V3.md +++ b/docs/API_V3.md @@ -77,6 +77,12 @@ type (`AirBeam-PM2.5`). Built-in `sensor_type_id`s are **1–99**, globally stab Call create once at the start of a recording, then stream measurements to `.../measurements`. +**All streams go in the create call** — *decided 2026-10-16.* v3 has no endpoint to add a stream to an +existing session, and a frame needs the `sensor_type_id` that only the create response hands out. So +the recording flow must know every stream before create (the AirBeam reports its sensors on connect) +and never create one lazily on a sensor's first measurement. A local stream without `sensor_type_id` +can never be uploaded: the uploader skips its rows (they stay in the outbox) and logs an error. + **⏳ Planned change to `/finish` (agreed with BE 2026-10-16, not in the spec yet):** the mobile `/finish` will accept the **client's** `finished_at` (the moment the user stopped recording, from the phone clock) instead of stamping the server's clock on arrival. Body shape still to be confirmed — From 782e4c9665b8e31cb3eeaf4052c0afa66903159e Mon Sep 17 00:00:00 2001 From: kgetman Date: Fri, 16 Oct 2026 15:32:03 +0200 Subject: [PATCH 05/11] feat(Session recording > networking): add MobileMeasurementUploader for mobile sessions --- .../upload/MobileMeasurementUploader.kt | 46 ++++++ .../upload/MobileMeasurementUploaderTest.kt | 149 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploader.kt create mode 100644 shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploaderTest.kt diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploader.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploader.kt new file mode 100644 index 0000000..1efe3b3 --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploader.kt @@ -0,0 +1,46 @@ +package pl.llp.aircasting.data.network.upload + +import co.touchlab.kermit.Logger +import pl.llp.aircasting.data.auth.AuthSession +import pl.llp.aircasting.data.auth.tokenOrNull +import pl.llp.aircasting.data.network.MobileSessionsApi +import pl.llp.aircasting.domain.session.MeasurementStreamRepository +import pl.llp.aircasting.domain.session.UploadOutbox + +/** Drains one mobile session's outbox to `POST /api/v3/mobile_sessions/{uuid}/measurements`. */ +class MobileMeasurementUploader( + private val api: MobileSessionsApi, + private val outbox: UploadOutbox, + private val streams: MeasurementStreamRepository, + private val auth: AuthSession, +) { + private val log = Logger.withTag("Upload") + + /** + * Sends what is pending now, in batches of at most [MobileMeasurementPayload.MaxFrames]. + * Throws if a request fails — rows are acknowledged only after a successful response, so + * anything unsent stays queued for the next call. + */ + suspend fun upload(sessionUuid: String) { + val token = auth.state.value.tokenOrNull ?: return + val sensorTypeIds = streams.findBySession(sessionUuid) + .mapNotNull { stream -> stream.sensorTypeId?.let { stream.id to it } } + .toMap() + + do { + val pending = outbox.pending(sessionUuid, MobileMeasurementPayload.MaxFrames) + val sendable = pending.mapNotNull { measurement -> + val sensorTypeId = sensorTypeIds[measurement.streamId] ?: return@mapNotNull null + val location = measurement.location ?: return@mapNotNull null + measurement to MobileFrame(measurement.time, sensorTypeId, measurement.value, location) + } + if (sendable.size < pending.size) { + log.e { "$sessionUuid: ${pending.size - sendable.size} rows unsendable (stream without sensor_type_id, or no location)" } + } + if (sendable.isEmpty()) return + + api.uploadMeasurementsBinary(token, sessionUuid, MobileMeasurementPayload.encode(sendable.map { it.second })) + outbox.acknowledge(sendable.map { it.first }) + } while (pending.size == MobileMeasurementPayload.MaxFrames) + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploaderTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploaderTest.kt new file mode 100644 index 0000000..fa921ab --- /dev/null +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploaderTest.kt @@ -0,0 +1,149 @@ +package pl.llp.aircasting.data.network.upload + +import com.russhwolf.settings.MapSettings +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.plugins.ServerResponseException +import io.ktor.client.request.HttpRequestData +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.test.runTest +import pl.llp.aircasting.bluetooth.transport.ble.AirBeamGatt.MiniV2.measurement +import pl.llp.aircasting.data.auth.StoredAuthSession +import pl.llp.aircasting.data.network.MobileSessionsApi +import pl.llp.aircasting.data.network.createAircastingHttpClient +import pl.llp.aircasting.domain.GeoLocation +import pl.llp.aircasting.domain.session.Measurement +import pl.llp.aircasting.domain.session.MeasurementStream +import pl.llp.aircasting.domain.session.MeasurementStreamRepository +import pl.llp.aircasting.domain.session.UploadOutbox +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class MobileMeasurementUploaderTest { + + private val requests = mutableListOf() + private val outbox = FakeUploadOutbox() + private val pm25 = MeasurementStream(id = 1, sessionUuid = UUID, sensorName = "AirBeam3-PM2.5", unitSymbol = "µg/m³", sensorTypeId = 3) + private val pm1 = MeasurementStream(id = 2, sessionUuid = UUID, sensorName = "AirBeam3-PM1", unitSymbol = "µg/m³", sensorTypeId = 1) + + @Test + fun sends_pending_measurements_as_one_binary_batch_then_acknowledges_them() = runTest { + val a = measurement(streamId = 1, second = 0, value = 12.5) + val b = measurement(streamId = 2, second = 0, value = 7.0) + outbox.rows += listOf(a, b) + + uploader(streams = listOf(pm25, pm1)).upload(UUID) + + val request = requests.single() + assertEquals("/api/v3/mobile_sessions/$UUID/measurements", request.url.encodedPath) + assertEquals("Bearer tok-123", request.headers[HttpHeaders.Authorization]) + val expected = MobileMeasurementPayload.encode( + listOf( + MobileFrame(a.time, sensorTypeId = 3, value = 12.5, location = LOCATION), + MobileFrame(b.time, sensorTypeId = 1, value = 7.0, location = LOCATION), + ) + ) + assertContentEquals(expected, request.bodyBytes()) + assertTrue(outbox.rows.isEmpty()) + } + @Test + fun empty_outbox_sends_nothing() = runTest { + uploader(streams = listOf(pm25)).upload(UUID) + + assertTrue(requests.isEmpty()) + } + + @Test + fun failed_request_throws_and_keeps_the_rows_queued() = runTest { + outbox.rows += measurement(streamId = 1, second = 0) + + assertFailsWith { + uploader(streams = listOf(pm25), status = HttpStatusCode.InternalServerError).upload(UUID) + } + assertEquals(1, outbox.rows.size) + } + + @Test + fun full_batch_triggers_another_pass_until_a_short_batch_drains_the_outbox() = runTest { + outbox.rows += List(MobileMeasurementPayload.MaxFrames + 1) { measurement(streamId = 1, second = it) } + + uploader(streams = listOf(pm25)).upload(UUID) + + assertEquals(2, requests.size) + assertTrue(outbox.rows.isEmpty()) + } + + @Test + fun rows_of_a_stream_without_sensor_type_id_stay_queued_and_the_rest_still_go() = runTest { + val orphanStream = pm1.copy(sensorTypeId = null) + val sendable = measurement(streamId = 1, second = 0) + val orphan = measurement(streamId = 2, second = 0) + outbox.rows += listOf(sendable, orphan) + + uploader(streams = listOf(pm25, orphanStream)).upload(UUID) + + assertEquals(1, requests.size) + assertEquals(listOf(orphan), outbox.rows) + } + + @Test + fun signed_out_sends_nothing_and_keeps_the_rows_queued() = runTest { + outbox.rows += measurement(streamId = 1, second = 0) + + uploader(streams = listOf(pm25), token = null).upload(UUID) + + assertTrue(requests.isEmpty()) + assertEquals(1, outbox.rows.size) + } + + private fun uploader( + streams: List, + status: HttpStatusCode = HttpStatusCode.OK, + token: String? = "tok-123", + ): MobileMeasurementUploader { + val engine = MockEngine { request -> + requests += request + if (status == HttpStatusCode.OK) respond("") else respondError(status) + } + return MobileMeasurementUploader( + api = MobileSessionsApi(createAircastingHttpClient(engine)), + outbox = outbox, + streams = FakeStreamRepository(streams), + auth = StoredAuthSession(MapSettings()).apply { token?.let(::start) }, + ) + } + + private fun measurement(streamId: Long, second: Int, value: Double = 10.0) = + Measurement(streamId = streamId, time = T0 + second.seconds, value = value, location = LOCATION) + + private fun HttpRequestData.bodyBytes() = (body as OutgoingContent.ByteArrayContent).bytes() + + /** Mirrors LocalUploadOutbox: oldest first, at most [limit]; acknowledge deletes. */ + private class FakeUploadOutbox : UploadOutbox { + val rows = mutableListOf() + override suspend fun pending(sessionUuid: String, limit: Int) = rows.sortedBy { it.time }.take(limit) + override suspend fun acknowledge(measurements: List) { rows -= measurements.toSet() } + } + + private class FakeStreamRepository(private val streams: List) : MeasurementStreamRepository { + override suspend fun findBySession(sessionUuid: String) = streams.filter { it.sessionUuid == sessionUuid } + override suspend fun findOrCreate(sessionUuid: String, sensorName: String, unitSymbol: String, sensorTypeId: Int?): Long = error("unused") + override fun observeBySession(sessionUuid: String): Flow> = error("unused") + override suspend fun setSensorTypeId(streamId: Long, sensorTypeId: Int) = error("unused") + } + + private companion object { + const val UUID = "8d7a3f2e-0000-4000-8000-000000000001" + val T0 = Instant.parse("2024-01-01T00:00:00Z") + val LOCATION = GeoLocation(latitude = 40.0, longitude = -74.0) + } +} From 4fbc11b08e5aab0b82b1a008565eb6e09a876638 Mon Sep 17 00:00:00 2001 From: kgetman Date: Fri, 16 Oct 2026 16:15:16 +0200 Subject: [PATCH 06/11] docs(api): no client-side clock checks in the uploader Phone clocks are network-synced and the legacy app never pre-checked timestamps. An invalid_epoch batch throws, stays queued, and is surfaced to the user and crash reporting. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/API_V3.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/API_V3.md b/docs/API_V3.md index 2244c1f..c9e8bb6 100644 --- a/docs/API_V3.md +++ b/docs/API_V3.md @@ -175,8 +175,7 @@ The two uploads look symmetric and are not. Three traps, in the order they bite: 2. **Out-of-range timestamps diverge.** A frame with epoch 0, before 2020-01-01 UTC, or more than 24 h ahead of server time is **silently dropped by fixed** (`200`, the rest of the payload is stored) but **rejected by mobile** (`400 invalid_epoch`). So the same clock bug surfaces as data loss on one path - and a hard failure on the other. Sync the clock first via the empty-body / `X-Server-Time` call rather - than trusting the device. + and a hard failure on the other. *We don't pre-check this client-side* — see outbox rule 3. 3. **A batch straddling `finished_at` is indistinguishable from a clean one.** Frames at or before `finished_at` are stored, later ones dropped, response `200` either way, with no count of what was kept. The only way to know is to **read `finished_at` off the session** and partition locally before @@ -212,10 +211,11 @@ insert-only, per `DOMAIN_KNOWLEDGE.md` rule #4). The traps above shape how it ma `end_time`, so none can land after `finished_at` — **upload and `/finish` may happen in any order**, and the uploader never reads `finished_at` back. (With server-side stamping, a phone clock running ahead of the server's would silently drop the last frames; sending our own time removes that.) -3. **Sync the clock before the first batch** (trap 2, empty body → `X-Server-Time`). On mobile one - out-of-range timestamp fails the **whole batch** with `400 invalid_epoch`, and a naive "retry the - oldest pending rows" loop then resends the same poisoned batch forever. The uploader must detect - `invalid_epoch` and handle the bad frames rather than retry unchanged. +3. **No client-side clock checks** (trap 2) — *decided 2026-10-16.* Phone clocks are network-synced, + and the legacy app never checked timestamps against server time without trouble. So the uploader + doesn't sync the clock or filter frames by the server's bounds. If a broken clock ever produces an + `invalid_epoch` batch, the request throws like any other failure, the rows stay queued, and the + error is surfaced to the user and to crash reporting — a visible stuck upload, not silent loss. 4. **Track per row `(stream_id, time)`, never a time cursor.** Mini V2 reconnect-sync backfills *older* timestamps after newer ones were already uploaded; a per-session "uploaded up to T" cursor would treat them as done and lose them. From e3e2ac1ae0216cee084fba46c33b9528562c859d Mon Sep 17 00:00:00 2001 From: kgetman Date: Mon, 19 Oct 2026 11:10:48 +0200 Subject: [PATCH 07/11] docs(api): /finish now takes the client's finished_at; flat create body, 409 recovery Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/API_V3.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/API_V3.md b/docs/API_V3.md index c9e8bb6..9211115 100644 --- a/docs/API_V3.md +++ b/docs/API_V3.md @@ -56,7 +56,7 @@ AirBeam mobile (moving) sessions. | GET | `/api/v3/mobile_sessions/{uuid}` | — | show **[A]** | | PATCH | `/api/v3/mobile_sessions/{uuid}` | partial JSON; empty `tag_list` clears tags | update **[A]** | | DELETE | `/api/v3/mobile_sessions/{uuid}` | — | delete **[A]** | -| POST | `/api/v3/mobile_sessions/{uuid}/finish` | `finished_at` ⏳ | sets `finished_at` to the client's stop time **[A]** | +| POST | `/api/v3/mobile_sessions/{uuid}/finish` | JSON `{ finished_at }` | sets `finished_at` to the client's stop time **[A]** | | GET | `/api/v3/mobile_sessions/{uuid}/measurements` | `sensor_name` (req), `start_time`, `end_time` | read **[A]** | | POST | `/api/v3/mobile_sessions/{uuid}/measurements` | **binary** (below) | upload **[A]** | | GET · POST | `/api/v3/mobile_sessions/{uuid}/notes` | — · JSON | list · add **[A]** | @@ -83,10 +83,17 @@ the recording flow must know every stream before create (the AirBeam reports its and never create one lazily on a sensor's first measurement. A local stream without `sensor_type_id` can never be uploaded: the uploader skips its rows (they stay in the outbox) and logs an error. -**⏳ Planned change to `/finish` (agreed with BE 2026-10-16, not in the spec yet):** the mobile -`/finish` will accept the **client's** `finished_at` (the moment the user stopped recording, from the -phone clock) instead of stamping the server's clock on arrival. Body shape still to be confirmed — -expect epoch ms, like `finished_at` in the response. Until it lands, the server stamps its own time. +**Create body is flat** — the fields above sit at the top level, not under a `session` key. +**`201` response:** `{ share_url, streams[] { sensor_name, sensor_type_id } }` — write each +`sensor_type_id` back onto the local stream. A `409 session_uuid_taken` on a retry usually means an +earlier create landed and its response was lost: read the ids back from `GET .../{uuid}`, whose +`streams` is an object keyed by `sensor_name`, each with a nullable `sensor_type_id`. + +**`/finish` takes the client's stop time** — *agreed with BE 2026-10-16, in the spec since +2026-10-19.* Body `{ "finished_at": }`; omit it or send `null` and the server stamps +its own clock. A `finished_at` **in the future** (by the server's clock) is `400 validation_error` — +so a phone clock running ahead can get a `/finish` sent right after stopping rejected. That heals +itself: the next sync pass retries, and by then the server clock has caught up. --- @@ -205,7 +212,7 @@ insert-only, per `DOMAIN_KNOWLEDGE.md` rule #4). The traps above shape how it ma never invents one. 2. **Acknowledged means "accepted in a `200` batch", not "stored".** See trap 1. So it is only truthful if the uploader **never sends a frame the server will silently drop**: - - **The client owns `finished_at`** (trap 3) — *decided 2026-10-16, pending the BE change above.* + - **The client owns `finished_at`** (trap 3) — *decided 2026-10-16; the BE change landed 2026-10-19.* Stopping is a user action: the app stores the stop locally (`sessions.end_time`) and sends that same instant to `POST .../finish`. Every recorded frame is stamped by the same phone clock at or before `end_time`, so none can land after `finished_at` — **upload and `/finish` may happen in any order**, From 0c9a85a4e515c43137a6499ec810bbb4213be055 Mon Sep 17 00:00:00 2001 From: kgetman Date: Mon, 19 Oct 2026 13:30:05 +0200 Subject: [PATCH 08/11] feat(Session Recording > Upload): add MobileSessionSync and finish session endpoint support --- .../data/local/LocalSessionRepository.kt | 5 + .../aircasting/data/network/ApiEndpoints.kt | 1 + .../pl/llp/aircasting/data/network/Dto.kt | 34 ++- .../data/network/MobileSessionsApi.kt | 12 +- .../data/network/upload/MobileSessionSync.kt | 80 +++++++ .../llp/aircasting/domain/session/Session.kt | 2 + .../domain/session/SessionRepositories.kt | 1 + .../llp/aircasting/data/local/db/Session.sq | 8 +- .../upload/MobileMeasurementUploaderTest.kt | 18 -- .../network/upload/MobileSessionSyncTest.kt | 213 ++++++++++++++++++ .../data/network/upload/UploadFakes.kt | 25 ++ 11 files changed, 372 insertions(+), 27 deletions(-) create mode 100644 shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt create mode 100644 shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt create mode 100644 shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/UploadFakes.kt diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/local/LocalSessionRepository.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/local/LocalSessionRepository.kt index 16ad6e4..c142638 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/local/LocalSessionRepository.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/local/LocalSessionRepository.kt @@ -65,6 +65,10 @@ class LocalSessionRepository( override suspend fun finish(uuid: String, endTime: Instant): Unit = withContext(ioDispatcher) { queries.finishSession(status = SessionStatus.FINISHED, endTime = endTime, uuid = uuid) } + + override suspend fun markFinishSynced(uuid: String): Unit = + withContext(ioDispatcher) { queries.markFinishSynced(uuid) } + override suspend fun delete(uuid: String): Unit = withContext(ioDispatcher) { queries.deleteSession(uuid) } } @@ -85,4 +89,5 @@ private fun Sessions.toDomain() = Session( isIndoor = is_indoor, contribute = contribute, sessionToken = session_token, + finishSynced = finish_synced, ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ApiEndpoints.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ApiEndpoints.kt index cec1ae6..dc8a225 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ApiEndpoints.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ApiEndpoints.kt @@ -18,6 +18,7 @@ object ApiEndpoints { fun v3MobileSessionMeasurements(uuid: String) = "/api/v3/mobile_sessions/$uuid/measurements" fun v3MobileSessionNotes(uuid: String) = "/api/v3/mobile_sessions/$uuid/notes" fun v3MobileSessionNote(uuid: String, noteId: Long) = "/api/v3/mobile_sessions/$uuid/notes/$noteId" + fun v3MobileSessionFinish(uuid: String) = "/api/v3/mobile_sessions/$uuid/finish" // ========================================================================= // Mobile app: Fixed sessions v3 (HTTP Bearer Auth: user token or session token) // ========================================================================= diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt index 2859544..bbbdc09 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt @@ -26,6 +26,8 @@ data class PaginationMetaDto( data class MobileSessionListResponseDto( val sessions: List = emptyList(), val meta: PaginationMetaDto, + @SerialName("share_url") val shareUrl: String? = null, + val streams: Map = emptyMap(), ) @Serializable data class MobileSessionSummaryDto( @@ -41,20 +43,38 @@ data class MobileSessionSummaryDto( val latitude: Double? = null, val longitude: Double? = null, @SerialName("share_url") val shareUrl: String? = null, + val streams: Map = emptyMap(), ) +/** `POST /api/v3/mobile_sessions` — flat body, no `session` wrapper. */ @Serializable data class CreateMobileSessionRequestDto( - val session: MobileSessionPayloadDto, -) -@Serializable -data class MobileSessionPayloadDto( val uuid: String, val title: String, @SerialName("time_zone") val timeZone: String, - val contribute: Boolean = true, - val tags: List = emptyList(), - val streams: List = emptyList(), + val contribute: Boolean, + @SerialName("tag_list") val tagList: String, + val device: DeviceDto, + val streams: List, +) +@Serializable +data class DeviceDto( + @SerialName("mac_address") val macAddress: String, + val model: String, ) +@Serializable +data class CreateMobileSessionResponseDto( + val streams: List, +) +@Serializable +data class FinishMobileSessionRequestDto( + @SerialName("finished_at") val finishedAt: Long, +) +/** One entry of a session's `streams` object (keyed by sensor_name); aggregates ignored. */ +@Serializable +data class SessionStreamDto( + @SerialName("sensor_type_id") val sensorTypeId: Int? = null, +) + @Serializable data class CreateStreamPayloadDto( @SerialName("sensor_name") val sensorName: String, diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/MobileSessionsApi.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/MobileSessionsApi.kt index ff2f16f..784ff1e 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/MobileSessionsApi.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/MobileSessionsApi.kt @@ -14,6 +14,7 @@ import io.ktor.http.ContentType import io.ktor.http.Headers import io.ktor.http.HttpHeaders import io.ktor.http.contentType +import kotlin.time.Instant class MobileSessionsApi(private val client: HttpClient) { /** List the signed-in user's mobile sessions, paginated. */ @@ -32,7 +33,7 @@ class MobileSessionsApi(private val client: HttpClient) { suspend fun createSession( userToken: String, request: CreateMobileSessionRequestDto, - ): MobileSessionSummaryDto = client.post(ApiEndpoints.V3MobileSessions) { + ): CreateMobileSessionResponseDto = client.post(ApiEndpoints.V3MobileSessions) { aircastingBearerAuth(userToken) contentType(ContentType.Application.Json) setBody(request) @@ -65,6 +66,15 @@ class MobileSessionsApi(private val client: HttpClient) { } } + /** Declare the recording over at [finishedAt] — the moment the user stopped, by the phone clock. */ + suspend fun finishSession(userToken: String, uuid: String, finishedAt: Instant) { + client.post(ApiEndpoints.v3MobileSessionFinish(uuid)) { + aircastingBearerAuth(userToken) + contentType(ContentType.Application.Json) + setBody(FinishMobileSessionRequestDto(finishedAt.toEpochMilliseconds())) + } + } + /** Upload recorded binary measurements for a mobile session. */ suspend fun uploadMeasurementsBinary( userToken: String, diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt new file mode 100644 index 0000000..6644417 --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt @@ -0,0 +1,80 @@ +package pl.llp.aircasting.data.network.upload + +import co.touchlab.kermit.Logger +import io.ktor.client.plugins.ClientRequestException +import io.ktor.http.HttpStatusCode +import pl.llp.aircasting.data.auth.AuthSession +import pl.llp.aircasting.data.auth.tokenOrNull +import pl.llp.aircasting.data.network.CreateMobileSessionRequestDto +import pl.llp.aircasting.data.network.CreateStreamPayloadDto +import pl.llp.aircasting.data.network.DeviceDto +import pl.llp.aircasting.data.network.MobileSessionsApi +import pl.llp.aircasting.domain.session.MeasurementStream +import pl.llp.aircasting.domain.session.MeasurementStreamRepository +import pl.llp.aircasting.domain.session.Session +import pl.llp.aircasting.domain.session.SessionRepository + +/** + * Brings one recorded mobile session up to date on the server: create (once), upload the + * outbox, then `/finish` (once, after the user stopped). Each step is skipped when already + * done, so calling it again after a failure resumes where it stopped. + */ +class MobileSessionSync( + private val api: MobileSessionsApi, + private val sessions: SessionRepository, + private val streams: MeasurementStreamRepository, + private val uploader: MobileMeasurementUploader, + private val auth: AuthSession, +) { + private val log = Logger.withTag("SessionSync") + + /** Throws on the first failed request; whatever was done before it stays done. */ + suspend fun sync(sessionUuid: String) { + val token = auth.state.value.tokenOrNull ?: return + val session = sessions.find(sessionUuid) ?: return + val local = streams.findBySession(sessionUuid) + if (local.any { it.sensorTypeId == null }) { + val assigned = createOrReadBack(token, session, local) + local.forEach { stream -> + assigned[stream.sensorName]?.let { streams.setSensorTypeId(stream.id, it) } + ?: log.e { "$sessionUuid: server assigned no sensor_type_id to ${stream.sensorName}" } + } + } + + uploader.upload(sessionUuid) + + val endTime = session.endTime + if (endTime != null && !session.finishSynced) { + api.finishSession(token, sessionUuid, endTime) + sessions.markFinishSynced(sessionUuid) + } + } + /** @return sensor_type_id by sensor_name. */ + private suspend fun createOrReadBack( + token: String, + session: Session, + local: List, + ): Map = try { + api.createSession(token, session.toCreateRequest(local)) + .streams.associate { it.sensorName to it.sensorTypeId } + } catch (e: ClientRequestException) { + if (e.response.status != HttpStatusCode.Conflict) throw e + // session_uuid_taken: an earlier create landed but its response never reached us. + api.getSession(token, session.uuid) + .streams.mapNotNull { (name, stream) -> stream.sensorTypeId?.let { name to it } } + .toMap() + } +} + +private fun Session.toCreateRequest(streams: List) = CreateMobileSessionRequestDto( + uuid = uuid, + title = name, + timeZone = timeZone, + contribute = contribute, + tagList = tags.joinToString(" "), + device = DeviceDto( + macAddress = requireNotNull(deviceId) { "$uuid: mobile session without a device id" }, + model = requireNotNull(deviceModel) { "$uuid: mobile session without a device model" }, + ), + streams = streams.map { CreateStreamPayloadDto(sensorName = it.sensorName, unitSymbol = it.unitSymbol) }, +) diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/Session.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/Session.kt index d92cd91..d37eb9e 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/Session.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/Session.kt @@ -28,4 +28,6 @@ data class Session( val contribute: Boolean = true, /** v3 fixed only: the Bearer token the AirBeam itself uses to upload. Not the user token. */ val sessionToken: String? = null, + /** Mobile only: the server has our `end_time` as `finished_at`. */ + val finishSynced: Boolean = false, ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/SessionRepositories.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/SessionRepositories.kt index b6c3c6b..6f15149 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/SessionRepositories.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/SessionRepositories.kt @@ -6,6 +6,7 @@ import kotlin.time.Instant interface SessionRepository { suspend fun insert(session: Session) suspend fun find(uuid: String): Session? + suspend fun markFinishSynced(uuid: String) fun observe(uuid: String): Flow fun observeAll(): Flow> suspend fun updateStatus(uuid: String, status: SessionStatus) diff --git a/shared/src/commonMain/sqldelight/pl/llp/aircasting/data/local/db/Session.sq b/shared/src/commonMain/sqldelight/pl/llp/aircasting/data/local/db/Session.sq index ad3ce73..a722434 100644 --- a/shared/src/commonMain/sqldelight/pl/llp/aircasting/data/local/db/Session.sq +++ b/shared/src/commonMain/sqldelight/pl/llp/aircasting/data/local/db/Session.sq @@ -21,7 +21,8 @@ CREATE TABLE sessions ( contribute INTEGER AS Boolean NOT NULL DEFAULT 1, time_zone TEXT NOT NULL, measurement_interval_millis INTEGER NOT NULL, - session_token TEXT + session_token TEXT, + finish_synced INTEGER AS Boolean NOT NULL DEFAULT 0 ); insertSession: @@ -51,6 +52,11 @@ UPDATE sessions SET status = :status, end_time = :endTime WHERE uuid = :uuid; +markFinishSynced: +UPDATE sessions +SET finish_synced = 1 +WHERE uuid = :uuid; + deleteSession: DELETE FROM sessions WHERE uuid = :uuid; \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploaderTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploaderTest.kt index fa921ab..b4549de 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploaderTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileMeasurementUploaderTest.kt @@ -9,17 +9,13 @@ import io.ktor.client.request.HttpRequestData import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.content.OutgoingContent -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.test.runTest -import pl.llp.aircasting.bluetooth.transport.ble.AirBeamGatt.MiniV2.measurement import pl.llp.aircasting.data.auth.StoredAuthSession import pl.llp.aircasting.data.network.MobileSessionsApi import pl.llp.aircasting.data.network.createAircastingHttpClient import pl.llp.aircasting.domain.GeoLocation import pl.llp.aircasting.domain.session.Measurement import pl.llp.aircasting.domain.session.MeasurementStream -import pl.llp.aircasting.domain.session.MeasurementStreamRepository -import pl.llp.aircasting.domain.session.UploadOutbox import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -127,20 +123,6 @@ class MobileMeasurementUploaderTest { private fun HttpRequestData.bodyBytes() = (body as OutgoingContent.ByteArrayContent).bytes() - /** Mirrors LocalUploadOutbox: oldest first, at most [limit]; acknowledge deletes. */ - private class FakeUploadOutbox : UploadOutbox { - val rows = mutableListOf() - override suspend fun pending(sessionUuid: String, limit: Int) = rows.sortedBy { it.time }.take(limit) - override suspend fun acknowledge(measurements: List) { rows -= measurements.toSet() } - } - - private class FakeStreamRepository(private val streams: List) : MeasurementStreamRepository { - override suspend fun findBySession(sessionUuid: String) = streams.filter { it.sessionUuid == sessionUuid } - override suspend fun findOrCreate(sessionUuid: String, sensorName: String, unitSymbol: String, sensorTypeId: Int?): Long = error("unused") - override fun observeBySession(sessionUuid: String): Flow> = error("unused") - override suspend fun setSensorTypeId(streamId: Long, sensorTypeId: Int) = error("unused") - } - private companion object { const val UUID = "8d7a3f2e-0000-4000-8000-000000000001" val T0 = Instant.parse("2024-01-01T00:00:00Z") diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt new file mode 100644 index 0000000..2d4f9fd --- /dev/null +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt @@ -0,0 +1,213 @@ +package pl.llp.aircasting.data.network.upload + +import com.russhwolf.settings.MapSettings +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.plugins.ServerResponseException +import io.ktor.client.request.HttpRequestData +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.TextContent +import io.ktor.http.headersOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import pl.llp.aircasting.data.auth.StoredAuthSession +import pl.llp.aircasting.data.network.MobileSessionsApi +import pl.llp.aircasting.data.network.createAircastingHttpClient +import pl.llp.aircasting.domain.GeoLocation +import pl.llp.aircasting.domain.session.Measurement +import pl.llp.aircasting.domain.session.MeasurementStream +import pl.llp.aircasting.domain.session.Session +import pl.llp.aircasting.domain.session.SessionRepository +import pl.llp.aircasting.domain.session.SessionStatus +import pl.llp.aircasting.domain.session.SessionType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +class MobileSessionSyncTest { + + private val requests = mutableListOf() + private var createStatus = HttpStatusCode.Created + private val outbox = FakeUploadOutbox() + private val sessions = FakeSessionRepository(SESSION) + private val streams = FakeStreamRepository( + listOf( + MeasurementStream( + id = 1, + sessionUuid = UUID, + sensorName = "AirBeamMini-PM2.5", + unitSymbol = "µg/m³" + ), + MeasurementStream( + id = 2, + sessionUuid = UUID, + sensorName = "AirBeamMini-PM1", + unitSymbol = "µg/m³" + ), + ) + ) + + @Test + fun creates_the_session_with_every_stream_and_writes_back_sensor_type_ids() = runTest { + sync().sync(UUID) + + val body = Json.parseToJsonElement(requests.single { it.url.encodedPath == CREATE_PATH }.bodyText()).jsonObject + assertNull(body["session"]) // flat body, no wrapper + assertEquals(UUID, body["uuid"]?.jsonPrimitive?.content) + assertEquals("Morning ride", body["title"]?.jsonPrimitive?.content) + assertEquals("Europe/Warsaw", body["time_zone"]?.jsonPrimitive?.content) + assertEquals(true, body["contribute"]?.jsonPrimitive?.boolean) + assertEquals("bike commute", body["tag_list"]?.jsonPrimitive?.content) + assertEquals("AA:BB:CC:DD:EE:FF", body["device"]?.jsonObject?.get("mac_address")?.jsonPrimitive?.content) + assertEquals("AirBeamMini", body["device"]?.jsonObject?.get("model")?.jsonPrimitive?.content) + assertEquals( + listOf("AirBeamMini-PM2.5", "AirBeamMini-PM1"), + body["streams"]?.jsonArray?.map { it.jsonObject["sensor_name"]?.jsonPrimitive?.content }, + ) + assertEquals(listOf(2, 1), streams.streams.map { it.sensorTypeId }) + } + + @Test + fun pending_measurements_go_out_with_the_ids_the_create_just_assigned() = runTest { + outbox.rows += Measurement(streamId = 1, time = START, value = 12.5, location = LOCATION) + + sync().sync(UUID) + + assertTrue(requests.any { it.url.encodedPath == "/api/v3/mobile_sessions/$UUID/measurements" }) + assertTrue(outbox.rows.isEmpty()) + } + + @Test + fun session_already_on_the_server_is_not_created_again() = runTest { + streams.setSensorTypeId(streamId = 1, sensorTypeId = 2) + streams.setSensorTypeId(streamId = 2, sensorTypeId = 1) + + sync().sync(UUID) + + assertTrue(requests.none { it.url.encodedPath == CREATE_PATH }) + } + + @Test + fun uuid_taken_reads_the_ids_back_from_the_existing_session() = runTest { + createStatus = HttpStatusCode.Conflict + + sync().sync(UUID) + + assertEquals(listOf(2, 1), streams.streams.map { it.sensorTypeId }) + } + + @Test + fun stopped_session_sends_its_end_time_to_finish_exactly_once() = runTest { + sessions.session = SESSION.copy(endTime = END) + val sync = sync() + + sync.sync(UUID) + sync.sync(UUID) + + val finish = requests.single { it.url.encodedPath == FINISH_PATH } + val body = Json.parseToJsonElement(finish.bodyText()).jsonObject + assertEquals(END.toEpochMilliseconds(), body["finished_at"]?.jsonPrimitive?.long) + } + + @Test + fun session_still_recording_is_not_finished() = runTest { + sync().sync(UUID) + + assertTrue(requests.none { it.url.encodedPath == FINISH_PATH }) + } + @Test + fun failed_create_throws_and_neither_uploads_nor_finishes() = runTest { + createStatus = HttpStatusCode.InternalServerError + sessions.session = SESSION.copy(endTime = END) + outbox.rows += Measurement(streamId = 1, time = START, value = 12.5, location = LOCATION) + + assertFailsWith { sync().sync(UUID) } + + assertEquals(listOf(CREATE_PATH), requests.map { it.url.encodedPath }) + assertEquals(1, outbox.rows.size) + } + + @Test + fun signed_out_sends_nothing() = runTest { + sync(token = null).sync(UUID) + + assertTrue(requests.isEmpty()) + } + private fun sync(token: String? = "tok-123"): MobileSessionSync { + val engine = MockEngine { request -> + requests += request + when (request.url.encodedPath) { + CREATE_PATH -> + if (createStatus == HttpStatusCode.Created) respond(CREATED_JSON, createStatus, JSON_HEADERS) + else respondError(createStatus) + "/api/v3/mobile_sessions/$UUID" -> respond(SHOW_JSON, HttpStatusCode.OK, JSON_HEADERS) + else -> respond("") // /measurements and /finish: only the status matters + } + } + val api = MobileSessionsApi(createAircastingHttpClient(engine)) + val auth = StoredAuthSession(MapSettings()).apply { token?.let(::start) } + return MobileSessionSync( + api = api, + sessions = sessions, + streams = streams, + uploader = MobileMeasurementUploader(api, outbox, streams, auth), + auth = auth, + ) + } + private fun HttpRequestData.bodyText() = (body as TextContent).text + + private class FakeSessionRepository(var session: Session) : SessionRepository { + override suspend fun find(uuid: String) = session.takeIf { it.uuid == uuid } + override suspend fun markFinishSynced(uuid: String) { session = session.copy(finishSynced = true) } + override suspend fun insert(session: Session) = error("unused") + override fun observe(uuid: String): Flow = error("unused") + override fun observeAll(): Flow> = error("unused") + override suspend fun updateStatus(uuid: String, status: SessionStatus) = error("unused") + override suspend fun finish(uuid: String, endTime: Instant) = error("unused") + override suspend fun delete(uuid: String) = error("unused") + } + private companion object { + const val UUID = "8d7a3f2e-0000-4000-8000-000000000001" + const val CREATE_PATH = "/api/v3/mobile_sessions" + const val FINISH_PATH = "/api/v3/mobile_sessions/$UUID/finish" + val START = Instant.parse("2026-10-19T08:00:00Z") + val END = Instant.parse("2026-10-19T08:30:00Z") + val LOCATION = GeoLocation(latitude = 52.23, longitude = 21.01) + val SESSION = Session( + uuid = UUID, + type = SessionType.MOBILE, + status = SessionStatus.RECORDING, + name = "Morning ride", + tags = listOf("bike", "commute"), + startTime = START, + timeZone = "Europe/Warsaw", + measurementInterval = 1.seconds, + deviceId = "AA:BB:CC:DD:EE:FF", + deviceModel = "AirBeamMini", + ) + val JSON_HEADERS = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + val CREATED_JSON = """ + {"share_url": "https://aircasting.org/s/ab12c", + "streams": [{"sensor_name": "AirBeamMini-PM2.5", "sensor_type_id": 2}, + {"sensor_name": "AirBeamMini-PM1", "sensor_type_id": 1}]} + """.trimIndent() + val SHOW_JSON = """ + {"uuid": "$UUID", "title": "Morning ride", "time_zone": "Europe/Warsaw", + "streams": {"AirBeamMini-PM2.5": {"sensor_name": "AirBeamMini-PM2.5", "sensor_type_id": 2, "measurements_count": 0}, + "AirBeamMini-PM1": {"sensor_name": "AirBeamMini-PM1", "sensor_type_id": 1, "measurements_count": 0}}} + """.trimIndent() + } +} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/UploadFakes.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/UploadFakes.kt new file mode 100644 index 0000000..ed3d2a2 --- /dev/null +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/UploadFakes.kt @@ -0,0 +1,25 @@ +package pl.llp.aircasting.data.network.upload + +import kotlinx.coroutines.flow.Flow +import pl.llp.aircasting.domain.session.Measurement +import pl.llp.aircasting.domain.session.MeasurementStream +import pl.llp.aircasting.domain.session.MeasurementStreamRepository +import pl.llp.aircasting.domain.session.UploadOutbox + +/** Mirrors LocalUploadOutbox: oldest first, at most [limit]; acknowledge deletes. */ +internal class FakeUploadOutbox : UploadOutbox { + val rows = mutableListOf() + override suspend fun pending(sessionUuid: String, limit: Int) = rows.sortedBy { it.time }.take(limit) + override suspend fun acknowledge(measurements: List) { rows -= measurements.toSet() } +} + +internal class FakeStreamRepository(streams: List) : MeasurementStreamRepository { + val streams = streams.toMutableList() + override suspend fun findBySession(sessionUuid: String) = streams.filter { it.sessionUuid == sessionUuid } + override suspend fun setSensorTypeId(streamId: Long, sensorTypeId: Int) { + val index = streams.indexOfFirst { it.id == streamId } + streams[index] = streams[index].copy(sensorTypeId = sensorTypeId) + } + override suspend fun findOrCreate(sessionUuid: String, sensorName: String, unitSymbol: String, sensorTypeId: Int?): Long = error("unused") + override fun observeBySession(sessionUuid: String): Flow> = error("unused") +} \ No newline at end of file From 6e021d877b7d23d4be557beea22959fc16676072 Mon Sep 17 00:00:00 2001 From: kgetman Date: Mon, 19 Oct 2026 13:43:18 +0200 Subject: [PATCH 09/11] refactor(session): name boolean fields as questions finishSynced -> isFinishedOnServer, contribute -> isContributingToCrowdMap (domain, DB columns, DTOs; wire key stays "contribute" via @SerialName). No DB migration: schema is unreleased. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../aircasting/data/local/LocalSessionRepository.kt | 10 +++++----- .../kotlin/pl/llp/aircasting/data/network/Dto.kt | 6 +++--- .../data/network/upload/MobileSessionSync.kt | 6 +++--- .../kotlin/pl/llp/aircasting/domain/session/Session.kt | 4 ++-- .../aircasting/domain/session/SessionRepositories.kt | 2 +- .../pl/llp/aircasting/data/local/db/Session.sq | 10 +++++----- .../data/network/upload/MobileSessionSyncTest.kt | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/local/LocalSessionRepository.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/local/LocalSessionRepository.kt index c142638..d27abd6 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/local/LocalSessionRepository.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/local/LocalSessionRepository.kt @@ -37,7 +37,7 @@ class LocalSessionRepository( latitude = session.location?.latitude, longitude = session.location?.longitude, is_indoor = session.isIndoor, - contribute = session.contribute, + is_contributing_to_crowd_map = session.isContributingToCrowdMap, time_zone = session.timeZone, measurement_interval_millis = session.measurementInterval.inWholeMilliseconds, session_token = session.sessionToken, @@ -66,8 +66,8 @@ class LocalSessionRepository( queries.finishSession(status = SessionStatus.FINISHED, endTime = endTime, uuid = uuid) } - override suspend fun markFinishSynced(uuid: String): Unit = - withContext(ioDispatcher) { queries.markFinishSynced(uuid) } + override suspend fun markFinishedOnServer(uuid: String): Unit = + withContext(ioDispatcher) { queries.markFinishedOnServer(uuid) } override suspend fun delete(uuid: String): Unit = withContext(ioDispatcher) { queries.deleteSession(uuid) } @@ -87,7 +87,7 @@ private fun Sessions.toDomain() = Session( deviceModel = device_model, location = latitude?.let { lat -> longitude?.let { lon -> GeoLocation(lat, lon) } }, isIndoor = is_indoor, - contribute = contribute, + isContributingToCrowdMap = is_contributing_to_crowd_map, sessionToken = session_token, - finishSynced = finish_synced, + isFinishedOnServer = is_finished_on_server, ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt index bbbdc09..da9f329 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt @@ -35,7 +35,7 @@ data class MobileSessionSummaryDto( val title: String, val type: String = "MobileSession", @SerialName("tag_list") val tagList: String? = null, - val contribute: Boolean = true, + @SerialName("contribute") val isContributingToCrowdMap: Boolean = true, @SerialName("time_zone") val timeZone: String, @SerialName("start_time") val startTime: Long? = null, @SerialName("end_time") val endTime: Long? = null, @@ -51,7 +51,7 @@ data class CreateMobileSessionRequestDto( val uuid: String, val title: String, @SerialName("time_zone") val timeZone: String, - val contribute: Boolean, + @SerialName("contribute") val isContributingToCrowdMap: Boolean, @SerialName("tag_list") val tagList: String, val device: DeviceDto, val streams: List, @@ -87,7 +87,7 @@ data class CreateStreamPayloadDto( data class UpdateMobileSessionRequestDto( val title: String? = null, @SerialName("tag_list") val tagList: String? = null, - val contribute: Boolean? = null, + @SerialName("contribute") val isContributingToCrowdMap: Boolean? = null, ) @Serializable data class NoteDto( diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt index 6644417..2b32e7d 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt @@ -44,9 +44,9 @@ class MobileSessionSync( uploader.upload(sessionUuid) val endTime = session.endTime - if (endTime != null && !session.finishSynced) { + if (endTime != null && !session.isFinishedOnServer) { api.finishSession(token, sessionUuid, endTime) - sessions.markFinishSynced(sessionUuid) + sessions.markFinishedOnServer(sessionUuid) } } /** @return sensor_type_id by sensor_name. */ @@ -70,7 +70,7 @@ private fun Session.toCreateRequest(streams: List) = CreateMo uuid = uuid, title = name, timeZone = timeZone, - contribute = contribute, + isContributingToCrowdMap = isContributingToCrowdMap, tagList = tags.joinToString(" "), device = DeviceDto( macAddress = requireNotNull(deviceId) { "$uuid: mobile session without a device id" }, diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/Session.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/Session.kt index d37eb9e..2e7ab69 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/Session.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/Session.kt @@ -25,9 +25,9 @@ data class Session( /** Per-session fix for fixed sessions; mobile carries location per measurement instead. */ val location: GeoLocation? = null, val isIndoor: Boolean = false, - val contribute: Boolean = true, + val isContributingToCrowdMap: Boolean = true, /** v3 fixed only: the Bearer token the AirBeam itself uses to upload. Not the user token. */ val sessionToken: String? = null, /** Mobile only: the server has our `end_time` as `finished_at`. */ - val finishSynced: Boolean = false, + val isFinishedOnServer: Boolean = false, ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/SessionRepositories.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/SessionRepositories.kt index 6f15149..802dbf1 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/SessionRepositories.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/domain/session/SessionRepositories.kt @@ -6,7 +6,7 @@ import kotlin.time.Instant interface SessionRepository { suspend fun insert(session: Session) suspend fun find(uuid: String): Session? - suspend fun markFinishSynced(uuid: String) + suspend fun markFinishedOnServer(uuid: String) fun observe(uuid: String): Flow fun observeAll(): Flow> suspend fun updateStatus(uuid: String, status: SessionStatus) diff --git a/shared/src/commonMain/sqldelight/pl/llp/aircasting/data/local/db/Session.sq b/shared/src/commonMain/sqldelight/pl/llp/aircasting/data/local/db/Session.sq index a722434..64fc7b0 100644 --- a/shared/src/commonMain/sqldelight/pl/llp/aircasting/data/local/db/Session.sq +++ b/shared/src/commonMain/sqldelight/pl/llp/aircasting/data/local/db/Session.sq @@ -18,18 +18,18 @@ CREATE TABLE sessions ( latitude REAL, longitude REAL, is_indoor INTEGER AS Boolean NOT NULL DEFAULT 0, - contribute INTEGER AS Boolean NOT NULL DEFAULT 1, + is_contributing_to_crowd_map INTEGER AS Boolean NOT NULL DEFAULT 1, time_zone TEXT NOT NULL, measurement_interval_millis INTEGER NOT NULL, session_token TEXT, - finish_synced INTEGER AS Boolean NOT NULL DEFAULT 0 + is_finished_on_server INTEGER AS Boolean NOT NULL DEFAULT 0 ); insertSession: INSERT INTO sessions( uuid, type, status, name, tags, device_id, device_model, start_time, end_time, latitude, longitude, - is_indoor, contribute, time_zone, measurement_interval_millis, session_token + is_indoor, is_contributing_to_crowd_map, time_zone, measurement_interval_millis, session_token ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); selectSessionByUuid: @@ -52,9 +52,9 @@ UPDATE sessions SET status = :status, end_time = :endTime WHERE uuid = :uuid; -markFinishSynced: +markFinishedOnServer: UPDATE sessions -SET finish_synced = 1 +SET is_finished_on_server = 1 WHERE uuid = :uuid; deleteSession: diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt index 2d4f9fd..47c505d 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt @@ -171,7 +171,7 @@ class MobileSessionSyncTest { private class FakeSessionRepository(var session: Session) : SessionRepository { override suspend fun find(uuid: String) = session.takeIf { it.uuid == uuid } - override suspend fun markFinishSynced(uuid: String) { session = session.copy(finishSynced = true) } + override suspend fun markFinishedOnServer(uuid: String) { session = session.copy(isFinishedOnServer = true) } override suspend fun insert(session: Session) = error("unused") override fun observe(uuid: String): Flow = error("unused") override fun observeAll(): Flow> = error("unused") From fc3bab25b5581be0f768ee5b52ac58885621a7be Mon Sep 17 00:00:00 2001 From: kgetman Date: Mon, 19 Oct 2026 14:28:45 +0200 Subject: [PATCH 10/11] refactor: name remaining boolean fields as questions AppPreferences: is{CrowdMap,LocationTracking,PushNotifications}Enabled, isDarkMode, isWifiOnlySync. AuthFormState: isPasswordVisible, isSubmitting. CustomServerForm: hasHostError/hasPortError; Testing.isTakingTooLong. AirBeamDevice.canReportOwnState, MicrophonePermission.isGranted, Deletion.AwaitingCode.isRejected. Storage keys unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../mic/MicrophonePermission.android.kt | 4 ++-- .../kotlin/pl/llp/aircasting/App.kt | 2 +- .../kotlin/pl/llp/aircasting/auth/AuthForm.kt | 4 ++-- .../pl/llp/aircasting/auth/AuthFormState.kt | 6 +++--- .../pl/llp/aircasting/auth/AuthViewModel.kt | 8 ++++---- .../pl/llp/aircasting/bluetooth/AirBeam.kt | 10 +++++----- .../account/AccountDeletionDialogs.kt | 6 +++--- .../settings/account/AccountScreenState.kt | 2 +- .../settings/account/AccountViewModel.kt | 2 +- .../settings/account/SettingsAccountScreen.kt | 2 +- .../aircasting/settings/app/AppPreferences.kt | 10 +++++----- .../settings/app/AppSettingsRepository.kt | 20 +++++++++---------- .../settings/app/AppSettingsRows.kt | 10 +++++----- .../settings/app/AppSettingsViewModel.kt | 12 +++++------ .../settings/mic/MicCalibrationSheet.kt | 8 ++++---- .../settings/mic/MicrophonePermission.kt | 4 ++-- .../settings/server/CustomDataServerScreen.kt | 2 +- .../settings/server/CustomDataServerSteps.kt | 4 ++-- .../server/CustomDataServerViewModel.kt | 4 ++-- .../settings/server/CustomServerForm.kt | 6 +++--- .../llp/aircasting/auth/AuthViewModelTest.kt | 8 ++++---- .../settings/account/AccountViewModelTest.kt | 4 ++-- .../settings/app/AppSettingsRowsTest.kt | 10 +++++----- .../settings/app/AppSettingsViewModelTest.kt | 10 +++++----- .../app/StoredAppSettingsRepositoryTest.kt | 20 +++++++++---------- .../server/CustomDataServerViewModelTest.kt | 2 +- .../settings/server/CustomServerFormTest.kt | 18 ++++++++--------- .../settings/mic/MicrophonePermission.ios.kt | 2 +- 28 files changed, 100 insertions(+), 100 deletions(-) diff --git a/shared/src/androidMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.android.kt b/shared/src/androidMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.android.kt index 98217f0..e04ec34 100644 --- a/shared/src/androidMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.android.kt +++ b/shared/src/androidMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.android.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.platform.LocalContext @Composable actual fun rememberMicrophonePermission(): MicrophonePermission { val context = LocalContext.current - // The MutableState (not its value) is captured, so `granted` re-reads it on every recomposition + // The MutableState (not its value) is captured, so `isGranted` re-reads it on every recomposition // and the object survives the grant flipping. val state = remember { mutableStateOf(context.hasRecordAudioPermission()) } val launcher = rememberLauncherForActivityResult( @@ -19,7 +19,7 @@ actual fun rememberMicrophonePermission(): MicrophonePermission { return remember(launcher) { object : MicrophonePermission { - override val granted get() = state.value + override val isGranted get() = state.value override fun request() = launcher.launch(Manifest.permission.RECORD_AUDIO) } } diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/App.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/App.kt index 1d035fa..a0ccb33 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/App.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/App.kt @@ -25,7 +25,7 @@ fun App(onRequestLocation: () -> Unit = {}) { currentLanguageTag = preferences.language ?: Locale.current.toLanguageTag(), ) ProvideStrings(lyricist, LocalStrings) { - AircastingTheme(darkTheme = preferences.darkMode ?: isSystemInDarkTheme()) { + AircastingTheme(darkTheme = preferences.isDarkMode ?: isSystemInDarkTheme()) { SplashGate { AppNavHost(onRequestLocation) } diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthForm.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthForm.kt index 5571897..6fe13db 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthForm.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthForm.kt @@ -75,7 +75,7 @@ fun AuthForm(state: AuthFormState, actions: AuthFormActions, modifier: Modifier AuthMode.SignIn -> strings.authPasswordLabel AuthMode.SignUp -> strings.authNewPasswordLabel }, - revealed = state.passwordVisible, + revealed = state.isPasswordVisible, onToggleReveal = actions.onTogglePassword, onSubmit = actions.onSubmit, error = state.passwordError, @@ -150,7 +150,7 @@ private fun SubmitButton(state: AuthFormState, onClick: () -> Unit) { contentColor = MaterialTheme.colorScheme.onPrimaryContainer, ), ) { - if (state.submitting) { + if (state.isSubmitting) { CircularProgressIndicator( modifier = Modifier.size(20.dp), strokeWidth = 2.dp, diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthFormState.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthFormState.kt index 0a63024..b37dab9 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthFormState.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthFormState.kt @@ -8,12 +8,12 @@ data class AuthFormState( val email: String = "", val username: String = "", val password: String = "", - val passwordVisible: Boolean = false, - val submitting: Boolean = false, + val isPasswordVisible: Boolean = false, + val isSubmitting: Boolean = false, val failure: AuthFailure? = null, ) { val canSubmit: Boolean - get() = !submitting && when (mode) { + get() = !isSubmitting && when (mode) { AuthMode.SignIn -> login.isNotBlank() && password.isNotBlank() AuthMode.SignUp -> email.isNotBlank() && username.isNotBlank() && password.isNotBlank() } diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthViewModel.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthViewModel.kt index df8a100..05f64b0 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthViewModel.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/auth/AuthViewModel.kt @@ -24,12 +24,12 @@ class AuthViewModel(private val repository: AuthRepository) : ViewModel() { fun onUsernameChange(value: String) = edit { it.copy(username = value) } fun onPasswordChange(value: String) = edit { it.copy(password = value) } - fun togglePasswordVisibility() = _form.update { it.copy(passwordVisible = !it.passwordVisible) } + fun togglePasswordVisibility() = _form.update { it.copy(isPasswordVisible = !it.isPasswordVisible) } fun submit() { val form = _form.value - if (!form.canSubmit) return // the double-tap guard; `submitting` feeds canSubmit - _form.value = form.copy(submitting = true, failure = null) + if (!form.canSubmit) return // the double-tap guard; `isSubmitting` feeds canSubmit + _form.value = form.copy(isSubmitting = true, failure = null) viewModelScope.launch { val result = when (form.mode) { AuthMode.SignIn -> repository.signIn(form.login.trim(), form.password) @@ -37,7 +37,7 @@ class AuthViewModel(private val repository: AuthRepository) : ViewModel() { repository.signUp(form.email.trim(), form.username.trim(), form.password) } result.onFailure { log.w(it) { "${form.mode} failed" } } - _form.update { it.copy(submitting = false, failure = result.exceptionOrNull()?.asFailure()) } + _form.update { it.copy(isSubmitting = false, failure = result.exceptionOrNull()?.asFailure()) } } } diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeam.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeam.kt index 2f15351..a8ec867 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeam.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/bluetooth/AirBeam.kt @@ -8,26 +8,26 @@ enum class Transport { BLE, CLASSIC_SERIAL } sealed interface AirBeamDevice { val transport: Transport - val reportsOwnState: Boolean + val canReportOwnState: Boolean data object AirBeam2 : AirBeamDevice { override val transport = Transport.CLASSIC_SERIAL - override val reportsOwnState = false + override val canReportOwnState = false } data object AirBeam3 : AirBeamDevice { override val transport = Transport.BLE - override val reportsOwnState = false + override val canReportOwnState = false } sealed interface Mini : AirBeamDevice { override val transport: Transport get() = Transport.BLE data object V1 : Mini { - override val reportsOwnState = false + override val canReportOwnState = false } data object V2 : Mini { - override val reportsOwnState = true + override val canReportOwnState = true } } } diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountDeletionDialogs.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountDeletionDialogs.kt index 1f61438..7f1312f 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountDeletionDialogs.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountDeletionDialogs.kt @@ -42,7 +42,7 @@ internal fun DeleteAccountConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> @Composable internal fun DeleteAccountCodeDialog( email: String, - rejected: Boolean, + isRejected: Boolean, onSubmit: (String) -> Unit, onResend: () -> Unit, onDismiss: () -> Unit, @@ -63,8 +63,8 @@ internal fun DeleteAccountCodeDialog( modifier = Modifier.fillMaxWidth(), label = { Text(strings.deleteAccountCodeLabel) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - isError = rejected, - supportingText = if (rejected) { + isError = isRejected, + supportingText = if (isRejected) { { Text(strings.deleteAccountCodeInvalid, color = MaterialTheme.colorScheme.error) } } else null, singleLine = true, diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountScreenState.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountScreenState.kt index cdb9737..04cc0ff 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountScreenState.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountScreenState.kt @@ -12,5 +12,5 @@ sealed interface AccountScreenState { sealed interface Deletion { data object None : Deletion data object Confirming : Deletion - data class AwaitingCode(val rejected: Boolean = false) : Deletion + data class AwaitingCode(val isRejected: Boolean = false) : Deletion } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountViewModel.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountViewModel.kt index 63a85a3..c30fa54 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountViewModel.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/AccountViewModel.kt @@ -62,7 +62,7 @@ class AccountViewModel(private val repository: AccountRepository) : ViewModel() .onSuccess { _sessionEnded.emit(Unit) } .onFailure { log.e(it) { "deletion confirmation rejected" } - updateDeletion { Deletion.AwaitingCode(rejected = true) } + updateDeletion { Deletion.AwaitingCode(isRejected = true) } } } diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/SettingsAccountScreen.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/SettingsAccountScreen.kt index e1c1ccb..8ce38db 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/SettingsAccountScreen.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/account/SettingsAccountScreen.kt @@ -87,7 +87,7 @@ fun SettingsAccountScreen( ) is Deletion.AwaitingCode -> DeleteAccountCodeDialog( email = content.profile.email, - rejected = deletion.rejected, + isRejected = deletion.isRejected, onSubmit = onDeletionCodeSubmit, onResend = onDeletionConfirmed, onDismiss = onDeletionDismissed, diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppPreferences.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppPreferences.kt index 69f242e..873b91e 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppPreferences.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppPreferences.kt @@ -4,12 +4,12 @@ import cafe.adriel.lyricist.LanguageTag import pl.llp.aircasting.settings.mic.DefaultCalibrationOffset data class AppPreferences( - val crowdMapEnabled: Boolean = true, - val locationTrackingEnabled: Boolean = true, + val isCrowdMapEnabled: Boolean = true, + val isLocationTrackingEnabled: Boolean = true, /** `null` = follow the system theme */ - val darkMode: Boolean? = null, - val pushNotificationsEnabled: Boolean = true, - val wifiOnlySync: Boolean = false, + val isDarkMode: Boolean? = null, + val isPushNotificationsEnabled: Boolean = true, + val isWifiOnlySync: Boolean = false, val temperatureUnit: TemperatureUnit = TemperatureUnit.Fahrenheit, val regionalFormat: RegionalFormat = RegionalFormat.US, val mapType: MapType = MapType.Default, diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsRepository.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsRepository.kt index 0da56af..5d04f2a 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsRepository.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsRepository.kt @@ -42,11 +42,11 @@ private fun Settings.readPreferences() = AppPreferences( microphoneCalibrationOffset = getInt(KeyMicCalibrationOffset, Defaults.microphoneCalibrationOffset), language = getStringOrNull(KeyLanguage), - crowdMapEnabled = getBoolean(KeyCrowdMap, Defaults.crowdMapEnabled), - locationTrackingEnabled = getBoolean(KeyLocationTracking, Defaults.locationTrackingEnabled), - darkMode = getBooleanOrNull(KeyDarkMode), - pushNotificationsEnabled = getBoolean(KeyPushNotifications, Defaults.pushNotificationsEnabled), - wifiOnlySync = getBoolean(KeyWifiOnlySync, Defaults.wifiOnlySync), + isCrowdMapEnabled = getBoolean(KeyCrowdMap, Defaults.isCrowdMapEnabled), + isLocationTrackingEnabled = getBoolean(KeyLocationTracking, Defaults.isLocationTrackingEnabled), + isDarkMode = getBooleanOrNull(KeyDarkMode), + isPushNotificationsEnabled = getBoolean(KeyPushNotifications, Defaults.isPushNotificationsEnabled), + isWifiOnlySync = getBoolean(KeyWifiOnlySync, Defaults.isWifiOnlySync), temperatureUnit = getEnum(KeyTemperatureUnit, Defaults.temperatureUnit), regionalFormat = getEnum(KeyRegionalFormat, Defaults.regionalFormat), mapType = getEnum(KeyMapType, Defaults.mapType), @@ -56,12 +56,12 @@ private fun Settings.writePreferences(prefs: AppPreferences) { prefs.dataServerUrl?.let { putString(KeyDataServerUrl, it) } ?: remove(KeyDataServerUrl) putInt(KeyMicCalibrationOffset, prefs.microphoneCalibrationOffset) prefs.language?.let { putString(KeyLanguage, it) } ?: remove(KeyLanguage) - putBoolean(KeyCrowdMap, prefs.crowdMapEnabled) - putBoolean(KeyLocationTracking, prefs.locationTrackingEnabled) + putBoolean(KeyCrowdMap, prefs.isCrowdMapEnabled) + putBoolean(KeyLocationTracking, prefs.isLocationTrackingEnabled) // Absent, not false: "not chosen yet" has to stay distinguishable from "chosen: light". - prefs.darkMode?.let { putBoolean(KeyDarkMode, it) } ?: remove(KeyDarkMode) - putBoolean(KeyPushNotifications, prefs.pushNotificationsEnabled) - putBoolean(KeyWifiOnlySync, prefs.wifiOnlySync) + prefs.isDarkMode?.let { putBoolean(KeyDarkMode, it) } ?: remove(KeyDarkMode) + putBoolean(KeyPushNotifications, prefs.isPushNotificationsEnabled) + putBoolean(KeyWifiOnlySync, prefs.isWifiOnlySync) putString(KeyTemperatureUnit, prefs.temperatureUnit.name) putString(KeyRegionalFormat, prefs.regionalFormat.name) putString(KeyMapType, prefs.mapType.name) diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsRows.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsRows.kt index 80fa700..1a70865 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsRows.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsRows.kt @@ -6,11 +6,11 @@ import pl.llp.aircasting.i18n.Strings fun AppPreferences.toRows(strings: Strings, systemDarkMode: Boolean): List = AppSetting.entries.map { setting -> when (setting) { - AppSetting.CrowdMap -> AppSettingRow.Toggle(setting, crowdMapEnabled) - AppSetting.DisableMapping -> AppSettingRow.Toggle(setting, !locationTrackingEnabled) - AppSetting.DarkMode -> AppSettingRow.Toggle(setting, darkMode ?: systemDarkMode) - AppSetting.PushNotifications -> AppSettingRow.Toggle(setting, pushNotificationsEnabled) - AppSetting.WifiOnlySync -> AppSettingRow.Toggle(setting, wifiOnlySync) + AppSetting.CrowdMap -> AppSettingRow.Toggle(setting, isCrowdMapEnabled) + AppSetting.DisableMapping -> AppSettingRow.Toggle(setting, !isLocationTrackingEnabled) + AppSetting.DarkMode -> AppSettingRow.Toggle(setting, isDarkMode ?: systemDarkMode) + AppSetting.PushNotifications -> AppSettingRow.Toggle(setting, isPushNotificationsEnabled) + AppSetting.WifiOnlySync -> AppSettingRow.Toggle(setting, isWifiOnlySync) AppSetting.TemperatureUnits -> AppSettingRow.Link(setting, strings.display(temperatureUnit)) AppSetting.RegionalFormats -> AppSettingRow.Link(setting, strings.display(regionalFormat)) AppSetting.MapType -> AppSettingRow.Link(setting, strings.display(mapType)) diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsViewModel.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsViewModel.kt index 04e94ff..4f4b9a3 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/app/AppSettingsViewModel.kt @@ -14,13 +14,13 @@ class AppSettingsViewModel(private val repository: AppSettingsRepository) : View fun toggle(setting: AppSetting, enabled: Boolean) = repository.update { prefs -> when (setting) { - AppSetting.CrowdMap -> prefs.copy(crowdMapEnabled = enabled) - // The row is the negative of the pref — see AppPreferences.locationTrackingEnabled. - AppSetting.DisableMapping -> prefs.copy(locationTrackingEnabled = !enabled) + AppSetting.CrowdMap -> prefs.copy(isCrowdMapEnabled = enabled) + // The row is the negative of the pref — see AppPreferences.isLocationTrackingEnabled. + AppSetting.DisableMapping -> prefs.copy(isLocationTrackingEnabled = !enabled) // Writing a Boolean is what pins the theme: from here on the system is ignored. - AppSetting.DarkMode -> prefs.copy(darkMode = enabled) - AppSetting.PushNotifications -> prefs.copy(pushNotificationsEnabled = enabled) - AppSetting.WifiOnlySync -> prefs.copy(wifiOnlySync = enabled) + AppSetting.DarkMode -> prefs.copy(isDarkMode = enabled) + AppSetting.PushNotifications -> prefs.copy(isPushNotificationsEnabled = enabled) + AppSetting.WifiOnlySync -> prefs.copy(isWifiOnlySync = enabled) // Exhaustive and inert: these rows navigate, and a toggle reaching them is a wiring bug // worth a log rather than a silent write. AppSetting.TemperatureUnits, diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/mic/MicCalibrationSheet.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/mic/MicCalibrationSheet.kt index e1f9ce8..1a20787 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/mic/MicCalibrationSheet.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/mic/MicCalibrationSheet.kt @@ -57,12 +57,12 @@ fun MicCalibrationSheet(offset: Int, onDismiss: () -> Unit, onConfirm: (Int) -> val permission = rememberMicrophonePermission() // Asked for on open, not behind a button: the sheet is useless without a reading, and the // system dialog only appears once per install anyway. - LaunchedEffect(Unit) { if (!permission.granted) permission.request() } + LaunchedEffect(Unit) { if (!permission.isGranted) permission.request() } // Recreating the flow when the grant flips restarts capture the moment permission arrives; // `collectAsStateWithLifecycle` tears the old collection (and the AudioRecord) down for us. - val levels: Flow = remember(source, permission.granted) { - if (permission.granted) source.levels() else emptyFlow() + val levels: Flow = remember(source, permission.isGranted) { + if (permission.isGranted) source.levels() else emptyFlow() } val raw by levels.collectAsStateWithLifecycle(initialValue = null) val reading = raw?.let { calibrate(it, draft) } @@ -79,7 +79,7 @@ fun MicCalibrationSheet(offset: Int, onDismiss: () -> Unit, onConfirm: (Int) -> ) { LiveReadingCard(reading) Text( - if (permission.granted) strings.micCalibrationHint else strings.micPermissionNeeded, + if (permission.isGranted) strings.micCalibrationHint else strings.micPermissionNeeded, modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground, diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.kt index 228abea..7fec8a4 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.kt @@ -6,12 +6,12 @@ import androidx.compose.runtime.Stable /** * Read-and-request access to the microphone. * - * `@Stable` because [granted] is backed by snapshot state on both platforms: Compose may skip + * `@Stable` because [isGranted] is backed by snapshot state on both platforms: Compose may skip * recomposition of anything holding this until that state actually changes. */ @Stable interface MicrophonePermission { - val granted: Boolean + val isGranted: Boolean fun request() } diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerScreen.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerScreen.kt index ce37c78..0b9b73b 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerScreen.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerScreen.kt @@ -35,7 +35,7 @@ fun CustomDataServerScreen( CustomServerStep.Intro -> WizardButton(strings.customServerNext, onNext) is CustomServerStep.Form -> WizardButton(strings.customServerNext, onNext, enabled = step.form.baseUrl != null) - is CustomServerStep.Testing -> if (step.tookTooLong) { + is CustomServerStep.Testing -> if (step.isTakingTooLong) { Text( strings.customServerTooLong, modifier = Modifier.clickable(onClick = onCancelTest).padding(16.dp), diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerSteps.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerSteps.kt index 18bc9bc..4d7df67 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerSteps.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerSteps.kt @@ -115,7 +115,7 @@ fun FormStep(form: CustomServerForm, onEdit: (CustomServerForm) -> Unit, onUseOf onValueChange = { onEdit(form.copy(host = it)) }, label = strings.customServerUrlLabel, supporting = strings.customServerUrlHint, - isError = form.hostError, + isError = form.hasHostError, keyboard = KeyboardType.Uri, ) ServerField( @@ -123,7 +123,7 @@ fun FormStep(form: CustomServerForm, onEdit: (CustomServerForm) -> Unit, onUseOf onValueChange = { onEdit(form.copy(port = it)) }, label = strings.customServerPortLabel, supporting = strings.customServerPortHint, - isError = form.portError, + isError = form.hasPortError, keyboard = KeyboardType.Number, ) Text( diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerViewModel.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerViewModel.kt index c05e2ce..4530c91 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerViewModel.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomDataServerViewModel.kt @@ -21,7 +21,7 @@ val TooLongThreshold = 10.seconds sealed interface CustomServerStep { data object Intro : CustomServerStep data class Form(val form: CustomServerForm = CustomServerForm()) : CustomServerStep - data class Testing(val baseUrl: String, val tookTooLong: Boolean = false) : CustomServerStep + data class Testing(val baseUrl: String, val isTakingTooLong: Boolean = false) : CustomServerStep data class Verified(val baseUrl: String) : CustomServerStep data class Failed(val baseUrl: String) : CustomServerStep } @@ -98,7 +98,7 @@ class CustomDataServerViewModel( probeJob = viewModelScope.launch { val nag = launch { delay(TooLongThreshold) - (_step.value as? CustomServerStep.Testing)?.let { _step.value = it.copy(tookTooLong = true) } + (_step.value as? CustomServerStep.Testing)?.let { _step.value = it.copy(isTakingTooLong = true) } } val reachable = probe.reachable(baseUrl) nag.cancel() diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomServerForm.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomServerForm.kt index c914fa4..c1f3b12 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomServerForm.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/settings/server/CustomServerForm.kt @@ -9,13 +9,13 @@ data class CustomServerForm(val host: String = "", val port: String = "") { private val portNumber = port.trim().toIntOrNull() private val portValid = portNumber != null && portNumber in 1..65535 - val hostError: Boolean get() = host.isNotBlank() && match == null - val portError: Boolean get() = port.isNotBlank() && !portValid + val hasHostError: Boolean get() = host.isNotBlank() && match == null + val hasPortError: Boolean get() = port.isNotBlank() && !portValid val baseUrl: String? get() { val m = match ?: return null - if (portError) return null + if (hasPortError) return null val suffix = if (portValid) ":$portNumber" else "" return "${m.groupValues[1]}://${m.groupValues[2]}$suffix" } diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/auth/AuthViewModelTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/auth/AuthViewModelTest.kt index 0f67e17..4f3ded8 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/auth/AuthViewModelTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/auth/AuthViewModelTest.kt @@ -58,7 +58,7 @@ class AuthViewModelTest { testScheduler.advanceUntilIdle() assertEquals(AuthState.SignedIn("tok"), vm.session.value) - assertFalse(vm.form.value.submitting) + assertFalse(vm.form.value.isSubmitting) assertNull(vm.form.value.failure) } @@ -71,7 +71,7 @@ class AuthViewModelTest { vm.submit() testScheduler.advanceUntilIdle() - assertTrue(vm.form.value.submitting) + assertTrue(vm.form.value.isSubmitting) assertFalse(vm.form.value.canSubmit) vm.submit() @@ -79,7 +79,7 @@ class AuthViewModelTest { testScheduler.advanceUntilIdle() assertEquals(1, repo.calls) - assertFalse(vm.form.value.submitting) + assertFalse(vm.form.value.isSubmitting) } @Test @@ -93,7 +93,7 @@ class AuthViewModelTest { assertEquals(AuthFailure.InvalidCredentials, vm.form.value.formError) assertNull(vm.form.value.passwordError) - assertFalse(vm.form.value.submitting) + assertFalse(vm.form.value.isSubmitting) } @Test diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/account/AccountViewModelTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/account/AccountViewModelTest.kt index 6add8ee..633d399 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/account/AccountViewModelTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/account/AccountViewModelTest.kt @@ -127,7 +127,7 @@ class AccountViewModelTest { vm.submitDeletionCode("0000"); testScheduler.advanceUntilIdle() - assertEquals(content(Deletion.AwaitingCode(rejected = true)), vm.state.value) + assertEquals(content(Deletion.AwaitingCode(isRejected = true)), vm.state.value) } @Test @@ -141,7 +141,7 @@ class AccountViewModelTest { repo.failConfirm = false vm.sendDeletionCode(); testScheduler.advanceUntilIdle() - assertEquals(content(Deletion.AwaitingCode(rejected = false)), vm.state.value) + assertEquals(content(Deletion.AwaitingCode(isRejected = false)), vm.state.value) assertEquals(2, repo.deletionRequests) } diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/AppSettingsRowsTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/AppSettingsRowsTest.kt index b6b8af7..9e0a1b8 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/AppSettingsRowsTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/AppSettingsRowsTest.kt @@ -24,9 +24,9 @@ class AppSettingsRowsTest { @Test fun `switch rows read the stored flags`() { val rows = AppPreferences( - crowdMapEnabled = false, - pushNotificationsEnabled = false, - wifiOnlySync = true, + isCrowdMapEnabled = false, + isPushNotificationsEnabled = false, + isWifiOnlySync = true, ).toRows(EnStrings, systemDarkMode = false) .associateBy { it.setting } assertEquals(AppSettingRow.Toggle(AppSetting.CrowdMap, false), rows[AppSetting.CrowdMap]) @@ -40,7 +40,7 @@ class AppSettingsRowsTest { @Test fun `Disable mapping shows the inverse of the stored flag`() { fun checked(tracking: Boolean) = - AppPreferences(locationTrackingEnabled = tracking) + AppPreferences(isLocationTrackingEnabled = tracking) .toRows(EnStrings, systemDarkMode = false) .filterIsInstance() .single { it.setting == AppSetting.DisableMapping } @@ -53,7 +53,7 @@ class AppSettingsRowsTest { @Test fun `dark mode follows the system until the user pins it`() { fun checked(stored: Boolean?, system: Boolean) = - AppPreferences(darkMode = stored) + AppPreferences(isDarkMode = stored) .toRows(EnStrings, systemDarkMode = system) .filterIsInstance() .single { it.setting == AppSetting.DarkMode } diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/AppSettingsViewModelTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/AppSettingsViewModelTest.kt index e30d32c..8af7fd9 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/AppSettingsViewModelTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/AppSettingsViewModelTest.kt @@ -18,9 +18,9 @@ class AppSettingsViewModelTest { assertEquals( AppPreferences( - crowdMapEnabled = false, - pushNotificationsEnabled = false, - wifiOnlySync = true + isCrowdMapEnabled = false, + isPushNotificationsEnabled = false, + isWifiOnlySync = true ), repo.preferences.value, ) @@ -33,7 +33,7 @@ class AppSettingsViewModelTest { vm.toggle(AppSetting.DisableMapping, true) - assertEquals(false, repo.preferences.value.locationTrackingEnabled) + assertEquals(false, repo.preferences.value.isLocationTrackingEnabled) } @Test @@ -45,7 +45,7 @@ class AppSettingsViewModelTest { vm.toggle(AppSetting.DarkMode, false) - assertEquals(false, repo.preferences.value.darkMode) + assertEquals(false, repo.preferences.value.isDarkMode) } @Test diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/StoredAppSettingsRepositoryTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/StoredAppSettingsRepositoryTest.kt index edbec1d..8f2424e 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/StoredAppSettingsRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/app/StoredAppSettingsRepositoryTest.kt @@ -24,11 +24,11 @@ class StoredAppSettingsRepositoryTest { // These are the states drawn in Figma 163:12617, and they are what a new user gets. assertEquals( AppPreferences( - crowdMapEnabled = true, - locationTrackingEnabled = true, - darkMode = null, - pushNotificationsEnabled = true, - wifiOnlySync = false, + isCrowdMapEnabled = true, + isLocationTrackingEnabled = true, + isDarkMode = null, + isPushNotificationsEnabled = true, + isWifiOnlySync = false, temperatureUnit = TemperatureUnit.Fahrenheit, regionalFormat = RegionalFormat.US, mapType = MapType.Default, @@ -40,23 +40,23 @@ class StoredAppSettingsRepositoryTest { fun `writes survive a restart`() { val store = MapSettings() StoredAppSettingsRepository(store).update { - it.copy(wifiOnlySync = true, temperatureUnit = TemperatureUnit.Celsius, darkMode = true) + it.copy(isWifiOnlySync = true, temperatureUnit = TemperatureUnit.Celsius, isDarkMode = true) } val reopened = StoredAppSettingsRepository(store).preferences.value - assertEquals(true, reopened.wifiOnlySync) + assertEquals(true, reopened.isWifiOnlySync) assertEquals(TemperatureUnit.Celsius, reopened.temperatureUnit) - assertEquals(true, reopened.darkMode) + assertEquals(true, reopened.isDarkMode) } @Test fun `an unset dark mode stays unset after other writes`() { // null is persisted as "absent", not as false — otherwise the first unrelated write would // silently pin the theme to light. val store = MapSettings() - StoredAppSettingsRepository(store).update { it.copy(wifiOnlySync = true) } + StoredAppSettingsRepository(store).update { it.copy(isWifiOnlySync = true) } - assertEquals(null, StoredAppSettingsRepository(store).preferences.value.darkMode) + assertEquals(null, StoredAppSettingsRepository(store).preferences.value.isDarkMode) } @Test diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/server/CustomDataServerViewModelTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/server/CustomDataServerViewModelTest.kt index 9cd12a1..f2a7b0b 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/server/CustomDataServerViewModelTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/server/CustomDataServerViewModelTest.kt @@ -89,7 +89,7 @@ class CustomDataServerViewModelTest { assertEquals(CustomServerStep.Testing("https://slow.server"), vm.step.value) advanceTimeBy(2.seconds) - assertEquals(CustomServerStep.Testing("https://slow.server", tookTooLong = true), vm.step.value) + assertEquals(CustomServerStep.Testing("https://slow.server", isTakingTooLong = true), vm.step.value) } @Test fun `cancelling a probe returns to the form with the typed values intact`() = runTest { diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/server/CustomServerFormTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/server/CustomServerFormTest.kt index 01c4096..23d06e4 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/server/CustomServerFormTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/settings/server/CustomServerFormTest.kt @@ -15,14 +15,14 @@ class CustomServerFormTest { // not done anything yet. val form = CustomServerForm() - assertFalse(form.hostError) - assertFalse(form.portError) + assertFalse(form.hasHostError) + assertFalse(form.hasPortError) assertNull(form.baseUrl) } @Test fun `a host without a scheme is rejected`() { - assertTrue(CustomServerForm(host = "aircasting.org").hostError) + assertTrue(CustomServerForm(host = "aircasting.org").hasHostError) assertNull(CustomServerForm(host = "aircasting.org").baseUrl) } @@ -55,17 +55,17 @@ class CustomServerFormTest { @Test fun `a host with a path or a space is rejected`() { - assertTrue(CustomServerForm(host = "https://aircasting.org/api").hostError) - assertTrue(CustomServerForm(host = "https://air casting.org").hostError) + assertTrue(CustomServerForm(host = "https://aircasting.org/api").hasHostError) + assertTrue(CustomServerForm(host = "https://air casting.org").hasHostError) assertNull(CustomServerForm(host = "https://aircasting.org/api").baseUrl) } @Test fun `the port must be a number in 1-65535`() { - assertTrue(CustomServerForm(host = "https://aircasting.org", port = "0").portError) - assertTrue(CustomServerForm(host = "https://aircasting.org", port = "65536").portError) - assertTrue(CustomServerForm(host = "https://aircasting.org", port = "80a").portError) - assertFalse(CustomServerForm(host = "https://aircasting.org", port = "65535").portError) + assertTrue(CustomServerForm(host = "https://aircasting.org", port = "0").hasPortError) + assertTrue(CustomServerForm(host = "https://aircasting.org", port = "65536").hasPortError) + assertTrue(CustomServerForm(host = "https://aircasting.org", port = "80a").hasPortError) + assertFalse(CustomServerForm(host = "https://aircasting.org", port = "65535").hasPortError) } diff --git a/shared/src/iosMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.ios.kt b/shared/src/iosMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.ios.kt index 3a05399..104bdc5 100644 --- a/shared/src/iosMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.ios.kt +++ b/shared/src/iosMain/kotlin/pl/llp/aircasting/settings/mic/MicrophonePermission.ios.kt @@ -17,7 +17,7 @@ actual fun rememberMicrophonePermission(): MicrophonePermission { return remember(session) { object : MicrophonePermission { - override val granted get() = state.value + override val isGranted get() = state.value override fun request() { session.requestRecordPermission { ok -> // The callback lands on an arbitrary queue; snapshot state must be written on main. From 522e2527d55b3152afef9ae10ce3afb7123ddab9 Mon Sep 17 00:00:00 2001 From: kgetman Date: Mon, 19 Oct 2026 16:01:29 +0200 Subject: [PATCH 11/11] refactor(upload): rename MobileSessionSync to MobileSessionUpload; document upload triggers Upload is no longer tied to backend sync: v3 has no sync_with_versioning, and mobile measurements now go up live, driven by the outbox. Docs record the legacy triggers and the new loop/backstop design. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/API_V3.md | 31 ++++++++++++++++++- docs/DOMAIN_KNOWLEDGE.md | 7 +++++ ...eSessionSync.kt => MobileSessionUpload.kt} | 6 ++-- ...SyncTest.kt => MobileSessionUploadTest.kt} | 26 ++++++++-------- 4 files changed, 53 insertions(+), 17 deletions(-) rename shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/{MobileSessionSync.kt => MobileSessionUpload.kt} (96%) rename shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/{MobileSessionSyncTest.kt => MobileSessionUploadTest.kt} (94%) diff --git a/docs/API_V3.md b/docs/API_V3.md index 9211115..fa26bd9 100644 --- a/docs/API_V3.md +++ b/docs/API_V3.md @@ -93,7 +93,7 @@ earlier create landed and its response was lost: read the ids back from `GET ... 2026-10-19.* Body `{ "finished_at": }`; omit it or send `null` and the server stamps its own clock. A `finished_at` **in the future** (by the server's clock) is `400 validation_error` — so a phone clock running ahead can get a `/finish` sent right after stopping rejected. That heals -itself: the next sync pass retries, and by then the server clock has caught up. +itself: the uploader backs off and retries, and by then the server clock has caught up. --- @@ -230,6 +230,35 @@ insert-only, per `DOMAIN_KNOWLEDGE.md` rule #4). The traps above shape how it ma rows in place — they go out again next pass. Resends are deduped server-side on `(stream, timestamp)`. State lives in SQLite, so it survives process death. +### When the uploader runs — *decided 2026-10-19* + +**Upload is not sync.** v3 has no `sync_with_versioning`, so nothing server-side decides what to upload; +the outbox does. Mobile measurements go up **live, during recording** (new — the legacy app uploaded a +session whole, after stop). Reconciliation (server deletes, downloads) is a separate, later feature. + +The trigger is **local data**, not a list of events. Work = a mobile session with `pending_uploads` +rows, or one with `end_time` set and `is_finished_on_server = 0`. One loop in `commonMain` observes both +(two queries) and, per pass, runs `MobileSessionUpload.upload(uuid)` (create once → batches → `/finish` +once) for each such session: + +1. No work → idle (no timer runs). +2. A stopped session not yet finished on the server → drain **now** — no caller has to remember to flush. +3. Only rows of still-recording sessions → wait **N = 60 s**, then drain (≈300 frames at 5 streams × 1 Hz; + radio wakeups, not bytes, drive battery cost). +4. Failure → exponential backoff (e.g. 30 s … ~10 min), reset on success. Heals the future-`finished_at` `400`. + +`flushNow()` exists for one case: the **app going to background** with work pending (lifecycle, not data). + +**Platform backstop**, for when the process may die with work left: +- **Android:** unique one-off WorkManager work, `NetworkType.CONNECTED` + backoff. Replaces the legacy + `ConnectivityReceiver`. No periodic work — one-off work persists until it succeeds. +- **iOS:** no WorkManager equivalent. `beginBackgroundTask` (~30 s) on backgrounding, drain on next + foreground/launch. **Known limit:** after a force-quit, pending uploads wait for the next launch — delayed, + never lost (SQLite). + +While recording, the process is alive on both platforms (Android foreground service, iOS +`bluetooth-central`), so the live path needs no platform scheduler. + ### Error codes All v3 errors share `{ error_code, message, fields? }` (`fields` only when the request shape is wrong). diff --git a/docs/DOMAIN_KNOWLEDGE.md b/docs/DOMAIN_KNOWLEDGE.md index 42a21de..7b54ac8 100644 --- a/docs/DOMAIN_KNOWLEDGE.md +++ b/docs/DOMAIN_KNOWLEDGE.md @@ -211,6 +211,13 @@ transports (BLE, WiFi-SoftAP, file I/O) are **PLATFORM**; HTTP is **CHANGE-LIKEL session versions, backend responds with `delete` / `upload` / `download` partitions; uploads mobile sessions, downloads remote changes. Also: periodic (60s) fixed-session download, one-shot followed-session download. + **Legacy triggers** (no WorkManager, nothing scheduled): dashboard setup on app start + (`MainController.setupDashboard`), connectivity regained (`ConnectivityReceiver`, only while + MainActivity lives), stop recording (`RecordingHandler.stopRecording`), session delete + (`SessionManager.deleteSession`), pull-to-refresh (`DashboardController`). Only non-recording + sessions; a failure waits for the next trigger. + **Rewrite:** mobile upload is split out of sync and runs live, driven by the outbox — see + [`API_V3.md` → When the uploader runs](./API_V3.md#when-the-uploader-runs--decided-2026-10-19). **Dedup differs:** V1 mobile filters `time > lastMeasurementTime`; V2 relies on the DB unique index `(session_id, stream_id, time)` + `IGNORE`. Preserve the dedup — in the rewrite diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionUpload.kt similarity index 96% rename from shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt rename to shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionUpload.kt index 2b32e7d..94390eb 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSync.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionUpload.kt @@ -19,17 +19,17 @@ import pl.llp.aircasting.domain.session.SessionRepository * outbox, then `/finish` (once, after the user stopped). Each step is skipped when already * done, so calling it again after a failure resumes where it stopped. */ -class MobileSessionSync( +class MobileSessionUpload( private val api: MobileSessionsApi, private val sessions: SessionRepository, private val streams: MeasurementStreamRepository, private val uploader: MobileMeasurementUploader, private val auth: AuthSession, ) { - private val log = Logger.withTag("SessionSync") + private val log = Logger.withTag("SessionUpload") /** Throws on the first failed request; whatever was done before it stays done. */ - suspend fun sync(sessionUuid: String) { + suspend fun upload(sessionUuid: String) { val token = auth.state.value.tokenOrNull ?: return val session = sessions.find(sessionUuid) ?: return val local = streams.findBySession(sessionUuid) diff --git a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionUploadTest.kt similarity index 94% rename from shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt rename to shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionUploadTest.kt index 47c505d..93872d8 100644 --- a/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionSyncTest.kt +++ b/shared/src/commonTest/kotlin/pl/llp/aircasting/data/network/upload/MobileSessionUploadTest.kt @@ -37,7 +37,7 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant -class MobileSessionSyncTest { +class MobileSessionUploadTest { private val requests = mutableListOf() private var createStatus = HttpStatusCode.Created @@ -62,7 +62,7 @@ class MobileSessionSyncTest { @Test fun creates_the_session_with_every_stream_and_writes_back_sensor_type_ids() = runTest { - sync().sync(UUID) + upload().upload(UUID) val body = Json.parseToJsonElement(requests.single { it.url.encodedPath == CREATE_PATH }.bodyText()).jsonObject assertNull(body["session"]) // flat body, no wrapper @@ -84,7 +84,7 @@ class MobileSessionSyncTest { fun pending_measurements_go_out_with_the_ids_the_create_just_assigned() = runTest { outbox.rows += Measurement(streamId = 1, time = START, value = 12.5, location = LOCATION) - sync().sync(UUID) + upload().upload(UUID) assertTrue(requests.any { it.url.encodedPath == "/api/v3/mobile_sessions/$UUID/measurements" }) assertTrue(outbox.rows.isEmpty()) @@ -95,7 +95,7 @@ class MobileSessionSyncTest { streams.setSensorTypeId(streamId = 1, sensorTypeId = 2) streams.setSensorTypeId(streamId = 2, sensorTypeId = 1) - sync().sync(UUID) + upload().upload(UUID) assertTrue(requests.none { it.url.encodedPath == CREATE_PATH }) } @@ -104,7 +104,7 @@ class MobileSessionSyncTest { fun uuid_taken_reads_the_ids_back_from_the_existing_session() = runTest { createStatus = HttpStatusCode.Conflict - sync().sync(UUID) + upload().upload(UUID) assertEquals(listOf(2, 1), streams.streams.map { it.sensorTypeId }) } @@ -112,10 +112,10 @@ class MobileSessionSyncTest { @Test fun stopped_session_sends_its_end_time_to_finish_exactly_once() = runTest { sessions.session = SESSION.copy(endTime = END) - val sync = sync() + val upload = upload() - sync.sync(UUID) - sync.sync(UUID) + upload.upload(UUID) + upload.upload(UUID) val finish = requests.single { it.url.encodedPath == FINISH_PATH } val body = Json.parseToJsonElement(finish.bodyText()).jsonObject @@ -124,7 +124,7 @@ class MobileSessionSyncTest { @Test fun session_still_recording_is_not_finished() = runTest { - sync().sync(UUID) + upload().upload(UUID) assertTrue(requests.none { it.url.encodedPath == FINISH_PATH }) } @@ -134,7 +134,7 @@ class MobileSessionSyncTest { sessions.session = SESSION.copy(endTime = END) outbox.rows += Measurement(streamId = 1, time = START, value = 12.5, location = LOCATION) - assertFailsWith { sync().sync(UUID) } + assertFailsWith { upload().upload(UUID) } assertEquals(listOf(CREATE_PATH), requests.map { it.url.encodedPath }) assertEquals(1, outbox.rows.size) @@ -142,11 +142,11 @@ class MobileSessionSyncTest { @Test fun signed_out_sends_nothing() = runTest { - sync(token = null).sync(UUID) + upload(token = null).upload(UUID) assertTrue(requests.isEmpty()) } - private fun sync(token: String? = "tok-123"): MobileSessionSync { + private fun upload(token: String? = "tok-123"): MobileSessionUpload { val engine = MockEngine { request -> requests += request when (request.url.encodedPath) { @@ -159,7 +159,7 @@ class MobileSessionSyncTest { } val api = MobileSessionsApi(createAircastingHttpClient(engine)) val auth = StoredAuthSession(MapSettings()).apply { token?.let(::start) } - return MobileSessionSync( + return MobileSessionUpload( api = api, sessions = sessions, streams = streams,