refactor: rename to common/, clients/desktop, clients/wearos

Move three directories into their target topology:
- doctate-common/ -> common/
- client-desktop/ -> clients/desktop/
- watch/wearos/   -> clients/wearos/

Update doctate-common path-dependency in 3 Cargo.toml files
(server, clients/desktop, experiments) and the workspace
members list in the root Cargo.toml. Crate names unchanged
(doctate-server, doctate-common, doctate-desktop).

Workspace still in single-Cargo.lock form; isolation into
standalone workspaces follows in stage 3. All 508 tests pass,
experiments standalone-workspace also resolves the new path.
This commit is contained in:
2026-05-02 11:55:38 +02:00
parent 39cc666ca6
commit 0c66fc6010
124 changed files with 4 additions and 4 deletions
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "doctate-desktop"
version.workspace = true
edition.workspace = true
description = "Desktop dictation client for the doctate medical dictation system"
[[bin]]
name = "doctate"
path = "src/main.rs"
[dependencies]
doctate-common = { path = "../../common" }
eframe = "0.28"
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
tokio = { workspace = true }
time = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { workspace = true }
directories = "5"
thiserror = "1"
webbrowser = "1"
which = "6"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }
wiremock = "0.6"
filetime = "0.2"
+942
View File
@@ -0,0 +1,942 @@
//! The egui application: config panel, record/stop controls, case list.
//!
//! State transitions are driven by:
//! - user clicks (new/continue/stop/open/save-config/dismiss-error)
//! - recorder events (ffmpeg finalizing → RecorderEvent::Finished)
//! - upload events (terminal failure → AppState::Error)
//!
//! Connectivity and upload-progress indicators live in the footer; they
//! never block the main state machine. See
//! `berpr-fe-den-plan-lively-journal.md` for the design rationale.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::case_store::{CaseMarker, CaseStore};
use crate::case_update::{PutOnelinerError, put_oneliner};
use crate::footer_status::{FooterStatus, pick_footer_status};
use crate::server_sync::{ServerSync, SyncConfig, WorkerSnapshot, WorkerState};
use crate::snapshot_cache::SnapshotCache;
use crate::startup::{DEFAULT_MARKER_RETENTION, DEFAULT_ORPHAN_AUDIO_RETENTION, run_startup_cleanup};
use crate::upload::{PendingUpload, UploadEvent, write_sidecar};
use doctate_common::join_url;
use doctate_common::oneliners::OnelinerState;
use doctate_common::timestamp::{extract_hhmm, now_rfc3339, recorded_at_to_filename_stem};
use eframe::egui;
use tokio::runtime::Runtime;
use tokio::sync::{mpsc, watch};
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::config::Config;
use crate::recorder::{Recorder, RecorderEvent};
use crate::state::AppState;
pub struct DoctateApp {
runtime: Arc<Runtime>,
config: Option<Config>,
pending_dir: PathBuf,
state: AppState,
server_url_input: String,
api_key_input: String,
http_client: Arc<reqwest::Client>,
case_store: Arc<CaseStore>,
snapshot_rx: watch::Receiver<Arc<Vec<CaseMarker>>>,
snapshot_cache: Arc<SnapshotCache>,
recorder: Option<Recorder>,
recorder_events: Option<mpsc::UnboundedReceiver<RecorderEvent>>,
server_sync: Option<ServerSync>,
worker_rx: Option<watch::Receiver<WorkerSnapshot>>,
upload_events: Option<mpsc::UnboundedReceiver<UploadEvent>>,
/// Context for an in-flight ffmpeg flush: we left `AppState::Recording`
/// for `AppState::Idle` on Stop (so the UI stays responsive), but we
/// still need `case_id` + `recorded_at` when the recorder emits
/// `Finished`. `None` when no flush is in progress.
finalizing: Option<RecordingContext>,
/// Active inline tap-to-edit on a oneliner row. `None` means
/// every row renders in read mode.
oneliner_edit: Option<OnelinerEditState>,
/// Channel for results coming back from background `put_oneliner`
/// spawns. The receiver is drained at the start of every frame.
save_result_tx: mpsc::UnboundedSender<SaveResult>,
save_result_rx: mpsc::UnboundedReceiver<SaveResult>,
/// Toast banner shown until the user dismisses it; populated when
/// a `put_oneliner` returns an error.
last_save_error: Option<String>,
}
/// Enough recording identity to finalize a pending flush or emit an
/// upload event once the recorder signals `Finished`.
#[derive(Debug, Clone)]
struct RecordingContext {
case_id: Uuid,
recorded_at: String,
}
/// Per-case oneliner edit buffer. Only one case is in edit mode at a
/// time; clicking another case (or pressing Esc) clears this. The
/// `focus_pending` flag drives a one-shot `request_focus()` on the
/// first render after the edit starts, so the user lands in the
/// TextEdit without a second click.
struct OnelinerEditState {
case_id: Uuid,
draft: String,
focus_pending: bool,
}
/// Result of a background `put_oneliner` request, posted back to the
/// UI thread via mpsc so the next `update()` frame can surface
/// success/failure to the user.
struct SaveResult {
case_id: Uuid,
outcome: Result<doctate_common::oneliners::OnelinerState, PutOnelinerError>,
}
enum UiAction {
SaveConfig,
StartNew,
Continue(Uuid),
OpenWeb(Uuid),
Stop,
DismissError,
StartOnelinerEdit { case_id: Uuid, current_text: String },
CancelOnelinerEdit,
SaveOneliner { case_id: Uuid, text: String },
DismissSaveError,
}
impl DoctateApp {
pub fn new(
runtime: Arc<Runtime>,
pending_dir: PathBuf,
cases_dir: PathBuf,
snapshot_cache_path: PathBuf,
) -> Self {
let config = match crate::config_path::load() {
Ok(Some(c)) => Some(c),
Ok(None) => None,
Err(e) => {
warn!(error = %e, "config load failed");
None
}
};
let server_url_input = config
.as_ref()
.map(|c| c.server_url.clone())
.unwrap_or_default();
let api_key_input = config
.as_ref()
.map(|c| c.api_key.clone())
.unwrap_or_default();
let (store, snapshot_rx) = runtime
.block_on(async { CaseStore::open(cases_dir).await })
.expect("open case store");
let case_store = Arc::new(store);
let snapshot_cache = Arc::new(SnapshotCache::new(snapshot_cache_path));
let http_client = Arc::new(reqwest::Client::new());
// Startup cleanup — one-shot, before the worker spawns.
{
let store = case_store.clone();
let pending = pending_dir.clone();
runtime.block_on(async move {
run_startup_cleanup(
&store,
&pending,
DEFAULT_MARKER_RETENTION,
DEFAULT_ORPHAN_AUDIO_RETENTION,
)
.await;
});
}
let (state, server_sync, worker_rx, upload_events) = if let Some(cfg) = &config {
let (sync, events, rx) = spawn_worker(
&runtime,
cfg.clone(),
pending_dir.clone(),
http_client.clone(),
case_store.clone(),
snapshot_cache.clone(),
);
(AppState::Idle, Some(sync), Some(rx), Some(events))
} else {
(AppState::NotConfigured, None, None, None)
};
let (save_result_tx, save_result_rx) = mpsc::unbounded_channel();
Self {
runtime,
config,
pending_dir,
state,
server_url_input,
api_key_input,
http_client,
case_store,
snapshot_rx,
snapshot_cache,
recorder: None,
recorder_events: None,
server_sync,
worker_rx,
upload_events,
finalizing: None,
oneliner_edit: None,
save_result_tx,
save_result_rx,
last_save_error: None,
}
}
fn on_save_config(&mut self) {
let cfg = Config {
server_url: self.server_url_input.trim().to_string(),
api_key: self.api_key_input.trim().to_string(),
oneliner_poll_interval_seconds: self
.config
.as_ref()
.and_then(|c| c.oneliner_poll_interval_seconds),
};
if let Err(e) = crate::config_path::save(&cfg) {
error!(error = %e, "config save failed");
self.state = AppState::Error {
message: format!("Speichern: {e}"),
};
return;
}
info!("config saved");
let store = self.case_store.clone();
let cache = self.snapshot_cache.clone();
self.runtime.block_on(async move {
if let Err(e) = store.clear_all().await {
warn!(error = %e, "case store clear failed on config change");
}
if let Err(e) = cache.clear().await {
warn!(error = %e, "snapshot cache clear failed on config change");
}
});
// Drop old worker explicitly so its abort fires before new spawn.
self.server_sync = None;
self.worker_rx = None;
self.upload_events = None;
let (sync, events, rx) = spawn_worker(
&self.runtime,
cfg.clone(),
self.pending_dir.clone(),
self.http_client.clone(),
self.case_store.clone(),
self.snapshot_cache.clone(),
);
self.server_sync = Some(sync);
self.worker_rx = Some(rx);
self.upload_events = Some(events);
self.config = Some(cfg);
self.state = AppState::Idle;
self.finalizing = None;
}
fn on_start_new(&mut self) {
self.start_recording_for(Uuid::new_v4(), /* is_continue */ false);
}
fn on_continue(&mut self, case_id: Uuid) {
self.start_recording_for(case_id, /* is_continue */ true);
}
fn start_recording_for(&mut self, case_id: Uuid, is_continue: bool) {
// Two-part guard: user-visible state AND no lingering recorder
// from a previous stop that is still flushing.
if !matches!(self.state, AppState::Idle) || self.recorder.is_some() {
return;
}
if let Err(e) = std::fs::create_dir_all(&self.pending_dir) {
self.state = AppState::Error {
message: format!("Pending-Verzeichnis: {e}"),
};
return;
}
let recorded_at = now_rfc3339();
let stem = format!("{}_{}", case_id, recorded_at_to_filename_stem(&recorded_at));
let output_path = self.pending_dir.join(format!("{stem}.m4a"));
// Persist the case marker before recording so the UI list
// reflects the new case immediately.
let store = self.case_store.clone();
let now = time::OffsetDateTime::now_utc();
self.runtime.block_on(async move {
let res = if is_continue {
store.mark_activity(case_id, now).await.map(|_| ())
} else {
store.create_local(case_id, now).await.map(|_| ())
};
if let Err(e) = res {
warn!(error = %e, "case store write failed");
}
});
let _guard = self.runtime.enter();
match Recorder::start(output_path) {
Ok((recorder, events)) => {
self.recorder = Some(recorder);
self.recorder_events = Some(events);
self.state = AppState::Recording {
case_id,
recorded_at,
started_at: Instant::now(),
};
}
Err(e) => {
self.state = AppState::Error {
message: format!("Aufnahme starten: {e}"),
};
}
}
}
fn on_stop(&mut self) {
let AppState::Recording {
case_id,
recorded_at,
..
} = &self.state
else {
return;
};
let case_id = *case_id;
let recorded_at = recorded_at.clone();
if let Some(rec) = self.recorder.take() {
rec.stop();
}
// UI is ready for the next recording immediately — the finalizing
// ffmpeg flush shows up as a footer spinner, not a blocking state.
self.finalizing = Some(RecordingContext {
case_id,
recorded_at,
});
self.state = AppState::Idle;
}
fn on_open_web(&self, case_id: Uuid) {
let Some(cfg) = &self.config else {
return;
};
let server_url = cfg.server_url.clone();
let api_key = cfg.api_key.clone();
let http = self.http_client.clone();
let return_to = format!("/web/cases/{case_id}");
let fallback_url = join_url(&server_url, &return_to);
// Fire-and-forget on the runtime. Blocking the egui UI thread on
// an HTTP round-trip would freeze the window for ~100500ms.
self.runtime.spawn(async move {
let url =
match crate::magic_link::build_magic_url(&http, &server_url, &api_key, &return_to)
.await
{
Ok(u) => u,
Err(e) => {
warn!(error = %e, "magic-link request failed; falling back to plain URL");
fallback_url
}
};
if let Err(e) = webbrowser::open(&url) {
warn!(url = %url, error = %e, "open browser failed");
}
});
}
fn drain_recorder_events(&mut self) {
let Some(rx) = self.recorder_events.as_mut() else {
return;
};
let mut events = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
for event in events {
match event {
RecorderEvent::Started => {}
RecorderEvent::Finished { output } => {
self.handle_recording_finished(output);
}
RecorderEvent::Failed { error: err } => {
error!(error = %err, "recorder failed");
self.finalizing = None;
self.state = AppState::Error {
message: format!("Aufnahme: {err}"),
};
self.recorder_events = None;
self.recorder = None;
}
}
}
}
fn handle_recording_finished(&mut self, output: PathBuf) {
// Prefer the explicit finalizing context (Stop-flow). Fall back
// to AppState::Recording (recorder terminated before Stop — rare
// but possible).
let ctx = if let Some(ctx) = self.finalizing.take() {
ctx
} else if let AppState::Recording {
case_id,
recorded_at,
..
} = &self.state
{
let ctx = RecordingContext {
case_id: *case_id,
recorded_at: recorded_at.clone(),
};
self.state = AppState::Idle;
ctx
} else {
warn!(state = ?self.state, "recorder finished in unexpected state");
self.recorder_events = None;
self.recorder = None;
return;
};
let RecordingContext {
case_id,
recorded_at,
} = ctx;
let store = self.case_store.clone();
let now = time::OffsetDateTime::now_utc();
self.runtime.block_on(async move {
if let Err(e) = store.mark_activity(case_id, now).await {
warn!(error = %e, "mark_activity on recording finished failed");
}
});
let upload = PendingUpload {
case_id,
recorded_at,
file: output,
};
if let Err(e) = write_sidecar(&upload) {
self.state = AppState::Error {
message: format!("Sidecar schreiben: {e}"),
};
self.recorder_events = None;
self.recorder = None;
return;
}
// Kick the worker so it picks up the fresh file without waiting
// for the next poll-tick.
if let Some(sync) = &self.server_sync {
sync.notify();
} else {
warn!("no server_sync active — upload stays on disk until next launch");
}
self.recorder_events = None;
self.recorder = None;
}
fn drain_upload_events(&mut self) {
let Some(rx) = self.upload_events.as_mut() else {
return;
};
let mut events = Vec::new();
while let Ok(event) = rx.try_recv() {
events.push(event);
}
for event in events {
match event {
UploadEvent::Started(case_id) => {
info!(case_id = %case_id, "upload attempt started");
}
UploadEvent::Succeeded { case_id, status } => {
info!(case_id = %case_id, status = ?status, "upload succeeded");
}
UploadEvent::Failed {
case_id,
reason,
will_retry,
} => {
warn!(case_id = %case_id, reason = %reason, will_retry, "upload failed");
if !will_retry {
// Terminal = operator action needed. Block UI.
self.state = AppState::Error { message: reason };
}
}
}
}
}
fn render_controls(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
match &self.state {
AppState::NotConfigured => {
ui.label("Server URL:");
ui.add(egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0));
ui.add_space(4.0);
ui.label("API Key:");
ui.add(
egui::TextEdit::singleline(&mut self.api_key_input)
.desired_width(280.0)
.password(true),
);
ui.add_space(8.0);
if ui.button("Speichern").clicked() {
return Some(UiAction::SaveConfig);
}
None
}
AppState::Idle => {
if ui.button("● Neu").clicked() {
return Some(UiAction::StartNew);
}
None
}
AppState::Recording { started_at, .. } => {
let elapsed = started_at.elapsed();
ui.label(format!(
"● REC {:02}:{:02}",
elapsed.as_secs() / 60,
elapsed.as_secs() % 60
));
ui.add_space(4.0);
if ui.button("■ Stop").clicked() {
return Some(UiAction::Stop);
}
None
}
AppState::Error { message } => {
ui.colored_label(egui::Color32::RED, message);
ui.add_space(8.0);
if ui.button("OK").clicked() {
return Some(UiAction::DismissError);
}
None
}
}
}
fn render_case_list(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
let snapshot = self.snapshot_rx.borrow().clone();
// Snapshot is already server-filtered — the server's `window_hours`
// per user (users.toml) is the single source of truth.
let visible: Vec<&CaseMarker> = snapshot.iter().collect();
if visible.is_empty() {
ui.add_space(8.0);
ui.colored_label(egui::Color32::GRAY, "Noch keine Fälle heute.");
return None;
}
// Pending-upload lookup for the badge. Cheap clone (Arc) from the
// worker's last published snapshot; empty set if no worker yet.
let pending_ids = self
.worker_rx
.as_ref()
.map(|rx| rx.borrow().pending_case_ids.clone())
.unwrap_or_default();
let mut action = None;
// Ready-for-new means the user-intent state is Idle AND no ffmpeg
// instance is still flushing. Upload-queue depth does NOT gate.
let idle = matches!(self.state, AppState::Idle) && self.recorder.is_none();
egui::ScrollArea::vertical().show(ui, |ui| {
for marker in visible {
ui.separator();
ui.add_space(4.0);
let time_str = extract_hhmm(&marker.last_activity_at);
let has_pending = pending_ids.contains(&marker.case_id);
let in_edit_mode = self
.oneliner_edit
.as_ref()
.is_some_and(|e| e.case_id == marker.case_id);
if in_edit_mode {
// Inline edit: TextEdit + ✓/✗. Enter saves, Esc cancels.
let edit = self.oneliner_edit.as_mut().unwrap();
ui.horizontal(|ui| {
ui.label(format!("{time_str} ·"));
let response = ui
.add(egui::TextEdit::singleline(&mut edit.draft).desired_width(280.0));
if edit.focus_pending {
response.request_focus();
edit.focus_pending = false;
}
let enter_pressed =
response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let save_clicked = ui.button("").clicked();
let cancel_clicked = ui.button("").clicked();
let esc_pressed = ui.input(|i| i.key_pressed(egui::Key::Escape));
if save_clicked || enter_pressed {
action = Some(UiAction::SaveOneliner {
case_id: marker.case_id,
text: edit.draft.clone(),
});
} else if cancel_clicked || esc_pressed {
action = Some(UiAction::CancelOnelinerEdit);
}
});
} else {
// Read mode: clickable label, plus a green pencil
// for Manual variant so the doctor sees their
// override is the source of truth here.
let oneliner_text = match &marker.oneliner {
Some(
OnelinerState::Ready { text, .. } | OnelinerState::Manual { text, .. },
) => text.clone(),
Some(OnelinerState::Empty { .. }) => "".to_owned(),
Some(OnelinerState::Error { .. }) => "".to_owned(),
None => "".to_owned(),
};
let line = if has_pending {
format!("{time_str} · {oneliner_text}")
} else {
format!("{time_str} · {oneliner_text}")
};
ui.horizontal(|ui| {
let label = if has_pending {
egui::Label::new(
egui::RichText::new(line)
.color(egui::Color32::from_rgb(200, 140, 0)),
)
.sense(egui::Sense::click())
} else {
egui::Label::new(line).sense(egui::Sense::click())
};
let resp = ui.add(label);
if matches!(marker.oneliner, Some(OnelinerState::Manual { .. })) {
// Person-glyph marks doctor-authored titles.
// Replaces an earlier pencil icon: the pencil
// suggested an edit affordance, but on this row
// the marker is informational only — clicking
// the title text already opens the editor.
ui.colored_label(egui::Color32::GRAY, "👤")
.on_hover_text("manuell bearbeitet");
}
if resp.clicked() {
// Pre-fill the editor with the visible text
// (Ready/Manual). For Empty/Error/None start
// with an empty buffer so the doctor types
// from scratch.
let current = match &marker.oneliner {
Some(
OnelinerState::Ready { text, .. }
| OnelinerState::Manual { text, .. },
) => text.clone(),
_ => String::new(),
};
action = Some(UiAction::StartOnelinerEdit {
case_id: marker.case_id,
current_text: current,
});
}
});
}
ui.horizontal(|ui| {
if ui
.add_enabled(idle, egui::Button::new("▶ Fortsetzen"))
.clicked()
{
action = Some(UiAction::Continue(marker.case_id));
}
if ui.button("🌐 Öffnen").clicked() {
action = Some(UiAction::OpenWeb(marker.case_id));
}
});
ui.add_space(4.0);
}
});
action
}
fn render_footer_status(&self, ui: &mut egui::Ui) {
let Some(worker_rx) = &self.worker_rx else {
ui.colored_label(egui::Color32::GRAY, "");
return;
};
let snap = worker_rx.borrow().clone();
let last_success_age = snap.last_success.map(|i| i.elapsed());
let last_failure_age = snap.last_failure.map(|i| i.elapsed());
let threshold = self
.config
.as_ref()
.map(|c| std::cmp::max(3 * c.poll_interval(), Duration::from_secs(30)))
.unwrap_or(Duration::from_secs(30));
let status = pick_footer_status(
self.finalizing.is_some(),
snap.state,
snap.queue_len,
last_success_age,
last_failure_age,
threshold,
);
match status {
FooterStatus::Finalizing => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Speichere Aufnahme…");
});
}
FooterStatus::Uploading { count } => {
ui.horizontal(|ui| {
ui.spinner();
if count <= 1 {
ui.label("Uploading…");
} else {
ui.label(format!("Uploading ({count} offen)…"));
}
});
}
FooterStatus::Synchronizing => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Synchronisiere…");
});
}
FooterStatus::NeverConnected => {
ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung");
}
FooterStatus::Offline => {
ui.colored_label(egui::Color32::DARK_RED, "🔴 Offline");
}
FooterStatus::Online => {
ui.colored_label(egui::Color32::DARK_GREEN, "🟢 Online");
}
}
}
fn handle_action(&mut self, action: UiAction) {
match action {
UiAction::SaveConfig => self.on_save_config(),
UiAction::StartNew => self.on_start_new(),
UiAction::Continue(id) => self.on_continue(id),
UiAction::OpenWeb(id) => self.on_open_web(id),
UiAction::Stop => self.on_stop(),
UiAction::DismissError => self.state = AppState::Idle,
UiAction::StartOnelinerEdit {
case_id,
current_text,
} => {
self.oneliner_edit = Some(OnelinerEditState {
case_id,
draft: current_text,
focus_pending: true,
});
}
UiAction::CancelOnelinerEdit => {
self.oneliner_edit = None;
}
UiAction::SaveOneliner { case_id, text } => {
self.on_save_oneliner(case_id, text);
}
UiAction::DismissSaveError => {
self.last_save_error = None;
}
}
}
/// Optimistic write + background PUT. Local state is updated
/// immediately so the new text appears on the next frame; the
/// server round-trip runs in a spawned task and reports back via
/// `save_result_tx`.
fn on_save_oneliner(&mut self, case_id: Uuid, text: String) {
let trimmed = text.trim().to_owned();
if trimmed.is_empty() {
// Empty payload would be rejected by the server (400) and
// is also a no-op on the local cache. Treat as cancel.
self.oneliner_edit = None;
return;
}
let local_state = OnelinerState::Manual {
text: trimmed.clone(),
set_at: now_rfc3339(),
};
let store = self.case_store.clone();
let local_clone = local_state.clone();
// Block briefly on the local write — single tokio::fs::write +
// map insert, microseconds in practice. Keeps the UI strictly
// monotonic: by the next render frame the new text is visible.
self.runtime.block_on(async move {
if let Err(e) = store.set_oneliner_local(case_id, local_clone).await {
warn!(case_id = %case_id, error = %e, "local set_oneliner failed");
}
});
self.oneliner_edit = None;
// Background PUT. On failure the next drain_save_results
// surfaces the error; on success no further work is needed
// because the local state is already correct.
let Some(cfg) = self.config.clone() else {
warn!("save_oneliner without config — should not happen");
return;
};
let http = self.http_client.clone();
let tx = self.save_result_tx.clone();
self.runtime.spawn(async move {
let outcome =
put_oneliner(&http, &cfg.server_url, &cfg.api_key, case_id, &trimmed).await;
let _ = tx.send(SaveResult { case_id, outcome });
});
}
fn drain_save_results(&mut self) {
while let Ok(result) = self.save_result_rx.try_recv() {
match result.outcome {
Ok(_) => {
info!(case_id = %result.case_id, "oneliner save succeeded");
}
Err(e) => {
error!(case_id = %result.case_id, error = ?e, "oneliner save failed");
self.last_save_error = Some(format!(
"Oneliner für Fall {} konnte nicht gespeichert werden: {}",
short_case_id(&result.case_id),
user_error_message(&e),
));
}
}
}
}
fn worker_is_active(&self) -> bool {
let Some(rx) = &self.worker_rx else {
return false;
};
let snap = rx.borrow();
snap.state != WorkerState::Idle || snap.queue_len > 0
}
}
fn spawn_worker(
runtime: &Runtime,
config: Config,
pending_dir: PathBuf,
http: Arc<reqwest::Client>,
case_store: Arc<CaseStore>,
snapshot_cache: Arc<SnapshotCache>,
) -> (
ServerSync,
mpsc::UnboundedReceiver<UploadEvent>,
watch::Receiver<WorkerSnapshot>,
) {
let _guard = runtime.enter();
let poll_interval = config.poll_interval();
let sync_config = SyncConfig {
server_url: config.server_url,
api_key: config.api_key,
poll_interval,
};
let (sync, events_rx) =
ServerSync::spawn(http, sync_config, case_store, snapshot_cache, pending_dir);
let snapshot_rx = sync.snapshot();
(sync, events_rx, snapshot_rx)
}
/// Short prefix of a `Uuid` for human-readable error messages.
fn short_case_id(id: &Uuid) -> String {
id.to_string().chars().take(8).collect()
}
/// Map a `PutOnelinerError` to a German user-facing one-liner.
fn user_error_message(e: &PutOnelinerError) -> String {
match e {
PutOnelinerError::Terminal { status, .. } => {
format!("Server-Fehler {status} — bitte Konfiguration prüfen.")
}
PutOnelinerError::Transient(_) => "Netzwerk-Fehler — bitte erneut versuchen.".to_string(),
PutOnelinerError::Parse(_) => "Antwort vom Server unverständlich.".to_string(),
}
}
impl eframe::App for DoctateApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.drain_recorder_events();
self.drain_upload_events();
self.drain_save_results();
let mut pending_action: Option<UiAction> = None;
// Toast banner for the last save failure. Sits above the
// header so the user can still navigate the rest of the app.
if let Some(msg) = self.last_save_error.clone() {
egui::TopBottomPanel::top("save-error-banner").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::DARK_RED, "");
ui.label(msg);
if ui.button("").clicked() {
pending_action = Some(UiAction::DismissSaveError);
}
});
ui.add_space(4.0);
});
}
egui::TopBottomPanel::top("header").show(ctx, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.heading("doctate");
ui.label(self.state.label());
});
ui.add_space(4.0);
});
egui::TopBottomPanel::bottom("footer").show(ctx, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
self.render_footer_status(ui);
});
ui.add_space(4.0);
});
egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical_centered(|ui| {
ui.add_space(8.0);
if let Some(a) = self.render_controls(ui) {
pending_action = Some(a);
}
ui.add_space(8.0);
});
if !matches!(self.state, AppState::NotConfigured)
&& let Some(a) = self.render_case_list(ui)
{
pending_action = Some(a);
}
});
if let Some(action) = pending_action {
self.handle_action(action);
}
// Repaint cadence: fast while visibly active (spinner, REC-timer),
// slow (1 Hz) in the quiet steady state.
let active_spinner = self.finalizing.is_some() || self.worker_is_active();
let interval = match self.state {
AppState::Recording { .. } => Duration::from_millis(250),
_ if active_spinner => Duration::from_millis(100),
_ => Duration::from_secs(1),
};
ctx.request_repaint_after(interval);
}
}
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
//! HTTP client for `PUT /api/cases/{case_id}/oneliner` — send the
//! doctor's manual oneliner override to the server.
//!
//! Returns the persisted [`OnelinerState`] on success (always
//! `Manual { text, set_at }` if the server obeyed the contract).
//! Errors are split into terminal (operator must fix — wrong API key,
//! unknown case, validation refused) and transient (network blip /
//! 5xx, retry sensible) so the caller can surface the right message.
use doctate_common::API_KEY_HEADER;
use doctate_common::join_url;
use doctate_common::oneliners::{
ONELINER_OVERRIDE_PATH_TEMPLATE, OnelinerOverrideRequest, OnelinerState,
};
use reqwest::StatusCode;
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum PutOnelinerError {
/// Server rejected the request for a reason the user must address:
/// 401 (bad API key), 403 (no permission), 404 (unknown case),
/// 400/422 (validation failure). Surfacing the body lets the UI
/// show the server-side message.
#[error("HTTP {status}: {body}")]
Terminal { status: u16, body: String },
/// Network error or 408/429/5xx — the caller may retry after a
/// backoff. The string is the most useful diagnostic available.
#[error("transient: {0}")]
Transient(String),
/// Server replied 200 but the body did not parse as
/// [`OnelinerState`]. Treated as terminal because there is no
/// obvious retry strategy: a body-shape mismatch is not a hiccup.
#[error("parse response: {0}")]
Parse(String),
}
/// Fire a `PUT /api/cases/{case_id}/oneliner` request and return the
/// persisted state on success. The server sets `set_at` on the
/// returned variant — clients should not pre-populate it.
pub async fn put_oneliner(
http: &reqwest::Client,
server_url: &str,
api_key: &str,
case_id: Uuid,
text: &str,
) -> Result<OnelinerState, PutOnelinerError> {
let path = ONELINER_OVERRIDE_PATH_TEMPLATE.replace("{case_id}", &case_id.to_string());
let url = join_url(server_url, &path);
let body = OnelinerOverrideRequest {
text: text.to_owned(),
};
let resp = match http
.put(&url)
.header(API_KEY_HEADER, api_key)
.json(&body)
.send()
.await
{
Ok(r) => r,
Err(e) => return Err(PutOnelinerError::Transient(format!("network: {e}"))),
};
let status = resp.status();
if status == StatusCode::OK {
match resp.json::<OnelinerState>().await {
Ok(s) => Ok(s),
Err(e) => Err(PutOnelinerError::Parse(format!("{e}"))),
}
} else if status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error() {
let body = resp.text().await.unwrap_or_default();
Err(PutOnelinerError::Transient(format!(
"HTTP {status}: {body}"
)))
} else {
let body = resp.text().await.unwrap_or_default();
Err(PutOnelinerError::Terminal {
status: status.as_u16(),
body,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{header, method, path_regex};
use wiremock::{Mock, MockServer, ResponseTemplate};
const TEST_API_KEY: &str = "test-key";
async fn mock_server_with(status: u16, body: serde_json::Value) -> MockServer {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path_regex(r"^/api/cases/[0-9a-fA-F-]+/oneliner$"))
.and(header(API_KEY_HEADER, TEST_API_KEY))
.respond_with(ResponseTemplate::new(status).set_body_json(body))
.mount(&server)
.await;
server
}
#[tokio::test]
async fn put_oneliner_happy_path_returns_manual_state() {
let server = mock_server_with(
200,
json!({
"kind": "manual",
"text": "55 J., Knieschmerz",
"set_at": "2026-04-26T12:00:00Z"
}),
)
.await;
let http = reqwest::Client::new();
let case_id = Uuid::new_v4();
let result = put_oneliner(
&http,
&server.uri(),
TEST_API_KEY,
case_id,
"55 J., Knieschmerz",
)
.await
.expect("ok");
match result {
OnelinerState::Manual { text, set_at } => {
assert_eq!(text, "55 J., Knieschmerz");
assert_eq!(set_at, "2026-04-26T12:00:00Z");
}
other => panic!("expected Manual, got {other:?}"),
}
}
#[tokio::test]
async fn put_oneliner_401_is_terminal() {
let server = mock_server_with(401, json!({"error": "Unauthorized"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
match err {
PutOnelinerError::Terminal { status, .. } => assert_eq!(status, 401),
other => panic!("expected Terminal, got {other:?}"),
}
}
#[tokio::test]
async fn put_oneliner_400_is_terminal() {
let server = mock_server_with(400, json!({"error": "empty text"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
assert!(matches!(
err,
PutOnelinerError::Terminal { status: 400, .. }
));
}
#[tokio::test]
async fn put_oneliner_404_is_terminal() {
let server = mock_server_with(404, json!({"error": "case not found"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
assert!(matches!(
err,
PutOnelinerError::Terminal { status: 404, .. }
));
}
#[tokio::test]
async fn put_oneliner_503_is_transient() {
let server = mock_server_with(503, json!({"error": "service unavailable"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
assert!(matches!(err, PutOnelinerError::Transient(_)));
}
#[tokio::test]
async fn put_oneliner_429_is_transient() {
let server = mock_server_with(429, json!({"error": "too many requests"})).await;
let http = reqwest::Client::new();
let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
.await
.expect_err("must err");
assert!(matches!(err, PutOnelinerError::Transient(_)));
}
#[tokio::test]
async fn put_oneliner_network_error_is_transient() {
// Point at a URL nothing is listening on.
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(200))
.build()
.unwrap();
let err = put_oneliner(
&http,
"http://127.0.0.1:1", // reserved port
TEST_API_KEY,
Uuid::new_v4(),
"x",
)
.await
.expect_err("must err");
assert!(matches!(err, PutOnelinerError::Transient(_)));
}
}
+180
View File
@@ -0,0 +1,180 @@
//! Shared client configuration: server URL, API key, polling cadence,
//! oneliner window. Every native client (desktop, watch, phone) reads
//! the same schema — only the on-disk path differs per platform.
//!
//! Path resolution is deliberately **not** part of this module. Callers
//! provide a concrete `&Path` to `load_from` / `save_to`; the
//! per-platform path helper lives in each client crate.
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Fallback polling cadence for `/api/oneliners` in seconds. Chosen to
/// feel real-time without hammering the server.
pub const DEFAULT_POLL_INTERVAL_SECONDS: u64 = 5;
/// Client configuration loaded from a TOML file at a platform-specific
/// path. All fields except the two durations are mandatory.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Config {
/// Base URL of the doctate server, e.g. `https://doctate.example.org`.
pub server_url: String,
/// Per-user API key used in the `X-API-Key` header.
pub api_key: String,
/// Polling cadence for `/api/oneliners`. Omit in TOML to use the
/// built-in default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oneliner_poll_interval_seconds: Option<u64>,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("cannot determine OS config directory (is $HOME set?)")]
NoConfigDir,
#[error("I/O error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("parse error at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
}
impl Config {
/// Load from `path`. Returns `Ok(None)` if the file does not exist
/// (first-run case); `Ok(Some(_))` on success; `Err` on I/O or parse
/// errors — caller should surface these to the user, not swallow.
pub fn load_from(path: &Path) -> Result<Option<Self>, ConfigError> {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(ConfigError::Io {
path: path.into(),
source: e,
});
}
};
let cfg: Config = toml::from_str(&text).map_err(|e| ConfigError::Parse {
path: path.into(),
source: e,
})?;
Ok(Some(cfg))
}
/// Save to `path`, creating parent directories as needed.
pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigError::Io {
path: parent.into(),
source: e,
})?;
}
let text = toml::to_string_pretty(self)?;
std::fs::write(path, text).map_err(|e| ConfigError::Io {
path: path.into(),
source: e,
})?;
Ok(())
}
/// Polling cadence as a [`Duration`], substituting the default if
/// the field was omitted.
pub fn poll_interval(&self) -> Duration {
Duration::from_secs(
self.oneliner_poll_interval_seconds
.unwrap_or(DEFAULT_POLL_INTERVAL_SECONDS),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample() -> Config {
Config {
server_url: "https://doctate.example.org".into(),
api_key: "sk-test-12345".into(),
oneliner_poll_interval_seconds: None,
}
}
#[test]
fn roundtrip_through_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("client.toml");
let original = sample();
original.save_to(&path).unwrap();
let loaded = Config::load_from(&path).unwrap();
assert_eq!(loaded, Some(original));
}
#[test]
fn load_missing_file_returns_none() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nonexistent.toml");
assert!(Config::load_from(&path).unwrap().is_none());
}
#[test]
fn save_creates_parent_dirs() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("deep/nested/client.toml");
sample().save_to(&path).unwrap();
assert!(path.exists());
}
#[test]
fn load_invalid_toml_returns_parse_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("bad.toml");
std::fs::write(&path, "this is not [valid toml").unwrap();
let err = Config::load_from(&path).unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn defaults_when_fields_omitted() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("minimal.toml");
std::fs::write(&path, "server_url = \"http://x\"\napi_key = \"y\"\n").unwrap();
let cfg = Config::load_from(&path).unwrap().unwrap();
assert_eq!(cfg.poll_interval(), Duration::from_secs(5));
}
#[test]
fn explicit_values_override_defaults() {
let cfg = Config {
server_url: "http://x".into(),
api_key: "y".into(),
oneliner_poll_interval_seconds: Some(10),
};
assert_eq!(cfg.poll_interval(), Duration::from_secs(10));
}
#[test]
fn roundtrip_preserves_optional_fields() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("client.toml");
let original = Config {
server_url: "http://x".into(),
api_key: "y".into(),
oneliner_poll_interval_seconds: Some(3),
};
original.save_to(&path).unwrap();
let loaded = Config::load_from(&path).unwrap().unwrap();
assert_eq!(loaded.oneliner_poll_interval_seconds, Some(3));
}
}
+48
View File
@@ -0,0 +1,48 @@
//! Desktop-side config helpers. The `Config` struct itself lives in
//! the sibling `config` module (TOML schema, platform-agnostic). This
//! module only provides the per-platform default path via the
//! `directories` crate, plus two convenience functions for load/save
//! against that default path.
use std::path::PathBuf;
use directories::ProjectDirs;
pub use crate::config::{Config, ConfigError};
const CONFIG_FILENAME: &str = "client.toml";
/// Default config path, following OS conventions:
/// Linux: `~/.config/doctate/client.toml`
/// Windows: `%APPDATA%\doctate\client.toml`
/// macOS: `~/Library/Application Support/doctate/client.toml`
pub fn default_config_path() -> Result<PathBuf, ConfigError> {
ProjectDirs::from("", "", "doctate")
.map(|dirs| dirs.config_dir().join(CONFIG_FILENAME))
.ok_or(ConfigError::NoConfigDir)
}
/// Convenience: load from `default_config_path()`.
pub fn load() -> Result<Option<Config>, ConfigError> {
Config::load_from(&default_config_path()?)
}
/// Convenience: save to `default_config_path()`.
pub fn save(cfg: &Config) -> Result<(), ConfigError> {
cfg.save_to(&default_config_path()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_path_ends_with_client_toml() {
// Sanity check on the ProjectDirs wiring — can't assert the
// exact path (varies per host), but the filename is deterministic.
let path = default_config_path().expect("config path available in test env");
assert_eq!(
path.file_name().and_then(|s| s.to_str()),
Some("client.toml")
);
}
}
+227
View File
@@ -0,0 +1,227 @@
//! Footer status derivation — a pure function that every client (desktop,
//! watch, phone) uses to decide which one-line indicator to show in its
//! status bar. No UI framework dependency.
//!
//! The truth-table is deliberately tied to the **worker state**, not the
//! queue depth: if the worker is sitting in `sleep(backoff)` after a
//! transient failure, the queue still has items but the worker is `Idle`
//! → we show `Offline` / `Online` based on `last_success_age` vs.
//! `last_failure_age`, not a misleading "Uploading…".
use std::time::Duration;
use crate::server_sync::WorkerState;
/// Derived observable for the client's status line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FooterStatus {
/// ffmpeg (or platform-equivalent) is flushing the last recording.
Finalizing,
/// Worker is uploading. `count` = pending-dir depth.
Uploading { count: usize },
/// Worker is polling `/api/oneliners`.
Synchronizing,
/// Never got a successful request yet, and never saw a failure either —
/// typical cold start with no network attempt so far.
NeverConnected,
/// Last event on record was a failure, or `last_success` is older
/// than the offline threshold.
Offline,
/// `last_success` is fresh enough, and no failure has overtaken it.
Online,
}
/// Decide which footer status to show. Pure function — no side effects,
/// fully table-testable.
///
/// Decision rules for the `Idle` branch:
/// - A failure younger than the last success (or a failure with no
/// prior success at all) wins → `Offline`.
/// - Otherwise fall back to success-age vs. threshold (classic staleness).
///
/// `last_*_age` arguments are `Option<Duration>` = "seconds since that
/// event, or `None` if it never happened". Smaller Duration = more recent.
pub fn pick_footer_status(
finalizing: bool,
worker_state: WorkerState,
queue_len: usize,
last_success_age: Option<Duration>,
last_failure_age: Option<Duration>,
offline_threshold: Duration,
) -> FooterStatus {
if finalizing {
return FooterStatus::Finalizing;
}
match worker_state {
WorkerState::Uploading => FooterStatus::Uploading {
count: queue_len.max(1),
},
WorkerState::Polling => FooterStatus::Synchronizing,
WorkerState::Idle => {
let failure_more_recent = match (last_success_age, last_failure_age) {
(Some(s), Some(f)) => f < s, // smaller age = younger
(None, Some(_)) => true, // only a failure on record
_ => false,
};
if failure_more_recent {
return FooterStatus::Offline;
}
match last_success_age {
None => FooterStatus::NeverConnected,
Some(age) if age > offline_threshold => FooterStatus::Offline,
Some(_) => FooterStatus::Online,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const THRESHOLD: Duration = Duration::from_secs(30);
/// Convenience: most tests don't care about the failure field.
fn status(
finalizing: bool,
worker_state: WorkerState,
queue_len: usize,
last_success_age: Option<Duration>,
) -> FooterStatus {
pick_footer_status(
finalizing,
worker_state,
queue_len,
last_success_age,
None,
THRESHOLD,
)
}
#[test]
fn finalizing_wins_over_everything() {
let s = pick_footer_status(
true,
WorkerState::Uploading,
5,
Some(Duration::from_millis(1)),
Some(Duration::from_millis(1)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Finalizing);
}
#[test]
fn uploading_when_queue_non_empty() {
let s = status(
false,
WorkerState::Uploading,
3,
Some(Duration::from_millis(1)),
);
assert_eq!(s, FooterStatus::Uploading { count: 3 });
}
#[test]
fn uploading_reports_at_least_one_even_if_scan_raced() {
let s = status(
false,
WorkerState::Uploading,
0,
Some(Duration::from_millis(1)),
);
assert_eq!(s, FooterStatus::Uploading { count: 1 });
}
#[test]
fn synchronizing_when_polling_and_no_queue() {
let s = status(
false,
WorkerState::Polling,
0,
Some(Duration::from_millis(1)),
);
assert_eq!(s, FooterStatus::Synchronizing);
}
#[test]
fn never_connected_without_last_success() {
let s = status(false, WorkerState::Idle, 0, None);
assert_eq!(s, FooterStatus::NeverConnected);
}
#[test]
fn offline_when_last_success_is_stale() {
let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(120)));
assert_eq!(s, FooterStatus::Offline);
}
#[test]
fn online_when_last_success_is_fresh() {
let s = status(false, WorkerState::Idle, 0, Some(Duration::from_secs(5)));
assert_eq!(s, FooterStatus::Online);
}
#[test]
fn offline_threshold_is_inclusive_of_equal_age_as_online() {
let s = status(false, WorkerState::Idle, 0, Some(THRESHOLD));
assert_eq!(s, FooterStatus::Online);
}
#[test]
fn offline_while_queue_has_items_but_worker_backing_off() {
let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(120)));
assert_eq!(s, FooterStatus::Offline);
}
#[test]
fn online_while_queue_has_items_but_worker_idle_and_recent_success() {
let s = status(false, WorkerState::Idle, 3, Some(Duration::from_secs(2)));
assert_eq!(s, FooterStatus::Online);
}
#[test]
fn never_connected_while_queue_has_items_and_no_success_ever() {
let s = status(false, WorkerState::Idle, 3, None);
assert_eq!(s, FooterStatus::NeverConnected);
}
#[test]
fn failure_younger_than_success_forces_offline() {
let s = pick_footer_status(
false,
WorkerState::Idle,
1,
Some(Duration::from_secs(10)),
Some(Duration::from_secs(1)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Offline);
}
#[test]
fn success_younger_than_failure_wins_back_online() {
let s = pick_footer_status(
false,
WorkerState::Idle,
0,
Some(Duration::from_secs(1)),
Some(Duration::from_secs(10)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Online);
}
#[test]
fn only_failure_no_success_is_offline() {
let s = pick_footer_status(
false,
WorkerState::Idle,
0,
None,
Some(Duration::from_secs(5)),
THRESHOLD,
);
assert_eq!(s, FooterStatus::Offline);
}
}
+21
View File
@@ -0,0 +1,21 @@
//! Desktop dictation client — Linux + Windows.
//!
//! Modules listed here are intentionally `pub` so that the Wear-OS
//! mirror (Kotlin) can reference canonical Rust paths via doc anchors
//! like `doctate-desktop::pending_cleanup::cleanup_orphan_audio`.
pub mod app;
pub mod case_store;
pub mod case_update;
pub mod config;
pub mod config_path;
pub mod footer_status;
pub mod magic_link;
pub mod paths;
pub mod pending_cleanup;
pub mod recorder;
pub mod server_sync;
pub mod snapshot_cache;
pub mod startup;
pub mod state;
pub mod upload;
+121
View File
@@ -0,0 +1,121 @@
//! Client-side helper: ask the server for a one-time magic-link token and
//! build the URL the system browser should open.
//!
//! Kept separate from `app.rs` so the network call can be unit-tested
//! against a wiremock server without involving `webbrowser::open` (which
//! would launch a real browser during tests).
//!
//! # Behaviour on error
//!
//! Caller decides. The desktop app's `on_open_web` falls back to the
//! plain case URL if this fails — the doctor lands on the login page and
//! signs in manually rather than seeing a dead button.
use doctate_common::{API_KEY_HEADER, join_url};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Serialize)]
struct CreateRequest<'a> {
return_to: &'a str,
}
#[derive(Deserialize)]
struct CreateResponse {
token: String,
}
#[derive(Debug, Error)]
pub enum MagicLinkError {
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("server returned status {0}")]
Status(reqwest::StatusCode),
}
/// Request a magic-link token and build the full URL to open. The
/// `return_to` path is what the browser lands on **after** the token
/// is consumed; must start with `/web/` (server enforces this too).
pub async fn build_magic_url(
http: &reqwest::Client,
server_url: &str,
api_key: &str,
return_to: &str,
) -> Result<String, MagicLinkError> {
let resp = http
.post(join_url(server_url, "/api/auth/magic-link"))
.header(API_KEY_HEADER, api_key)
.json(&CreateRequest { return_to })
.send()
.await?;
if !resp.status().is_success() {
return Err(MagicLinkError::Status(resp.status()));
}
let parsed: CreateResponse = resp.json().await?;
Ok(join_url(
server_url,
&format!("/web/magic?token={}", parsed.token),
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn returns_full_url_with_token() {
let server = MockServer::start().await;
let token = "tok-1234567890";
Mock::given(method("POST"))
.and(path("/api/auth/magic-link"))
.and(header(API_KEY_HEADER, "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "token": token })))
.mount(&server)
.await;
let http = reqwest::Client::new();
let url = build_magic_url(&http, &server.uri(), "test-key", "/web/cases/abc")
.await
.expect("magic url");
assert_eq!(url, format!("{}/web/magic?token={token}", server.uri()));
}
#[tokio::test]
async fn propagates_server_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/magic-link"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let http = reqwest::Client::new();
let err = build_magic_url(&http, &server.uri(), "wrong-key", "/web/cases/abc")
.await
.expect_err("should error");
assert!(matches!(err, MagicLinkError::Status(s) if s.as_u16() == 401));
}
#[tokio::test]
async fn trims_trailing_slash_in_server_url() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/auth/magic-link"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "token": "abc" })))
.mount(&server)
.await;
let http = reqwest::Client::new();
let url_with_slash = format!("{}/", server.uri());
let url = build_magic_url(&http, &url_with_slash, "k", "/web/cases/abc")
.await
.expect("magic url");
assert!(!url.contains("//web/"), "double slash leaked: {url}");
}
}
+112
View File
@@ -0,0 +1,112 @@
use std::sync::Arc;
use eframe::egui;
use tracing::info;
use tracing_subscriber::EnvFilter;
use std::fs::{File, OpenOptions, TryLockError};
use doctate_desktop::app::DoctateApp;
use doctate_desktop::paths::{cases_dir, lock_file_path, pending_dir, snapshot_cache_path};
/// Unwrap a startup `Result` or print `context: {err}` to stderr and
/// `exit(1)`. Used for unrecoverable bootstrap failures before the
/// egui event loop runs — there is no UI to show an error in.
fn or_die<T, E: std::fmt::Display>(result: Result<T, E>, context: &str) -> T {
match result {
Ok(v) => v,
Err(e) => {
eprintln!("{context}: {e}");
std::process::exit(1);
}
}
}
fn main() -> eframe::Result {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("doctate_desktop=info")),
)
.init();
info!("starting doctate-desktop");
let runtime = Arc::new(
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("build tokio runtime"),
);
let pending = or_die(pending_dir(), "cannot determine pending directory");
// Single-instance guard. The lock is held by the `_lock_file` binding
// for the entire `main` lifetime — OS releases it automatically on
// process exit or crash, so a stale lock file after a crash is
// harmless (the next launch re-acquires).
let _lock_file = acquire_single_instance_lock();
let cases = or_die(cases_dir(), "cannot determine cases directory");
let cache_path = or_die(
snapshot_cache_path(),
"cannot determine snapshot cache path",
);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([400.0, 560.0])
.with_min_inner_size([320.0, 400.0])
.with_resizable(true),
..Default::default()
};
eframe::run_native(
"doctate",
options,
Box::new(move |_cc| {
let app = DoctateApp::new(runtime, pending, cases, cache_path);
Ok(Box::new(app) as Box<dyn eframe::App>)
}),
)
}
/// Open and exclusively lock a per-user `client.lock`. Returns the
/// handle; dropping it (process exit) releases the lock. If another
/// instance already holds it, prints a friendly message and exits.
fn acquire_single_instance_lock() -> File {
let path = or_die(lock_file_path(), "cannot determine lock file path");
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
eprintln!("cannot create data directory {}: {e}", parent.display());
std::process::exit(1);
}
let file = match OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
{
Ok(f) => f,
Err(e) => {
eprintln!("cannot open lock file {}: {e}", path.display());
std::process::exit(1);
}
};
match file.try_lock() {
Ok(()) => file,
Err(TryLockError::WouldBlock) => {
eprintln!(
"doctate läuft bereits (Lock hält auf {}). Nur eine Instanz pro User zulässig.",
path.display()
);
std::process::exit(1);
}
Err(TryLockError::Error(e)) => {
eprintln!("lock acquire failed on {}: {e}", path.display());
std::process::exit(1);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
//! OS-conventional paths for client-side storage.
use std::path::PathBuf;
use directories::ProjectDirs;
use thiserror::Error;
/// Directory name for pending (not-yet-uploaded) recordings inside the
/// data-local root.
const PENDING_DIR_NAME: &str = "pending";
/// Directory name for per-case marker files inside the data-local root.
const CASES_DIR_NAME: &str = "cases";
/// File name for the oneliners snapshot cache inside the data-local root.
const SNAPSHOT_CACHE_FILENAME: &str = "oneliners_cache.json";
/// File name for the single-instance advisory lock inside the data-local
/// root.
const LOCK_FILENAME: &str = "client.lock";
#[derive(Debug, Error)]
pub enum PathError {
#[error("cannot determine OS data directory (is $HOME set?)")]
NoDataDir,
}
/// Resolve `{data_local_dir}/{rel}` under the doctate project scope, or
/// error if the OS won't give us a data directory at all. Every public
/// path helper here delegates to this so the `ProjectDirs::from(…)`
/// triple stays in one place.
fn project_path(rel: &str) -> Result<PathBuf, PathError> {
ProjectDirs::from("", "", "doctate")
.map(|dirs| dirs.data_local_dir().join(rel))
.ok_or(PathError::NoDataDir)
}
/// Directory where recordings wait for upload. Persists across app
/// restarts and crashes.
/// Linux: `~/.local/share/doctate/pending/`
/// Windows: `%LOCALAPPDATA%\doctate\pending\`
/// macOS: `~/Library/Application Support/doctate/pending/`
pub fn pending_dir() -> Result<PathBuf, PathError> {
project_path(PENDING_DIR_NAME)
}
/// Directory holding one JSON marker per known case (locally created
/// and/or server-synced). Persists across app restarts.
/// Linux: `~/.local/share/doctate/cases/`
pub fn cases_dir() -> Result<PathBuf, PathError> {
project_path(CASES_DIR_NAME)
}
/// File path for the last-successful `/api/oneliners` response. Feeds
/// the UI at cold start before the first live poll completes.
pub fn snapshot_cache_path() -> Result<PathBuf, PathError> {
project_path(SNAPSHOT_CACHE_FILENAME)
}
/// File path for the single-instance advisory lock. Held by the running
/// process for its entire lifetime; a second launch attempting to lock
/// the same file gets `WouldBlock` and exits cleanly.
pub fn lock_file_path() -> Result<PathBuf, PathError> {
project_path(LOCK_FILENAME)
}
+219
View File
@@ -0,0 +1,219 @@
//! Orphan reaping for the client's pending-upload directory.
//!
//! The uploader happy-path deletes `.m4a` + `.meta.json` pairs after
//! ACK. A crash between recorder-stop and sidecar-write, or a crash
//! between ACK and the two `remove_file` calls, can leave **orphans**
//! behind:
//!
//! - `*.m4a` without `*.meta.json` (or vice versa): the uploader's
//! `scan_pending_dir` silently skips these on next launch, so they
//! would accumulate indefinitely without explicit cleanup.
//! - `*.json.tmp` / `*.m4a.tmp` leftovers from interrupted atomic
//! writes: pure garbage, always safe to remove.
//!
//! Orphan audio is sensitive — clients must not hoard patient
//! recordings that never made it to the server. This module reaps them
//! on a caller-supplied age threshold. The caller passes an `age_cutoff:
//! SystemTime`; everything whose mtime is strictly older is deleted.
//! `.tmp` leftovers bypass the cutoff (they are never legitimate work
//! in progress at app startup — the lock file guarantees exclusivity).
use std::path::Path;
use std::time::SystemTime;
use tracing::{info, warn};
/// Reap orphan artefacts in `pending_dir`. Returns the number of files
/// deleted. Missing directory is treated as "nothing to do".
pub async fn cleanup_orphan_audio(pending_dir: &Path, age_cutoff: SystemTime) -> usize {
let Ok(mut entries) = tokio::fs::read_dir(pending_dir).await else {
return 0;
};
// First pass: collect filenames so we can answer "does the sibling
// exist?" without doing N separate stat calls.
let mut names: Vec<String> = Vec::new();
while let Ok(Some(e)) = entries.next_entry().await {
if let Some(n) = e.file_name().to_str() {
names.push(n.to_owned());
}
}
let has_name = |n: &str| names.iter().any(|x| x == n);
let mut deleted = 0usize;
for name in &names {
let path = pending_dir.join(name);
// Atomic-write leftovers: always delete, no age check. A running
// process would hold the single-instance lock, so `.tmp` at
// startup is by definition stale.
if name.ends_with(".m4a.tmp") || name.ends_with(".meta.json.tmp") {
if let Err(e) = tokio::fs::remove_file(&path).await {
warn!(path = %path.display(), error = %e, "failed to remove tmp leftover");
} else {
info!(path = %path.display(), "tmp leftover removed");
deleted += 1;
}
continue;
}
// Candidate: orphan `.m4a` — only delete if older than cutoff.
if let Some(stem) = name.strip_suffix(".m4a") {
let sibling = format!("{stem}.meta.json");
if has_name(&sibling) {
continue; // paired, leave for the uploader
}
if mtime_older_than(&path, age_cutoff).await {
if let Err(e) = tokio::fs::remove_file(&path).await {
warn!(path = %path.display(), error = %e, "failed to remove orphan m4a");
} else {
info!(path = %path.display(), "orphan m4a removed (no sidecar)");
deleted += 1;
}
}
continue;
}
// Candidate: orphan sidecar — only delete if older than cutoff.
if let Some(stem) = name.strip_suffix(".meta.json") {
let sibling = format!("{stem}.m4a");
if has_name(&sibling) {
continue;
}
if mtime_older_than(&path, age_cutoff).await {
if let Err(e) = tokio::fs::remove_file(&path).await {
warn!(path = %path.display(), error = %e, "failed to remove orphan sidecar");
} else {
info!(path = %path.display(), "orphan sidecar removed (no m4a)");
deleted += 1;
}
}
continue;
}
}
deleted
}
async fn mtime_older_than(path: &Path, cutoff: SystemTime) -> bool {
match tokio::fs::metadata(path).await {
Ok(meta) => match meta.modified() {
Ok(mtime) => mtime < cutoff,
Err(_) => false,
},
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tempfile::TempDir;
async fn touch_with_mtime(path: &Path, mtime: SystemTime) {
tokio::fs::write(path, b"x").await.unwrap();
filetime::set_file_mtime(path, filetime::FileTime::from_system_time(mtime)).unwrap();
}
fn now() -> SystemTime {
SystemTime::now()
}
#[tokio::test]
async fn missing_directory_returns_zero() {
let tmp = TempDir::new().unwrap();
let n = cleanup_orphan_audio(&tmp.path().join("nope"), now()).await;
assert_eq!(n, 0);
}
#[tokio::test]
async fn paired_files_are_preserved() {
let tmp = TempDir::new().unwrap();
let stem = "uuid_2026-04-18T08-00-00Z";
touch_with_mtime(&tmp.path().join(format!("{stem}.m4a")), now()).await;
touch_with_mtime(&tmp.path().join(format!("{stem}.meta.json")), now()).await;
// Cutoff in the future → any orphan would be deleted — but
// these are paired, must survive.
let cutoff = now() + Duration::from_secs(3600);
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
assert_eq!(n, 0);
assert!(tmp.path().join(format!("{stem}.m4a")).exists());
assert!(tmp.path().join(format!("{stem}.meta.json")).exists());
}
#[tokio::test]
async fn young_orphan_m4a_is_preserved() {
let tmp = TempDir::new().unwrap();
let m4a = tmp.path().join("uuid_young.m4a");
touch_with_mtime(&m4a, now()).await;
// cutoff = 1h ago → file (mtime=now) is younger → keep
let cutoff = now() - Duration::from_secs(3600);
assert_eq!(cleanup_orphan_audio(tmp.path(), cutoff).await, 0);
assert!(m4a.exists());
}
#[tokio::test]
async fn old_orphan_m4a_is_deleted() {
let tmp = TempDir::new().unwrap();
let m4a = tmp.path().join("uuid_old.m4a");
touch_with_mtime(&m4a, now() - Duration::from_secs(48 * 3600)).await;
let cutoff = now() - Duration::from_secs(24 * 3600);
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
assert_eq!(n, 1);
assert!(!m4a.exists(), "old orphan m4a must be gone");
}
#[tokio::test]
async fn old_orphan_sidecar_is_deleted() {
let tmp = TempDir::new().unwrap();
let sc = tmp.path().join("uuid_orphan.meta.json");
touch_with_mtime(&sc, now() - Duration::from_secs(48 * 3600)).await;
let cutoff = now() - Duration::from_secs(24 * 3600);
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
assert_eq!(n, 1);
assert!(!sc.exists());
}
#[tokio::test]
async fn tmp_leftovers_always_removed_regardless_of_age() {
let tmp = TempDir::new().unwrap();
let young_tmp = tmp.path().join("fresh.meta.json.tmp");
let m4a_tmp = tmp.path().join("interrupted.m4a.tmp");
touch_with_mtime(&young_tmp, now()).await;
touch_with_mtime(&m4a_tmp, now()).await;
// Cutoff in the far past → age check would preserve everything.
// But tmp leftovers bypass age check.
let n = cleanup_orphan_audio(tmp.path(), now() - Duration::from_secs(10 * 86400)).await;
assert_eq!(n, 2);
assert!(!young_tmp.exists());
assert!(!m4a_tmp.exists());
}
#[tokio::test]
async fn mix_of_pairs_orphans_and_tmp() {
let tmp = TempDir::new().unwrap();
let keep_stem = "uuid_keep_2026-04-18";
let orphan_stem = "uuid_orphan_old";
// Paired (keep)
touch_with_mtime(&tmp.path().join(format!("{keep_stem}.m4a")), now()).await;
touch_with_mtime(&tmp.path().join(format!("{keep_stem}.meta.json")), now()).await;
// Old orphan m4a (delete)
touch_with_mtime(
&tmp.path().join(format!("{orphan_stem}.m4a")),
now() - Duration::from_secs(48 * 3600),
)
.await;
// tmp leftover (delete)
touch_with_mtime(&tmp.path().join("crash.m4a.tmp"), now()).await;
let cutoff = now() - Duration::from_secs(24 * 3600);
let n = cleanup_orphan_audio(tmp.path(), cutoff).await;
assert_eq!(n, 2, "orphan + tmp must be removed, pair must stay");
assert!(tmp.path().join(format!("{keep_stem}.m4a")).exists());
assert!(tmp.path().join(format!("{keep_stem}.meta.json")).exists());
assert!(!tmp.path().join(format!("{orphan_stem}.m4a")).exists());
assert!(!tmp.path().join("crash.m4a.tmp").exists());
}
}
+316
View File
@@ -0,0 +1,316 @@
//! Audio recorder via an ffmpeg subprocess. Produces m4a/AAC files on disk.
//!
//! Portability note: this module is intentionally UI-agnostic and carries no
//! `egui`/`eframe` deps so it can be lifted into a shared client-core crate
//! once a second Rust client (Windows variant, etc.) arrives.
//!
//! Call `Recorder::start` inside a Tokio runtime context (entered with
//! `Runtime::enter()` or via `#[tokio::main]`).
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use thiserror::Error;
use tokio::process::{Child, Command};
use tokio::sync::{mpsc, oneshot};
use tokio::time::timeout;
use tracing::{info, warn};
/// Max time to wait for ffmpeg to finalize the MOOV atom after stop; past
/// this we hard-kill and emit `Failed`.
const STOP_GRACEFUL_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug)]
pub enum RecorderEvent {
/// ffmpeg spawned successfully; audio capture has begun.
Started,
/// ffmpeg exited cleanly; the m4a file is ready.
Finished { output: PathBuf },
/// ffmpeg or I/O failed; output file may be missing or corrupt.
Failed { error: String },
}
#[derive(Debug, Error)]
pub enum RecorderError {
#[error("ffmpeg not found in PATH")]
FfmpegMissing,
#[error("spawn ffmpeg failed: {0}")]
Spawn(#[from] std::io::Error),
}
/// Handle to a running recorder. Dropping it does NOT stop the recorder —
/// call `stop()` explicitly (consumes self).
pub struct Recorder {
stop_tx: oneshot::Sender<()>,
}
impl Recorder {
/// Spawn a recorder task that captures the system default audio input
/// to `output_path`. Returns a handle + event receiver. The task
/// continues until `stop()` is called, then finalizes the m4a.
pub fn start(
output_path: PathBuf,
) -> Result<(Self, mpsc::UnboundedReceiver<RecorderEvent>), RecorderError> {
let input = audio_input_args();
Self::spawn(output_path, &input)
}
/// Test-only entry point: spawn with custom input args (e.g. `lavfi`
/// test sources instead of a real audio device). Lets integration
/// tests exercise the full lifecycle without depending on hardware.
#[cfg(test)]
fn start_with_input_args(
output_path: PathBuf,
input_args: &[&str],
) -> Result<(Self, mpsc::UnboundedReceiver<RecorderEvent>), RecorderError> {
Self::spawn(output_path, input_args)
}
fn spawn(
output_path: PathBuf,
input_args: &[&str],
) -> Result<(Self, mpsc::UnboundedReceiver<RecorderEvent>), RecorderError> {
if which::which("ffmpeg").is_err() {
return Err(RecorderError::FfmpegMissing);
}
let (events_tx, events_rx) = mpsc::unbounded_channel();
let (stop_tx, stop_rx) = oneshot::channel();
let mut cmd = Command::new("ffmpeg");
cmd.args(ffmpeg_args(&output_path, input_args))
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
let child = cmd.spawn()?;
tokio::spawn(run_recorder_task(child, output_path, stop_rx, events_tx));
Ok((Self { stop_tx }, events_rx))
}
/// Request the recorder to finalize and exit. Consumes the handle —
/// no double-stop. Completion arrives as `Finished` (or `Failed`) on
/// the event receiver.
pub fn stop(self) {
let _ = self.stop_tx.send(());
}
}
async fn run_recorder_task(
mut child: Child,
output_path: PathBuf,
stop_rx: oneshot::Receiver<()>,
events_tx: mpsc::UnboundedSender<RecorderEvent>,
) {
let _ = events_tx.send(RecorderEvent::Started);
tokio::select! {
exit_result = child.wait() => {
let event = match exit_result {
Ok(status) if status.success() => RecorderEvent::Finished { output: output_path },
Ok(status) => RecorderEvent::Failed {
error: format!("ffmpeg exited unexpectedly: {status}"),
},
Err(e) => RecorderEvent::Failed {
error: format!("wait ffmpeg: {e}"),
},
};
let _ = events_tx.send(event);
}
_ = stop_rx => {
finalize(&mut child, output_path, &events_tx).await;
}
}
}
/// Graceful stop. On Unix, ffmpeg does NOT poll stdin for keys when
/// stdin is a pipe (its `term_init()` calls `isatty()` and disables
/// `stdin_interaction` for non-TTY), so writing `q` is silently
/// ignored. SIGINT is honored regardless of TTY status — ffmpeg's
/// signal handler sets a flag, the main loop breaks out between
/// frames, MOOV atom is written, `exit(0)` fires.
///
/// On Windows there is no equivalent via `tokio::process` without
/// launching the child in its own process group; we fall back to
/// writing `q` to stdin and rely on the timeout for corrupt-output
/// detection. Proper Windows graceful stop is future work.
async fn finalize(
child: &mut Child,
output_path: PathBuf,
events_tx: &mpsc::UnboundedSender<RecorderEvent>,
) {
#[cfg(unix)]
{
if let Some(pid) = child.id() {
match send_sigint(pid) {
Ok(()) => info!(pid, "SIGINT sent to ffmpeg"),
Err(e) => warn!(pid, error = %e, "SIGINT failed"),
}
}
}
#[cfg(windows)]
{
use tokio::io::AsyncWriteExt;
if let Some(mut stdin) = child.stdin.take()
&& let Err(e) = stdin.write_all(b"q").await
{
warn!(error = %e, "failed to write stop to ffmpeg stdin");
}
}
// SIGINT causes ffmpeg to exit with a non-zero status (often 255)
// even when the MOOV atom was written cleanly. Don't second-guess
// the exit code — verify the output file was produced and is
// non-empty. Real corruption will surface server-side at decode.
let event = match timeout(STOP_GRACEFUL_TIMEOUT, child.wait()).await {
Ok(Ok(status)) => match tokio::fs::metadata(&output_path).await {
Ok(meta) if meta.len() > 0 => RecorderEvent::Finished {
output: output_path,
},
Ok(_) => RecorderEvent::Failed {
error: format!("ffmpeg exit {status} and empty output"),
},
Err(e) => RecorderEvent::Failed {
error: format!("ffmpeg exit {status} and no output: {e}"),
},
},
Ok(Err(e)) => RecorderEvent::Failed {
error: format!("wait ffmpeg: {e}"),
},
Err(_elapsed) => {
warn!("ffmpeg did not exit within timeout; killing");
let _ = child.kill().await;
RecorderEvent::Failed {
error: "ffmpeg stop timeout — output may be corrupt".into(),
}
}
};
let _ = events_tx.send(event);
}
/// Send SIGINT to a child PID. Used only on Unix.
#[cfg(unix)]
fn send_sigint(pid: u32) -> std::io::Result<()> {
// SAFETY: libc::kill accepts any pid_t; SIGINT is a well-known
// signal. If the child already exited we get ESRCH, which we
// convert to io::Error for the caller to log.
let result = unsafe { libc::kill(pid as i32, libc::SIGINT) };
if result == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
fn ffmpeg_args(output: &Path, input_args: &[&str]) -> Vec<String> {
let mut args: Vec<String> = vec!["-loglevel".into(), "error".into()];
for s in input_args {
args.push((*s).to_string());
}
args.extend([
"-c:a".into(),
"aac".into(),
"-b:a".into(),
"96k".into(),
"-ar".into(),
"48000".into(),
"-ac".into(),
"1".into(),
"-movflags".into(),
"+faststart".into(),
"-y".into(),
output.to_string_lossy().into_owned(),
]);
args
}
#[cfg(target_os = "linux")]
fn audio_input_args() -> Vec<&'static str> {
// PulseAudio interface (also works under PipeWire via pipewire-pulse).
vec!["-f", "pulse", "-i", "default"]
}
#[cfg(target_os = "windows")]
fn audio_input_args() -> Vec<&'static str> {
// TODO(windows-client): enumerate dshow devices via
// `ffmpeg -list_devices true -f dshow -i dummy` and cache the first
// non-loopback capture device. "audio=default" below is a placeholder;
// most Windows systems do not have a device literally named "default",
// so the Windows client must resolve a real name before shipping.
vec!["-f", "dshow", "-i", "audio=default"]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ffmpeg_args_contain_output_path_and_encoder() {
let path = Path::new("/tmp/test.m4a");
let input = audio_input_args();
let args = ffmpeg_args(path, &input);
assert!(args.contains(&"/tmp/test.m4a".to_string()));
assert!(args.contains(&"-y".to_string()));
assert!(args.contains(&"aac".to_string()));
assert!(args.contains(&"+faststart".to_string()));
}
#[test]
fn ffmpeg_args_have_exactly_one_input() {
let input = audio_input_args();
let args = ffmpeg_args(Path::new("/tmp/x.m4a"), &input);
let input_flags = args.iter().filter(|s| *s == "-i").count();
assert_eq!(input_flags, 1);
}
/// End-to-end: spawn ffmpeg with a lavfi silence source, stop via
/// `Recorder::stop()`, verify the m4a is finalized (exists, has
/// non-trivial size, starts with a valid MP4 `ftyp` atom).
///
/// `#[ignore]` because it requires ffmpeg in PATH and takes ~1s.
/// Run with: `cargo test -p doctate-desktop -- --ignored`.
#[tokio::test]
#[ignore = "requires ffmpeg in PATH"]
async fn records_lavfi_source_and_stops_cleanly() {
let tmp = tempfile::TempDir::new().unwrap();
let output = tmp.path().join("lavfi.m4a");
let (recorder, mut events) = Recorder::start_with_input_args(
output.clone(),
&["-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono"],
)
.expect("start recorder");
match events.recv().await {
Some(RecorderEvent::Started) => {}
other => panic!("expected Started, got {other:?}"),
}
tokio::time::sleep(Duration::from_millis(500)).await;
recorder.stop();
let event = tokio::time::timeout(Duration::from_secs(10), events.recv())
.await
.expect("recorder event timed out")
.expect("event channel closed");
let output_file = match event {
RecorderEvent::Finished { output } => output,
other => panic!("expected Finished, got {other:?}"),
};
assert!(output_file.exists(), "output file should exist");
let bytes = std::fs::read(&output_file).unwrap();
assert!(
bytes.len() > 500,
"output should be > 500 bytes, got {}",
bytes.len()
);
// MP4/m4a: "ftyp" box magic at offset 4.
assert_eq!(&bytes[4..8], b"ftyp", "should start with MP4 ftyp atom");
}
}
File diff suppressed because it is too large Load Diff
+176
View File
@@ -0,0 +1,176 @@
//! On-disk cache of the last successful `/api/oneliners` response.
//!
//! Survives app restarts so the UI has data before the first poll
//! completes. A single JSON file under a caller-chosen path. Atomic
//! writes via temp-file + rename. Read errors on malformed content are
//! treated the same as "no cache" — the next poll will rebuild it.
use std::path::{Path, PathBuf};
use doctate_common::oneliners::OnelinersResponse;
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedSnapshot {
/// Last ETag we handed the server in `If-None-Match` — raw header
/// value, including the `W/"..."` wrapper.
pub etag: String,
/// RFC3339 UTC; when the client received the response. Feeds the
/// staleness indicator on cold start.
pub fetched_at: String,
pub response: OnelinersResponse,
}
#[derive(Clone)]
pub struct SnapshotCache {
path: PathBuf,
}
impl SnapshotCache {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
/// Read and deserialize. `None` on missing file, unreadable file, or
/// parse error — the caller treats all three as "cold start".
pub async fn load(&self) -> Option<CachedSnapshot> {
let bytes = match tokio::fs::read(&self.path).await {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
warn!(path = %self.path.display(), error = %e, "snapshot cache read failed");
return None;
}
};
match serde_json::from_slice::<CachedSnapshot>(&bytes) {
Ok(c) => Some(c),
Err(e) => {
warn!(path = %self.path.display(), error = %e, "snapshot cache malformed — ignoring");
None
}
}
}
/// Persist atomically: write to `{path}.tmp`, then rename.
pub async fn store(&self, cached: &CachedSnapshot) -> std::io::Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = self.path.with_extension(tmp_extension(&self.path));
let bytes = serde_json::to_vec(cached)
.map_err(|e| std::io::Error::other(format!("serialize cached snapshot: {e}")))?;
tokio::fs::write(&tmp, bytes).await?;
tokio::fs::rename(&tmp, &self.path).await?;
debug!(path = %self.path.display(), "snapshot cache written");
Ok(())
}
/// Delete the cache file. Idempotent: missing file is success.
pub async fn clear(&self) -> std::io::Result<()> {
match tokio::fs::remove_file(&self.path).await {
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
}
/// Build a `{original_extension}.tmp`-style extension that the rename
/// target never accidentally matches. For `foo.json` → `json.tmp`.
fn tmp_extension(path: &Path) -> String {
match path.extension().and_then(|s| s.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use doctate_common::oneliners::{OnelinerEntry, OnelinerState};
use tempfile::TempDir;
fn sample() -> CachedSnapshot {
CachedSnapshot {
etag: "W/\"1729512345-72\"".into(),
fetched_at: "2026-04-18T10:45:00Z".into(),
response: OnelinersResponse {
as_of: "2026-04-18T10:45:00Z".into(),
window_hours: 72,
oneliners: vec![OnelinerEntry {
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
oneliner: Some(OnelinerState::Ready {
text: "Kniegelenk".into(),
generated_at: "2026-04-18T10:30:00Z".into(),
}),
created_at: "2026-04-18T10:00:00Z".into(),
last_recording_at: Some("2026-04-18T10:30:00Z".into()),
updated_at: Some("2026-04-18T10:30:00Z".into()),
}],
},
}
}
#[tokio::test]
async fn store_and_load_roundtrip() {
let tmp = TempDir::new().unwrap();
let cache = SnapshotCache::new(tmp.path().join("cache.json"));
let s = sample();
cache.store(&s).await.unwrap();
let loaded = cache.load().await.expect("cache present");
assert_eq!(loaded.etag, s.etag);
assert_eq!(loaded.response.oneliners.len(), 1);
let text = match &loaded.response.oneliners[0].oneliner {
Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()),
_ => None,
};
assert_eq!(text, Some("Kniegelenk"));
}
#[tokio::test]
async fn load_returns_none_when_missing() {
let tmp = TempDir::new().unwrap();
let cache = SnapshotCache::new(tmp.path().join("missing.json"));
assert!(cache.load().await.is_none());
}
#[tokio::test]
async fn load_returns_none_on_garbage() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("garbage.json");
tokio::fs::write(&path, b"this is not json").await.unwrap();
let cache = SnapshotCache::new(path);
assert!(cache.load().await.is_none());
}
#[tokio::test]
async fn clear_is_idempotent() {
let tmp = TempDir::new().unwrap();
let cache = SnapshotCache::new(tmp.path().join("cache.json"));
cache.clear().await.unwrap();
cache.store(&sample()).await.unwrap();
assert!(cache.load().await.is_some());
cache.clear().await.unwrap();
assert!(cache.load().await.is_none());
// Second clear on already-empty is still Ok.
cache.clear().await.unwrap();
}
#[tokio::test]
async fn store_creates_parent_dirs() {
let tmp = TempDir::new().unwrap();
let deep = tmp.path().join("a/b/c/cache.json");
let cache = SnapshotCache::new(deep.clone());
cache.store(&sample()).await.unwrap();
assert!(deep.exists());
}
}
+150
View File
@@ -0,0 +1,150 @@
//! One-shot client startup routines shared across all native clients.
//!
//! Today this is just the data-minimization sweep: ephemeral devices
//! (desktop, watch, phone) are "sophisticated microphones" — the client
//! must not hoard patient-sensitive audio or metadata past the
//! dictation-day horizon.
//!
//! Two retention horizons exist:
//! - **Audio** (sensitive): 24 h by default. Orphan m4a/sidecar files
//! that never reached the server get deleted.
//! - **Markers** (non-sensitive: UUIDs + oneliners): 72 h by default.
//! Long enough to span a weekend; short enough that a stolen device
//! can't reconstruct a long case history.
use std::path::Path;
use std::time::{Duration, SystemTime};
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::case_store::CaseStore;
use crate::pending_cleanup::cleanup_orphan_audio;
/// Default retention for case-markers on disk. Confirmed-by-server
/// markers older than this are pruned; unsynced markers are kept
/// forever (they guard pending uploads).
pub const DEFAULT_MARKER_RETENTION: Duration = Duration::from_secs(72 * 3600);
/// Default retention for orphan audio files in the pending directory.
/// Orphan = an `.m4a` without its matching sidecar, or vice versa,
/// older than this horizon.
pub const DEFAULT_ORPHAN_AUDIO_RETENTION: Duration = Duration::from_secs(24 * 3600);
/// Run the one-shot startup cleanup. Intended to be called once at app
/// launch, before the server-sync worker spawns.
///
/// Logs each outcome via `tracing`; no error bubbles up because both
/// sweeps are best-effort (a failing sweep shouldn't prevent the app
/// from starting).
pub async fn run_startup_cleanup(
store: &CaseStore,
pending_dir: &Path,
marker_retention: Duration,
orphan_audio_retention: Duration,
) {
let marker_cutoff =
OffsetDateTime::now_utc() - time::Duration::seconds(marker_retention.as_secs() as i64);
match store.cleanup_stale(marker_cutoff).await {
Ok(n) if n > 0 => info!(count = n, "startup cleanup: stale markers removed"),
Ok(_) => {}
Err(e) => warn!(error = %e, "startup cleanup: marker sweep failed"),
}
let audio_cutoff = SystemTime::now() - orphan_audio_retention;
let n = cleanup_orphan_audio(pending_dir, audio_cutoff).await;
if n > 0 {
info!(count = n, "startup cleanup: orphan audio removed");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::upload::{PendingUpload, write_sidecar};
use filetime::{FileTime, set_file_mtime};
use tempfile::TempDir;
use uuid::Uuid;
/// Integration-style test: seed a stale synced marker + an orphan
/// audio file, call the helper with tight retention horizons, watch
/// both go away.
#[tokio::test]
async fn sweeps_both_stale_markers_and_orphan_audio() {
let tmp = TempDir::new().unwrap();
let cases_dir = tmp.path().join("cases");
let pending_dir = tmp.path().join("pending");
std::fs::create_dir_all(&pending_dir).unwrap();
// Seed a stale synced marker by opening the store, creating it,
// flipping the synced flag via merge, then reopening to re-scan.
{
let (store, _rx) = CaseStore::open(cases_dir.clone()).await.unwrap();
let old_id = Uuid::new_v4();
let then = OffsetDateTime::parse(
"2020-01-01T00:00:00Z",
&time::format_description::well_known::Rfc3339,
)
.unwrap();
store.create_local(old_id, then).await.unwrap();
store.mark_synced(old_id).await.unwrap();
}
// Seed an orphan audio file, pre-dated to be older than 24h.
let orphan = pending_dir.join("orphan.m4a");
std::fs::write(&orphan, b"dummy").unwrap();
let two_days_ago = SystemTime::now() - Duration::from_secs(48 * 3600);
set_file_mtime(&orphan, FileTime::from_system_time(two_days_ago)).unwrap();
// Also seed a fresh, *paired* upload — must survive both sweeps.
let fresh_id = Uuid::new_v4();
let fresh_stem = format!("{fresh_id}_2026-04-18T10-00-00Z");
let fresh_m4a = pending_dir.join(format!("{fresh_stem}.m4a"));
let upload = PendingUpload {
case_id: fresh_id,
recorded_at: "2026-04-18T10:00:00Z".into(),
file: fresh_m4a.clone(),
};
std::fs::write(&fresh_m4a, b"fresh audio").unwrap();
write_sidecar(&upload).unwrap();
// Reopen store to load all on-disk markers.
let (store, _rx) = CaseStore::open(cases_dir).await.unwrap();
run_startup_cleanup(
&store,
&pending_dir,
Duration::from_secs(3600), // markers older than 1h: nuke
Duration::from_secs(3600), // orphans older than 1h: nuke
)
.await;
// Stale marker gone.
assert!(
store.list().await.is_empty(),
"old synced marker must be swept"
);
// Orphan gone.
assert!(!orphan.exists(), "old orphan audio must be swept");
// Fresh paired upload survived.
assert!(fresh_m4a.exists(), "fresh paired m4a must remain");
}
#[tokio::test]
async fn no_op_on_clean_directories() {
let tmp = TempDir::new().unwrap();
let cases_dir = tmp.path().join("cases");
let pending_dir = tmp.path().join("pending");
std::fs::create_dir_all(&pending_dir).unwrap();
let (store, _rx) = CaseStore::open(cases_dir).await.unwrap();
// Should simply return without error and without logs at >= warn.
run_startup_cleanup(
&store,
&pending_dir,
DEFAULT_MARKER_RETENTION,
DEFAULT_ORPHAN_AUDIO_RETENTION,
)
.await;
}
}
+38
View File
@@ -0,0 +1,38 @@
//! UI-visible application state.
//!
//! The state machine here only reflects **user intent** — "ready to
//! record", "recording in progress", "config error, please fix".
//! Background activity (upload queue, poll cycle, finalize flush) lives
//! in a separate observable layer — see
//! `doctate_client_core::footer_status`.
use std::time::Instant;
use uuid::Uuid;
#[derive(Debug)]
pub enum AppState {
/// No valid config on disk — show settings panel.
NotConfigured,
/// Ready for a new recording. No ephemeral context.
Idle,
/// ffmpeg is actively capturing audio.
Recording {
case_id: Uuid,
recorded_at: String,
started_at: Instant,
},
/// Terminal error; user must dismiss or fix config.
Error { message: String },
}
impl AppState {
pub fn label(&self) -> &'static str {
match self {
Self::NotConfigured => "Konfiguration fehlt",
Self::Idle => "Bereit",
Self::Recording { .. } => "Aufnahme läuft",
Self::Error { .. } => "Fehler",
}
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Upload I/O building blocks: `PendingUpload` DTO, atomic sidecar write,
//! pending-dir scan.
//!
//! No network logic here — the actual `POST /api/upload` lives in the
//! `server_sync` worker. This module is only about persistence on the
//! client's disk queue:
//!
//! - `{stem}.m4a` — the recorded audio (written by the recorder)
//! - `{stem}.meta.json` — the sidecar with `case_id` + `recorded_at`
//!
//! A pair of both is a ready-to-upload work item; singletons are orphans
//! that `pending_cleanup` reaps.
use std::path::{Path, PathBuf};
use doctate_common::ack::AckStatus;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::warn;
use uuid::Uuid;
/// A recording waiting to be uploaded. The `file` path is derived from the
/// sidecar's on-disk location, not from the JSON content itself — so the
/// sidecar stays valid if the pending directory is moved/renamed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingUpload {
pub case_id: Uuid,
pub recorded_at: String,
#[serde(skip, default)]
pub file: PathBuf,
}
/// Lifecycle events for a single upload attempt. Emitted by the worker,
/// consumed by the UI.
#[derive(Debug)]
pub enum UploadEvent {
Started(Uuid),
Succeeded {
case_id: Uuid,
status: AckStatus,
},
Failed {
case_id: Uuid,
reason: String,
will_retry: bool,
},
}
#[derive(Debug, Error)]
pub enum UploadIoError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("serialize sidecar: {0}")]
Serialize(#[from] serde_json::Error),
}
/// Sidecar path next to a `.m4a` file: same directory, same stem, `.meta.json`.
pub fn sidecar_path_for(m4a: &Path) -> PathBuf {
let stem = m4a
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let dir = m4a.parent().unwrap_or_else(|| Path::new("."));
dir.join(format!("{stem}.meta.json"))
}
/// Persist a pending upload's metadata next to its m4a. Atomic: writes a
/// `*.tmp` sibling and renames it into place. A crash mid-write cannot
/// leave a zero-byte or partial sidecar on disk — `scan_pending_dir`
/// would otherwise skip the pair as an unreadable orphan.
pub fn write_sidecar(upload: &PendingUpload) -> Result<(), UploadIoError> {
let sidecar = sidecar_path_for(&upload.file);
if let Some(parent) = sidecar.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(upload)?;
let tmp = sidecar.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &sidecar)?;
Ok(())
}
/// Scan a pending directory for `.m4a` files with matching sidecars.
/// Orphans (no sidecar, unreadable sidecar) are logged and skipped — one
/// broken pair should not stop recovery of the rest.
pub fn scan_pending_dir(pending_dir: &Path) -> Vec<PendingUpload> {
let entries = match std::fs::read_dir(pending_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
warn!(dir = ?pending_dir, error = %e, "scan pending dir failed");
return Vec::new();
}
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
continue;
}
match read_pending(&path) {
Ok(upload) => out.push(upload),
Err(e) => warn!(path = ?path, error = %e, "skipping pending entry"),
}
}
out
}
fn read_pending(m4a: &Path) -> Result<PendingUpload, UploadIoError> {
let sidecar = sidecar_path_for(m4a);
let json = std::fs::read_to_string(&sidecar)?;
let mut upload: PendingUpload = serde_json::from_str(&json)?;
upload.file = m4a.to_path_buf();
Ok(upload)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample(dir: &Path, stem: &str) -> PendingUpload {
let file = dir.join(format!("{stem}.m4a"));
std::fs::write(&file, b"dummy audio").unwrap();
PendingUpload {
case_id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
recorded_at: "2026-04-15T10:32:00Z".into(),
file,
}
}
#[test]
fn sidecar_roundtrip_via_scan() {
let tmp = TempDir::new().unwrap();
let upload = sample(tmp.path(), "case_a");
write_sidecar(&upload).unwrap();
let found = scan_pending_dir(tmp.path());
assert_eq!(found.len(), 1);
assert_eq!(found[0].case_id, upload.case_id);
assert_eq!(found[0].recorded_at, upload.recorded_at);
assert_eq!(found[0].file, upload.file);
}
#[test]
fn scan_ignores_m4a_without_sidecar() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("orphan.m4a"), b"x").unwrap();
let found = scan_pending_dir(tmp.path());
assert!(found.is_empty());
}
#[test]
fn scan_returns_empty_for_missing_dir() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist");
assert!(scan_pending_dir(&missing).is_empty());
}
#[test]
fn write_sidecar_leaves_no_tmp_artefact() {
let tmp = TempDir::new().unwrap();
let upload = sample(tmp.path(), "case_b");
write_sidecar(&upload).unwrap();
let sidecar = sidecar_path_for(&upload.file);
assert!(sidecar.exists(), "sidecar must be at final path");
let tmp_path = sidecar.with_extension("json.tmp");
assert!(!tmp_path.exists(), "tmp artefact must not remain");
}
#[test]
fn scan_returns_pairs_in_arbitrary_order_but_all_present() {
let tmp = TempDir::new().unwrap();
for stem in &["case_1", "case_2", "case_3"] {
let upload = sample(tmp.path(), stem);
write_sidecar(&upload).unwrap();
}
let found = scan_pending_dir(tmp.path());
assert_eq!(found.len(), 3);
}
}
@@ -0,0 +1,49 @@
//! Regression guard for the single-instance lock in `main.rs`. The
//! implementation relies on `std::fs::File::try_lock` (stable since
//! Rust 1.89) — specifically that opening the same file a second time
//! from within the same process and calling `try_lock` yields
//! `TryLockError::WouldBlock` while the first handle lives.
//!
//! Two-process testing is out of scope (would need `std::process::Command`
//! with shared state); this test covers the in-process assumption which
//! is what our `acquire_single_instance_lock` depends on.
use std::fs::{OpenOptions, TryLockError};
use tempfile::TempDir;
#[test]
fn second_try_lock_on_same_file_blocks() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("client.lock");
let first = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)
.unwrap();
first.try_lock().expect("first lock must succeed");
let second = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
match second.try_lock() {
Err(TryLockError::WouldBlock) => {}
Ok(()) => panic!("second try_lock unexpectedly succeeded"),
Err(e) => panic!("unexpected lock error: {e:?}"),
}
// Dropping `first` releases the OS lock; the third attempt then
// succeeds — proves the lock truly scopes to the file handle.
drop(first);
let third = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
third.try_lock().expect("after drop, re-lock must succeed");
}
+10
View File
@@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
.watch_serial
+1
View File
@@ -0,0 +1 @@
/build
+117
View File
@@ -0,0 +1,117 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.doctate.watch"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "com.doctate.watch"
minSdk = 30
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Build-time config resolved in this order:
// 1. Gradle CLI property (-Pdoctate.serverUrl=...) — used by run.sh
// to switch between emulator and watch builds without touching
// local.properties.
// 2. local.properties (gitignored) — sticky per-developer default.
// 3. Hardcoded fallback — emulator loopback + sentinel key.
val localProps = Properties().apply {
rootProject.file("local.properties").takeIf { it.exists() }
?.inputStream()?.use { load(it) }
}
fun resolveProp(key: String, default: String): String =
(project.findProperty(key) as? String)
?: localProps.getProperty(key)
?: default
buildConfigField(
"String", "SERVER_URL",
"\"${resolveProp("doctate.serverUrl", "http://10.0.2.2:3000")}\""
)
buildConfigField(
"String", "API_KEY",
"\"${resolveProp("doctate.apiKey", "MISSING_API_KEY")}\""
)
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
useLibrary("wear-sdk")
buildFeatures {
compose = true
buildConfig = true
}
testOptions {
// Stub Android-framework methods (Log.i, etc.) return defaults instead of
// throwing "not mocked" — required for JVM unit tests of code that uses
// android.util.Log alongside core java APIs.
unitTests.isReturnDefaultValues = true
}
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.activity.compose)
implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui.tooling)
implementation(libs.core.splashscreen)
implementation(libs.play.services.wearable)
implementation(libs.ui)
implementation(libs.ui.graphics)
implementation(libs.ui.tooling.preview)
implementation(libs.wear.tooling.preview)
implementation(libs.wear.tiles)
implementation(libs.wear.protolayout)
implementation(libs.wear.protolayout.material3)
implementation(libs.wear.protolayout.expression)
implementation(libs.wear.complications.data.source.ktx)
implementation(libs.wear.compose.navigation)
implementation(libs.wear.input)
implementation(libs.guava)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.ui.test.junit4)
debugImplementation(libs.ui.test.manifest)
debugImplementation(libs.ui.tooling)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
implementation(libs.okhttp)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.truth)
// Real org.json so JVM unit tests don't hit the "not mocked" stub from android.jar.
testImplementation("org.json:json:20240303")
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.okhttp.mockwebserver)
androidTestImplementation(libs.truth)
androidTestImplementation(libs.kotlinx.coroutines.test)
}
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- Ignore the IconLocation for the Tile preview images -->
<issue id="IconLocation">
<ignore path="res/drawable/tile_preview.png" />
<ignore path="res/drawable-round/tile_preview.png" />
</issue>
</lint>
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
Binary file not shown.
@@ -0,0 +1,94 @@
package com.doctate.watch.net
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.doctate.watch.settings.Settings
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class UploadClientTest {
private lateinit var server: MockWebServer
private lateinit var client: UploadClient
private lateinit var audioFile: File
@Before fun setUp() {
server = MockWebServer().apply { start() }
val settings = object : Settings {
override val serverUrl = server.url("/").toString().trimEnd('/')
override val apiKey = "key-dr_test"
}
client = UploadClient(OkHttpClient(), settings)
audioFile = copyAssetToTempFile("sample.m4a")
}
@After fun tearDown() {
server.shutdown()
audioFile.delete()
}
@Test fun upload_sends_correct_multipart_and_returns_success_on_200() = runTest {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""{"case_id":"550e8400-e29b-41d4-a716-446655440000","recorded_at":"2026-04-23T10:30:00Z","status":"received"}"""
)
)
val result = client.upload(
caseId = "550e8400-e29b-41d4-a716-446655440000",
recordedAt = "2026-04-23T10:30:00Z",
audio = audioFile,
)
val req = server.takeRequest()
assertThat(req.method).isEqualTo("POST")
assertThat(req.path).isEqualTo("/api/upload")
assertThat(req.getHeader("X-API-Key")).isEqualTo("key-dr_test")
val body = req.body.readUtf8()
assertThat(body).contains("name=\"case_id\"")
assertThat(body).contains("550e8400-e29b-41d4-a716-446655440000")
assertThat(body).contains("name=\"recorded_at\"")
assertThat(body).contains("2026-04-23T10:30:00Z")
assertThat(body).contains("name=\"audio\"")
assertThat(body).contains("Content-Type: audio/mp4")
assertThat(result).isInstanceOf(UploadResult.Success::class.java)
}
@Test fun upload_classifies_5xx_as_transient() = runTest {
server.enqueue(MockResponse().setResponseCode(500).setBody("oh no"))
val result = client.upload("uuid", "2026-04-23T10:30:00Z", audioFile)
assertThat(result).isInstanceOf(UploadResult.Transient::class.java)
}
@Test fun upload_classifies_401_as_terminal() = runTest {
server.enqueue(MockResponse().setResponseCode(401).setBody("nope"))
val result = client.upload("uuid", "2026-04-23T10:30:00Z", audioFile)
assertThat(result).isInstanceOf(UploadResult.Terminal::class.java)
}
@Test fun upload_classifies_io_error_as_transient() = runTest {
server.shutdown()
val result = client.upload("uuid", "2026-04-23T10:30:00Z", audioFile)
assertThat(result).isInstanceOf(UploadResult.Transient::class.java)
}
private fun copyAssetToTempFile(assetName: String): File {
val instrumentation = InstrumentationRegistry.getInstrumentation()
// Assets are packaged with the test APK; cache dir lives under the app APK.
val testCtx = instrumentation.context
val appCtx = instrumentation.targetContext
val tmp = File.createTempFile("upload-test-", ".m4a", appCtx.cacheDir)
testCtx.assets.open(assetName).use { input ->
tmp.outputStream().use { output -> input.copyTo(output) }
}
return tmp
}
}
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature android:name="android.hardware.type.watch" />
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<application
android:name=".DoctateApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault">
<uses-library
android:name="com.google.android.wearable"
android:required="true" />
<uses-library
android:name="wear-sdk"
android:required="false" />
<!--
Set to true if your app is Standalone, that is, it does not require the handheld
app to run.
-->
<meta-data
android:name="com.google.android.wearable.standalone"
android:value="true" />
<activity
android:name=".presentation.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/MainActivityTheme.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".tile.DoctateTileService"
android:description="@string/tile_description"
android:exported="true"
android:icon="@drawable/tile_preview"
android:label="@string/tile_label"
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER">
<intent-filter>
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
</intent-filter>
<meta-data
android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/tile_preview" />
</service>
<service
android:name=".sync.SyncService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".complication.DoctateComplicationService"
android:exported="true"
android:icon="@drawable/ic_complication"
android:label="@string/app_name"
android:permission="com.google.android.wearable.permission.BIND_COMPLICATION_PROVIDER">
<intent-filter>
<action android:name="android.support.wearable.complications.ACTION_COMPLICATION_UPDATE_REQUEST" />
</intent-filter>
<meta-data
android:name="android.support.wearable.complications.SUPPORTED_TYPES"
android:value="SHORT_TEXT" />
<meta-data
android:name="android.support.wearable.complications.UPDATE_PERIOD_SECONDS"
android:value="0" />
</service>
</application>
</manifest>
@@ -0,0 +1,103 @@
package com.doctate.watch
import android.app.Application
import android.util.Log
import androidx.wear.tiles.TileService
import com.doctate.watch.audio.PendingStore
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.domain.DiskCaseStore
import com.doctate.watch.domain.StartupCleanup
import com.doctate.watch.net.HttpClientProvider
import com.doctate.watch.net.OnelinerOverrideClient
import com.doctate.watch.net.OnelinersClient
import com.doctate.watch.net.SnapshotCache
import com.doctate.watch.net.UploadClient
import com.doctate.watch.settings.BuildConfigSettings
import com.doctate.watch.settings.Settings
import com.doctate.watch.sync.SyncKick
import com.doctate.watch.sync.SyncServiceLauncher
import com.doctate.watch.sync.SyncStateProvider
import com.doctate.watch.sync.SyncTrigger
import com.doctate.watch.tile.DoctateTileService
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
/**
* Application-level service locator. Singletons are exposed via lazy properties so
* consumers (ViewModelFactories, etc.) pull them through `(applicationContext as DoctateApp)`.
*/
class DoctateApp : Application() {
val settings: Settings by lazy { BuildConfigSettings() }
val httpClient: OkHttpClient by lazy { HttpClientProvider.create() }
val uploadClient: UploadClient by lazy { UploadClient(httpClient, settings) }
val onelinersClient: OnelinersClient by lazy { OnelinersClient(httpClient, settings) }
val onelinerOverrideClient: OnelinerOverrideClient by lazy {
OnelinerOverrideClient(httpClient, settings)
}
val snapshotCache: SnapshotCache by lazy {
SnapshotCache(File(filesDir, "recordings/snapshot_cache.json"))
}
/** Persistent disk-backed marker store. Single source of truth for the case list. */
val caseStore: CaseStore by lazy {
DiskCaseStore(File(filesDir, "recordings/cases"))
}
/** Persistent FIFO queue of recordings waiting to be uploaded. */
val pendingStore: PendingStore by lazy {
PendingStore(File(filesDir, "recordings/unsynced"))
}
/** Routes sync kicks into [com.doctate.watch.sync.SyncService]. */
val syncTrigger: SyncTrigger = SyncServiceLauncher
/** Single source of truth for the worker's status, observed by UI/Tile. */
val syncStateProvider: SyncStateProvider by lazy { SyncStateProvider() }
/** Process-scoped coroutine scope for work that must outlive any single ViewModel
* (e.g. the post-stop OneLiner-burst fires after the user swipes back to the list). */
val applicationScope: CoroutineScope by lazy {
CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
override fun onCreate() {
super.onCreate()
// Trigger a Tile refresh whenever the rendered head of the case list
// changes — Tile shows currentCase, only the head matters.
caseStore.casesFlow
.map { it.firstOrNull()?.let { e -> Triple(e.caseId, e.lastActivityAt, e.oneliner) } }
.distinctUntilChanged()
.drop(1) // initial emission is the bootstrap snapshot, no refresh needed
.onEach {
Log.i(TAG, "tile refresh requested: head changed")
TileService.getUpdater(this).requestUpdate(DoctateTileService::class.java)
}
.launchIn(applicationScope)
// One-shot data-minimization sweep before the sync worker starts,
// then kick the service so it polls /api/oneliners and uploads any
// pending recordings. Kicking unconditionally — even with an empty
// queue we want the case list to populate from the server.
applicationScope.launch {
StartupCleanup.run(
store = caseStore,
pendingDir = File(filesDir, "recordings/unsynced"),
)
Log.i(TAG, "startup: kicking sync service (queueLen=${pendingStore.scan().size})")
syncTrigger.kick(this@DoctateApp, SyncKick.NewPending)
}
}
private companion object {
const val TAG = "DoctateApp"
}
}
@@ -0,0 +1,76 @@
package com.doctate.watch.audio
import android.content.Context
import android.media.MediaRecorder
import android.os.Build
import java.io.File
/**
* Thin wrapper around [MediaRecorder] producing an MPEG-4/AAC file on disk.
* Not thread-safe: call start()/stop()/cancel() from a single coroutine scope.
*
* Format: 16 kHz mono AAC-LC at 64 kbps — matches Whisper's native sample rate
* so the server-side remux can skip a resample stage.
*
* Writes directly into the caller-supplied target file so the pending
* upload queue can sit on the audio without an intermediate copy. The
* caller is responsible for choosing a path that survives crashes
* (typically [PendingStore.audioPathFor]).
*/
class AudioRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
private var currentFile: File? = null
/** Start recording into [outFile]. The parent directory is created if missing. */
fun start(outFile: File): File {
require(recorder == null) { "recorder already active" }
outFile.parentFile?.takeIf { !it.exists() }?.mkdirs()
val r = newRecorderInstance().apply {
// VOICE_RECOGNITION lowers raw level vs MIC but applies the
// platform's noise/echo cleaning. Combined with the
// server-side replay-gain pass the playback ends up at
// target loudness with cleaner SNR than raw MIC + post-gain.
setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(16_000)
setAudioEncodingBitRate(64_000)
setAudioChannels(1)
setOutputFile(outFile.absolutePath)
prepare()
start()
}
recorder = r
currentFile = outFile
return outFile
}
fun stop(): File {
val r = requireNotNull(recorder) { "recorder not started" }
val f = requireNotNull(currentFile)
try { r.stop() } finally {
r.release()
recorder = null
currentFile = null
}
return f
}
fun cancel() {
val r = recorder ?: return
val f = currentFile
try { r.stop() } catch (_: RuntimeException) { /* not started or too short — ignore */ }
r.release()
recorder = null
currentFile = null
f?.delete()
}
private fun newRecorderInstance(): MediaRecorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
@@ -0,0 +1,75 @@
package com.doctate.watch.audio
import android.util.Log
import java.io.File
/**
* Reap orphan artefacts in [pendingDir]. Mirrors
* `doctate-client-core::pending_cleanup::cleanup_orphan_audio`.
*
* "Orphan" = an `.m4a` without sidecar (or vice versa) older than
* [ageCutoffMs]. Atomic-write leftovers (`.tmp` files) bypass the cutoff
* — at app startup they cannot be in-progress work; the watch is single-
* process per user.
*
* Returns the count of deleted files. Best-effort: failures are logged
* and skipped, never thrown — a missing-permissions or read-only-fs
* surprise must not stop the watch from starting.
*/
object PendingCleanup {
private const val TAG = "PendingCleanup"
fun cleanupOrphanAudio(pendingDir: File, ageCutoffMs: Long): Int {
if (!pendingDir.exists() || !pendingDir.isDirectory) return 0
val files = pendingDir.listFiles() ?: return 0
val names = files.map { it.name }.toHashSet()
var deleted = 0
for (file in files) {
val name = file.name
// Tmp leftovers: always remove, no age check.
if (name.endsWith(".m4a.tmp") || name.endsWith(".meta.json.tmp")) {
if (file.delete()) {
Log.i(TAG, "tmp leftover removed: $name")
deleted++
} else {
Log.w(TAG, "failed to remove tmp leftover: $name")
}
continue
}
// Orphan m4a: no sidecar present.
if (name.endsWith(".m4a")) {
val stem = name.removeSuffix(".m4a")
val sibling = "$stem.meta.json"
if (sibling in names) continue
if (file.lastModified() < ageCutoffMs) {
if (file.delete()) {
Log.i(TAG, "orphan m4a removed (no sidecar): $name")
deleted++
} else {
Log.w(TAG, "failed to remove orphan m4a: $name")
}
}
continue
}
// Orphan sidecar: no audio present.
if (name.endsWith(".meta.json")) {
val stem = name.removeSuffix(".meta.json")
val sibling = "$stem.m4a"
if (sibling in names) continue
if (file.lastModified() < ageCutoffMs) {
if (file.delete()) {
Log.i(TAG, "orphan sidecar removed (no m4a): $name")
deleted++
} else {
Log.w(TAG, "failed to remove orphan sidecar: $name")
}
}
}
}
return deleted
}
}
@@ -0,0 +1,115 @@
package com.doctate.watch.audio
import android.util.Log
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
import java.time.temporal.ChronoUnit
import org.json.JSONObject
/**
* Persistent FIFO queue of recordings waiting to be uploaded. One pair per
* recording: `{stem}.m4a` + `{stem}.meta.json`. The audio is written by
* [AudioRecorder] directly into [dir]; the sidecar is written here, atomic
* tmp+rename so a crash mid-write cannot leave a partial file.
*
* Mirrors `doctate-client-core::upload` exactly (sidecar shape, scan rules,
* orphan handling). Wire-compatible with the desktop client.
*/
class PendingStore(private val dir: File) {
/** Compute the target audio path for a new recording. Caller passes it to [AudioRecorder.start]. */
fun audioPathFor(caseId: String, recordedAtUtc: String): File {
if (!dir.exists()) dir.mkdirs()
val stem = "${caseId}_${recordedAtToFilenameStem(recordedAtUtc)}"
return File(dir, "$stem.m4a")
}
/** Sidecar path next to a `.m4a` — same dir, same stem, `.meta.json`. */
fun sidecarPathFor(audio: File): File =
File(audio.parentFile ?: dir, "${audio.nameWithoutExtension}.meta.json")
/**
* Persist a sidecar atomically. Caller must have already written the
* `.m4a` audio to [PendingUpload.file]; the sidecar makes the pair
* complete and visible to [scan].
*/
fun writeSidecar(upload: PendingUpload) {
if (!dir.exists()) dir.mkdirs()
val sidecar = sidecarPathFor(upload.file)
val tmp = File(sidecar.parentFile ?: dir, "${sidecar.name}.tmp")
val json = JSONObject().apply {
put("case_id", upload.caseId)
put("recorded_at", upload.recordedAt)
}.toString(2)
tmp.writeText(json, Charsets.UTF_8)
Files.move(
tmp.toPath(),
sidecar.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
}
/**
* Scan [dir] for `.m4a` files with a matching sidecar. Returns the
* complete pairs sorted by `recordedAt` ascending — the upload worker
* processes them FIFO so the oldest dictation wins. Orphans (m4a
* without sidecar, unreadable sidecar) are logged and skipped — one
* broken pair must not stop recovery of the rest.
*/
fun scan(): List<PendingUpload> {
if (!dir.exists()) return emptyList()
val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".m4a") } ?: return emptyList()
val out = mutableListOf<PendingUpload>()
for (audio in files) {
val sidecar = sidecarPathFor(audio)
if (!sidecar.exists()) continue
try {
val obj = JSONObject(sidecar.readText(Charsets.UTF_8))
out += PendingUpload(
caseId = obj.getString("case_id"),
recordedAt = obj.getString("recorded_at"),
file = audio,
)
} catch (e: Exception) {
Log.w(TAG, "skipping unreadable sidecar ${sidecar.name}: ${e.message}")
}
}
return out.sortedBy { it.recordedAt }
}
/**
* Drop both files of a pair. Idempotent — missing files are not an
* error. Called by the sync worker after a Success or Terminal ACK.
*/
fun deletePair(audio: File) {
val sidecar = sidecarPathFor(audio)
if (audio.exists() && !audio.delete()) {
Log.w(TAG, "failed to delete audio ${audio.name}")
}
if (sidecar.exists() && !sidecar.delete()) {
Log.w(TAG, "failed to delete sidecar ${sidecar.name}")
}
}
companion object {
private const val TAG = "PendingStore"
/** RFC3339 → filesystem-safe stem (replace `:` with `-`). 1:1 to
* `doctate-common::timestamp::recorded_at_to_filename_stem`. */
fun recordedAtToFilenameStem(recordedAt: String): String =
recordedAt.replace(':', '-')
/** Current UTC time as RFC3339 with second granularity, e.g.
* `2026-04-15T10:32:00Z`. 1:1 to
* `doctate-common::timestamp::now_rfc3339`. The truncation is
* load-bearing: the server's filename parser expects exactly
* `YYYY-MM-DDTHH-MM-SSZ` after the `:` → `-` substitution, and
* `Instant.now().toString()` would otherwise leak microseconds
* from the platform clock. */
fun nowRfc3339(): String =
Instant.now().truncatedTo(ChronoUnit.SECONDS).toString()
}
}
@@ -0,0 +1,17 @@
package com.doctate.watch.audio
import java.io.File
/**
* One recording waiting to be uploaded. Mirrors
* `doctate-client-core::PendingUpload` (Rust).
*
* The [file] path is not persisted in the sidecar JSON — it's recovered
* from the sidecar's on-disk location, so the pair stays valid if the
* pending directory is moved.
*/
data class PendingUpload(
val caseId: String,
val recordedAt: String,
val file: File,
)
@@ -0,0 +1,55 @@
package com.doctate.watch.complication
import android.app.PendingIntent
import android.content.Intent
import androidx.wear.watchface.complications.data.ComplicationData
import androidx.wear.watchface.complications.data.ComplicationType
import androidx.wear.watchface.complications.data.PlainComplicationText
import androidx.wear.watchface.complications.data.ShortTextComplicationData
import androidx.wear.watchface.complications.datasource.ComplicationRequest
import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService
import com.doctate.watch.presentation.MainActivity
import com.doctate.watch.presentation.NavCommand
/**
* Fast-launch Complication for the Doctate app. Always reports the same label;
* tap opens the case list. Doctors place it on their watchface manually.
*/
class DoctateComplicationService : SuspendingComplicationDataSourceService() {
override fun getPreviewData(type: ComplicationType): ComplicationData? =
when (type) {
ComplicationType.SHORT_TEXT -> buildShortText(previewOnly = true)
else -> null
}
override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? =
when (request.complicationType) {
ComplicationType.SHORT_TEXT -> buildShortText(previewOnly = false)
else -> null
}
private fun buildShortText(previewOnly: Boolean): ShortTextComplicationData {
val text = PlainComplicationText.Builder("Doctate").build()
val builder = ShortTextComplicationData.Builder(
text = text,
contentDescription = PlainComplicationText.Builder("Open Doctate case list").build(),
)
if (!previewOnly) {
builder.setTapAction(buildTapIntent())
}
return builder.build()
}
private fun buildTapIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply {
putExtra(NavCommand.EXTRA_OPEN, NavCommand.OPEN_LIST)
}
return PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
}
@@ -0,0 +1,23 @@
package com.doctate.watch.domain
/**
* Per-case record held client-side. Mirrors the essentials of
* `doctate-client-core::CaseEntry` (Rust).
*
* `oneliner == null` means no state has been observed yet (fresh case);
* the UI renders a placeholder ("…"). `OnelinerState.Manual` marks a
* doctor-authored override; auto-updates from the server merge must not
* overwrite it (the merge logic checks `oneliner.isManual()`).
*
* `syncedToServer` flips to true once the server has acknowledged at
* least one upload for this case. Used by `reconcileWithServerSnapshot`
* to safely drop markers that no longer appear in the server window —
* pending markers are never reconciled away.
*/
data class CaseEntry(
val caseId: String,
val createdAt: Long,
val lastActivityAt: Long,
val oneliner: OnelinerState?,
val syncedToServer: Boolean = false,
)
@@ -0,0 +1,13 @@
package com.doctate.watch.domain
import java.util.UUID
/**
* Each recording session generates a fresh case id. The server treats every unknown
* UUID as a new case directory (server/src/routes/upload.rs:71). Reusing a case id
* across recordings (e.g. caching) would silently merge unrelated dictations into
* one case.
*/
object CaseId {
fun new(): String = UUID.randomUUID().toString()
}
@@ -0,0 +1,85 @@
package com.doctate.watch.domain
import org.json.JSONObject
import java.time.Instant
/**
* On-disk representation of a single case. Wire-format identical to
* `doctate-client-core::CaseMarker` (Rust) — same field names (snake_case),
* same RFC3339 timestamps, same internally-tagged OnelinerState. A marker
* file written by either client should round-trip through the other.
*
* Kept disjoint from [CaseEntry] so the in-memory UI representation can
* use Long epoch-ms (faster to format) without dragging the wire format
* along. Conversion happens in [DiskCaseStore] at the IO boundary.
*/
internal data class CaseMarker(
val caseId: String,
val createdAtRfc: String,
val lastActivityAtRfc: String,
val syncedToServer: Boolean,
val oneliner: OnelinerState?,
) {
fun toJson(): String = JSONObject().apply {
put("case_id", caseId)
put("created_at", createdAtRfc)
put("last_activity_at", lastActivityAtRfc)
put("synced_to_server", syncedToServer)
put("oneliner", oneliner?.let { onelinerToJson(it) } ?: JSONObject.NULL)
}.toString(2)
fun toEntry(): CaseEntry = CaseEntry(
caseId = caseId,
createdAt = Instant.parse(createdAtRfc).toEpochMilli(),
lastActivityAt = Instant.parse(lastActivityAtRfc).toEpochMilli(),
oneliner = oneliner,
syncedToServer = syncedToServer,
)
companion object {
fun fromJson(text: String): CaseMarker {
val obj = JSONObject(text)
return CaseMarker(
caseId = obj.getString("case_id"),
createdAtRfc = obj.getString("created_at"),
lastActivityAtRfc = obj.getString("last_activity_at"),
syncedToServer = obj.optBoolean("synced_to_server", false),
oneliner = obj.optJSONObject("oneliner")?.let { onelinerFromJson(it) },
)
}
}
}
/** Encode `OnelinerState` as the same internally-tagged JSON the server emits. */
internal fun onelinerToJson(state: OnelinerState): JSONObject = when (state) {
is OnelinerState.Ready -> JSONObject()
.put("kind", "ready")
.put("text", state.text)
.put("generated_at", state.generatedAt)
is OnelinerState.Empty -> JSONObject()
.put("kind", "empty")
.put("generated_at", state.generatedAt)
is OnelinerState.Error -> JSONObject()
.put("kind", "error")
.put("generated_at", state.generatedAt)
is OnelinerState.Manual -> JSONObject()
.put("kind", "manual")
.put("text", state.text)
.put("set_at", state.setAt)
}
/** Decode an internally-tagged `OnelinerState`. Unknown kinds raise IllegalArgumentException. */
internal fun onelinerFromJson(obj: JSONObject): OnelinerState =
when (val kind = obj.getString("kind")) {
"ready" -> OnelinerState.Ready(
text = obj.getString("text"),
generatedAt = obj.getString("generated_at"),
)
"empty" -> OnelinerState.Empty(generatedAt = obj.getString("generated_at"))
"error" -> OnelinerState.Error(generatedAt = obj.getString("generated_at"))
"manual" -> OnelinerState.Manual(
text = obj.getString("text"),
setAt = obj.getString("set_at"),
)
else -> throw IllegalArgumentException("unknown OnelinerState kind: $kind")
}
@@ -0,0 +1,55 @@
package com.doctate.watch.domain
import kotlinx.coroutines.flow.StateFlow
/**
* Per-case marker store. Single source of truth for the case list shown in
* Activity, Tile and Complication. Mirrors `doctate-client-core::CaseStore`
* (Rust) — the asymmetric merge/reconcile design is the load-bearing piece:
*
* - [mergeServerSnapshot] only adds/updates. `/api/oneliners` is a sliding
* window, so "missing" means nothing on its own.
* - [reconcileWithServerSnapshot] removes synced markers the server no
* longer reports, scoped to the window it covers. Pending markers are
* never touched — losing one would silently erase a recording.
*/
interface CaseStore {
/** Reactive snapshot, sorted by `lastActivityAt` desc. */
val casesFlow: StateFlow<List<CaseEntry>>
/** Snapshot accessor for non-suspending contexts (Tile, Complication). */
val currentCase: CaseEntry? get() = casesFlow.value.firstOrNull()
/** Append a new case the client itself just started. Returns the new id. */
suspend fun createLocal(now: Long = System.currentTimeMillis()): String
/** Bump `lastActivityAt`. Creates the marker if missing. */
suspend fun markActivity(caseId: String, now: Long = System.currentTimeMillis())
/** Flip `syncedToServer` to true after a successful upload ACK. No-op if absent. */
suspend fun markSynced(caseId: String)
/** Apply a doctor's manual oneliner override locally (optimistic UI). */
suspend fun setOnelinerLocal(caseId: String, state: OnelinerState)
/**
* Apply a server `/api/oneliners` response: insert/update each entry,
* never delete. Server's oneliner + timestamps win, except
* `lastActivityAt` keeps whichever (local vs. server) is newer, and a
* locally-set `Manual` oneliner sticks against non-Manual server states.
*/
suspend fun mergeServerSnapshot(response: OnelinersResponse)
/**
* Remove synced markers inside the response's window that the server
* no longer lists. Returns count removed. Pending markers and markers
* outside the window are preserved.
*/
suspend fun reconcileWithServerSnapshot(response: OnelinersResponse): Int
/** Drop synced markers older than `cutoffMs`. Returns count removed. */
suspend fun cleanupStale(cutoffMs: Long): Int
/** Wipe everything (config change, account switch). */
suspend fun clearAll()
}
@@ -0,0 +1,239 @@
package com.doctate.watch.domain
import android.util.Log
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.time.Instant
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Disk-backed [CaseStore]. One JSON file per case under [dir]; in-memory
* map serialised by [mutex] so concurrent merges from the sync worker and
* the UI can't collide on a write.
*
* Atomic writes via temp-file + `Files.move(... ATOMIC_MOVE, REPLACE_EXISTING)`
* — same trick as `case_store.rs::write_marker_file`. Readers either see
* the previous version or the new one, never a partial blob.
*
* Bootstrap is synchronous (constructor body): the watch can render the
* cached list before any coroutine has run. Empty/missing dir is fine —
* the store starts empty and creates the dir lazily on first write.
*/
class DiskCaseStore(private val dir: File) : CaseStore {
private val state = LinkedHashMap<String, CaseMarker>()
private val mutex = Mutex()
private val _flow = MutableStateFlow<List<CaseEntry>>(emptyList())
override val casesFlow: StateFlow<List<CaseEntry>> = _flow.asStateFlow()
init {
bootstrap()
}
private fun bootstrap() {
if (!dir.exists()) {
dir.mkdirs()
return
}
val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".json") } ?: return
for (f in files) {
try {
val marker = CaseMarker.fromJson(f.readText(Charsets.UTF_8))
state[marker.caseId] = marker
} catch (e: Exception) {
// A single unreadable file shouldn't poison the store.
Log.w(TAG, "skipping unreadable marker ${f.name}: ${e.message}")
}
}
broadcast()
}
override suspend fun createLocal(now: Long): String {
val id = CaseId.new()
val nowRfc = epochMsToRfc(now)
val marker = CaseMarker(
caseId = id,
createdAtRfc = nowRfc,
lastActivityAtRfc = nowRfc,
syncedToServer = false,
oneliner = null,
)
mutex.withLock {
writeMarkerFile(marker)
state[id] = marker
broadcast()
}
Log.i(TAG, "createLocal id=${id.take(8)}")
return id
}
override suspend fun markActivity(caseId: String, now: Long) {
val nowRfc = epochMsToRfc(now)
mutex.withLock {
val existing = state[caseId]
val updated = existing?.copy(lastActivityAtRfc = nowRfc)
?: CaseMarker(
caseId = caseId,
createdAtRfc = nowRfc,
lastActivityAtRfc = nowRfc,
syncedToServer = false,
oneliner = null,
)
writeMarkerFile(updated)
state[caseId] = updated
broadcast()
}
}
override suspend fun markSynced(caseId: String) {
mutex.withLock {
val existing = state[caseId] ?: return
if (existing.syncedToServer) return
val updated = existing.copy(syncedToServer = true)
writeMarkerFile(updated)
state[caseId] = updated
broadcast()
}
Log.i(TAG, "markSynced id=${caseId.take(8)}")
}
override suspend fun setOnelinerLocal(caseId: String, state: OnelinerState) {
mutex.withLock {
val existing = this.state[caseId] ?: return
val updated = existing.copy(oneliner = state)
writeMarkerFile(updated)
this.state[caseId] = updated
broadcast()
}
}
override suspend fun mergeServerSnapshot(response: OnelinersResponse) {
mutex.withLock {
for (entry in response.oneliners) {
val serverActivityRfc = entry.lastRecordingAt
?: entry.updatedAt
?: entry.createdAt
val existing = state[entry.caseId]
val updated = if (existing != null) {
// A locally-set Manual oneliner sticks against non-Manual server overrides.
val keepLocalManual = existing.oneliner is OnelinerState.Manual &&
entry.oneliner !is OnelinerState.Manual
existing.copy(
syncedToServer = true,
oneliner = if (keepLocalManual) existing.oneliner else entry.oneliner,
lastActivityAtRfc = if (serverActivityRfc > existing.lastActivityAtRfc) {
serverActivityRfc
} else existing.lastActivityAtRfc,
)
} else {
CaseMarker(
caseId = entry.caseId,
createdAtRfc = entry.createdAt,
lastActivityAtRfc = serverActivityRfc,
syncedToServer = true,
oneliner = entry.oneliner,
)
}
writeMarkerFile(updated)
state[entry.caseId] = updated
}
broadcast()
}
}
override suspend fun reconcileWithServerSnapshot(response: OnelinersResponse): Int {
// Window anchor: as_of - window_hours, lexicographically comparable to RFC3339.
val asOfMs = Instant.parse(response.asOf).toEpochMilli()
val windowStartMs = asOfMs - response.windowHours * 3_600_000L
val windowStartRfc = epochMsToRfc(windowStartMs)
val serverIds = response.oneliners.map { it.caseId }.toHashSet()
var removed = 0
mutex.withLock {
val toRemove = state.values.filter { m ->
m.syncedToServer &&
m.lastActivityAtRfc >= windowStartRfc &&
m.caseId !in serverIds
}.map { it.caseId }
for (id in toRemove) {
deleteMarkerFile(id)
state.remove(id)
Log.i(TAG, "marker removed (reconcile: missing from server snapshot) id=${id.take(8)}")
removed++
}
if (removed > 0) broadcast()
}
return removed
}
override suspend fun cleanupStale(cutoffMs: Long): Int {
val cutoffRfc = epochMsToRfc(cutoffMs)
var removed = 0
mutex.withLock {
val toRemove = state.values.filter { m ->
m.syncedToServer && m.lastActivityAtRfc < cutoffRfc
}.map { it.caseId }
for (id in toRemove) {
deleteMarkerFile(id)
state.remove(id)
Log.i(TAG, "stale marker removed id=${id.take(8)} cutoff=$cutoffRfc")
removed++
}
if (removed > 0) broadcast()
}
return removed
}
override suspend fun clearAll() {
mutex.withLock {
val files = dir.listFiles { f -> f.name.endsWith(".json") } ?: emptyArray()
for (f in files) {
if (!f.delete()) Log.w(TAG, "failed to delete marker ${f.name} during clearAll")
}
state.clear()
broadcast()
}
}
private fun broadcast() {
// Sort newest-first so .firstOrNull() yields the "current case".
val list = state.values
.map { it.toEntry() }
.sortedByDescending { it.lastActivityAt }
_flow.value = list
}
private fun writeMarkerFile(marker: CaseMarker) {
if (!dir.exists()) dir.mkdirs()
val finalPath = File(dir, "${marker.caseId}.json")
val tmpPath = File(dir, "${marker.caseId}.json.tmp")
tmpPath.writeText(marker.toJson(), Charsets.UTF_8)
Files.move(
tmpPath.toPath(),
finalPath.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
}
private fun deleteMarkerFile(caseId: String) {
val f = File(dir, "$caseId.json")
if (f.exists() && !f.delete()) {
Log.w(TAG, "failed to delete marker $caseId.json")
}
}
companion object {
private const val TAG = "DiskCaseStore"
/** Epoch-ms → RFC3339 UTC string with `Z` suffix (no offset). */
fun epochMsToRfc(ms: Long): String = Instant.ofEpochMilli(ms).toString()
}
}
@@ -0,0 +1,33 @@
package com.doctate.watch.domain
/**
* Outcome of the LLM oneliner generation for a single case. Mirrors
* `doctate-common/src/oneliners.rs::OnelinerState` (Rust). Internally-tagged
* JSON: `{"kind":"ready", ...}`. The variants are disjoint on purpose so the
* UI can distinguish "LLM said nothing medical" (Empty) from "LLM call
* failed" (Error) from "doctor overrode it" (Manual).
*/
sealed interface OnelinerState {
/** Diagnostic timestamp of when this state was produced (RFC3339 UTC). */
val timestamp: String
/** LLM produced a usable summary. */
data class Ready(val text: String, val generatedAt: String) : OnelinerState {
override val timestamp: String get() = generatedAt
}
/** LLM deliberately returned nothing medical — valid empty result, not failure. */
data class Empty(val generatedAt: String) : OnelinerState {
override val timestamp: String get() = generatedAt
}
/** LLM call errored — recovery may retry. */
data class Error(val generatedAt: String) : OnelinerState {
override val timestamp: String get() = generatedAt
}
/** Doctor-authored override. Sticky against auto-regeneration. */
data class Manual(val text: String, val setAt: String) : OnelinerState {
override val timestamp: String get() = setAt
}
}
@@ -0,0 +1,17 @@
package com.doctate.watch.domain
/**
* Text the UI should render for a oneliner state, or null when nothing
* meaningful exists yet. Empty/Error collapse to null so the UI can render
* a placeholder ("…") in both cases.
*/
fun OnelinerState?.displayText(): String? = when (this) {
null -> null
is OnelinerState.Ready -> text
is OnelinerState.Manual -> text
is OnelinerState.Empty -> null
is OnelinerState.Error -> null
}
/** True when the oneliner is a doctor-authored override. Drives auto-update suppression. */
fun OnelinerState?.isManual(): Boolean = this is OnelinerState.Manual
@@ -0,0 +1,57 @@
package com.doctate.watch.domain
import org.json.JSONArray
import org.json.JSONObject
/**
* JSON ↔ Kotlin DTO marshallers for the `/api/oneliners` wire format.
* Server-side source of truth: `doctate-common/src/oneliners.rs`. Field
* names are snake_case to match the Rust serde output.
*
* Lives in `domain` (not `net`) because the parsed shape — including the
* `OnelinerState` sealed interface — is the in-memory model the rest of
* the app reasons about, not a transport-only concern.
*/
internal fun parseOnelinersResponse(text: String): OnelinersResponse {
val obj = JSONObject(text)
val arr = obj.getJSONArray("oneliners")
val entries = ArrayList<OnelinerEntry>(arr.length())
for (i in 0 until arr.length()) {
entries += parseOnelinerEntry(arr.getJSONObject(i))
}
return OnelinersResponse(
asOf = obj.getString("as_of"),
windowHours = obj.getInt("window_hours"),
oneliners = entries,
)
}
internal fun onelinersResponseToJson(response: OnelinersResponse): String {
val arr = JSONArray()
for (e in response.oneliners) arr.put(onelinerEntryToJson(e))
return JSONObject().apply {
put("as_of", response.asOf)
put("window_hours", response.windowHours)
put("oneliners", arr)
}.toString()
}
private fun parseOnelinerEntry(obj: JSONObject): OnelinerEntry = OnelinerEntry(
caseId = obj.getString("case_id"),
oneliner = obj.optJSONObject("oneliner")?.let { onelinerFromJson(it) },
createdAt = obj.getString("created_at"),
lastRecordingAt = obj.optStringOrNull("last_recording_at"),
updatedAt = obj.optStringOrNull("updated_at"),
)
private fun onelinerEntryToJson(entry: OnelinerEntry): JSONObject = JSONObject().apply {
put("case_id", entry.caseId)
put("oneliner", entry.oneliner?.let { onelinerToJson(it) } ?: JSONObject.NULL)
put("created_at", entry.createdAt)
put("last_recording_at", entry.lastRecordingAt ?: JSONObject.NULL)
put("updated_at", entry.updatedAt ?: JSONObject.NULL)
}
/** `optString` returns "" for null/missing; we want a real null. */
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key) && !isNull(key)) getString(key) else null
@@ -0,0 +1,24 @@
package com.doctate.watch.domain
/**
* Wire-format DTOs for `GET /api/oneliners`. Mirror
* `doctate-common/src/oneliners.rs::OnelinersResponse` and `OnelinerEntry`
* exactly; the `OnelinersClient` (Phase 6) parses JSON into these.
*
* Timestamps stay RFC3339 strings — lexicographic comparison on the Zulu
* format is correctly ordered, which is the same trick the Rust client uses
* for window-anchored reconciliation.
*/
data class OnelinersResponse(
val asOf: String,
val windowHours: Int,
val oneliners: List<OnelinerEntry>,
)
data class OnelinerEntry(
val caseId: String,
val oneliner: OnelinerState?,
val createdAt: String,
val lastRecordingAt: String?,
val updatedAt: String?,
)
@@ -0,0 +1,50 @@
package com.doctate.watch.domain
import android.util.Log
import com.doctate.watch.audio.PendingCleanup
import java.io.File
/**
* One-shot startup cleanup. Mirrors `doctate-client-core::startup`.
*
* Two retention horizons: 72 h for synced markers (non-sensitive UUIDs +
* oneliners), 24 h for orphan audio in the pending queue (sensitive). The
* watch is a "sophisticated microphone" — it must not hoard recordings
* that never reached the server past the dictation-day horizon.
*
* Best-effort: failures are logged, never thrown. A failing sweep
* shouldn't stop the watch from starting.
*/
object StartupCleanup {
private const val TAG = "StartupCleanup"
/** 72 hours — long enough to span a weekend, short enough to limit data exposure. */
const val DEFAULT_MARKER_RETENTION_MS: Long = 72L * 3600L * 1000L
/** 24 hours — orphan recordings older than yesterday are presumed lost. */
const val DEFAULT_ORPHAN_AUDIO_RETENTION_MS: Long = 24L * 3600L * 1000L
suspend fun run(
store: CaseStore,
pendingDir: File,
nowMs: Long = System.currentTimeMillis(),
markerRetentionMs: Long = DEFAULT_MARKER_RETENTION_MS,
orphanAudioRetentionMs: Long = DEFAULT_ORPHAN_AUDIO_RETENTION_MS,
) {
try {
val markerCutoff = nowMs - markerRetentionMs
val n = store.cleanupStale(markerCutoff)
if (n > 0) Log.i(TAG, "startup: stale markers removed: $n")
} catch (e: Exception) {
Log.w(TAG, "startup: marker sweep failed: ${e.message}")
}
try {
val audioCutoff = nowMs - orphanAudioRetentionMs
val n = PendingCleanup.cleanupOrphanAudio(pendingDir, audioCutoff)
if (n > 0) Log.i(TAG, "startup: orphan audio removed: $n")
} catch (e: Exception) {
Log.w(TAG, "startup: orphan-audio sweep failed: ${e.message}")
}
}
}
@@ -0,0 +1,17 @@
package com.doctate.watch.net
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
/**
* Single shared OkHttpClient. OkHttp pools connections and threads internally;
* sharing one instance is the recommended pattern (creating new clients leaks pools).
*/
object HttpClientProvider {
fun create(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
}
@@ -0,0 +1,68 @@
package com.doctate.watch.net
import android.util.Log
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.domain.onelinerFromJson
import com.doctate.watch.settings.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/**
* `PUT /api/cases/{case_id}/oneliner` — push a doctor's manual override
* to the server. Mirrors `OnelinerOverrideRequest` from
* `doctate-common/src/oneliners.rs` (just `{text: string}`); the server
* timestamps it server-side as `OnelinerState.Manual` and persists
* `oneliner.json` next to the audio.
*
* The watch fires this fire-and-forget right after a tap-to-edit save:
* UI updates optimistically via `caseStore.setOnelinerLocal`, then we
* push to the server. On failure we log — the next successful poll
* usually pulls in the server-side state and reconciles. Pre-upload
* edits (case_dir doesn't exist server-side) get a 404 here; the local
* Manual sticks until the case is uploaded and the user re-edits.
*/
class OnelinerOverrideClient(
private val http: OkHttpClient,
private val settings: Settings,
) {
sealed interface Outcome {
data class Success(val state: OnelinerState) : Outcome
data class HttpError(val code: Int, val body: String) : Outcome
data class Network(val cause: Throwable) : Outcome
}
suspend fun put(caseId: String, text: String): Outcome = withContext(Dispatchers.IO) {
val payload = JSONObject().put("text", text).toString()
val request = Request.Builder()
.url(settings.serverUrl.trimEnd('/') + buildPath(caseId))
.header(API_KEY_HEADER, settings.apiKey)
.put(payload.toRequestBody(JSON.toMediaType()))
.build()
try {
http.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
if (response.code in 200..299) {
Outcome.Success(onelinerFromJson(JSONObject(body)))
} else {
Log.w(TAG, "PUT oneliner caseId=${caseId.take(8)} http=${response.code} body=$body")
Outcome.HttpError(response.code, body)
}
}
} catch (e: Exception) {
Log.w(TAG, "PUT oneliner caseId=${caseId.take(8)} network: ${e.message}")
Outcome.Network(e)
}
}
companion object {
private const val TAG = "OnelinerOverride"
private const val API_KEY_HEADER = "X-API-Key"
private const val JSON = "application/json"
private fun buildPath(caseId: String): String = "/api/cases/$caseId/oneliner"
}
}
@@ -0,0 +1,58 @@
package com.doctate.watch.net
import com.doctate.watch.domain.parseOnelinersResponse
import com.doctate.watch.settings.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Conditional-GET client for `/api/oneliners`. Wire format mirrors
* `doctate-client-core::server_sync::poll_once`:
*
* - Always sends `X-API-Key`.
* - If [poll] receives a non-null `etag`, sends `If-None-Match: <etag>`.
* - 200 → parse body, surface new ETag (if any).
* - 304 → no body.
* - 408/429/5xx → [PollOutcome.TransientHttp] (worker backs off).
* - Anything else (4xx) → also surfaced as TransientHttp; we don't want
* the polling loop to die over a one-off auth glitch — the upload
* path's terminal-vs-transient distinction is more nuanced.
*/
class OnelinersClient(
private val http: OkHttpClient,
private val settings: Settings,
) {
suspend fun poll(etag: String?): PollOutcome = withContext(Dispatchers.IO) {
val builder = Request.Builder()
.url(settings.serverUrl.trimEnd('/') + ONELINERS_PATH)
.header(API_KEY_HEADER, settings.apiKey)
.get()
if (etag != null) builder.header(IF_NONE_MATCH, etag)
try {
http.newCall(builder.build()).execute().use { response ->
when (response.code) {
200 -> {
val body = response.body?.string().orEmpty()
val parsed = parseOnelinersResponse(body)
val newEtag = response.header(ETAG)
PollOutcome.Success(parsed, newEtag)
}
304 -> PollOutcome.NotModified
else -> PollOutcome.TransientHttp(response.code)
}
}
} catch (e: Exception) {
PollOutcome.Network(e)
}
}
companion object {
const val API_KEY_HEADER = "X-API-Key"
const val ONELINERS_PATH = "/api/oneliners"
const val IF_NONE_MATCH = "If-None-Match"
const val ETAG = "ETag"
}
}
@@ -0,0 +1,18 @@
package com.doctate.watch.net
import com.doctate.watch.domain.OnelinersResponse
/** Outcome of one `/api/oneliners` poll. Mirrors `PollOutcome` in `server_sync.rs`. */
sealed interface PollOutcome {
/** 200 OK — body parsed, etag returned (may be null if server didn't ship one). */
data class Success(val response: OnelinersResponse, val etag: String?) : PollOutcome
/** 304 — client cache is current. */
data object NotModified : PollOutcome
/** Transient HTTP failure (5xx, 408, 429). Caller backs off and retries. */
data class TransientHttp(val code: Int) : PollOutcome
/** Network/IO/parse failure. Treated as transient. */
data class Network(val cause: Throwable) : PollOutcome
}
@@ -0,0 +1,69 @@
package com.doctate.watch.net
import android.util.Log
import com.doctate.watch.domain.OnelinersResponse
import com.doctate.watch.domain.onelinersResponseToJson
import com.doctate.watch.domain.parseOnelinersResponse
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import org.json.JSONObject
/**
* Disk-persisted copy of the most recent `/api/oneliners` response and
* its ETag. Loaded on cold-start so the watch can render its case list
* before any network round-trip — the user never sees a blank list flash
* just because the service hasn't polled yet.
*
* Mirrors `doctate-client-core::snapshot_cache`.
*/
data class CachedSnapshot(
val etag: String,
val fetchedAt: String,
val response: OnelinersResponse,
)
class SnapshotCache(private val file: File) {
fun load(): CachedSnapshot? {
if (!file.exists()) return null
return try {
val obj = JSONObject(file.readText(Charsets.UTF_8))
CachedSnapshot(
etag = obj.getString("etag"),
fetchedAt = obj.getString("fetched_at"),
response = parseOnelinersResponse(obj.getString("response")),
)
} catch (e: Exception) {
Log.w(TAG, "snapshot cache load failed: ${e.message} — starting fresh")
null
}
}
fun store(snapshot: CachedSnapshot) {
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
val tmp = File(file.parentFile, "${file.name}.tmp")
val payload = JSONObject().apply {
put("etag", snapshot.etag)
put("fetched_at", snapshot.fetchedAt)
put("response", onelinersResponseToJson(snapshot.response))
}.toString()
tmp.writeText(payload, Charsets.UTF_8)
Files.move(
tmp.toPath(),
file.toPath(),
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE,
)
}
fun clear() {
if (file.exists() && !file.delete()) {
Log.w(TAG, "snapshot cache clear failed: ${file.name}")
}
}
companion object {
private const val TAG = "SnapshotCache"
}
}
@@ -0,0 +1,77 @@
package com.doctate.watch.net
import com.doctate.watch.settings.Settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import org.json.JSONObject
import java.io.File
import java.io.IOException
/**
* POST a recorded audio file to the Doctate server. Wire format mirrors
* doctate-common/src/constants.rs and doctate-client-core/src/server_sync.rs:344-399.
* If the server contract changes, both this client and server/tests/upload_test.rs
* fail independently — that coupling is intentional.
*/
class UploadClient(
private val http: OkHttpClient,
private val settings: Settings,
) {
suspend fun upload(caseId: String, recordedAt: String, audio: File): UploadResult =
withContext(Dispatchers.IO) {
val body = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(FIELD_CASE_ID, caseId)
.addFormDataPart(FIELD_RECORDED_AT, recordedAt)
.addFormDataPart(
FIELD_AUDIO,
audio.name,
audio.asRequestBody(CONTENT_TYPE_AUDIO_MP4.toMediaType()),
)
.build()
val request = Request.Builder()
.url(settings.serverUrl.trimEnd('/') + UPLOAD_PATH)
.header(API_KEY_HEADER, settings.apiKey)
.post(body)
.build()
try {
http.newCall(request).execute().use { response ->
classify(response.code, response.body?.string().orEmpty())
}
} catch (e: IOException) {
UploadResult.Transient("network: ${e.message}")
}
}
private fun classify(code: Int, body: String): UploadResult = when {
code in 200..299 -> parseAck(body)
code == 408 || code == 429 || code in 500..599 -> UploadResult.Transient("HTTP $code: $body")
else -> UploadResult.Terminal("HTTP $code: $body")
}
private fun parseAck(body: String): UploadResult = try {
val json = JSONObject(body)
UploadResult.Success(
caseId = json.getString("case_id"),
recordedAt = json.getString("recorded_at"),
status = json.getString("status"),
)
} catch (e: Exception) {
UploadResult.Terminal("parse ack: ${e.message}")
}
companion object {
// Wire-protocol constants. Source of truth: doctate-common/src/constants.rs:1-14.
const val API_KEY_HEADER = "X-API-Key"
const val UPLOAD_PATH = "/api/upload"
const val FIELD_CASE_ID = "case_id"
const val FIELD_RECORDED_AT = "recorded_at"
const val FIELD_AUDIO = "audio"
const val CONTENT_TYPE_AUDIO_MP4 = "audio/mp4"
}
}
@@ -0,0 +1,11 @@
package com.doctate.watch.net
/**
* Outcome of [UploadClient.upload]. Mirrors UploadOutcome in
* doctate-client-core/src/server_sync.rs (Succeeded / Transient / Terminal).
*/
sealed interface UploadResult {
data class Success(val caseId: String, val recordedAt: String, val status: String) : UploadResult
data class Transient(val reason: String) : UploadResult
data class Terminal(val reason: String) : UploadResult
}
@@ -0,0 +1,92 @@
package com.doctate.watch.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.wear.compose.material3.AppScaffold
import androidx.wear.compose.navigation.SwipeDismissableNavHost
import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
import com.doctate.watch.DoctateApp
import com.doctate.watch.presentation.theme.DoctateWatchTheme
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
object Routes {
const val LIST = "list"
const val DETAIL = "detail/{caseId}"
const val RECORDING = "recording/{caseId}"
fun detail(caseId: String) = "detail/$caseId"
fun recording(caseId: String) = "recording/$caseId"
}
@Composable
fun AppNav(pendingCommand: StateFlow<NavCommand.Pending>) {
val navController = rememberSwipeDismissableNavController()
val pending by pendingCommand.collectAsStateWithLifecycle()
val app = LocalContext.current.applicationContext as DoctateApp
val coroutineScope = rememberCoroutineScope()
// Key on seq so repeated identical commands still fire.
LaunchedEffect(pending.seq) {
when (val c = pending.cmd) {
NavCommand.ShowList -> {
// Initial value fires at app start; NavHost is already on LIST. No-op.
// Incoming Complication tap or Tile "Fälle" tap: pop to list if stacked.
navController.popBackStack(Routes.LIST, inclusive = false)
}
NavCommand.NewCase -> {
val id = app.caseStore.createLocal()
navController.navigate(Routes.recording(id))
}
is NavCommand.ContinueCase -> {
navController.navigate(Routes.detail(c.caseId))
}
}
}
DoctateWatchTheme {
AppScaffold {
SwipeDismissableNavHost(
navController = navController,
startDestination = Routes.LIST,
) {
composable(Routes.LIST) {
CaseListScreen(
onCaseTap = { caseId ->
navController.navigate(Routes.detail(caseId))
},
onNewCase = {
coroutineScope.launch {
val id = app.caseStore.createLocal()
navController.navigate(Routes.recording(id))
}
},
)
}
composable(
route = Routes.DETAIL,
arguments = listOf(navArgument("caseId") { type = NavType.StringType }),
) { backStack ->
val caseId = backStack.arguments?.getString("caseId").orEmpty()
CaseDetailScreen(
caseId = caseId,
onRecord = { navController.navigate(Routes.recording(caseId)) },
)
}
composable(
route = Routes.RECORDING,
arguments = listOf(navArgument("caseId") { type = NavType.StringType }),
) { backStack ->
val caseId = backStack.arguments?.getString("caseId").orEmpty()
RecordingScreen(caseId = caseId, onDone = { navController.popBackStack() })
}
}
}
}
}
@@ -0,0 +1,209 @@
package com.doctate.watch.presentation
import android.app.Activity
import android.app.RemoteInput
import android.os.Bundle
import android.speech.RecognizerIntent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.wear.compose.material3.EdgeButton
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import androidx.wear.input.RemoteInputIntentHelper
import com.doctate.watch.DoctateApp
import com.doctate.watch.audio.PendingStore
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.domain.displayText
import com.doctate.watch.domain.isManual
import java.time.Instant
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private const val ONELINER_INPUT_KEY = "oneliner"
// BCP-47 tag pinned to German so voice input stays in the app's language
// regardless of the watch's system locale. Medical vocabulary needs de-DE
// to produce usable transcripts.
private val VOICE_INPUT_LANGUAGE: String = Locale.GERMANY.toLanguageTag()
private val detailDateFormat = DateTimeFormatter.ofPattern("dd.MM.yy", Locale.getDefault())
private val detailTimeFormat = DateTimeFormatter.ofPattern("HH:mm:ss", Locale.getDefault())
@Composable
fun CaseDetailScreen(
caseId: String,
onRecord: () -> Unit,
) {
val app = LocalContext.current.applicationContext as DoctateApp
val cases by app.caseStore.casesFlow.collectAsStateWithLifecycle()
val case = cases.firstOrNull { it.caseId == caseId }
val coroutineScope = rememberCoroutineScope()
val editLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val text = result.data
?.let { RemoteInput.getResultsFromIntent(it) }
?.getCharSequence(ONELINER_INPUT_KEY)
?.toString()
?.trim()
.orEmpty()
if (text.isNotEmpty()) {
coroutineScope.launch {
// Optimistic local update — UI flips to Manual immediately.
app.caseStore.setOnelinerLocal(
caseId,
OnelinerState.Manual(text, setAt = PendingStore.nowRfc3339()),
)
// Push the edit to the server. Fire-and-forget: the local
// state stays even if the network is down. Edits before
// the case has been uploaded will 404 here — that's
// logged inside the client and we accept the divergence
// until the next manual edit lands after upload.
app.onelinerOverrideClient.put(caseId, text)
}
}
}
val launchEdit: () -> Unit = {
val languageExtras = Bundle().apply {
putString(RecognizerIntent.EXTRA_LANGUAGE, VOICE_INPUT_LANGUAGE)
putString(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, VOICE_INPUT_LANGUAGE)
}
val remoteInput = RemoteInput.Builder(ONELINER_INPUT_KEY)
.setLabel("OneLiner")
.setAllowFreeFormInput(true)
.addExtras(languageExtras)
.build()
val intent = RemoteInputIntentHelper.createActionRemoteInputIntent().apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE, VOICE_INPUT_LANGUAGE)
putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, VOICE_INPUT_LANGUAGE)
}
RemoteInputIntentHelper.putRemoteInputsExtra(intent, listOf(remoteInput))
RemoteInputIntentHelper.putTitleExtra(intent, "OneLiner")
editLauncher.launch(intent)
}
ScreenScaffold { contentPadding ->
if (case == null) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
"Fall nicht gefunden",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
.padding(horizontal = 16.dp),
) {
// Top third: date label over HH:mm:ss clock.
Column(
modifier = Modifier.weight(1f).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = formatDetailDate(case.createdAt),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
text = formatDetailTime(case.createdAt),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
// Middle third: prominent one-liner. Tap launches the Wear OS
// system input picker (voice / keyboard / handwriting).
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clickable(onClick = launchEdit),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = case.oneliner.displayText()
?.let { if (case.oneliner.isManual()) "👤 $it" else it }
?: "",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
// Bottom third: reserved for EdgeButton.
Spacer(modifier = Modifier.weight(1f))
}
}
EdgeButton(
onClick = onRecord,
modifier = Modifier.align(Alignment.BottomCenter),
) {
Box(
modifier = Modifier
.size(20.dp)
.background(
color = MaterialTheme.colorScheme.error,
shape = CircleShape,
),
)
}
}
}
private fun formatDetailDate(epochMs: Long): String {
val zone = ZoneId.systemDefault()
val created = Instant.ofEpochMilli(epochMs).atZone(zone).toLocalDate()
val today = LocalDate.now(zone)
return when (created) {
today -> "Heute"
today.minusDays(1) -> "Gestern"
else -> created.format(detailDateFormat)
}
}
private fun formatDetailTime(epochMs: Long): String =
Instant.ofEpochMilli(epochMs).atZone(ZoneId.systemDefault()).format(detailTimeFormat)
@@ -0,0 +1,147 @@
package com.doctate.watch.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
import androidx.wear.compose.foundation.lazy.items
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material3.Button
import androidx.wear.compose.material3.Card
import androidx.wear.compose.material3.EdgeButton
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
import com.doctate.watch.DoctateApp
import com.doctate.watch.domain.CaseEntry
import com.doctate.watch.domain.displayText
import com.doctate.watch.domain.isManual
@Composable
fun CaseListScreen(
onCaseTap: (caseId: String) -> Unit,
onNewCase: () -> Unit,
) {
val app = LocalContext.current.applicationContext as DoctateApp
val cases by app.caseStore.casesFlow.collectAsStateWithLifecycle()
val sync by app.syncStateProvider.state.collectAsStateWithLifecycle()
val listState = rememberScalingLazyListState()
// Display oldest-first so the newest entry sits next to the `Neu` EdgeButton.
// Store invariant (lastActivityAt desc) stays untouched for Tile / Complication consumers.
val displayed = cases.asReversed()
// Scroll to the newest entry (now at the bottom) once data is available.
LaunchedEffect(displayed.isNotEmpty()) {
if (displayed.isNotEmpty()) {
listState.scrollToItem(displayed.lastIndex)
}
}
ScreenScaffold(
scrollState = listState,
edgeButton = {
EdgeButton(onClick = onNewCase) {
Text("● Neu")
}
},
edgeButtonSpacing = 4.dp,
) { contentPadding ->
if (displayed.isEmpty()) {
EmptyState(onNewCase)
} else {
if (sync.queueLen > 0) {
Text(
text = "☁↑${sync.queueLen}",
style = MaterialTheme.typography.bodyExtraSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
textAlign = TextAlign.Center,
)
}
ScalingLazyColumn(
state = listState,
contentPadding = contentPadding,
autoCentering = null,
verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
modifier = Modifier.fillMaxSize(),
) {
items(displayed, key = { it.caseId }) { case ->
CaseRow(
case = case,
pendingUpload = case.caseId in sync.pendingCaseIds,
onTap = { onCaseTap(case.caseId) },
)
}
}
}
}
}
@Composable
private fun CaseRow(case: CaseEntry, pendingUpload: Boolean, onTap: () -> Unit) {
Card(
onClick = onTap,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
text = if (pendingUpload) "${formatTime(case.createdAt)}" else formatTime(case.createdAt),
style = MaterialTheme.typography.bodyExtraSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterHorizontally),
)
Text(
text = formatOnelinerForRow(case),
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp),
)
}
}
/** "👤 text" if doctor-authored, plain text if auto-generated, "…" placeholder otherwise. */
private fun formatOnelinerForRow(case: CaseEntry): String {
val text = case.oneliner.displayText() ?: return ""
return if (case.oneliner.isManual()) "👤 $text" else text
}
@Composable
private fun EmptyState(onNewCase: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
"Noch keine Fälle heute",
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Button(
onClick = onNewCase,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
Text("● Neu")
}
}
}
@@ -0,0 +1,56 @@
package com.doctate.watch.presentation
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.flow.MutableStateFlow
class MainActivity : ComponentActivity() {
private val seq = AtomicLong(0)
private val pendingCommand = MutableStateFlow(
NavCommand.Pending(0L, NavCommand.ShowList),
)
private val notificationsLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { /* result is informational; the foreground service still runs without it,
but the persistent notification will be hidden on Wear OS 13+. */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
dispatch(intent)
setContent { AppNav(pendingCommand = pendingCommand) }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
dispatch(intent)
}
private fun dispatch(intent: Intent?) {
pendingCommand.value = NavCommand.Pending(
seq = seq.incrementAndGet(),
cmd = NavCommand.fromIntent(intent),
)
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted = ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
if (!granted) {
notificationsLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
@@ -0,0 +1,39 @@
package com.doctate.watch.presentation
import android.content.Intent
/**
* Normalised entry-point commands carried via Intent extras from Tile, Complication,
* Launcher, or internal navigation. Keeps the string-key protocol confined to
* `fromIntent`; the rest of the code uses the sealed type.
*/
sealed interface NavCommand {
data object ShowList : NavCommand
data object NewCase : NavCommand
data class ContinueCase(val caseId: String) : NavCommand
/**
* Carries a monotonic `seq` so Compose sees every incoming tap as a distinct value
* — crucial when two identical commands (e.g. two "Neu" taps from the Tile) arrive
* back-to-back; `LaunchedEffect(key)` only fires on change.
*/
data class Pending(val seq: Long, val cmd: NavCommand)
companion object {
const val EXTRA_OPEN = "open"
const val EXTRA_CASE_ID = "caseId"
const val OPEN_LIST = "list"
const val OPEN_NEW = "new"
const val OPEN_CONTINUE = "continue"
fun fromIntent(intent: Intent?): NavCommand = when (intent?.getStringExtra(EXTRA_OPEN)) {
OPEN_NEW -> NewCase
OPEN_CONTINUE -> intent.getStringExtra(EXTRA_CASE_ID)
?.takeIf { it.isNotBlank() }
?.let(::ContinueCase)
?: ShowList
else -> ShowList
}
}
}
@@ -0,0 +1,119 @@
package com.doctate.watch.presentation
import android.Manifest
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.wear.compose.material3.EdgeButton
import androidx.wear.compose.material3.MaterialTheme
import androidx.wear.compose.material3.ScreenScaffold
import androidx.wear.compose.material3.Text
@Composable
fun RecordingScreen(
caseId: String,
onDone: () -> Unit,
viewModel: RecordingViewModel = viewModel(
key = caseId,
factory = RecordingViewModel.factoryFor(caseId),
),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val view = LocalView.current
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { granted -> viewModel.onPermissionResult(granted) }
// Auto-start: entering the screen kicks off the record flow. Guard against
// re-entry — only trigger while VM is still Idle.
LaunchedEffect(Unit) {
if (state is UiState.Idle) {
viewModel.onRecordTap()
}
}
// Request mic permission when the VM flips to RequestingPermission.
LaunchedEffect(state is UiState.RequestingPermission) {
if (state is UiState.RequestingPermission) {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
// Pop on any post-Recording state: clean stop (Uploading), auto-stop
// completion (Success), or permission denial / recorder failure (Error).
val isDone = state is UiState.Uploading ||
state is UiState.Success ||
state is UiState.Error
if (isDone) {
LaunchedEffect(Unit) { onDone() }
}
// Option C: keep display on while this composable is alive.
// onDispose doubles as the discard trigger for swipe-right / back —
// discardIfStillRecording() is a no-op when state is already Uploading.
DisposableEffect(Unit) {
view.keepScreenOn = true
onDispose {
view.keepScreenOn = false
viewModel.discardIfStillRecording()
}
}
ScreenScaffold { contentPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
.padding(top = 24.dp, bottom = 72.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val s = state
if (s is UiState.Recording) {
Text(
text = formatDuration(s.elapsedSeconds),
style = MaterialTheme.typography.displayMedium,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center,
)
}
}
if (state is UiState.Recording) {
EdgeButton(
onClick = { viewModel.onStopTap() },
modifier = Modifier.align(Alignment.BottomCenter),
) {
Box(
modifier = Modifier
.size(16.dp)
.background(MaterialTheme.colorScheme.onPrimary),
)
}
}
}
}
private fun formatDuration(seconds: Int): String {
val m = seconds / 60
val s = seconds % 60
return "%d:%02d".format(m, s)
}
@@ -0,0 +1,181 @@
package com.doctate.watch.presentation
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.doctate.watch.DoctateApp
import com.doctate.watch.audio.AudioRecorder
import com.doctate.watch.audio.PendingStore
import com.doctate.watch.audio.PendingUpload
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.sync.SyncKick
import com.doctate.watch.sync.SyncTrigger
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RecordingViewModel(
private val caseId: String,
private val recorder: AudioRecorder,
private val pendingStore: PendingStore,
private val caseStore: CaseStore,
private val syncTrigger: SyncTrigger,
private val appContext: Context,
private val applicationScope: CoroutineScope,
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Idle)
val state: StateFlow<UiState> = _state.asStateFlow()
// Ticker coroutine that counts elapsed seconds during active recording.
private var recordingJob: Job? = null
// Guards against double-finalization when stop, discard and auto-stop race.
private val finalizationStarted = AtomicBoolean(false)
// Captured once recording starts; reused by finalizeAndUpload so audio file name
// and sidecar metadata agree on the same `recorded_at` timestamp.
private var pendingRecordedAt: String? = null
fun onRecordTap() = dispatch(RecordingEvent.OnRecordTap)
fun onPermissionResult(granted: Boolean) {
if (!granted) {
dispatch(RecordingEvent.PermissionDenied)
return
}
dispatch(RecordingEvent.PermissionGranted)
startRecordingFlow()
}
fun onStopTap() {
if (!finalizationStarted.compareAndSet(false, true)) return
recordingJob?.cancel()
recordingJob = null
dispatch(RecordingEvent.OnStopTap)
applicationScope.launch { finalizeAndUpload() }
}
fun onDiscardTap() {
if (!finalizationStarted.compareAndSet(false, true)) return
recordingJob?.cancel()
recordingJob = null
dispatch(RecordingEvent.OnDiscardTap)
Log.i(TAG, "discard: caseId=${caseId.take(8)}")
applicationScope.launch(Dispatchers.IO) {
try {
recorder.cancel()
} catch (e: Exception) {
Log.w(TAG, "recorder.cancel() failed on discard: ${e.message}")
}
}
}
/**
* Idempotent trigger for swipe-dismiss / back-press paths that land in
* DisposableEffect.onDispose. Clean stop already flipped the state to
* Uploading, in which case this is a no-op.
*/
fun discardIfStillRecording() {
if (_state.value is UiState.Recording) onDiscardTap()
}
fun onResetTap() = dispatch(RecordingEvent.OnResetTap)
private fun startRecordingFlow() {
finalizationStarted.set(false)
recordingJob = viewModelScope.launch {
val recordedAt = PendingStore.nowRfc3339()
val target = pendingStore.audioPathFor(caseId, recordedAt)
try {
withContext(Dispatchers.IO) { recorder.start(target) }
pendingRecordedAt = recordedAt
} catch (e: Exception) {
dispatch(RecordingEvent.UploadFailed("Recorder failed: ${e.message}", transient = false))
return@launch
}
var elapsed = 0
while (elapsed < MAX_SECONDS) {
delay(1_000)
elapsed++
dispatch(RecordingEvent.RecordingTick(elapsed))
}
// Hit the safety cap: treat like an external onStopTap().
if (finalizationStarted.compareAndSet(false, true)) {
recordingJob = null
dispatch(RecordingEvent.OnStopTap)
Log.i(TAG, "auto-stop at $MAX_SECONDS s: caseId=${caseId.take(8)}")
applicationScope.launch { finalizeAndUpload() }
}
}
}
/**
* Hand the freshly-finished recording over to the sync queue and kick
* the worker. The actual `POST /api/upload` happens in the foreground
* service (Phase 4); from the UI's point of view "queued" looks like
* "uploaded" — we surface the success state immediately and pop back
* to the case detail.
*/
private suspend fun finalizeAndUpload() {
val finalFile = try {
withContext(Dispatchers.IO) { recorder.stop() }
} catch (e: Exception) {
Log.w(TAG, "recorder.stop() failed: ${e.message}")
dispatch(RecordingEvent.UploadFailed("Stop failed: ${e.message}", transient = false))
return
}
val recordedAt = pendingRecordedAt ?: PendingStore.nowRfc3339()
pendingRecordedAt = null
try {
withContext(Dispatchers.IO) {
pendingStore.writeSidecar(PendingUpload(caseId, recordedAt, finalFile))
}
caseStore.markActivity(caseId)
syncTrigger.kick(appContext, SyncKick.NewPending)
dispatch(RecordingEvent.UploadSucceeded(caseId))
} catch (e: Exception) {
Log.w(TAG, "writeSidecar failed: ${e.message}")
// Audio is on disk but no sidecar: pending_cleanup will reap the orphan.
dispatch(RecordingEvent.UploadFailed("Queue write failed: ${e.message}", transient = false))
}
}
private fun dispatch(event: RecordingEvent) {
_state.update { current -> reduce(current, event) }
}
companion object {
private const val TAG = "RecordingVM"
private const val MAX_SECONDS = 300
fun factoryFor(caseId: String): ViewModelProvider.Factory = viewModelFactory {
initializer {
val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as DoctateApp
RecordingViewModel(
caseId = caseId,
recorder = AudioRecorder(app),
pendingStore = app.pendingStore,
caseStore = app.caseStore,
syncTrigger = app.syncTrigger,
appContext = app,
applicationScope = app.applicationScope,
)
}
}
}
}
@@ -0,0 +1,41 @@
package com.doctate.watch.presentation
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private val timeFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.getDefault())
private val dateTimeFormat = DateTimeFormatter.ofPattern("dd.MM.yy - HH:mm", Locale.getDefault())
/**
* German relative time label for case timestamps.
*
* - < 60 min → "Vor N Minuten" (with singular/plural and "Gerade eben" for 0)
* - today → "HH:mm"
* - yesterday → "Gestern - HH:mm"
* - older → "dd.MM.yy - HH:mm"
*
* Shared by CaseListScreen rows and CaseDetailScreen header so the formats
* never drift apart.
*/
internal fun formatTime(epochMs: Long): String {
val ageMinutes = (System.currentTimeMillis() - epochMs) / 60_000L
if (ageMinutes in 0L until 60L) {
return when (ageMinutes) {
0L -> "Gerade eben"
1L -> "Vor 1 Minute"
else -> "Vor $ageMinutes Minuten"
}
}
val zone = ZoneId.systemDefault()
val created = Instant.ofEpochMilli(epochMs).atZone(zone)
val createdDate = created.toLocalDate()
val today = LocalDate.now(zone)
return when (createdDate) {
today -> created.format(timeFormat)
today.minusDays(1) -> "Gestern - ${created.format(timeFormat)}"
else -> created.format(dateTimeFormat)
}
}
@@ -0,0 +1,59 @@
package com.doctate.watch.presentation
/** What the recording screen currently shows. */
sealed interface UiState {
data object Idle : UiState
data object RequestingPermission : UiState
data class Recording(val elapsedSeconds: Int) : UiState
data object Uploading : UiState
data class Success(val caseId: String) : UiState
data class Error(val message: String, val transient: Boolean) : UiState
}
/** Events that drive state transitions. Side effects live in the ViewModel. */
sealed interface RecordingEvent {
data object OnRecordTap : RecordingEvent
data object PermissionGranted : RecordingEvent
data object PermissionDenied : RecordingEvent
data class RecordingTick(val elapsedSeconds: Int) : RecordingEvent
data object OnStopTap : RecordingEvent
data object OnDiscardTap : RecordingEvent
data object RecordingFinished : RecordingEvent
data class UploadSucceeded(val caseId: String) : RecordingEvent
data class UploadFailed(val message: String, val transient: Boolean) : RecordingEvent
data object OnResetTap : RecordingEvent
}
/**
* Pure state transition. Unknown (state, event) combinations are no-ops: e.g. a late
* UploadSucceeded after the user already tapped reset stays Idle.
*/
internal fun reduce(state: UiState, event: RecordingEvent): UiState = when (state) {
is UiState.Idle -> when (event) {
RecordingEvent.OnRecordTap -> UiState.RequestingPermission
else -> state
}
is UiState.RequestingPermission -> when (event) {
RecordingEvent.PermissionGranted -> UiState.Recording(elapsedSeconds = 0)
RecordingEvent.PermissionDenied ->
UiState.Error(message = "Microphone permission denied", transient = false)
else -> state
}
is UiState.Recording -> when (event) {
is RecordingEvent.RecordingTick -> UiState.Recording(elapsedSeconds = event.elapsedSeconds)
RecordingEvent.OnStopTap -> UiState.Uploading
RecordingEvent.OnDiscardTap -> UiState.Idle
RecordingEvent.RecordingFinished -> UiState.Uploading
is RecordingEvent.UploadFailed -> UiState.Error(event.message, event.transient)
else -> state
}
is UiState.Uploading -> when (event) {
is RecordingEvent.UploadSucceeded -> UiState.Success(caseId = event.caseId)
is RecordingEvent.UploadFailed -> UiState.Error(event.message, event.transient)
else -> state
}
is UiState.Success, is UiState.Error -> when (event) {
RecordingEvent.OnResetTap -> UiState.Idle
else -> state
}
}
@@ -0,0 +1,17 @@
package com.doctate.watch.presentation.theme
import androidx.compose.runtime.Composable
import androidx.wear.compose.material3.MaterialTheme
@Composable
fun DoctateWatchTheme(
content: @Composable () -> Unit
) {
/**
* Empty theme to customize for your app.
* See: https://developer.android.com/jetpack/compose/designsystems/custom
*/
MaterialTheme(
content = content
)
}
@@ -0,0 +1,17 @@
package com.doctate.watch.settings
import com.doctate.watch.BuildConfig
/**
* Read-only access to runtime configuration. Future implementations (e.g. DataStore-backed
* for a user-facing settings screen) can replace [BuildConfigSettings] without touching consumers.
*/
interface Settings {
val serverUrl: String
val apiKey: String
}
class BuildConfigSettings : Settings {
override val serverUrl: String = BuildConfig.SERVER_URL
override val apiKey: String = BuildConfig.API_KEY
}
@@ -0,0 +1,165 @@
package com.doctate.watch.sync
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.doctate.watch.DoctateApp
import com.doctate.watch.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
/**
* Foreground sync service. Runs the [SyncWorkerLoop] for the lifetime of
* the process; survives Doze via `foregroundServiceType="dataSync"`.
*
* Lifecycle: started either from [SyncServiceLauncher.kick] (recording
* just queued an upload) or from [DoctateApp.onCreate] when the pending
* queue is non-empty at app launch (recovery after restart).
*
* Notification is intentionally low-importance and silent — the doctor
* shouldn't be alerted every time a case syncs in the background. The
* channel is registered once on first run.
*/
class SyncService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var loopJob: Job? = null
private val kickChannel = Channel<SyncKick>(Channel.UNLIMITED)
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
registerNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = buildNotification()
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else 0,
)
// Forward the incoming kick (if any) to the loop. Multiple onStartCommand
// calls just enqueue more kicks — the loop coalesces.
intent?.let { i ->
val kindOrdinal = i.getIntExtra(EXTRA_KICK_KIND, KICK_KIND_NEW_PENDING)
val kick = when (kindOrdinal) {
KICK_KIND_BURST_POLL -> {
val caseId = i.getStringExtra(EXTRA_BURST_CASE_ID).orEmpty()
val deadline = i.getLongExtra(EXTRA_BURST_DEADLINE_MS, 0L)
SyncKick.BurstPoll(caseId, deadline)
}
else -> SyncKick.NewPending
}
kickChannel.trySend(kick)
}
if (loopJob?.isActive != true) {
loopJob = startLoop()
}
return START_STICKY
}
private fun startLoop(): Job {
val app = applicationContext as DoctateApp
val loop = SyncWorkerLoop(
pendingStore = app.pendingStore,
uploader = SyncWorkerLoop.Uploader { caseId, recordedAt, audio ->
app.uploadClient.upload(caseId, recordedAt, audio)
},
caseStore = app.caseStore,
state = app.syncStateProvider,
kickChannel = kickChannel,
poller = app.onelinersClient,
snapshotCache = app.snapshotCache,
)
return scope.launch {
try {
loop.run()
} catch (e: Throwable) {
Log.w(TAG, "loop crashed: ${e.message}", e)
}
}
}
override fun onDestroy() {
super.onDestroy()
loopJob?.cancel()
scope.cancel()
kickChannel.close()
}
private fun registerNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Doctate sync",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Background uploads of recordings"
setShowBadge(false)
}
nm.createNotificationChannel(channel)
}
private fun buildNotification(): Notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Doctate")
.setContentText("Synchronisiere Aufnahmen")
.setSmallIcon(R.drawable.ic_complication)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
companion object {
private const val TAG = "SyncService"
private const val NOTIFICATION_ID = 1
private const val CHANNEL_ID = "doctate_sync"
const val ACTION_KICK = "com.doctate.watch.sync.KICK"
const val EXTRA_KICK_KIND = "kick_kind"
const val EXTRA_BURST_CASE_ID = "burst_case_id"
const val EXTRA_BURST_DEADLINE_MS = "burst_deadline_ms"
const val KICK_KIND_NEW_PENDING = 0
const val KICK_KIND_BURST_POLL = 1
fun startIntent(context: Context, kick: SyncKick): Intent {
val intent = Intent(context, SyncService::class.java).apply {
action = ACTION_KICK
when (kick) {
SyncKick.NewPending -> putExtra(EXTRA_KICK_KIND, KICK_KIND_NEW_PENDING)
is SyncKick.BurstPoll -> {
putExtra(EXTRA_KICK_KIND, KICK_KIND_BURST_POLL)
putExtra(EXTRA_BURST_CASE_ID, kick.caseId)
putExtra(EXTRA_BURST_DEADLINE_MS, kick.deadlineMs)
}
}
}
return intent
}
}
}
private fun CoroutineScope.cancel() {
(coroutineContext[Job] ?: return).cancel()
}
@@ -0,0 +1,20 @@
package com.doctate.watch.sync
import android.content.Context
import android.os.Build
/**
* [SyncTrigger] that routes kicks into [SyncService]. Wired in
* [com.doctate.watch.DoctateApp.onCreate]; replaces [NoopSyncTrigger]
* once the service is registered in the manifest.
*/
object SyncServiceLauncher : SyncTrigger {
override fun kick(context: Context, kick: SyncKick) {
val intent = SyncService.startIntent(context, kick)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
@@ -0,0 +1,21 @@
package com.doctate.watch.sync
/**
* Snapshot of what the sync worker is currently doing, surfaced to the UI
* (Tile / List / Detail) so it can render the cloud indicator. Mirrors
* `WorkerSnapshot` in `doctate-client-core::server_sync`.
*
* This is the authoritative view — never trust the queue length you'd
* compute by listing the directory yourself, because the worker can be
* mid-delete-pair and the disk count is briefly off-by-one.
*/
enum class WorkerPhase { Idle, Uploading, Polling }
data class SyncState(
val phase: WorkerPhase = WorkerPhase.Idle,
val queueLen: Int = 0,
val pendingCaseIds: Set<String> = emptySet(),
val lastSuccessAt: Long? = null,
val lastFailureAt: Long? = null,
val lastFailureReason: String? = null,
)
@@ -0,0 +1,36 @@
package com.doctate.watch.sync
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Owns the [SyncState] StateFlow. Written by [SyncWorkerLoop], read by
* the UI. Decoupled from the Service / Application class so JVM tests
* can drive the worker against a fresh provider.
*/
class SyncStateProvider {
private val _state = MutableStateFlow(SyncState())
val state: StateFlow<SyncState> = _state.asStateFlow()
fun setIdle(queueLen: Int, pendingCaseIds: Set<String>) {
_state.update { it.copy(phase = WorkerPhase.Idle, queueLen = queueLen, pendingCaseIds = pendingCaseIds) }
}
fun setUploading(queueLen: Int, pendingCaseIds: Set<String>) {
_state.update { it.copy(phase = WorkerPhase.Uploading, queueLen = queueLen, pendingCaseIds = pendingCaseIds) }
}
fun setPolling(queueLen: Int, pendingCaseIds: Set<String>) {
_state.update { it.copy(phase = WorkerPhase.Polling, queueLen = queueLen, pendingCaseIds = pendingCaseIds) }
}
fun recordSuccess(at: Long) {
_state.update { it.copy(lastSuccessAt = at, lastFailureReason = null) }
}
fun recordFailure(at: Long, reason: String) {
_state.update { it.copy(lastFailureAt = at, lastFailureReason = reason) }
}
}
@@ -0,0 +1,26 @@
package com.doctate.watch.sync
import android.content.Context
/**
* Kick the sync worker. Implementation lives in Phase 4 (`SyncServiceLauncher`);
* Phase 2 only needs the seam so the recording flow can call into it.
*
* Different kicks carry different intent — `NewPending` says the queue
* just grew, `BurstPoll` (Phase 7) says "poll aggressively for this case
* for the next 60 seconds".
*/
sealed interface SyncKick {
data object NewPending : SyncKick
data class BurstPoll(val caseId: String, val deadlineMs: Long) : SyncKick
}
/** Indirection so the ViewModel can call into the sync service without owning a reference to it. */
fun interface SyncTrigger {
fun kick(context: Context, kick: SyncKick)
}
/** No-op. Replaced by [com.doctate.watch.sync.SyncServiceLauncher] in Phase 4. */
object NoopSyncTrigger : SyncTrigger {
override fun kick(context: Context, kick: SyncKick) = Unit
}
@@ -0,0 +1,255 @@
package com.doctate.watch.sync
import android.util.Log
import com.doctate.watch.audio.PendingStore
import com.doctate.watch.audio.PendingUpload
import com.doctate.watch.domain.CaseStore
import com.doctate.watch.net.OnelinersClient
import com.doctate.watch.net.PollOutcome
import com.doctate.watch.net.SnapshotCache
import com.doctate.watch.net.UploadResult
import com.doctate.watch.domain.parseOnelinersResponse // re-export only for callers
import com.doctate.watch.domain.OnelinersResponse
import com.doctate.watch.domain.OnelinerEntry
import com.doctate.watch.net.CachedSnapshot
import java.io.File
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
import java.time.Instant
/**
* Pure upload + poll worker, testable on the JVM. Mirrors the loop in
* `doctate-client-core::server_sync::run` — uploads have priority over
* polling.
*
* The worker exposes [runOnce] for direct test invocation and [run] for
* the production infinite-loop variant. Splitting them keeps tests off
* the `runTest` ↔ infinite-loop tarpit (advanceUntilIdle has no
* stopping condition against an unconditional while).
*
* If [poller] is null the loop is upload-only — this is the Phase 4
* shape; Phase 6 wires in the real `OnelinersClient` so the idle branch
* does conditional GET against `/api/oneliners`.
*/
class SyncWorkerLoop(
private val pendingStore: PendingStore,
private val uploader: Uploader,
private val caseStore: CaseStore,
private val state: SyncStateProvider,
private val kickChannel: Channel<SyncKick>,
private val poller: OnelinersClient? = null,
private val snapshotCache: SnapshotCache? = null,
private val initialBackoffMs: Long = INITIAL_BACKOFF_MS,
private val maxBackoffMs: Long = MAX_BACKOFF_MS,
private val pollIntervalMsProvider: () -> Long = { DEFAULT_POLL_INTERVAL_MS },
private val nowMs: () -> Long = { System.currentTimeMillis() },
) {
/** Functional seam: takes the same args as `UploadClient.upload`. */
fun interface Uploader {
suspend fun upload(caseId: String, recordedAt: String, audio: File): UploadResult
}
/** Outcome of a single iteration. */
sealed interface Step {
/** A pending upload was processed. */
data class Uploaded(val result: UploadResult) : Step
/** Idle branch ran a poll. */
data class Polled(val outcome: PollOutcome) : Step
/** Idle branch with no poller wired (test/Phase-4 shape). */
data object Idle : Step
}
private var backoffMs: Long = initialBackoffMs
private var etag: String? = null
// Post-stop burst polling state. Active when a BurstPoll kick has arrived
// and the deadline has not yet passed and the named case still lacks a
// text-bearing oneliner. While active, [currentPollIntervalMs] returns
// [BURST_POLL_INTERVAL_MS] instead of [DEFAULT_POLL_INTERVAL_MS] — the
// perceived "time-to-oneliner" after stop is what tells the doctor
// whether the automation works, so we burn an extra 30 polls in 60s
// for a chance at sub-five-second feedback.
private var burstCaseId: String? = null
private var burstDeadlineMs: Long = 0L
suspend fun runOnce(): Step {
val pending = pendingStore.scan()
val next = pending.firstOrNull()
if (next != null) {
publish(WorkerPhase.Uploading, pending)
val result = uploader.upload(next.caseId, next.recordedAt, next.file)
applyUploadResult(next, result)
return Step.Uploaded(result)
}
if (poller != null) {
publish(WorkerPhase.Polling, emptyList())
val outcome = poller.poll(etag)
applyPollOutcome(outcome)
publish(WorkerPhase.Idle, emptyList())
return Step.Polled(outcome)
}
publish(WorkerPhase.Idle, emptyList())
return Step.Idle
}
/** Infinite loop variant for production. Idle branch waits on kick or poll interval. */
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun run() {
// Cold-start prime: load the cached snapshot so the UI has data
// before any network call lands. Etag is hydrated so the very first
// poll can be a 304.
snapshotCache?.load()?.let { cached ->
try {
caseStore.mergeServerSnapshot(cached.response)
etag = cached.etag
Log.i(TAG, "cold-start primed from snapshot cache")
} catch (e: Exception) {
Log.w(TAG, "snapshot prime failed: ${e.message}")
}
}
while (currentCoroutineContext().isActive) {
when (runOnce()) {
is Step.Uploaded -> Unit // proceed immediately
is Step.Polled, Step.Idle -> waitForKickOrTimeout()
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun waitForKickOrTimeout() {
select<Unit> {
onTimeout(currentPollIntervalMs()) {}
kickChannel.onReceiveCatching { res ->
res.getOrNull()?.let { handleKick(it) }
}
}
}
private fun handleKick(kick: SyncKick) {
when (kick) {
SyncKick.NewPending -> Unit // wake up, next iteration scans
is SyncKick.BurstPoll -> {
burstCaseId = kick.caseId
burstDeadlineMs = kick.deadlineMs
Log.i(TAG, "burst-poll started caseId=${kick.caseId.take(8)} deadline=${kick.deadlineMs}")
}
}
}
/** Effective poll interval: 2 s while a burst is active, else the configured default. */
private fun currentPollIntervalMs(): Long {
if (burstActive()) return BURST_POLL_INTERVAL_MS
return pollIntervalMsProvider()
}
private fun burstActive(): Boolean {
if (burstCaseId == null) return false
if (nowMs() >= burstDeadlineMs) {
Log.i(TAG, "burst-poll budget expired caseId=${burstCaseId?.take(8)}")
burstCaseId = null
return false
}
return true
}
/** Test-only: trigger the kick handler synchronously. */
internal fun applyKickForTest(kick: SyncKick) = handleKick(kick)
private suspend fun applyUploadResult(next: PendingUpload, result: UploadResult) {
when (result) {
is UploadResult.Success -> {
pendingStore.deletePair(next.file)
caseStore.markSynced(next.caseId)
state.recordSuccess(nowMs())
backoffMs = initialBackoffMs
// Self-kick a burst poll for this case — gives the user a
// sub-five-second oneliner if the LLM is fast.
kickChannel.trySend(SyncKick.BurstPoll(next.caseId, nowMs() + BURST_POLL_BUDGET_MS))
Log.i(TAG, "upload ok caseId=${next.caseId.take(8)} status=${result.status}")
}
is UploadResult.Transient -> {
state.recordFailure(nowMs(), result.reason)
Log.w(TAG, "transient: ${result.reason} → backoff ${backoffMs}ms")
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(maxBackoffMs)
}
is UploadResult.Terminal -> {
Log.w(TAG, "terminal: ${result.reason} → dropping caseId=${next.caseId.take(8)}")
pendingStore.deletePair(next.file)
state.recordFailure(nowMs(), result.reason)
backoffMs = initialBackoffMs
}
}
}
private suspend fun applyPollOutcome(outcome: PollOutcome) {
when (outcome) {
is PollOutcome.Success -> {
caseStore.mergeServerSnapshot(outcome.response)
caseStore.reconcileWithServerSnapshot(outcome.response)
if (outcome.etag != null) {
snapshotCache?.store(
CachedSnapshot(
etag = outcome.etag,
fetchedAt = Instant.ofEpochMilli(nowMs()).toString(),
response = outcome.response,
),
)
etag = outcome.etag
} else {
Log.w(TAG, "server returned 200 without ETag — conditional requests disabled")
etag = null
}
state.recordSuccess(nowMs())
// Burst poll: did the case the user just stopped on get its oneliner?
burstCaseId?.let { caseId ->
val entry = outcome.response.oneliners.firstOrNull { it.caseId == caseId }
if (entry?.oneliner != null) {
Log.i(TAG, "burst-poll hit caseId=${caseId.take(8)} → returning to default interval")
burstCaseId = null
}
}
}
is PollOutcome.NotModified -> {
state.recordSuccess(nowMs())
}
is PollOutcome.TransientHttp -> {
state.recordFailure(nowMs(), "poll HTTP ${outcome.code}")
Log.w(TAG, "poll transient: HTTP ${outcome.code}")
}
is PollOutcome.Network -> {
state.recordFailure(nowMs(), "poll: ${outcome.cause.message}")
Log.w(TAG, "poll network: ${outcome.cause.message}")
}
}
}
private fun publish(phase: WorkerPhase, pending: List<PendingUpload>) {
val ids = pending.map { it.caseId }.toSet()
when (phase) {
WorkerPhase.Idle -> state.setIdle(pending.size, ids)
WorkerPhase.Uploading -> state.setUploading(pending.size, ids)
WorkerPhase.Polling -> state.setPolling(pending.size, ids)
}
}
/** Test-only accessors. */
internal val currentBackoffMs: Long get() = backoffMs
internal val currentEtag: String? get() = etag
internal val isBurstActive: Boolean get() = burstActive()
internal val effectivePollIntervalMs: Long get() = currentPollIntervalMs()
companion object {
private const val TAG = "SyncWorkerLoop"
const val INITIAL_BACKOFF_MS: Long = 2_000L
const val MAX_BACKOFF_MS: Long = 60_000L
const val DEFAULT_POLL_INTERVAL_MS: Long = 30_000L
const val BURST_POLL_INTERVAL_MS: Long = 2_000L
const val BURST_POLL_BUDGET_MS: Long = 60_000L
}
}
@@ -0,0 +1,55 @@
package com.doctate.watch.tile
import androidx.wear.protolayout.LayoutElementBuilders
import androidx.wear.protolayout.ResourceBuilders
import androidx.wear.protolayout.TimelineBuilders
import androidx.wear.tiles.RequestBuilders
import androidx.wear.tiles.TileBuilders
import androidx.wear.tiles.TileService
import com.doctate.watch.DoctateApp
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
private const val RESOURCES_VERSION = "1"
/**
* Serves the single Doctate Tile. Reads a non-blocking snapshot from
* [DoctateApp.caseStore] and renders the "glance at current case" view with
* three tap regions. Refreshes are triggered externally via
* `TileService.getUpdater(ctx).requestUpdate(...)` — the App wires that up
* to the case-store flow.
*/
class DoctateTileService : TileService() {
override fun onTileRequest(
requestParams: RequestBuilders.TileRequest,
): ListenableFuture<TileBuilders.Tile> {
val app = applicationContext as DoctateApp
val current = app.caseStore.currentCase
val layout = LayoutElementBuilders.Layout.Builder()
.setRoot(buildTileLayout(packageName, current))
.build()
val timeline = TimelineBuilders.Timeline.Builder()
.addTimelineEntry(
TimelineBuilders.TimelineEntry.Builder()
.setLayout(layout)
.build(),
)
.build()
val tile = TileBuilders.Tile.Builder()
.setResourcesVersion(RESOURCES_VERSION)
.setTileTimeline(timeline)
.build()
return Futures.immediateFuture(tile)
}
override fun onTileResourcesRequest(
requestParams: RequestBuilders.ResourcesRequest,
): ListenableFuture<ResourceBuilders.Resources> {
val resources = ResourceBuilders.Resources.Builder()
.setVersion(RESOURCES_VERSION)
.build()
return Futures.immediateFuture(resources)
}
}
@@ -0,0 +1,166 @@
package com.doctate.watch.tile
import androidx.wear.protolayout.ActionBuilders
import androidx.wear.protolayout.ColorBuilders.argb
import androidx.wear.protolayout.DimensionBuilders.dp
import androidx.wear.protolayout.DimensionBuilders.expand
import androidx.wear.protolayout.LayoutElementBuilders
import androidx.wear.protolayout.LayoutElementBuilders.Box
import androidx.wear.protolayout.LayoutElementBuilders.Column
import androidx.wear.protolayout.LayoutElementBuilders.FontStyle
import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER
import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_END
import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_START
import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement
import androidx.wear.protolayout.LayoutElementBuilders.Row
import androidx.wear.protolayout.LayoutElementBuilders.Spacer
import androidx.wear.protolayout.LayoutElementBuilders.Text
import androidx.wear.protolayout.LayoutElementBuilders.VERTICAL_ALIGN_BOTTOM
import androidx.wear.protolayout.LayoutElementBuilders.VERTICAL_ALIGN_CENTER
import androidx.wear.protolayout.ModifiersBuilders.Background
import androidx.wear.protolayout.ModifiersBuilders.Clickable
import androidx.wear.protolayout.ModifiersBuilders.Corner
import androidx.wear.protolayout.ModifiersBuilders.Modifiers
import androidx.wear.protolayout.ModifiersBuilders.Padding
import androidx.wear.protolayout.TypeBuilders.StringProp
import com.doctate.watch.domain.CaseEntry
import com.doctate.watch.domain.displayText
import com.doctate.watch.domain.isManual
import com.doctate.watch.presentation.NavCommand
private const val CLICK_ID_LIST = "list"
private const val CLICK_ID_NEW = "new"
private const val CLICK_ID_CONTINUE = "continue"
/** Build the root layout element for the Tile. Returns the "current case" view or an
* empty-state view depending on whether any case exists in [CaseStoreStub]. */
internal fun buildTileLayout(packageName: String, current: CaseEntry?): LayoutElement {
val column = Column.Builder()
.setWidth(expand())
.setHeight(expand())
.setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
.addContent(topRow(packageName))
.addContent(Spacer.Builder().setHeight(dp(8f)).build())
.addContent(middleContent(packageName, current))
.addContent(Spacer.Builder().setHeight(dp(8f)).build())
.addContent(bottomNewButton(packageName))
.build()
return Box.Builder()
.setWidth(expand())
.setHeight(expand())
.setVerticalAlignment(VERTICAL_ALIGN_CENTER)
.addContent(column)
.build()
}
private fun topRow(packageName: String): LayoutElement {
val faelle = Text.Builder()
.setText("☰ Fälle")
.setFontStyle(FontStyle.Builder().setSize(sp(14f)).build())
.setModifiers(
Modifiers.Builder()
.setClickable(launchClickable(CLICK_ID_LIST, packageName, NavCommand.OPEN_LIST))
.setPadding(
Padding.Builder().setStart(dp(8f)).setEnd(dp(8f))
.setTop(dp(4f)).setBottom(dp(4f)).build(),
)
.build(),
)
.build()
// Row has no horizontal-alignment prop; use a Box with END alignment instead.
return Box.Builder()
.setWidth(expand())
.setHorizontalAlignment(HORIZONTAL_ALIGN_END)
.setVerticalAlignment(VERTICAL_ALIGN_CENTER)
.addContent(faelle)
.build()
}
private fun middleContent(packageName: String, current: CaseEntry?): LayoutElement {
val label = when {
current == null -> "Noch kein Fall heute"
else -> current.oneliner.displayText()
?.let { if (current.oneliner.isManual()) "👤 $it" else it }
?: ""
}
val textBuilder = Text.Builder()
.setText(label)
.setMaxLines(3)
.setFontStyle(FontStyle.Builder().setSize(sp(16f)).build())
val modifiersBuilder = Modifiers.Builder()
.setPadding(
Padding.Builder().setStart(dp(12f)).setEnd(dp(12f))
.setTop(dp(6f)).setBottom(dp(6f)).build(),
)
if (current != null) {
modifiersBuilder.setClickable(
launchClickable(
CLICK_ID_CONTINUE,
packageName,
NavCommand.OPEN_CONTINUE,
caseId = current.caseId,
),
)
}
textBuilder.setModifiers(modifiersBuilder.build())
return textBuilder.build()
}
private fun bottomNewButton(packageName: String): LayoutElement {
val text = Text.Builder()
.setText("● Neu")
.setFontStyle(FontStyle.Builder().setSize(sp(14f)).build())
.build()
return Box.Builder()
.setWidth(expand())
.setHeight(dp(36f))
.setVerticalAlignment(VERTICAL_ALIGN_BOTTOM)
.setHorizontalAlignment(HORIZONTAL_ALIGN_CENTER)
.setModifiers(
Modifiers.Builder()
.setClickable(launchClickable(CLICK_ID_NEW, packageName, NavCommand.OPEN_NEW))
.setBackground(
Background.Builder()
.setColor(argb(0xFF1F1F1F.toInt()))
.setCorner(Corner.Builder().setRadius(dp(18f)).build())
.build(),
)
.setPadding(
Padding.Builder().setStart(dp(12f)).setEnd(dp(12f))
.setTop(dp(6f)).setBottom(dp(6f)).build(),
)
.build(),
)
.addContent(text)
.build()
}
private fun launchClickable(
id: String,
packageName: String,
open: String,
caseId: String? = null,
): Clickable {
val activity = ActionBuilders.AndroidActivity.Builder()
.setPackageName(packageName)
.setClassName("com.doctate.watch.presentation.MainActivity")
.addKeyToExtraMapping(
NavCommand.EXTRA_OPEN,
ActionBuilders.AndroidStringExtra.Builder().setValue(open).build(),
)
if (caseId != null) {
activity.addKeyToExtraMapping(
NavCommand.EXTRA_CASE_ID,
ActionBuilders.AndroidStringExtra.Builder().setValue(caseId).build(),
)
}
return Clickable.Builder()
.setId(id)
.setOnClick(ActionBuilders.LaunchAction.Builder().setAndroidActivity(activity.build()).build())
.build()
}
private fun sp(size: Float): androidx.wear.protolayout.DimensionBuilders.SpProp =
androidx.wear.protolayout.DimensionBuilders.SpProp.Builder().setValue(size).build()
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?android:attr/colorForeground">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM9,17l-5,-5 1.41,-1.41L9,14.17l9.59,-9.59L20,6l-11,11z" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:width="48dp"
android:height="48dp"
android:gravity="center">
<shape android:shape="oval">
<solid android:color="#FFFFFF" />
</shape>
</item>
<item
android:width="40dp"
android:height="40dp"
android:gravity="center">
<vector
android:width="24dp"
android:height="24dp"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z" />
</vector>
</item>
</layer-list>
@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="192dp"
android:height="192dp"
android:viewportWidth="192"
android:viewportHeight="192">
<!-- Dark circular tile background. -->
<path
android:fillColor="#1F1F1F"
android:pathData="M96,96m-96,0a96,96 0,1 1,192 0a96,96 0,1 1,-192 0" />
<!-- Stylised "D" glyph to hint at Doctate branding. -->
<path
android:fillColor="#E6E0E9"
android:pathData="M64,56 L64,136 L96,136 C119.196,136 136,119.196 136,96 C136,72.804 119.196,56 96,56 L64,56 Z M80,72 L96,72 C109.255,72 120,82.745 120,96 C120,109.255 109.255,120 96,120 L80,120 L80,72 Z" />
<!-- Bottom accent bar to suggest the "● Neu" EdgeButton. -->
<path
android:fillColor="#E6E0E9"
android:pathData="M58,160 L134,160 A10,10 0 0 1 134,180 L58,180 A10,10 0 0 1 58,160 Z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

@@ -0,0 +1,6 @@
<resources>
<string name="app_name">Doctate Watch</string>
<string name="hello_world">Doctate!</string>
<string name="tile_description">Aktueller Fall und Schnellstart</string>
<string name="tile_label">Doctate</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="MainActivityTheme.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@android:color/black</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="postSplashScreenTheme">@android:style/Theme.DeviceDefault</item>
</style>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Allow cleartext only for development targets:
- 10.0.2.2 is the Android emulator's host loopback alias.
- 192.168.178.27 is the dev laptop on the home LAN, reachable
from the real Pixel Watch over WiFi.
Production HTTPS targets remain locked down by base-config. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">192.168.178.27</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,73 @@
package com.doctate.watch.audio
import com.google.common.truth.Truth.assertThat
import java.io.File
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class PendingCleanupTest {
@get:Rule val tempDir = TemporaryFolder()
private fun pendingDir(): File = tempDir.newFolder("unsynced")
private fun touch(parent: File, name: String, mtime: Long): File {
val f = File(parent, name)
f.writeText("x")
f.setLastModified(mtime)
return f
}
@Test fun deletes_orphan_m4a_older_than_cutoff() {
val dir = pendingDir()
val now = 10_000_000L
touch(dir, "case-a.m4a", mtime = now - 30_000L)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(1)
assertThat(File(dir, "case-a.m4a").exists()).isFalse()
}
@Test fun preserves_orphan_m4a_younger_than_cutoff() {
val dir = pendingDir()
val now = 10_000_000L
touch(dir, "case-a.m4a", mtime = now - 5_000L)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(0)
assertThat(File(dir, "case-a.m4a").exists()).isTrue()
}
@Test fun preserves_paired_files_regardless_of_age() {
val dir = pendingDir()
val now = 10_000_000L
touch(dir, "case-a.m4a", mtime = now - 30_000L)
touch(dir, "case-a.meta.json", mtime = now - 30_000L)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(0)
assertThat(File(dir, "case-a.m4a").exists()).isTrue()
assertThat(File(dir, "case-a.meta.json").exists()).isTrue()
}
@Test fun deletes_orphan_sidecar_older_than_cutoff() {
val dir = pendingDir()
val now = 10_000_000L
touch(dir, "case-x.meta.json", mtime = now - 30_000L)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(1)
assertThat(File(dir, "case-x.meta.json").exists()).isFalse()
}
@Test fun deletes_tmp_leftovers_unconditionally() {
val dir = pendingDir()
val now = 10_000_000L
// Tmp files might be brand new (interrupted write) — still delete.
touch(dir, "case-a.m4a.tmp", mtime = now)
touch(dir, "case-b.meta.json.tmp", mtime = now)
val deleted = PendingCleanup.cleanupOrphanAudio(dir, ageCutoffMs = now - 10_000L)
assertThat(deleted).isEqualTo(2)
}
@Test fun returns_zero_for_missing_dir() {
val deleted = PendingCleanup.cleanupOrphanAudio(File(tempDir.root, "missing"), ageCutoffMs = 0L)
assertThat(deleted).isEqualTo(0)
}
}
@@ -0,0 +1,116 @@
package com.doctate.watch.audio
import com.google.common.truth.Truth.assertThat
import java.io.File
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class PendingStoreTest {
@get:Rule val tempDir = TemporaryFolder()
private fun newStore(): PendingStore = PendingStore(File(tempDir.root, "unsynced"))
private fun writeAudio(store: PendingStore, caseId: String, recordedAt: String): File {
val audio = store.audioPathFor(caseId, recordedAt)
audio.writeBytes(byteArrayOf(0x66, 0x74, 0x79, 0x70)) // dummy 'ftyp' bytes
return audio
}
@Test fun audioPathFor_replaces_colons_in_recorded_at() {
val store = newStore()
val path = store.audioPathFor("case-aaa", "2026-04-26T10:30:45Z")
assertThat(path.name).isEqualTo("case-aaa_2026-04-26T10-30-45Z.m4a")
}
@Test fun writeSidecar_atomic_no_tmp_left() {
val store = newStore()
val audio = writeAudio(store, "case-a", "2026-04-26T10:30:45Z")
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:30:45Z", audio))
val sidecar = store.sidecarPathFor(audio)
assertThat(sidecar.exists()).isTrue()
val tmpArtefacts = audio.parentFile?.listFiles { f -> f.name.endsWith(".tmp") } ?: emptyArray()
assertThat(tmpArtefacts).isEmpty()
}
@Test fun scan_returns_pairs_only_when_sidecar_present() {
val store = newStore()
// pair 1: complete
val a = writeAudio(store, "case-a", "2026-04-26T10:00:00Z")
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:00:00Z", a))
// pair 2: orphan audio (no sidecar) — must be skipped, not crash the scan
writeAudio(store, "case-b", "2026-04-26T11:00:00Z")
val found = store.scan()
assertThat(found.map { it.caseId }).containsExactly("case-a")
}
@Test fun scan_skips_unreadable_sidecar_without_killing_recovery() {
val store = newStore()
// pair 1: complete
val a = writeAudio(store, "case-a", "2026-04-26T10:00:00Z")
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:00:00Z", a))
// pair 2: corrupted sidecar
val b = writeAudio(store, "case-b", "2026-04-26T11:00:00Z")
store.sidecarPathFor(b).writeText("{ this is not valid json", Charsets.UTF_8)
val found = store.scan()
assertThat(found.map { it.caseId }).containsExactly("case-a")
}
@Test fun scan_returns_pairs_in_recordedAt_ascending_order_for_FIFO_upload() {
val store = newStore()
val ts3 = "2026-04-26T12:00:00Z"
val ts1 = "2026-04-26T10:00:00Z"
val ts2 = "2026-04-26T11:00:00Z"
// Insert out of chronological order — scan must still sort.
for ((id, ts) in listOf("c-c" to ts3, "c-a" to ts1, "c-b" to ts2)) {
val audio = writeAudio(store, id, ts)
store.writeSidecar(PendingUpload(id, ts, audio))
}
val recorded = store.scan().map { it.recordedAt }
assertThat(recorded).isInOrder() // ascending
}
@Test fun scan_returns_empty_for_missing_dir() {
val missing = PendingStore(File(tempDir.root, "does-not-exist"))
assertThat(missing.scan()).isEmpty()
}
@Test fun deletePair_idempotent_even_when_files_missing() {
val store = newStore()
val audio = writeAudio(store, "case-x", "2026-04-26T10:00:00Z")
store.writeSidecar(PendingUpload("case-x", "2026-04-26T10:00:00Z", audio))
// First delete: both files go.
store.deletePair(audio)
assertThat(audio.exists()).isFalse()
assertThat(store.sidecarPathFor(audio).exists()).isFalse()
// Second delete: no exception.
store.deletePair(audio)
}
@Test fun roundtrip_via_scan_recovers_caseId_and_recordedAt() {
val store = newStore()
val audio = writeAudio(store, "case-a", "2026-04-26T10:00:00Z")
store.writeSidecar(PendingUpload("case-a", "2026-04-26T10:00:00Z", audio))
val found = store.scan().single()
assertThat(found.caseId).isEqualTo("case-a")
assertThat(found.recordedAt).isEqualTo("2026-04-26T10:00:00Z")
assertThat(found.file.absolutePath).isEqualTo(audio.absolutePath)
}
/** Regression guard: subsecond precision must be stripped. A single
* microsecond leak would feed the server filename parser
* `YYYY-MM-DDTHH-MM-SS.NNNNNNZ.m4a`, which it treats as malformed —
* the resulting `<time datetime>` is empty and the day-grouping
* falls back to `today`, producing duplicate "Heute" headers.
* Mirrors `now_rfc3339_has_no_subsecond_component` in the Rust
* `doctate-common::timestamp` crate. */
@Test fun nowRfc3339_has_no_subsecond_component() {
val now = PendingStore.nowRfc3339()
assertThat(now).doesNotContain(".")
assertThat(now).endsWith("Z")
}
}
@@ -0,0 +1,20 @@
package com.doctate.watch.domain
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class CaseIdTest {
private val uuidV4Pattern =
"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
@Test fun new_returns_uuid_v4_string() {
val id = CaseId.new()
assertThat(id).matches(uuidV4Pattern)
}
@Test fun new_returns_distinct_uuids() {
val a = CaseId.new()
val b = CaseId.new()
assertThat(a).isNotEqualTo(b)
}
}
@@ -0,0 +1,209 @@
package com.doctate.watch.domain
import com.google.common.truth.Truth.assertThat
import java.io.File
import java.time.Instant
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class DiskCaseStoreTest {
@get:Rule val tempDir = TemporaryFolder()
private fun newStore(subdir: String = "cases"): DiskCaseStore =
DiskCaseStore(File(tempDir.root, subdir))
@Test fun createLocal_persists_marker_file_and_emits_snapshot() = runTest {
val store = newStore()
val id = store.createLocal(now = 1_700_000_000_000L)
val list = store.casesFlow.value
assertThat(list).hasSize(1)
assertThat(list[0].caseId).isEqualTo(id)
assertThat(list[0].syncedToServer).isFalse()
val markerFile = File(tempDir.root, "cases/$id.json")
assertThat(markerFile.exists()).isTrue()
val parsed = CaseMarker.fromJson(markerFile.readText())
assertThat(parsed.caseId).isEqualTo(id)
assertThat(parsed.syncedToServer).isFalse()
}
@Test fun bootstrap_reloads_markers_from_disk() = runTest {
val s1 = newStore()
val a = s1.createLocal(now = 1_700_000_000_000L)
val b = s1.createLocal(now = 1_700_000_001_000L)
// Build a fresh store on the same dir → must repopulate from disk.
val s2 = newStore()
val ids = s2.casesFlow.value.map { it.caseId }.toSet()
assertThat(ids).containsExactly(a, b)
}
@Test fun markActivity_creates_marker_if_missing() = runTest {
val store = newStore()
store.markActivity("11111111-1111-1111-1111-111111111111", now = 1_700_000_000_000L)
val list = store.casesFlow.value
assertThat(list).hasSize(1)
assertThat(list[0].caseId).isEqualTo("11111111-1111-1111-1111-111111111111")
}
@Test fun markSynced_sets_flag_and_persists() = runTest {
val store = newStore()
val id = store.createLocal(now = 1_700_000_000_000L)
store.markSynced(id)
assertThat(store.casesFlow.value.first { it.caseId == id }.syncedToServer).isTrue()
// Persisted?
val parsed = CaseMarker.fromJson(File(tempDir.root, "cases/$id.json").readText())
assertThat(parsed.syncedToServer).isTrue()
}
@Test fun mergeServerSnapshot_inserts_unknown_case_with_synced_true() = runTest {
val store = newStore()
val response = OnelinersResponse(
asOf = "2026-04-26T12:00:00Z",
windowHours = 72,
oneliners = listOf(
OnelinerEntry(
caseId = "case-aaaa",
oneliner = OnelinerState.Ready("hi", generatedAt = "2026-04-26T11:55:00Z"),
createdAt = "2026-04-26T11:00:00Z",
lastRecordingAt = "2026-04-26T11:30:00Z",
updatedAt = null,
),
),
)
store.mergeServerSnapshot(response)
val entry = store.casesFlow.value.single()
assertThat(entry.caseId).isEqualTo("case-aaaa")
assertThat(entry.syncedToServer).isTrue()
assertThat((entry.oneliner as OnelinerState.Ready).text).isEqualTo("hi")
}
@Test fun mergeServerSnapshot_keeps_local_manual_against_non_manual_server() = runTest {
val store = newStore()
val id = store.createLocal(now = 1_700_000_000_000L)
store.setOnelinerLocal(id, OnelinerState.Manual("doc-text", setAt = "2026-04-26T11:00:00Z"))
val response = OnelinersResponse(
asOf = "2026-04-26T12:00:00Z",
windowHours = 72,
oneliners = listOf(
OnelinerEntry(
caseId = id,
oneliner = OnelinerState.Ready("server-text", generatedAt = "2026-04-26T11:30:00Z"),
createdAt = "2026-04-26T11:00:00Z",
lastRecordingAt = null,
updatedAt = null,
),
),
)
store.mergeServerSnapshot(response)
val entry = store.casesFlow.value.single()
val manual = entry.oneliner as OnelinerState.Manual
assertThat(manual.text).isEqualTo("doc-text")
}
@Test fun mergeServerSnapshot_keeps_newer_local_lastActivity() = runTest {
val store = newStore()
// Local activity at T+10s; server reports T+5s — local wins.
val laterMs = Instant.parse("2026-04-26T11:00:10Z").toEpochMilli()
val id = "case-bbbb"
store.markActivity(id, now = laterMs)
store.markSynced(id)
val response = OnelinersResponse(
asOf = "2026-04-26T12:00:00Z",
windowHours = 72,
oneliners = listOf(
OnelinerEntry(
caseId = id,
oneliner = null,
createdAt = "2026-04-26T11:00:00Z",
lastRecordingAt = "2026-04-26T11:00:05Z",
updatedAt = null,
),
),
)
store.mergeServerSnapshot(response)
val entry = store.casesFlow.value.single()
assertThat(entry.lastActivityAt).isEqualTo(laterMs)
}
@Test fun reconcile_removes_synced_marker_inside_window_missing_from_server() = runTest {
val store = newStore()
val asOf = "2026-04-26T12:00:00Z"
val asOfMs = Instant.parse(asOf).toEpochMilli()
val id = "case-cccc"
store.markActivity(id, now = asOfMs - 3_600_000L) // 1h ago, well inside 72h window
store.markSynced(id)
val response = OnelinersResponse(asOf = asOf, windowHours = 72, oneliners = emptyList())
val removed = store.reconcileWithServerSnapshot(response)
assertThat(removed).isEqualTo(1)
assertThat(store.casesFlow.value).isEmpty()
// marker file gone
assertThat(File(tempDir.root, "cases/$id.json").exists()).isFalse()
}
@Test fun reconcile_preserves_pending_marker() = runTest {
val store = newStore()
val asOf = "2026-04-26T12:00:00Z"
val asOfMs = Instant.parse(asOf).toEpochMilli()
val id = store.createLocal(now = asOfMs - 3_600_000L) // pending: synced=false
val response = OnelinersResponse(asOf = asOf, windowHours = 72, oneliners = emptyList())
val removed = store.reconcileWithServerSnapshot(response)
assertThat(removed).isEqualTo(0)
assertThat(store.casesFlow.value.map { it.caseId }).containsExactly(id)
}
@Test fun reconcile_preserves_marker_outside_window() = runTest {
val store = newStore()
val asOf = "2026-04-26T12:00:00Z"
val asOfMs = Instant.parse(asOf).toEpochMilli()
val id = "case-old"
// 80h ago — outside 72h window: server silence not authoritative.
store.markActivity(id, now = asOfMs - 80 * 3_600_000L)
store.markSynced(id)
val response = OnelinersResponse(asOf = asOf, windowHours = 72, oneliners = emptyList())
val removed = store.reconcileWithServerSnapshot(response)
assertThat(removed).isEqualTo(0)
}
@Test fun cleanupStale_drops_only_synced() = runTest {
val store = newStore()
val cutoff = 2_000_000L
val olderSynced = "case-old-synced"
val olderPending = "case-old-pending"
store.markActivity(olderSynced, now = 1_000_000L)
store.markSynced(olderSynced)
store.markActivity(olderPending, now = 1_000_000L)
val removed = store.cleanupStale(cutoffMs = cutoff)
assertThat(removed).isEqualTo(1)
assertThat(store.casesFlow.value.map { it.caseId }).containsExactly(olderPending)
}
@Test fun clearAll_wipes_memory_and_disk() = runTest {
val store = newStore()
store.createLocal()
store.createLocal()
store.clearAll()
assertThat(store.casesFlow.value).isEmpty()
val files = File(tempDir.root, "cases").listFiles()?.toList().orEmpty()
assertThat(files).isEmpty()
}
@Test fun concurrent_creates_serialize_via_mutex() = runTest {
val store = newStore()
val ids = (1..10).map {
async { store.createLocal(now = 1_700_000_000_000L + it) }
}.awaitAll()
// Each create wrote one file; size matches.
assertThat(store.casesFlow.value.map { it.caseId }.toSet()).hasSize(ids.size)
val onDisk = File(tempDir.root, "cases").listFiles()?.filter { it.name.endsWith(".json") }
?.map { it.nameWithoutExtension } ?: emptyList()
assertThat(onDisk.toSet()).isEqualTo(ids.toSet())
}
}
@@ -0,0 +1,41 @@
package com.doctate.watch.domain
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class OnelinerStateExtTest {
private val ts = "2026-04-26T10:00:00Z"
@Test fun displayText_returns_text_for_ready() {
val state: OnelinerState = OnelinerState.Ready("hello", ts)
assertThat(state.displayText()).isEqualTo("hello")
}
@Test fun displayText_returns_text_for_manual() {
val state: OnelinerState = OnelinerState.Manual("doctor wrote this", ts)
assertThat(state.displayText()).isEqualTo("doctor wrote this")
}
@Test fun displayText_returns_null_for_empty() {
val state: OnelinerState = OnelinerState.Empty(ts)
assertThat(state.displayText()).isNull()
}
@Test fun displayText_returns_null_for_error() {
val state: OnelinerState = OnelinerState.Error(ts)
assertThat(state.displayText()).isNull()
}
@Test fun displayText_returns_null_for_null_state() {
val state: OnelinerState? = null
assertThat(state.displayText()).isNull()
}
@Test fun isManual_only_true_for_manual_variant() {
assertThat((OnelinerState.Manual("x", ts) as OnelinerState?).isManual()).isTrue()
assertThat((OnelinerState.Ready("x", ts) as OnelinerState?).isManual()).isFalse()
assertThat((OnelinerState.Empty(ts) as OnelinerState?).isManual()).isFalse()
assertThat((OnelinerState.Error(ts) as OnelinerState?).isManual()).isFalse()
assertThat((null as OnelinerState?).isManual()).isFalse()
}
}
@@ -0,0 +1,103 @@
package com.doctate.watch.domain
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class OnelinersJsonTest {
@Test fun parses_full_response_with_all_oneliner_kinds() {
val json = """
{
"as_of": "2026-04-26T12:00:00Z",
"window_hours": 72,
"oneliners": [
{
"case_id": "c-ready",
"oneliner": {"kind":"ready","text":"Hello","generated_at":"2026-04-26T11:00:00Z"},
"created_at": "2026-04-26T10:00:00Z",
"last_recording_at": "2026-04-26T10:30:00Z",
"updated_at": "2026-04-26T11:00:00Z"
},
{
"case_id": "c-empty",
"oneliner": {"kind":"empty","generated_at":"2026-04-26T11:01:00Z"},
"created_at": "2026-04-26T10:01:00Z",
"last_recording_at": null,
"updated_at": null
},
{
"case_id": "c-error",
"oneliner": {"kind":"error","generated_at":"2026-04-26T11:02:00Z"},
"created_at": "2026-04-26T10:02:00Z",
"last_recording_at": "2026-04-26T10:30:00Z",
"updated_at": "2026-04-26T11:02:00Z"
},
{
"case_id": "c-manual",
"oneliner": {"kind":"manual","text":"Doctor","set_at":"2026-04-26T11:03:00Z"},
"created_at": "2026-04-26T10:03:00Z",
"last_recording_at": "2026-04-26T10:30:00Z",
"updated_at": "2026-04-26T11:03:00Z"
},
{
"case_id": "c-no-oneliner",
"oneliner": null,
"created_at": "2026-04-26T10:04:00Z",
"last_recording_at": null,
"updated_at": null
}
]
}
""".trimIndent()
val parsed = parseOnelinersResponse(json)
assertThat(parsed.asOf).isEqualTo("2026-04-26T12:00:00Z")
assertThat(parsed.windowHours).isEqualTo(72)
assertThat(parsed.oneliners.map { it.caseId })
.containsExactly("c-ready", "c-empty", "c-error", "c-manual", "c-no-oneliner").inOrder()
val ready = parsed.oneliners[0].oneliner as OnelinerState.Ready
assertThat(ready.text).isEqualTo("Hello")
assertThat(parsed.oneliners[1].oneliner).isInstanceOf(OnelinerState.Empty::class.java)
assertThat(parsed.oneliners[2].oneliner).isInstanceOf(OnelinerState.Error::class.java)
val manual = parsed.oneliners[3].oneliner as OnelinerState.Manual
assertThat(manual.text).isEqualTo("Doctor")
assertThat(parsed.oneliners[4].oneliner).isNull()
}
@Test fun roundtrip_preserves_all_fields() {
val original = OnelinersResponse(
asOf = "2026-04-26T12:00:00Z",
windowHours = 24,
oneliners = listOf(
OnelinerEntry(
caseId = "c-1",
oneliner = OnelinerState.Ready("hi", "2026-04-26T11:00:00Z"),
createdAt = "2026-04-26T10:00:00Z",
lastRecordingAt = "2026-04-26T10:30:00Z",
updatedAt = "2026-04-26T11:00:00Z",
),
),
)
val parsed = parseOnelinersResponse(onelinersResponseToJson(original))
assertThat(parsed).isEqualTo(original)
}
@Test fun null_optional_fields_round_trip_as_null() {
val original = OnelinersResponse(
asOf = "2026-04-26T12:00:00Z",
windowHours = 24,
oneliners = listOf(
OnelinerEntry(
caseId = "c-no-data",
oneliner = null,
createdAt = "2026-04-26T10:00:00Z",
lastRecordingAt = null,
updatedAt = null,
),
),
)
val parsed = parseOnelinersResponse(onelinersResponseToJson(original))
assertThat(parsed.oneliners.single().oneliner).isNull()
assertThat(parsed.oneliners.single().lastRecordingAt).isNull()
assertThat(parsed.oneliners.single().updatedAt).isNull()
}
}
@@ -0,0 +1,74 @@
package com.doctate.watch.domain
import com.doctate.watch.testfixtures.InMemoryCaseStore
import com.google.common.truth.Truth.assertThat
import java.io.File
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class StartupCleanupTest {
@get:Rule val tempDir = TemporaryFolder()
@Test fun sweeps_stale_synced_markers() = runTest {
val store = InMemoryCaseStore()
val now = 10_000_000_000L
// Old + synced → eligible.
val oldId = "case-old"
store.markActivity(oldId, now = now - 100L * 3600L * 1000L) // 100h ago
store.markSynced(oldId)
// Recent + synced → keep.
val recentId = "case-recent"
store.markActivity(recentId, now = now - 1L * 3600L * 1000L) // 1h ago
store.markSynced(recentId)
StartupCleanup.run(
store = store,
pendingDir = tempDir.newFolder("pending"),
nowMs = now,
markerRetentionMs = 72L * 3600L * 1000L,
)
val ids = store.casesFlow.value.map { it.caseId }
assertThat(ids).containsExactly(recentId)
}
@Test fun preserves_pending_markers_regardless_of_age() = runTest {
val store = InMemoryCaseStore()
val now = 10_000_000_000L
val oldPendingId = "case-old-pending"
store.markActivity(oldPendingId, now = now - 200L * 3600L * 1000L)
// No markSynced — pending must be preserved.
StartupCleanup.run(
store = store,
pendingDir = tempDir.newFolder("pending"),
nowMs = now,
markerRetentionMs = 72L * 3600L * 1000L,
)
val ids = store.casesFlow.value.map { it.caseId }
assertThat(ids).containsExactly(oldPendingId)
}
@Test fun reaps_old_orphan_audio_in_pending_dir() = runTest {
val store = InMemoryCaseStore()
val pending = tempDir.newFolder("pending")
val now = 10_000_000_000L
// Old orphan m4a (no sidecar) — should die.
File(pending, "old-orphan.m4a").apply {
writeText("x"); setLastModified(now - 30L * 3600L * 1000L) // 30h ago
}
// Young orphan m4a — should survive (within 24h).
File(pending, "fresh-orphan.m4a").apply {
writeText("x"); setLastModified(now - 1L * 3600L * 1000L) // 1h ago
}
StartupCleanup.run(
store = store,
pendingDir = pending,
nowMs = now,
orphanAudioRetentionMs = 24L * 3600L * 1000L,
)
assertThat(File(pending, "old-orphan.m4a").exists()).isFalse()
assertThat(File(pending, "fresh-orphan.m4a").exists()).isTrue()
}
}
@@ -0,0 +1,79 @@
package com.doctate.watch.net
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.settings.Settings
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
class OnelinerOverrideClientTest {
private lateinit var server: MockWebServer
private lateinit var client: OnelinerOverrideClient
@Before fun setUp() {
server = MockWebServer()
server.start()
val settings = object : Settings {
override val serverUrl = server.url("/").toString().trimEnd('/')
override val apiKey = "test-key"
}
client = OnelinerOverrideClient(OkHttpClient(), settings)
}
@After fun tearDown() {
server.shutdown()
}
@Test fun success_parses_returned_manual_state() = runTest {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""{"kind":"manual","text":"Doctor wrote this","set_at":"2026-04-26T22:00:00Z"}""",
),
)
val outcome = client.put("case-aaa", "Doctor wrote this")
assertThat(outcome).isInstanceOf(OnelinerOverrideClient.Outcome.Success::class.java)
val state = (outcome as OnelinerOverrideClient.Outcome.Success).state
assertThat(state).isInstanceOf(OnelinerState.Manual::class.java)
assertThat((state as OnelinerState.Manual).text).isEqualTo("Doctor wrote this")
}
@Test fun sends_PUT_with_correct_path_headers_and_body() = runTest {
server.enqueue(MockResponse().setResponseCode(200).setBody(stubManual()))
client.put("550e8400-e29b-41d4-a716-446655440000", "neue Bezeichnung")
val request = server.takeRequest()
assertThat(request.method).isEqualTo("PUT")
assertThat(request.path).isEqualTo("/api/cases/550e8400-e29b-41d4-a716-446655440000/oneliner")
assertThat(request.getHeader("X-API-Key")).isEqualTo("test-key")
assertThat(request.getHeader("Content-Type")).contains("application/json")
assertThat(request.body.readUtf8()).contains("\"text\":\"neue Bezeichnung\"")
}
@Test fun http_404_classifies_as_HttpError_not_thrown() = runTest {
server.enqueue(MockResponse().setResponseCode(404).setBody("not found"))
val outcome = client.put("case-x", "anything")
assertThat(outcome).isInstanceOf(OnelinerOverrideClient.Outcome.HttpError::class.java)
assertThat((outcome as OnelinerOverrideClient.Outcome.HttpError).code).isEqualTo(404)
}
@Test fun http_400_validation_error_classifies_as_HttpError() = runTest {
server.enqueue(MockResponse().setResponseCode(400).setBody("oneliner text must not be empty"))
val outcome = client.put("case-x", "")
assertThat(outcome).isInstanceOf(OnelinerOverrideClient.Outcome.HttpError::class.java)
assertThat((outcome as OnelinerOverrideClient.Outcome.HttpError).code).isEqualTo(400)
}
@Test fun network_failure_yields_Network_outcome() = runTest {
server.shutdown()
val outcome = client.put("case-x", "text")
assertThat(outcome).isInstanceOf(OnelinerOverrideClient.Outcome.Network::class.java)
}
private fun stubManual() = """{"kind":"manual","text":"x","set_at":"2026-04-26T22:00:00Z"}"""
}
@@ -0,0 +1,89 @@
package com.doctate.watch.net
import com.doctate.watch.settings.Settings
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
class OnelinersClientTest {
private lateinit var server: MockWebServer
private lateinit var client: OnelinersClient
@Before fun setUp() {
server = MockWebServer()
server.start()
val settings = object : Settings {
override val serverUrl = server.url("/").toString().trimEnd('/')
override val apiKey = "test-key"
}
client = OnelinersClient(OkHttpClient(), settings)
}
@After fun tearDown() {
server.shutdown()
}
@Test fun success_returns_parsed_body_and_etag() = runTest {
val body = """
{
"as_of":"2026-04-26T12:00:00Z",
"window_hours":72,
"oneliners":[]
}
""".trimIndent()
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("ETag", "W/\"abc-72\"")
.setBody(body),
)
val outcome = client.poll(etag = null)
assertThat(outcome).isInstanceOf(PollOutcome.Success::class.java)
val success = outcome as PollOutcome.Success
assertThat(success.etag).isEqualTo("W/\"abc-72\"")
assertThat(success.response.windowHours).isEqualTo(72)
}
@Test fun not_modified_returns_NotModified() = runTest {
server.enqueue(MockResponse().setResponseCode(304).setHeader("ETag", "W/\"abc-72\""))
val outcome = client.poll(etag = "W/\"abc-72\"")
assertThat(outcome).isEqualTo(PollOutcome.NotModified)
}
@Test fun if_none_match_header_is_sent_when_etag_provided() = runTest {
server.enqueue(MockResponse().setResponseCode(304))
client.poll(etag = "W/\"prev-tag\"")
val request = server.takeRequest()
assertThat(request.getHeader("If-None-Match")).isEqualTo("W/\"prev-tag\"")
assertThat(request.getHeader("X-API-Key")).isEqualTo("test-key")
}
@Test fun no_if_none_match_when_etag_null() = runTest {
server.enqueue(MockResponse().setResponseCode(200).setBody(emptyResponseBody()))
client.poll(etag = null)
val request = server.takeRequest()
assertThat(request.getHeader("If-None-Match")).isNull()
}
@Test fun http_503_classifies_as_TransientHttp() = runTest {
server.enqueue(MockResponse().setResponseCode(503))
val outcome = client.poll(etag = null)
assertThat(outcome).isEqualTo(PollOutcome.TransientHttp(503))
}
@Test fun network_failure_yields_Network_outcome() = runTest {
server.shutdown() // server is down → connection refused
val outcome = client.poll(etag = null)
assertThat(outcome).isInstanceOf(PollOutcome.Network::class.java)
}
private fun emptyResponseBody() = """
{"as_of":"2026-04-26T12:00:00Z","window_hours":72,"oneliners":[]}
""".trimIndent()
}
@@ -0,0 +1,63 @@
package com.doctate.watch.net
import com.doctate.watch.domain.OnelinerEntry
import com.doctate.watch.domain.OnelinerState
import com.doctate.watch.domain.OnelinersResponse
import com.google.common.truth.Truth.assertThat
import java.io.File
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class SnapshotCacheTest {
@get:Rule val tempDir = TemporaryFolder()
private fun newCache(): SnapshotCache = SnapshotCache(File(tempDir.root, "cache.json"))
@Test fun load_returns_null_when_file_missing() {
assertThat(newCache().load()).isNull()
}
@Test fun store_then_load_roundtrips_full_snapshot() {
val cache = newCache()
val snap = CachedSnapshot(
etag = "W/\"abc-72\"",
fetchedAt = "2026-04-26T12:00:00Z",
response = OnelinersResponse(
asOf = "2026-04-26T12:00:00Z",
windowHours = 72,
oneliners = listOf(
OnelinerEntry(
caseId = "c-1",
oneliner = OnelinerState.Ready("hello", "2026-04-26T11:00:00Z"),
createdAt = "2026-04-26T10:00:00Z",
lastRecordingAt = "2026-04-26T10:30:00Z",
updatedAt = "2026-04-26T11:00:00Z",
),
),
),
)
cache.store(snap)
val loaded = cache.load()
assertThat(loaded).isEqualTo(snap)
}
@Test fun load_returns_null_on_corrupted_file() {
val cache = newCache()
File(tempDir.root, "cache.json").writeText("not json", Charsets.UTF_8)
assertThat(cache.load()).isNull()
}
@Test fun clear_removes_file() {
val cache = newCache()
cache.store(
CachedSnapshot(
etag = "x",
fetchedAt = "2026-04-26T12:00:00Z",
response = OnelinersResponse("2026-04-26T12:00:00Z", 72, emptyList()),
),
)
cache.clear()
assertThat(File(tempDir.root, "cache.json").exists()).isFalse()
}
}
@@ -0,0 +1,85 @@
package com.doctate.watch.presentation
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UiStateReducerTest {
@Test fun idle_to_requesting_permission_on_record_tap() {
val next = reduce(UiState.Idle, RecordingEvent.OnRecordTap)
assertThat(next).isEqualTo(UiState.RequestingPermission)
}
@Test fun requesting_permission_to_recording_on_grant() {
val next = reduce(UiState.RequestingPermission, RecordingEvent.PermissionGranted)
assertThat(next).isEqualTo(UiState.Recording(elapsedSeconds = 0))
}
@Test fun requesting_permission_to_error_on_denial() {
val next = reduce(UiState.RequestingPermission, RecordingEvent.PermissionDenied)
assertThat(next).isInstanceOf(UiState.Error::class.java)
assertThat((next as UiState.Error).transient).isFalse()
}
@Test fun recording_updates_elapsed_seconds_on_tick() {
val next = reduce(UiState.Recording(elapsedSeconds = 0), RecordingEvent.RecordingTick(3))
assertThat(next).isEqualTo(UiState.Recording(elapsedSeconds = 3))
}
@Test fun recording_moves_to_uploading_on_stop_tap() {
val next = reduce(UiState.Recording(elapsedSeconds = 12), RecordingEvent.OnStopTap)
assertThat(next).isEqualTo(UiState.Uploading)
}
@Test fun recording_moves_to_idle_on_discard_tap() {
val next = reduce(UiState.Recording(elapsedSeconds = 12), RecordingEvent.OnDiscardTap)
assertThat(next).isEqualTo(UiState.Idle)
}
@Test fun recording_moves_to_uploading_when_finished() {
val next = reduce(UiState.Recording(elapsedSeconds = 5), RecordingEvent.RecordingFinished)
assertThat(next).isEqualTo(UiState.Uploading)
}
@Test fun recording_to_error_on_recorder_failure() {
val next = reduce(
UiState.Recording(elapsedSeconds = 3),
RecordingEvent.UploadFailed("mic busy", transient = false),
)
val err = next as UiState.Error
assertThat(err.message).isEqualTo("mic busy")
assertThat(err.transient).isFalse()
}
@Test fun uploading_to_success_on_ack() {
val next = reduce(UiState.Uploading, RecordingEvent.UploadSucceeded("abc-123"))
assertThat(next).isEqualTo(UiState.Success("abc-123"))
}
@Test fun uploading_to_error_on_transient_failure() {
val next = reduce(UiState.Uploading, RecordingEvent.UploadFailed("timeout", transient = true))
val err = next as UiState.Error
assertThat(err.message).isEqualTo("timeout")
assertThat(err.transient).isTrue()
}
@Test fun success_resets_to_idle_on_tap() {
val next = reduce(UiState.Success("abc-123"), RecordingEvent.OnResetTap)
assertThat(next).isEqualTo(UiState.Idle)
}
@Test fun error_resets_to_idle_on_tap() {
val next = reduce(UiState.Error("boom", true), RecordingEvent.OnResetTap)
assertThat(next).isEqualTo(UiState.Idle)
}
@Test fun unknown_event_is_noop() {
val next = reduce(UiState.Idle, RecordingEvent.UploadSucceeded("late ack"))
assertThat(next).isEqualTo(UiState.Idle)
}
@Test fun discard_tap_outside_recording_is_noop() {
val next = reduce(UiState.Uploading, RecordingEvent.OnDiscardTap)
assertThat(next).isEqualTo(UiState.Uploading)
}
}

Some files were not shown because too many files have changed in this diff Show More