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.
@@ -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"
|
||||
@@ -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 ~100–500ms.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
.watch_serial
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
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)
|
||||
}
|
||||
}
|
||||