The `RecordingView` struct now includes `gain_db` to represent
replay-gain. This value is read from the recording's metadata (`.json`
sidecar) and passed to the frontend.
The JavaScript in `case_recordings.html` uses this `data-gain-db`
attribute to apply replay-gain using the Web Audio API. This ensures
consistent playback loudness across different recordings. The
implementation uses a shared `AudioContext` to manage resources
efficiently and handles cases where the browser might not support
`AudioContext`.
Additionally, the audio recording on Wear OS now uses
`MediaRecorder.AudioSource.VOICE_RECOGNITION` instead of `MIC`. This
leverages platform noise and echo cancellation, aiming for a cleaner
signal before server-side gain adjustment.
Introduces `PendingStore.nowRfc3339()` to consistently generate RFC3339
timestamps with second granularity. This avoids potential issues with
sub-second precision in filenames and aligns with common timestamp
formatting practices. The helper function is used in `CaseDetailScreen`
and `RecordingViewModel` to replace direct calls to
`Instant.now().toString()`. A new unit test verifies the expected
behavior of the helper function.
Tap-to-edit on the case detail now does both halves:
1. Optimistic local update (caseStore.setOnelinerLocal) — UI flips to
Manual immediately, no waiting for the network round-trip.
2. PUT /api/cases/{case_id}/oneliner with X-API-Key — fire-and-forget
so an offline edit doesn't block the UI. On HTTP failure we log;
the local Manual sticks and the next manual edit (presumably online)
gets pushed up. Edits before the case has been uploaded server-side
will 404 here — accepted divergence.
OnelinerOverrideClient mirrors the server contract from
server/src/routes/oneliner_override.rs:108-124 exactly: PUT body is
{text: string}, response is OnelinerState (Manual variant). 5 new
MockWebServer tests cover happy path, 400/404/network classification,
and the on-the-wire request shape.
UI: 👤 prefix on doctor-authored OneLiners across all three surfaces
(CaseListScreen row, CaseDetailScreen large text, Tile center). Auto-
generated (Ready) and unset (Empty/Error/null) states render without
the prefix, so the doctor can tell at a glance whether they wrote
this themselves. Verified live on emulator-5556 against existing
server Manual cases ('Kastanienbaum', 'eigener Bezeichner').
78 tests green total.
Without this, the watch would only run a poll cycle if the pending
queue happened to be non-empty — meaning the case list was empty
forever for users who didn't dictate before opening the app. Kicking
on every launch makes the foreground service start, primes the case
store from the snapshot cache, then polls /api/oneliners.
Verified end-to-end on Wear OS 36 emulator-5556 against a live local
server:
- 4 server cases populate the list within ~150 ms of app start
- Tap Neu → mic permission → record → stop completes in <3 s of UI
- Sync service uploads, server transcribes (whisper) + classifies
(ollama), watch's burst-poll hits within 2 s of upload-success and
merges the OnelinerState.Empty (silence rule) back into the marker
- unsynced/ ends empty (deletePair after ACK), cases/ has 5 markers
with synced_to_server=true, snapshot_cache.json on disk
After a successful upload the worker self-kicks a BurstPoll(caseId, now+60s)
into its own kick channel. While a burst is active, currentPollIntervalMs()
returns 2 s instead of the 30 s default, giving the doctor a chance to see
the LLM-generated oneliner before they swipe away from the case.
Burst exits on either of:
- the response carries a non-null oneliner for the named case
- the 60 s budget elapses
Implemented as worker-internal state — no Service plumbing changes, the
existing kick channel is the medium. SyncService still receives external
BurstPoll kicks via Intent (already wired in Phase 4) so the launcher path
remains usable, but the post-upload self-kick covers the primary use case.
2 new tests (kick-on-upload + budget-expiry-resets-interval). 73 tests
green total.
OnelinersClient does GET /api/oneliners with X-API-Key + If-None-Match,
classifies 200/304/transient/network. Mirrors
doctate-client-core::server_sync::poll_once exactly. JSON parsing lives
in domain.OnelinersJson (org.json) so the wire DTOs are first-class
in-memory types.
SnapshotCache persists the last successful response + its ETag in
filesDir/recordings/snapshot_cache.json. SyncWorkerLoop primes the
case store from this cache on cold start before the first network
call, so the watch never flashes an empty list while the service
boots.
Worker loop integration: when the pending queue is empty and a poller
is wired, runOnce polls instead of just publishing Idle. Success runs
mergeServerSnapshot + reconcileWithServerSnapshot, then writes the
new snapshot to the cache. Phase 4's poller-null behaviour remains as
the test/skeleton path.
13 new tests (OnelinersJsonTest 3, OnelinersClientTest 6, SnapshotCacheTest
4) covering parse round-trip, 200/304/transient/network classification,
If-None-Match header presence, corrupted-cache resilience. 71 tests
green total.
CaseListScreen shows a small ☁↑N at the top whenever the worker has
queue items; per-row ↑ next to the timestamp marks individual cases
that are still mid-upload. Both surface the SyncStateProvider directly
so the user sees the queue draining in real time.
MainActivity prompts for POST_NOTIFICATIONS on Wear OS 13+ — the
foreground service runs either way, but on API 33+ the persistent
notification only renders if the runtime grant is given. Result is
informational; we don't gate the recording flow on it (the doctor
still needs to be able to dictate even after a 'deny').
SyncWorkerLoop is the upload+poll core, mirrored from
doctate-client-core::server_sync::run. Pending uploads have priority
over polling (no point asking the server for state if we still hold
the doctor's audio). Backoff is in-process state — after restart it
drops back to 2s and gives the server one fresh chance, which beats
'wait 60s because we cancelled mid-backoff'.
Architecturally split into runOnce (single iteration, suspends only
on real I/O) and run (infinite loop, suspends on idle). The split
makes the loop directly testable on JVM virtual time without dealing
with advanceUntilIdle vs. infinite-while semantics — tests call
runOnce N times and assert in between.
Wired through:
- SyncService (foregroundServiceType=dataSync, low-importance silent
notification, ServiceCompat.startForeground for API 34+)
- SyncServiceLauncher replaces NoopSyncTrigger in DoctateApp
- DoctateApp.onCreate kicks the service after StartupCleanup if the
pending queue is non-empty (recovery from crash/reboot)
- AndroidManifest: FOREGROUND_SERVICE / FOREGROUND_SERVICE_DATA_SYNC /
POST_NOTIFICATIONS perms; service declaration
6 SyncWorkerLoopTest cases (success/transient/terminal/idle/fifo plus
backoff-resets-after-success). 58 tests green total.
Two retention sweeps run once on app launch before the sync worker
spawns. Mirrors doctate-client-core::startup + ::pending_cleanup.
- Marker sweep: synced markers older than 72h are dropped via
caseStore.cleanupStale; unsynced markers (guarding pending uploads)
are preserved indefinitely.
- Orphan audio sweep: m4a without sidecar (or vice versa) older than
24h goes; tmp leftovers go unconditionally. Paired files survive
regardless of age — the upload worker owns deletion of valid pairs.
Best-effort: failures are logged but never thrown. A hostile filesystem
must not stop the watch from starting.
9 new tests (PendingCleanupTest 6, StartupCleanupTest 3) covering the
window, sibling, and tmp-leftover edges. 52 tests green total.
Recording flow no longer uploads inline. AudioRecorder writes the m4a
straight into filesDir/recordings/unsynced/{caseId}_{utc}.m4a; the VM
follows up with an atomic sidecar (.meta.json) holding case_id + recorded_at.
Sync trigger is a SyncTrigger seam (no-op for now, fleshed out in Phase 4)
so the screen pops back to the case detail immediately after the queue
write — uploads happen out-of-band.
PendingStore mirrors doctate-client-core::upload (sidecar shape, atomic
tmp+rename, scan FIFO sorted by recorded_at, deletePair idempotent).
Audio file naming uses recorded_at_to_filename_stem semantics — colons
swapped for dashes — so paths round-trip with the desktop client and
filesystems that disallow colons.
8 new PendingStoreTest cases (atomic write, FIFO order, orphan skip,
unreadable sidecar resilience, idempotent delete). 43 tests green total.
Per-case JSON markers under filesDir/recordings/cases/, atomic tmp+rename
writes, kotlinx.coroutines Mutex serialising mutations. Wire format mirrors
doctate-client-core::CaseMarker exactly (snake_case keys, RFC3339 strings,
internally-tagged OnelinerState) — markers round-trip with the desktop client.
mergeServerSnapshot adds/updates only; reconcileWithServerSnapshot removes
synced markers inside the response window that the server stopped reporting.
Pending markers are never touched. Local Manual oneliners stick against
non-Manual server overrides.
DoctateApp now exposes caseStore as a lazy property and wires a
distinct-head flow collector to TileService.requestUpdate, decoupling
the store from Android Tile APIs (testable on the JVM).
JVM test infra: real org.json:json:20240303 plus
testOptions.unitTests.isReturnDefaultValues=true to bypass android.jar
'not mocked' stubs for Log/JSONObject. 35 unit tests green incl. 13 new
DiskCaseStoreTest cases (bootstrap, merge/reconcile semantics, mutex
ordering).
Mirrors doctate-common/src/oneliners.rs::OnelinerState (Ready/Empty/Error/Manual)
in Kotlin. CaseEntry gains syncedToServer; manualOneliner boolean is gone — the
Manual variant carries that semantic. UI render sites use displayText() so the
'…' placeholder shows up consistently for null/Empty/Error states.
Phase 0 of the Wear-OS full-implementation plan: shape the data, no behaviour
change. All 30 JVM tests green.
Tap opens the Wear OS system input picker (voice/keyboard/handwriting),
pinned to de-DE for medical German vocabulary. Doctor-authored oneliners
latch a manual flag that blocks the simulated LLM burst from overwriting
them; a subsequent manual edit still wins.
Tap on a case in the list (or Tile continue-case) now opens a new
CaseDetailScreen showing date + oneliner + record EdgeButton. Only
hitting record enters RecordingScreen, which is itself trimmed to
just a live mm:ss counter and a stop EdgeButton. Swipe-right on the
recording screen discards the in-flight recording (no confirmation).
"New case" button still bypasses the detail step.
State machine: Recording now counts elapsedSeconds (up) instead of
secondsLeft. New OnStopTap and OnDiscardTap events, new reducer
transitions. Safety cap at 300 s.
ViewModel: startRecordingFlow split into a cancellable ticker job
plus a separate finalizeAndUpload run in applicationScope so the
upload survives the pop that follows a clean stop. AtomicBoolean
finalizationStarted guards against double-finalization between the
stop, discard, and auto-stop paths.
Display: FLAG_KEEP_SCREEN_ON on the recording screen via
view.keepScreenOn. DisposableEffect.onDispose doubles as the
discard trigger for swipe-right / back / host destruction;
discardIfStillRecording() is a no-op after a clean stop.
CaseDetailScreen lays out three vertical thirds: date/time at top
(Heute/Gestern/dd.MM.yy + HH:mm:ss), oneliner in the middle,
EdgeButton at the bottom. formatTime extracted to TimeFormat.kt
so list and detail never drift apart.
Introduces a `minutesAgo` helper function and uses it in `CaseStoreStub`
to populate `CaseEntry` objects with relative timestamps for recent
activities. Also updates `formatTime` in `CaseListScreen` to display
"Gerade eben", "Vor 1 Minute", or "Vor X Minuten" for entries younger
than an hour. This provides a more user-friendly display for recent
cases.
Refactor `seedDemoData` to use `java.time` for more accurate date and
time generation, including specific times for today, yesterday, and
older dates.
Update `formatTime` in `CaseListScreen` to leverage `java.time` for a
more robust and localized date and time formatting, supporting "today",
"yesterday", and older date formats.
This ensures that the `ANDROID_SERIAL` environment variable is exported
when
`ADB_SERIAL` is set. This is crucial because Android Gradle Plugin's
`android.injected.device.serial` property can silently fall back to
selecting
all attached devices if the serial doesn't match exactly. Exporting
`ANDROID_SERIAL` provides a deeper level of filtering for spawned `adb`
binaries, maintaining the intended device selection.
Improve the visual presentation of individual case entries by adjusting
styling and text handling. This includes setting a minimum height for
rows, applying consistent padding, and enabling text truncation with an
ellipsis for longer oneliner descriptions.
Drop the legacy "(none)" target fallback that quietly picked the first
attached device. Every device-bound subcommand now requires a `watch` or
`emulator` prefix (or DOCTATE_TARGET env var).
The emulator path never auto-picks a running emulator: even with exactly
one running, the menu shows so the user can boot a different AVD
instead. ADB_SERIAL / WEAR_AVD still bypass the menu for scripted runs.
Menu results now travel through a global RESOLVED_SERIAL/PICKED instead
of stdout+$(), so `die` inside the resolvers aborts the main shell
directly rather than getting swallowed by a command-substitution subshell.
This commit introduces support for Wear OS Tiles and Complications,
enabling users to view case information and launch the app directly from
their watch face.
Key changes include:
- **New Complication Service:** `DoctateComplicationService.kt` provides
a simple complication that displays "Doctate" and launches the app's
case list upon tap.
- **New Tile Service:** `DoctateTileService.kt` serves a dynamic tile
showing the current case's one-liner. It includes tappable areas to
navigate to the case list, create a new case, or continue an existing
one.
- **UI Navigation:** `AppNav.kt` and related files (`CaseListScreen.kt`,
`MainActivity.kt`, `NavCommand.kt`) set up the navigation structure
for the Wear OS app, handling intents from the Tile and Complication.
- **Data Structures:** `CaseEntry.kt` defines the minimal data structure
for case information displayed on the watch.
- **In-Memory Data Store:** `CaseStoreStub.kt` acts as a temporary,
in-memory store for case data, ensuring consistency across the
activity, Tile, and Complication.
- **Dependency Updates:** Added necessary Wear OS libraries to
`build.gradle.kts` and `libs.versions.toml`.
- **AndroidManifest:** Configured the `MainActivity` to handle
`singleTask` launch mode and added necessary intent filters for
complications.
- **Demo Data:** Seeded `CaseStoreStub` with demo data for initial
testing and visualization.
These files mutate on every IDE click and leak per-developer state
(last deployed device, recent activity) into commits. A single root
`.idea/` ignore replaces the scattered per-file rules in
watch/wearos/.gitignore.
Real hardware has no USB and can't resolve 10.0.2.2, so we pair over
ADB-WiFi and point the app at the dev laptop's LAN URL. run.sh gains a
`watch`/`emulator` target prefix so both devices can be attached in
parallel without serial-picking roulette.
- network_security_config.xml: whitelist 192.168.178.27 for cleartext
- build.gradle.kts: resolveProp() threads Gradle CLI `-P` overrides
into BuildConfig (SERVER_URL, API_KEY) ahead of local.properties
- run.sh: adb_cmd wrapper honours ADB_SERIAL; new `devices` and
`connect [ip:port]` subcommands; `.watch_serial` caches the paired
endpoint so `./run.sh watch install` just works on subsequent runs
- .gitignore: ignore `.watch_serial`
Completes the 5-second-record-and-upload flow:
- RecordingViewModel orchestrates recorder, countdown, and upload
- MediaRecorder wrapper produces 16kHz/AAC-LC m4a (Whisper-compatible)
- Compose RecordingScreen renders the state pyramid
(Idle / RequestingPermission / Recording / Uploading / Success / Error)
- UiState + pure reducer with full JVM unit-test coverage
- run.sh "test" subcommand wraps the unit and instrumented tiers
(set SKIP_INSTRUMENTED=1 to bypass the on-device tier)
Verified end-to-end: emulator captures "Hallo, hallo", lands at
tmpdata/<user>/<case>/*.m4a, Whisper transcribes, Ollama classifies,
document.md is written — full pipeline in ~5s.
Known: MockWebServer-based UploadClientTest hangs in setUp on the
Wear OS 34 emulator (likely SELinux socket policy). Deferred — the
real-server end-to-end already validates the wire protocol.
Builds the network foundation for the Wear OS vertical PoC:
OkHttp-based UploadClient mirroring doctate-client-core's wire protocol
(multipart /api/upload, X-API-Key, transient/terminal classification),
BuildConfig-backed Settings sourced from local.properties, CaseId factory,
DoctateApp service locator, MockWebServer-based UploadClientTest for
wire-format assertions, plus the manifest/permissions/network-security-
config plumbing for emulator-loopback cleartext.
MainActivity is still the placeholder UI; audio recording, ViewModel,
and recording screen come in follow-up commits.
Adds the deviceManager.xml file, which configures device sorting within
the IDE, to the `.idea` directory. This ensures consistent device
management across different developer environments.
Inner-loop tooling so the dev cycle does not require Android Studio.
Subcommands: build, install, start, stop, logcat, shot, clean
(default = install + start). Auto-detects JAVA_HOME (bundled JBR) and
adb. Boots an emulator on demand when no device is attached: interactive
menu of available AVDs, with WEAR_AVD=<name> env-var bypass for
non-interactive use. Waits for sys.boot_completed (180s timeout) before
installing.
Initial skeleton for the Pixel Watch dictation client (Plan Phase 5b).
Generated by Android Studio's "Empty Wear App" template, kept verbatim
to defer bikeshedding until real friction appears.
Toolchain pinned via wrapper / version catalog:
AGP 9.2.0, Kotlin 2.2.10, Gradle 9.4.1, JDK 21 (auto-provisioned).
compileSdk 36, minSdk 30 — covers Pixel Watch 1/2/3.
UI starter: Wear Compose Material3 1.5.6 with TransformingLazyColumn
and EdgeButton — already close to the planned per-case full-screen
list pattern.
Manifest declares the app standalone, so the Wear OS Network Proxy
will tunnel uploads transparently when the watch lacks LTE/WiFi.
No Phone-side companion module required.
Multi-module split (:core-domain, :core-audio, :core-sync, :core-http,
:core-storage, :app-mobile) intentionally postponed — current single
:app module is the smallest thing that compiles, which gives us a
known-good baseline before restructuring.
AS's default .gitignore left as-is. A handful of .idea/ project-config
files (compiler.xml, gradle.xml, misc.xml, codeStyles, runConfigurations)
are committed by AS's design; revisit if machine-specific paths or
multi-machine drift become painful.