diff --git a/.env.example b/.env.example deleted file mode 100644 index 93cd99a..0000000 --- a/.env.example +++ /dev/null @@ -1,67 +0,0 @@ -# S.H.O.N.A.R. configuration — copy to .env and edit. -# NEVER commit a real .env. NEVER hard-code keys in code. - -# --- Core ------------------------------------------------------------------ -# REQUIRED in production. Generate with: -# python3 -c "import secrets; print(secrets.token_urlsafe(48))" -SHONAR_SECRET_KEY=change…ring - -# Set to true only for local debugging (verbose errors, permissive CORS). -SHONAR_DEBUG=false - -# Allow new user registration on this server. Set false to invite-only -# (create accounts via `scripts/create_user.py`). -SHONAR_ALLOW_REGISTRATION=true - -# --- Database ---------------------------------------------------------------- -# Used by the API and worker. In docker compose these are wired automatically. -SHONAR_DATABASE_URL=postgresql+asyncpg://shonar:shonar@localhost:5432/shonar -SHONAR_DB_POOL_SIZE=5 - -# Compose Postgres credentials (must match DATABASE_URL above) -SHONAR_DB_USER=shonar -SHONAR_DB_PASS=shonar -SHONAR_DB_NAME=shonar - -# --- Storage ------------------------------------------------------------------- -# local | s3 (s3 works with any S3-compatible store: MinIO, R2, etc.) -SHONAR_STORAGE_BACKEND=local -SHONAR_STORAGE_PATH=./data/storage - -SHONAR_S3_ENDPOINT_URL= -SHONAR_S3_BUCKET= -SHONAR_S3_REGION=us-east-1 -SHONAR_S3_ACCESS_KEY_ID= -SHONAR_S3_SECRET_ACCESS_KEY= - -# --- Upload limits ------------------------------------------------------------- -SHONAR_MAX_UPLOAD_BYTES=2147483648 -SHONAR_MAX_CHUNK_BYTES=16777216 - -# --- Queue ---------------------------------------------------------------------- -SHONAR_REDIS_URL=redis://localhost:6379/0 - -# --- Audio processing ------------------------------------------------------------- -# Optional server-side FFmpeg normalization (creates derivative copies; -# originals are never modified). -SHONAR_AUDIO_CONVERSION_ENABLED=false -SHONAR_FFMPEG_BIN=ffmpeg - -# --- AI (all optional; system fully works with providers=none) ---------------------- -# Transcription: none | whisper_http | faster_whisper -SHONAR_TRANSCRIPTION_PROVIDER=none -SHONAR_TRANSCRIPTION_MODEL=base -# Any whisper-compatible HTTP server (e.g. faster-whisper-server, whisper.cpp) -SHONAR_TRANSCRIPTION_BASE_URL= -SHONAR_TRANSCRIPTION_API_KEY= - -# LLM: none | openai_compat | ollama -SHONAR_LLM_PROVIDER=none -SHONAR_LLM_MODEL= -# e.g. http://localhost:11434/v1 (Ollama) or any OpenAI-compatible endpoint -SHONAR_LLM_BASE_URL= -SHONAR_LLM_API_KEY= - -# --- Rate limits ----------------------------------------------------------------- -SHONAR_RATE_LIMIT_AUTH=10/minute -SHONAR_RATE_LIMIT_DEFAULT=120/minute diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ee6c1f..f40a8ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,34 +6,6 @@ on: pull_request: jobs: - backend: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16-alpine - env: - POSTGRES_USER: shonar - POSTGRES_PASSWORD: shonar - POSTGRES_DB: shonar_test - ports: ["5432:5432"] - options: >- - --health-cmd "pg_isready -U shonar" - --health-interval 5s --health-timeout 3s --health-retries 10 - defaults: - run: - working-directory: backend - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/setup-uv@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - run: uv venv .venv && uv pip install -e ".[dev]" - - run: .venv/bin/ruff check . - - run: .venv/bin/pytest - env: - SHONAR_TEST_DATABASE_URL: postgresql+asyncpg://shonar:shonar@localhost:5432/shonar_test - android: runs-on: ubuntu-latest defaults: diff --git a/README.md b/README.md index 4d86d7d..c87d124 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,26 @@ # S.H.O.N.A.R. -**S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder** +**S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder (local-only)** -An open-source, self-hosted alternative to cloud voice-note AI devices and -services. Record conversations on your Android phone, sync them to a server -**you** control, transcribe and summarize them with AI providers **you** -choose, and keep full ownership of your audio, transcripts, and accounts. +Record conversations on your Android phone and keep them in a folder you +control. No accounts, no servers, no uploads, no telemetry — the app has +no network permission at all. If you want your recordings on other +machines, sync the folder yourself (Syncthing, USB, …); the app adopts +files added from outside into its library automatically. -- No telemetry. No third-party analytics. No hidden external AI calls. -- Runs fully locally by default (local transcription + local LLM supported). -- Works with **no AI configured at all**: recording, sync, playback, download, - and manual transcripts still work. -- Apache-2.0 licensed. All code in this repository is original. +- Record with one tap (app, notification, or home-screen widget), pause / + resume, save with rename, play back with seek + speed. +- Library lives in a folder on the phone (`Music/Recordings` by default); + rename and delete move together with the audio file. +- Apache-2.0 licensed. ## Repository layout ``` android/ Android app (Kotlin, Jetpack Compose, Material 3) -backend/ FastAPI + PostgreSQL API server -worker/ Background worker entrypoint (same image as backend) -shared/ OpenAPI spec shared with the Android client -deploy/ Docker Compose, reverse proxy, backup/restore -docs/ Architecture, API, security, self-hosting guides -scripts/ Dev / CI helper scripts +docs/ Recording-consent notice ``` -## Quick start (Docker) - -```bash -cp .env.example .env -# EDIT .env — at minimum set SHONAR_SECRET_KEY (any 32+ random chars): -# python3 -c "import secrets; print(secrets.token_urlsafe(48))" -docker compose up -d --build -``` - -Then open `http://localhost:8000/docs` for the interactive API docs and -point the Android app at your server URL. (ReDoc at `/redoc`, raw schema at -`/openapi.json`.) - -The default compose stack is: API + worker + PostgreSQL + Redis, with audio -stored on the local filesystem. No paid cloud service is required. - ## Quick start (Android) Requirements: JDK 17, Android SDK (platform 35). See @@ -52,85 +32,12 @@ cd android adb install app/build/outputs/apk/debug/app-debug.apk ``` -## Feature status - -S.H.O.N.A.R. is developed in milestones; each merged milestone is tested and -buildable. See [docs/ROADMAP.md](docs/ROADMAP.md) for the maintained matrix. - -**Current state:** - -- Backend: M0–M2 complete — health, full auth (register, login, rotating - refresh tokens with reuse detection, logout, delete-account), rate - limiting, chunked resumable uploads, recordings CRUD, storage - abstraction, Postgres FTS schema, Docker dev stack. -- Android: app shell with a generic data-driven **Custom Settings** system - (8 value types, add/edit/delete/reset/search/export/import). -- **Product direction:** the server layer is provider-based, with - **Nextcloud as the default** provider and options for Start9, Umbrel, a - custom SHONAR server, or local-only storage. See - [docs/server-providers.md](docs/server-providers.md) for the interface, - data model, auth flow, and phased plan. -- Home Assistant is **not** part of the initial product. Prior work is - preserved but disabled under [`deferred/home-assistant/`](deferred/home-assistant/README.md) - and on branch `deferred/home-assistant`. -- Provider sync and AI pipeline remain TODO; local recording, playback, and - library storage are implemented in the Android app. The custom SHONAR - server provider (login, token persistence + auto-refresh, chunked - upload/download) is implemented with provider-selection login UI; - on-device verification pending, WorkManager sync driver still TODO. - -## AI providers - -All AI is optional and provider-independent, configured only through -environment variables (never hard-coded keys): - -| Variable | Values | Default | -|---|---|---| -| `SHONAR_TRANSCRIPTION_PROVIDER` | `none`, `whisper_http`, `faster_whisper` | `none` | -| `SHONAR_TRANSCRIPTION_MODEL` | model name (e.g. `base`, `small`) | `base` | -| `SHONAR_TRANSCRIPTION_BASE_URL` | whisper-compatible HTTP server URL | — | -| `SHONAR_LLM_PROVIDER` | `none`, `openai_compat`, `ollama` | `none` | -| `SHONAR_LLM_MODEL` / `SHONAR_LLM_BASE_URL` / `SHONAR_LLM_API_KEY` | model/endpoint/credentials | — | -| `SHONAR_STORAGE_BACKEND` | `local`, `s3` | `local` | -| `SHONAR_STORAGE_PATH` | filesystem storage root | `./data/storage` | -| `SHONAR_DATABASE_URL` | SQLAlchemy async URL | postgres in compose | - -When any external (non-local) provider is enabled, `/api/v1/system/status` -reports `external_ai_in_use: true` and the Android app shows it in Settings — -you always know if your audio or text leaves your machine. - -## Documentation - -- [docs/self-hosting.md](docs/self-hosting.md) — deployment, HTTPS, backups -- [docs/api.md](docs/api.md) — API overview (OpenAPI at `/openapi.json`) -- [docs/security.md](docs/security.md) — security model and honest limits -- [docs/architecture.md](docs/architecture.md) — system design -- [docs/recording-consent.md](docs/recording-consent.md) — legal notice - ## Development ```bash -# backend -cd backend && uv venv .venv && uv pip install -e ".[dev]" -docker compose -f deploy/docker-compose.dev.yml up -d -.venv/bin/alembic upgrade head -.venv/bin/uvicorn shonar.main:app --reload -.venv/bin/pytest # tests -.venv/bin/ruff check . # lint - -# android cd android && ./gradlew test ``` -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) and -[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). - -## License - -Apache-2.0 — see [LICENSE](LICENSE). - ## Legal notice **You are responsible for complying with recording-consent laws in your diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a9c3c03..5edba8b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -61,15 +61,8 @@ dependencies { // persistence implementation("androidx.datastore:datastore-preferences:1.1.1") - // keystore-backed secrets (server provider credentials) - implementation("androidx.security:security-crypto:1.1.0-alpha06") - // networking (server provider APIs over HTTPS) - implementation("com.squareup.okhttp3:okhttp:4.12.0") - // background sync driver (M5) - implementation("androidx.work:work-runtime-ktx:2.9.0") - // MediaStyle pill buttons in the recording notification (system-rendered). - implementation("androidx.media:media:1.7.0") + // background work (none — local-only app keeps no workers) implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") @@ -80,10 +73,6 @@ dependencies { // dispatcher or provider JSON parsing under test. testImplementation("org.json:json:20240303") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") - testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") - // In-test certificate authority for the P5 TOFU tests (self-signed - // fixtures generated at runtime, no checked-in keys). - testImplementation("com.squareup.okhttp3:okhttp-tls:4.12.0") testImplementation("androidx.room:room-testing:2.6.1") androidTestImplementation(composeBom) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 089ad87..4b65913 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,7 @@ - - - + @@ -12,11 +10,9 @@ - + @@ -28,11 +24,7 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" - android:theme="@style/Theme.Shonar" - android:networkSecurityConfig="@xml/network_security_config"> - + android:theme="@style/Theme.Shonar"> nav.navigate("detail/$id") }, ) } @@ -72,37 +68,6 @@ class MainActivity : ComponentActivity() { onBack = { nav.popBackStack() }, ) } - composable("storage") { - StorageScreen( - onBack = { nav.popBackStack() }, - onSwitchProvider = { nav.navigate("provider") }, - ) - } - composable("provider") { entry -> - val pick by entry.savedStateHandle - .getStateFlow("picked_path", null) - .collectAsState() - ProviderSelectionScreen( - onDone = { nav.popBackStack() }, - onBack = { nav.popBackStack() }, - browsePick = pick, - onPickConsumed = { - entry.savedStateHandle.remove("picked_path") - }, - onBrowse = { nav.navigate("folderBrowser") }, - ) - } - composable("folderBrowser") { - FolderBrowserScreen( - onPick = { path -> - nav.previousBackStackEntry - ?.savedStateHandle - ?.set("picked_path", path) - nav.popBackStack() - }, - onBack = { nav.popBackStack() }, - ) - } composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) } } diff --git a/android/app/src/main/java/com/shonar/ShonarApplication.kt b/android/app/src/main/java/com/shonar/ShonarApplication.kt index 14c8620..cca8bd9 100644 --- a/android/app/src/main/java/com/shonar/ShonarApplication.kt +++ b/android/app/src/main/java/com/shonar/ShonarApplication.kt @@ -5,15 +5,18 @@ import androidx.room.Room import com.shonar.recording.RecordingRepository import com.shonar.recording.ShonarDatabase import com.shonar.settings.DataStoreSettingsStore -import com.shonar.settings.SecureSettingsStore import com.shonar.settings.SettingsManager -import java.util.concurrent.atomic.AtomicBoolean +import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +/** + * Local-only app: record to the on-phone library folder, no accounts, no + * servers, no background uploads. File sync (if wanted) is the user's own + * tooling outside this app. + */ class ShonarApplication : Application() { private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -23,106 +26,38 @@ class ShonarApplication : Application() { .addMigrations( com.shonar.recording.MIGRATION_1_2, com.shonar.recording.MIGRATION_2_3, + com.shonar.recording.MIGRATION_3_4, ) .build() } - val recordingRepository: RecordingRepository by lazy { - RecordingRepository(filesDir, database.recordingDao()) { - // New recordings belong in the picked folder (local-only - // provider), or the app-private library by default / for - // server-backed setups. Never throws — null means default. - runCatching { - settingsManager.ensureLoaded() - val pid = settingsManager.string( - com.shonar.settings.BuiltInSettings.PROVIDER_ID - ).ifBlank { "local-only" } - (providerRegistry.provider(pid) as? com.shonar.provider.LocalOnlyProvider) - ?.currentRoot() - }.getOrNull() - } - } - - val secureStore: SecureSettingsStore by lazy { SecureSettingsStore(this) } - val settingsManager: SettingsManager by lazy { - SettingsManager( - store = DataStoreSettingsStore(this), - secureStore = secureStore, - ) - } - - /** P5: trust-on-first-use pins (secure store) + in-memory cache. */ - val tofu: com.shonar.provider.TofuManager by lazy { - com.shonar.provider.TofuManager( - com.shonar.provider.TofuStore(secureStore) - ) + SettingsManager(store = DataStoreSettingsStore(this)) } /** - * HTTP debug logging follows the `log_http_bodies` setting. Interceptors - * run on OkHttp threads that cannot suspend, so the flag is cached here - * and refreshed whenever settings change — toggling needs no restart. + * Library root: the previously picked folder (persisted path) or the + * shared Music/Recordings folder. Never throws — null means default. */ - private val bodyLogging = AtomicBoolean(false) + private suspend fun libraryRoot(): File? = runCatching { + settingsManager.ensureLoaded() + val raw = settingsManager.string(KEY_LIBRARY_ROOT).ifBlank { DEFAULT_LIBRARY_PATH } + File(raw).takeIf { it.exists() } ?: File(DEFAULT_LIBRARY_PATH).apply { mkdirs() } + }.getOrNull() - /** Server providers (P1 local-only, P3 custom SHONAR, P4 Nextcloud + sync folder). */ - val providerRegistry: com.shonar.provider.ProviderRegistry by lazy { - com.shonar.provider.ProviderRegistry.withDefaults( - appFilesDir = filesDir, - secureStore = secureStore, - plainStore = DataStoreSettingsStore(this), - tlsPolicy = com.shonar.provider.TlsPolicy( - tofu = tofu, - bodiesEnabled = bodyLogging::get, - sink = { msg -> android.util.Log.d("ShonarNet", msg) }, - ), - ) + val recordingRepository: RecordingRepository by lazy { + RecordingRepository(filesDir, database.recordingDao(), ::libraryRoot) } override fun onCreate() { super.onCreate() appScope.launch { - settingsManager.ensureLoaded() - // TOFU pins must be in cache before any TLS handshake needs them. - tofu.refresh() - // Point new recordings at the picked folder (if any). runCatching { recordingRepository.refreshRoot() } - syncBodyLoggingFlag() - scheduleSync() - settingsManager.valuesChanged.collect { - syncBodyLoggingFlag() - scheduleSync() - } } } - /** M5: steady-state periodic drain plus an immediate drain at startup - * and whenever sync constraints change. Re-running on unrelated - * settings edits only refreshes the periodic schedule (cheap UPDATE), - * never a drain. */ - private var lastSyncFlags: Pair? = null - - private suspend fun scheduleSync() { - val flags = wifiOnly() to chargingOnly() - com.shonar.recording.SyncScheduler.ensurePeriodic(this, flags.first, flags.second) - if (lastSyncFlags == null || lastSyncFlags != flags) { - com.shonar.recording.SyncScheduler.requestNow(this, flags.first, flags.second) - } - lastSyncFlags = flags - } - - private suspend fun wifiOnly(): Boolean = runCatching { - settingsManager.bool(com.shonar.settings.BuiltInSettings.WIFI_ONLY_UPLOAD) - }.getOrDefault(true) - - private suspend fun chargingOnly(): Boolean = runCatching { - settingsManager.bool(com.shonar.settings.BuiltInSettings.CHARGING_ONLY_UPLOAD) - }.getOrDefault(false) - - private suspend fun syncBodyLoggingFlag() { - bodyLogging.set( - runCatching { settingsManager.bool("log_http_bodies") }.getOrDefault(false) - ) + companion object { + const val KEY_LIBRARY_ROOT = "library.root" + const val DEFAULT_LIBRARY_PATH = "/storage/emulated/0/Music/Recordings" } } diff --git a/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt b/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt deleted file mode 100644 index ec38a05..0000000 --- a/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt +++ /dev/null @@ -1,748 +0,0 @@ -package com.shonar.provider - -import java.io.File -import java.security.MessageDigest -import java.time.Instant -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response - -/** - * P3: SHONAR-backend provider (this repo's FastAPI server, M1 auth + M2 - * uploads). Speaks only documented endpoints: - * - * - GET /api/v1/provider-info (probe; no credentials) - * - POST /api/v1/auth/login {email, password, device_name, platform} - * - POST /api/v1/auth/refresh {refresh_token} (rotating; reuse-detected) - * - POST /api/v1/auth/logout {refresh_token} - * - GET /api/v1/auth/me (token validation) - * - POST /api/v1/auth/delete-account {password} (via [deleteAccount]) - * - POST /api/v1/uploads (create session) - * - GET /api/v1/uploads/{id} (resume: received indexes) - * - PUT /api/v1/uploads/{id}/chunks/{n} (+ X-Chunk-Sha256) - * - POST /api/v1/uploads/{id}/finalize - * - GET /api/v1/recordings?limit&offset&sort&order - * - GET /api/v1/recordings/{id}/audio - * - DELETE /api/v1/recordings/{id}?purge=true - * - * Token discipline: the backend rotates refresh tokens with reuse - * detection, so the stored pair is overwritten on every login AND every - * refresh, and concurrent 401s serialize on [refreshMutex] — two parallel - * refreshes would look like token reuse and burn the whole family. - * - * Sidecars: file cache under [sidecarRoot] keyed by remote recording id - * (same layout as LocalOnlyProvider). AI content (transcript/summary/jobs) - * goes through the real M7 endpoints instead — see [fetchTranscript] and - * friends, consumed by the M8 details screen. - * - * Cancellation safety: a cancelled upload leaves an open server session - * with some chunks stored — invisible until finalize, resumable via the - * status endpoint, and idempotent per draft id through - * `client_recording_id`. Nothing half-visible ever appears in listings. - */ -class CustomShonarProvider( - private val auth: ShonarAuthStore, - private val sidecarRoot: File, - private val client: OkHttpClient = defaultClient(), - private val handshake: ShonarHandshake = ShonarHandshake(), - /** Identifies this client to the server (login device_name/platform). */ - private val deviceName: String = "SHONAR Android", - private val platform: String = "android", -) : ShonarProvider { - - override val descriptor = ProviderDescriptor( - id = ProviderRegistry.CUSTOM_SHONAR_ID, - displayName = "Custom SHONAR server", - capabilities = setOf( - ProviderDescriptor.Capability.CHUNKED_UPLOAD, - ProviderDescriptor.Capability.ACCOUNT_DELETION, - ), - ) - - private val _authState = MutableStateFlow(AuthState.DISCONNECTED) - override val authState: StateFlow = _authState - - /** Base origin, e.g. https://shonar.example.com. Set by connect/reconnect. */ - private var origin: String? = null - - private val refreshMutex = Mutex() - - // ---- lifecycle --------------------------------------------------------- - - override suspend fun probe(baseUrl: ServerUrl): ProbeResult = handshake.probe(baseUrl) - - override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) { - when (credential) { - is ProviderCredential.ShonarLogin -> login( - credential.serverUrl.origin, credential.email, credential.password - ) - is ProviderCredential.OAuthTokens -> resumeWithTokens(credential) - else -> throw ProviderError.InvalidUrl( - "Custom SHONAR server needs an email + password login" - ) - } - } - - override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) { - val saved = auth.load() - if (saved == null || saved.refreshToken.isBlank()) { - _authState.value = AuthState.DISCONNECTED - return@withContext _authState.value - } - origin = saved.baseUrl - val base = saved.baseUrl - try { - executeAuthed(base) { token -> get(base, "/api/v1/auth/me", token) }.use { resp -> - if (resp.code != 200) throw ProviderError.AuthExpired() - } - _authState.value = AuthState.CONNECTED - } catch (e: ProviderError.AuthExpired) { - // Refresh already failed inside executeAuthed: tokens are dead. - auth.clearTokens() - _authState.value = AuthState.EXPIRED - } catch (e: ProviderError) { - _authState.value = AuthState.OFFLINE - } - _authState.value - } - - override suspend fun disconnect(revokeOnServer: Boolean) = withContext(Dispatchers.IO) { - if (revokeOnServer) { - // Best effort: local state is cleared even if revoke fails. - runCatching { - val saved = auth.load() - if (saved != null && saved.refreshToken.isNotBlank()) { - postUnauthed( - saved.baseUrl, "/api/v1/auth/logout", - """{"refresh_token":${jsonStr(saved.refreshToken)}}""", - ).close() - } - } - } - auth.clearTokens() - _authState.value = AuthState.DISCONNECTED - } - - override suspend fun deleteAccountAndData() { - // No password is available here (never stored), so server-side - // account purge needs the explicit [deleteAccount] call below. - // This path revokes the session and wipes everything local. - withContext(Dispatchers.IO) { - disconnect(revokeOnServer = true) - sidecarRoot.deleteRecursively() - } - } - - /** - * Full server-side account purge (backend: 30-day grace, then hard - * delete). Needs the password because it is never stored — call this - * from a confirmation screen that asks for it once. - */ - suspend fun deleteAccount(password: String) = withContext(Dispatchers.IO) { - val base = origin ?: auth.load()?.baseUrl ?: throw ProviderError.NotConnected() - executeAuthed(base) { token -> - post(base, "/api/v1/auth/delete-account", token, - """{"password":${jsonStr(password)}}""") - }.use { resp -> - if (resp.code != 202 && resp.code != 204) { - throw ProviderError.Transient("Account deletion refused (HTTP ${resp.code})") - } - } - auth.clearAll() - origin = null - sidecarRoot.deleteRecursively() - _authState.value = AuthState.DISCONNECTED - } - - // ---- storage ----------------------------------------------------------- - - override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef = - withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - val total = draft.sizeBytes.coerceAtLeast(1) - - val (sessionId, chunkSize) = createSession(base, draft) - val received = uploadStatus(base, sessionId).toMutableSet() - - var sent = 0L - // Account progress for already-present chunks so resume continues - // the bar instead of restarting it. - var idx = 0 - val chunkCount = ((draft.sizeBytes + chunkSize - 1) / chunkSize).toInt().coerceAtLeast(1) - var resumedBytes = 0L - // Estimate resumed bytes from the received set (last chunk may be short). - for (i in received) { - resumedBytes += if (i < chunkCount - 1) chunkSize.toLong() - else (draft.sizeBytes - chunkSize * (chunkCount - 1)).coerceAtLeast(0) - } - sent = resumedBytes - if (sent > 0) onProgress((sent.toFloat() / total).coerceIn(0f, 1f)) - - while (idx < chunkCount) { - // Cooperative cancellation between chunks; a cancelled - // upload leaves a resumable server session, never a - // half-visible object. - ensureActive() - if (idx !in received) { - val slice = readSlice(draft.sourceFile, idx.toLong() * chunkSize, chunkSize) - putChunk(base, sessionId, idx, slice) - sent += slice.size - onProgress((sent.toFloat() / total).coerceIn(0f, 1f)) - } - idx++ - } - - val recordingId = finalize(base, sessionId, draft) - onProgress(1f) - RemoteRef(ProviderRegistry.CUSTOM_SHONAR_ID, recordingId, etag = null, sizeBytes = draft.sizeBytes) - } - - override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) = - withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - executeAuthed(base) { token -> get(base, "/api/v1/recordings/${ref.key}/audio", token) }.use { resp -> - when (resp.code) { - 200 -> { - val body = resp.body ?: throw ProviderError.Transient("Empty download response") - val total = body.contentLength().takeIf { it > 0 } ?: -1 - dest.parentFile?.mkdirs() - body.byteStream().use { input -> - dest.outputStream().use { output -> - val buf = ByteArray(64 * 1024) - var written = 0L - var lastReported = -1f - while (true) { - val n = input.read(buf) - if (n < 0) break - output.write(buf, 0, n) - written += n - if (total > 0) { - val p = (written.toFloat() / total).coerceIn(0f, 1f) - if (p > lastReported) { - onProgress(p) - lastReported = p - } - } - } - } - } - onProgress(1f) - } - 404 -> throw ProviderError.NotFound(ref.key) - else -> throw ProviderError.Transient("Download failed (HTTP ${resp.code})") - } - } - } - - override suspend fun delete(ref: RemoteRef) { - withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - executeAuthed(base) { token -> - Request.Builder().url("$base/api/v1/recordings/${ref.key}?purge=true") - .delete().header("Authorization", "Bearer $token").build() - }.use { resp -> - when (resp.code) { - 204, 200 -> { - sidecarDir(ref).deleteRecursively() - } - 404 -> throw ProviderError.NotFound(ref.key) - else -> throw ProviderError.Transient("Delete failed (HTTP ${resp.code})") - } - } - } - } - - override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - val offset = cursor?.toIntOrNull()?.coerceAtLeast(0) ?: 0 - val limit = 200 - executeAuthed(base) { token -> - get(base, "/api/v1/recordings?limit=$limit&offset=$offset&sort=recorded_at&order=desc", token) - }.use { resp -> - if (resp.code != 200) throw ProviderError.Transient("Listing failed (HTTP ${resp.code})") - val root = org.json.JSONObject(resp.body?.string().orEmpty()) - val total = root.optInt("total", 0) - val items = root.optJSONArray("items") ?: org.json.JSONArray() - val out = mutableListOf() - for (i in 0 until items.length()) { - parseRecording(items.getJSONObject(i))?.let { out += it } - } - val next = if (offset + limit < total) (offset + limit).toString() else null - Page(out, next) - } - } - - // ---- AI content (M7 endpoints, consumed by the M8 details screen) ---- - // - // The recording id here is the server-side UUID — the local row's - // [com.shonar.recording.RecordingEntity.remoteKey] after upload. - // 404 surfaces as [ProviderError.NotFound] ("no transcript yet" is a - // normal state, not a failure). Raw JSON comes back so parsing stays in - // the testable `recording.AiContent` helpers, not in this IO class. - - suspend fun fetchTranscript(recordingId: String): String = getAiJson( - "/api/v1/recordings/$recordingId/transcript", recordingId - ) - - suspend fun fetchSummary(recordingId: String): String = getAiJson( - "/api/v1/recordings/$recordingId/summary", recordingId - ) - - suspend fun fetchJobs(recordingId: String): String = getAiJson( - "/api/v1/recordings/$recordingId/jobs", recordingId - ) - - /** Force one pipeline stage to re-run on the server (no re-upload). - * [model] only applies to job="transcribe" (switches the saved - * per-recording override). 409 = stage already running or nothing to - * summarize; surfaced as ProviderError.Transient with the server's - * detail message. */ - suspend fun reprocess(recordingId: String, job: String, model: String? = null): String = - withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - val q = if (model != null) "&model=$model" else "" - executeAuthed(base) { token -> - post(base, "/api/v1/recordings/$recordingId/reprocess?job=$job$q", token, "{}") - }.use { resp -> - when (resp.code) { - 200, 201 -> resp.body?.string().orEmpty() - 404 -> throw ProviderError.NotFound(recordingId) - else -> { - val body = runCatching { - org.json.JSONObject(resp.body?.string().orEmpty()) - .optString("detail") - }.getOrNull() - throw ProviderError.Transient( - body?.takeIf { it.isNotBlank() } - ?: "Reprocess failed (HTTP ${resp.code})") - } - } - } - } - - /** User edit → new server version with edited_by_user=true (pipeline won't clobber). */ - suspend fun updateTranscript( - recordingId: String, - payloadJson: String, - ): String = withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - executeAuthed(base) { token -> put(base, "/api/v1/recordings/$recordingId/transcript", token, payloadJson) } - .use { resp -> - when (resp.code) { - 200 -> resp.body?.string().orEmpty() - 404 -> throw ProviderError.NotFound(recordingId) - else -> throw ProviderError.Transient("Transcript update failed (HTTP ${resp.code})") - } - } - } - - suspend fun updateSummary(recordingId: String, payloadJson: String): String = - withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - executeAuthed(base) { token -> put(base, "/api/v1/recordings/$recordingId/summary", token, payloadJson) } - .use { resp -> - when (resp.code) { - 200 -> resp.body?.string().orEmpty() - 404 -> throw ProviderError.NotFound(recordingId) - else -> throw ProviderError.Transient("Summary update failed (HTTP ${resp.code})") - } - } - } - - /** Title/notes rename from the details screen (server + Room updated by caller). */ - suspend fun patchRecording(recordingId: String, payloadJson: String): String = - withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - executeAuthed(base) { token -> patch(base, "/api/v1/recordings/$recordingId", token, payloadJson) } - .use { resp -> - when (resp.code) { - 200 -> resp.body?.string().orEmpty() - 404 -> throw ProviderError.NotFound(recordingId) - else -> throw ProviderError.Transient("Recording update failed (HTTP ${resp.code})") - } - } - } - - private suspend fun getAiJson(path: String, recordingId: String): String = - withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - executeAuthed(base) { token -> get(base, path, token) }.use { resp -> - when (resp.code) { - 200 -> resp.body?.string().orEmpty() - 404 -> throw ProviderError.NotFound(recordingId) - else -> throw ProviderError.Transient("Request failed (HTTP ${resp.code})") - } - } - } - - // ---- transcription models (Stage 1 endpoints, desktop Settings) -------- - // Raw JSON throughout; parsing lives in the testable AiContent helpers. - - suspend fun fetchModels(): String = withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - executeAuthed(base) { token -> get(base, "/api/v1/models", token) }.use { resp -> - if (resp.code != 200) throw ProviderError.Transient("Models request failed (HTTP ${resp.code})") - resp.body?.string().orEmpty() - } - } - - suspend fun setDefaultModel(model: String): String = withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - val payload = """{"model":${jsonStr(model)}}""" - executeAuthed(base) { token -> put(base, "/api/v1/models/default", token, payload) } - .use { resp -> - when (resp.code) { - 200 -> resp.body?.string().orEmpty() - 422 -> throw ProviderError.InvalidUrl("Unsupported model: $model") - else -> throw ProviderError.Transient("Default model update failed (HTTP ${resp.code})") - } - } - } - - /** Fetch a model into the server cache (needs internet once; may take minutes). */ - suspend fun downloadModel(model: String): String = withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - executeAuthed(base) { token -> - post(base, "/api/v1/models/$model/download", token, "{}") - }.use { resp -> - when (resp.code) { - 200 -> resp.body?.string().orEmpty() - 422 -> throw ProviderError.InvalidUrl("Unsupported model: $model") - 501 -> throw ProviderError.Transient("faster-whisper is not installed on the server") - else -> throw ProviderError.Transient("Model download failed (HTTP ${resp.code})") - } - } - } - - // ---- sidecars (local cache until the backend gains endpoints in M7) ---- - - private fun sidecarDir(ref: RemoteRef) = File(sidecarRoot, ref.key) - - override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) = - withContext(Dispatchers.IO) { - val f = File(sidecarDir(ref), kind.fileName) - f.parentFile?.mkdirs() - f.writeBytes(bytes) - } - - override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = - withContext(Dispatchers.IO) { - val f = File(sidecarDir(ref), kind.fileName) - if (f.exists()) f.readBytes() else null - } - - // ---- status ------------------------------------------------------------ - - override suspend fun storageLocationSummary(): StorageLocation = - withContext(Dispatchers.IO) { - ensureConnected() - val base = currentOrigin() - val host = runCatching { java.net.URI(base).host }.getOrNull() ?: base - val email = auth.load()?.email.orEmpty() - var count = 0 - var bytes = 0L - var cursor: String? = null - do { - val page = list(cursor) - count += page.items.size - bytes += page.items.sumOf { it.ref.sizeBytes } - cursor = page.nextCursor - } while (cursor != null) - StorageLocation( - headline = "Custom SHONAR server at $host", - detail = if (email.isBlank()) "$count recordings synced." - else "Signed in as $email · $count recordings synced.", - syncedCount = count, - localOnlyCount = 0, - bytesUsed = bytes, - ) - } - - // ---- internals --------------------------------------------------------- - - private fun ensureConnected() { - if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected() - } - - private suspend fun currentOrigin(): String = - origin ?: auth.load()?.baseUrl ?: throw ProviderError.NotConnected() - - private suspend fun login(base: String, email: String, password: String) { - val body = """{"email":${jsonStr(email)},"password":${jsonStr(password)},""" + - """"device_name":${jsonStr(deviceName)},"platform":${jsonStr(platform)}}""" - postUnauthed(base, "/api/v1/auth/login", body).use { resp -> - when (resp.code) { - 200 -> { - persistSession(base, email, org.json.JSONObject(resp.body?.string().orEmpty())) - origin = base - _authState.value = AuthState.CONNECTED - } - 401 -> throw ProviderError.Transient("Invalid email or password") - else -> throw ProviderError.Transient("Login failed (HTTP ${resp.code})") - } - } - } - - private suspend fun resumeWithTokens(credential: ProviderCredential.OAuthTokens) { - val base = origin ?: auth.load()?.baseUrl - ?: throw ProviderError.InvalidUrl("No SHONAR server configured yet") - origin = base - auth.save( - ShonarSession( - baseUrl = base, - email = credential.accountLabel, - accessToken = credential.accessToken, - refreshToken = credential.refreshToken.orEmpty(), - expiresAtEpochSec = credential.expiresAtEpochSec, - deviceId = null, - ) - ) - executeAuthed(base) { token -> get(base, "/api/v1/auth/me", token) }.use { resp -> - if (resp.code != 200) throw ProviderError.AuthExpired() - } - _authState.value = AuthState.CONNECTED - } - - private suspend fun persistSession(base: String, email: String, json: org.json.JSONObject) { - val nowSec = Instant.now().epochSecond - auth.save( - ShonarSession( - baseUrl = base, - email = email, - accessToken = json.getString("access_token"), - refreshToken = json.getString("refresh_token"), - expiresAtEpochSec = nowSec + json.optInt("expires_in", 900), - deviceId = json.optString("device_id", null).takeUnless { it.isNullOrBlank() }, - ) - ) - } - - /** Single-flight refresh; concurrent 401s must not double-refresh. */ - private suspend fun refreshLocked(failedAccess: String): String = refreshMutex.withLock { - val current = auth.load() ?: throw ProviderError.NotConnected() - // A peer already refreshed while we queued — reuse its tokens. - if (current.accessToken != failedAccess && current.accessToken.isNotBlank()) { - return@withLock current.accessToken - } - if (current.refreshToken.isBlank()) { - _authState.value = AuthState.EXPIRED - throw ProviderError.AuthExpired() - } - val body = """{"refresh_token":${jsonStr(current.refreshToken)}}""" - try { - postUnauthed(current.baseUrl, "/api/v1/auth/refresh", body).use { resp -> - if (resp.code != 200) { - // Reuse detected or revoked family: stored tokens are dead. - auth.clearTokens() - _authState.value = AuthState.EXPIRED - throw ProviderError.AuthExpired() - } - persistSession(current.baseUrl, current.email, org.json.JSONObject(resp.body?.string().orEmpty())) - _authState.value = AuthState.CONNECTED - return@withLock auth.load()?.accessToken ?: throw ProviderError.AuthExpired() - } - } catch (e: ProviderError) { - throw e - } catch (e: Exception) { - throw ProviderError.Transient("Token refresh failed (${e.javaClass.simpleName})") - } - } - - /** Execute an authenticated request; one transparent refresh+retry on 401. */ - private suspend fun executeAuthed( - base: String, - build: (access: String) -> Request, - ): Response = withContext(Dispatchers.IO) { - val session = auth.load() ?: throw ProviderError.NotConnected() - val first = client.newCall(build(session.accessToken)).execute() - if (first.code != 401) return@withContext first - first.close() - val fresh = refreshLocked(session.accessToken) - val retry = client.newCall(build(fresh)).execute() - if (retry.code == 401) { - retry.close() - _authState.value = AuthState.EXPIRED - throw ProviderError.AuthExpired() - } - retry - } - - private fun get(base: String, path: String, access: String): Request = - Request.Builder().url(base + path).get() - .header("Authorization", "Bearer $access").build() - - private fun post(base: String, path: String, access: String, json: String): Request = - Request.Builder().url(base + path) - .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())) - .header("Authorization", "Bearer $access").build() - - private fun put(base: String, path: String, access: String, json: String): Request = - Request.Builder().url(base + path) - .put(json.toRequestBody("application/json; charset=utf-8".toMediaType())) - .header("Authorization", "Bearer $access").build() - - private fun patch(base: String, path: String, access: String, json: String): Request = - Request.Builder().url(base + path) - .patch(json.toRequestBody("application/json; charset=utf-8".toMediaType())) - .header("Authorization", "Bearer $access").build() - - private fun postUnauthed(base: String, path: String, json: String): Response { - val req = Request.Builder().url(base + path) - .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build() - return client.newCall(req).execute() - } - - // Upload-session flow returns Triple(sessionId, chunkSize, declaredMime echo not needed). - private suspend fun createSession(base: String, draft: RecordingDraft): Pair { - val body = buildString { - append("""{"declared_mime_type":${jsonStr(draft.mime)},""") - append(""""declared_size_bytes":${draft.sizeBytes},""") - append(""""title":${jsonStr(draft.title)},""") - append(""""client_recording_id":${jsonStr(draft.id)}""") - if (draft.transcriptionModel != null) { - append(""","transcription_model":${jsonStr(draft.transcriptionModel)}""") - } - append("}") - } - executeAuthed(base) { token -> post(base, "/api/v1/uploads", token, body) }.use { resp -> - when (resp.code) { - 201 -> { - val json = org.json.JSONObject(resp.body?.string().orEmpty()) - return json.getString("id") to json.optInt("chunk_size_bytes", 16 * 1024 * 1024) - } - 413 -> throw ProviderError.Transient("Recording exceeds the server size limit") - 415 -> throw ProviderError.Transient("Audio type not accepted by the server") - else -> throw ProviderError.Transient("Upload rejected (HTTP ${resp.code})") - } - } - } - - private suspend fun uploadStatus(base: String, sessionId: String): Set { - executeAuthed(base) { token -> get(base, "/api/v1/uploads/$sessionId", token) }.use { resp -> - if (resp.code != 200) throw ProviderError.Transient("Upload status failed (HTTP ${resp.code})") - val arr = org.json.JSONObject(resp.body?.string().orEmpty()) - .optJSONArray("received_chunk_indexes") ?: return emptySet() - return (0 until arr.length()).map { arr.getInt(it) }.toSet() - } - } - - private suspend fun putChunk(base: String, sessionId: String, index: Int, bytes: ByteArray) { - val digest = MessageDigest.getInstance("SHA-256").digest(bytes).toHex() - val req = { token: String -> - Request.Builder().url("$base/api/v1/uploads/$sessionId/chunks/$index") - .put(bytes.toRequestBody("application/octet-stream".toMediaType())) - .header("Authorization", "Bearer $token") - .header("X-Chunk-Sha256", digest).build() - } - executeAuthed(base, req).use { resp -> - if (resp.code != 201) throw ProviderError.Transient("Chunk $index rejected (HTTP ${resp.code})") - } - } - - private suspend fun finalize(base: String, sessionId: String, draft: RecordingDraft): String { - val recordedAt = Instant.ofEpochMilli(draft.createdAtEpochMs).toString() - val body = buildString { - append("""{"recorded_at":${jsonStr(recordedAt)},""") - append(""""duration_seconds":${draft.durationMs / 1000.0}""") - if (draft.transcriptionModel != null) { - append(""","transcription_model":${jsonStr(draft.transcriptionModel)}""") - } - append("}") - } - executeAuthed(base) { token -> post(base, "/api/v1/uploads/$sessionId/finalize", token, body) } - .use { resp -> - if (resp.code != 201) { - throw ProviderError.Transient("Upload finalize failed (HTTP ${resp.code})") - } - return org.json.JSONObject(resp.body?.string().orEmpty()).getString("id") - } - } - - private fun parseRecording(json: org.json.JSONObject): RemoteRecording? { - if (!json.optBoolean("has_audio", false)) return null - val id = json.optString("id", "") - if (id.isBlank()) return null - val createdAt = runCatching { - Instant.parse(json.getString("recorded_at")).toEpochMilli() - }.getOrDefault(0L) - return RemoteRecording( - ref = RemoteRef( - providerId = ProviderRegistry.CUSTOM_SHONAR_ID, - key = id, - etag = null, - sizeBytes = json.optLong("size_bytes", 0), - ), - title = json.optString("title", id), - createdAtEpochMs = createdAt, - durationMs = (json.optDouble("duration_seconds", 0.0) * 1000).toLong(), - mime = json.optString("mime_type", null) ?: "application/octet-stream", - ) - } - - companion object { - fun defaultClient(): OkHttpClient = OkHttpClient.Builder() - // No logging interceptor by policy (docs/server-providers.md §4): - // bodies would carry audio bytes and transcripts. - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() - - /** JSON string literal with escaping; never pass secrets to message strings. */ - internal fun jsonStr(raw: String): String = buildString { - append('"') - for (c in raw) when (c) { - '"' -> append("\\\"") - '\\' -> append("\\\\") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> if (c < ' ') append("\\u%04x".format(c.code)) else append(c) - } - append('"') - } - - private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } - - private fun readSlice(file: File, offset: Long, max: Int): ByteArray { - file.inputStream().use { input -> - var skipped = 0L - while (skipped < offset) { - val n = input.skip(offset - skipped) - if (n <= 0) break - skipped += n - } - val buf = ByteArray(max) - var read = 0 - while (read < max) { - val n = input.read(buf, read, max - read) - if (n < 0) break - read += n - } - return if (read == max) buf else buf.copyOf(read) - } - } - } -} diff --git a/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt b/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt deleted file mode 100644 index eb002d7..0000000 --- a/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt +++ /dev/null @@ -1,194 +0,0 @@ -package com.shonar.provider - -import java.io.File -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext - -/** - * P4: sync-folder provider — "bring your own sync". The app reads and - * writes plain files under a user-chosen directory; an external tool - * (Syncthing, the Nextcloud desktop client, rsync, …) moves those bytes - * between devices. The app never talks to a network for this provider — - * provable by construction (no HTTP imports in this file). - * - * The on-disk layout is identical to [LocalOnlyProvider] (`audio/{id}`, - * `sidecars/…`), so switching between local-only and a sync folder is a - * copy, not a migration, and anything already syncing the folder picks - * the recordings up with no special handling. - * - * Two deliberate differences from local-only: - * - [connect] validates the directory (must exist, be a directory, be - * readable AND writable) and refuses anything else. A typo must be an - * error, never a silently created folder somewhere surprising. Paths - * escaping via `..` are rejected for the same reason. - * - [deleteAccountAndData] NEVER deletes the folder's contents. That - * directory belongs to the user and their sync tool, not to the app — - * forgetting the path is the whole operation. - */ -class FolderSyncProvider( - private val pathStore: com.shonar.settings.SettingsStore = - com.shonar.settings.InMemorySettingsStore(), -) : ShonarProvider { - - override val descriptor = ProviderDescriptor( - id = ID, - displayName = "Sync folder", - capabilities = setOf(), // the sync tool owns the protocol, not us - ) - - private val _authState = MutableStateFlow(AuthState.DISCONNECTED) - override val authState: StateFlow = _authState - - private suspend fun storedRoot(): File? { - val raw = pathStore.getString(KEY_ROOT) ?: return null - return File(raw) - } - - private fun checkDir(dir: File): File { - if (".." in dir.path.split(File.separatorChar)) { - throw ProviderError.InvalidUrl("Folder path must not contain '..'") - } - if (!dir.exists()) throw ProviderError.InvalidUrl( - "Folder does not exist: ${dir.path}. Create it (or let Syncthing create it) first." - ) - if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}") - if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl( - "Folder is not readable and writable: ${dir.path}" - ) - return dir - } - - override suspend fun probe(baseUrl: ServerUrl): ProbeResult = - ProbeResult.Incompatible // no server involved — nothing to probe - - override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) { - val folder = credential as? ProviderCredential.FolderPath - ?: throw ProviderError.InvalidUrl( - "Sync folder needs a folder path to sync through" - ) - val dir = checkDir(File(folder.path)) - pathStore.putString(KEY_ROOT, dir.canonicalPath) - _authState.value = AuthState.CONNECTED - } - - /** - * Create the folder, then connect. Safety rules (a typo must never - * spray directories across storage): - * - no `..` segments, never a blank path; - * - the direct parent must already exist, be a directory, and be - * writable — only the final segment is ever created; - * - when the folder already exists this is just [connect]. - */ - suspend fun createRoot(rawPath: String) = withContext(Dispatchers.IO) { - val trimmed = rawPath.trim() - if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty") - val dir = File(trimmed) - if (".." in dir.path.split(File.separatorChar)) { - throw ProviderError.InvalidUrl("Folder path must not contain '..'") - } - if (!dir.exists()) { - val parent = dir.absoluteFile.parentFile - ?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed") - if (!parent.isDirectory) throw ProviderError.InvalidUrl( - "Parent is not a folder: ${parent.path}" - ) - if (!parent.canWrite()) throw ProviderError.InvalidUrl( - "Parent folder is not writable: ${parent.path}" - ) - if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl( - "Could not create folder: ${dir.path}" - ) - } - val checked = checkDir(dir) - pathStore.putString(KEY_ROOT, checked.canonicalPath) - _authState.value = AuthState.CONNECTED - } - - override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) { - val root = storedRoot() - if (root == null) { - _authState.value = AuthState.DISCONNECTED - return@withContext _authState.value - } - runCatching { checkDir(root) } - .onSuccess { _authState.value = AuthState.CONNECTED } - .onFailure { _authState.value = AuthState.DISCONNECTED } - _authState.value - } - - override suspend fun disconnect(revokeOnServer: Boolean) { - // Nothing remote to revoke; the path is kept so reconnect is one tap. - _authState.value = AuthState.DISCONNECTED - } - - override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) { - // Forget the folder. The files stay — they belong to the user and - // their sync tool, and deleting someone's Syncthing folder because - // they tapped "disconnect" would be unforgivable. - pathStore.remove(KEY_ROOT) - _authState.value = AuthState.DISCONNECTED - } - - // ---- storage: delegate with rewritten identity -------------------------- - - private suspend fun root(): File { - if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected() - return storedRoot()?.let { checkDir(it) } ?: throw ProviderError.NotConnected() - } - - override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef = - withContext(Dispatchers.IO) { - val ref = LocalOnlyProvider(root()).upload(draft, onProgress) - ref.copy(providerId = ID) - } - - override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) = - withContext(Dispatchers.IO) { - LocalOnlyProvider(root()).download(ref.copy(providerId = LocalOnlyProvider.ID), dest, onProgress) - } - - override suspend fun delete(ref: RemoteRef) = withContext(Dispatchers.IO) { - LocalOnlyProvider(root()).delete(ref.copy(providerId = LocalOnlyProvider.ID)) - } - - override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) { - val page = LocalOnlyProvider(root()).list(cursor) - Page( - page.items.map { it.copy(ref = it.ref.copy(providerId = ID)) }, - page.nextCursor, - ) - } - - override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) = - withContext(Dispatchers.IO) { - LocalOnlyProvider(root()) - .putSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind, bytes) - } - - override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = - withContext(Dispatchers.IO) { - LocalOnlyProvider(root()) - .getSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind) - } - - override suspend fun storageLocationSummary(): StorageLocation = - withContext(Dispatchers.IO) { - val r = root() - val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList() - StorageLocation( - headline = "Sync folder", - detail = "${audio.size} recordings — synced by your sync tool, not by this app.", - syncedCount = 0, - localOnlyCount = audio.size, - bytesUsed = audio.sumOf { it.length() }, - path = r.path, - ) - } - - companion object { - const val ID = "sync-folder" - const val KEY_ROOT = "provider.sync-folder.root" - } -} diff --git a/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt b/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt deleted file mode 100644 index 2e2a3d0..0000000 --- a/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt +++ /dev/null @@ -1,249 +0,0 @@ -package com.shonar.provider - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import java.io.File - -/** - * Local-only provider: everything stays in app-private storage. First-class - * so the app has no "no server" special case. No networking code paths at - * all — provable by construction (this file imports no HTTP library). - * - * The storage root is the app-private [defaultRoot], unless the user picked - * their own folder ([connect]/[createRoot] with a [ProviderCredential.FolderPath]): - * then files live there and [deleteAccountAndData] only forgets the path — - * a user folder is never deleted by this app. - */ -class LocalOnlyProvider( - private val defaultRoot: File, - private val pathStore: com.shonar.settings.SettingsStore = - com.shonar.settings.InMemorySettingsStore(), - private val rootKey: String = KEY_ROOT, -) : ShonarProvider { - - override val descriptor = ProviderDescriptor( - id = ID, - displayName = "Local-only storage", - capabilities = setOf(), // no chunked protocol needed; no server AI - ) - - private val _authState = MutableStateFlow(AuthState.CONNECTED) - override val authState: StateFlow = _authState - - private suspend fun storedRoot(): File? { - val raw = pathStore.getString(rootKey) ?: return null - return File(raw).takeIf { it.exists() } - } - - /** App-private dir by default; the user's own folder once picked. */ - private suspend fun effectiveRoot(): File = storedRoot() ?: defaultRoot - - /** Where new recordings belong right now. Read by the recording repository. */ - suspend fun currentRoot(): File = effectiveRoot() - - private fun checkDir(dir: File): File { - if (".." in dir.path.split(File.separatorChar)) { - throw ProviderError.InvalidUrl("Folder path must not contain '..'") - } - if (!dir.exists()) throw ProviderError.InvalidUrl( - "Folder does not exist: ${dir.path}." - ) - if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}") - if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl( - "Folder is not readable and writable: ${dir.path}" - ) - return dir - } - - override suspend fun probe(baseUrl: ServerUrl): ProbeResult = - ProbeResult.Incompatible // local-only never talks to servers - - override suspend fun connect(credential: ProviderCredential) { - when (credential) { - ProviderCredential.None -> _authState.value = AuthState.CONNECTED - is ProviderCredential.FolderPath -> { - val dir = checkDir(File(credential.path)) - pathStore.putString(rootKey, dir.canonicalPath) - _authState.value = AuthState.CONNECTED - } - else -> throw ProviderError.InvalidUrl( - "Local-only storage takes no credentials" - ) - } - } - - /** - * Use the app-private folder again (forget a previously picked folder; - * its files stay where they are). - */ - suspend fun useDefaultRoot() { - pathStore.remove(rootKey) - _authState.value = AuthState.CONNECTED - } - - /** - * Create the folder, then use it. Same safety rules as the sync - * folder: only the final segment is ever created, the parent must - * already exist, no `..`. When it exists this is just [connect]. - */ - suspend fun createRoot(rawPath: String) { - val trimmed = rawPath.trim() - if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty") - val dir = File(trimmed) - if (".." in dir.path.split(File.separatorChar)) { - throw ProviderError.InvalidUrl("Folder path must not contain '..'") - } - if (!dir.exists()) { - val parent = dir.absoluteFile.parentFile - ?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed") - if (!parent.isDirectory) throw ProviderError.InvalidUrl( - "Parent is not a folder: ${parent.path}" - ) - if (!parent.canWrite()) throw ProviderError.InvalidUrl( - "Parent folder is not writable: ${parent.path}" - ) - if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl( - "Could not create folder: ${dir.path}" - ) - } - val checked = checkDir(dir) - pathStore.putString(rootKey, checked.canonicalPath) - _authState.value = AuthState.CONNECTED - } - - override suspend fun reconnect(): AuthState { - // A picked folder may have been deleted out from under us; fall - // back to the app-private dir rather than stranding recordings. - _authState.value = AuthState.CONNECTED - return _authState.value - } - - override suspend fun disconnect(revokeOnServer: Boolean) { - // Nothing to revoke; DISCONNECTED means "user left local mode" — the - // sync layer treats it as paused, files remain on disk. - _authState.value = AuthState.DISCONNECTED - } - - override suspend fun deleteAccountAndData() { - if (storedRoot() != null) { - // A user-picked folder belongs to the user — forget it, never - // delete it. - pathStore.remove(rootKey) - } else if (defaultRoot.exists()) { - defaultRoot.deleteRecursively() - } - _authState.value = AuthState.DISCONNECTED - } - - // ---- storage ----------------------------------------------------------- - - private suspend fun audioFile(ref: RemoteRef) = File(effectiveRoot(), ref.key) - private suspend fun sidecarFile(ref: RemoteRef, kind: SidecarKind) = - File(effectiveRoot(), "sidecars/${ref.key}/${kind.fileName}") - - override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef { - val key = "audio/${draft.id}" - val dest = File(effectiveRoot(), key) - dest.parentFile?.mkdirs() - // "Upload" locally = copy; report progress in slices so UI behaves uniformly - draft.sourceFile.inputStream().use { input -> - dest.outputStream().use { output -> - val buf = ByteArray(64 * 1024) - val total = draft.sizeBytes.coerceAtLeast(1) - var written = 0L - while (true) { - val n = input.read(buf) - if (n < 0) break - output.write(buf, 0, n) - written += n - onProgress((written.toFloat() / total).coerceIn(0f, 1f)) - } - } - } - return RemoteRef(ID, key, etag = "sha256:" + dest.sha256Hex(), sizeBytes = dest.length()) - } - - override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) { - val src = audioFile(ref) - if (!src.exists()) throw ProviderError.NotFound(ref.key) - src.copyTo(dest, overwrite = true) - onProgress(1f) - } - - override suspend fun delete(ref: RemoteRef) { - audioFile(ref).delete() - File(effectiveRoot(), "sidecars/${ref.key}").deleteRecursively() - } - - override suspend fun list(cursor: String?): Page { - val audioRoot = File(effectiveRoot(), "audio") - val files = audioRoot.listFiles()?.filter { it.isFile } ?: emptyList() - // single page; no paging for local storage - val items = files.map { f -> - RemoteRecording( - ref = RemoteRef(ID, "audio/${f.name}", etag = "sha256:" + f.sha256Hex(), sizeBytes = f.length()), - title = f.nameWithoutExtension, - createdAtEpochMs = f.lastModified(), - durationMs = 0, // duration is tracked in Room, not on disk - mime = "application/octet-stream", - ) - } - return Page(items, nextCursor = null) - } - - override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) { - val f = sidecarFile(ref, kind) - f.parentFile?.mkdirs() - f.writeBytes(bytes) - } - - override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? { - val f = sidecarFile(ref, kind) - return if (f.exists()) f.readBytes() else null - } - - override suspend fun storageLocationSummary(): StorageLocation { - val raw = pathStore.getString(rootKey) - val custom = storedRoot() - if (raw != null && custom == null) { - return StorageLocation( - headline = "Folder unavailable", - detail = "Your picked folder is gone or unreadable. " + - "Reconnect storage or pick again — new recordings use the app folder meanwhile.", - syncedCount = 0, - localOnlyCount = 0, - bytesUsed = 0, - path = raw, - ) - } - val r = custom ?: defaultRoot - val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList() - return StorageLocation( - headline = if (custom != null) "Your folder" else "On this device only", - detail = if (custom != null) "Recordings stay in your folder and never leave your phone." - else "Recordings and transcripts never leave your phone.", - syncedCount = 0, - localOnlyCount = audio.size, - bytesUsed = audio.sumOf { it.length() }, - path = r.absolutePath, - ) - } - - companion object { - const val ID = "local-only" - const val KEY_ROOT = "provider.local-only.root" - - private fun File.sha256Hex(): String { - val md = java.security.MessageDigest.getInstance("SHA-256") - inputStream().use { inn -> - val buf = ByteArray(64 * 1024) - while (true) { - val n = inn.read(buf) - if (n < 0) break - md.update(buf, 0, n) - } - } - return md.digest().joinToString("") { "%02x".format(it) } - } - } -} diff --git a/android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt b/android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt deleted file mode 100644 index 9750e75..0000000 --- a/android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.shonar.provider - -import java.util.concurrent.TimeUnit -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 - -/** - * Nextcloud server identification + login flow v2 (docs/server-providers.md - * §3, §6). No credentials are ever sent here: [startLogin] is anonymous, - * [poll] carries only the one-time poll token. - * - * Login flow v2 (official, supported): - * 1. POST {base}/index.php/login/v2 -> {poll:{token,endpoint}, login:url} - * 2. user approves {login} in a browser (server-owned consent screen) - * 3. POST {poll.endpoint} {"token":...} -> 404 while pending, - * 200 {server, loginName, appPassword} once approved - */ -class NextcloudAuth( - private val client: OkHttpClient = defaultClient(), - private val tofu: TofuManager? = null, -) { - - /** Is there a Nextcloud at [url]? Read-only, no credentials. */ - suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) { - val req = Request.Builder().url(url.origin + "/status.php").get().build() - try { - client.newCall(req).execute().use { resp -> - if (resp.code != 200) return@withContext ProbeResult.Incompatible - val body = JSONObject(resp.body?.string().orEmpty()) - // status.php: {productname, versionstring, ...}. Some forks - // report "Nextcloud" with different casing — accept any. - val product = body.optString("productname", "") - if (!product.equals("nextcloud", ignoreCase = true)) { - return@withContext ProbeResult.Incompatible - } - ProbeResult.Compatible( - descriptor = ProviderDescriptor( - id = ProviderRegistry.NEXTCLOUD_ID, - displayName = "Nextcloud", - isDefault = true, - capabilities = setOf( - ProviderDescriptor.Capability.CHUNKED_UPLOAD, - ProviderDescriptor.Capability.QUOTA_INFO, - ), - ), - serverName = product, - version = body.optString("versionstring", "unknown"), - ) - } - } catch (e: javax.net.ssl.SSLHandshakeException) { - ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown") - } catch (e: javax.net.ssl.SSLPeerUnverifiedException) { - ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown") - } catch (e: Exception) { - ProbeResult.NetworkError(e.javaClass.simpleName) - } - } - - /** Begins login flow v2. Returns the browser URL + poll handle. */ - suspend fun startLogin(url: ServerUrl): LoginFlowSession = withContext(Dispatchers.IO) { - val req = Request.Builder().url(url.origin + "/index.php/login/v2") - .post(ByteArray(0).toRequestBody(null)).build() - try { - client.newCall(req).execute().use { resp -> - if (resp.code != 200) { - throw ProviderError.Transient( - "Server refused the login request (HTTP ${resp.code}). " + - "Is this a Nextcloud?" - ) - } - val body = JSONObject(resp.body?.string().orEmpty()) - val poll = body.optJSONObject("poll") - val login = body.optString("login", "") - val token = poll?.optString("token", "").orEmpty() - val endpoint = poll?.optString("endpoint", "").orEmpty() - if (login.isBlank() || token.isBlank() || endpoint.isBlank()) { - throw ProviderError.Transient("Server gave an incomplete login response") - } - LoginFlowSession( - baseUrl = url.origin, - loginUrl = login, - pollToken = token, - pollEndpoint = endpoint, - ) - } - } catch (e: ProviderError) { - throw e - } catch (e: Exception) { - throw ProviderError.Transient("Could not reach the server (${e.javaClass.simpleName})") - } - } - - /** One poll attempt. Call repeatedly until [PollResult.Approved]. */ - suspend fun poll(flow: LoginFlowSession): PollResult = withContext(Dispatchers.IO) { - val json = """{"token":"${flow.pollToken}"}""" - val req = Request.Builder().url(flow.pollEndpoint) - .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build() - try { - client.newCall(req).execute().use { resp -> - when (resp.code) { - 200 -> { - val body = JSONObject(resp.body?.string().orEmpty()) - val server = body.optString("server", flow.baseUrl) - val name = body.optString("loginName", "") - val pass = body.optString("appPassword", "") - if (name.isBlank() || pass.isBlank()) { - return@withContext PollResult.Failed("Server approved but sent no credentials") - } - PollResult.Approved( - ProviderCredential.AppPassword( - accountLabel = "$name@${baseHost(server)}", - loginUrl = server, - user = name, - password = pass, - ) - ) - } - 404 -> PollResult.Pending - else -> PollResult.Failed("Login poll failed (HTTP ${resp.code})") - } - } - } catch (e: Exception) { - PollResult.Failed("Login poll failed (${e.javaClass.simpleName})") - } - } - - companion object { - fun defaultClient(): OkHttpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(20, TimeUnit.SECONDS) - .followRedirects(false) - .build() - - private fun baseHost(server: String): String = - runCatching { java.net.URI(server).host }.getOrNull() ?: server - } -} - -/** Browser URL + one-time poll handle from [NextcloudAuth.startLogin]. */ -data class LoginFlowSession( - val baseUrl: String, - val loginUrl: String, - val pollToken: String, - val pollEndpoint: String, -) - -/** One poll attempt's outcome. The token itself never appears in messages. */ -sealed class PollResult { - data object Pending : PollResult() - data class Approved(val credential: ProviderCredential.AppPassword) : PollResult() - data class Failed(val reason: String) : PollResult() -} diff --git a/android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt b/android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt deleted file mode 100644 index 572cd45..0000000 --- a/android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.shonar.provider - -import com.shonar.settings.SettingsStore - -/** - * Secure persistence for the Nextcloud session: server origin, DAV user id, - * display name, and the app password from login flow v2. The user's *normal* - * account password is never requested, typed, or stored — only the - * server-issued app password lives here (docs/server-providers.md §3). - */ -class NextcloudAuthStore(private val secure: SettingsStore) { - - suspend fun save(session: NcSession) { - secure.putString(KEY_BASE_URL, session.baseUrl) - secure.putString(KEY_USER_ID, session.userId) - secure.putString(KEY_USERNAME, session.username) - secure.putString(KEY_APP_PASSWORD, session.appPassword) - } - - suspend fun load(): NcSession? { - val base = secure.getString(KEY_BASE_URL) ?: return null - val pass = secure.getString(KEY_APP_PASSWORD) ?: return null - return NcSession( - baseUrl = base, - userId = secure.getString(KEY_USER_ID).orEmpty(), - username = secure.getString(KEY_USERNAME).orEmpty(), - appPassword = pass, - ) - } - - suspend fun clear() { - secure.remove(KEY_BASE_URL) - secure.remove(KEY_USER_ID) - secure.remove(KEY_USERNAME) - secure.remove(KEY_APP_PASSWORD) - } - - companion object { - private const val PREFIX = "provider.nextcloud." - const val KEY_BASE_URL = PREFIX + "base_url" - const val KEY_USER_ID = PREFIX + "user_id" - const val KEY_USERNAME = PREFIX + "username" - const val KEY_APP_PASSWORD = PREFIX + "app_password" - } -} - -/** Authenticated Nextcloud session. Never logged (see toString). */ -data class NcSession( - val baseUrl: String, - val userId: String, // DAV path user (OCS `id`, not the display name) - val username: String, // human hint only - val appPassword: String, -) { - override fun toString(): String = "NcSession(server=$baseUrl, user=$username, [redacted])" -} diff --git a/android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt b/android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt deleted file mode 100644 index 5ab2e9a..0000000 --- a/android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt +++ /dev/null @@ -1,644 +0,0 @@ -package com.shonar.provider - -import java.io.File -import java.time.format.DateTimeFormatter -import java.util.UUID -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.Response -import org.json.JSONObject - -/** - * P4: Nextcloud provider — the product default. Talks only official, - * supported endpoints (docs/server-providers.md §6): - * - * - GET {base}/status.php (probe) - * - login flow v2 (see [NextcloudAuth]) - * - GET {base}/ocs/v2.php/cloud/user (identity + quota) - * - DELETE {base}/ocs/v2.php/core/apppassword (revoke own app password) - * - WebDAV {base}/remote.php/dav/files/{user}/… (PROPFIND/GET/PUT/MKCOL/MOVE/DELETE) - * - Chunked upload v2 {base}/remote.php/dav/uploads/{user}/{transfer}/ - * (MKCOL, PUT chunks 00001..N, MOVE {transfer}/.file -> destination) - * - * Layout: `SHONAR/audio/{uuid}.m4a`, `SHONAR/sidecars/{uuid}/{kind}.json`. - * Originals are never overwritten by processing artifacts — uploads with - * the same draft id MOVE onto the same key (idempotent replace). - * - * Chunk naming follows the developer manual: chunks are numbered 1..10000 - * and assembled in name order, so names are zero-padded to 5 digits - * ("00001".."10000") to keep lexical order == numeric order. Chunk size - * defaults to 16 MiB — the server requires 5 MiB..5 GiB per chunk (last - * chunk exempt), so never lower the default for production use; the - * constructor parameter exists for tests only. - * - * Transfer ids are deterministic per recording (`shonar-{draft.id}`), so a - * killed upload resumes by PROPFIND-ing the transfer folder and skipping - * present chunks — including across process restarts. - */ -class NextcloudProvider( - private val auth: NextcloudAuthStore, - private val client: OkHttpClient = NextcloudAuth.defaultClient(), - private val loginFlow: NextcloudAuth = NextcloudAuth(client), - private val chunkSizeBytes: Long = 16 * 1024 * 1024, -) : ShonarProvider { - - override val descriptor = ProviderDescriptor( - id = ProviderRegistry.NEXTCLOUD_ID, - displayName = "Nextcloud", - isDefault = true, - capabilities = setOf( - ProviderDescriptor.Capability.CHUNKED_UPLOAD, - ProviderDescriptor.Capability.QUOTA_INFO, - ), - ) - - private val _authState = MutableStateFlow(AuthState.DISCONNECTED) - override val authState: StateFlow = _authState - - // ---- lifecycle --------------------------------------------------------- - - override suspend fun probe(baseUrl: ServerUrl): ProbeResult = loginFlow.probe(baseUrl) - - override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) { - val app = credential as? ProviderCredential.AppPassword - ?: throw ProviderError.InvalidUrl( - "Nextcloud connects with an app password from the browser login flow" - ) - val origin = ServerUrl.parse(app.loginUrl).getOrNull()?.origin - ?: throw ProviderError.InvalidUrl("Not a valid server URL") - // Validate before persisting: a wrong password must not overwrite a - // working session. - val userId = try { - ocsUserId(origin, app.user, app.password) - } catch (e: ProviderError.AuthExpired) { - throw ProviderError.Transient( - "Nextcloud rejected the login — approve it in the browser again" - ) - } - auth.save( - NcSession( - baseUrl = origin, - userId = userId, - username = app.user, - appPassword = app.password, - ) - ) - _authState.value = AuthState.CONNECTED - } - - override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) { - val saved = auth.load() - if (saved == null || saved.appPassword.isBlank() || saved.userId.isBlank()) { - _authState.value = AuthState.DISCONNECTED - return@withContext _authState.value - } - try { - ocsUserId(saved.baseUrl, saved.userId, saved.appPassword) - _authState.value = AuthState.CONNECTED - } catch (e: ProviderError.AuthExpired) { - _authState.value = AuthState.EXPIRED - } catch (e: ProviderError) { - _authState.value = AuthState.OFFLINE - } - _authState.value - } - - override suspend fun disconnect(revokeOnServer: Boolean) = withContext(Dispatchers.IO) { - if (revokeOnServer) { - // Best effort: local state is cleared even if revoke fails. - runCatching { - val saved = auth.load() - if (saved != null && saved.appPassword.isNotBlank()) { - dav(saved, "DELETE", ocsPath("/core/apppassword"), null).close() - } - } - } - auth.clear() - _authState.value = AuthState.DISCONNECTED - } - - /** - * Revokes the app password and forgets the session. There is no API for - * deleting the whole Nextcloud account — that stays a manual step on the - * server, and the message says so. - */ - override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) { - disconnect(revokeOnServer = true) - } - - // ---- storage ----------------------------------------------------------- - - override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef = - withContext(Dispatchers.IO) { - val session = connectedSession() - val key = "SHONAR/audio/${draft.id}${extFor(draft.mime)}" - val total = draft.sizeBytes.coerceAtLeast(1) - ensureDir(session, "SHONAR") - ensureDir(session, "SHONAR/audio") - - val transfer = "shonar-${draft.id}" - mkcol(session, transfer) - val received = transferChunks(session, transfer) - val chunkCount = ((draft.sizeBytes + chunkSizeBytes - 1) / chunkSizeBytes) - .toInt().coerceAtLeast(1) - var sent = 0L - for (i in received) { - sent += if (i < chunkCount) chunkSizeBytes else 0L - } - sent = sent.coerceAtMost(draft.sizeBytes) - if (sent > 0) onProgress((sent.toFloat() / total).coerceIn(0f, 1f)) - - var idx = 1 - while (idx <= chunkCount) { - ensureActive() - if (idx !in received) { - val slice = readSlice(draft.sourceFile, (idx - 1) * chunkSizeBytes, chunkSizeBytes) - putChunk(session, transfer, idx, slice, draft.sizeBytes, key) - sent += slice.size - onProgress((sent.toFloat() / total).coerceIn(0f, 1f)) - } - idx++ - } - assemble(session, transfer, key, draft) - // Best-effort cleanup of the transfer folder; the server also - // expires stale upload dirs on its own. - runCatching { - dav(session, "DELETE", uploadsPath(session, transfer) + "/", null).close() - } - onProgress(1f) - RemoteRef(ProviderRegistry.NEXTCLOUD_ID, key, etag = null, sizeBytes = draft.sizeBytes) - } - - override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) = - withContext(Dispatchers.IO) { - val session = connectedSession() - dav(session, "GET", filesPath(session, ref.key), null).use { resp -> - when (resp.code) { - 200 -> { - val body = resp.body ?: throw ProviderError.Transient("Empty download response") - val total = body.contentLength().takeIf { it > 0 } ?: -1 - dest.parentFile?.mkdirs() - body.byteStream().use { input -> - dest.outputStream().use { output -> - val buf = ByteArray(64 * 1024) - var written = 0L - var last = -1f - while (true) { - val n = input.read(buf) - if (n < 0) break - output.write(buf, 0, n) - written += n - if (total > 0) { - val p = (written.toFloat() / total).coerceIn(0f, 1f) - if (p > last) { - onProgress(p) - last = p - } - } - } - } - } - onProgress(1f) - } - 401 -> throw ProviderError.AuthExpired() - 404 -> throw ProviderError.NotFound(ref.key) - else -> throw ProviderError.Transient("Download failed (HTTP ${resp.code})") - } - } - } - - override suspend fun delete(ref: RemoteRef) { - withContext(Dispatchers.IO) { - val session = connectedSession() - dav(session, "DELETE", filesPath(session, ref.key), null).use { resp -> - when (resp.code) { - 200, 201, 204 -> Unit - 401 -> throw ProviderError.AuthExpired() - 404 -> throw ProviderError.NotFound(ref.key) - else -> throw ProviderError.Transient("Delete failed (HTTP ${resp.code})") - } - } - // Sidecars go with the recording; ignore failures (may not exist). - runCatching { - dav(session, "DELETE", filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/"), null) - .close() - } - } - } - - override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) { - val session = connectedSession() - // Single page: a PROPFIND Depth:1 returns the whole folder. Paging - // stays null until a library outgrows one response. - if (cursor != null) return@withContext Page(emptyList(), null) - val items = propfind(session, filesPath(session, "SHONAR/audio/"), depth = "1") - .filter { it.isFile && it.relativePath != "SHONAR/audio/" && !it.relativePath.removePrefix("SHONAR/audio/").contains('/') } - .map { e -> - val name = e.relativePath.removePrefix("SHONAR/audio/") - RemoteRecording( - ref = RemoteRef(ProviderRegistry.NEXTCLOUD_ID, e.relativePath, e.etag, e.size), - title = name.substringBeforeLast('.'), - createdAtEpochMs = e.lastModified, - durationMs = 0, // duration is tracked locally, not over DAV - mime = e.contentType ?: "application/octet-stream", - ) - } - Page(items, nextCursor = null) - } - - // ---- sidecars: real remote files under SHONAR/sidecars/{uuid}/ -------- - - override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) = - withContext(Dispatchers.IO) { - val session = connectedSession() - val dir = "SHONAR/sidecars/${uuidForKey(ref.key)}" - ensureDir(session, "SHONAR/sidecars") - ensureDir(session, dir) - dav( - session, "PUT", filesPath(session, "$dir/${kind.fileName}"), - bytes.toRequestBody("application/json; charset=utf-8".toMediaType()), - sensitiveBody = true, // transcripts are never logged, in any mode - ).use { resp -> - if (resp.code !in 200..201 && resp.code != 204) { - throw mapError(resp.code, "Sidecar upload failed") - } - } - } - - override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = - withContext(Dispatchers.IO) { - val session = connectedSession() - dav( - session, "GET", - filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/${kind.fileName}"), null, - sensitiveBody = true, // transcripts are never logged, in any mode - ).use { resp -> - when (resp.code) { - 200 -> resp.body?.bytes() - 401 -> throw ProviderError.AuthExpired() - 404 -> null - else -> throw mapError(resp.code, "Sidecar download failed") - } - } - } - - // ---- status ------------------------------------------------------------ - - override suspend fun storageLocationSummary(): StorageLocation = - withContext(Dispatchers.IO) { - val session = connectedSession() - val quota = ocsQuota(session) - var count = 0 - var bytes = 0L - // list() is single-page for now; keep the loop for when it pages. - var cursor: String? = null - do { - val page = list(cursor) - count += page.items.size - bytes += page.items.sumOf { it.ref.sizeBytes } - cursor = page.nextCursor - } while (cursor != null) - val host = runCatching { java.net.URI(session.baseUrl).host }.getOrNull() - ?: session.baseUrl - StorageLocation( - headline = "Nextcloud at $host", - detail = "${session.username} · $count recordings synced" + - (quota?.let { " · ${formatBytes(it.free)} free of ${formatBytes(it.total)}" } ?: ""), - syncedCount = count, - localOnlyCount = 0, - bytesUsed = bytes, - ) - } - - // ---- HTTP + DAV plumbing ----------------------------------------------- - - private suspend fun connectedSession(): NcSession { - if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected() - return auth.load()?.takeIf { it.appPassword.isNotBlank() && it.userId.isNotBlank() } - ?: throw ProviderError.NotConnected() - } - - private fun basic(session: NcSession): String { - val raw = "${session.userId}:${session.appPassword}".toByteArray(Charsets.UTF_8) - return "Basic " + java.util.Base64.getEncoder().encodeToString(raw) - } - - /** Raw DAV/OCS call. Caller closes the response. [path] starts with '/'. */ - private fun dav( - session: NcSession, - method: String, - path: String, - body: okhttp3.RequestBody?, - sensitiveBody: Boolean = false, - ): Response { - val builder = Request.Builder().url(session.baseUrl + path) - .header("Authorization", basic(session)) - if (sensitiveBody) builder.header(RedactingLogger.SENSITIVE_BODY, "1") - if (method == "GET") builder.get() - else builder.method(method, body) - if (path.startsWith("/ocs/")) { - builder.header("OCS-APIRequest", "true") - builder.header("Accept", "application/json") - } - return client.newCall(builder.build()).execute() - } - - private fun filesPath(session: NcSession, relative: String): String { - val segs = relative.split('/').filter { it.isNotEmpty() }.joinToString("/") { enc(it) } - return "/remote.php/dav/files/${enc(session.userId)}/$segs" - } - - private fun uploadsPath(session: NcSession, transfer: String): String = - "/remote.php/dav/uploads/${enc(session.userId)}/${enc(transfer)}" - - private fun ocsPath(suffix: String): String = "/ocs/v2.php$suffix" - - /** OCS identity check; returns the DAV user id or throws. */ - private suspend fun ocsUserId(origin: String, user: String, appPassword: String): String = - withContext(Dispatchers.IO) { - val raw = "$user:$appPassword".toByteArray(Charsets.UTF_8) - val req = Request.Builder().url(origin + ocsPath("/cloud/user")).get() - .header("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString(raw)) - .header("OCS-APIRequest", "true") - .header("Accept", "application/json").build() - client.newCall(req).execute().use { resp -> - when (resp.code) { - 200 -> { - val data = JSONObject(resp.body?.string().orEmpty()) - .optJSONObject("ocs")?.optJSONObject("data") - val id = data?.optString("id", "").orEmpty() - if (id.isBlank()) throw ProviderError.Transient("Server identity reply was empty") - id - } - 401 -> throw ProviderError.AuthExpired() - else -> throw ProviderError.Transient("Server identity check failed (HTTP ${resp.code})") - } - } - } - - private data class Quota(val free: Long, val total: Long) - - private suspend fun ocsQuota(session: NcSession): Quota? = withContext(Dispatchers.IO) { - // Quota is informational; never fail the summary over it. - runCatching { - dav(session, "GET", ocsPath("/cloud/user"), null).use { resp -> - if (resp.code != 200) return@runCatching null - val q = JSONObject(resp.body?.string().orEmpty()) - .optJSONObject("ocs")?.optJSONObject("data")?.optJSONObject("quota") - ?: return@runCatching null - // Quota values may be numbers or numeric strings; total -3 - // (or "unknown") means unlimited. - fun num(v: Any?): Long = when (v) { - is Number -> v.toLong() - is String -> v.toLongOrNull() ?: -3L - else -> -3L - } - val free = num(q.opt("free")) - val total = num(q.opt("total")) - if (total < 0) null else Quota(free.coerceAtLeast(0), total) - } - }.getOrNull() - } - - /** MKCOL tolerant of "already exists". */ - private suspend fun ensureDir(session: NcSession, relative: String) { - // Create level by level so a missing parent reads as progress, not 409. - val parts = relative.split('/').filter { it.isNotEmpty() } - var prefix = "" - for (part in parts) { - prefix = if (prefix.isEmpty()) part else "$prefix/$part" - dav(session, "MKCOL", filesPath(session, prefix), null).use { resp -> - if (resp.code == 401) throw ProviderError.AuthExpired() - if (resp.code != 201 && resp.code != 405) { - throw mapError(resp.code, "Could not create folder $prefix") - } - } - } - } - - private suspend fun mkcol(session: NcSession, transfer: String) { - dav(session, "MKCOL", uploadsPath(session, transfer) + "/", null).use { resp -> - // 405: transfer folder from a previous attempt — resume into it. - if (resp.code == 401) throw ProviderError.AuthExpired() - if (resp.code != 201 && resp.code != 405) { - throw mapError(resp.code, "Could not start the upload") - } - } - } - - /** Chunk names present in the transfer folder (resume). */ - private suspend fun transferChunks(session: NcSession, transfer: String): Set { - val prefix = "/remote.php/dav/uploads/${session.userId}/" - val entries = propfind(session, uploadsPath(session, transfer) + "/", depth = "1", prefix = prefix) - return entries.mapNotNullTo(mutableSetOf()) { e -> - // Chunk names are "00001".. — compare by numeric value. - e.name.trimStart('0').ifEmpty { "0" }.toIntOrNull() - ?.takeIf { it in 1..10000 } - } - } - - private suspend fun putChunk( - session: NcSession, - transfer: String, - index: Int, // 1-based - bytes: ByteArray, - totalBytes: Long, - destKey: String, - ) { - val name = "%05d".format(index) - val dest = session.baseUrl + filesPath(session, destKey) - val req = Request.Builder() - .url(session.baseUrl + uploadsPath(session, transfer) + "/" + name) - .put(bytes.toRequestBody("application/octet-stream".toMediaType())) - .header("Authorization", basic(session)) - .header("OC-Total-Length", totalBytes.toString()) - .header("Destination", dest).build() - client.newCall(req).execute().use { resp -> - when (resp.code) { - 200, 201, 204 -> Unit - 401 -> throw ProviderError.AuthExpired() - 507 -> throw ProviderError.QuotaExceeded() - else -> throw mapError(resp.code, "Chunk $index rejected") - } - } - } - - private suspend fun assemble( - session: NcSession, - transfer: String, - destKey: String, - draft: RecordingDraft, - ) { - val dest = session.baseUrl + filesPath(session, destKey) - val req = Request.Builder() - .url(session.baseUrl + uploadsPath(session, transfer) + "/.file") - .method("MOVE", null) - .header("Authorization", basic(session)) - .header("Destination", dest) - .header("Overwrite", "T") - .header("OC-Total-Length", draft.sizeBytes.toString()) - // Server mtime = recording time, so listings sort by when it - // was recorded, not when it finished uploading. - .header("X-OC-Mtime", (draft.createdAtEpochMs / 1000).toString()).build() - client.newCall(req).execute().use { resp -> - when (resp.code) { - 200, 201, 204 -> Unit - 401 -> throw ProviderError.AuthExpired() - 404 -> throw ProviderError.Transient("Upload assembly failed — retry the upload") - 507 -> throw ProviderError.QuotaExceeded() - else -> throw mapError(resp.code, "Upload assembly failed") - } - } - } - - internal data class DavEntry( - val relativePath: String, // relative to files/{user}/, decoded - val name: String, - val isFile: Boolean, - val size: Long, - val etag: String?, - val contentType: String?, - val lastModified: Long, - ) - - private suspend fun propfind( - session: NcSession, - path: String, - depth: String, - prefix: String = "/remote.php/dav/files/${session.userId}/", - ): List = - withContext(Dispatchers.IO) { - val body = """ -""" - val req = Request.Builder().url(session.baseUrl + path) - .method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType())) - .header("Authorization", basic(session)) - .header("Depth", depth).build() - client.newCall(req).execute().use { resp -> - when (resp.code) { - 200, 207 -> parseMultistatus(resp.body?.string().orEmpty(), prefix = prefix) - 401 -> throw ProviderError.AuthExpired() - 404 -> emptyList() - else -> throw mapError(resp.code, "Listing failed") - } - } - } - - private fun mapError(code: Int, fallback: String): ProviderError = when (code) { - 401 -> ProviderError.AuthExpired() - 507 -> ProviderError.QuotaExceeded() - else -> ProviderError.Transient("$fallback (HTTP $code)") - } - - companion object { - /** One path segment, percent-encoded (spaces as %20, not '+'). */ - internal fun enc(segment: String): String = - java.net.URLEncoder.encode(segment, "UTF-8").replace("+", "%20") - - internal fun uuidForKey(key: String): String = - key.substringAfterLast('/').substringBeforeLast('.') - - internal fun extFor(mime: String): String = when (mime.lowercase().substringBefore(';').trim()) { - "audio/mp4", "audio/m4a" -> ".m4a" - "audio/aac" -> ".aac" - "audio/wav", "audio/x-wav" -> ".wav" - "audio/ogg", "audio/opus" -> ".ogg" - "audio/webm" -> ".webm" - "audio/mpeg" -> ".mp3" - else -> ".m4a" // the app records m4a; unknown mimes keep a playable suffix - } - - internal fun formatBytes(n: Long): String { - if (n < 1024) return "$n B" - val units = arrayOf("KB", "MB", "GB", "TB") - var v = n.toDouble() / 1024 - var u = 0 - while (v >= 1024 && u < units.size - 1) { - v /= 1024 - u++ - } - return "%s %s".format(if (v >= 100) "%.0f" else "%.1f".format(v), units[u]) - } - - internal fun parseMultistatus(xml: String, prefix: String): List { - if (xml.isBlank()) return emptyList() - val out = mutableListOf() - try { - val factory = javax.xml.parsers.DocumentBuilderFactory.newInstance() - factory.isNamespaceAware = true - // Harden against XXE: multistatus docs never need doctypes. - runCatching { - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) - } - val doc = factory.newDocumentBuilder() - .parse(java.io.ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8))) - val responses = doc.getElementsByTagNameNS("DAV:", "response") - for (i in 0 until responses.length) { - val el = responses.item(i) as? org.w3c.dom.Element ?: continue - fun text(tag: String): String? { - val nodes = el.getElementsByTagNameNS("DAV:", tag) - if (nodes.length == 0) return null - return nodes.item(0).textContent?.trim()?.takeIf { it.isNotEmpty() } - } - val href = text("href") ?: continue - val decoded = runCatching { - java.net.URLDecoder.decode(href, "UTF-8") - }.getOrNull() ?: href - // Strip scheme+host when the server returns absolute hrefs. - val pathOnly = runCatching { java.net.URI(decoded).path }.getOrNull() ?: decoded - val relative = pathOnly.removePrefix(prefix).trim('/') - val isCollection = runCatching { - val rt = el.getElementsByTagNameNS("DAV:", "resourcetype") - rt.length > 0 && (rt.item(0) as org.w3c.dom.Element) - .getElementsByTagNameNS("DAV:", "collection").length > 0 - }.getOrDefault(false) - val lastMod = text("getlastmodified")?.let { - runCatching { - java.time.ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME) - .toInstant().toEpochMilli() - }.getOrNull() - } ?: 0L - out += DavEntry( - relativePath = relative, - name = relative.substringAfterLast('/'), - isFile = !isCollection, - size = text("getcontentlength")?.toLongOrNull() ?: 0L, - etag = text("getetag")?.trim('"'), - contentType = text("getcontenttype"), - lastModified = lastMod, - ) - } - } catch (e: Exception) { - throw ProviderError.Transient("Could not read the server listing") - } - return out - } - - private fun readSlice(file: File, offset: Long, max: Long): ByteArray { - file.inputStream().use { input -> - var skipped = 0L - while (skipped < offset) { - val n = input.skip(offset - skipped) - if (n <= 0) break - skipped += n - } - val cap = max.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() - val buf = ByteArray(cap) - var read = 0 - while (read < cap) { - val n = input.read(buf, read, cap - read) - if (n < 0) break - read += n - } - return if (read == cap) buf else buf.copyOf(read) - } - } - } -} diff --git a/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt b/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt deleted file mode 100644 index d11895b..0000000 --- a/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.shonar.provider - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -/** - * Maps provider ids to implementations. The active provider is whichever the - * user selected during setup; switching is just changing this id — the app - * never branches on concrete provider classes. - */ -class ProviderRegistry( - private val factories: Map ShonarProvider>, - /** Builds every provider HTTP client (TOFU trust + redacting logger). */ - val tls: TlsPolicy = defaultTls(), -) { - - private val _activeId = MutableStateFlow(LocalOnlyProvider.ID) - val activeId: StateFlow = _activeId - - /** Nextcloud is the product default once its factory registers (P4). */ - val defaultProviderId: String - get() = factories.keys.sorted().let { ids -> - ids.firstOrNull { it == NEXTCLOUD_ID } ?: ids.firstOrNull() ?: LocalOnlyProvider.ID - } - - fun provider(id: String): ShonarProvider = - factories[id]?.invoke() ?: throw ProviderError.InvalidUrl("Unknown provider: $id") - - val active: ShonarProvider get() = provider(_activeId.value) - - /** Selecting a provider does not touch existing local data. */ - fun select(id: String): ShonarProvider { - if (id !in factories) throw ProviderError.InvalidUrl("Unknown provider: $id") - _activeId.value = id - return provider(id) - } - - fun available(): List = - factories.keys.map { provider(it).descriptor } - - companion object { - const val NEXTCLOUD_ID = "nextcloud" - const val CUSTOM_SHONAR_ID = "custom-shonar" - const val SYNC_FOLDER_ID = FolderSyncProvider.ID - - /** Behaviour-identical default: system trust, logging off, no pins. */ - fun defaultTls(sink: (String) -> Unit = {}): TlsPolicy = TlsPolicy( - tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())), - sink = sink, - ) - - /** - * Production factory map. P1 registered local-only, P3 the custom - * SHONAR server, P4 Nextcloud + the sync folder. Network providers - * are single shared instances (their sessions live in the secure - * store, not in the object) so connect/reconnect state survives - * `provider(id)` calls. P5: every HTTP client comes from [tlsPolicy]. - */ - fun withDefaults( - appFilesDir: java.io.File, - secureStore: com.shonar.settings.SettingsStore = - com.shonar.settings.InMemorySettingsStore(), - plainStore: com.shonar.settings.SettingsStore = - com.shonar.settings.InMemorySettingsStore(), - tlsPolicy: TlsPolicy = defaultTls(), - ): ProviderRegistry { - val custom = CustomShonarProvider( - auth = ShonarAuthStore(secureStore), - sidecarRoot = java.io.File(appFilesDir, "shonar-sidecars"), - client = tlsPolicy.apiClient(), - handshake = ShonarHandshake(tlsPolicy.probeClient(), tlsPolicy.tofu), - ) - val nextcloud = NextcloudProvider( - auth = NextcloudAuthStore(secureStore), - client = tlsPolicy.nextcloudClient(), - loginFlow = NextcloudAuth(tlsPolicy.nextcloudClient(), tlsPolicy.tofu), - ) - val folder = FolderSyncProvider(pathStore = plainStore) - return ProviderRegistry( - mapOf( - LocalOnlyProvider.ID to { - LocalOnlyProvider( - java.io.File(appFilesDir, "shonar-local"), - pathStore = plainStore, - ) - }, - CUSTOM_SHONAR_ID to { custom }, - NEXTCLOUD_ID to { nextcloud }, - SYNC_FOLDER_ID to { folder }, - // P6: start9 / umbrel platform probes - ), - tls = tlsPolicy, - ) - } - } -} diff --git a/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt b/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt deleted file mode 100644 index 9bb8650..0000000 --- a/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt +++ /dev/null @@ -1,220 +0,0 @@ -package com.shonar.provider - -import java.io.File - -/** - * Shared vocabulary for server providers. Nothing here depends on any - * specific server product; the rest of the app talks to providers only - * through these types plus [ShonarProvider]. - */ - -/** What kind of provider this is and how the UI should present it. */ -data class ProviderDescriptor( - val id: String, // "nextcloud" | "custom-shonar" | "local-only" | ... - val displayName: String, // "Nextcloud" - val isDefault: Boolean = false, - val capabilities: Set = emptySet(), -) { - enum class Capability { - CHUNKED_UPLOAD, // resumable large-file upload protocol - SERVER_TRANSCRIPTION, // backend can transcribe (feature-gate AI) - SERVER_SUMMARY, - QUOTA_INFO, // storageLocationSummary can report quota - ACCOUNT_DELETION, // provider supports deleteAccountAndData - } -} - -/** - * A validated server URL. Construction only via [parse]; guarantees: - * - scheme https, or http ONLY for private/LAN hosts (RFC1918, loopback, .local) - * - no userinfo (user:pass in URL is rejected) - * - no path traversal ("..") - * - non-blank host - */ -data class ServerUrl(val scheme: String, val host: String, val port: Int, val pathSegments: List) { - - val isCleartext: Boolean get() = scheme == "http" - - /** Normalized base for API calls, e.g. https://cloud.example.com */ - val origin: String - get() = buildString { - append(scheme).append("://").append(host) - val default = if (scheme == "https") 443 else 80 - if (port != default) append(":").append(port) - } - - companion object { - fun parse(raw: String): Result { - val trimmed = raw.trim().removeSuffix("/") - if (trimmed.isBlank()) return Result.failure(ProviderError.InvalidUrl("URL is empty")) - val uri = runCatching { java.net.URI(trimmed) } - .getOrElse { return Result.failure(ProviderError.InvalidUrl("Not a valid URL")) } - val scheme = (uri.scheme ?: "").lowercase() - if (scheme != "https" && scheme != "http") - return Result.failure(ProviderError.InvalidUrl("Only http(s) URLs are allowed")) - if (uri.userInfo != null) - return Result.failure(ProviderError.InvalidUrl("Credentials in URL are not allowed")) - val host = (uri.host ?: "").lowercase() - if (host.isBlank()) - return Result.failure(ProviderError.InvalidUrl("Missing host")) - val segs = uri.path.split('/').filter { it.isNotBlank() } - if (segs.any { it == ".." }) - return Result.failure(ProviderError.InvalidUrl("Path traversal is not allowed")) - if (scheme == "http" && !isPrivateHost(host)) - return Result.failure( - ProviderError.InvalidUrl("Cleartext http:// is only allowed for local/LAN servers; use https:// for $host") - ) - val port = if (uri.port > 0) uri.port else if (scheme == "https") 443 else 80 - return Result.success(ServerUrl(scheme, host, port, segs)) - } - - fun isPrivateHost(host: String): Boolean { - if (host == "localhost" || host.endsWith(".local")) return true - val octets = host.split('.').takeIf { it.size == 4 }?.map { it.toIntOrNull() ?: -1 } ?: return false - if (octets.any { it !in 0..255 }) return false - return when { - octets[0] == 10 -> true // 10/8 - octets[0] == 127 -> true // loopback - octets[0] == 192 && octets[1] == 168 -> true // 192.168/16 - octets[0] == 172 && octets[1] in 16..31 -> true // 172.16/12 - octets[0] == 169 && octets[1] == 254 -> true // link-local - else -> false - } - } - } -} - -/** Credential material for a provider. NEVER put this in logs or Room. */ -sealed class ProviderCredential { - abstract val accountLabel: String // human hint only (e.g. "user@cloud") - - /** OAuth2/OIDC access token + refresh token (PKCE flow). */ - data class OAuthTokens( - override val accountLabel: String, - val accessToken: String, - val refreshToken: String?, - val expiresAtEpochSec: Long?, - ) : ProviderCredential() { - override fun toString(): String = "OAuthTokens(account=$accountLabel, [redacted])" - } - - /** Nextcloud login-flow-v2 / manually created app password. */ - data class AppPassword( - override val accountLabel: String, - val loginUrl: String, - val user: String, - val password: String, - ) : ProviderCredential() { - override fun toString(): String = "AppPassword(account=$accountLabel, [redacted])" - } - - /** - * Custom SHONAR server login: email + password exchanged for a rotating - * token pair (backend M1). The password is held in memory only for the - * login call and never persisted — only the resulting tokens are stored - * (see ShonarAuthStore). - */ - data class ShonarLogin( - override val accountLabel: String, - val serverUrl: ServerUrl, - val email: String, - val password: String, - ) : ProviderCredential() { - override fun toString(): String = - "ShonarLogin(account=$accountLabel, server=${serverUrl.origin}, [redacted])" - } - - /** No credential needed (local-only provider). */ - data object None : ProviderCredential() { - override val accountLabel: String get() = "local" - override fun toString(): String = "None" - } - - /** - * Sync-folder provider: an absolute directory path owned by an external - * sync tool (Syncthing, the Nextcloud desktop client, rsync…). The path - * is not secret, but it is validated — connect refuses a missing or - * non-writable directory rather than creating whatever was typed. - */ - data class FolderPath( - override val accountLabel: String, - val path: String, - ) : ProviderCredential() -} - -/** Result of probing a base URL for a compatible service. */ -sealed class ProbeResult { - data class Compatible(val descriptor: ProviderDescriptor, val serverName: String, val version: String) : ProbeResult() - /** Reachable, SHONAR-compatible services were found; user must pick one (Start9/Umbrel platforms). */ - data class ServicesFound(val services: List) : ProbeResult() - data object Incompatible : ProbeResult() - data class NetworkError(val reason: String) : ProbeResult() // reason must be secret-free - data class TlsFailure(val fingerprintSha256: String) : ProbeResult() // triggers TOFU approval UI -} - -/** A SHONAR-compatible service discovered on a platform (Start9/Umbrel). */ -data class DiscoveredService( - val platformId: String, // "start9" | "umbrel" - val serviceName: String, // app/service display name - val baseUrl: ServerUrl, - val providerKind: String, // provider to bind once confirmed -) - -/** Auth lifecycle shown in UI. Transitions: see docs/server-providers.md §3. */ -enum class AuthState { DISCONNECTED, CONNECTED, EXPIRED, REVOKED, OFFLINE } - -/** Opaque pointer to a remote object. Providers map keys to their own layout. */ -data class RemoteRef( - val providerId: String, - val key: String, // provider-relative key, never a local path - val etag: String?, - val sizeBytes: Long, -) - -/** Input to [ShonarProvider.upload]. sourceFile is a local file owned by the app. */ -data class RecordingDraft( - val id: String, // public UUID string - val title: String, - val createdAtEpochMs: Long, - val durationMs: Long, - val mime: String, // audio/mp4 | audio/wav | audio/ogg - val sourceFile: File, - val sizeBytes: Long, - /** Optional per-recording transcription model override (null = server default). */ - val transcriptionModel: String? = null, -) - -enum class SidecarKind(val fileName: String) { - TRANSCRIPT("transcript.json"), - SUMMARY("summary.json"), - ACTION_ITEMS("action-items.json"), - KEYWORDS("keywords.json"), - NOTES("notes.json"), -} - -data class Page(val items: List, val nextCursor: String?) -data class RemoteRecording(val ref: RemoteRef, val title: String, val createdAtEpochMs: Long, val durationMs: Long, val mime: String) - -/** "Where is my data?" — what the StorageLocation screen renders. */ -data class StorageLocation( - val headline: String, // "On this device only" / "Nextcloud at cloud.example.com" - val detail: String, // human-readable path/prefix, quota, etc. - val syncedCount: Int, - val localOnlyCount: Int, - val bytesUsed: Long, - /** Concrete on-device path, when the files live in a real folder. */ - val path: String? = null, -) - -/** Provider-level errors. Messages must never contain credentials/tokens. */ -sealed class ProviderError(message: String) : Exception(message) { - class InvalidUrl(message: String) : ProviderError(message) - class NotConnected : ProviderError("Provider is not connected") - class AuthExpired : ProviderError("Access token expired — re-authentication required") - class Revoked : ProviderError("Access was revoked on the server") - class TlsUntrusted(fingerprint: String) : ProviderError("Server certificate not trusted (SHA-256 $fingerprint)") - class NotFound(key: String) : ProviderError("Remote item not found: $key") - class Conflict(key: String) : ProviderError("Remote item changed since last read: $key") - class QuotaExceeded : ProviderError("Server storage quota exceeded") - class Transient(message: String) : ProviderError(message) -} diff --git a/android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt b/android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt deleted file mode 100644 index af028ec..0000000 --- a/android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt +++ /dev/null @@ -1,83 +0,0 @@ -package com.shonar.provider - -import com.shonar.settings.SettingsStore - -/** - * Secure persistence for the custom SHONAR server session. - * - * Stored (all in the Keystore-backed secure store, never in Room or logs): - * - base URL origin (e.g. https://shonar.example.com) - * - account email (hint only) - * - access token + expiry - * - refresh token + device id - * - * The user's password is NEVER stored here: it lives in memory for exactly - * one login call (see [ProviderCredential.ShonarLogin]). - * - * Rotation discipline (backend M1 has refresh reuse detection): every - * successful refresh overwrites the stored pair immediately, so a stored - * refresh token is always the newest one the server has issued. - */ -class ShonarAuthStore(private val secure: SettingsStore) { - - suspend fun save(session: ShonarSession) { - secure.putString(KEY_BASE_URL, session.baseUrl) - secure.putString(KEY_EMAIL, session.email) - secure.putString(KEY_ACCESS, session.accessToken) - secure.putString(KEY_REFRESH, session.refreshToken) - secure.putString(KEY_EXPIRES_AT, session.expiresAtEpochSec.toString()) - if (session.deviceId != null) secure.putString(KEY_DEVICE_ID, session.deviceId) - else secure.remove(KEY_DEVICE_ID) - } - - suspend fun load(): ShonarSession? { - val baseUrl = secure.getString(KEY_BASE_URL) ?: return null - val access = secure.getString(KEY_ACCESS) ?: return null - val refresh = secure.getString(KEY_REFRESH) ?: return null - return ShonarSession( - baseUrl = baseUrl, - email = secure.getString(KEY_EMAIL).orEmpty(), - accessToken = access, - refreshToken = refresh, - expiresAtEpochSec = secure.getString(KEY_EXPIRES_AT)?.toLongOrNull(), - deviceId = secure.getString(KEY_DEVICE_ID), - ) - } - - /** Drop tokens but keep the URL + email so re-login is one step. */ - suspend fun clearTokens() { - secure.remove(KEY_ACCESS) - secure.remove(KEY_REFRESH) - secure.remove(KEY_EXPIRES_AT) - secure.remove(KEY_DEVICE_ID) - } - - /** Forget everything, including which server was configured. */ - suspend fun clearAll() { - secure.remove(KEY_BASE_URL) - secure.remove(KEY_EMAIL) - clearTokens() - } - - companion object { - private const val PREFIX = "provider.custom-shonar." - const val KEY_BASE_URL = PREFIX + "base_url" - const val KEY_EMAIL = PREFIX + "email" - const val KEY_ACCESS = PREFIX + "access_token" - const val KEY_REFRESH = PREFIX + "refresh_token" - const val KEY_EXPIRES_AT = PREFIX + "expires_at" - const val KEY_DEVICE_ID = PREFIX + "device_id" - } -} - -/** In-memory session handed between login/refresh calls and the store. */ -data class ShonarSession( - val baseUrl: String, - val email: String, - val accessToken: String, - val refreshToken: String, - val expiresAtEpochSec: Long?, - val deviceId: String?, -) { - override fun toString(): String = "ShonarSession(server=$baseUrl, account=$email, [redacted])" -} diff --git a/android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt b/android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt deleted file mode 100644 index 522095a..0000000 --- a/android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.shonar.provider - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import java.util.concurrent.TimeUnit -import javax.net.ssl.SSLHandshakeException - -/** - * Unauthenticated handshake against the SHONAR backend's - * GET /api/v1/provider-info. Used by the provider-selection screen and by - * Start9/Umbrel platform probes to identify SHONAR-compatible services. - * - * TLS policy (docs/server-providers.md §4): - * - full system verification by default, no bypass, ever. - * - a handshake failure yields ProbeResult.TlsFailure with the peer cert's - * SPKI SHA-256 (recorded by the TOFU trust manager) so the UI can run - * explicit trust-on-first-use approval; this client NEVER retries with - * verification disabled. - */ -class ShonarHandshake( - private val client: OkHttpClient = defaultClient(), - private val tofu: TofuManager? = null, -) { - - suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) { - val endpoint = url.origin + "/api/v1/provider-info" - val request = Request.Builder().url(endpoint).get().build() - try { - client.newCall(request).execute().use { resp -> - if (resp.code == 200) { - parseBody(resp.body?.string().orEmpty()) - } else { - ProbeResult.Incompatible - } - } - } catch (e: SSLHandshakeException) { - ProbeResult.TlsFailure(fingerprintFor(url.host)) - } catch (e: javax.net.ssl.SSLPeerUnverifiedException) { - // Hostname mismatch: strict verifier stays strict, but the UI - // should still say *why* instead of a generic network error. - ProbeResult.TlsFailure(fingerprintFor(url.host)) - } catch (e: Exception) { - // message must stay generic: exception text can contain URLs but - // never credentials (this call sends no credentials at all) - ProbeResult.NetworkError(e.javaClass.simpleName) - } - } - - private fun parseBody(json: String): ProbeResult = try { - val root = org.json.JSONObject(json) - if (root.optString("kind") != "shonar") { - ProbeResult.Incompatible - } else { - val caps = root.optJSONObject("capabilities") ?: org.json.JSONObject() - val set = mutableSetOf() - if (caps.optBoolean("chunked_upload")) set += ProviderDescriptor.Capability.CHUNKED_UPLOAD - if (caps.optBoolean("server_transcription")) set += ProviderDescriptor.Capability.SERVER_TRANSCRIPTION - if (caps.optBoolean("server_summary")) set += ProviderDescriptor.Capability.SERVER_SUMMARY - if (caps.optBoolean("account_deletion")) set += ProviderDescriptor.Capability.ACCOUNT_DELETION - ProbeResult.Compatible( - descriptor = ProviderDescriptor( - id = ProviderRegistry.CUSTOM_SHONAR_ID, - displayName = "Custom SHONAR server", - capabilities = set, - ), - serverName = root.optString("storage_backend", "server"), - version = root.optString("version", "unknown"), - ) - } - } catch (e: Exception) { - ProbeResult.Incompatible - } - - /** - * SPKI SHA-256 recorded by the TOFU trust manager during the failed - * handshake, or "unknown" when nothing was captured (plain HTTP, - * pre-handshake failure, or no TOFU manager wired). - */ - private fun fingerprintFor(host: String): String = - tofu?.failureFor(host)?.spkiHex ?: "unknown" - - companion object { - fun defaultClient(): OkHttpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .followRedirects(false) // do not silently follow redirects to other hosts - .build() - } -} diff --git a/android/app/src/main/java/com/shonar/provider/ShonarProvider.kt b/android/app/src/main/java/com/shonar/provider/ShonarProvider.kt deleted file mode 100644 index c9aed97..0000000 --- a/android/app/src/main/java/com/shonar/provider/ShonarProvider.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.shonar.provider - -import java.io.File - -/** - * The single contract between SHONAR and any server (or the device itself). - * - * Everything the app needs from "the cloud" goes through this interface; - * UI, sync engine, and database reference providers by descriptor id only. - * LocalOnlyProvider is a first-class implementation, so a missing server is - * never a special case. - * - * Contract rules (enforced by the shared provider contract test suite): - * - all suspends are safe to cancel; partial uploads leave no visible object - * - upload() is idempotent per RecordingDraft.id (re-upload replaces the - * same key, never duplicates) - * - originals are immutable after successful upload until delete() - * - no method ever logs credentials, tokens, audio bytes, or transcripts - * - errors are ProviderError subtypes with secret-free messages - */ -interface ShonarProvider { - - val descriptor: ProviderDescriptor - - /** Current auth lifecycle; providers push updates here. */ - val authState: kotlinx.coroutines.flow.StateFlow - - /** - * Is there a SHONAR-compatible service at [baseUrl]? Purely read-only; - * must not require or request credentials. TlsFailure carries the SPKI - * fingerprint so the UI can run explicit trust-on-first-use approval — - * never silently accept. - */ - suspend fun probe(baseUrl: ServerUrl): ProbeResult - - /** Validate [credential] against the server, then hand it to the secure store. */ - suspend fun connect(credential: ProviderCredential): Unit - - /** Re-validate stored credential (app start / after network return). */ - suspend fun reconnect(): AuthState - - /** - * Disconnect locally; if [revokeOnServer] and the provider supports it, - * revoke the credential server-side first (best effort — local state is - * cleared even if revoke fails, with the failure surfaced). - */ - suspend fun disconnect(revokeOnServer: Boolean) - - /** Provider-native account/data deletion, then wipe local state. */ - suspend fun deleteAccountAndData() - - // ---- storage ----------------------------------------------------------- - - /** - * Upload the original audio for [draft], reporting [onProgress] 0..1. - * Implementations use chunked/resumable transfers when the capability is - * advertised. Returns the ref for later download/delete/sidecars. - */ - suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef - - suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) - - suspend fun delete(ref: RemoteRef) - - suspend fun list(cursor: String?): Page - - // ---- sidecars (transcript/summary/... JSON, synced independently) ------ - - suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) - - /** null when the sidecar does not exist remotely. */ - suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? - - // ---- status ------------------------------------------------------------ - - suspend fun storageLocationSummary(): StorageLocation -} diff --git a/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt b/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt deleted file mode 100644 index 73eff4f..0000000 --- a/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt +++ /dev/null @@ -1,190 +0,0 @@ -package com.shonar.provider - -import java.util.concurrent.TimeUnit -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Response - -/** - * P5: builds every provider HTTP client with the TOFU trust manager and the - * redacting logger (docs/server-providers.md §4). One place, so no call - * site can accidentally build a client that skips either. - * - * The log [sink] is an explicit constructor argument (no platform default) - * so this file compiles on Android and JVM desktop alike; each platform - * passes its own sink (Logcat vs stdout). - */ -class TlsPolicy( - val tofu: TofuManager, - private val bodiesEnabled: () -> Boolean = { false }, - private val sink: (String) -> Unit, -) { - fun newClient( - connectTimeoutS: Long, - readTimeoutS: Long, - writeTimeoutS: Long = readTimeoutS, - followRedirects: Boolean = true, - ): OkHttpClient { - val tm = tofu.trustManager - val sslContext = javax.net.ssl.SSLContext.getInstance("TLS") - sslContext.init(null, arrayOf(tm), null) - return OkHttpClient.Builder() - .sslSocketFactory(sslContext.socketFactory, tm) - .connectTimeout(connectTimeoutS, TimeUnit.SECONDS) - .readTimeout(readTimeoutS, TimeUnit.SECONDS) - .writeTimeout(writeTimeoutS, TimeUnit.SECONDS) - .followRedirects(followRedirects) - .addInterceptor(RedactingLogger(bodiesEnabled, sink)) - .build() - } - - /** P3-era API shape (15/60/60s). */ - fun apiClient(): OkHttpClient = newClient(15, 60, 60) - - /** Short probing shape (10/15s, no redirects). */ - fun probeClient(): OkHttpClient = newClient(10, 15, 15, followRedirects = false) - - /** Nextcloud shape (10/20/60s, no redirects). */ - fun nextcloudClient(): OkHttpClient = newClient(10, 20, 60, followRedirects = false) -} - -/** - * Logging is OFF by default; when the `log_http_bodies` debug setting is - * on, requests log in redacted form. Invariants (leak-tested): - * - Authorization / Cookie / Set-Cookie headers are never logged. - * - bodies log only for JSON/XML/text under [MAX_BODY] bytes; audio and - * other binary bodies never log. - * - token-shaped JSON values (`…token…`, `password`, `appPassword`) are - * masked, and Basic credentials are masked, even inside bodies. - */ -class RedactingLogger( - private val bodiesEnabled: () -> Boolean, - private val sink: (String) -> Unit, -) : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - val sensitive = chain.request().header(SENSITIVE_BODY) != null - val req = chain.request().newBuilder().removeHeader(SENSITIVE_BODY).build() - if (!bodiesEnabled()) return chain.proceed(req) - val t0 = System.currentTimeMillis() - val reqBody = req.body - val reqLen = reqBody?.contentLength()?.takeIf { it >= 0 } - sink("→ ${req.method} ${req.url.host}${req.url.encodedPath} body=${reqLen?.let { "$it B" } ?: "?"}") - for (i in 0 until req.headers.size) { - val name = req.headers.name(i) - sink(" $name: ${if (isSensitiveHeader(name)) "[redacted]" else req.headers.value(i)}") - } - if (sensitive) { - sink(" request-body: [sensitive, not logged]") - } else { - logRequestBody(reqBody?.contentType()?.toString(), reqBody) - } - try { - val resp = chain.proceed(req) - val ms = System.currentTimeMillis() - t0 - sink("← ${resp.code} ${req.url.encodedPath} (${ms}ms)") - if (sensitive) { - sink(" response-body: [sensitive, not logged]") - return resp - } - val peek = resp.peekBody(MAX_BODY + 1) - logBody( - " response", - peek.contentType()?.toString(), - peek.bytes().toList(), - ) - return resp - } catch (e: Exception) { - sink("✕ ${req.url.encodedPath} failed (${e.javaClass.simpleName})") - throw e - } - } - - /** - * Request bodies need a size gate BEFORE reading: uploads are file - * bodies that must never be buffered just to log a prefix of them. - */ - private fun logRequestBody(contentType: String?, body: okhttp3.RequestBody?) { - if (body == null) { - sink(" request-body: none") - return - } - val len = try { - body.contentLength() - } catch (e: Exception) { - -1L - } - if (len < 0 || len > MAX_BODY) { - val what = if (len < 0) "streaming" else "$len bytes" - sink(" request-body: [$what, not logged]") - return - } - val bytes = try { - val buf = okio.Buffer() - body.writeTo(buf) - buf.readByteArray().toList() - } catch (e: Exception) { - null - } - logBody(" request", contentType, bytes) - } - - private fun logBody(prefix: String, contentType: String?, bytes: List?) { - if (bytes == null) { - sink("$prefix-body: none") - return - } - if (!isLoggableType(contentType)) { - sink("$prefix-body: [${bytes.size} bytes, not logged]") - return - } - if (bytes.size > MAX_BODY) { - sink("$prefix-body: [${bytes.size} bytes, over the $MAX_BODY B cap, not logged]") - return - } - sink("$prefix-body: ${redact(bytes.toByteArray().toString(Charsets.UTF_8))}") - } - - companion object { - const val MAX_BODY: Long = 8192 - - /** - * Opt out of body logging per request (both directions). Providers - * set this on calls whose bodies are transcripts or other sensitive - * payloads; the logger strips it before sending so it never reaches - * the wire. - */ - const val SENSITIVE_BODY = "X-Shonar-Sensitive-Body" - - internal fun isSensitiveHeader(name: String): Boolean = when (name.lowercase()) { - "authorization", "cookie", "set-cookie", "x-chunk-sha256" -> true - else -> false - } - - internal fun isLoggableType(contentType: String?): Boolean { - if (contentType == null) return false - val t = contentType.lowercase().substringBefore(';').trim() - return t.startsWith("application/json") || t.endsWith("+json") || - t.startsWith("text/") || t.endsWith("+xml") || - t == "application/xml" || t == "application/x-www-form-urlencoded" - } - - private val SECRET_JSON = Regex( - """"[^"]*(token|password|secret)[^"]*"\s*:\s*"[^"]*"""", - RegexOption.IGNORE_CASE, - ) - private val BASIC = Regex("""Basic\s+[A-Za-z0-9+/=]{8,}""") - private val BEARER = Regex("""Bearer\s+[A-Za-z0-9\-_.~+/=]{8,}""") - - /** Mask token-shaped values; safe to run on any text. */ - internal fun redact(text: String): String { - var out = SECRET_JSON.replace(text) { m -> - val key = m.value.substringBefore(':') - """$key:"***"""" - } - out = BASIC.replace(out, "Basic [redacted]") - out = BEARER.replace(out, "Bearer [redacted]") - return out - } - } -} diff --git a/android/app/src/main/java/com/shonar/provider/TofuTrust.kt b/android/app/src/main/java/com/shonar/provider/TofuTrust.kt deleted file mode 100644 index f684b10..0000000 --- a/android/app/src/main/java/com/shonar/provider/TofuTrust.kt +++ /dev/null @@ -1,287 +0,0 @@ -package com.shonar.provider - -import com.shonar.settings.SettingsStore -import java.net.Socket -import java.security.MessageDigest -import java.security.cert.CertificateException -import java.security.cert.X509Certificate -import java.util.concurrent.ConcurrentHashMap -import javax.net.ssl.SSLEngine -import javax.net.ssl.SSLSession -import javax.net.ssl.X509ExtendedTrustManager -import javax.net.ssl.X509TrustManager - -/** - * P5: trust-on-first-use (TOFU) for self-signed LAN servers - * (docs/server-providers.md §4). - * - * Policy, enforced by construction: - * - system CAs are always tried first; TOFU pins are a fallback, never a - * replacement. A host that later gets a real certificate just works. - * - pins are per-host: an approved cert for `nas.local` is trusted ONLY - * when `nas.local` presents it. Byte-equality on the leaf DER, so a - * rotation needs a fresh approval (correct TOFU semantics). - * - nothing is ever trusted silently: the first failure only RECORDS the - * chain, and trust requires an explicit [TofuManager.approve] call from - * a UI that showed the fingerprint. - * - the normal TLS hostname verifier stays strict; TOFU covers unknown - * CAs, not name mismatches. - */ -class TofuStore(private val secure: SettingsStore) { - - suspend fun addPin(host: String, derBase64: String) { - val pins = pins(host).toMutableSet() - pins += derBase64 - secure.putString(pinKey(host), org.json.JSONArray(pins.toList()).toString()) - val hosts = hosts().toMutableSet() - if (hosts.add(host.lowercase())) { - secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString()) - } - } - - suspend fun pins(host: String): Set { - val raw = secure.getString(pinKey(host.lowercase())) ?: return emptySet() - return runCatching { - val arr = org.json.JSONArray(raw) - (0 until arr.length()).map { arr.getString(it) }.toSet() - }.getOrDefault(emptySet()) - } - - suspend fun hosts(): Set { - val raw = secure.getString(KEY_HOSTS) ?: return emptySet() - return runCatching { - val arr = org.json.JSONArray(raw) - (0 until arr.length()).map { arr.getString(it) }.toSet() - }.getOrDefault(emptySet()) - } - - suspend fun removeHost(host: String) { - secure.remove(pinKey(host.lowercase())) - val hosts = hosts().toMutableSet() - if (hosts.remove(host.lowercase())) { - secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString()) - } - } - - companion object { - private const val PREFIX = "tofu." - const val KEY_HOSTS = PREFIX + "hosts" - fun pinKey(host: String): String = PREFIX + "pins." + host.lowercase() - } -} - -/** A recorded untrusted chain, shown to the user for approval. */ -data class TofuFailure( - val host: String, - /** SPKI SHA-256, browser-style `AB:CD:…` uppercase hex. */ - val spkiHex: String, - val subject: String, - val issuer: String, - val validFrom: String, - val validUntil: String, - val leafDer: ByteArray, - val recordedAtMs: Long = System.currentTimeMillis(), -) { - override fun toString(): String = - "TofuFailure(host=$host, spki=$spkiHex, subject=$subject)" - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is TofuFailure) return false - return host == other.host && spkiHex == other.spkiHex && - leafDer.contentEquals(other.leafDer) - } - - override fun hashCode(): Int = 31 * host.hashCode() + spkiHex.hashCode() -} - -/** - * Thrown (as a [CertificateException], so it propagates through the TLS - * stack untouched) when a chain is neither system-trusted nor pinned. - * Carries no secrets — DNs and fingerprints are public cert contents. - */ -class TofuUntrustedException( - val failure: TofuFailure, - message: String = "Untrusted certificate for ${failure.host} (SPKI ${failure.spkiHex})", -) : CertificateException(message) - -/** - * System-first trust manager with per-host TOFU fallback. The pin cache is - * in-memory (handshakes run on TLS threads that cannot suspend); it is - * filled by [TofuManager.refresh] at startup and kept in sync by - * approve/forget. - */ -class TofuTrustManager( - private val system: X509TrustManager, - private val manager: TofuManager, -) : X509ExtendedTrustManager() { - - override fun checkClientTrusted(chain: Array, authType: String) { - system.checkClientTrusted(chain, authType) - } - - // Client-auth paths: this app is always the TLS client, so these only - // need to exist (newer JDKs/Android declare them abstract). Delegate to - // the 2-arg system check. - override fun checkClientTrusted( - chain: Array, authType: String, socket: Socket, - ) { - system.checkClientTrusted(chain, authType) - } - - override fun checkClientTrusted( - chain: Array, authType: String, engine: SSLEngine, - ) { - system.checkClientTrusted(chain, authType) - } - - override fun checkServerTrusted(chain: Array, authType: String) { - checkServerTrusted(chain, authType, host = null) - } - - override fun checkServerTrusted( - chain: Array, authType: String, socket: Socket, - ) { - val host = runCatching { - (socket as? javax.net.ssl.SSLSocket)?.handshakeSession?.peerHost - }.getOrNull() - checkServerTrusted(chain, authType, host = host) - } - - override fun checkServerTrusted( - chain: Array, authType: String, engine: SSLEngine, - ) { - val host = runCatching { - (engine.session as? javax.net.ssl.ExtendedSSLSession)?.peerHost - }.getOrNull() - checkServerTrusted(chain, authType, host = host) - } - - private fun checkServerTrusted( - chain: Array, authType: String, host: String?, - ) { - // pins are checked first so an approved self-signed cert keeps - // working even where a system path would also exist; system trust - // is the fallback that makes later real certs frictionless. - if (chain.isNotEmpty() && host != null && manager.isPinned(host, chain[0])) { - return - } - try { - system.checkServerTrusted(chain, authType) - } catch (e: CertificateException) { - val failure = TofuFailure( - host = (host ?: "").lowercase(), - spkiHex = spkiHex(chain[0]), - subject = chain[0].subjectX500Principal.name, - issuer = chain[0].issuerX500Principal.name, - validFrom = chain[0].notBefore.toString(), - validUntil = chain[0].notAfter.toString(), - leafDer = chain[0].encoded, - ) - manager.record(failure) - throw TofuUntrustedException(failure) - } - } - - override fun getAcceptedIssuers(): Array = system.acceptedIssuers - - companion object { - /** SPKI SHA-256 as browser-style colon-separated uppercase hex. */ - internal fun spkiHex(cert: X509Certificate): String { - val digest = MessageDigest.getInstance("SHA-256") - .digest(cert.publicKey.encoded) - return digest.joinToString(":") { "%02X".format(it) } - } - } -} - -/** - * Owns the pin store, the in-memory cache the trust manager reads, and the - * recorded failures the approval UI consumes. - */ -class TofuManager( - private val store: TofuStore, - system: X509TrustManager = defaultSystemTrustManager(), -) { - private val cache = ConcurrentHashMap>() - private val failures = ConcurrentHashMap() - - val trustManager: TofuTrustManager by lazy { TofuTrustManager(system, this) } - - /** Fill the cache from the store. Call at startup (and only there). */ - suspend fun refresh() { - val fresh = mutableMapOf>() - for (host in store.hosts()) { - fresh[host.lowercase()] = store.pins(host) - } - cache.clear() - cache.putAll(fresh) - } - - internal fun isPinned(host: String, leaf: X509Certificate): Boolean { - val pins = cache[host.lowercase()] ?: return false - val der = runCatching { leaf.encoded }.getOrNull() ?: return false - return pins.any { pin -> - runCatching { - MessageDigest.isEqual( - der, - java.util.Base64.getDecoder().decode(pin), - ) - }.getOrDefault(false) - } - } - - internal fun record(failure: TofuFailure) { - failures[failure.host] = failure - } - - /** Most recent failure for [host], else a fresh hostless one, else null. */ - fun failureFor(host: String): TofuFailure? { - val key = host.lowercase() - failures[key]?.let { return it } - val fallback = failures[""] ?: return null - // A hostless record only belongs to this probe if it just happened - // (single onboarding flow, no concurrency to speak of). - if (System.currentTimeMillis() - fallback.recordedAtMs > FRESH_MS) return null - return fallback - } - - /** - * Trust [host]'s recorded leaf from now on. Returns false when there is - * nothing recorded (never invent trust). - */ - suspend fun approve(host: String): Boolean { - val key = host.lowercase() - val failure = failureFor(key) ?: return false - val der = java.util.Base64.getEncoder().encodeToString(failure.leafDer) - store.addPin(key, der) - cache[key] = store.pins(key) - failures.remove(key) - failures.remove("") - return true - } - - suspend fun decline(host: String) { - failures.remove(host.lowercase()) - failures.remove("") - } - - suspend fun forget(host: String) { - store.removeHost(host) - cache.remove(host.lowercase()) - } - - suspend fun pinnedHosts(): Set = store.hosts() - - companion object { - private const val FRESH_MS = 30_000L - - fun defaultSystemTrustManager(): X509TrustManager { - val factory = javax.net.ssl.TrustManagerFactory.getInstance( - javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm() - ) - factory.init(null as java.security.KeyStore?) - return factory.trustManagers.filterIsInstance().first() - } - } -} diff --git a/android/app/src/main/java/com/shonar/recording/AiContent.kt b/android/app/src/main/java/com/shonar/recording/AiContent.kt deleted file mode 100644 index 4f60ff7..0000000 --- a/android/app/src/main/java/com/shonar/recording/AiContent.kt +++ /dev/null @@ -1,225 +0,0 @@ -package com.shonar.recording - -import org.json.JSONArray -import org.json.JSONObject - -/** - * M8: AI content models for the recording details screen. - * - * Pure Kotlin + org.json — no Android imports, so this file runs under - * plain JVM unit tests. The backend shapes mirror - * `backend/shonar/api/schemas_recordings.py`: - * - transcript: {version, language, provider, model, text, - * segments: [{start, end, text, speaker}], edited_by_user} - * - summary: {version, provider, model, content: {short, detailed, - * key_points, decisions, action_items, questions}, edited_by_user} - * - jobs: [{job_type, status, attempt, max_attempts, error, ...}] - */ - -data class TranscriptSegment( - val startSec: Double, - val endSec: Double, - val text: String, - val speaker: String? = null, -) - -data class TranscriptData( - val version: Int, - val language: String? = null, - val provider: String = "", - val model: String? = null, - val text: String = "", - val segments: List = emptyList(), - val editedByUser: Boolean = false, -) - -data class SummaryData( - val version: Int, - val provider: String = "", - val model: String? = null, - /** Raw content dict; known keys read via [summaryString]/[summaryList]. */ - val content: Map = emptyMap(), - val editedByUser: Boolean = false, -) { - val short: String get() = summaryString(content, "short") - val detailed: String get() = summaryString(content, "detailed") - fun list(key: String): List = summaryList(content, key) -} - -data class JobInfo( - val jobType: String, - val status: String, - val attempt: Int = 0, - val maxAttempts: Int = 0, - val error: String? = null, - /** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */ - val stage: String? = null, - val progress: Int? = null, -) - -/** Summary content keys, in display order. */ -val SUMMARY_LIST_KEYS = listOf("key_points", "decisions", "action_items", "questions") - -/** - * Index of the segment playing at [positionMs]: the last segment whose - * start is at or before the position. -1 when there are no segments or - * playback precedes the first one. End times are ignored on purpose — - * gaps between segments keep the last-spoken line highlighted instead of - * flickering to nothing. - */ -fun activeSegmentIndex(segments: List, positionMs: Long): Int { - val posSec = positionMs / 1000.0 - var active = -1 - for (i in segments.indices) { - if (segments[i].startSec <= posSec) active = i else break - } - return active -} - -fun parseTranscript(raw: String): TranscriptData? = runCatching { - val json = JSONObject(raw) - val segments = mutableListOf() - json.optJSONArray("segments")?.let { arr -> - for (i in 0 until arr.length()) { - val s = arr.optJSONObject(i) ?: continue - segments += TranscriptSegment( - startSec = s.optDouble("start", 0.0), - endSec = s.optDouble("end", 0.0), - text = s.optString("text", ""), - speaker = s.optString("speaker", null).takeUnless { it.isNullOrBlank() }, - ) - } - } - segments.sortBy { it.startSec } - TranscriptData( - version = json.optInt("version", 0), - language = json.optString("language", null).takeUnless { it.isNullOrBlank() }, - provider = json.optString("provider", ""), - model = json.optString("model", null).takeUnless { it.isNullOrBlank() }, - text = json.optString("text", ""), - segments = segments, - editedByUser = json.optBoolean("edited_by_user", false), - ) -}.getOrNull() - -fun parseSummary(raw: String): SummaryData? = runCatching { - val json = JSONObject(raw) - val content = mutableMapOf() - json.optJSONObject("content")?.let { obj -> - for (key in obj.keys()) content[key] = obj.get(key) - } - SummaryData( - version = json.optInt("version", 0), - provider = json.optString("provider", ""), - model = json.optString("model", null).takeUnless { it.isNullOrBlank() }, - content = content, - editedByUser = json.optBoolean("edited_by_user", false), - ) -}.getOrNull() - -fun parseJobs(raw: String): List = runCatching { - val arr = JSONArray(raw) - (0 until arr.length()).mapNotNull { i -> - val o = arr.optJSONObject(i) ?: return@mapNotNull null - JobInfo( - jobType = o.optString("job_type", ""), - status = o.optString("status", ""), - attempt = o.optInt("attempt", 0), - maxAttempts = o.optInt("max_attempts", 0), - error = o.optString("error", null).takeUnless { it.isNullOrBlank() }, - stage = o.optString("stage", null).takeUnless { it.isNullOrBlank() }, - progress = if (o.has("progress") && !o.isNull("progress")) o.optInt("progress") else null, - ) - } -}.getOrDefault(emptyList()) - -/** Payload for PUT /recordings/{id}/transcript. Null segments = text-only correction. */ -fun transcriptUpdatePayload(text: String, segments: List? = null): String { - val obj = JSONObject().put("text", text) - if (segments != null) { - obj.put( - "segments", - JSONArray().also { arr -> - segments.forEach { s -> - arr.put( - JSONObject() - .put("start", s.startSec) - .put("end", s.endSec) - .put("text", s.text) - .let { o -> if (s.speaker != null) o.put("speaker", s.speaker) else o }, - ) - } - }, - ) - } - return obj.toString() -} - -/** Payload for PUT /recordings/{id}/summary. */ -fun summaryUpdatePayload(content: Map): String = - JSONObject().put("content", JSONObject(content)).toString() - -/** Payload for PATCH /recordings/{id} (title/notes rename from details). */ -fun recordingPatchPayload(title: String? = null, notes: String? = null): String { - val obj = JSONObject() - if (title != null) obj.put("title", title) - if (notes != null) obj.put("notes", notes) - return obj.toString() -} - -// ---- transcription models (Stage 1: GET /api/v1/models) ------------------------ - -data class TranscriptionModelInfo( - val name: String, - val displayName: String, - val description: String, - val params: String = "", - val approxMemory: String = "", - val relativeSpeed: String = "", - val isDefault: Boolean = false, - val downloaded: Boolean = false, - val available: Boolean = false, -) - -data class ModelsData( - val defaultModel: String, - val fasterWhisperInstalled: Boolean, - val models: List = emptyList(), -) { - fun byName(name: String): TranscriptionModelInfo? = models.firstOrNull { it.name == name } -} - -fun parseModels(raw: String): ModelsData? = runCatching { - val json = JSONObject(raw) - val models = mutableListOf() - json.optJSONArray("models")?.let { arr -> - for (i in 0 until arr.length()) { - val o = arr.optJSONObject(i) ?: continue - models += TranscriptionModelInfo( - name = o.optString("name", ""), - displayName = o.optString("display_name", o.optString("name", "")), - description = o.optString("description", ""), - params = o.optString("params", ""), - approxMemory = o.optString("approx_memory", ""), - relativeSpeed = o.optString("relative_speed", ""), - isDefault = o.optBoolean("is_default", false), - downloaded = o.optBoolean("downloaded", false), - available = o.optBoolean("available", false), - ) - } - } - ModelsData( - defaultModel = json.optString("default_model", "base"), - fasterWhisperInstalled = json.optBoolean("faster_whisper_installed", false), - models = models.filter { it.name.isNotBlank() }, - ) -}.getOrNull() - -private fun summaryString(content: Map, key: String): String = - (content[key] as? String).orEmpty() - -private fun summaryList(content: Map, key: String): List = when (val v = content[key]) { - is JSONArray -> (0 until v.length()).mapNotNull { v.optString(it, null)?.takeIf { it.isNotBlank() } } - is List<*> -> v.mapNotNull { (it as? String)?.takeIf { it.isNotBlank() } } - else -> emptyList() -} diff --git a/android/app/src/main/java/com/shonar/recording/MigrationRunner.kt b/android/app/src/main/java/com/shonar/recording/MigrationRunner.kt deleted file mode 100644 index 89541e2..0000000 --- a/android/app/src/main/java/com/shonar/recording/MigrationRunner.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.shonar.recording - -import com.shonar.provider.LocalOnlyProvider -import com.shonar.provider.ProviderRegistry -import com.shonar.provider.ShonarProvider -import com.shonar.provider.SyncState -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive - -/** - * P7 foreground "migrate now" uploader. Deliberately bounded — this is NOT - * the M5 background driver: - * - * - runs while the user watches (progress callback), cancellable; the - * in-flight file finishes, files never started stay exactly as they - * were, and the rest can run later (or under M5). - * - one attempt per file, no retry, no network/constraint checks. - * - upload-only: the local file is the source of truth and is never - * modified; success overwrites the row's remote slot with the new - * provider, failure records ERROR + the provider's message and moves on. - * - local-only is rejected as a target: there is nothing to upload to. - * ("Keep everything local" is a migration *choice*, handled by leaving - * rows untouched — not by this runner.) - */ -class MigrationRunner( - private val slots: SyncSlots, - private val registry: ProviderRegistry, -) { - data class Progress(val done: Int, val total: Int, val currentTitle: String) - data class Result(val uploaded: Int, val failed: List, val cancelled: Boolean) - - suspend fun migrateAllTo( - providerId: String, - onProgress: (Progress) -> Unit = {}, - ): Result { - require(providerId != LocalOnlyProvider.ID) { "cannot migrate to local-only" } - val target: ShonarProvider = registry.provider(providerId) - val rows = slots.rows() - var uploaded = 0 - val failed = mutableListOf() - var cancelled = false - rows.forEachIndexed { index, row -> - // Cancellation lands here between files: counted, honest, resumable. - try { - currentCoroutineContext().ensureActive() - } catch (e: kotlinx.coroutines.CancellationException) { - cancelled = true - return Result(uploaded, failed, cancelled) - } - onProgress(Progress(index, rows.size, row.title)) - val draft = slots.draftFor(row) - if (draft == null) { - slots.markFailed(row, "local file is gone") - failed += row.title - return@forEachIndexed - } - stepToUploading(row) - try { - val ref = target.upload(draft) {} - slots.markUploaded(slots.rows().firstOrNull { it.id == row.id } ?: row, providerId, ref) - uploaded++ - } catch (e: kotlinx.coroutines.CancellationException) { - // Interrupted mid-file: leave the row UPLOADING so a later - // run retries it explicitly rather than assuming either - // outcome. - throw e - } catch (e: Exception) { - val fresh = slots.rows().firstOrNull { it.id == row.id } ?: row - // Backoff included: the M5 drain picks ERROR rows up when due. - slots.markFailed(fresh, e.message ?: e.javaClass.simpleName) - failed += row.title - } - } - onProgress(Progress(rows.size, rows.size, "")) - return Result(uploaded, failed, cancelled) - } - - /** - * Legal first steps toward UPLOADING from any resting state, per the - * transition table (shared with the M5 drain). - */ - private suspend fun stepToUploading(row: RecordingEntity) { - var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row - for (step in stepsToUploading(fresh.syncState)) { - slots.markState(fresh, step) - fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh - } - } -} diff --git a/android/app/src/main/java/com/shonar/recording/RecordingDao.kt b/android/app/src/main/java/com/shonar/recording/RecordingDao.kt index 22a86ad..43cde59 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingDao.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingDao.kt @@ -3,6 +3,7 @@ package com.shonar.recording import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow @@ -21,6 +22,13 @@ interface RecordingDao { @Insert suspend fun insert(recording: RecordingEntity) + /** + * Import path only: a second scan pass for an already-tracked file is a + * no-op instead of a duplicate row (unique filePath index). + */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIgnore(recording: RecordingEntity): Long + @Update suspend fun update(recording: RecordingEntity) diff --git a/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt b/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt index aa8ac3d..e7097d0 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt @@ -1,6 +1,7 @@ package com.shonar.recording import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import com.shonar.provider.SyncState @@ -16,7 +17,7 @@ import com.shonar.provider.SyncState * driver exists, the foreground migrator (P7) is the only writer besides * these defaults. */ -@Entity(tableName = "recordings") +@Entity(tableName = "recordings", indices = [Index(value = ["filePath"], unique = true)]) data class RecordingEntity( @PrimaryKey val id: String, val title: String, diff --git a/android/app/src/main/java/com/shonar/recording/RecordingFiles.kt b/android/app/src/main/java/com/shonar/recording/RecordingFiles.kt index dfa7b92..70678bb 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingFiles.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingFiles.kt @@ -20,7 +20,7 @@ object RecordingFiles { fun defaultDisplayName(nowMs: Long = System.currentTimeMillis()): String { val s = SimpleDateFormat("yyyy-MM-dd HH-mm", Locale.US).format(Date(nowMs)) - return "Shonar Recording - $s" + return "Recording - $s" } /** Null = blank / "." / ".." / nothing usable after cleaning. */ diff --git a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt index 66c0a54..f43532b 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt @@ -1,9 +1,9 @@ package com.shonar.recording -import com.shonar.provider.RecordingDraft -import com.shonar.provider.RemoteRef import com.shonar.provider.SyncState import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.File import java.text.DateFormat import java.util.Date @@ -20,7 +20,6 @@ class RecordingRepository( */ private val rootResolver: (suspend () -> File?)? = null, ) { - private val slots = SyncSlots(dao) private val sandboxDir: File = appFilesDir private val legacyDir = File(sandboxDir, "recordings").apply { mkdirs() } @@ -28,6 +27,9 @@ class RecordingRepository( private val recordingsDir: File get() = libraryRoot private fun tempDir(): File = File(libraryRoot, ".in-progress").apply { mkdirs() } + /** Serializes library scans so two passes can never double-insert a file. */ + private val importMutex = Mutex() + val recordings: Flow> = dao.observeAll() fun newRecordingId(): String = UUID.randomUUID().toString() @@ -41,12 +43,16 @@ class RecordingRepository( /** * Adopt audio files already sitting in the library folder (put there by - * another app, USB, …). Flat scan, top level only; dotfiles and the - * in-progress dir are skipped. Files already tracked by path are left - * alone — renaming a file re-imports it as new, which is honest. - * Nothing is copied, moved, or modified. + * the user's own file sync, USB, …). Flat scan, top level only; + * dotfiles and the in-progress dir are skipped. Files already tracked + * by path are left alone — renaming a file re-imports it as new, which + * is honest. Nothing is copied, moved, or modified. + * + * Double-insert proof: serialized by [importMutex] AND backed by the + * unique filePath index (insertIgnore) — two overlapping passes can + * never create duplicate rows. */ - suspend fun importExistingFiles() { + suspend fun importExistingFiles() = importMutex.withLock { val root = libraryRoot val known = dao.getAll().mapNotNullTo(mutableSetOf()) { runCatching { File(it.filePath).canonicalPath }.getOrNull() @@ -61,7 +67,7 @@ class RecordingRepository( .orEmpty() for (f in files) { val ext = f.extension.lowercase() - dao.insert( + dao.insertIgnore( RecordingEntity( id = UUID.randomUUID().toString(), title = f.nameWithoutExtension, @@ -155,25 +161,10 @@ class RecordingRepository( dao.delete(recording) } - // ---- P7 provider slot (delegates to SyncSlots; Context-free logic) ----- - - /** Draft for upload, or null when the local file is gone. */ - fun toDraft(entity: RecordingEntity): RecordingDraft? = slots.draftFor(entity) - - suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) = - slots.markState(entity, state, reason) - - suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) = - slots.markUploaded(entity, providerId, ref) - - /** - * Forget every remote link (P7 "start fresh" choice). Files stay; - * nothing remote is touched — forgetting is metadata-only. - */ - suspend fun forgetAllRemotes(newOriginId: String) = slots.forgetAllRemotes(newOriginId) + // ---- sync layer removed (local-only app) ----- companion object { - /** Extensions adopted by [importExistingFiles] (mirrors backend magic check). */ + /** Extensions adopted by [importExistingFiles]. */ val AUDIO_MIME_BY_EXTENSION = mapOf( "m4a" to "audio/mp4", "mp4" to "audio/mp4", "aac" to "audio/aac", diff --git a/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt b/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt index 9e4c82e..b559546 100644 --- a/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt +++ b/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt @@ -6,7 +6,7 @@ import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase -@Database(entities = [RecordingEntity::class], version = 3, exportSchema = false) +@Database(entities = [RecordingEntity::class], version = 4, exportSchema = false) @TypeConverters(SyncStateConverter::class) abstract class ShonarDatabase : RoomDatabase() { abstract fun recordingDao(): RecordingDao @@ -39,3 +39,18 @@ val MIGRATION_2_3 = object : Migration(2, 3) { db.execSQL("ALTER TABLE recordings ADD COLUMN syncRetryAtMs INTEGER") } } + +/** + * v3 -> v4 (local-only): one row per file. Drops any duplicate rows left by + * overlapping library scans (keeps the earliest row per path), then enforces + * the unique filePath index so re-scans can never duplicate again. + */ +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "DELETE FROM recordings WHERE rowid NOT IN " + + "(SELECT MIN(rowid) FROM recordings GROUP BY filePath)" + ) + db.execSQL("CREATE UNIQUE INDEX index_recordings_filePath ON recordings (filePath)") + } +} diff --git a/android/app/src/main/java/com/shonar/recording/SyncDrain.kt b/android/app/src/main/java/com/shonar/recording/SyncDrain.kt deleted file mode 100644 index 7b36682..0000000 --- a/android/app/src/main/java/com/shonar/recording/SyncDrain.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.shonar.recording - -import com.shonar.provider.LocalOnlyProvider -import com.shonar.provider.ShonarProvider -import com.shonar.provider.SyncState -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive - -/** - * M5 background drain engine. Context-free on purpose: the [SyncWorker] - * (Context-bound) builds the inputs, unit tests drive this directly. - * - * One pass over due rows — QUEUED, plus ERROR whose backoff expired (null - * retryAt = due immediately, which is how P7 leftovers read). SYNCED, - * UPLOADED and LOCAL_ONLY rows are never touched. Local-only target is a - * no-op. Failures record backoff via [SyncSlots.markFailed]; cancellation - * between files stops honestly, and a row interrupted mid-upload reverts - * to QUEUED so a later pass retries it explicitly. - */ -class SyncDrain( - private val slots: SyncSlots, - private val clock: () -> Long = System::currentTimeMillis, -) { - /** Snapshot of device conditions, taken by the worker. Pure to evaluate. */ - data class Gates(val online: Boolean, val unmetered: Boolean, val charging: Boolean) - - data class Progress(val done: Int, val total: Int, val currentTitle: String) - data class Result(val uploaded: Int, val failed: List, val postponed: Boolean) - - suspend fun drainOnce( - provider: ShonarProvider, - wifiOnly: Boolean, - chargingOnly: Boolean, - gates: Gates, - onProgress: (Progress) -> Unit = {}, - ): Result { - if (provider.descriptor.id == LocalOnlyProvider.ID) { - return Result(0, emptyList(), postponed = false) - } - val now = clock() - val due = slots.rows().filter { row -> - row.syncState == SyncState.QUEUED || - (row.syncState == SyncState.ERROR && - (row.syncRetryAtMs == null || row.syncRetryAtMs <= now)) - } - if (due.isEmpty()) return Result(0, emptyList(), postponed = false) - if (!gates.satisfiedBy(wifiOnly, chargingOnly)) { - return Result(0, emptyList(), postponed = true) - } - var uploaded = 0 - val failed = mutableListOf() - due.forEachIndexed { index, row -> - try { - currentCoroutineContext().ensureActive() - } catch (e: kotlinx.coroutines.CancellationException) { - return Result(uploaded, failed, postponed = false) - } - onProgress(Progress(index, due.size, row.title)) - val draft = slots.draftFor(row) - if (draft == null) { - slots.markFailed(row, "local file is gone", now) - failed += row.title - return@forEachIndexed - } - var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row - for (step in stepsToUploading(fresh.syncState)) { - slots.markState(fresh, step) - fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh - } - try { - val ref = provider.upload(draft) {} - val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh - slots.markUploaded(done, provider.descriptor.id, ref) - uploaded++ - } catch (e: kotlinx.coroutines.CancellationException) { - // Interrupted mid-file: revert so a later pass retries - // explicitly instead of assuming either outcome. - val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh - slots.markState(done, SyncState.QUEUED) - throw e - } catch (e: Exception) { - val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh - slots.markFailed(done, e.message ?: e.javaClass.simpleName, now) - failed += row.title - } - } - onProgress(Progress(due.size, due.size, "")) - return Result(uploaded, failed, postponed = false) - } -} - -/** Pure gate evaluation, unit-tested. */ -internal fun SyncDrain.Gates.satisfiedBy(wifiOnly: Boolean, chargingOnly: Boolean): Boolean = - online && (!wifiOnly || unmetered) && (!chargingOnly || charging) diff --git a/android/app/src/main/java/com/shonar/recording/SyncSlots.kt b/android/app/src/main/java/com/shonar/recording/SyncSlots.kt deleted file mode 100644 index 67543ef..0000000 --- a/android/app/src/main/java/com/shonar/recording/SyncSlots.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.shonar.recording - -import com.shonar.provider.RecordingDraft -import com.shonar.provider.RemoteRef -import com.shonar.provider.SyncState -import java.io.File - -/** - * P7 provider-slot bookkeeping over [RecordingDao]. Context-free on purpose: - * the repository (Context-bound) delegates here, and the migration runner - * and unit tests drive it directly. - * - * One slot is the whole history: switching providers never touches it until - * the user migrates (slot overwritten) or forgets (slot cleared). The local - * file is always the source of truth. - */ -class SyncSlots(private val dao: RecordingDao) { - - suspend fun rows(): List = dao.getAll() - - /** Draft for upload, or null when the local file is gone. */ - fun draftFor(entity: RecordingEntity): RecordingDraft? { - val file = File(entity.filePath) - if (!file.isFile) return null - return RecordingDraft( - id = entity.id, - title = entity.title, - createdAtEpochMs = entity.createdAtEpochMs, - durationMs = entity.durationMs, - mime = entity.mimeType, - sourceFile = file, - sizeBytes = file.length(), - ) - } - - suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) { - dao.update(entity.copy(syncState = state, syncReason = reason)) - } - - suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) { - dao.update( - entity.copy( - originProviderId = providerId, - remoteKey = ref.key, - remoteEtag = ref.etag, - remoteSizeBytes = ref.sizeBytes, - syncState = SyncState.UPLOADED, - syncReason = null, - syncAttempts = 0, - syncRetryAtMs = null, - ) - ) - } - - /** - * Record a failed attempt with exponential backoff. [nowMs] is injected - * (default wall-clock) so tests run on a fixed clock. - */ - suspend fun markFailed( - entity: RecordingEntity, - reason: String, - nowMs: Long = System.currentTimeMillis(), - ) { - val attempts = (entity.syncAttempts + 1).coerceAtLeast(1) - dao.update( - entity.copy( - syncState = SyncState.ERROR, - syncReason = reason, - syncAttempts = attempts, - syncRetryAtMs = nowMs + backoffDelayMs(attempts), - ) - ) - } - - /** - * Forget every remote link ("start fresh"). Files stay; rows read as - * never-synced under [newOriginId], ready for a later upload run. - * Nothing remote is touched — forgetting is metadata-only. - */ - suspend fun forgetAllRemotes(newOriginId: String) { for (row in dao.getAll()) { - dao.update( - row.copy( - originProviderId = newOriginId, - remoteKey = null, - remoteEtag = null, - remoteSizeBytes = null, - syncState = SyncState.QUEUED, - syncReason = null, - syncAttempts = 0, - syncRetryAtMs = null, - ) - ) - } - } -} - -/** - * Legal steps from any resting state toward UPLOADING, per the transition - * table. Shared by the P7 foreground migrator and the M5 background drain - * so both move rows through identical states. - */ -internal fun stepsToUploading(from: SyncState): List = when (from) { - SyncState.LOCAL_ONLY, SyncState.ERROR -> listOf(SyncState.QUEUED, SyncState.UPLOADING) - // A leftover UPLOADING row (killed run) has no self-loop: detour via QUEUED. - SyncState.UPLOADING -> listOf(SyncState.QUEUED, SyncState.UPLOADING) - else -> listOf(SyncState.UPLOADING) -} - -/** - * Consecutive-failure backoff: 1m, 2m, 4m … capped at 1h. [attempt] is the - * 1-based count *including* the failure just recorded. - */ -internal fun backoffDelayMs(attempt: Int): Long { - val shift = (attempt.coerceAtLeast(1) - 1).coerceAtMost(6) - return (60_000L shl shift).coerceAtMost(3_600_000L) -} diff --git a/android/app/src/main/java/com/shonar/recording/SyncWorker.kt b/android/app/src/main/java/com/shonar/recording/SyncWorker.kt deleted file mode 100644 index 1553dd4..0000000 --- a/android/app/src/main/java/com/shonar/recording/SyncWorker.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.shonar.recording - -import android.content.Context -import androidx.work.BackoffPolicy -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import com.shonar.ShonarApplication -import com.shonar.provider.AuthState -import com.shonar.provider.LocalOnlyProvider -import com.shonar.settings.BuiltInSettings -import java.util.concurrent.TimeUnit - -/** - * M5 background sync driver. Thin by design: it resolves settings, the - * active provider, and live device gates, then hands one pass to the - * Context-free [SyncDrain] (which owns all state transitions and is where - * the unit tests live). - * - * Result contract: - * - success: the pass completed (per-file failures are recorded in the DB - * with backoff — retrying the whole worker immediately would hammer). - * - retry: transient only — offline at start, postponed by gates. Auth - * states needing the user (disconnected/expired/revoked) are failure. - * - cancellation propagates: the drain reverts the in-flight row to QUEUED. - */ -class SyncWorker(appContext: Context, params: WorkerParameters) : - CoroutineWorker(appContext, params) { - - override suspend fun doWork(): Result { - val app = applicationContext as ShonarApplication - app.settingsManager.ensureLoaded() - val settings = app.settingsManager - val providerId = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" } - if (providerId == LocalOnlyProvider.ID) return Result.success() - val wifiOnly = settings.bool(BuiltInSettings.WIFI_ONLY_UPLOAD) - val chargingOnly = settings.bool(BuiltInSettings.CHARGING_ONLY_UPLOAD) - - val gates = readGates(applicationContext) - val provider = try { - app.providerRegistry.provider(providerId) - } catch (e: Exception) { - return Result.failure() - } - val auth = try { - provider.reconnect() - } catch (e: Exception) { - return Result.retry() - } - when (auth) { - AuthState.DISCONNECTED, AuthState.EXPIRED, AuthState.REVOKED -> - return Result.failure() - AuthState.OFFLINE -> return Result.retry() - AuthState.CONNECTED -> Unit - } - val drain = SyncDrain(SyncSlots(app.database.recordingDao())) - val res = drain.drainOnce(provider, wifiOnly, chargingOnly, gates) - return if (res.postponed) Result.retry() else Result.success() - } - - companion object { - /** Snapshot of device conditions for [SyncDrain.Gates]. */ - internal fun readGates(context: Context): SyncDrain.Gates { - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) - as? android.net.ConnectivityManager - val caps = cm?.getNetworkCapabilities(cm.activeNetwork) - val online = caps?.hasCapability( - android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED - ) == true - val unmetered = caps?.hasCapability( - android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED - ) == true - val bm = context.getSystemService(Context.BATTERY_SERVICE) - as? android.os.BatteryManager - return SyncDrain.Gates( - online = online, - unmetered = unmetered, - charging = bm?.isCharging == true, - ) - } - } -} - -/** - * WorkManager wiring: one-shot drains on demand, a periodic 15-minute - * drain (the platform minimum) while constraints hold, and pause. - * Constraint mapping is a pure function ([workConstraints]) so the - * settings-to-WorkManager translation is unit-tested. - */ -object SyncScheduler { - const val ONCE_NAME = "shonar-sync-drain-once" - const val PERIODIC_NAME = "shonar-sync-drain-periodic" - - /** Pure settings -> constraints mapping. */ - internal fun workConstraints(wifiOnly: Boolean, chargingOnly: Boolean): Constraints = - Constraints.Builder() - .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) - .setRequiresCharging(chargingOnly) - .build() - - /** Drain now (startup, after a switch, after settings change). */ - fun requestNow(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) { - val req = OneTimeWorkRequestBuilder() - .setConstraints(workConstraints(wifiOnly, chargingOnly)) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) - .addTag(ONCE_NAME) - .build() - WorkManager.getInstance(context) - .enqueueUniqueWork(ONCE_NAME, ExistingWorkPolicy.REPLACE, req) - } - - /** Steady-state background drain. Idempotent; safe to call on every launch. */ - fun ensurePeriodic(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) { - val req = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) - .setConstraints(workConstraints(wifiOnly, chargingOnly)) - .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) - .addTag(PERIODIC_NAME) - .build() - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - PERIODIC_NAME, ExistingPeriodicWorkPolicy.UPDATE, req - ) - } - - /** Pause: cancel scheduled work and revert in-flight rows to QUEUED. */ - suspend fun pauseAll(context: Context, dao: RecordingDao) { - val wm = WorkManager.getInstance(context) - wm.cancelUniqueWork(ONCE_NAME) - wm.cancelUniqueWork(PERIODIC_NAME) - for (row in dao.getAll()) { - if (row.syncState == com.shonar.provider.SyncState.UPLOADING) { - dao.update(row.copy(syncState = com.shonar.provider.SyncState.QUEUED)) - } - } - } -} diff --git a/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt index d88e5e0..dbb814b 100644 --- a/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt +++ b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt @@ -3,23 +3,18 @@ package com.shonar.settings /** * Built-in settings. These ship with the app; users may add more at runtime. * - * NOTE: Home Assistant settings were removed from the default configuration - * (product scope change) and preserved on branch `deferred/home-assistant` - * under `deferred/home-assistant/`. + * NOTE: network/upload/provider settings were removed (product scope change + * to local-only): no accounts, no servers, no background uploads. File sync + * is the user's own tooling outside this app. */ object BuiltInSettings { const val CAT_GENERAL = "General" const val CAT_APPEARANCE = "Appearance" - const val CAT_NETWORK = "Network" const val CAT_ADVANCED = "Advanced" // ids other code depends on (single source of truth) const val CONSENT = "consent_notice_seen" - const val PROVIDER_ID = "provider_id" - const val PROVIDER_URL = "provider_url" - const val WIFI_ONLY_UPLOAD = "wifi_only_upload" - const val CHARGING_ONLY_UPLOAD = "charging_only_upload" val all: List = listOf( // --- General ------------------------------------------------------- @@ -59,51 +54,7 @@ object BuiltInSettings { type = SettingType.COLOR, defaultJson = "\"#4FD1C5\"", ), - // --- Storage provider (selection persisted here; see ProviderRegistry) --- - SettingDefinition( - id = PROVIDER_ID, - name = "Storage provider", - description = "Where your recordings live.", - category = CAT_GENERAL, - type = SettingType.SELECT, - choices = listOf("local-only", "nextcloud", "custom-shonar", "sync-folder", "start9", "umbrel"), - defaultJson = "\"local-only\"", - ), - SettingDefinition( - id = PROVIDER_URL, - name = "Provider server URL", - description = "Server address for the selected provider (empty for local-only).", - category = CAT_GENERAL, - type = SettingType.URL, - defaultJson = "\"\"", - ), - // --- Network ------------------------------------------------------------- - SettingDefinition( - id = "wifi_only_upload", - name = "Upload on Wi-Fi only", - description = "Applies once a sync provider is connected (next milestone).", - category = CAT_NETWORK, - type = SettingType.BOOLEAN, - defaultJson = "true", - ), - SettingDefinition( - id = "charging_only_upload", - name = "Upload only while charging", - description = "Applies once a sync provider is connected (next milestone).", - category = CAT_NETWORK, - type = SettingType.BOOLEAN, - defaultJson = "false", - ), // --- Advanced ------------------------------------------------------------ - SettingDefinition( - id = "log_http_bodies", - name = "Log request/response bodies (debug)", - description = "Never enabled by default. May write sensitive data " + - "to logcat; for debugging only.", - category = CAT_ADVANCED, - type = SettingType.BOOLEAN, - defaultJson = "false", - ), SettingDefinition( id = "app_version", name = "App version", diff --git a/android/app/src/main/java/com/shonar/settings/SettingsManager.kt b/android/app/src/main/java/com/shonar/settings/SettingsManager.kt index 00c528b..e01b1b1 100644 --- a/android/app/src/main/java/com/shonar/settings/SettingsManager.kt +++ b/android/app/src/main/java/com/shonar/settings/SettingsManager.kt @@ -28,11 +28,11 @@ data class ImportResult(val applied: List, val rejected: Map>(emptyList()) diff --git a/android/app/src/main/java/com/shonar/settings/SettingsStore.kt b/android/app/src/main/java/com/shonar/settings/SettingsStore.kt index ea6fb41..9ff9b88 100644 --- a/android/app/src/main/java/com/shonar/settings/SettingsStore.kt +++ b/android/app/src/main/java/com/shonar/settings/SettingsStore.kt @@ -7,18 +7,14 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.first -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** - * Key/value persistence abstraction. Two implementations: - * - [DataStoreSettingsStore] for ordinary settings - * - [SecureSettingsStore] (EncryptedSharedPreferences, AndroidKeyStore- - * backed) for anything marked sensitive. Secrets never touch the - * unencrypted store. + * Key/value persistence abstraction. Local-only app: a single DataStore + * (no secrets exist — no accounts, no server credentials). In-memory + * store for unit tests. */ interface SettingsStore { suspend fun getString(key: String): String? @@ -57,38 +53,6 @@ class DataStoreSettingsStore(private val context: Context) : SettingsStore { store.data.first().asMap().keys.map { it.name }.toSet() } -class SecureSettingsStore(context: Context) : SettingsStore { - private val prefs by lazy { - val masterKey = MasterKey.Builder(context, "shonar_secrets_master") - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - EncryptedSharedPreferences.create( - context, - "shonar_secrets", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) - } - - private val changeFlow = MutableStateFlow(0L) - override val changes: StateFlow = changeFlow.asStateFlow() - - override suspend fun getString(key: String): String? = prefs.getString(key, null) - - override suspend fun putString(key: String, value: String) { - prefs.edit().putString(key, value).apply() - changeFlow.value += 1 - } - - override suspend fun remove(key: String) { - prefs.edit().remove(key).apply() - changeFlow.value += 1 - } - - override suspend fun keys(): Set = prefs.all.keys -} - /** In-memory store: used by unit tests. */ class InMemorySettingsStore : SettingsStore { private val map = mutableMapOf() diff --git a/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt b/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt index ccc3630..1bb25f7 100644 --- a/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt @@ -1,19 +1,12 @@ package com.shonar.ui.detail -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Edit @@ -21,7 +14,6 @@ import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -29,15 +21,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -47,15 +35,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.shonar.ShonarApplication -import com.shonar.recording.SUMMARY_LIST_KEYS -import com.shonar.recording.SummaryData -import com.shonar.recording.TranscriptData -import com.shonar.recording.activeSegmentIndex +import java.text.DateFormat +import java.util.Date /** - * M8 recording details: playback synced to the transcript, summary with - * action items, processing status, and editing. Everything server-side - * degrades to an honest empty state — playback and rename always work. + * Recording details: playback, rename, file info. Local-only — no tabs, + * no server content. */ @Composable fun DetailsScreen(recordingId: String, onBack: () -> Unit) { @@ -127,42 +112,18 @@ private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) { } @Composable -private fun ColumnScope.ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) { - var tab by remember { mutableIntStateOf(0) } +private fun ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) { PlaybackCard(ready = ready, onToggle = vm::togglePlayback, onSeek = vm::seekTo, onSpeed = vm::cycleSpeed) ready.playbackError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } - TabRow(selectedTabIndex = tab) { - Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Transcript") }) - Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Summary") }) - Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Status") }) - } - when (tab) { - 0 -> TranscriptTab( - transcript = ready.transcript, - ai = ready.ai, - positionMs = ready.positionMs, - canEdit = ready.remoteId != null, - busy = ready.busy, - saveError = ready.saveError, - onSeek = vm::seekTo, - onSave = vm::saveTranscript, - onDismissError = vm::dismissSaveError, - ) - 1 -> SummaryTab( - summary = ready.summary, - ai = ready.ai, - canEdit = ready.remoteId != null, - busy = ready.busy, - saveError = ready.saveError, - onSave = vm::saveSummary, - onDismissError = vm::dismissSaveError, - ) - else -> StatusTab(ready = ready) + ready.saveError?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) } + InfoCard(ready = ready) } @Composable @@ -203,293 +164,25 @@ private fun PlaybackCard( } @Composable -private fun ColumnScope.TranscriptTab( - transcript: TranscriptData?, - ai: DetailsViewModel.AiState, - positionMs: Long, - canEdit: Boolean, - busy: String?, - saveError: String?, - onSeek: (Long) -> Unit, - onSave: (String) -> Unit, - onDismissError: () -> Unit, -) { - var editing by remember { mutableStateOf(false) } - when { - transcript == null -> AiEmptyState(ai, what = "transcript") - transcript.segments.isEmpty() -> { - Text(transcript.text.ifBlank { "Transcript is empty." }) - EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true }) - } - else -> { - val active = activeSegmentIndex(transcript.segments, positionMs) - val listState = rememberLazyListState() - LaunchedEffect(active) { - if (active >= 0) listState.animateScrollToItem(active) - } - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - (if (transcript.editedByUser) "Edited by you" else "Auto transcript") + - " · v${transcript.version}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true }) - } - LazyColumn( - Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - itemsIndexed(transcript.segments, key = { i, s -> "$i-${s.startSec}" }) { i, seg -> - val isActive = i == active - Card( - colors = if (isActive) { - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - ) - } else { - CardDefaults.cardColors() - }, - modifier = Modifier.fillMaxWidth() - .clickable { onSeek((seg.startSec * 1000).toLong()) }, - ) { - Column(Modifier.padding(10.dp)) { - Text( - formatMs((seg.startSec * 1000).toLong()) + - (seg.speaker?.let { " · $it" } ?: ""), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text(seg.text, style = MaterialTheme.typography.bodyMedium) - } - } - } - } - } - } - saveError?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.clickable { onDismissError() }) - } - if (editing && transcript != null) { - var draft by remember(transcript.version) { mutableStateOf(transcript.text) } - AlertDialog( - onDismissRequest = { editing = false }, - title = { Text("Edit transcript") }, - text = { - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth().height(220.dp), - ) - }, - confirmButton = { - TextButton( - onClick = { onSave(draft); editing = false }, - enabled = draft.isNotBlank(), - ) { Text("Save") } - }, - dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } }, - ) - } -} - -@Composable -private fun ColumnScope.SummaryTab( - summary: SummaryData?, - ai: DetailsViewModel.AiState, - canEdit: Boolean, - busy: String?, - saveError: String?, - onSave: (Map) -> Unit, - onDismissError: () -> Unit, -) { - var editing by remember { mutableStateOf(false) } - if (summary == null) { - AiEmptyState(ai, what = "summary") - return - } - LazyColumn( - Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - (if (summary.editedByUser) "Edited by you" else "AI summary") + - " · v${summary.version}", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true }) - } - } - if (summary.short.isNotBlank()) { - item { - SummarySection(title = "Summary", body = summary.short) - } - } - if (summary.detailed.isNotBlank()) { - item { - SummarySection(title = "Details", body = summary.detailed) - } - } - SUMMARY_LIST_KEYS.forEach { key -> - val items = summary.list(key) - if (items.isNotEmpty()) { - item { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - key.replace('_', ' ').replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleSmall, - ) - items.forEach { Bullet(it) } - } - } - } - } - } - saveError?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.clickable { onDismissError() }) - } - if (editing) { - SummaryEditDialog(summary = summary, onDismiss = { editing = false }, onSave = { - onSave(it); editing = false - }) - } -} - -@Composable -private fun SummaryEditDialog( - summary: SummaryData, - onDismiss: () -> Unit, - onSave: (Map) -> Unit, -) { - var short by remember { mutableStateOf(summary.short) } - var detailed by remember { mutableStateOf(summary.detailed) } - var actionItems by remember { mutableStateOf(summary.list("action_items").joinToString("\n")) } - var keyPoints by remember { mutableStateOf(summary.list("key_points").joinToString("\n")) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Edit summary") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(value = short, onValueChange = { short = it }, - label = { Text("Short") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = detailed, onValueChange = { detailed = it }, - label = { Text("Detailed") }, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = actionItems, onValueChange = { actionItems = it }, - label = { Text("Action items (one per line)") }, - modifier = Modifier.fillMaxWidth()) - OutlinedTextField(value = keyPoints, onValueChange = { keyPoints = it }, - label = { Text("Key points (one per line)") }, - modifier = Modifier.fillMaxWidth()) - } - }, - confirmButton = { - TextButton(onClick = { - val content = summary.content.toMutableMap() - content["short"] = short - content["detailed"] = detailed - content["action_items"] = linesOf(actionItems) - content["key_points"] = linesOf(keyPoints) - onSave(content) - }) { Text("Save") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - ) -} - -private fun linesOf(raw: String): List = - raw.lines().map { it.trim() }.filter { it.isNotEmpty() } - -@Composable -private fun StatusTab(ready: DetailsViewModel.Ready) { - val jobs = (ready.ai as? DetailsViewModel.AiState.Ready)?.jobs.orEmpty() - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Sync: ${ready.recording.syncState}" + - (ready.recording.syncReason?.let { " ($it)" } ?: "")) - if (jobs.isEmpty()) { - Text("No processing jobs reported.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - jobs.forEach { job -> - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(10.dp)) { - Text("${job.jobType}: ${job.status}", - style = MaterialTheme.typography.titleSmall) - if (job.error != null) { - Text(job.error, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error) - } else { - Text("attempt ${job.attempt}/${job.maxAttempts}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } +private fun InfoCard(ready: DetailsViewModel.Ready) { + val r = ready.recording + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + InfoRow("Length", formatMs(r.durationMs)) + InfoRow("Size", formatBytes(r.sizeBytes)) + InfoRow("Recorded", DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + .format(Date(r.createdAtEpochMs))) + InfoRow("File", r.filePath.substringAfterLast('/')) } } } @Composable -private fun AiEmptyState(ai: DetailsViewModel.AiState, what: String) { - when (ai) { - DetailsViewModel.AiState.Loading -> LoadingRow() - is DetailsViewModel.AiState.Unavailable -> Text( - ai.reason.ifBlank { "No $what yet." }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - is DetailsViewModel.AiState.Error -> Text( - ai.message, - color = MaterialTheme.colorScheme.error, - ) - is DetailsViewModel.AiState.Ready -> Text( - "No $what yet — processing may still be running. Check Status.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.height(4.dp)) -} - -@Composable -private fun EditRow(canEdit: Boolean, busy: String?, onEdit: () -> Unit) { - if (!canEdit) return - if (busy != null) { - Text(busy, style = MaterialTheme.typography.labelMedium) - } else { - TextButton(onClick = onEdit) { Text("Edit") } - } -} - -@Composable -private fun SummarySection(title: String, body: String) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(title, style = MaterialTheme.typography.titleSmall) - Text(body, style = MaterialTheme.typography.bodyMedium) - } -} - -@Composable -private fun Bullet(text: String) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text("•", style = MaterialTheme.typography.bodyMedium) - Text(text, style = MaterialTheme.typography.bodyMedium) +private fun InfoRow(label: String, value: String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium) } } @@ -505,3 +198,9 @@ private fun formatMs(ms: Long): String { val total = (ms / 1000).coerceAtLeast(0) return "%d:%02d".format(total / 60, total % 60) } + +private fun formatBytes(bytes: Long): String = when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) +} diff --git a/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt b/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt index 5388d34..d6c48d3 100644 --- a/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt +++ b/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt @@ -6,20 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.shonar.ShonarApplication -import com.shonar.provider.AuthState -import com.shonar.provider.CustomShonarProvider -import com.shonar.provider.ProviderError -import com.shonar.provider.ProviderRegistry -import com.shonar.recording.JobInfo import com.shonar.recording.RecordingEntity -import com.shonar.recording.SummaryData -import com.shonar.recording.TranscriptData -import com.shonar.recording.parseJobs -import com.shonar.recording.parseSummary -import com.shonar.recording.parseTranscript -import com.shonar.recording.recordingPatchPayload -import com.shonar.recording.summaryUpdatePayload -import com.shonar.recording.transcriptUpdatePayload import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -28,28 +15,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch /** - * M8 details screen: local playback (seek + speed) with the server transcript - * synced to the playhead, summary/action items, processing jobs, and editing. - * - * AI content lives on the custom SHONAR server (M7 endpoints) and is - * addressable only after upload — the local row's [RecordingEntity.remoteKey] - * is the server recording id. Anything else (local-only, not yet synced, - * other providers) gets an honest [AiState.Unavailable] instead of a - * spinner: playback and title/notes editing always work offline. + * Details: local playback (seek + speed), rename, file info. No accounts, + * no servers — everything here works offline on the on-phone library. */ class DetailsViewModel( private val app: ShonarApplication, private val recordingId: String, ) : ViewModel() { - sealed interface AiState { - data object Loading : AiState - data class Ready(val jobs: List) : AiState - /** No transcript/summary yet, or AI not applicable here. */ - data class Unavailable(val reason: String) : AiState - data class Error(val message: String) : AiState - } - data class Ready( val recording: RecordingEntity, val playing: Boolean = false, @@ -57,12 +30,6 @@ class DetailsViewModel( val durationMs: Long = 0, val speed: Float = 1f, val playbackError: String? = null, - val transcript: TranscriptData? = null, - val summary: SummaryData? = null, - val ai: AiState = AiState.Loading, - /** Null remote id = editing goes to Room only; non-null = also PUT/PATCH server. */ - val remoteId: String? = null, - val busy: String? = null, val saveError: String? = null, ) @@ -73,8 +40,6 @@ class DetailsViewModel( } private val dao = app.database.recordingDao() - private val registry = app.providerRegistry - private val settings = app.settingsManager private val _state = MutableStateFlow(UiState.Loading) val state: StateFlow = _state.asStateFlow() @@ -105,104 +70,7 @@ class DetailsViewModel( _state.value = UiState.Error("Recording not found.") return@launch } - val ready = Ready(recording = entity, durationMs = entity.durationMs) - _state.value = UiState.ReadyState(ready) - loadAi(entity) - } - } - - // ---- AI content ---------------------------------------------------------- - - private suspend fun loadAi(entity: RecordingEntity) { - val cur = current() ?: return - if (entity.originProviderId != ProviderRegistry.CUSTOM_SHONAR_ID || entity.remoteKey == null) { - val reason = if (entity.remoteKey == null && - entity.originProviderId == ProviderRegistry.CUSTOM_SHONAR_ID - ) { - "Not uploaded yet — transcript and summary appear after sync + processing." - } else { - "AI transcript and summary live on a SHONAR server — " + - "connect one and sync this recording to see them here." - } - _state.value = UiState.ReadyState(cur.copy(ai = AiState.Unavailable(reason))) - return - } - val provider = registry.provider(entity.originProviderId) as? CustomShonarProvider - if (provider == null) { - _state.value = UiState.ReadyState( - cur.copy(ai = AiState.Unavailable("This provider has no AI content.")) - ) - return - } - // Best effort: stored tokens may have expired while the app was away. - if (provider.authState.value != AuthState.CONNECTED) { - runCatching { provider.reconnect() } - } - if (provider.authState.value != AuthState.CONNECTED) { - _state.value = UiState.ReadyState( - cur.copy(ai = AiState.Error("Not connected — reconnect, then reopen this recording.")) - ) - return - } - val remoteId = entity.remoteKey - val transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } - .getOrElse { return setAiError(cur, "Could not load transcript.") } - val summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) } - .getOrElse { return setAiError(cur, "Could not load summary.") } - val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrDefault(emptyList()) - _state.value = UiState.ReadyState( - cur.copy( - transcript = transcript, - summary = summary, - ai = AiState.Ready(jobs), - remoteId = remoteId, - ) - ) - } - - private fun setAiError(cur: Ready, message: String) { - _state.value = UiState.ReadyState(cur.copy(ai = AiState.Error(message))) - } - - fun saveTranscript(text: String) { - val cur = current() ?: return - val remoteId = cur.remoteId ?: return - val provider = registry.provider(cur.recording.originProviderId) as? CustomShonarProvider - ?: return - viewModelScope.launch { - _state.value = UiState.ReadyState(cur.copy(busy = "Saving transcript…", saveError = null)) - val result = runCatching { - provider.updateTranscript(remoteId, transcriptUpdatePayload(text.trim())) - } - val parsed = result.getOrNull()?.let { parseTranscript(it) } - if (parsed != null) { - _state.value = UiState.ReadyState(current()!!.copy(transcript = parsed, busy = null)) - } else { - _state.value = UiState.ReadyState( - current()!!.copy(busy = null, saveError = "Save failed — kept your text, try again.") - ) - } - } - } - - fun saveSummary(content: Map) { - val cur = current() ?: return - val remoteId = cur.remoteId ?: return - val provider = registry.provider(cur.recording.originProviderId) as? CustomShonarProvider - ?: return - viewModelScope.launch { - _state.value = UiState.ReadyState(cur.copy(busy = "Saving summary…", saveError = null)) - val result = runCatching { - provider.updateSummary(remoteId, summaryUpdatePayload(content)) - } - val parsed = result.getOrNull()?.let { parseSummary(it) } - if (parsed != null) { - _state.value = UiState.ReadyState(current()!!.copy(summary = parsed, busy = null)) - } else { - _state.value = UiState.ReadyState( - current()!!.copy(busy = null, saveError = "Save failed — try again.") - ) - } + _state.value = UiState.ReadyState(Ready(recording = entity, durationMs = entity.durationMs)) } } @@ -221,14 +89,8 @@ class DetailsViewModel( return@launch } _state.value = UiState.ReadyState( - (current() ?: cur).copy(recording = updated) + (current() ?: cur).copy(recording = updated, saveError = null) ) - val remoteId = (current() ?: cur).remoteId ?: return@launch - val provider = registry.provider(updated.originProviderId) as? CustomShonarProvider - ?: return@launch - runCatching { - provider.patchRecording(remoteId, recordingPatchPayload(title = updated.title)) - } } } diff --git a/android/app/src/main/java/com/shonar/ui/folder/FolderBrowserScreen.kt b/android/app/src/main/java/com/shonar/ui/folder/FolderBrowserScreen.kt deleted file mode 100644 index 08adbac..0000000 --- a/android/app/src/main/java/com/shonar/ui/folder/FolderBrowserScreen.kt +++ /dev/null @@ -1,223 +0,0 @@ -package com.shonar.ui.folder - -import android.Manifest -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.Settings -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.CreateNewFolder -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import java.io.File - -/** - * Pick one of your own folders: browse shared storage, create a folder - * where you want it, and hand the path back. The caller (provider setup) - * still runs its own validation — the browser never writes recordings. - */ -@Composable -fun FolderBrowserScreen( - onPick: (String) -> Unit, - onBack: () -> Unit, -) { - val context = LocalContext.current - val lifecycle = LocalLifecycleOwner.current.lifecycle - - fun hasAccess(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Environment.isExternalStorageManager() - } else { - ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) == - PackageManager.PERMISSION_GRANTED - } - - var access by remember { mutableStateOf(hasAccess()) } - // Re-check when returning from the Settings grant screen. - DisposableEffect(lifecycle) { - val obs = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) access = hasAccess() - } - lifecycle.addObserver(obs) - onDispose { lifecycle.removeObserver(obs) } - } - - val settingsLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { access = hasAccess() } - val legacyPermission = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> if (granted) access = true } - - fun requestAccess() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - settingsLauncher.launch( - Intent( - Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - Uri.parse("package:${context.packageName}"), - ) - ) - } else { - legacyPermission.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } - } - - Scaffold { padding -> - Column( - Modifier.fillMaxSize().padding(padding).padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - Text("Choose a folder", style = MaterialTheme.typography.titleLarge) - } - if (!access) { - Text( - "SHONAR needs file access so you can browse your own " + - "folders. Only the folder you pick is ever used.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button(onClick = ::requestAccess, modifier = Modifier.fillMaxWidth()) { - Text("Grant file access") - } - } else { - BrowserBody(onPick = onPick) - } - } - } -} - -@Composable -private fun ColumnScope.BrowserBody(onPick: (String) -> Unit) { - val start = Environment.getExternalStorageDirectory()?.path ?: "/storage/emulated/0" - var current by remember { mutableStateOf(File(start)) } - var entries by remember { mutableStateOf(listChildDirs(current)) } - var error by remember { mutableStateOf(null) } - var showCreate by remember { mutableStateOf(false) } - - fun reload(dir: File = current) { - current = dir - error = null - entries = listChildDirs(dir) - if (!dir.canRead()) error = "Cannot read this folder." - } - - Text( - current.absolutePath, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton( - onClick = { current.parentFile?.let { reload(it) } }, - enabled = current.parentFile != null, - modifier = Modifier.weight(1f), - ) { Text("Up") } - OutlinedButton( - onClick = { showCreate = true }, - modifier = Modifier.weight(1f), - ) { - Icon(Icons.Filled.CreateNewFolder, contentDescription = null) - Text(" New folder") - } - } - error?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall) - } - LazyColumn( - Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(entries, key = { it.path }) { entry -> - Row( - Modifier.fillMaxWidth().padding(vertical = 10.dp) - .clickable { reload(File(entry.path)) }, - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(Icons.Filled.Folder, contentDescription = null, - tint = MaterialTheme.colorScheme.primary) - Text(entry.name, modifier = Modifier.padding(start = 12.dp).weight(1f)) - Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open") - } - } - } - Button( - onClick = { onPick(current.absolutePath) }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Use this folder") } - - if (showCreate) { - var name by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { showCreate = false }, - title = { Text("New folder") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Inside:\n${current.absolutePath}", - style = MaterialTheme.typography.bodySmall) - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Folder name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - TextButton( - onClick = { - createChildDir(current, name) - .onSuccess { reload(it) } - .onFailure { error = it.message } - if (error == null) showCreate = false - }, - enabled = isValidFolderName(name), - ) { Text("Create") } - }, - dismissButton = { TextButton(onClick = { showCreate = false }) { Text("Cancel") } }, - ) - } -} diff --git a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt index ba87b0e..631336f 100644 --- a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt @@ -1,5 +1,9 @@ package com.shonar.ui.home +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,9 +16,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card @@ -38,25 +42,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat -import android.Manifest -import android.content.pm.PackageManager +import androidx.lifecycle.viewmodel.compose.viewModel import com.shonar.ShonarApplication import com.shonar.settings.BuiltInSettings import kotlinx.coroutines.launch -import androidx.lifecycle.viewmodel.compose.viewModel /** - * Home: offline recording, local library, provider selection, and settings. - * a Settings entry, and the first-launch recording-consent notice which must - * be acknowledged before anything else. + * Home: record, local library, settings. Everything stays on this phone — + * no accounts, no servers. File sync (if wanted) is the user's own tooling. + * Includes the first-launch recording-consent notice which must be + * acknowledged before anything else. */ @Composable fun HomeScreen( onOpenSettings: () -> Unit, - onOpenStorage: () -> Unit, onOpenDetail: (String) -> Unit = {}, ) { val context = LocalContext.current @@ -68,19 +68,6 @@ fun HomeScreen( val notice by vm.notice.collectAsState() val scope = rememberCoroutineScope() var consentSeen by remember { mutableStateOf(null) } - var storageHeadline by remember { mutableStateOf(null) } - var storagePath by remember { mutableStateOf(null) } - var headlineTick by remember { mutableStateOf(0) } - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - // Returning from the folder picker resumes this screen without - // recomposition — re-read the headline so it shows the picked folder. - androidx.compose.runtime.DisposableEffect(lifecycleOwner) { - val obs = androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) headlineTick++ - } - lifecycleOwner.lifecycle.addObserver(obs) - onDispose { lifecycleOwner.lifecycle.removeObserver(obs) } - } val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> if (granted) vm.start() } @@ -95,20 +82,11 @@ fun HomeScreen( } } - LaunchedEffect(headlineTick) { + LaunchedEffect(Unit) { app.settingsManager.ensureLoaded() consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT) - // Adopt files added outside the app (USB, downloads…) into the library. + // Adopt files added outside the app (own file sync, USB…) into the library. runCatching { app.recordingRepository.refreshRoot() } - val pid = app.settingsManager.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" } - val purl = app.settingsManager.string(BuiltInSettings.PROVIDER_URL) - // Live summary first (shows the picked folder); static label as fallback. - val summary = runCatching { - app.providerRegistry.provider(pid).storageLocationSummary() - }.getOrNull() - storageHeadline = summary?.headline ?: com.shonar.ui.provider.ProviderSelectionViewModel - .headlineFor(pid.ifBlank { "local-only" }, purl) - storagePath = summary?.path } Scaffold( @@ -141,16 +119,12 @@ fun HomeScreen( ) { Text("SHONAR", style = MaterialTheme.typography.headlineMedium) Text( - "Self-hosted Oral Notes and Audio Recorder", + "Oral Notes and Audio Recorder — everything stays on this phone", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - storageHeadline?.let { headline -> - StorageChip(headline = headline, onClick = onOpenStorage) - } + StorageChip() Spacer(Modifier.height(24.dp)) - QuickCard("Choose storage", storagePath ?: storageHeadline ?: "Where recordings live", - Icons.Filled.Cloud, onOpenStorage) QuickCard("Settings", "Appearance, custom settings", Icons.Filled.Settings, onOpenSettings) if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) { @@ -181,7 +155,7 @@ fun HomeScreen( } } else { Text( - "Your recordings will appear here and stay on this device until you choose a provider.", + "Your recordings will appear here and stay on this device.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -311,19 +285,18 @@ private fun formatBytes(bytes: Long): String = when { else -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) } -/** Persistent "Stored: …" indicator — users always see where data goes. */ +/** Fixed on-phone storage indicator — no selection, nothing leaves the phone. */ @Composable -private fun StorageChip(headline: String, onClick: () -> Unit) { - Card(onClick = onClick, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer)) { +private fun StorageChip() { + Card(colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer)) { Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Filled.Cloud, contentDescription = null, + Icon(Icons.Filled.PhoneAndroid, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer) Spacer(Modifier.size(8.dp)) - Text("Stored: $headline", + Text("Stored: on this phone", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSecondaryContainer) } diff --git a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt deleted file mode 100644 index 8b6cd04..0000000 --- a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt +++ /dev/null @@ -1,450 +0,0 @@ -package com.shonar.ui.provider - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import android.content.Intent -import android.net.Uri -import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel - -/** - * "Choose where your recordings live." One screen, every target. - * Unavailable providers are visible but disabled with an honest reason — - * never a fake-connected state. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProviderSelectionScreen( - onDone: () -> Unit, - onBack: () -> Unit, - vm: ProviderSelectionViewModel = viewModel(factory = ProviderSelectionViewModel.Factory), - browsePick: String? = null, - onPickConsumed: () -> Unit = {}, - onBrowse: () -> Unit = {}, -) { - val state by vm.state.collectAsState() - val context = LocalContext.current - var selectedId by remember { mutableStateOf(null) } - var urlInput by remember { mutableStateOf("") } - var pathInput by remember { mutableStateOf("") } - var confirmCreate by remember { mutableStateOf(false) } - var showAdvanced by remember { mutableStateOf(false) } - - // A path picked in the folder browser saves immediately — the browse - // choice IS the confirmation, no second tap needed. With no card - // tapped, the pick means on-this-phone (device storage is the only - // non-advanced target). - LaunchedEffect(browsePick) { - if (browsePick != null) { - pathInput = browsePick - val localId = com.shonar.provider.LocalOnlyProvider.ID - val target = vm.options.firstOrNull { it.id == selectedId } - ?: vm.options.firstOrNull { it.id == localId } - if (target != null && target.needsPath && target.available) { - selectedId = target.id - vm.connectFolder(browsePick, target.id) - } - onPickConsumed() - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Where your recordings live") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - LazyColumn( - modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - Text( - "Recordings stay on this phone, in your own folder if you " + - "like. You can switch later without losing anything.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) - } - items(vm.simpleOptions) { opt -> - ProviderCard( - option = opt, - selected = selectedId == opt.id, - onSelect = { - selectedId = opt.id - if (!opt.available) { - // surface the honest reason immediately - vm.choose(opt, "") - } else if (opt.allowsBlankPath) { - // Tapping is the choice: persist the default now. - // Picking a folder below upgrades it. - vm.choose(opt, "") - } - }, - ) - } - val sel = vm.options.firstOrNull { it.id == selectedId } - item { - TextButton( - onClick = { showAdvanced = !showAdvanced }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - if (showAdvanced) "Hide self-hosted servers" - else "Self-hosted servers (advanced)" - ) - } - } - if (showAdvanced) { - items(vm.advancedOptions) { opt -> - ProviderCard( - option = opt, - selected = selectedId == opt.id, - onSelect = { - selectedId = opt.id - if (!opt.available) { - vm.choose(opt, "") - } - }, - ) - } - } - if (sel != null && sel.available && sel.needsUrl) { - item { - OutlinedTextField( - value = urlInput, - onValueChange = { urlInput = it }, - label = { Text("Server URL (https://…)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - item { - Button( - onClick = { vm.choose(sel, urlInput) }, - enabled = state !is SelectionUiState.Probing && urlInput.isNotBlank(), - modifier = Modifier.fillMaxWidth(), - ) { Text("Check server") } - } - } - if (sel != null && sel.available && sel.needsPath) { - item { - OutlinedTextField( - value = pathInput, - onValueChange = { pathInput = it }, - label = { Text("Folder path (or browse below)") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - item { - OutlinedButton( - onClick = onBrowse, - modifier = Modifier.fillMaxWidth(), - ) { Text("Browse my folders…") } - } - item { - Button( - onClick = { vm.connectFolder(pathInput, sel.id) }, - enabled = state !is SelectionUiState.Probing && - (pathInput.isNotBlank() || sel.allowsBlankPath), - modifier = Modifier.fillMaxWidth(), - ) { Text(if (pathInput.isBlank()) "Use default folder" else "Use this folder") } - } - item { - OutlinedButton( - onClick = { confirmCreate = true }, - enabled = state !is SelectionUiState.Probing && pathInput.isNotBlank(), - modifier = Modifier.fillMaxWidth(), - ) { Text("Create folder") } - } - if (confirmCreate) { - item { - AlertDialog( - onDismissRequest = { confirmCreate = false }, - title = { Text("Create folder?") }, - text = { - Text( - "This creates:\n${pathInput.trim()}\n\n" + - "Only the last part is created — its parent " + - "must already exist. Nothing else on your " + - "device is touched." - ) - }, - confirmButton = { - TextButton(onClick = { - confirmCreate = false - vm.createFolder(pathInput, sel.id) - }) { Text("Create") } - }, - dismissButton = { - TextButton(onClick = { confirmCreate = false }) { Text("Cancel") } - }, - ) - } - } - } - if (state is SelectionUiState.NeedsNcApproval) { - item { - val approval = state as SelectionUiState.NeedsNcApproval - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - "Approve SHONAR in your browser, then come back here. " + - "Nextcloud issues an app password — your normal password " + - "never touches this app.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Button( - onClick = { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(approval.loginUrl)) - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Open Nextcloud login") } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TextButton(onClick = { vm.cancelFlow() }) { Text("Cancel") } - Spacer(Modifier.weight(1f)) - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) - Spacer(Modifier.width(10.dp)) - TextButton(onClick = { vm.pollNow() }) { Text("I've approved — check now") } - } - } - } - } - } - if (state is SelectionUiState.NeedsCredentials) { - item { - var email by remember { mutableStateOf("") } - var password by remember { mutableStateOf("") } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = email, - onValueChange = { email = it }, - label = { Text("Server email") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Server password") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Button( - onClick = { - vm.login( - (state as SelectionUiState.NeedsCredentials).url, - email, password, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Sign in") } - } - } - } - when (val s = state) { - is SelectionUiState.Probing -> item { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) - Spacer(Modifier.width(10.dp)) - Text("Checking ${s.url} …") - } - } - is SelectionUiState.Error -> item { - Text(s.message, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium) - } - is SelectionUiState.NeedsTlsApproval -> item { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - "The server at ${s.url} uses a certificate this app doesn't trust yet. " + - "Only approve if you run this server (or you verified the " + - "fingerprint with whoever does) — and only on a network you trust.", - color = MaterialTheme.colorScheme.error, - ) - if (s.fingerprint != "unknown") { - Text( - "SHA-256: ${s.fingerprint}", - style = MaterialTheme.typography.bodySmall, - ) - } - if (s.details.isNotBlank()) { - Text( - s.details, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - TextButton(onClick = { vm.declineCert() }) { Text("Cancel") } - Spacer(Modifier.weight(1f)) - Button( - onClick = { vm.approvePendingCert() }, - enabled = s.fingerprint != "unknown", - ) { Text("Trust once") } - } - } - } - is SelectionUiState.Saved -> item { - Text( - "Connected: ${s.headline}", - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodyMedium, - ) - } - is SelectionUiState.NeedsCredentials -> {} - is SelectionUiState.NeedsNcApproval -> {} - is SelectionUiState.NeedsMigration -> {} - is SelectionUiState.Migrating -> {} - is SelectionUiState.Idle -> {} - } - item { - Button( - onClick = onDone, - enabled = state !is SelectionUiState.Probing && - state !is SelectionUiState.NeedsTlsApproval && - state !is SelectionUiState.NeedsMigration && - state !is SelectionUiState.Migrating, - modifier = Modifier.fillMaxWidth(), - ) { Text(if (state is SelectionUiState.Saved) "Continue" else "Keep current setting") } - Spacer(Modifier.height(24.dp)) - } - } - } - - val migration = state as? SelectionUiState.NeedsMigration - if (migration != null) { - AlertDialog( - onDismissRequest = { vm.keepAsIs() }, - title = { Text("Move your library?") }, - text = { - Text( - "You have ${migration.count} recordings. Upload them to the new " + - "provider now, keep them as they are, or forget old server links " + - "and start fresh? Your local files never move either way." - ) - }, - confirmButton = { - TextButton( - onClick = { vm.migrateNow() }, - enabled = migration.canUpload, - ) { Text("Upload ${migration.count} now") } - }, - dismissButton = { - Row { - TextButton(onClick = { vm.forgetLinks() }) { Text("Forget links") } - TextButton(onClick = { vm.keepAsIs() }) { Text("Keep as-is") } - } - }, - ) - } - - val migrating = state as? SelectionUiState.Migrating - if (migrating != null) { - AlertDialog( - onDismissRequest = { }, - title = { Text("Uploading ${migrating.done} of ${migrating.total}") }, - text = { - Column { - LinearProgressIndicator( - progress = { (migrating.done.toFloat() / migrating.total.coerceAtLeast(1)) }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - Text( - migrating.current.ifBlank { "Finishing…" }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - confirmButton = { - TextButton(onClick = { vm.cancelMigration() }) { Text("Cancel") } - }, - ) - } -} - -@Composable -private fun ProviderCard(option: ProviderOption, selected: Boolean, onSelect: () -> Unit) { - Card(onClick = onSelect) { - Row( - Modifier.fillMaxWidth().padding(16.dp), - verticalAlignment = Alignment.Top, - ) { - RadioButton(selected = selected, onClick = onSelect) - Spacer(Modifier.width(8.dp)) - Column(Modifier.fillMaxWidth()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(option.name, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold) - if (!option.available) { - Spacer(Modifier.width(8.dp)) - Text( - "coming soon", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - ) - } - } - Text(option.blurb, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} diff --git a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt deleted file mode 100644 index b1d8ee8..0000000 --- a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt +++ /dev/null @@ -1,622 +0,0 @@ -package com.shonar.ui.provider - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import com.shonar.ShonarApplication -import com.shonar.provider.ProbeResult -import com.shonar.provider.ProviderError -import com.shonar.provider.ProviderRegistry -import com.shonar.provider.ServerUrl -import com.shonar.provider.ShonarHandshake -import com.shonar.settings.BuiltInSettings -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -/** One card in the provider-selection list. */ -data class ProviderOption( - val id: String, - val name: String, - val blurb: String, - val needsUrl: Boolean, - val available: Boolean, // false -> disabled with "coming soon" - val needsPath: Boolean = false, // local folder choice: a directory path - val advanced: Boolean = false, // self-hosted servers, hidden by default -) { - /** On-this-phone accepts a blank path (app-private default folder). */ - val allowsBlankPath: Boolean - get() = id == com.shonar.provider.LocalOnlyProvider.ID && !needsUrl -} - -sealed interface SelectionUiState { - data object Idle : SelectionUiState - data class Probing(val url: String) : SelectionUiState - data class NeedsCredentials(val url: String) : SelectionUiState - data class NeedsNcApproval( - val url: String, - val loginUrl: String, - val flow: com.shonar.provider.LoginFlowSession, - ) : SelectionUiState - data class NeedsTlsApproval( - val url: String, - val fingerprint: String, - val details: String = "", - ) : SelectionUiState - data class Error(val message: String) : SelectionUiState - data class Saved(val providerId: String, val headline: String) : SelectionUiState - /** - * P7: switching providers with a non-empty library asks what happens to - * it before anything is moved or forgotten. - */ - data class NeedsMigration( - val providerId: String, - val count: Int, - val canUpload: Boolean, - ) : SelectionUiState - data class Migrating(val done: Int, val total: Int, val current: String) : SelectionUiState -} - -class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { - - private val settings = app.settingsManager - private val tofu = app.tofu - private val handshake = ShonarHandshake(app.providerRegistry.tls.probeClient(), app.tofu) - private val ncAuth = com.shonar.provider.NextcloudAuth( - app.providerRegistry.tls.nextcloudClient(), app.tofu - ) - private val customProvider: com.shonar.provider.ShonarProvider by lazy { - app.providerRegistry.provider(ProviderRegistry.CUSTOM_SHONAR_ID) - } - private val nextcloudProvider: com.shonar.provider.ShonarProvider by lazy { - app.providerRegistry.provider(ProviderRegistry.NEXTCLOUD_ID) - } - private var pollJob: kotlinx.coroutines.Job? = null - private var migrationJob: kotlinx.coroutines.Job? = null - private var migratingTo: String? = null - private var pendingOption: ProviderOption? = null - private var pendingUrl: String = "" - private var pendingMigration: SelectionUiState.NeedsMigration? = null - - private val registry = app.providerRegistry - private val repository = app.recordingRepository - private val dao = app.database.recordingDao() - - private val _state = MutableStateFlow(SelectionUiState.Idle) - val state: StateFlow = _state.asStateFlow() - - val options: List = listOf( - ProviderOption( - id = com.shonar.provider.LocalOnlyProvider.ID, - name = "On this phone", - blurb = "Recordings stay on this phone, in your own folder if you " + - "like — sync it yourself however you want. Playback and " + - "search work fully offline.", - needsUrl = false, - available = true, - needsPath = true, - ), - ProviderOption( - id = ProviderRegistry.NEXTCLOUD_ID, - name = "Nextcloud", - blurb = "Your files on your Nextcloud via WebDAV. Connects with an app password — never your normal password.", - needsUrl = true, - available = true, // P4 - advanced = true, - ), - ProviderOption( - id = ProviderRegistry.CUSTOM_SHONAR_ID, - name = "Custom SHONAR server", - blurb = "A SHONAR backend you run yourself (this project's server). Sign in with your server account.", - needsUrl = true, - available = true, // P3 - advanced = true, - ), - ProviderOption( - id = "start9", - name = "Start9 Server", - blurb = "A service on your Start9 box — enter its URL and the app detects " + - "Nextcloud or SHONAR automatically.", - needsUrl = true, - available = true, // P6a: generic hosted setup (no platform RPC) - advanced = true, - ), - ProviderOption( - id = "umbrel", - name = "Umbrel", - blurb = "A service on your Umbrel — enter its URL and the app detects " + - "Nextcloud or SHONAR automatically.", - needsUrl = true, - available = true, // P6a: generic hosted setup (no platform RPC) - advanced = true, - ), - ) - - /** Cards shown without opening Advanced. Server choices stay one tap away. */ - val simpleOptions: List get() = options.filter { !it.advanced } - - /** Self-hosted server choices, behind the Advanced disclosure. */ - val advancedOptions: List get() = options.filter { it.advanced } - - fun choose(option: ProviderOption, rawUrl: String) { - viewModelScope.launch { - pollJob?.cancel() - migrationJob?.cancel() - if (!option.available) { - _state.value = SelectionUiState.Error("${option.name} support ships in an upcoming milestone.") - return@launch - } - if (!option.needsUrl) { - // Local-only saves straight away (its folder is optional — - // the path step below only overrides it). The sync folder - // has no default, so it waits for its path step. - if (option.needsPath && - option.id != com.shonar.provider.LocalOnlyProvider.ID - ) { - return@launch - } - runCatching { save(option.id, "") } - .onFailure { _state.value = SelectionUiState.Error(it.message ?: "Could not save provider") } - return@launch - } - val parsed = ServerUrl.parse(rawUrl) - parsed.exceptionOrNull()?.let { - _state.value = SelectionUiState.Error(it.message ?: "Invalid server URL") - return@launch - } - val url = parsed.getOrThrow() - pendingOption = option - pendingUrl = rawUrl - _state.value = SelectionUiState.Probing(rawUrl) - if (option.id == ProviderRegistry.NEXTCLOUD_ID) { - probeNextcloud(url, rawUrl) - return@launch - } - if (option.id == "start9" || option.id == "umbrel") { - probeHosted(url, rawUrl, option.name) - return@launch - } - when (val probe = handshake.probe(url)) { - is ProbeResult.Compatible -> - if (option.id == ProviderRegistry.CUSTOM_SHONAR_ID) { - // Probe proved the server; credentials come next (P3 login). - _state.value = SelectionUiState.NeedsCredentials(rawUrl) - } else { - save(option.id, rawUrl) - } - ProbeResult.Incompatible -> - _state.value = SelectionUiState.Error( - "No SHONAR-compatible service found at ${url.origin}. " + - "Check the URL, or choose Local only for now." - ) - is ProbeResult.ServicesFound -> - _state.value = SelectionUiState.Error( - "Multiple services found — pick one: " + probe.services.joinToString { it.serviceName } - ) - is ProbeResult.TlsFailure -> - _state.value = toTlsApproval(rawUrl, probe.fingerprintSha256) - is ProbeResult.NetworkError -> - _state.value = SelectionUiState.Error( - "Can't reach ${url.origin} (${probe.reason}). Check the address and your network." - ) - } - } - } - - /** - * P5 approval: the user saw the fingerprint and trusts this server. - * Pins the leaf cert for that host only, then retries the probe. - */ - fun approvePendingCert() { - viewModelScope.launch { - val option = pendingOption ?: return@launch - val rawUrl = pendingUrl - if (rawUrl.isBlank()) return@launch - val host = ServerUrl.parse(rawUrl).getOrNull()?.host ?: return@launch - _state.value = SelectionUiState.Probing(rawUrl) - val ok = runCatching { tofu.approve(host) }.getOrDefault(false) - if (!ok) { - _state.value = SelectionUiState.Error( - "Nothing recorded for $host — try checking the server again." - ) - return@launch - } - choose(option, rawUrl) - } - } - - /** Walk away from the untrusted server; nothing is pinned. */ - fun declineCert() { - viewModelScope.launch { - runCatching { - val host = ServerUrl.parse(pendingUrl).getOrNull()?.host ?: return@launch - tofu.decline(host) - } - _state.value = SelectionUiState.Idle - } - } - - private fun toTlsApproval(rawUrl: String, fingerprint: String): SelectionUiState { - val host = ServerUrl.parse(rawUrl).getOrNull()?.host.orEmpty() - val failure = tofu.failureFor(host) - val details = failure?.let { - "Issued to: ${it.subject}\nIssued by: ${it.issuer}\nValid: ${it.validFrom} → ${it.validUntil}" - }.orEmpty() - return SelectionUiState.NeedsTlsApproval(rawUrl, fingerprint, details) - } - - /** - * P3 login: exchange email + password for the rotating token pair. - * The password lives in memory for this call only — only the tokens - * reach the secure store. - */ - fun login(rawUrl: String, email: String, password: String) { - viewModelScope.launch { - val parsed = ServerUrl.parse(rawUrl) - parsed.exceptionOrNull()?.let { - _state.value = SelectionUiState.Error(it.message ?: "Invalid server URL") - return@launch - } - if (email.isBlank() || password.isEmpty()) { - _state.value = SelectionUiState.Error("Enter your server email and password.") - return@launch - } - _state.value = SelectionUiState.Probing(rawUrl) - runCatching { - customProvider.connect( - com.shonar.provider.ProviderCredential.ShonarLogin( - accountLabel = email.trim(), - serverUrl = parsed.getOrThrow(), - email = email.trim(), - password = password, - ) - ) - }.onFailure { - _state.value = SelectionUiState.Error( - it.message ?: "Could not sign in to ${parsed.getOrThrow().origin}" - ) - return@launch - } - save(ProviderRegistry.CUSTOM_SHONAR_ID, rawUrl) - } - } - - /** - * P6a hosted setup: a Start9/Umbrel box (or anything else reachable) runs - * *some* service — probe for Nextcloud first, then SHONAR, and hand off - * to that provider's existing flow. The platform is an entry path, not a - * sync implementation: what gets persisted is the underlying protocol - * provider. No platform RPCs (deferred to P6b). - */ - private suspend fun probeHosted(url: ServerUrl, rawUrl: String, platform: String) { - val nc = ncAuth.probe(url) - // A compatible Nextcloud answers fast; only probe SHONAR when it - // didn't match — but a TLS failure on either blocks everything. - val shonar = if (nc is ProbeResult.Compatible) nc else handshake.probe(url) - when (routeHosted(nc, shonar)) { - HostedRoute.NEXT_CLOUD -> { - runCatching { ncAuth.startLogin(url) } - .onSuccess { - _state.value = SelectionUiState.NeedsNcApproval(rawUrl, it.loginUrl, it) - startPolling() - } - .onFailure { - _state.value = SelectionUiState.Error( - it.message ?: "Could not start the Nextcloud login" - ) - } - } - HostedRoute.SHONAR -> - _state.value = SelectionUiState.NeedsCredentials(rawUrl) - HostedRoute.TLS -> { - val fp = (nc as? ProbeResult.TlsFailure)?.fingerprintSha256 - ?: (shonar as? ProbeResult.TlsFailure)?.fingerprintSha256 - ?: "unknown" - _state.value = toTlsApproval(rawUrl, fp) - } - HostedRoute.NONE -> - _state.value = SelectionUiState.Error( - "No Nextcloud or SHONAR service found at ${url.origin} from $platform. " + - "Check the URL, or choose another option for now." - ) - } - } - /** - * P4 Nextcloud: probe for status.php, then start login flow v2. The - * browser approval + polling continue in [NeedsNcApproval]. - */ - private suspend fun probeNextcloud(url: ServerUrl, rawUrl: String) { - when (val probe = ncAuth.probe(url)) { - is ProbeResult.Compatible -> { - runCatching { ncAuth.startLogin(url) } - .onSuccess { - _state.value = SelectionUiState.NeedsNcApproval(rawUrl, it.loginUrl, it) - startPolling() - } - .onFailure { - _state.value = SelectionUiState.Error( - it.message ?: "Could not start the Nextcloud login" - ) - } - } - ProbeResult.Incompatible -> - _state.value = SelectionUiState.Error( - "That doesn't look like a Nextcloud (no status.php). " + - "Check the URL, or choose another option for now." - ) - is ProbeResult.ServicesFound -> - _state.value = SelectionUiState.Error("Unexpected probe result — try again.") - is ProbeResult.TlsFailure -> - _state.value = toTlsApproval(rawUrl, probe.fingerprintSha256) - is ProbeResult.NetworkError -> - _state.value = SelectionUiState.Error( - "Can't reach ${url.origin} (${probe.reason}). Check the address and your network." - ) - } - } - - private fun startPolling() { - pollJob?.cancel() - pollJob = viewModelScope.launch { - while (true) { - kotlinx.coroutines.delay(2500) - val cur = _state.value as? SelectionUiState.NeedsNcApproval ?: break - if (!pollOnce(cur)) break - } - } - } - - /** One poll attempt. Returns false when polling should stop. */ - private suspend fun pollOnce(cur: SelectionUiState.NeedsNcApproval): Boolean { - return when (val r = ncAuth.poll(cur.flow)) { - is com.shonar.provider.PollResult.Approved -> { - runCatching { nextcloudProvider.connect(r.credential) } - .onSuccess { save(ProviderRegistry.NEXTCLOUD_ID, cur.url) } - .onFailure { - _state.value = SelectionUiState.Error( - it.message ?: "Could not connect to ${cur.url}" - ) - } - false - } - is com.shonar.provider.PollResult.Failed -> { - _state.value = SelectionUiState.Error(r.reason) - false - } - com.shonar.provider.PollResult.Pending -> true - } - } - - /** "I've approved — check now" button. */ - fun pollNow() { - viewModelScope.launch { - val cur = _state.value as? SelectionUiState.NeedsNcApproval ?: return@launch - pollOnce(cur) - } - } - - /** Leave the approval flow without disconnecting anything. */ - fun cancelFlow() { - pollJob?.cancel() - _state.value = SelectionUiState.Idle - } - - /** - * Use a folder for a path-capable provider (on-this-phone or sync - * folder). Blank path means "just the default": valid for on-this-phone - * (app-private dir), an error for the sync folder, which has no default. - */ - fun connectFolder(rawPath: String, providerId: String = ProviderRegistry.SYNC_FOLDER_ID) { - viewModelScope.launch { - val path = rawPath.trim() - if (path.isEmpty()) { - if (providerId == com.shonar.provider.LocalOnlyProvider.ID) { - runCatching { save(providerId, "") } - .onFailure { - _state.value = SelectionUiState.Error( - it.message ?: "Could not save provider" - ) - } - } else { - _state.value = SelectionUiState.Error( - "Enter the folder path — e.g. the Syncthing folder for SHONAR." - ) - } - return@launch - } - _state.value = SelectionUiState.Probing(path) - runCatching { - registry.provider(providerId).connect( - com.shonar.provider.ProviderCredential.FolderPath( - accountLabel = path, - path = path, - ) - ) - }.onFailure { - _state.value = SelectionUiState.Error(it.message ?: "Could not use $path") - return@launch - } - save(providerId, "") - } - } - - /** - * Create the folder (only the final segment; the parent must already - * exist) and use it for [providerId]. The UI confirms the exact path - * before calling — creation is always explicit, never a typo side - * effect. Blank path falls back to the default, like [connectFolder]. - */ - fun createFolder( - rawPath: String, - providerId: String = ProviderRegistry.SYNC_FOLDER_ID, - ) { - viewModelScope.launch { - val path = rawPath.trim() - if (path.isEmpty()) { - connectFolder("", providerId) - return@launch - } - _state.value = SelectionUiState.Probing(path) - val provider = runCatching { registry.provider(providerId) }.getOrNull() - val failure = runCatching { - when (provider) { - is com.shonar.provider.LocalOnlyProvider -> provider.createRoot(path) - is com.shonar.provider.FolderSyncProvider -> provider.createRoot(path) - else -> throw com.shonar.provider.ProviderError.InvalidUrl( - "This provider has no folder to create" - ) - } - }.exceptionOrNull() - if (failure != null) { - _state.value = SelectionUiState.Error(failure.message ?: "Could not create $path") - return@launch - } - save(providerId, "") - } - } - - private suspend fun save(providerId: String, url: String) { - settings.ensureLoaded() - val previous = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" } - settings.setValue(BuiltInSettings.PROVIDER_ID, "\"" + providerId + "\"") - if (url.isBlank()) { - // An empty URL is represented by the setting's default, not by an - // invalid URL value. This also clears a previously configured - // remote provider when switching back to local-only storage. - settings.reset(BuiltInSettings.PROVIDER_URL) - } else { - settings.setValue( - BuiltInSettings.PROVIDER_URL, - "\"" + url.replace("\\", "\\\\").replace("\"", "\\\"") + "\"", - ) - } - // New recordings follow the picked folder from here on. - // Older clips stay where they were recorded. - runCatching { repository.refreshRoot() } - // P7: switching providers with a non-empty library asks what happens - // to it — nothing moves or is forgotten silently. - val count = runCatching { dao.getAll().size }.getOrDefault(0) - if (previous != providerId && count > 0) { - val ask = SelectionUiState.NeedsMigration( - providerId = providerId, - count = count, - canUpload = providerId != com.shonar.provider.LocalOnlyProvider.ID, - ) - pendingMigration = ask - _state.value = ask - } else { - _state.value = SelectionUiState.Saved(providerId, headlineFor(providerId, url)) - } - } - - /** P7 "keep everything as it is": history stays, states stay. */ - fun keepAsIs() { - viewModelScope.launch { - val ask = pendingMigration ?: return@launch - pendingMigration = null - _state.value = SelectionUiState.Saved( - ask.providerId, headlineFor(ask.providerId, pendingUrl) - ) - } - } - - /** P7 "start fresh": clear every remote link. Files stay; nothing remote - * is touched — forgetting is metadata-only. */ - fun forgetLinks() { - viewModelScope.launch { - val ask = pendingMigration ?: return@launch - pendingMigration = null - runCatching { repository.forgetAllRemotes(ask.providerId) } - _state.value = SelectionUiState.Saved( - ask.providerId, headlineFor(ask.providerId, pendingUrl) - ) - } - } - - /** - * P7 "upload now": foreground migration to the new provider. Cancellable; - * already-finished files stay uploaded, the rest stay QUEUED for a later - * run. Failures are recorded per recording and reported, never retried - * here — retry policy is M5's job. - */ - fun migrateNow() { - viewModelScope.launch { - val ask = pendingMigration ?: return@launch - pendingMigration = null - migrationJob?.cancel() - val runner = com.shonar.recording.MigrationRunner( - com.shonar.recording.SyncSlots(dao), registry - ) - migratingTo = ask.providerId - migrationJob = viewModelScope.launch { - val res = runner.migrateAllTo(ask.providerId) { p -> - _state.value = SelectionUiState.Migrating(p.done, p.total, p.currentTitle) - } - migratingTo = null - _state.value = if (res.failed.isEmpty()) { - SelectionUiState.Saved( - ask.providerId, headlineFor(ask.providerId, pendingUrl) - ) - } else { - SelectionUiState.Error( - "Uploaded ${res.uploaded} of ${res.uploaded + res.failed.size}. " + - "Failed (kept locally, retried by a future sync): " + - res.failed.take(3).joinToString(", ") + - if (res.failed.size > 3) ", …" else "" - ) - } - } - } - } - - /** Cancel a running migration. Finished files stay uploaded. */ - fun cancelMigration() { - migrationJob?.cancel() - viewModelScope.launch { - // The runner reports cancellation itself by leaving Migrating; - // this is only the fallback if it somehow didn't. - kotlinx.coroutines.delay(100) - val target = migratingTo - if (_state.value is SelectionUiState.Migrating && target != null) { - migratingTo = null - _state.value = SelectionUiState.Saved(target, headlineFor(target, pendingUrl)) - } - } - } - - companion object { - val Factory: ViewModelProvider.Factory = viewModelFactory { - initializer { - val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication - ProviderSelectionViewModel(app) - } - } - - fun headlineFor(providerId: String, url: String): String = when (providerId) { - com.shonar.provider.LocalOnlyProvider.ID -> "On this device only" - ProviderRegistry.SYNC_FOLDER_ID -> "Sync folder" - else -> { - val host = ServerUrl.parse(url).getOrNull()?.host - if (host.isNullOrBlank()) providerId else "$providerId at $host" - } - } - } -} - -/** Where a hosted-service URL routes after probing. Pure; unit-tested. */ -enum class HostedRoute { NEXT_CLOUD, SHONAR, TLS, NONE } - -/** - * P6a routing: a compatible match wins on either probe; otherwise a TLS - * failure on either blocks everything (approval retries the whole flow); - * anything else means "nothing we speak lives here". - */ -fun routeHosted(nc: ProbeResult, shonar: ProbeResult): HostedRoute = when { - nc is ProbeResult.Compatible -> HostedRoute.NEXT_CLOUD - shonar is ProbeResult.Compatible -> HostedRoute.SHONAR - nc is ProbeResult.TlsFailure || shonar is ProbeResult.TlsFailure -> HostedRoute.TLS - else -> HostedRoute.NONE -} diff --git a/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt b/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt deleted file mode 100644 index 5a388c5..0000000 --- a/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt +++ /dev/null @@ -1,263 +0,0 @@ -package com.shonar.ui.provider - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.shonar.provider.AuthState -import com.shonar.provider.LocalOnlyProvider - -/** - * P7 "Where is my data?": live provider summary plus the account - * lifecycle. Switching providers lives in the selection screen (the - * migration question is asked at the moment of switching). - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun StorageScreen( - onBack: () -> Unit, - onSwitchProvider: () -> Unit, - vm: StorageViewModel = viewModel(factory = StorageViewModel.Factory), -) { - val state by vm.state.collectAsState() - - LaunchedEffect(Unit) { vm.refresh() } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Where your recordings live") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Spacer(Modifier.height(4.dp)) - when (val s = state) { - is StorageViewModel.UiState.Loading -> { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) - Spacer(Modifier.width(10.dp)) - Text("Loading storage info …") - } - } - is StorageViewModel.UiState.Error -> { - Text(s.message, color = MaterialTheme.colorScheme.error) - Button(onClick = onSwitchProvider, modifier = Modifier.fillMaxWidth()) { - Text("Choose a provider") - } - } - is StorageViewModel.UiState.ReadyState -> ReadyBody( - ready = s.ready, - onSwitchProvider = onSwitchProvider, - onReconnect = { vm.reconnect() }, - onDisconnect = { vm.disconnect(it) }, - onPauseSync = { vm.pauseSync() }, - onAskDelete = { vm.askDelete() }, - ) - } - Spacer(Modifier.height(24.dp)) - } - } - - val ready = (state as? StorageViewModel.UiState.ReadyState)?.ready - if (ready?.confirmDelete == true) { - DeleteDialog( - ready = ready, - onCancel = { vm.cancelDelete() }, - onConfirm = { vm.confirmDelete(it) }, - ) - } -} - -@Composable -private fun ReadyBody( - ready: StorageViewModel.Ready, - onSwitchProvider: () -> Unit, - onReconnect: () -> Unit, - onDisconnect: (Boolean) -> Unit, - onPauseSync: () -> Unit, - onAskDelete: () -> Unit, -) { - var revoke by remember(ready.providerId) { mutableStateOf(true) } - - Text( - ready.displayName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - Text( - "Status: ${authLabel(ready.auth)}", - color = if (ready.auth == AuthState.CONNECTED) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, - ) - ready.summary?.let { - Text(it.headline, style = MaterialTheme.typography.titleMedium) - it.path?.let { path -> - Text( - path, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - Text( - it.detail, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - "Synced ${it.syncedCount} · local-only ${it.localOnlyCount}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - ready.summaryError?.let { - Text(it, style = MaterialTheme.typography.bodyMedium) - } - ready.busy?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) - Spacer(Modifier.width(10.dp)) - Text(it) - } - } - - val enabled = ready.busy == null - val isLocal = ready.providerId == LocalOnlyProvider.ID - Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) { - Text("Location") - } - // On-device storage has no accounts, servers, or sync jobs — just the - // location. Everything below is server/provider lifecycle. - if (!isLocal) { - if (ready.auth != AuthState.CONNECTED) { - Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) { - Text("Reconnect") - } - } - OutlinedButton( - onClick = onPauseSync, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - ) { Text("Pause sync") } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = revoke, onCheckedChange = { revoke = it }, enabled = enabled) - Text("Also revoke on the server") - } - OutlinedButton( - onClick = { onDisconnect(revoke) }, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - ) { Text("Disconnect") } - OutlinedButton( - onClick = onAskDelete, - enabled = enabled, - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - modifier = Modifier.fillMaxWidth(), - ) { Text("Delete account & data") } - } -} - -private fun authLabel(auth: AuthState): String = when (auth) { - AuthState.CONNECTED -> "connected" - AuthState.DISCONNECTED -> "disconnected" - AuthState.EXPIRED -> "expired — reconnect" - AuthState.REVOKED -> "revoked — connect again" - AuthState.OFFLINE -> "offline" -} - -@Composable -private fun DeleteDialog( - ready: StorageViewModel.Ready, - onCancel: () -> Unit, - onConfirm: (String) -> Unit, -) { - var password by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = onCancel, - title = { Text("Delete account & data?") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - if (ready.needPassword) { - "This asks the server to purge the account (30-day grace), " + - "revokes this device, and forgets every remote link. " + - "Your local library files stay on this phone." - } else { - "This revokes access and forgets every remote link on " + - "${ready.displayName}. Your local library files stay " + - "on this phone; server copies are left alone." - } - ) - if (ready.needPassword) { - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Server password") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - ready.deleteError?.let { - Text(it, color = MaterialTheme.colorScheme.error) - } - } - }, - confirmButton = { - TextButton( - onClick = { onConfirm(password) }, - enabled = !ready.needPassword || password.isNotEmpty(), - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ), - ) { Text("Delete") } - }, - dismissButton = { - TextButton(onClick = onCancel) { Text("Keep") } - }, - ) -} diff --git a/android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt b/android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt deleted file mode 100644 index 4b748f8..0000000 --- a/android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt +++ /dev/null @@ -1,202 +0,0 @@ -package com.shonar.ui.provider - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory -import com.shonar.ShonarApplication -import com.shonar.provider.AuthState -import com.shonar.provider.StorageLocation -import com.shonar.settings.BuiltInSettings -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -/** - * P7 "Where is my data?" screen: the active provider's live summary plus - * the account lifecycle — reconnect, disconnect (optionally revoking - * server-side), and delete-account-and-data. Switching providers happens - * in the selection screen; the migration question is asked there, at the - * moment of switching. - */ -class StorageViewModel(private val app: ShonarApplication) : ViewModel() { - - data class Ready( - val providerId: String, - val displayName: String, - val auth: AuthState, - val summary: StorageLocation?, - val summaryError: String?, - val busy: String? = null, - val confirmDelete: Boolean = false, - val needPassword: Boolean = false, - val deleteError: String? = null, - ) - - sealed interface UiState { - data object Loading : UiState - data class ReadyState(val ready: Ready) : UiState - data class Error(val message: String) : UiState - } - - private val settings = app.settingsManager - private val registry = app.providerRegistry - private val repository = app.recordingRepository - private val dao = app.database.recordingDao() - - private val _state = MutableStateFlow(UiState.Loading) - val state: StateFlow = _state.asStateFlow() - - fun refresh() { - viewModelScope.launch { - _state.value = UiState.Loading - _state.value = load() - } - } - - private suspend fun load(): UiState { - settings.ensureLoaded() - val id = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" } - val provider = try { - registry.provider(id) - } catch (e: Exception) { - return UiState.Error("Unknown provider \"$id\" — pick one again.") - } - val auth = provider.authState.value - var summary: StorageLocation? = null - var summaryError: String? = null - try { - summary = provider.storageLocationSummary() - } catch (e: com.shonar.provider.ProviderError.NotConnected) { - summaryError = "Not connected — reconnect to see live numbers." - } catch (e: Exception) { - summaryError = e.message ?: "Could not load storage info." - } - return UiState.ReadyState( - Ready( - providerId = id, - displayName = provider.descriptor.displayName, - auth = auth, - summary = summary, - summaryError = summaryError, - ) - ) - } - - fun reconnect() { - viewModelScope.launch { - val cur = currentReady() ?: return@launch - setBusy(cur, "Reconnecting…") - val provider = registry.provider(cur.providerId) - runCatching { provider.reconnect() } - _state.value = loadWith(cur) - } - } - - fun disconnect(revokeOnServer: Boolean) { - viewModelScope.launch { - val cur = currentReady() ?: return@launch - setBusy(cur, "Disconnecting…") - runCatching { registry.provider(cur.providerId).disconnect(revokeOnServer) } - _state.value = loadWith(cur) - } - } - - /** - * M5 pause: cancel scheduled sync work and revert in-flight rows to - * QUEUED. Pause is a resting state, not a job kill — the next drain - * (or a settings change) resumes cleanly. - */ - fun pauseSync() { - viewModelScope.launch { - val cur = currentReady() ?: return@launch - setBusy(cur, "Pausing…") - runCatching { - com.shonar.recording.SyncScheduler.pauseAll(app.applicationContext, dao) - } - _state.value = loadWith(cur) - } - } - - fun askDelete() { - viewModelScope.launch { - val cur = currentReady() ?: return@launch - val needsPassword = registry.provider(cur.providerId) is - com.shonar.provider.CustomShonarProvider - _state.value = UiState.ReadyState( - cur.copy(confirmDelete = true, needPassword = needsPassword, deleteError = null) - ) - } - } - - fun cancelDelete() { - viewModelScope.launch { - val cur = currentReady() ?: return@launch - _state.value = UiState.ReadyState(cur.copy(confirmDelete = false, deleteError = null)) - } - } - - /** - * Delete account & data on the active provider, then fall back to - * local-only with cleared remote links. Provider semantics differ and - * the confirm dialog says which apply; the local library files always - * stay — only the remote links are forgotten. - */ - fun confirmDelete(password: String = "") { - viewModelScope.launch { - val cur = currentReady() ?: return@launch - setBusy(cur, "Deleting…") - val provider = registry.provider(cur.providerId) - val failure = runCatching { - val custom = provider as? com.shonar.provider.CustomShonarProvider - if (custom != null) { - if (password.isEmpty()) throw IllegalArgumentException("Password required") - custom.deleteAccount(password) - } else { - provider.deleteAccountAndData() - } - }.exceptionOrNull() - if (failure != null) { - _state.value = UiState.ReadyState( - cur.copy( - busy = null, - deleteError = failure.message ?: "Delete failed.", - ) - ) - return@launch - } - settings.ensureLoaded() - settings.setValue(BuiltInSettings.PROVIDER_ID, "\"local-only\"") - settings.reset(BuiltInSettings.PROVIDER_URL) - repository.forgetAllRemotes("local-only") - _state.value = load() - } - } - - private suspend fun currentReady(): Ready? = - (_state.value as? UiState.ReadyState)?.ready - - private suspend fun setBusy(cur: Ready, label: String) { - _state.value = UiState.ReadyState(cur.copy(busy = label)) - } - - private suspend fun loadWith(cur: Ready): UiState { - val fresh = load() - // Preserve an open confirm dialog across refreshes. - if (fresh is UiState.ReadyState && cur.confirmDelete) { - return UiState.ReadyState(fresh.ready.copy(confirmDelete = true)) - } - return fresh - } - - companion object { - val Factory: ViewModelProvider.Factory = viewModelFactory { - initializer { - val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication - StorageViewModel(app) - } - } - } -} diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index f737223..0000000 --- a/android/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - localhost - .local - 10.0.0.0 - 192.168.0.0 - 172.16.0.0 - - diff --git a/android/app/src/test/java/com/shonar/provider/CustomShonarAiTest.kt b/android/app/src/test/java/com/shonar/provider/CustomShonarAiTest.kt deleted file mode 100644 index eda24e1..0000000 --- a/android/app/src/test/java/com/shonar/provider/CustomShonarAiTest.kt +++ /dev/null @@ -1,180 +0,0 @@ -package com.shonar.provider - -import java.io.File -import kotlinx.coroutines.runBlocking -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import org.json.JSONArray -import org.json.JSONObject -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test - -/** - * M8: CustomShonarProvider AI-content methods against a fake with the M7 - * read endpoints plus the M8 PUT edit endpoints. Shapes mirror - * `backend/shonar/api/schemas_recordings.py`. - */ -class CustomShonarAiTest { - - private val server = MockWebServer() - private lateinit var dir: File - private lateinit var store: com.shonar.settings.InMemorySettingsStore - - private var transcriptText = "auto text" - private var transcriptVersion = 1 - private var summaryContent = JSONObject().put("short", "auto summary") - private var summaryVersion = 1 - - @Before fun setUp() { - dir = File(System.getProperty("java.io.tmpdir"), "custom-ai-${System.nanoTime()}").apply { mkdirs() } - store = com.shonar.settings.InMemorySettingsStore() - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = handle(request) - } - server.start() - } - - @After fun tearDown() { - server.shutdown() - dir.deleteRecursively() - } - - private val url: String get() = server.url("/").toString().removeSuffix("/") - - private fun provider(): CustomShonarProvider = CustomShonarProvider( - auth = ShonarAuthStore(store), - sidecarRoot = File(dir, "sidecars"), - ) - - private suspend fun connected(): CustomShonarProvider { - val p = provider() - p.connect( - ProviderCredential.ShonarLogin( - accountLabel = "t@example.com", - serverUrl = ServerUrl.parse(url)!!.getOrThrow(), - email = "t@example.com", - password = "pw", - ) - ) - return p - } - - private fun handle(request: RecordedRequest): MockResponse { - val path = request.path?.substringBefore('?').orEmpty() - val method = request.method.orEmpty() - if (method == "POST" && path == "/api/v1/auth/login") { - return json(200, JSONObject() - .put("access_token", "a").put("token_type", "bearer") - .put("expires_in", 900).put("refresh_token", "r")) - } - if (request.getHeader("Authorization") != "Bearer a") { - return json(401, JSONObject().put("detail", "bad token")) - } - val recId = "rec-1" - return when { - method == "GET" && path == "/api/v1/recordings/$recId/transcript" -> - json(200, transcriptJson()) - method == "GET" && path == "/api/v1/recordings/$recId/summary" -> - json(200, summaryJson()) - method == "GET" && path == "/api/v1/recordings/$recId/jobs" -> - MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody(JSONArray().apply { - put(JSONObject().put("job_type", "transcribe") - .put("status", "succeeded").put("attempt", 1).put("max_attempts", 3)) - }.toString()) - method == "PUT" && path == "/api/v1/recordings/$recId/transcript" -> { - val body = JSONObject(request.body.readUtf8()) - transcriptText = body.getString("text") - transcriptVersion++ - json(200, transcriptJson()) - } - method == "PUT" && path == "/api/v1/recordings/$recId/summary" -> { - summaryContent = JSONObject(request.body.readUtf8()).getJSONObject("content") - summaryVersion++ - json(200, summaryJson()) - } - method == "PATCH" && path == "/api/v1/recordings/$recId" -> - json(200, JSONObject().put("id", recId) - .put("title", JSONObject(request.body.readUtf8()).optString("title", "x"))) - method == "GET" && path == "/api/v1/recordings/missing/transcript" -> - json(404, JSONObject().put("detail", "No transcript yet")) - else -> json(404, JSONObject().put("detail", "unknown $method $path")) - } - } - - private fun transcriptJson() = JSONObject() - .put("version", transcriptVersion).put("language", "en") - .put("provider", if (transcriptVersion == 1) "faster-whisper" else "user") - .put("text", transcriptText) - .put("segments", JSONArray().apply { - put(JSONObject().put("start", 0.0).put("end", 1.0).put("text", transcriptText)) - }) - .put("edited_by_user", transcriptVersion > 1) - - private fun summaryJson() = JSONObject() - .put("version", summaryVersion) - .put("provider", if (summaryVersion == 1) "ollama" else "user") - .put("content", summaryContent) - .put("edited_by_user", summaryVersion > 1) - - private fun json(code: Int, obj: JSONObject) = MockResponse() - .setResponseCode(code).setHeader("Content-Type", "application/json") - .setBody(obj.toString()) - - @Test fun fetchTranscript_ok() = runBlocking { - val p = connected() - val raw = p.fetchTranscript("rec-1") - assertEquals("auto text", JSONObject(raw).getString("text")) - } - - @Test fun fetchTranscript_missingIsNotFound() = runBlocking { - val p = connected() - try { - p.fetchTranscript("missing") - fail("expected NotFound") - } catch (e: ProviderError.NotFound) { - assertTrue(e.message!!.contains("missing")) - } - } - - @Test fun updateTranscript_roundTrip() = runBlocking { - val p = connected() - val raw = p.updateTranscript( - "rec-1", - com.shonar.recording.transcriptUpdatePayload("user corrected"), - ) - val json = JSONObject(raw) - assertEquals("user corrected", json.getString("text")) - assertEquals(2, json.getInt("version")) - assertEquals(true, json.getBoolean("edited_by_user")) - // server state actually moved - assertEquals("user corrected", JSONObject(p.fetchTranscript("rec-1")).getString("text")) - } - - @Test fun updateSummary_roundTrip() = runBlocking { - val p = connected() - val raw = p.updateSummary( - "rec-1", - com.shonar.recording.summaryUpdatePayload(mapOf("short" to "user short")), - ) - val json = JSONObject(raw) - assertEquals("user short", json.getJSONObject("content").getString("short")) - assertEquals(2, json.getInt("version")) - } - - @Test fun patchRecording_ok() = runBlocking { - val p = connected() - val raw = p.patchRecording( - "rec-1", - com.shonar.recording.recordingPatchPayload(title = "Renamed"), - ) - assertEquals("Renamed", JSONObject(raw).getString("title")) - } -} diff --git a/android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt deleted file mode 100644 index 4242708..0000000 --- a/android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -package com.shonar.provider - -import java.io.File -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test - -/** - * Auth lifecycle of CustomShonarProvider against the fake backend: - * persistence, transparent refresh, reuse-detection burn, revocation, - * restore, and purge. The storage happy path is covered by - * [CustomShonarProviderContractTest]. - */ -class CustomShonarProviderAuthTest { - - private val backend = FakeShonarBackend() - private lateinit var dir: File - private lateinit var store: com.shonar.settings.InMemorySettingsStore - - @Before fun setUp() { - backend.start() - dir = File(System.getProperty("java.io.tmpdir"), "custom-auth-${System.nanoTime()}").apply { mkdirs() } - store = com.shonar.settings.InMemorySettingsStore() - } - - @After fun tearDown() { - backend.stop() - dir.deleteRecursively() - } - - private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow() - - private fun loginCred() = ProviderCredential.ShonarLogin( - accountLabel = "test@example.com", - serverUrl = serverUrl(), - email = "test@example.com", - password = "correct-horse-battery", - ) - - private fun connectedProvider(): CustomShonarProvider = runBlocking { - val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars")) - p.connect(loginCred()) - p - } - - @Test fun login_persistsSessionSecurely() = runBlocking { - connectedProvider() - val saved = ShonarAuthStore(store).load() - assertNotNull(saved) - assertEquals(backend.url, saved!!.baseUrl) - assertEquals("test@example.com", saved.email) - assertEquals(backend.currentAccess, saved.accessToken) - assertEquals(backend.currentRefresh, saved.refreshToken) - // password must never be persisted - assertFalse(store.keys().flatMap { listOf(store.getString(it).orEmpty()) } - .any { it.contains("correct-horse-battery") }) - } - - @Test fun staleAccessToken_triggersSingleRefreshAndRetry() = runBlocking { - val p = connectedProvider() - // Simulate an expired access token without touching the refresh token. - val saved = ShonarAuthStore(store).load()!! - ShonarAuthStore(store).save(saved.copy(accessToken = "access-stale")) - val before = backend.accessSeq - - val page = p.list(null) // 401 -> refresh -> retry, transparently - assertTrue(page.items.isEmpty()) - assertEquals(before + 1, backend.accessSeq) - assertEquals(backend.currentAccess, ShonarAuthStore(store).load()!!.accessToken) - assertEquals(AuthState.CONNECTED, p.authState.value) - } - - @Test fun deadRefreshToken_burnsToExpired() = runBlocking { - val p = connectedProvider() - val saved = ShonarAuthStore(store).load()!! - // Both tokens unknown to the server (reuse-detection burn / revocation). - ShonarAuthStore(store).save(saved.copy(accessToken = "dead", refreshToken = "dead")) - - try { - p.list(null) - fail("dead tokens must raise AuthExpired") - } catch (expected: ProviderError.AuthExpired) { - } - assertEquals(AuthState.EXPIRED, p.authState.value) - // Dead tokens are dropped; URL + email stay for one-step re-login. - val after = ShonarAuthStore(store).load() - assertNull(after?.accessToken?.takeIf { it.isNotBlank() }) - } - - @Test fun disconnect_revokesServerSideAndClearsTokens() = runBlocking { - val p = connectedProvider() - val refresh = ShonarAuthStore(store).load()!!.refreshToken - p.disconnect(revokeOnServer = true) - assertEquals(AuthState.DISCONNECTED, p.authState.value) - assertTrue("logout must carry the refresh token", backend.logouts.contains(refresh)) - assertNull(ShonarAuthStore(store).load()?.refreshToken?.takeIf { it.isNotBlank() }) - } - - @Test fun disconnectWithoutRevoke_keepsServerSession() = runBlocking { - val p = connectedProvider() - p.disconnect(revokeOnServer = false) - assertEquals(AuthState.DISCONNECTED, p.authState.value) - assertTrue(backend.logouts.isEmpty()) - } - - @Test fun reconnect_restoresPersistedSession() = runBlocking { - connectedProvider() - // Fresh provider object, same secure store — like an app restart. - val p2 = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars")) - assertEquals(AuthState.CONNECTED, p2.reconnect()) - } - - @Test fun reconnect_withNothingStored_staysDisconnected() = runBlocking { - val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars")) - assertEquals(AuthState.DISCONNECTED, p.reconnect()) - } - - @Test fun deleteAccount_purgesServerSideAndWipesLocal() = runBlocking { - val p = connectedProvider() - p.deleteAccount("correct-horse-battery") - assertEquals(1, backend.deleteAccountCalls) - assertEquals(AuthState.DISCONNECTED, p.authState.value) - assertNull(ShonarAuthStore(store).load()) - } - - @Test fun operationsWhileDisconnected_throwNotConnected() = runBlocking { - val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars")) - try { - p.list(null) - fail("must throw NotConnected") - } catch (expected: ProviderError.NotConnected) { - } - } -} - -class ShonarAuthStoreTest { - - private val store = com.shonar.settings.InMemorySettingsStore() - private val auth = ShonarAuthStore(store) - - @Test fun save_load_roundtrip() = runBlocking { - auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 123L, "d1")) - val loaded = auth.load() - assertEquals("https://s.example.com", loaded!!.baseUrl) - assertEquals("a1", loaded.accessToken) - assertEquals("r1", loaded.refreshToken) - assertEquals("d1", loaded.deviceId) - } - - @Test fun load_empty_isNull() = runBlocking { - assertNull(auth.load()) - } - - @Test fun clearTokens_keepsUrlAndEmail() = runBlocking { - auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 1L, null)) - auth.clearTokens() - assertNull(auth.load()) // tokens gone -> no usable session - assertEquals("https://s.example.com", store.getString(ShonarAuthStore.KEY_BASE_URL)) - assertEquals("u@x.com", store.getString(ShonarAuthStore.KEY_EMAIL)) - } - - @Test fun clearAll_forgetsEverything() = runBlocking { - auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 1L, null)) - auth.clearAll() - assertNull(auth.load()) - assertNull(store.getString(ShonarAuthStore.KEY_BASE_URL)) - } -} - -class SyncStateTest { - - @Test fun queued_canStartUploading() { - assertTrue(SyncStatus(SyncState.QUEUED).canTransitionTo(SyncState.UPLOADING)) - } - - @Test fun uploading_canFinishOrFailButNotSkipToSynced() { - assertTrue(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.UPLOADED)) - assertTrue(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.ERROR)) - assertFalse(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.SYNCED)) - } - - @Test fun error_onlyRetriesOrGoesLocal() { - assertTrue(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.QUEUED)) - assertFalse(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.UPLOADING)) - assertFalse(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.SYNCED)) - } - - @Test fun synced_canReuploadAfterLocalEdit() { - assertTrue(SyncStatus(SyncState.SYNCED).canTransitionTo(SyncState.UPLOADING)) - assertFalse(SyncStatus(SyncState.SYNCED).canTransitionTo(SyncState.QUEUED)) - } -} diff --git a/android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt deleted file mode 100644 index 7d7816d..0000000 --- a/android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt +++ /dev/null @@ -1,315 +0,0 @@ -package com.shonar.provider - -import java.io.File -import java.util.UUID -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import org.json.JSONArray -import org.json.JSONObject -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * In-memory fake of the SHONAR backend's M1 (auth) + M2 (uploads/recordings) - * endpoints. Response shapes mirror the real schemas - * (`backend/shonar/api/schemas_*.py`) so the contract suite below exercises - * the same JSON the app will meet in production: - * - * - rotating refresh tokens with reuse detection (old refresh dies on use) - * - chunk sessions with resume status + idempotent finalize per - * client_recording_id - */ -internal class FakeShonarBackend { - - val server = MockWebServer() - - var accessSeq = 0 - var currentAccess = "access-0" - var currentRefresh = "refresh-0" - - data class Session( - val id: String, - val declaredSize: Long, - val mime: String, - val title: String?, - val clientRecordingId: String?, - val chunks: MutableMap = mutableMapOf(), - ) - - data class Recording( - val id: String, - val title: String, - val mime: String, - val bytes: ByteArray, - val durationSeconds: Double, - ) - - val sessions = mutableMapOf() - val recordings = mutableMapOf() // id -> recording - val finalizedByClientId = mutableMapOf() // client id -> recording id - val logouts = mutableListOf() - var deleteAccountCalls = 0 - - val url: String get() = server.url("/").toString().removeSuffix("/") - - fun start() { - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = - handle(request) - } - server.start() - } - - fun stop() = server.shutdown() - - private fun authed(request: RecordedRequest): Boolean = - request.getHeader("Authorization") == "Bearer $currentAccess" - - private fun handle(request: RecordedRequest): MockResponse { - val path = request.path?.substringBefore('?').orEmpty() - val method = request.method.orEmpty() - return when { - method == "GET" && path == "/api/v1/provider-info" -> json(200, JSONObject() - .put("kind", "shonar").put("version", "test") - .put("api_version", "v1") - .put("capabilities", JSONObject() - .put("chunked_upload", true).put("server_transcription", false) - .put("server_summary", false).put("account_deletion", true)) - .put("storage_backend", "local")) - - method == "POST" && path == "/api/v1/auth/login" -> { - val body = JSONObject(request.body.readUtf8()) - if (body.optString("email") == "test@example.com" && - body.optString("password") == "correct-horse-battery" - ) { - accessSeq++ - currentAccess = "access-$accessSeq" - currentRefresh = "refresh-$accessSeq" - json(200, tokenPair()) - } else { - json(401, JSONObject().put("detail", "Invalid credentials")) - } - } - - method == "POST" && path == "/api/v1/auth/refresh" -> { - val body = JSONObject(request.body.readUtf8()) - if (body.optString("refresh_token") == currentRefresh) { - accessSeq++ - currentAccess = "access-$accessSeq" - currentRefresh = "refresh-$accessSeq" - json(200, tokenPair()) - } else { - // reuse detection: unknown/rotated token burns the family - json(401, JSONObject().put("detail", "Invalid refresh token")) - } - } - - method == "POST" && path == "/api/v1/auth/logout" -> { - logouts += JSONObject(request.body.readUtf8()).optString("refresh_token") - MockResponse().setResponseCode(204) - } - - method == "POST" && path == "/api/v1/auth/delete-account" -> { - if (!authed(request)) return json(401, JSONObject().put("detail", "Nope")) - deleteAccountCalls++ - json(202, JSONObject().put("detail", "scheduled")) - } - - method == "GET" && path == "/api/v1/auth/me" -> - if (authed(request)) json(200, JSONObject() - .put("id", UUID.randomUUID().toString()).put("email", "test@example.com")) - else json(401, JSONObject().put("detail", "Invalid or expired token")) - - method == "POST" && path == "/api/v1/uploads" -> { - if (!authed(request)) return unauthorized() - val body = JSONObject(request.body.readUtf8()) - val id = UUID.randomUUID().toString() - sessions[id] = Session( - id = id, - declaredSize = body.getLong("declared_size_bytes"), - mime = body.getString("declared_mime_type"), - title = body.optString("title", null), - clientRecordingId = body.optString("client_recording_id", null) - .takeUnless { it.isNullOrBlank() }, - ) - json(201, JSONObject().put("id", id).put("status", "open") - .put("chunk_size_bytes", 64 * 1024) - .put("declared_mime_type", body.getString("declared_mime_type")) - .put("declared_size_bytes", body.getLong("declared_size_bytes"))) - } - - method == "GET" && path.startsWith("/api/v1/uploads/") -> { - if (!authed(request)) return unauthorized() - val id = path.removePrefix("/api/v1/uploads/") - val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone")) - json(200, JSONObject().put("id", id).put("status", "open") - .put("received_chunk_indexes", JSONArray(s.chunks.keys.sorted()))) - } - - method == "PUT" && "/chunks/" in path -> { - if (!authed(request)) return unauthorized() - val rest = path.removePrefix("/api/v1/uploads/") // {id}/chunks/{n} - val id = rest.substringBefore("/chunks/") - val idx = rest.substringAfter("/chunks/").toInt() - val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone")) - s.chunks[idx] = request.body.readByteArray() - json(201, JSONObject().put("chunk_index", idx) - .put("size_bytes", s.chunks[idx]!!.size)) - } - - method == "POST" && path.endsWith("/finalize") -> { - if (!authed(request)) return unauthorized() - val id = path.removePrefix("/api/v1/uploads/").removeSuffix("/finalize") - val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone")) - val total = s.chunks.toSortedMap().values.sumOf { it.size.toLong() } - if (total != s.declaredSize) { - return json(422, JSONObject().put("detail", "Size mismatch")) - } - val existing = s.clientRecordingId?.let { finalizedByClientId[it] } - if (existing != null) return json(201, recordingJson(recordings[existing]!!)) - val assembled = s.chunks.toSortedMap().values - .fold(byteArrayOf()) { acc, b -> acc + b } - val body = JSONObject(request.body.readUtf8()) - val rec = Recording( - id = UUID.randomUUID().toString(), - title = s.title ?: "Untitled recording", - mime = s.mime, - bytes = assembled, - durationSeconds = body.optDouble("duration_seconds", 0.0), - ) - recordings[rec.id] = rec - s.clientRecordingId?.let { finalizedByClientId[it] = rec.id } - json(201, recordingJson(rec)) - } - - method == "GET" && path == "/api/v1/recordings" -> { - if (!authed(request)) return unauthorized() - val q = request.path?.substringAfter('?', "").orEmpty() - .split('&').associate { - val (k, v) = it.split('=', limit = 2) + "" - k to v - } - val limit = q["limit"]?.toIntOrNull() ?: 50 - val offset = q["offset"]?.toIntOrNull() ?: 0 - val all = recordings.values.sortedByDescending { it.id } - val page = all.drop(offset).take(limit) - json(200, JSONObject() - .put("items", JSONArray(page.map { recordingJson(it) })) - .put("total", all.size).put("limit", limit).put("offset", offset)) - } - - method == "GET" && path.endsWith("/audio") -> { - if (!authed(request)) return unauthorized() - val id = path.removePrefix("/api/v1/recordings/").removeSuffix("/audio") - val rec = recordings[id] ?: return json(404, JSONObject().put("detail", "gone")) - MockResponse().setResponseCode(200) - .setHeader("Content-Type", rec.mime) - .setBody(okio.Buffer().write(rec.bytes)) - } - - method == "DELETE" && path.startsWith("/api/v1/recordings/") -> { - if (!authed(request)) return unauthorized() - val id = path.removePrefix("/api/v1/recordings/") - if (recordings.remove(id) == null) return json(404, JSONObject().put("detail", "gone")) - finalizedByClientId.entries.removeIf { it.value == id } - MockResponse().setResponseCode(204) - } - - else -> json(404, JSONObject().put("detail", "unknown $method $path")) - } - } - - private fun unauthorized() = json(401, JSONObject().put("detail", "Invalid or expired token")) - - private fun tokenPair() = JSONObject() - .put("access_token", currentAccess).put("token_type", "bearer") - .put("expires_in", 900).put("refresh_token", currentRefresh) - - private fun recordingJson(r: Recording) = JSONObject() - .put("id", r.id).put("title", r.title) - .put("recorded_at", "2026-01-02T03:04:05Z") - .put("duration_seconds", r.durationSeconds) - .put("has_audio", true).put("mime_type", r.mime).put("size_bytes", r.bytes.size) - - private fun json(code: Int, obj: JSONObject) = MockResponse() - .setResponseCode(code).setHeader("Content-Type", "application/json") - .setBody(obj.toString()) -} - -/** Runs the shared provider contract suite against CustomShonarProvider. */ -class CustomShonarProviderContractTest : ProviderContractTest() { - - private val backend = FakeShonarBackend() - private lateinit var dir: File - private lateinit var store: com.shonar.settings.InMemorySettingsStore - - @org.junit.Before fun setUp() { - backend.start() - dir = File(System.getProperty("java.io.tmpdir"), "custom-contract-${System.nanoTime()}").apply { mkdirs() } - store = com.shonar.settings.InMemorySettingsStore() - } - - @After fun tearDown() { - backend.stop() - dir.deleteRecursively() - } - - private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow() - - override fun initialCredential(): ProviderCredential = ProviderCredential.ShonarLogin( - accountLabel = "test@example.com", - serverUrl = serverUrl(), - email = "test@example.com", - password = "correct-horse-battery", - ) - - override suspend fun makeProvider(): ShonarProvider = CustomShonarProvider( - auth = ShonarAuthStore(store), - sidecarRoot = File(dir, "sidecars"), - ) - - override suspend fun makeDraft(id: String): RecordingDraft { - val src = File(dir, "src-$id.m4a") - // mp4 magic (ftyp at [4:8]) like backend/tests/test_recordings.py mp4_bytes() - val rnd = java.util.Random(id.hashCode().toLong()) - val payload = ByteArray(256 * 1024).also { rnd.nextBytes(it) } - src.writeBytes(byteArrayOf(0, 0, 0, 0x20.toByte()) + "ftypM4A ".toByteArray() + payload) - return RecordingDraft( - id = id, - title = "Contract recording", - createdAtEpochMs = 1_700_000_000_000, - durationMs = 12_345, - mime = "audio/mp4", - sourceFile = src, - sizeBytes = src.length(), - ) - } - - override fun credentialSecretStrings(cred: ProviderCredential): List = - super.credentialSecretStrings(cred) - - @Test fun probe_identifiesShonarServer() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - val probe = p.probe(serverUrl()) - assertTrue(probe is ProbeResult.Compatible) - assertEquals(ProviderRegistry.CUSTOM_SHONAR_ID, (probe as ProbeResult.Compatible).descriptor.id) - } - - @Test fun badPassword_loginRefusesWithoutLeaking() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - val bad = ProviderCredential.ShonarLogin("t", serverUrl(), "test@example.com", "wrong-pw-123") - try { - p.connect(bad) - org.junit.Assert.fail("bad password must not connect") - } catch (e: ProviderError.Transient) { - assertFalse(e.message!!.contains("wrong-pw-123")) - } - } -} diff --git a/android/app/src/test/java/com/shonar/provider/FolderSyncCreateTest.kt b/android/app/src/test/java/com/shonar/provider/FolderSyncCreateTest.kt deleted file mode 100644 index 63b6b2f..0000000 --- a/android/app/src/test/java/com/shonar/provider/FolderSyncCreateTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.shonar.provider - -import java.io.File -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test - -/** Folder creation: explicit, leaf-only, typo-safe. Strict connect is untouched. */ -class FolderSyncCreateTest { - - private lateinit var base: File - private lateinit var store: com.shonar.settings.InMemorySettingsStore - - @Before fun setUp() { - base = File(System.getProperty("java.io.tmpdir"), "sync-create-${System.nanoTime()}") - .apply { mkdirs() } - store = com.shonar.settings.InMemorySettingsStore() - } - - @After fun tearDown() { - base.deleteRecursively() - } - - private fun provider() = FolderSyncProvider(pathStore = store) - - @Test fun createRoot_createsLeafAndConnects() = runBlocking { - val p = provider() - val leaf = File(base, "SHONAR-new").path - p.createRoot(leaf) - assertTrue(File(leaf).isDirectory) - assertEquals(AuthState.CONNECTED, p.authState.value) - // second call is just a connect - p.createRoot(leaf) - assertEquals(AuthState.CONNECTED, p.authState.value) - } - - @Test fun createRoot_refusesMissingParent() = runBlocking { - val p = provider() - try { - p.createRoot(File(base, "no-such-parent/leaf").path) - fail("missing parent must not create deep trees") - } catch (e: ProviderError.InvalidUrl) { - assertTrue(e.message!!.contains("Parent")) - } - assertTrue(p.authState.value != AuthState.CONNECTED) - } - - @Test fun createRoot_refusesTraversal() = runBlocking { - val p = provider() - try { - p.createRoot(base.path + "/../escape") - fail(".. must be rejected") - } catch (e: ProviderError.InvalidUrl) { - assertTrue(e.message!!.contains("..")) - } - } - - @Test fun connect_stillRefusesMissing() = runBlocking { - val p = provider() - try { - p.connect(ProviderCredential.FolderPath("x", File(base, "missing").path)) - fail("strict connect must keep refusing") - } catch (e: ProviderError.InvalidUrl) { - assertTrue(e.message!!.contains("does not exist")) - } - } -} diff --git a/android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt b/android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt deleted file mode 100644 index d58a4e0..0000000 --- a/android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.shonar.provider - -import java.io.File -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test - -/** Runs the shared provider contract suite against FolderSyncProvider. */ -class FolderSyncProviderContractTest : ProviderContractTest() { - - private lateinit var root: File - private lateinit var store: com.shonar.settings.InMemorySettingsStore - - @Before fun setUp() { - root = File(System.getProperty("java.io.tmpdir"), "foldersync-${System.nanoTime()}") - .apply { mkdirs() } - store = com.shonar.settings.InMemorySettingsStore() - } - - @After fun tearDown() { - root.deleteRecursively() - } - - override fun initialCredential(): ProviderCredential = - ProviderCredential.FolderPath(accountLabel = root.path, path = root.path) - - override suspend fun makeProvider(): ShonarProvider = FolderSyncProvider(store) - - override suspend fun makeDraft(id: String): RecordingDraft { - val src = File.createTempFile("draft-$id", ".bin") - val rnd = java.util.Random(7) - src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) }) - src.deleteOnExit() - return RecordingDraft( - id = id, - title = "Contract recording", - createdAtEpochMs = 1_700_000_000_000, - durationMs = 12_345, - mime = "audio/mp4", - sourceFile = src, - sizeBytes = src.length(), - ) - } - - @Test fun refs_carrySyncFolderId() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - p.connect(initialCredential()) - val ref = p.upload(makeDraft("dddddddd-dddd-4ddd-8ddd-dddddddddddd")) { } - assertEquals(FolderSyncProvider.ID, ref.providerId) - assertEquals(1, p.list(null).items.count { it.ref.providerId == FolderSyncProvider.ID }) - } - - @Test fun missingFolder_refusedNotCreated() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - val missing = File(root, "not-there") - try { - p.connect(ProviderCredential.FolderPath("x", missing.path)) - fail("missing folder must be refused") - } catch (expected: ProviderError.InvalidUrl) { - } - assertFalse("a typo must never create directories", missing.exists()) - } - - @Test fun traversal_refused() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - try { - p.connect(ProviderCredential.FolderPath("x", root.path + "/../evil")) - fail(".. must be refused") - } catch (expected: ProviderError.InvalidUrl) { - } - } - - @Test fun deleteAccount_forgetsButKeepsFiles() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - p.connect(initialCredential()) - val ref = p.upload(makeDraft("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")) { } - val onDisk = File(root, ref.key) - assertTrue(onDisk.exists()) - p.deleteAccountAndData() - // The files stay — they belong to the user and their sync tool. - assertTrue("user files must survive disconnect", onDisk.exists()) - assertEquals(AuthState.DISCONNECTED, p.authState.value) - assertEquals(AuthState.DISCONNECTED, p.reconnect()) - } - - @Test fun reconnect_restoresPersistedFolder() = kotlinx.coroutines.runBlocking { - makeProvider().connect(initialCredential()) - val p2 = makeProvider() - assertEquals(AuthState.CONNECTED, p2.reconnect()) - } - - @Test fun wrongCredentialType_rejected() = kotlinx.coroutines.runBlocking { - try { - makeProvider().connect(ProviderCredential.None) - fail("None must be rejected") - } catch (expected: ProviderError.InvalidUrl) { - } - } -} diff --git a/android/app/src/test/java/com/shonar/provider/LocalOnlyFolderTest.kt b/android/app/src/test/java/com/shonar/provider/LocalOnlyFolderTest.kt deleted file mode 100644 index 1b4eea5..0000000 --- a/android/app/src/test/java/com/shonar/provider/LocalOnlyFolderTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.shonar.provider - -import java.io.File -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test - -/** Local-only with a user-picked folder: files land there, deletion never touches it. */ -class LocalOnlyFolderTest { - - private lateinit var appDir: File - private lateinit var userDir: File - private lateinit var store: com.shonar.settings.InMemorySettingsStore - - @Before fun setUp() { - val base = File(System.getProperty("java.io.tmpdir"), "local-folder-${System.nanoTime()}") - appDir = File(base, "app").apply { mkdirs() } - userDir = File(base, "user").apply { mkdirs() } - store = com.shonar.settings.InMemorySettingsStore() - } - - @After fun tearDown() { - appDir.deleteRecursively() - userDir.deleteRecursively() - } - - private fun provider() = LocalOnlyProvider(appDir, pathStore = store) - - private fun draft(id: String, dir: File): RecordingDraft { - val src = File(dir, "src-$id.bin") - src.writeBytes(ByteArray(1024) { it.toByte() }) - return RecordingDraft(id, "t", 1L, 1000L, "audio/mp4", src, src.length()) - } - - @Test fun defaultRoot_unchangedWithoutPick() = runBlocking { - val p = provider() - p.connect(ProviderCredential.None) - val ref = p.upload(draft("a", appDir)) { } - assertTrue(File(appDir, ref.key).exists()) - assertEquals("On this device only", p.storageLocationSummary().headline) - } - - @Test fun pickedFolder_receivesUploads() = runBlocking { - val p = provider() - p.connect(ProviderCredential.FolderPath("mine", userDir.path)) - val ref = p.upload(draft("b", appDir)) { } - assertTrue(File(userDir, ref.key).exists()) - assertFalse(File(appDir, "audio").exists()) - val summary = p.storageLocationSummary() - assertEquals("Your folder", summary.headline) - assertEquals(userDir.canonicalPath, java.io.File(summary.path!!).canonicalPath) - } - - @Test fun deleteAccount_keepsUserFolder() = runBlocking { - val p = provider() - p.connect(ProviderCredential.FolderPath("mine", userDir.path)) - p.upload(draft("c", appDir)) { } - p.deleteAccountAndData() - assertTrue("user files must survive", File(userDir, "audio").exists()) - } - - @Test fun createRoot_leafOnly() = runBlocking { - val p = provider() - val leaf = File(userDir, "SHONAR-new").path - p.createRoot(leaf) - assertTrue(File(leaf).isDirectory) - try { - p.createRoot(File(userDir, "no-parent/leaf").path) - fail("missing parent must fail") - } catch (e: ProviderError.InvalidUrl) { - assertTrue(e.message!!.contains("Parent")) - } - } - - @Test fun useDefaultRoot_forgetsPick() = runBlocking { - val p = provider() - p.connect(ProviderCredential.FolderPath("mine", userDir.path)) - p.useDefaultRoot() - val ref = p.upload(draft("d", appDir)) { } - assertTrue(File(appDir, ref.key).exists()) - } -} diff --git a/android/app/src/test/java/com/shonar/provider/LocalOnlyProviderContractTest.kt b/android/app/src/test/java/com/shonar/provider/LocalOnlyProviderContractTest.kt deleted file mode 100644 index 905d552..0000000 --- a/android/app/src/test/java/com/shonar/provider/LocalOnlyProviderContractTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.shonar.provider - -import org.junit.Assert.assertEquals -import org.junit.Test -import java.io.File - -/** Runs the shared contract suite against LocalOnlyProvider. */ -class LocalOnlyProviderContractTest : ProviderContractTest() { - - private val root: File = createTempDirSafe("shonar-contract") - - private fun createTempDirSafe(prefix: String): File = - File(System.getProperty("java.io.tmpdir"), prefix + "-" + System.nanoTime()).apply { mkdirs() } - - override suspend fun makeProvider(): ShonarProvider = LocalOnlyProvider(root) - - override suspend fun makeDraft(id: String): RecordingDraft { - val src = File(root, "src-$id.bin") - // deterministic pseudo-audio payload, ~256 KB - val rnd = java.util.Random(42) - src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) }) - return RecordingDraft( - id = id, - title = "Contract recording", - createdAtEpochMs = 1_700_000_000_000, - durationMs = 12_345, - mime = "audio/mp4", - sourceFile = src, - sizeBytes = src.length(), - ) - } - - override suspend fun cleanup() { - root.deleteRecursively() - } - - // ---- local-only specifics --------------------------------------------------- - - @Test - fun localOnly_probe_isIncompatible_neverTouchesNetwork() = kotlinx.coroutines.runBlocking { - val p = LocalOnlyProvider(root) - val url = ServerUrl.parse("https://example.com")!!.getOrThrow() - assertEquals(ProbeResult.Incompatible, p.probe(url)) - } - - @Test - fun localOnly_rejectsCredentials() = kotlinx.coroutines.runBlocking { - val p = LocalOnlyProvider(root) - try { - p.connect(ProviderCredential.AppPassword("a@b", "https://x", "u", "hunter2")) - org.junit.Assert.fail("local-only must not accept credentials") - } catch (expected: ProviderError.InvalidUrl) { - } - } - - @Test - fun deleteAccountAndData_wipesRoot() = kotlinx.coroutines.runBlocking { - val p = LocalOnlyProvider(root) - p.connect(ProviderCredential.None) - val draft = makeDraft("99999999-9999-4999-8999-999999999999") - p.upload(draft) { } - org.junit.Assert.assertTrue(File(root, "audio").exists()) - p.deleteAccountAndData() - org.junit.Assert.assertFalse("local data must be wiped", root.exists()) - } -} diff --git a/android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt b/android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt deleted file mode 100644 index abe392b..0000000 --- a/android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt +++ /dev/null @@ -1,211 +0,0 @@ -package com.shonar.provider - -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import org.junit.After -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test - -/** - * P5 leak tests (docs/server-providers.md §4, §8): with debug logging ON, - * no credential, token, audio byte, or transcript may reach the log sink; - * with it OFF, nothing logs at all. - */ -class RedactingLoggerTest { - - private lateinit var server: MockWebServer - private val logs = mutableListOf() - private var bodiesOn = false - - @Before fun setUp() { - server = MockWebServer() - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = - MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("""{"access_token":"resp-token-abc","ok":true}""") - } - server.start() - logs.clear() - bodiesOn = false - } - - @After fun tearDown() { - server.shutdown() - } - - private fun client(): OkHttpClient = OkHttpClient.Builder() - .addInterceptor(RedactingLogger(bodiesEnabled = { bodiesOn }, sink = logs::add)) - .build() - - private fun joined(): String = logs.joinToString("\n") - - @Test fun off_byDefault_logsNothing() { - client().newCall(Request.Builder().url(server.url("/x")).get().build()) - .execute().close() - assertTrue("logging off must be silent", logs.isEmpty()) - } - - @Test fun on_logsSummaryButRedactsAuthHeader() { - bodiesOn = true - client().newCall( - Request.Builder().url(server.url("/api/v1/auth/me")).get() - .header("Authorization", "Bearer header-secret-123").build() - ).execute().close() - val out = joined() - assertTrue(out.contains("GET") && out.contains("200")) - assertTrue("header name stays, value goes", out.contains("Authorization: [redacted]")) - assertFalse(out.contains("header-secret-123")) - } - - @Test fun on_redactsTokenJsonAndBasic() { - bodiesOn = true - val body = """{"email":"a@b.c","password":"pw-secret-1","refresh_token":"rt-secret-2"}""" - client().newCall( - Request.Builder().url(server.url("/api/v1/auth/login")) - .post(body.toRequestBody("application/json".toMediaType())) - .header("Authorization", "Basic dGVzdHVzZXI6YXBwLXBhc3M=").build() - ).execute().close() - val out = joined() - assertFalse(out.contains("pw-secret-1")) - assertFalse(out.contains("rt-secret-2")) - assertFalse("response token must be masked too", out.contains("resp-token-abc")) - assertFalse(out.contains("dGVzdHVzZXI6YXBwLXBhc3M=")) - assertTrue(out.contains("***")) - } - - @Test fun on_neverLogsAudioBytes() { - bodiesOn = true - val marker = "LEAKMARKER-AUDIO-0123456789".toByteArray() - val payload = marker + ByteArray(1024) { 0x7F } - client().newCall( - Request.Builder().url(server.url("/put")) - .put(payload.toRequestBody("audio/mp4".toMediaType())).build() - ).execute().close() - assertFalse("audio marker must not appear", joined().contains("LEAKMARKER-AUDIO")) - assertTrue(joined().contains("not logged")) - } - - @Test fun on_neverLogsBigJson() { - bodiesOn = true - val big = """{"blob":"${"x".repeat(9000)}"}""" - client().newCall( - Request.Builder().url(server.url("/big")) - .post(big.toRequestBody("application/json".toMediaType())).build() - ).execute().close() - assertTrue(joined().contains("not logged")) - } - - @Test fun sensitiveFlag_strippedAndSkipsBodies() { - bodiesOn = true - // JSON body that WOULD log (small, loggable type) is suppressed by - // the flag, and the flag itself never reaches the wire. - val transcript = """{"text":"LEAKMARKER-TRANSCRIPT-transcribed words here"}""" - client().newCall( - Request.Builder().url(server.url("/sidecar")) - .put(transcript.toRequestBody("application/json".toMediaType())) - .header(RedactingLogger.SENSITIVE_BODY, "1").build() - ).execute().close() - val out = joined() - assertFalse(out.contains("LEAKMARKER-TRANSCRIPT")) - // Response body (echo JSON with token) is suppressed too. - assertFalse(out.contains("resp-token-abc")) - assertTrue(out.contains("[sensitive, not logged]")) - val sent = server.takeRequest() - assertNull("flag must be stripped before sending", sent.getHeader(RedactingLogger.SENSITIVE_BODY)) - } - - @Test fun failure_logsClassOnly() { - bodiesOn = true - try { - // Nothing listens on this port: fast refusal, no secrets involved. - client().newCall(Request.Builder().url("http://127.0.0.1:1/unreachable").get().build()) - .execute().close() - } catch (ignored: Exception) { - } - assertTrue(joined().contains("failed (")) - } -} - -/** - * Provider-level leak test: a full Nextcloud upload + sidecar + download - * with debug logging ON must leave no credential, audio, or transcript - * material in the sink. - */ -class ProviderLeakTest { - - private val backend = FakeNextcloud() - private lateinit var store: com.shonar.settings.InMemorySettingsStore - private val logs = mutableListOf() - - private val audioMarker = "LEAKMARKER-AUDIO-555" - private val transcriptMarker = "LEAKMARKER-TRANSCRIPT-777" - - @Before fun setUp() { - backend.start() - store = com.shonar.settings.InMemorySettingsStore() - logs.clear() - } - - @After fun tearDown() { - backend.stop() - } - - @Test fun nextcloudFlow_leaksNothing() = kotlinx.coroutines.runBlocking { - // Distinctive markers stand in for real secrets/audio/transcripts; - // the fake's fixed creds exercise the same code paths. - val tls = TlsPolicy( - tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())), - bodiesEnabled = { true }, - sink = logs::add, - ) - val p = NextcloudProvider( - auth = NextcloudAuthStore(store), - client = tls.nextcloudClient(), - loginFlow = NextcloudAuth(tls.nextcloudClient(), tls.tofu), - chunkSizeBytes = 64 * 1024, - ) - p.connect( - ProviderCredential.AppPassword( - "t", backend.url, backend.userId, backend.appPassword - ) - ) - val dir = java.io.File(System.getProperty("java.io.tmpdir"), "leak-${System.nanoTime()}") - .apply { mkdirs() } - try { - val payload = (audioMarker + "|").toByteArray() + ByteArray(256 * 1024 - 32) { 0x3C } - val src = java.io.File(dir, "s.m4a").apply { writeBytes(payload) } - val draft = RecordingDraft( - "ffffffff-ffff-4fff-8fff-ffffffffffff", "t", 1_700_000_000_000, - 1000, "audio/mp4", src, src.length(), - ) - val ref = p.upload(draft) { } - p.putSidecar( - ref, SidecarKind.TRANSCRIPT, - """{"text":"$transcriptMarker transcribed words"}""".toByteArray(), - ) - val dest = java.io.File(dir, "out.m4a") - p.download(ref, dest) { } - val out = logs.joinToString("\n") - assertFalse("audio bytes in logs", out.contains(audioMarker)) - assertFalse("transcript in logs", out.contains(transcriptMarker)) - assertFalse("app password in logs", out.contains(backend.appPassword)) - assertFalse("basic credentials in logs", - out.contains(java.util.Base64.getEncoder().encodeToString( - "${backend.userId}:${backend.appPassword}".toByteArray() - ))) - assertTrue("something logged (logger was on)", out.isNotEmpty()) - } finally { - dir.deleteRecursively() - } - } -} diff --git a/android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt b/android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt deleted file mode 100644 index 6e06fa7..0000000 --- a/android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt +++ /dev/null @@ -1,531 +0,0 @@ -package com.shonar.provider - -import java.io.File -import java.util.Base64 -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import okio.Buffer -import org.json.JSONObject -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test - -/** - * In-memory fake of a Nextcloud server: status.php, login flow v2, OCS - * user/quota, and the WebDAV file + chunking-v2 APIs. Shapes mirror the - * official developer manual (chunk names 1..10000 assembled in name order, - * `OC-Total-Length`, `MOVE …/.file` + `Destination`): - * - * - MKCOL {dav}uploads/{user}/{transfer}/ (405 when present) - * - PUT {dav}uploads/{user}/{transfer}/{00001..} (201) - * - MOVE {dav}uploads/{user}/{transfer}/.file + Destination (201) - * - PROPFIND Depth:1 multistatus, hrefs percent-encoded like the real thing - */ -internal class FakeNextcloud { - - val server = MockWebServer() - - var productName = "Nextcloud" - var versionString = "30.0.0" - val userId = "testuser" - val appPassword = "app-pass-123" - var quotaFree = 10L * 1024 * 1024 * 1024 - var quotaTotal = 50L * 1024 * 1024 * 1024 - - /** Decoded DAV paths -> bytes. Dirs end with '/'. */ - val files = mutableMapOf() - val dirs = mutableSetOf() - - var pollApproved = false - val pollToken = "poll-token-1" - - var chunkPuts = 0 - val chunkNames = mutableListOf() - val chunkHeaders = mutableListOf>() - var lastMove: Map = emptyMap() - var revokeCalls = 0 - var failNextPut507 = false - - val url: String get() = server.url("/").toString().removeSuffix("/") - private val davRoot get() = "/remote.php/dav/files/$userId/" - private val uploadsRoot get() = "/remote.php/dav/uploads/$userId/" - - fun start() { - dirs += davRoot - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = - handle(request) - } - server.start() - } - - fun stop() = server.shutdown() - - private fun basicOk(request: RecordedRequest): Boolean { - val expect = "Basic " + Base64.getEncoder() - .encodeToString("$userId:$appPassword".toByteArray(Charsets.UTF_8)) - return request.getHeader("Authorization") == expect - } - - private fun dec(path: String): String = runCatching { - java.net.URLDecoder.decode(path, "UTF-8") - }.getOrDefault(path) - - private fun encPath(path: String): String = path.split('/') - .joinToString("/") { - java.net.URLEncoder.encode(it, "UTF-8").replace("+", "%20") - } - - private fun parentOf(dir: String): String { - val t = dir.trimEnd('/') - return t.substringBeforeLast('/', "") + "/" - } - - private fun handle(request: RecordedRequest): MockResponse { - val rawPath = request.path?.substringBefore('?').orEmpty() - val path = dec(rawPath) - val method = request.method.orEmpty() - return when { - method == "GET" && path == "/status.php" -> json(200, JSONObject() - .put("productname", productName) - .put("versionstring", versionString)) - - method == "POST" && path == "/index.php/login/v2" -> json(200, JSONObject() - .put("poll", JSONObject() - .put("token", pollToken) - .put("endpoint", "$url/index.php/login/v2/poll")) - .put("login", "$url/login/v2/flow/abc123")) - - method == "POST" && path == "/index.php/login/v2/poll" -> { - val token = runCatching { JSONObject(request.body.readUtf8()).optString("token") } - .getOrDefault("") - if (token == pollToken && pollApproved) { - json(200, JSONObject() - .put("server", url) - .put("loginName", userId) - .put("appPassword", appPassword)) - } else { - json(404, JSONObject()) - } - } - - path == "/ocs/v2.php/cloud/user" -> { - if (!basicOk(request)) return json(401, JSONObject().put("message", "Unauthorized")) - json(200, JSONObject().put("ocs", JSONObject().put("data", JSONObject() - .put("id", userId) - .put("display-name", "Test User") - .put("quota", JSONObject() - .put("free", quotaFree).put("used", quotaTotal - quotaFree) - .put("total", quotaTotal).put("relative", 80.0))))) - } - - method == "DELETE" && path == "/ocs/v2.php/core/apppassword" -> { - if (!basicOk(request)) return json(401, JSONObject().put("message", "Unauthorized")) - revokeCalls++ - json(200, JSONObject()) - } - - // ---- chunked upload v2 ------------------------------------------------- - method == "MKCOL" && path.startsWith(uploadsRoot) -> { - if (!basicOk(request)) return MockResponse().setResponseCode(401) - val dir = path.trimEnd('/') + '/' - if (dir in dirs) return MockResponse().setResponseCode(405) - dirs += dir - MockResponse().setResponseCode(201) - } - - method == "PUT" && path.startsWith(uploadsRoot) -> { - if (!basicOk(request)) return MockResponse().setResponseCode(401) - if (failNextPut507) { - failNextPut507 = false - return MockResponse().setResponseCode(507) - } - val dir = parentOf(path) - if (dir !in dirs) return MockResponse().setResponseCode(404) - files[path] = request.body.readByteArray() - chunkPuts++ - chunkNames += path.substringAfterLast('/') - chunkHeaders += mapOf( - "OC-Total-Length" to request.getHeader("OC-Total-Length"), - "Destination" to request.getHeader("Destination"), - ) - MockResponse().setResponseCode(201) - } - - method == "MOVE" && path.startsWith(uploadsRoot) && path.endsWith("/.file") -> { - if (!basicOk(request)) return MockResponse().setResponseCode(401) - lastMove = mapOf( - "Destination" to request.getHeader("Destination"), - "Overwrite" to request.getHeader("Overwrite"), - "OC-Total-Length" to request.getHeader("OC-Total-Length"), - ) - val destUrl = request.getHeader("Destination") ?: return MockResponse().setResponseCode(400) - val destPath = dec(runCatching { java.net.URI(destUrl).path }.getOrDefault(destUrl)) - // Assemble transfer chunks in name order (server semantics). - val transferDir = path.removeSuffix(".file") - val chunks = files.keys.filter { it.startsWith(transferDir) && it != path } - .sortedBy { it.substringAfterLast('/') } - if (chunks.isEmpty()) return MockResponse().setResponseCode(404) - val assembled = chunks.fold(byteArrayOf()) { acc, k -> acc + files[k]!! } - files[destPath] = assembled - MockResponse().setResponseCode(201) - } - - // ---- plain WebDAV ------------------------------------------------------ - method == "MKCOL" && path.startsWith(davRoot) -> { - if (!basicOk(request)) return MockResponse().setResponseCode(401) - val dir = path.trimEnd('/') + '/' - if (dir in dirs) return MockResponse().setResponseCode(405) - if (parentOf(dir) !in dirs) return MockResponse().setResponseCode(409) - dirs += dir - MockResponse().setResponseCode(201) - } - - method == "PUT" && path.startsWith(davRoot) -> { - if (!basicOk(request)) return MockResponse().setResponseCode(401) - if (parentOf(path) !in dirs && parentOf(path) != davRoot) { - return MockResponse().setResponseCode(409) - } - files[path] = request.body.readByteArray() - MockResponse().setResponseCode(201) - } - - method == "GET" && path.startsWith(davRoot) -> { - if (!basicOk(request)) return MockResponse().setResponseCode(401) - val bytes = files[path] ?: return MockResponse().setResponseCode(404) - MockResponse().setResponseCode(200) - .setHeader("Content-Type", "audio/mp4") - .setBody(Buffer().write(bytes)) - } - - method == "DELETE" && path.startsWith(davRoot) -> { - if (!basicOk(request)) return MockResponse().setResponseCode(401) - // Collections may arrive with or without the trailing slash; - // real servers treat both the same. - val asDir = path.trimEnd('/') + '/' - val fileGone = files.remove(path) != null - val dirGone = if (asDir in dirs) { - dirs.removeIf { it == asDir || it.startsWith(asDir) } - files.keys.removeIf { it.startsWith(asDir) } - true - } else false - if (!fileGone && !dirGone) return MockResponse().setResponseCode(404) - MockResponse().setResponseCode(204) - } - - method == "DELETE" && path.startsWith(uploadsRoot) -> { - if (!basicOk(request)) return MockResponse().setResponseCode(401) - files.keys.removeIf { it.startsWith(path) } - dirs.removeIf { it == path || it.startsWith(path) } - MockResponse().setResponseCode(204) - } - - method == "PROPFIND" -> { - if (!basicOk(request)) return MockResponse().setResponseCode(401) - val dir = if (path.endsWith('/')) path else "$path/" - val children = mutableListOf() - // Self first, then direct children — like the real server. - if (dir in dirs || dir == davRoot) children += dir - val seen = mutableSetOf() - for (d in dirs) { - if (d != dir && parentOf(d) == dir && seen.add(d)) children += d - } - for (f in files.keys) { - val d = parentOf(f) - if (d == dir && seen.add(f)) children += f - } - if (children.isEmpty()) return MockResponse().setResponseCode(404) - val items = children.joinToString("") { child -> - val isDir = child.endsWith('/') - val size = files[child]?.size ?: 0 - """${encPath(child)}""" + - (if (!isDir) "$size" else "") + - (if (!isDir) """"etag-${child.hashCode()}"""" else "") + - (if (!isDir) """audio/mp4""" else "") + - """Tue, 02 Jan 2026 03:04:05 GMT""" + - (if (isDir) "" else "") + - """HTTP/1.1 200 OK""" - } - MockResponse().setResponseCode(207) - .setHeader("Content-Type", "application/xml; charset=utf-8") - .setBody("""$items""") - } - - path.startsWith(uploadsRoot) -> MockResponse().setResponseCode(404) - - else -> json(404, JSONObject().put("message", "unknown $method $rawPath")) - } - } - - private fun json(code: Int, obj: JSONObject) = MockResponse() - .setResponseCode(code).setHeader("Content-Type", "application/json") - .setBody(obj.toString()) -} - -/** Runs the shared provider contract suite against NextcloudProvider. */ -class NextcloudProviderContractTest : ProviderContractTest() { - - private val backend = FakeNextcloud() - private lateinit var dir: File - private lateinit var store: com.shonar.settings.InMemorySettingsStore - - @Before fun setUp() { - backend.start() - dir = File(System.getProperty("java.io.tmpdir"), "nc-contract-${System.nanoTime()}").apply { mkdirs() } - store = com.shonar.settings.InMemorySettingsStore() - } - - @After fun tearDown() { - backend.stop() - dir.deleteRecursively() - } - - private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow() - - override fun initialCredential(): ProviderCredential = ProviderCredential.AppPassword( - accountLabel = "testuser", - loginUrl = backend.url, - user = backend.userId, - password = backend.appPassword, - ) - - override suspend fun makeProvider(): ShonarProvider = NextcloudProvider( - auth = NextcloudAuthStore(store), - chunkSizeBytes = 64 * 1024, // 4 chunks per 256 KiB draft - ) - - override suspend fun makeDraft(id: String): RecordingDraft { - val src = File(dir, "src-$id.m4a") - val rnd = java.util.Random(id.hashCode().toLong()) - src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) }) - return RecordingDraft( - id = id, - title = "Contract recording", - createdAtEpochMs = 1_700_000_000_000, - durationMs = 12_345, - mime = "audio/mp4", - sourceFile = src, - sizeBytes = src.length(), - ) - } - - @Test fun probe_identifiesNextcloud() = kotlinx.coroutines.runBlocking { - val probe = makeProvider().probe(serverUrl()) - assertTrue(probe is ProbeResult.Compatible) - probe as ProbeResult.Compatible - assertEquals(ProviderRegistry.NEXTCLOUD_ID, probe.descriptor.id) - assertTrue(probe.descriptor.isDefault) - assertTrue( - ProviderDescriptor.Capability.CHUNKED_UPLOAD in probe.descriptor.capabilities - ) - } - - @Test fun probe_rejectsNonNextcloud() = kotlinx.coroutines.runBlocking { - backend.productName = "ownCloud" - assertEquals(ProbeResult.Incompatible, makeProvider().probe(serverUrl())) - } - - @Test fun wrongAppPassword_failsWithoutLeaking() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - try { - p.connect( - ProviderCredential.AppPassword("t", backend.url, backend.userId, "wrong-pass-1") - ) - fail("wrong app password must not connect") - } catch (e: ProviderError.Transient) { - assertFalse(e.message!!.contains("wrong-pass-1")) - } - } - - @Test fun upload_usesNumberedChunksAndMoveAssembly() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - p.connect(initialCredential()) - val draft = makeDraft("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") - p.upload(draft) { } - // 4 chunks named 00001..00004, each carrying quota + destination headers. - assertEquals(listOf("00001", "00002", "00003", "00004"), backend.chunkNames) - assertTrue(backend.chunkHeaders.all { it["OC-Total-Length"] == draft.sizeBytes.toString() }) - assertTrue(backend.chunkHeaders.all { (it["Destination"] ?: "").endsWith(".m4a") }) - // Assembly: MOVE …/.file with Destination + Overwrite + total length. - assertTrue(backend.lastMove["Destination"]!!.endsWith("SHONAR/audio/${draft.id}.m4a")) - assertEquals("T", backend.lastMove["Overwrite"]) - assertEquals(draft.sizeBytes.toString(), backend.lastMove["OC-Total-Length"]) - } - - @Test fun reupload_skipsPresentChunks() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - p.connect(initialCredential()) - // Simulate an interrupted upload: transfer folder with 2 of 4 chunks - // already on the server (e.g. left by a killed process). - val draft = makeDraft("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") - val transferDir = "/remote.php/dav/uploads/${backend.userId}/shonar-${draft.id}/" - backend.dirs += transferDir - val src = draft.sourceFile.readBytes() - val quarter = src.size / 4 - backend.files[transferDir + "00001"] = src.copyOfRange(0, quarter) - backend.files[transferDir + "00002"] = src.copyOfRange(quarter, quarter * 2) - p.upload(draft) { } - assertEquals("only the 2 missing chunks are sent", 2, backend.chunkPuts) - assertEquals(listOf("00003", "00004"), backend.chunkNames) - // And the assembled file is complete. - val dest = java.io.File.createTempFile("resume-dl", ".bin") - try { - val ref = p.list(null).items.single().ref - p.download(ref, dest) { } - assertTrue(dest.readBytes().contentEquals(src)) - } finally { - dest.delete() - } - } - - @Test fun quotaExceeded_surfaces() = kotlinx.coroutines.runBlocking { - val p = makeProvider() - p.connect(initialCredential()) - backend.failNextPut507 = true - try { - p.upload(makeDraft("cccccccc-cccc-4ccc-8ccc-cccccccccccc")) { } - fail("507 must surface as QuotaExceeded") - } catch (expected: ProviderError.QuotaExceeded) { - } - } -} - -/** Auth lifecycle + store for the Nextcloud provider. */ -class NextcloudProviderAuthTest { - - private val backend = FakeNextcloud() - private lateinit var dir: File - private lateinit var store: com.shonar.settings.InMemorySettingsStore - - @Before fun setUp() { - backend.start() - dir = File(System.getProperty("java.io.tmpdir"), "nc-auth-${System.nanoTime()}").apply { mkdirs() } - store = com.shonar.settings.InMemorySettingsStore() - } - - @After fun tearDown() { - backend.stop() - dir.deleteRecursively() - } - - private fun provider() = NextcloudProvider(auth = NextcloudAuthStore(store)) - - private fun cred() = ProviderCredential.AppPassword( - accountLabel = "testuser", loginUrl = backend.url, - user = backend.userId, password = backend.appPassword, - ) - - @Test fun loginFlow_endToEnd() = kotlinx.coroutines.runBlocking { - val flow = NextcloudAuth() - val url = ServerUrl.parse(backend.url)!!.getOrThrow() - assertTrue(flow.probe(url) is ProbeResult.Compatible) - val session = flow.startLogin(url) - assertTrue(session.loginUrl.isNotBlank()) - assertEquals(com.shonar.provider.PollResult.Pending, flow.poll(session)) - backend.pollApproved = true - val approved = flow.poll(session) - assertTrue(approved is com.shonar.provider.PollResult.Approved) - approved as com.shonar.provider.PollResult.Approved - assertEquals(backend.appPassword, approved.credential.password) - - val p = provider() - p.connect(approved.credential) - assertEquals(AuthState.CONNECTED, p.authState.value) - val saved = NextcloudAuthStore(store).load() - assertNotNull(saved) - assertEquals(backend.url, saved!!.baseUrl) - assertEquals(backend.userId, saved.userId) - // The normal password was never involved; only the app password persists. - assertFalse( - store.keys().flatMap { listOf(store.getString(it).orEmpty()) } - .any { it.contains("normal-password") } - ) - } - - @Test fun login_persistsUserIdForDavPaths() = kotlinx.coroutines.runBlocking { - val p = provider() - p.connect(cred()) - assertEquals(backend.userId, NextcloudAuthStore(store).load()!!.userId) - } - - @Test fun reconnect_restoresSession() = kotlinx.coroutines.runBlocking { - provider().connect(cred()) - val p2 = provider() - assertEquals(AuthState.CONNECTED, p2.reconnect()) - } - - @Test fun revokedPassword_reconnectReportsExpired() = kotlinx.coroutines.runBlocking { - val p = provider() - p.connect(cred()) - // Server-side revocation: the stored app password stops working. - val saved = NextcloudAuthStore(store).load()!! - NextcloudAuthStore(store).save(saved.copy(appPassword = "revoked")) - assertEquals(AuthState.EXPIRED, p.reconnect()) - } - - @Test fun disconnect_revokesAppPassword() = kotlinx.coroutines.runBlocking { - val p = provider() - p.connect(cred()) - p.disconnect(revokeOnServer = true) - assertEquals(1, backend.revokeCalls) - assertEquals(AuthState.DISCONNECTED, p.authState.value) - assertNull(NextcloudAuthStore(store).load()) - } - - @Test fun summary_carriesQuota() = kotlinx.coroutines.runBlocking { - val p = provider() - p.connect(cred()) - val s = p.storageLocationSummary() - assertTrue(s.headline.startsWith("Nextcloud at")) - assertTrue("quota must be shown" , "free of" in s.detail) - } -} - -class NextcloudAuthStoreTest { - - private val store = com.shonar.settings.InMemorySettingsStore() - private val auth = NextcloudAuthStore(store) - - @Test fun save_load_clear() = kotlinx.coroutines.runBlocking { - assertNull(auth.load()) - auth.save(NcSession("https://nc.example.com", "u1", "User One", "pw")) - val loaded = auth.load() - assertEquals("https://nc.example.com", loaded!!.baseUrl) - assertEquals("u1", loaded.userId) - assertEquals("pw", loaded.appPassword) - auth.clear() - assertNull(auth.load()) - } -} - -class NextcloudProtocolTest { - - @Test fun enc_spacesNotPlus() { - assertEquals("a%20b", NextcloudProvider.enc("a b")) - assertEquals("100%25", NextcloudProvider.enc("100%")) - } - - @Test fun uuidForKey_stripsDirsAndSuffix() { - assertEquals("abc", NextcloudProvider.uuidForKey("SHONAR/audio/abc.m4a")) - } - - @Test fun extFor_commonMimes() { - assertEquals(".m4a", NextcloudProvider.extFor("audio/mp4")) - assertEquals(".wav", NextcloudProvider.extFor("audio/x-wav")) - assertEquals(".ogg", NextcloudProvider.extFor("audio/opus")) - assertEquals(".mp3", NextcloudProvider.extFor("audio/mpeg")) - } - - @Test fun formatBytes_units() { - assertEquals("512 B", NextcloudProvider.formatBytes(512)) - assertEquals("10.0 GB", NextcloudProvider.formatBytes(10L * 1024 * 1024 * 1024)) - } -} diff --git a/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt b/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt deleted file mode 100644 index 5edc726..0000000 --- a/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.shonar.provider - -import kotlinx.coroutines.flow.StateFlow -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Test - -/** - * Shared provider contract suite (docs/server-providers.md §8). - * Every implementation (LocalOnly, CustomShonar, Nextcloud) runs these same - * tests — subclass and implement [makeProvider] + [makeDraft]. - */ -abstract class ProviderContractTest { - - protected abstract suspend fun makeProvider(): ShonarProvider - protected abstract suspend fun makeDraft(id: String): RecordingDraft - protected open suspend fun cleanup() {} - - // ---- lifecycle ---------------------------------------------------------- - - @Test - fun freshProvider_startsDisconnectedOrConnected() = runContract { - val p = makeProvider() - assertTrue( - "authState must start in a defined state", - p.authState.value == AuthState.DISCONNECTED || p.authState.value == AuthState.CONNECTED, - ) - } - - @Test - fun connect_thenStateConnected() = runContract { - val p = makeProvider() - p.connect(initialCredential()) - assertEquals(AuthState.CONNECTED, p.authState.value) - } - - @Test - fun disconnect_setsDisconnectedState() = runContract { - val p = makeProvider() - p.connect(initialCredential()) - p.disconnect(revokeOnServer = false) - assertEquals(AuthState.DISCONNECTED, p.authState.value) - } - - // ---- storage happy path --------------------------------------------------- - - @Test - fun upload_download_roundtrip_byteIdentical() = runContract { - val p = makeProvider() - p.connect(initialCredential()) - val draft = makeDraft("11111111-1111-4111-8111-111111111111") - val payload = draft.sourceFile.readBytes() - val ref = p.upload(draft) { } - assertEquals(draft.sizeBytes, ref.sizeBytes) - - val dest = java.io.File.createTempFile("contract-dl", ".bin") - try { - p.download(ref, dest) { } - assertTrue("downloaded bytes must equal uploaded bytes", - dest.readBytes().contentEquals(payload)) - } finally { dest.delete() } - } - - @Test - fun upload_reportsMonotonicProgressEndingAtOne() = runContract { - val p = makeProvider() - p.connect(initialCredential()) - val draft = makeDraft("22222222-2222-4222-8222-222222222222") - val seen = mutableListOf() - val ref = p.upload(draft) { seen += it } - assertNotNull(ref) - assertTrue("progress should have been reported", seen.isNotEmpty()) - assertEquals(1.0, seen.last().toDouble(), 1e-6) - for (i in 1 until seen.size) { - assertTrue("progress must be monotonic", seen[i] >= seen[i - 1] - 1e-6f) - } - } - - @Test - fun upload_idempotent_perDraftId() = runContract { - val p = makeProvider() - p.connect(initialCredential()) - val draft = makeDraft("33333333-3333-4333-8333-333333333333") - val a = p.upload(draft) { } - val b = p.upload(draft) { } - assertEquals("re-upload with same id must reuse the same key", a.key, b.key) - // and still exactly one object for that draft - val keys = p.list(null).items.filter { it.ref.key == a.key } - assertEquals(1, keys.size) - } - - @Test - fun delete_removesObjectAndSidecars() = runContract { - val p = makeProvider() - p.connect(initialCredential()) - val draft = makeDraft("44444444-4444-4444-8444-444444444444") - val ref = p.upload(draft) { } - p.putSidecar(ref, SidecarKind.TRANSCRIPT, """{"text":"hello"}""".toByteArray()) - assertNotNull(p.getSidecar(ref, SidecarKind.TRANSCRIPT)) - p.delete(ref) - val probe = java.io.File.createTempFile("gone", ".bin") - try { - p.download(ref, probe) { } - fail("download after delete must throw NotFound") - } catch (expected: ProviderError.NotFound) { - } finally { - probe.delete() - } - assertNull(p.getSidecar(ref, SidecarKind.TRANSCRIPT)) - } - - // ---- sidecars --------------------------------------------------------------- - - @Test - fun sidecar_roundtripAndOverwrite() = runContract { - val p = makeProvider() - p.connect(initialCredential()) - val draft = makeDraft("55555555-5555-4555-8555-555555555555") - val ref = p.upload(draft) { } - p.putSidecar(ref, SidecarKind.SUMMARY, "v1".toByteArray()) - assertEquals("v1", p.getSidecar(ref, SidecarKind.SUMMARY)!!.decodeToString()) - p.putSidecar(ref, SidecarKind.SUMMARY, "v2".toByteArray()) - assertEquals("v2", p.getSidecar(ref, SidecarKind.SUMMARY)!!.decodeToString()) - assertNull(p.getSidecar(ref, SidecarKind.ACTION_ITEMS)) - } - - // ---- status --------------------------------------------------------------- - - @Test - fun storageSummary_countsAfterUpload() = runContract { - val p = makeProvider() - p.connect(initialCredential()) - val draft = makeDraft("66666666-6666-4666-8666-666666666666") - p.upload(draft) { } - val s = p.storageLocationSummary() - assertTrue("headline must be non-blank", s.headline.isNotBlank()) - assertTrue("summary must count the stored object", - s.syncedCount + s.localOnlyCount >= 1) - assertTrue(s.bytesUsed >= draft.sizeBytes) - } - - // ---- error hygiene --------------------------------------------------------- - - @Test - fun providerErrorMessages_neverLeakCredentialMaterial() = runContract { - val p = makeProvider() - val cred = initialCredential() - val secrets = credentialSecretStrings(cred) - try { - p.download( - RemoteRef(p.descriptor.id, "does-not-exist-42", etag = null, sizeBytes = 0), - java.io.File.createTempFile("n-a-", ".bin"), - ) { } - fail("expected NotFound") - } catch (e: ProviderError) { - secrets.forEach { s -> - assertFalse("error message leaked credential material", e.message?.contains(s) == true) - } - } - } - - // ---- helpers --------------------------------------------------------------- - - /** Credentials for tests: local-only uses None; network providers override. */ - protected open fun initialCredential(): ProviderCredential = ProviderCredential.None - - /** Strings that must never appear in error messages (tokens/passwords). */ - protected open fun credentialSecretStrings(cred: ProviderCredential): List = - when (cred) { - is ProviderCredential.OAuthTokens -> - listOfNotNull(cred.accessToken, cred.refreshToken).filter { it.isNotBlank() } - is ProviderCredential.AppPassword -> listOf(cred.password).filter { it.isNotBlank() } - is ProviderCredential.ShonarLogin -> - listOf(cred.password).filter { it.isNotBlank() } - is ProviderCredential.FolderPath -> emptyList() // a path is not secret - ProviderCredential.None -> emptyList() - } - - /** Bridges suspend contract bodies to JUnit4. */ - private fun runContract(body: suspend () -> Unit) { - kotlinx.coroutines.runBlocking { - try { - body() - } finally { - cleanup() - } - } - } - - /** Helper subclasses use to assert a StateFlow is exposed (compile-time check). */ - protected fun assertStateFlow(flow: StateFlow) { - assertNotNull(flow.value) - } -} diff --git a/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt b/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt deleted file mode 100644 index 0f6fb07..0000000 --- a/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package com.shonar.provider - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** URL validation rules from docs/server-providers.md §4. */ -class ServerUrlTest { - - private fun ok(raw: String) = ServerUrl.parse(raw).getOrThrow() - - @Test fun httpsPublicHost_accepted() { - val u = ok("https://cloud.example.com") - assertEquals("https", u.scheme) - assertEquals("cloud.example.com", u.host) - assertFalse(u.isCleartext) - assertEquals("https://cloud.example.com", u.origin) - } - - @Test fun httpsWithPortAndPath_accepted() { - val u = ok("https://nextcloud.lan:8443/remote.php") - assertEquals(8443, u.port) - assertEquals(listOf("remote.php"), u.pathSegments) - } - - @Test fun trailingSlash_normalized() { - assertEquals(ok("https://a.example.com/").origin, "https://a.example.com") - } - - @Test fun httpPrivateLan_accepted() { - assertTrue(ok("http://192.168.1.50:8123").isCleartext) - ok("http://10.0.0.9") - ok("http://172.20.0.5") - ok("http://localhost:8080") - ok("http://home.local") - } - - @Test fun httpPublicHost_rejected() { - val r = ServerUrl.parse("http://example.com") - assertTrue(r.isFailure) - assertTrue(r.exceptionOrNull() is ProviderError.InvalidUrl) - // message must not contain anything credential-like (it contains host only) - assertTrue(r.exceptionOrNull()!!.message!!.contains("https")) - } - - @Test fun credentialsInUrl_rejected() { - assertTrue(ServerUrl.parse("https://user:***@example.com").isFailure) - } - - @Test fun nonHttpScheme_rejected() { - assertTrue(ServerUrl.parse("ftp://example.com").isFailure) - assertTrue(ServerUrl.parse("file:///etc/passwd").isFailure) - } - - @Test fun pathTraversal_rejected() { - assertTrue(ServerUrl.parse("https://example.com/a/../b").isFailure) - } - - @Test fun blankAndGarbage_rejected() { - assertTrue(ServerUrl.parse("").isFailure) - assertTrue(ServerUrl.parse(" ").isFailure) - assertTrue(ServerUrl.parse("not a url").isFailure) - assertTrue(ServerUrl.parse("https://").isFailure) - } - - @Test fun privateRanges_exact() { - assertTrue(ServerUrl.isPrivateHost("10.255.0.1")) - assertTrue(ServerUrl.isPrivateHost("192.168.0.1")) - assertFalse(ServerUrl.isPrivateHost("192.169.0.1")) - assertTrue(ServerUrl.isPrivateHost("172.16.0.1")) - assertTrue(ServerUrl.isPrivateHost("172.31.255.255")) - assertFalse(ServerUrl.isPrivateHost("172.32.0.1")) - assertFalse(ServerUrl.isPrivateHost("8.8.8.8")) - assertFalse(ServerUrl.isPrivateHost("999.1.1.1")) - } - - @Test fun toStringOfCredential_neverLeaksSecret() { - val c = ProviderCredential.AppPassword("u@example", "https://x", "u", "sup3rs3cr3t") - assertFalse(c.toString().contains("sup3rs3cr3t")) - val o = ProviderCredential.OAuthTokens("u", "ACCESS-XYZ", "REFRESH-XYZ", null) - assertFalse(o.toString().contains("ACCESS-XYZ")) - assertFalse(o.toString().contains("REFRESH-XYZ")) - val s = ProviderCredential.ShonarLogin( - "u@x.com", ServerUrl.parse("https://s.example.com")!!.getOrThrow(), - "u@x.com", "hunter2-hunter2", - ) - assertFalse(s.toString().contains("hunter2-hunter2")) - } -} - -class ProviderRegistryTest { - - private fun registry(dir: java.io.File) = ProviderRegistry.withDefaults(dir) - - @Test fun defaults_startLocalOnly() { - val r = registry(createTempFileSafe()) - assertEquals(com.shonar.provider.LocalOnlyProvider.ID, r.activeId.value) - } - - @Test fun unknownProvider_rejected() { - val r = registry(createTempFileSafe()) - try { - r.select("dropbox") - org.junit.Assert.fail("unknown provider must be rejected") - } catch (expected: ProviderError.InvalidUrl) { - } - } - - @Test fun select_localOnly_resolves() { - val r = registry(createTempFileSafe()) - val p = r.select(LocalOnlyProvider.ID) - assertEquals(LocalOnlyProvider.ID, p.descriptor.id) - assertEquals(LocalOnlyProvider.ID, r.activeId.value) - } - - @Test fun available_listsDescriptors() { - val ids = registry(createTempFileSafe()).available().map { it.id } - assertTrue(ids.contains(LocalOnlyProvider.ID)) - assertTrue(ids.contains(ProviderRegistry.CUSTOM_SHONAR_ID)) - assertTrue(ids.contains(ProviderRegistry.NEXTCLOUD_ID)) - assertTrue(ids.contains(ProviderRegistry.SYNC_FOLDER_ID)) - } - - @Test fun default_isNextcloud() { - assertEquals( - ProviderRegistry.NEXTCLOUD_ID, - registry(createTempFileSafe()).defaultProviderId, - ) - } - - private fun createTempFileSafe(): java.io.File = - java.io.File(System.getProperty("java.io.tmpdir"), "reg-${System.nanoTime()}").apply { mkdirs() } -} diff --git a/android/app/src/test/java/com/shonar/provider/TofuTest.kt b/android/app/src/test/java/com/shonar/provider/TofuTest.kt deleted file mode 100644 index e1e7cf0..0000000 --- a/android/app/src/test/java/com/shonar/provider/TofuTest.kt +++ /dev/null @@ -1,241 +0,0 @@ -package com.shonar.provider - -import java.security.MessageDigest -import okhttp3.Request -import okhttp3.mockwebserver.Dispatcher -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.RecordedRequest -import okhttp3.tls.HandshakeCertificates -import okhttp3.tls.HeldCertificate -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Before -import org.junit.Test - -/** - * P5 cert-fixture tests. Certificates are generated at runtime with - * okhttp-tls HeldCertificate (no checked-in keys, nothing to expire): - * a private CA the system does NOT trust, so every handshake exercises - * the TOFU path. - */ -class TofuTrustTest { - - private lateinit var server: MockWebServer - private lateinit var held: HeldCertificate - private lateinit var store: com.shonar.settings.InMemorySettingsStore - private lateinit var tofu: TofuManager - private lateinit var tls: TlsPolicy - private lateinit var base: String - private var port: Int = 0 - - @Before fun setUp() { - held = HeldCertificate.Builder() - .commonName("shonar-test") - .addSubjectAlternativeName("localhost") - .addSubjectAlternativeName("127.0.0.1") - .build() - val serverCerts = HandshakeCertificates.Builder() - .heldCertificate(held) - .build() - server = MockWebServer() - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = - MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json") - .setBody("""{"kind":"shonar","version":"t","capabilities":{}}""") - } - server.useHttps(serverCerts.sslSocketFactory(), false) - server.start() - port = server.port - base = "https://localhost:$port" - store = com.shonar.settings.InMemorySettingsStore() - tofu = TofuManager(TofuStore(store)) - tls = TlsPolicy(tofu, bodiesEnabled = { false }, sink = {}) - } - - @After fun tearDown() { - server.shutdown() - } - - private fun expectedSpki(): String { - val digest = MessageDigest.getInstance("SHA-256") - .digest(held.certificate.publicKey.encoded) - return digest.joinToString(":") { "%02X".format(it) } - } - - private fun get(url: String): Int { - val client = tls.newClient(10, 10, 10) - client.newCall(Request.Builder().url(url).get().build()).execute().use { resp -> - return resp.code - } - } - - @Test fun untrusted_recordsFailureWithRealSpki() { - try { - get("$base/api/v1/provider-info") - fail("self-signed must not connect unapproved") - } catch (expected: javax.net.ssl.SSLHandshakeException) { - } - val failure = tofu.failureFor("localhost") - assertNotNull("failure must be recorded for approval UI", failure) - assertEquals(expectedSpki(), failure!!.spkiHex) - assertTrue("subject must name the test cert", failure.subject.contains("shonar-test")) - } - - @Test fun approve_thenConnects() { - try { - get("$base/api/v1/provider-info") - } catch (ignored: Exception) { - } - assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) - assertEquals(200, get("$base/api/v1/provider-info")) - assertEquals(setOf("localhost"), tofu.pinnedHostsForTest()) - } - - @Test fun pin_isPerHost() { - try { - get("$base/api/v1/provider-info") - } catch (ignored: Exception) { - } - assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) - assertEquals(200, get("$base/api/v1/provider-info")) - // Same cert, different host: still untrusted (per-host binding). - try { - get("https://127.0.0.1:$port/api/v1/provider-info") - fail("pin for localhost must not cover 127.0.0.1") - } catch (expected: javax.net.ssl.SSLHandshakeException) { - } - assertNotNull(tofu.failureFor("127.0.0.1")) - } - - @Test fun decline_clearsWithoutPinning() { - try { - get("$base/api/v1/provider-info") - } catch (ignored: Exception) { - } - assertNotNull(tofu.failureFor("localhost")) - kotlinx.coroutines.runBlocking { tofu.decline("localhost") } - assertNull(tofu.failureFor("localhost")) - } - - @Test fun approve_withoutFailure_returnsFalse() { - assertFalse(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) - } - - @Test fun forget_revokesTrust() { - try { - get("$base/api/v1/provider-info") - } catch (ignored: Exception) { - } - assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) - assertEquals(200, get("$base/api/v1/provider-info")) - kotlinx.coroutines.runBlocking { tofu.forget("localhost") } - try { - get("$base/api/v1/provider-info") - fail("forgotten host must fail again") - } catch (expected: javax.net.ssl.SSLHandshakeException) { - } - } - - @Test fun systemTrust_unaffectedForPlainHttp() { - // The TOFU trust manager must be invisible on non-TLS and on - // system-trusted paths: plain HTTP MockWebServer still works. - val plain = MockWebServer() - plain.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse = - MockResponse().setResponseCode(200).setBody("ok") - } - plain.start() - try { - assertEquals(200, get("http://localhost:${plain.port}/x")) - } finally { - plain.shutdown() - } - } - - // test-only accessor without growing the public API - private fun TofuManager.pinnedHostsForTest(): Set = - kotlinx.coroutines.runBlocking { pinnedHosts() } -} - -class TofuProbeTest { - - private lateinit var server: MockWebServer - private lateinit var tofu: TofuManager - private lateinit var tls: TlsPolicy - private var port: Int = 0 - - @Before fun setUp() { - val held = HeldCertificate.Builder() - .commonName("shonar-test") - .addSubjectAlternativeName("localhost") - .build() - val serverCerts = HandshakeCertificates.Builder().heldCertificate(held).build() - server = MockWebServer() - server.dispatcher = object : Dispatcher() { - override fun dispatch(request: RecordedRequest): MockResponse { - val body = if (request.path == "/status.php") { - """{"productname":"Nextcloud","versionstring":"30.0.0"}""" - } else { - """{"kind":"shonar","version":"t","capabilities":{"chunked_upload":true}}""" - } - return MockResponse().setResponseCode(200) - .setHeader("Content-Type", "application/json").setBody(body) - } - } - server.useHttps(serverCerts.sslSocketFactory(), false) - server.start() - port = server.port - tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())) - tls = TlsPolicy(tofu, bodiesEnabled = { false }, sink = {}) - } - - @After fun tearDown() { - server.shutdown() - } - - @Test fun probe_reportsRealFingerprint_thenApproves() = kotlinx.coroutines.runBlocking { - val url = ServerUrl.parse("https://localhost:$port")!!.getOrThrow() - val handshake = ShonarHandshake(tls.probeClient(), tofu) - val first = handshake.probe(url) - assertTrue(first is ProbeResult.TlsFailure) - first as ProbeResult.TlsFailure - assertTrue("fingerprint must be real, not a placeholder", - first.fingerprintSha256 != "unknown" && ":" in first.fingerprintSha256) - assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) - val second = handshake.probe(url) - assertTrue("approved host probes cleanly: $second", second is ProbeResult.Compatible) - } - - @Test fun nextcloudProbe_sameFlow() = kotlinx.coroutines.runBlocking { - val url = ServerUrl.parse("https://localhost:$port")!!.getOrThrow() - val auth = NextcloudAuth(tls.nextcloudClient(), tofu) - assertTrue(auth.probe(url) is ProbeResult.TlsFailure) - assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) - val second = auth.probe(url) - assertTrue("approved Nextcloud probes cleanly: $second", second is ProbeResult.Compatible) - } -} - -class TofuStoreTest { - - private val store = com.shonar.settings.InMemorySettingsStore() - private val tofu = TofuStore(store) - - @Test fun roundtrip() = kotlinx.coroutines.runBlocking { - assertEquals(emptySet(), tofu.hosts()) - tofu.addPin("Nas.Local", "DER-1") - tofu.addPin("nas.local", "DER-2") - assertEquals(setOf("DER-1", "DER-2"), tofu.pins("NAS.LOCAL")) - assertEquals(setOf("nas.local"), tofu.hosts()) - tofu.removeHost("nas.local") - assertEquals(emptySet(), tofu.hosts()) - assertEquals(emptySet(), tofu.pins("nas.local")) - } -} diff --git a/android/app/src/test/java/com/shonar/recording/AiContentTest.kt b/android/app/src/test/java/com/shonar/recording/AiContentTest.kt deleted file mode 100644 index cb39d77..0000000 --- a/android/app/src/test/java/com/shonar/recording/AiContentTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.shonar.recording - -import org.json.JSONObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** M8: transcript/summary parsing, playback-sync mapping, edit payloads. */ -class AiContentTest { - - private fun seg(start: Double, text: String) = - TranscriptSegment(start, start + 1.0, text) - - // ---- activeSegmentIndex ---------------------------------------------------- - - @Test fun activeSegment_emptyIsMinusOne() { - assertEquals(-1, activeSegmentIndex(emptyList(), 5_000)) - } - - @Test fun activeSegment_beforeFirstIsMinusOne() { - val segs = listOf(seg(2.0, "a"), seg(4.0, "b")) - assertEquals(-1, activeSegmentIndex(segs, 1_999)) - } - - @Test fun activeSegment_exactStartSelectsIt() { - val segs = listOf(seg(2.0, "a"), seg(4.0, "b")) - assertEquals(0, activeSegmentIndex(segs, 2_000)) - assertEquals(1, activeSegmentIndex(segs, 4_000)) - } - - @Test fun activeSegment_gapKeepsLastSpoken() { - val segs = listOf(seg(0.0, "a"), seg(10.0, "b")) - assertEquals(0, activeSegmentIndex(segs, 9_999)) - } - - @Test fun activeSegment_afterLastStaysOnLast() { - val segs = listOf(seg(0.0, "a"), seg(2.0, "b")) - assertEquals(1, activeSegmentIndex(segs, 999_000)) - } - - // ---- parsing ----------------------------------------------------------------- - - @Test fun parseTranscript_fullShape() { - val raw = JSONObject() - .put("version", 3) - .put("language", "en") - .put("provider", "faster-whisper") - .put("model", "small") - .put("text", "hello world") - .put("segments", org.json.JSONArray().apply { - put(JSONObject().put("start", 1.5).put("end", 2.5) - .put("text", "world").put("speaker", "S1")) - put(JSONObject().put("start", 0.0).put("end", 1.0).put("text", "hello")) - }) - .put("edited_by_user", true) - .toString() - val t = parseTranscript(raw)!! - assertEquals(3, t.version) - assertEquals("en", t.language) - assertEquals("hello world", t.text) - assertTrue(t.editedByUser) - // out-of-order segments arrive sorted by start - assertEquals(listOf("hello", "world"), t.segments.map { it.text }) - assertEquals("S1", t.segments[1].speaker) - assertNull(t.segments[0].speaker) - } - - @Test fun parseTranscript_minimalShape() { - val t = parseTranscript(JSONObject().put("version", 1).put("text", "x").toString())!! - assertEquals(1, t.version) - assertTrue(t.segments.isEmpty()) - assertFalse(t.editedByUser) - } - - @Test fun parseTranscript_garbageIsNull() { - assertNull(parseTranscript("not json{")) - } - - @Test fun parseSummary_sections() { - val raw = JSONObject() - .put("version", 2) - .put("provider", "ollama") - .put("edited_by_user", false) - .put("content", JSONObject() - .put("short", "Standup.") - .put("detailed", "Long version.") - .put("key_points", org.json.JSONArray().put("a").put("b")) - .put("action_items", org.json.JSONArray().put("ship it")) - .put("decisions", org.json.JSONArray()) - .put("questions", org.json.JSONArray())) - .toString() - val s = parseSummary(raw)!! - assertEquals("Standup.", s.short) - assertEquals("Long version.", s.detailed) - assertEquals(listOf("a", "b"), s.list("key_points")) - assertEquals(listOf("ship it"), s.list("action_items")) - assertTrue(s.list("decisions").isEmpty()) - } - - @Test fun parseJobs_list() { - val raw = org.json.JSONArray().apply { - put(JSONObject().put("job_type", "transcribe").put("status", "succeeded") - .put("attempt", 1).put("max_attempts", 3)) - put(JSONObject().put("job_type", "summarize").put("status", "failed") - .put("attempt", 3).put("max_attempts", 3).put("error", "boom")) - }.toString() - val jobs = parseJobs(raw) - assertEquals(2, jobs.size) - assertEquals("succeeded", jobs[0].status) - assertEquals("boom", jobs[1].error) - } - - // ---- payloads ------------------------------------------------------------------ - - @Test fun transcriptPayload_textOnly() { - val payload = JSONObject(transcriptUpdatePayload("fixed text")) - assertEquals("fixed text", payload.getString("text")) - assertFalse(payload.has("segments")) - } - - @Test fun transcriptPayload_withSegmentsRoundTrips() { - val segs = listOf(TranscriptSegment(0.0, 1.0, "hi", "S1")) - val payload = JSONObject(transcriptUpdatePayload("hi", segs)) - val arr = payload.getJSONArray("segments") - assertEquals(1, arr.length()) - assertEquals("S1", arr.getJSONObject(0).getString("speaker")) - } - - @Test fun summaryPayload_shape() { - val payload = JSONObject(summaryUpdatePayload(mapOf("short" to "s"))) - assertEquals("s", payload.getJSONObject("content").getString("short")) - } - - @Test fun patchPayload_titleOnly() { - val payload = JSONObject(recordingPatchPayload(title = "New")) - assertEquals("New", payload.getString("title")) - assertFalse(payload.has("notes")) - assertNotNull(JSONObject(recordingPatchPayload(title = "a", notes = "b"))) - } -} diff --git a/android/app/src/test/java/com/shonar/recording/Fixtures.kt b/android/app/src/test/java/com/shonar/recording/Fixtures.kt index 0a8187f..e0b8c4d 100644 --- a/android/app/src/test/java/com/shonar/recording/Fixtures.kt +++ b/android/app/src/test/java/com/shonar/recording/Fixtures.kt @@ -1,21 +1,8 @@ package com.shonar.recording -import com.shonar.provider.AuthState -import com.shonar.provider.ProviderCredential -import com.shonar.provider.ProviderDescriptor -import com.shonar.provider.ProviderRegistry -import com.shonar.provider.RecordingDraft -import com.shonar.provider.RemoteRef -import com.shonar.provider.Page -import com.shonar.provider.ProbeResult -import com.shonar.provider.ServerUrl -import com.shonar.provider.ShonarProvider -import com.shonar.provider.SidecarKind -import com.shonar.provider.StorageLocation import com.shonar.provider.SyncState import java.io.File import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf /** In-memory RecordingDao: no Android needed. Shared by recording tests. */ @@ -27,6 +14,11 @@ internal class FakeDao(initial: List = emptyList()) : Recording override suspend fun insert(recording: RecordingEntity) { rows[recording.id] = recording } + override suspend fun insertIgnore(recording: RecordingEntity): Long { + if (rows.values.any { it.filePath == recording.filePath }) return -1L + rows[recording.id] = recording + return 1L + } override suspend fun update(recording: RecordingEntity) { check(rows.containsKey(recording.id)) { "no such row ${recording.id}" } rows[recording.id] = recording @@ -36,40 +28,6 @@ internal class FakeDao(initial: List = emptyList()) : Recording } } -/** Scripted provider: per-draft behaviour for upload, inert elsewhere. */ -internal class ScriptProvider( - val id: String = "nextcloud", - var failWith: (RecordingDraft) -> Exception? = { null }, -) : ShonarProvider { - override val descriptor = ProviderDescriptor(id, id) - private val states = MutableStateFlow(AuthState.CONNECTED) - override val authState: kotlinx.coroutines.flow.StateFlow = states - val uploadedKeys = mutableListOf() - override suspend fun probe(baseUrl: ServerUrl): ProbeResult = ProbeResult.Incompatible - override suspend fun connect(credential: ProviderCredential) {} - override suspend fun reconnect(): AuthState = AuthState.CONNECTED - override suspend fun disconnect(revokeOnServer: Boolean) {} - override suspend fun deleteAccountAndData() {} - override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef { - failWith(draft)?.let { throw it } - onProgress(1f) - return RemoteRef(id, "SHONAR/audio/${draft.id}.m4a", etag = "e-${draft.id}", sizeBytes = draft.sizeBytes) - .also { uploadedKeys += draft.id } - } - override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) = - throw UnsupportedOperationException() - override suspend fun delete(ref: RemoteRef) = throw UnsupportedOperationException() - override suspend fun list(cursor: String?): Page = - Page(emptyList(), null) - override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) = - throw UnsupportedOperationException() - override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = null - override suspend fun storageLocationSummary(): StorageLocation = StorageLocation(id, "", 0, 0, 0) -} - -internal fun registryOf(vararg providers: ShonarProvider): ProviderRegistry = - ProviderRegistry(providers.associateBy({ it.descriptor.id }) { { it } }) - internal fun entity( id: String, dir: File, diff --git a/android/app/src/test/java/com/shonar/recording/MigrationTest.kt b/android/app/src/test/java/com/shonar/recording/MigrationTest.kt index 9bc46b8..07e1524 100644 --- a/android/app/src/test/java/com/shonar/recording/MigrationTest.kt +++ b/android/app/src/test/java/com/shonar/recording/MigrationTest.kt @@ -1,199 +1,75 @@ package com.shonar.recording -import com.shonar.provider.LocalOnlyProvider -import com.shonar.provider.ProviderError -import com.shonar.provider.ProviderRegistry import com.shonar.provider.SyncState import java.io.File -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue -import org.junit.Assert.fail import org.junit.Test -class MigrationRunnerTest { +class MigrationVersionsTest { - @Test fun allSucceed_marksUploadedWithSlots() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("mig-ok") - try { - val dao = FakeDao(listOf(entity("a", dir), entity("b", dir))) - val script = ScriptProvider() - val res = MigrationRunner(SyncSlots(dao), registryOf(script)) - .migrateAllTo("nextcloud") - assertEquals(2, res.uploaded) - assertTrue(res.failed.isEmpty()) - assertEquals( - listOf("a", "b"), - dao.getAll().sortedBy { it.id }.map { - assertEquals(SyncState.UPLOADED, it.syncState) - assertEquals("nextcloud", it.originProviderId) - assertEquals("SHONAR/audio/${it.id}.m4a", it.remoteKey) - it.id - }, - ) - } finally { - dir.deleteRecursively() - } + @Test fun v3() { + assertEquals(2, MIGRATION_2_3.startVersion) + assertEquals(3, MIGRATION_2_3.endVersion) } - @Test fun failure_recordsErrorAndContinues() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("mig-partial") - try { - val dao = FakeDao(listOf(entity("a", dir), entity("b", dir), entity("c", dir))) - val script = ScriptProvider().apply { - failWith = { if (it.id == "b") ProviderError.Transient("boom") else null } - } - val res = MigrationRunner(SyncSlots(dao), registryOf(script)) - .migrateAllTo("nextcloud") - assertEquals(2, res.uploaded) - assertEquals(listOf("t-b"), res.failed) - val b = dao.getById("b")!! - assertEquals(SyncState.ERROR, b.syncState) - assertEquals("boom", b.syncReason) - assertEquals(SyncState.UPLOADED, dao.getById("a")!!.syncState) - assertEquals(SyncState.UPLOADED, dao.getById("c")!!.syncState) - } finally { - dir.deleteRecursively() - } - } - - @Test fun missingFile_recordsErrorWithoutUpload() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("mig-gone") - try { - val ghost = RecordingEntity( - id = "g", title = "ghost", createdAtEpochMs = 1L, durationMs = 1, - filePath = File(dir, "nope.m4a").absolutePath, mimeType = "audio/mp4", sizeBytes = 0, - ) - val dao = FakeDao(listOf(ghost)) - val script = ScriptProvider() - val res = MigrationRunner(SyncSlots(dao), registryOf(script)).migrateAllTo("nextcloud") - assertEquals(0, res.uploaded) - assertEquals(listOf("ghost"), res.failed) - assertTrue(script.uploadedKeys.isEmpty()) - assertEquals(SyncState.ERROR, dao.getById("g")!!.syncState) - } finally { - dir.deleteRecursively() - } - } - - @Test fun errorRow_rerunsThroughLegalTransitions() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("mig-retry") - try { - val dao = FakeDao(listOf(entity("a", dir, SyncState.ERROR))) - val res = MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())) - .migrateAllTo("nextcloud") - assertEquals(1, res.uploaded) - assertEquals(SyncState.UPLOADED, dao.getById("a")!!.syncState) - } finally { - dir.deleteRecursively() - } - } - - @Test fun cancel_leavesRestUntouched() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("mig-cancel") - try { - val dao = FakeDao(listOf(entity("a", dir), entity("b", dir), entity("c", dir))) - val runner = MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())) - var job: Job? = null - job = launch { - runner.migrateAllTo("nextcloud") { - // Cancel as soon as the first file reports progress. - if (it.done == 0) job?.cancel() - } - } - job.join() - // First file may or may not have finished (upload is quick); the - // point is later files were never started. - val states = dao.getAll().associate { it.id to it.syncState } - assertTrue( - "unstarted rows keep their state, got $states", - states["c"] == SyncState.LOCAL_ONLY, - ) - } finally { - dir.deleteRecursively() - } - } - - @Test fun localOnlyTarget_rejected() = kotlinx.coroutines.runBlocking { - val dao = FakeDao() - try { - MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())).migrateAllTo("local-only") - fail("must reject local-only target") - } catch (expected: IllegalArgumentException) { - } - } - - @Test fun unknownTarget_throws() = kotlinx.coroutines.runBlocking { - val dao = FakeDao() - try { - MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())).migrateAllTo("dropbox") - fail("must reject unknown provider") - } catch (expected: ProviderError.InvalidUrl) { - } - } - - @Test fun forget_clearsSlotsKeepsFiles() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("mig-forget") - try { - val synced = entity("a", dir, SyncState.UPLOADED).copy( - originProviderId = "nextcloud", - remoteKey = "SHONAR/audio/a.m4a", - remoteEtag = "e", - remoteSizeBytes = 128, - ) - val dao = FakeDao(listOf(synced)) - SyncSlots(dao).forgetAllRemotes("custom-shonar") - val row = dao.getById("a")!! - assertNull(row.remoteKey) - assertNull(row.remoteEtag) - assertNull(row.remoteSizeBytes) - assertEquals("custom-shonar", row.originProviderId) - assertEquals(SyncState.QUEUED, row.syncState) - assertTrue("files stay", File(row.filePath).exists()) - } finally { - dir.deleteRecursively() - } - } - - @Test fun localOnlyId_rejectedAsTargetConstant() { - assertEquals("local-only", LocalOnlyProvider.ID) + @Test fun v4_uniqueFilePath() { + assertEquals(3, MIGRATION_3_4.startVersion) + assertEquals(4, MIGRATION_3_4.endVersion) } } -class SyncSlotsTest { +/** + * Library import never duplicates: sequential re-scans and concurrent + * overlapping scans each leave exactly one row per file (mutex + + * unique filePath index via insertIgnore). + */ +class ImportDedupeTest { - @Test fun draftFor_missingFileIsNull() = kotlinx.coroutines.runBlocking { - val ghost = RecordingEntity( - id = "g", title = "ghost", createdAtEpochMs = 1L, durationMs = 1, - filePath = "/nonexistent-dir-xyz/nope.m4a", mimeType = "audio/mp4", sizeBytes = 0, - ) - assertNull(SyncSlots(FakeDao()).draftFor(ghost)) + private fun repo(dir: File, dao: FakeDao): RecordingRepository { + // rootResolver returns the dir; refreshRoot/import do the rest. + return RecordingRepository(dir, dao) { dir } + } + + @Test fun rescan_doesNotDuplicate() = runBlocking { + val dir = tmpDir("import-rescan") + try { + File(dir, "a.m4a").writeBytes(ByteArray(64)) + File(dir, "b.m4a").writeBytes(ByteArray(64)) + val dao = FakeDao() + val repo = repo(dir, dao) + repo.refreshRoot() + repo.importExistingFiles() + repo.importExistingFiles() + assertEquals(2, dao.getAll().size) + } finally { + dir.deleteRecursively() + } + } + + @Test fun concurrentScans_doesNotDuplicate() = runBlocking { + val dir = tmpDir("import-race") + try { + File(dir, "a.m4a").writeBytes(ByteArray(64)) + val dao = FakeDao() + val repo = repo(dir, dao) + repo.refreshRoot() + awaitAll(async { repo.importExistingFiles() }, async { repo.importExistingFiles() }) + assertEquals(1, dao.getAll().size) + } finally { + dir.deleteRecursively() + } } @Test fun entityDefaults_readAsNeverSyncedLocal() { - // A v1-shaped row (new columns absent) must behave as an unsynced local. val row = RecordingEntity( id = "v1", title = "old", createdAtEpochMs = 1L, durationMs = 1, filePath = "/x.m4a", mimeType = "audio/mp4", sizeBytes = 10, ) assertEquals("local-only", row.originProviderId) - assertNull(row.remoteKey) assertEquals(SyncState.LOCAL_ONLY, row.syncState) - assertNull(row.syncReason) - } - - @Test fun converter_roundtripsAndFallsBack() { - val c = SyncStateConverter() - assertEquals(SyncState.SYNCED, c.toState(c.toString(SyncState.SYNCED))) - assertEquals("UPLOADED", c.toString(SyncState.UPLOADED)) - assertEquals(SyncState.LOCAL_ONLY, c.toState("FUTURE_STATE")) - assertEquals(SyncState.LOCAL_ONLY, c.toState(null)) - } - - @Test fun migration_movesV1ToV2() { - assertEquals(1, MIGRATION_1_2.startVersion) - assertEquals(2, MIGRATION_1_2.endVersion) } } diff --git a/android/app/src/test/java/com/shonar/recording/RecordingRenameTest.kt b/android/app/src/test/java/com/shonar/recording/RecordingRenameTest.kt index 632ed40..97dd85f 100644 --- a/android/app/src/test/java/com/shonar/recording/RecordingRenameTest.kt +++ b/android/app/src/test/java/com/shonar/recording/RecordingRenameTest.kt @@ -29,6 +29,12 @@ class RecordingRenameTest { rows[recording.id] = recording emit() } + override suspend fun insertIgnore(recording: RecordingEntity): Long { + if (rows.values.any { it.filePath == recording.filePath }) return -1L + rows[recording.id] = recording + emit() + return 1L + } override suspend fun update(recording: RecordingEntity) { rows[recording.id] = recording diff --git a/android/app/src/test/java/com/shonar/recording/SyncDrainTest.kt b/android/app/src/test/java/com/shonar/recording/SyncDrainTest.kt deleted file mode 100644 index 03119f3..0000000 --- a/android/app/src/test/java/com/shonar/recording/SyncDrainTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -package com.shonar.recording - -import androidx.work.NetworkType -import com.shonar.provider.ProviderError -import com.shonar.provider.SyncState -import com.shonar.provider.SyncStatus -import com.shonar.provider.canTransitionTo -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -private val OPEN = SyncDrain.Gates(online = true, unmetered = true, charging = true) -private const val NOW = 1_000_000L - -class SyncDrainTest { - - @Test fun drainsQueuedAndDueErrors_skipsTheRest() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("drain-mix") - try { - val dueError = entity("b", dir, SyncState.ERROR) - .copy(syncRetryAtMs = NOW - 1, syncAttempts = 1) - val futureError = entity("c", dir, SyncState.ERROR) - .copy(syncRetryAtMs = NOW + 60_000, syncAttempts = 1) - val rows = listOf( - entity("a", dir, SyncState.QUEUED), - dueError, - futureError, - entity("d", dir, SyncState.SYNCED), - entity("e", dir, SyncState.UPLOADED), - entity("f", dir), // LOCAL_ONLY: the drain never volunteers rows - ) - val dao = FakeDao(rows) - val res = SyncDrain(SyncSlots(dao)) { NOW } - .drainOnce(ScriptProvider(), wifiOnly = false, chargingOnly = false, gates = OPEN) - assertEquals(2, res.uploaded) - assertTrue(res.failed.isEmpty()) - // P7 leftovers (ERROR, no retryAt) are due immediately too. - val states = dao.getAll().associate { it.id to it.syncState } - assertEquals(SyncState.UPLOADED, states["a"]) - assertEquals(SyncState.UPLOADED, states["b"]) - assertEquals(SyncState.ERROR, states["c"]) - assertEquals(SyncState.SYNCED, states["d"]) - assertEquals(SyncState.UPLOADED, states["e"]) - assertEquals(SyncState.LOCAL_ONLY, states["f"]) - } finally { - dir.deleteRecursively() - } - } - - @Test fun legacyErrorWithoutRetryAt_isDue() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("drain-legacy") - try { - // Exactly how a P7-run failure reads after the v2->v3 upgrade. - val legacy = entity("a", dir, SyncState.ERROR) - .copy(syncReason = "boom", syncAttempts = 0, syncRetryAtMs = null) - val dao = FakeDao(listOf(legacy)) - val res = SyncDrain(SyncSlots(dao)) { NOW } - .drainOnce(ScriptProvider(), false, false, OPEN) - assertEquals(1, res.uploaded) - } finally { - dir.deleteRecursively() - } - } - - @Test fun localOnlyTarget_isNoop() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("drain-local") - try { - val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED))) - val script = ScriptProvider("local-only") - val res = SyncDrain(SyncSlots(dao)) { NOW } - .drainOnce(script, false, false, OPEN) - assertEquals(0, res.uploaded) - assertTrue(script.uploadedKeys.isEmpty()) - assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState) - } finally { - dir.deleteRecursively() - } - } - - @Test fun gatesBlocked_postponesWithoutTouching() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("drain-gates") - try { - val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED))) - val script = ScriptProvider() - val res = SyncDrain(SyncSlots(dao)) { NOW }.drainOnce( - script, wifiOnly = true, chargingOnly = false, - gates = SyncDrain.Gates(online = true, unmetered = false, charging = true), - ) - assertTrue(res.postponed) - assertTrue(script.uploadedKeys.isEmpty()) - assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState) - } finally { - dir.deleteRecursively() - } - } - - @Test fun failure_recordsBackoffAttempts() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("drain-backoff") - try { - val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED))) - val script = ScriptProvider().apply { - failWith = { ProviderError.Transient("nope") } - } - val drain = SyncDrain(SyncSlots(dao)) { NOW } - val res = drain.drainOnce(script, false, false, OPEN) - assertEquals(0, res.uploaded) - assertEquals(listOf("t-a"), res.failed) - val first = dao.getById("a")!! - assertEquals(SyncState.ERROR, first.syncState) - assertEquals(1, first.syncAttempts) - assertEquals(NOW + 60_000L, first.syncRetryAtMs) - // Second failure doubles the delay. - val res2 = SyncDrain(SyncSlots(dao)) { NOW + 61_000L } - .drainOnce(script, false, false, OPEN) - assertEquals(0, res2.uploaded) - val second = dao.getById("a")!! - assertEquals(2, second.syncAttempts) - assertEquals(NOW + 61_000L + 120_000L, second.syncRetryAtMs) - } finally { - dir.deleteRecursively() - } - } - - @Test fun interruption_revertsInflightToQueued() = kotlinx.coroutines.runBlocking { - val dir = tmpDir("drain-cancel") - try { - val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED))) - val script = ScriptProvider().apply { - failWith = { throw kotlinx.coroutines.CancellationException("stopped") } - } - try { - SyncDrain(SyncSlots(dao)) { NOW }.drainOnce(script, false, false, OPEN) - org.junit.Assert.fail("cancellation must propagate") - } catch (expected: kotlinx.coroutines.CancellationException) { - } - assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState) - } finally { - dir.deleteRecursively() - } - } -} - -class SyncGatesTest { - - @Test fun matrix() { - val onlineMetered = SyncDrain.Gates(online = true, unmetered = false, charging = false) - assertTrue(onlineMetered.satisfiedBy(wifiOnly = false, chargingOnly = false)) - assertFalse(onlineMetered.satisfiedBy(wifiOnly = true, chargingOnly = false)) - assertFalse(onlineMetered.satisfiedBy(wifiOnly = false, chargingOnly = true)) - assertFalse( - SyncDrain.Gates(online = false, unmetered = false, charging = true) - .satisfiedBy(false, false) - ) - assertTrue(OPEN.satisfiedBy(true, true)) - } -} - -class BackoffTest { - - @Test fun exponentialCappedAtOneHour() { - assertEquals(60_000L, backoffDelayMs(1)) - assertEquals(120_000L, backoffDelayMs(2)) - assertEquals(240_000L, backoffDelayMs(3)) - assertEquals(3_600_000L, backoffDelayMs(7)) - assertEquals(3_600_000L, backoffDelayMs(100)) - assertEquals(60_000L, backoffDelayMs(0)) - } - - @Test fun stepsToUploading_areLegalTransitions() { - for (from in SyncState.values()) { - val steps = stepsToUploading(from) - assertTrue("must end uploading, from=$from", steps.last() == SyncState.UPLOADING) - var cur = SyncStatus(from) - for (next in steps) { - assertTrue( - "$from -> $next must be legal", - cur.canTransitionTo(next), - ) - cur = SyncStatus(next) - } - } - } -} - -class SchedulerMappingTest { - - @Test fun constraintsFollowSettings() { - val wifi = SyncScheduler.workConstraints(wifiOnly = true, chargingOnly = true) - assertEquals(NetworkType.UNMETERED, wifi.requiredNetworkType) - assertTrue(wifi.requiresCharging()) - val any = SyncScheduler.workConstraints(wifiOnly = false, chargingOnly = false) - assertEquals(NetworkType.CONNECTED, any.requiredNetworkType) - assertFalse(any.requiresCharging()) - } -} - -class MigrationVersionsTest { - - @Test fun v3() { - assertEquals(2, MIGRATION_2_3.startVersion) - assertEquals(3, MIGRATION_2_3.endVersion) - } -} diff --git a/android/app/src/test/java/com/shonar/ui/folder/FolderListTest.kt b/android/app/src/test/java/com/shonar/ui/folder/FolderListTest.kt deleted file mode 100644 index c9d7ffd..0000000 --- a/android/app/src/test/java/com/shonar/ui/folder/FolderListTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.shonar.ui.folder - -import java.io.File -import java.nio.file.Files -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Assert.fail -import org.junit.Test - -class FolderListTest { - - private fun tempDir(): File = Files.createTempDirectory("folder-list").toFile() - - @Test fun listChildDirs_sortedSkipsHiddenAndFiles() { - val root = tempDir() - try { - File(root, "b").mkdir() - File(root, "A").mkdir() - File(root, ".hidden").mkdir() - File(root, "note.txt").writeText("x") - assertEquals( - listOf("A", "b"), - listChildDirs(root).map { it.name }, - ) - } finally { - root.deleteRecursively() - } - } - - @Test fun listChildDirs_missingIsEmpty() { - assertTrue(listChildDirs(File("/no/such/dir/xyz")).isEmpty()) - } - - @Test fun folderName_validation() { - assertTrue(isValidFolderName("SHONAR")) - assertTrue(isValidFolderName(" spaced ")) - assertFalse(isValidFolderName("")) - assertFalse(isValidFolderName("a/b")) - assertFalse(isValidFolderName("..")) - assertFalse(isValidFolderName(".")) - } - - @Test fun createChildDir_roundTrip() { - val root = tempDir() - try { - val child = createChildDir(root, "New").getOrThrow() - assertTrue(child.isDirectory) - // idempotent when already a dir - createChildDir(root, "New").getOrThrow() - // refuses file clash - File(root, "f").writeText("x") - try { - createChildDir(root, "f").getOrThrow() - fail("file clash must fail") - } catch (e: IllegalArgumentException) { - assertTrue(e.message!!.contains("file")) - } - } finally { - root.deleteRecursively() - } - } -} diff --git a/android/app/src/test/java/com/shonar/ui/provider/HostedRouteTest.kt b/android/app/src/test/java/com/shonar/ui/provider/HostedRouteTest.kt deleted file mode 100644 index 095e25c..0000000 --- a/android/app/src/test/java/com/shonar/ui/provider/HostedRouteTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.shonar.ui.provider - -import com.shonar.provider.ProbeResult -import com.shonar.provider.ProviderDescriptor -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * P6a hosted-routing matrix: a Start9/Umbrel URL (or anything reachable) - * routes to whichever protocol answered, TLS blocks everything, and - * anything else is an honest "nothing here". - */ -class HostedRouteTest { - - private fun ncCompatible(): ProbeResult = ProbeResult.Compatible( - descriptor = ProviderDescriptor(id = "nextcloud", displayName = "Nextcloud"), - serverName = "Nextcloud", - version = "30", - ) - - private fun shonarCompatible(): ProbeResult = ProbeResult.Compatible( - descriptor = ProviderDescriptor(id = "custom-shonar", displayName = "Custom SHONAR server"), - serverName = "server", - version = "test", - ) - - @Test fun nextcloudWinsWhenBothAnswer() { - assertEquals( - HostedRoute.NEXT_CLOUD, - routeHosted(ncCompatible(), shonarCompatible()), - ) - } - - @Test fun shonarUsedWhenNextcloudDoesNotMatch() { - assertEquals( - HostedRoute.SHONAR, - routeHosted(ProbeResult.Incompatible, shonarCompatible()), - ) - } - - @Test fun tlsBlocksOnlyWhenNothingMatched() { - val tls = ProbeResult.TlsFailure("AB:CD") - assertEquals(HostedRoute.TLS, routeHosted(tls, ProbeResult.Incompatible)) - assertEquals( - HostedRoute.TLS, - routeHosted(ProbeResult.Incompatible, tls), - ) - assertEquals( - HostedRoute.TLS, - routeHosted(tls, ProbeResult.NetworkError("timeout")), - ) - } - - @Test fun compatiblesBeatTls() { - // A match is actionable; TLS only blocks when nothing matched. - assertEquals( - HostedRoute.NEXT_CLOUD, - routeHosted(ncCompatible(), ProbeResult.TlsFailure("AB:CD")), - ) - assertEquals( - HostedRoute.SHONAR, - routeHosted(ProbeResult.TlsFailure("AB:CD"), shonarCompatible()), - ) - } - - @Test fun nothingMatchingIsNone() { - assertEquals( - HostedRoute.NONE, - routeHosted(ProbeResult.Incompatible, ProbeResult.Incompatible), - ) - assertEquals( - HostedRoute.NONE, - routeHosted( - ProbeResult.NetworkError("timeout"), - ProbeResult.Incompatible, - ), - ) - } -} diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index 0f73b7f..0000000 --- a/backend/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# S.H.O.N.A.R. backend - -FastAPI + PostgreSQL backend for the S.H.O.N.A.R. Android app. - -See the repository root [README](../README.md) and `docs/` for full documentation. - -## Quick start (development) - -```bash -cd backend -uv venv .venv && uv pip install -e ".[dev]" -cp ../.env.example .env # then edit SHONAR_SECRET_KEY etc. -uvicorn shonar.main:app --reload --port 8000 -``` - -Tests: `pytest` · Lint: `ruff check .` · Migrations: `alembic upgrade head` diff --git a/backend/alembic.ini b/backend/alembic.ini deleted file mode 100644 index 5d2e0e2..0000000 --- a/backend/alembic.ini +++ /dev/null @@ -1,37 +0,0 @@ -[alembic] -script_location = migrations -prepend_sys_path = . -# URL is injected from settings in env.py; this is a placeholder. -sqlalchemy.url = - -[loggers] -keys = root,sqlalchemy,alembic - -[handlers] -keys = console - -[formatters] -keys = generic - -[logger_root] -level = WARN -handlers = console - -[logger_sqlalchemy] -level = WARN -handlers = -qualname = sqlalchemy.engine - -[logger_alembic] -level = INFO -handlers = -qualname = alembic - -[handler_console] -class = StreamHandler -args = (sys.stderr,) -level = NOTSET -formatter = generic - -[formatter_generic] -format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/backend/migrations/env.py b/backend/migrations/env.py deleted file mode 100644 index fae9a1d..0000000 --- a/backend/migrations/env.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Alembic environment (async).""" - -from __future__ import annotations - -import asyncio -from logging.config import fileConfig - -from alembic import context -from sqlalchemy import pool -from sqlalchemy.engine import Connection -from sqlalchemy.ext.asyncio import async_engine_from_config - -from shonar.core.config import get_settings -from shonar.db import models # noqa: F401 (register tables) -from shonar.db.base import Base - -config = context.config -if config.config_file_name is not None: - fileConfig(config.config_file_name) - -settings = get_settings() -config.set_main_option("sqlalchemy.url", settings.database_url) - -target_metadata = Base.metadata - - -def run_migrations_offline() -> None: - context.configure( - url=settings.database_url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - with context.begin_transaction(): - context.run_migrations() - - -def do_run_migrations(connection: Connection) -> None: - context.configure(connection=connection, target_metadata=target_metadata) - with context.begin_transaction(): - context.run_migrations() - - -async def run_async_migrations() -> None: - connectable = async_engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - async with connectable.connect() as connection: - await connection.run_sync(do_run_migrations) - await connectable.dispose() - - -def run_migrations_online() -> None: - asyncio.run(run_async_migrations()) - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako deleted file mode 100644 index 5e0c0c5..0000000 --- a/backend/migrations/script.py.mako +++ /dev/null @@ -1,26 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from __future__ import annotations - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -${imports if imports else ""} -revision: str = ${repr(up_revision)} -down_revision: str | None = ${repr(down_revision)} -branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} -depends_on: str | Sequence[str] | None = ${repr(depends_on)} - - -def upgrade() -> None: - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/8d51af959ae4_initial_schema.py b/backend/migrations/versions/8d51af959ae4_initial_schema.py deleted file mode 100644 index ace87d1..0000000 --- a/backend/migrations/versions/8d51af959ae4_initial_schema.py +++ /dev/null @@ -1,281 +0,0 @@ -"""initial schema - -Revision ID: 8d51af959ae4 -Revises: -Create Date: 2026-09-08 13:07:10.455837 - -""" -from __future__ import annotations - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -revision: str = '8d51af959ae4' -down_revision: str | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def _json_type() -> sa.types.TypeEngine: - """JSONB on PostgreSQL, plain JSON on SQLite (desktop bundled engine).""" - if op.get_context().dialect.name == "postgresql": - return postgresql.JSONB(astext_type=sa.Text()) - return sa.JSON() - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('email', sa.String(length=320), nullable=False), - sa.Column('password_hash', sa.String(length=255), nullable=False), - sa.Column('display_name', sa.String(length=120), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('location_storage_enabled', sa.Boolean(), nullable=False), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) - op.create_table('devices', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('name', sa.String(length=120), nullable=False), - sa.Column('platform', sa.String(length=40), nullable=False), - sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_devices_user_id'), 'devices', ['user_id'], unique=False) - op.create_table('tags', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('name', sa.String(length=80), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'name') - ) - op.create_index(op.f('ix_tags_user_id'), 'tags', ['user_id'], unique=False) - op.create_table('recordings', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('device_id', sa.Uuid(), nullable=True), - sa.Column('client_recording_id', sa.String(length=64), nullable=True), - sa.Column('title', sa.String(length=300), nullable=False), - sa.Column('recorded_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('duration_seconds', sa.Float(), nullable=False), - sa.Column('notes', sa.Text(), nullable=True), - sa.Column('latitude', sa.Float(), nullable=True), - sa.Column('longitude', sa.Float(), nullable=True), - sa.Column('location_accuracy_m', sa.Float(), nullable=True), - sa.Column('processing_status', sa.Enum('pending_upload', 'uploaded', 'queued', 'processing', 'completed', 'failed', 'ai_disabled', name='processing_status'), nullable=False), - sa.Column('processing_error', sa.Text(), nullable=True), - sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_recordings_client_recording_id'), 'recordings', ['client_recording_id'], unique=False) - op.create_index(op.f('ix_recordings_processing_status'), 'recordings', ['processing_status'], unique=False) - op.create_index('ix_recordings_user_client_id', 'recordings', ['user_id', 'client_recording_id'], unique=True, postgresql_where='client_recording_id IS NOT NULL', sqlite_where=sa.text('client_recording_id IS NOT NULL')) - op.create_index(op.f('ix_recordings_user_id'), 'recordings', ['user_id'], unique=False) - op.create_index('ix_recordings_user_recorded', 'recordings', ['user_id', 'recorded_at'], unique=False) - op.create_table('refresh_tokens', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('token_hash', sa.String(length=128), nullable=False), - sa.Column('family', sa.Uuid(), nullable=False), - sa.Column('device_id', sa.Uuid(), nullable=True), - sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('replaced_by', sa.Uuid(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['replaced_by'], ['refresh_tokens.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('token_hash') - ) - op.create_index(op.f('ix_refresh_tokens_family'), 'refresh_tokens', ['family'], unique=False) - op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) - op.create_table('assets', - sa.Column('recording_id', sa.Uuid(), nullable=True), - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('kind', sa.Enum('original', 'normalized', 'export', name='asset_kind'), nullable=False), - sa.Column('storage_key', sa.String(length=500), nullable=False), - sa.Column('mime_type', sa.String(length=100), nullable=False), - sa.Column('size_bytes', sa.BigInteger(), nullable=False), - sa.Column('checksum_sha256', sa.String(length=64), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_assets_recording_id'), 'assets', ['recording_id'], unique=False) - op.create_index(op.f('ix_assets_user_id'), 'assets', ['user_id'], unique=False) - op.create_index('uq_assets_one_original', 'assets', ['recording_id'], unique=True, postgresql_where="kind = 'original'", sqlite_where=sa.text("kind = 'original'")) - op.create_table('processing_jobs', - sa.Column('recording_id', sa.Uuid(), nullable=False), - sa.Column('job_type', sa.Enum('normalize_audio', 'transcribe', 'summarize', name='job_type'), nullable=False), - sa.Column('status', sa.Enum('queued', 'running', 'succeeded', 'failed', 'skipped', name='job_status'), nullable=False), - sa.Column('attempt', sa.Integer(), nullable=False), - sa.Column('max_attempts', sa.Integer(), nullable=False), - sa.Column('error', sa.Text(), nullable=True), - sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('task_handle', sa.String(length=120), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_processing_jobs_recording_id'), 'processing_jobs', ['recording_id'], unique=False) - op.create_index('ix_processing_jobs_recording_type', 'processing_jobs', ['recording_id', 'job_type'], unique=False) - op.create_index(op.f('ix_processing_jobs_status'), 'processing_jobs', ['status'], unique=False) - op.create_table('recording_tags', - sa.Column('recording_id', sa.Uuid(), nullable=False), - sa.Column('tag_id', sa.Uuid(), nullable=False), - sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('recording_id', 'tag_id') - ) - op.create_table('summaries', - sa.Column('recording_id', sa.Uuid(), nullable=False), - sa.Column('version', sa.Integer(), nullable=False), - sa.Column('superseded_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('provider', sa.String(length=80), nullable=False), - sa.Column('model', sa.String(length=120), nullable=True), - sa.Column('content', _json_type(), nullable=False), - sa.Column('edited_by_user', sa.Boolean(), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_summaries_recording_id'), 'summaries', ['recording_id'], unique=False) - op.create_table('transcripts', - sa.Column('recording_id', sa.Uuid(), nullable=False), - sa.Column('version', sa.Integer(), nullable=False), - sa.Column('superseded_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('language', sa.String(length=16), nullable=True), - sa.Column('provider', sa.String(length=80), nullable=False), - sa.Column('model', sa.String(length=120), nullable=True), - sa.Column('text', sa.Text(), nullable=False), - sa.Column('segments', _json_type(), nullable=True), - sa.Column('edited_by_user', sa.Boolean(), nullable=False), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_transcripts_recording_id'), 'transcripts', ['recording_id'], unique=False) - op.create_index('ix_transcripts_recording_version', 'transcripts', ['recording_id', 'version'], unique=False) - op.create_table('export_jobs', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('recording_id', sa.Uuid(), nullable=False), - sa.Column('export_type', sa.String(length=40), nullable=False), - sa.Column('status', sa.Enum('queued', 'running', 'succeeded', 'failed', 'skipped', name='export_job_status'), nullable=False), - sa.Column('asset_id', sa.Uuid(), nullable=True), - sa.Column('error', sa.Text(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['asset_id'], ['assets.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_export_jobs_recording_id'), 'export_jobs', ['recording_id'], unique=False) - op.create_index(op.f('ix_export_jobs_user_id'), 'export_jobs', ['user_id'], unique=False) - op.create_table('upload_sessions', - sa.Column('user_id', sa.Uuid(), nullable=False), - sa.Column('recording_id', sa.Uuid(), nullable=True), - sa.Column('client_recording_id', sa.String(length=64), nullable=True), - sa.Column('title', sa.String(length=300), nullable=True), - sa.Column('declared_mime_type', sa.String(length=100), nullable=False), - sa.Column('declared_size_bytes', sa.BigInteger(), nullable=False), - sa.Column('chunk_size_bytes', sa.BigInteger(), nullable=False), - sa.Column('status', sa.Enum('open', 'finalizing', 'completed', 'aborted', 'expired', name='upload_session_status'), nullable=False), - sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('completed_asset_id', sa.Uuid(), nullable=True), - sa.Column('id', sa.Uuid(), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['completed_asset_id'], ['assets.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_upload_sessions_user_id'), 'upload_sessions', ['user_id'], unique=False) - op.create_table('upload_chunks', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('session_id', sa.Uuid(), nullable=False), - sa.Column('chunk_index', sa.Integer(), nullable=False), - sa.Column('size_bytes', sa.BigInteger(), nullable=False), - sa.Column('checksum_sha256', sa.String(length=64), nullable=False), - sa.Column('storage_key', sa.String(length=500), nullable=False), - sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), - sa.ForeignKeyConstraint(['session_id'], ['upload_sessions.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('session_id', 'chunk_index') - ) - op.create_index(op.f('ix_upload_chunks_session_id'), 'upload_chunks', ['session_id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_upload_chunks_session_id'), table_name='upload_chunks') - op.drop_table('upload_chunks') - op.drop_index(op.f('ix_upload_sessions_user_id'), table_name='upload_sessions') - op.drop_table('upload_sessions') - op.drop_index(op.f('ix_export_jobs_user_id'), table_name='export_jobs') - op.drop_index(op.f('ix_export_jobs_recording_id'), table_name='export_jobs') - op.drop_table('export_jobs') - op.drop_index('ix_transcripts_recording_version', table_name='transcripts') - op.drop_index(op.f('ix_transcripts_recording_id'), table_name='transcripts') - op.drop_table('transcripts') - op.drop_index(op.f('ix_summaries_recording_id'), table_name='summaries') - op.drop_table('summaries') - op.drop_table('recording_tags') - op.drop_index(op.f('ix_processing_jobs_status'), table_name='processing_jobs') - op.drop_index('ix_processing_jobs_recording_type', table_name='processing_jobs') - op.drop_index(op.f('ix_processing_jobs_recording_id'), table_name='processing_jobs') - op.drop_table('processing_jobs') - op.drop_index('uq_assets_one_original', table_name='assets', postgresql_where="kind = 'original'") - op.drop_index(op.f('ix_assets_user_id'), table_name='assets') - op.drop_index(op.f('ix_assets_recording_id'), table_name='assets') - op.drop_table('assets') - op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') - op.drop_index(op.f('ix_refresh_tokens_family'), table_name='refresh_tokens') - op.drop_table('refresh_tokens') - op.drop_index('ix_recordings_user_recorded', table_name='recordings') - op.drop_index(op.f('ix_recordings_user_id'), table_name='recordings') - op.drop_index('ix_recordings_user_client_id', table_name='recordings', postgresql_where='client_recording_id IS NOT NULL') - op.drop_index(op.f('ix_recordings_processing_status'), table_name='recordings') - op.drop_index(op.f('ix_recordings_client_recording_id'), table_name='recordings') - op.drop_table('recordings') - op.drop_index(op.f('ix_tags_user_id'), table_name='tags') - op.drop_table('tags') - op.drop_index(op.f('ix_devices_user_id'), table_name='devices') - op.drop_table('devices') - op.drop_index(op.f('ix_users_email'), table_name='users') - op.drop_table('users') - # ### end Alembic commands ### diff --git a/backend/migrations/versions/fts0000000001_fts_columns.py b/backend/migrations/versions/fts0000000001_fts_columns.py deleted file mode 100644 index 4257d64..0000000 --- a/backend/migrations/versions/fts0000000001_fts_columns.py +++ /dev/null @@ -1,86 +0,0 @@ -"""full-text search columns (PostgreSQL tsvector) - -Revision ID: fts0000000001 -Revises: 0c938663a363 - -Generated (STORED) tsvector columns + GIN indexes for search across title, -transcript text, summary content, tags, and action items. The SearchBackend -protocol keeps this swappable for Meilisearch/OpenSearch later. -""" -from __future__ import annotations - -from collections.abc import Sequence - -from alembic import op - -revision: str = "fts0000000001" -down_revision: str | None = "8d51af959ae4" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # tsvector is PostgreSQL-only. On SQLite (desktop bundled engine) full- - # text search is handled outside the DB (the app scans report files), - # so this migration is a no-op. - if op.get_context().dialect.name != "postgresql": - return - # recordings: title + notes + tag names are indexed; transcript/summary - # contribute via their own tables (joined at query time). - op.execute( - """ - ALTER TABLE recordings - ADD COLUMN search_vector tsvector - GENERATED ALWAYS AS ( - setweight(to_tsvector('simple', coalesce(title, '')), 'A') || - setweight(to_tsvector('simple', coalesce(notes, '')), 'B') - ) STORED; - """ - ) - op.execute("CREATE INDEX ix_recordings_search ON recordings USING GIN (search_vector);") - - op.execute( - """ - ALTER TABLE transcripts - ADD COLUMN search_vector tsvector - GENERATED ALWAYS AS ( - setweight(to_tsvector('simple', coalesce(text, '')), 'B') - ) STORED; - """ - ) - op.execute("CREATE INDEX ix_transcripts_search ON transcripts USING GIN (search_vector);") - - # summary content JSON -> extractive text for FTS. Generated columns - # forbid subqueries/set-returning functions, so we index the JSON body - # with punctuation stripped (covers key_points/decisions/action_items/ - # questions) plus weighted short/detailed fields. - op.execute( - """ - ALTER TABLE summaries - ADD COLUMN search_vector tsvector - GENERATED ALWAYS AS ( - setweight(to_tsvector('simple', coalesce(content->>'short', '')), 'A') || - setweight(to_tsvector('simple', coalesce(content->>'detailed', '')), 'B') || - setweight( - to_tsvector('simple', - regexp_replace(coalesce(content::text, ''), '[\\[\\]{}"]', ' ', 'g')), 'C') - ) STORED; - """ - ) - op.execute("CREATE INDEX ix_summaries_search ON summaries USING GIN (search_vector);") - - op.execute( - """ - ALTER TABLE tags ADD COLUMN IF NOT EXISTS search_vector tsvector - GENERATED ALWAYS AS (to_tsvector('simple', coalesce(name, ''))) STORED; - """ - ) - op.execute("CREATE INDEX ix_tags_search ON tags USING GIN (search_vector);") - - -def downgrade() -> None: - if op.get_context().dialect.name != "postgresql": - return - for table in ("tags", "summaries", "transcripts", "recordings"): - op.execute(f"DROP INDEX IF EXISTS ix_{table}_search;") - op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS search_vector;") diff --git a/backend/migrations/versions/m8models000001_model_support.py b/backend/migrations/versions/m8models000001_model_support.py deleted file mode 100644 index 37aa5ed..0000000 --- a/backend/migrations/versions/m8models000001_model_support.py +++ /dev/null @@ -1,69 +0,0 @@ -"""per-recording transcription model + job progress + app settings - -Revision ID: m8models000001 -Revises: fts0000000001 - -- recordings.transcription_model (nullable; NULL = server default) -- processing_jobs.stage / processing_jobs.progress (display-only) -- app_settings table (global default model, …) -""" -from __future__ import annotations - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -revision: str = 'm8models000001' -down_revision: str | None = 'fts0000000001' -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def _json_type() -> sa.types.TypeEngine: - """JSONB on PostgreSQL, plain JSON on SQLite (desktop bundled engine).""" - if op.get_context().dialect.name == "postgresql": - return postgresql.JSONB(astext_type=sa.Text()) - return sa.JSON() - - -def upgrade() -> None: - op.add_column( - "recordings", - sa.Column("transcription_model", sa.String(length=32), nullable=True), - ) - op.add_column( - "upload_sessions", - sa.Column("transcription_model", sa.String(length=32), nullable=True), - ) - op.add_column( - "processing_jobs", - sa.Column("stage", sa.String(length=32), nullable=True), - ) - op.add_column( - "processing_jobs", - sa.Column("progress", sa.Integer(), nullable=True), - ) - op.create_table( - "app_settings", - sa.Column("key", sa.String(length=120), nullable=False), - sa.Column( - "value", - _json_type(), - nullable=False, - ), - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("key"), - ) - - -def downgrade() -> None: - op.drop_table("app_settings") - op.drop_column("processing_jobs", "progress") - op.drop_column("processing_jobs", "stage") - op.drop_column("upload_sessions", "transcription_model") - op.drop_column("recordings", "transcription_model") diff --git a/backend/pyproject.toml b/backend/pyproject.toml deleted file mode 100644 index dbaabff..0000000 --- a/backend/pyproject.toml +++ /dev/null @@ -1,64 +0,0 @@ -[project] -name = "shonar-backend" -version = "0.1.0" -description = "S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder backend" -readme = "README.md" -requires-python = ">=3.11" -license = { text = "Apache-2.0" } -dependencies = [ - "fastapi>=0.115", - "uvicorn[standard]>=0.30", - "sqlalchemy[asyncio]>=2.0", - "asyncpg>=0.29", - "aiosqlite>=0.20", # tests only in practice; harmless dep - "alembic>=1.13", - "pydantic>=2.8", - "pydantic-settings>=2.4", - "email-validator>=2.0", - "argon2-cffi>=23.1", - "pyjwt>=2.9", - "python-multipart>=0.0.9", - "slowapi>=0.1.9", - "redis>=5.0", - "arq>=0.26", -] - -[project.optional-dependencies] -s3 = ["boto3>=1.34"] -faster-whisper = ["faster-whisper>=1.0"] -dev = [ - "pytest>=8.0", - "pytest-asyncio>=0.23", - "httpx>=0.27", - "ruff>=0.5", - "mypy>=1.10", -] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["shonar"] - -[tool.pytest.ini_options] -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "session" -asyncio_default_test_loop_scope = "session" -testpaths = ["tests"] - -[tool.ruff] -line-length = 100 -target-version = "py311" -exclude = ["migrations"] - -[tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "SIM"] - -[tool.ruff.lint.per-file-ignores] -# FastAPI idiom: Query(...)/Depends() as parameter defaults. -"shonar/api/**" = ["B008"] - -[tool.mypy] -python_version = "3.11" -ignore_missing_imports = true diff --git a/backend/shonar/__init__.py b/backend/shonar/__init__.py deleted file mode 100644 index 88b009d..0000000 --- a/backend/shonar/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder.""" - -__version__ = "0.1.0" diff --git a/backend/shonar/api/__init__.py b/backend/shonar/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/shonar/api/deps.py b/backend/shonar/api/deps.py deleted file mode 100644 index f0f1f08..0000000 --- a/backend/shonar/api/deps.py +++ /dev/null @@ -1,49 +0,0 @@ -"""FastAPI dependencies: current user, session, rate limiting.""" - -from __future__ import annotations - -import uuid -from typing import Annotated - -from fastapi import Depends, HTTPException, Request, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from sqlalchemy.ext.asyncio import AsyncSession - -from shonar.core.security import TokenError, decode_access_token -from shonar.db.models import User -from shonar.db.session import get_session - -SessionDep = Annotated[AsyncSession, Depends(get_session)] - -_bearer = HTTPBearer(auto_error=False) - - -async def get_current_user( - request: Request, - session: SessionDep, - creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None, -) -> User: - if creds is None or not creds.credentials: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Not authenticated", - headers={"WWW-Authenticate": "Bearer"}, - ) from None - try: - payload = decode_access_token(creds.credentials) - except TokenError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or expired token", - headers={"WWW-Authenticate": "Bearer"}, - ) from None - user = await session.get(User, uuid.UUID(payload["sub"])) - if user is None or not user.is_active or user.deleted_at is not None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Account unavailable" - ) from None - request.state.user_id = user.id - return user - - -CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/backend/shonar/api/schemas_common.py b/backend/shonar/api/schemas_common.py deleted file mode 100644 index 7eda9fe..0000000 --- a/backend/shonar/api/schemas_common.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Shared Pydantic schemas.""" - -from __future__ import annotations - -import uuid -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, EmailStr, Field - - -class ORMModel(BaseModel): - model_config = ConfigDict(from_attributes=True) - - -# --- auth --- - - -class RegisterRequest(BaseModel): - email: EmailStr - password: str = Field(min_length=10, max_length=256) - display_name: str | None = Field(default=None, max_length=120) - - -class LoginRequest(BaseModel): - email: EmailStr - password: str - device_name: str | None = Field(default=None, max_length=120) - platform: str = Field(default="android", max_length=40) - - -class TokenPair(BaseModel): - access_token: str - token_type: str = "bearer" - expires_in: int - refresh_token: str - device_id: uuid.UUID | None = None - - -class RefreshRequest(BaseModel): - refresh_token: str - - -class LogoutRequest(BaseModel): - refresh_token: str - - -class UserOut(ORMModel): - id: uuid.UUID - email: EmailStr - display_name: str | None - location_storage_enabled: bool - created_at: datetime - - -class UserUpdate(BaseModel): - display_name: str | None = Field(default=None, max_length=120) - location_storage_enabled: bool | None = None - - -class DeviceOut(ORMModel): - id: uuid.UUID - name: str - platform: str - last_seen_at: datetime - revoked_at: datetime | None - - -class DeleteAccountRequest(BaseModel): - password: str diff --git a/backend/shonar/api/schemas_recordings.py b/backend/shonar/api/schemas_recordings.py deleted file mode 100644 index 0eea6cc..0000000 --- a/backend/shonar/api/schemas_recordings.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Recording / upload schemas.""" - -from __future__ import annotations - -import uuid -from datetime import datetime - -from pydantic import BaseModel, ConfigDict, Field - - -class ORMModel(BaseModel): - model_config = ConfigDict(from_attributes=True) - - -class UploadSessionCreate(BaseModel): - declared_mime_type: str = Field(max_length=100) - declared_size_bytes: int = Field(gt=0) - title: str | None = Field(default=None, max_length=300) - client_recording_id: str | None = Field(default=None, max_length=64) - # Optional per-recording transcription model override ("Use default" - # when omitted). Validated against the model registry at finalize time. - transcription_model: str | None = Field(default=None, max_length=32) - - -class UploadSessionOut(ORMModel): - id: uuid.UUID - status: str - chunk_size_bytes: int - declared_mime_type: str - declared_size_bytes: int - expires_at: datetime - - -class UploadStatusOut(BaseModel): - id: uuid.UUID - status: str - received_chunk_indexes: list[int] - - -class RecordingFinalize(BaseModel): - recorded_at: datetime | None = None - duration_seconds: float = Field(default=0.0, ge=0) - # Location accepted ONLY if the user has location storage enabled - # (enforced in the route; silently dropped otherwise). - latitude: float | None = Field(default=None, ge=-90, le=90) - longitude: float | None = Field(default=None, ge=-180, le=180) - location_accuracy_m: float | None = Field(default=None, ge=0) - notes: str | None = Field(default=None, max_length=20000) - # Optional per-recording transcription model override. When both the - # session and the finalize body specify one, the finalize body wins. - transcription_model: str | None = Field(default=None, max_length=32) - - -class RecordingOut(ORMModel): - id: uuid.UUID - title: str - recorded_at: datetime - duration_seconds: float - notes: str | None - latitude: float | None - longitude: float | None - processing_status: str - processing_error: str | None - # Effective transcription model saved at finalize time (override or the - # global default then in force). None for pre-model rows. - transcription_model: str | None - has_audio: bool - mime_type: str | None - size_bytes: int | None - tags: list[str] - created_at: datetime - updated_at: datetime - - -class RecordingUpdate(BaseModel): - title: str | None = Field(default=None, min_length=1, max_length=300) - notes: str | None = Field(default=None, max_length=20000) - tags: list[str] | None = Field(default=None, max_length=20) - latitude: float | None = Field(default=None, ge=-90, le=90) - longitude: float | None = Field(default=None, ge=-180, le=180) - - -class RecordingListOut(BaseModel): - items: list[RecordingOut] - total: int - limit: int - offset: int - - -class SegmentOut(BaseModel): - start: float - end: float - text: str - speaker: str | None = None - - -class TranscriptOut(ORMModel): - version: int - language: str | None - provider: str - model: str | None - text: str - segments: list[SegmentOut] - edited_by_user: bool - created_at: datetime - updated_at: datetime - - -class SummaryOut(ORMModel): - version: int - provider: str - model: str | None - content: dict - edited_by_user: bool - created_at: datetime - updated_at: datetime - - -class ProcessingJobOut(ORMModel): - job_type: str - status: str - attempt: int - max_attempts: int - error: str | None - # Display-only phase within a running job ("loading-model", - # "transcribing") plus 0-100 progress when known. - stage: str | None - progress: int | None - started_at: datetime | None - finished_at: datetime | None - - -class TranscriptUpdate(BaseModel): - """User edit: stored as a new version with edited_by_user=True. - - The AI pipeline never overwrites a newest user-edited row, so edits - are verdicts. Segments are optional — when omitted the previous - segments are dropped (plain-text correction). - """ - - text: str = Field(min_length=1, max_length=200000) - segments: list[SegmentOut] | None = Field(default=None, max_length=2000) - language: str | None = Field(default=None, max_length=16) - - -class SummaryUpdate(BaseModel): - """User edit for the structured summary (same versioning rule).""" - - content: dict = Field(max_length=100) diff --git a/backend/shonar/api/schemas_search.py b/backend/shonar/api/schemas_search.py deleted file mode 100644 index 05b353e..0000000 --- a/backend/shonar/api/schemas_search.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Search + export schemas (M9).""" - -from __future__ import annotations - -import uuid - -from pydantic import BaseModel - - -class SearchHitOut(BaseModel): - id: uuid.UUID - title: str - field: str # title | tag | notes | summary | transcript - snippet: str - - -class SearchOut(BaseModel): - query: str - scope: str - items: list[SearchHitOut] - total: int - limit: int - offset: int diff --git a/backend/shonar/api/v1/__init__.py b/backend/shonar/api/v1/__init__.py deleted file mode 100644 index 7435a72..0000000 --- a/backend/shonar/api/v1/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""API v1 router aggregation.""" - -from fastapi import APIRouter - -from shonar.api.v1 import ( - auth, - health, - models, - provider_info, - recordings, - search_exports, - users, -) - -api_router = APIRouter(prefix="/api/v1") -api_router.include_router(health.router) -api_router.include_router(auth.router) -api_router.include_router(users.router) -api_router.include_router(recordings.router) -api_router.include_router(models.router) -api_router.include_router(provider_info.router) -api_router.include_router(search_exports.router) - -# Included as later milestones land: -# - tags (M9 follow-on) diff --git a/backend/shonar/api/v1/auth.py b/backend/shonar/api/v1/auth.py deleted file mode 100644 index f27741f..0000000 --- a/backend/shonar/api/v1/auth.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Auth endpoints (rate-limited).""" - -from __future__ import annotations - -from fastapi import APIRouter, HTTPException, Request - -from shonar.api.deps import CurrentUser, SessionDep -from shonar.api.schemas_common import ( - DeleteAccountRequest, - LoginRequest, - LogoutRequest, - RefreshRequest, - RegisterRequest, - TokenPair, - UserOut, -) -from shonar.core.config import get_settings -from shonar.core.ratelimit import auth_limit -from shonar.core.security import create_access_token -from shonar.services import auth as auth_service -from shonar.services.auth import AuthError - -router = APIRouter(tags=["auth"]) - - -@router.post("/auth/register", response_model=TokenPair, status_code=201) -@auth_limit -async def register(body: RegisterRequest, request: Request, session: SessionDep): - settings = get_settings() - if not settings.allow_registration: - raise HTTPException(403, "Registration is disabled on this server.") from None - try: - user = await auth_service.register_user( - session, body.email, body.password, body.display_name - ) - except AuthError as e: - raise HTTPException(e.status_code, e.message) from None - access, ttl = create_access_token(user.id) - from shonar.services.auth import issue_refresh_token - - refresh, _ = await issue_refresh_token(session, user.id, None, None) - return TokenPair(access_token=access, expires_in=ttl, refresh_token=refresh) - - -@router.post("/auth/login", response_model=TokenPair) -@auth_limit -async def login(body: LoginRequest, request: Request, session: SessionDep): - try: - user, refresh, device = await auth_service.login( - session, body.email, body.password, body.device_name, body.platform - ) - except AuthError as e: - raise HTTPException(e.status_code, e.message) from None - access, ttl = create_access_token(user.id, device.id) - return TokenPair( - access_token=access, expires_in=ttl, refresh_token=refresh, device_id=device.id - ) - - -@router.post("/auth/refresh", response_model=TokenPair) -@auth_limit -async def refresh(body: RefreshRequest, request: Request, session: SessionDep): - try: - user, new_refresh, device_id = await auth_service.rotate_refresh_token( - session, body.refresh_token - ) - except AuthError as e: - raise HTTPException(e.status_code, e.message) from None - access, ttl = create_access_token(user.id, device_id) - return TokenPair( - access_token=access, expires_in=ttl, refresh_token=new_refresh, device_id=device_id - ) - - -@router.post("/auth/logout", status_code=204) -async def logout(body: LogoutRequest, session: SessionDep): - await auth_service.logout(session, body.refresh_token) - - -@router.get("/auth/me", response_model=UserOut) -async def me(user: CurrentUser): - return user - - -@router.post("/auth/delete-account", status_code=202) -async def delete_account(body: DeleteAccountRequest, user: CurrentUser, session: SessionDep): - try: - await auth_service.delete_account(session, user, body.password) - except AuthError as e: - raise HTTPException(e.status_code, e.message) from None - return {"detail": "Account scheduled for deletion.", "grace_days": 30} diff --git a/backend/shonar/api/v1/health.py b/backend/shonar/api/v1/health.py deleted file mode 100644 index 5722725..0000000 --- a/backend/shonar/api/v1/health.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Health and system endpoints.""" - -from __future__ import annotations - -import time - -from fastapi import APIRouter -from sqlalchemy import text - -from shonar.api.deps import SessionDep -from shonar.core.config import get_settings - -router = APIRouter(tags=["health"]) - -_STARTED = time.monotonic() - - -@router.get("/healthz") -async def healthz() -> dict: - """Liveness: process up. No auth, no dependencies.""" - return {"status": "ok", "uptime_seconds": round(time.monotonic() - _STARTED, 1)} - - -@router.get("/readyz") -async def readyz(session: SessionDep) -> dict: - """Readiness: database reachable. Config warnings surfaced for admins - via /api/v1/system/status instead of failing readiness.""" - try: - await session.execute(text("SELECT 1")) - db_ok = True - except Exception: - db_ok = False - status_code_body = {"status": "ok" if db_ok else "degraded", "database": db_ok} - return status_code_body - - -@router.get("/system/status") -async def system_status() -> dict: - """Public-ish status: which AI features are enabled (never any secrets). - The app uses this to show honest AI-processing state to users.""" - settings = get_settings() - return { - "app": settings.app_name, - "registration_enabled": settings.allow_registration, - "ai": { - "transcription_provider": settings.transcription_provider, - "transcription_enabled": settings.transcription_provider != "none", - "llm_provider": settings.llm_provider, - "llm_enabled": settings.llm_provider != "none", - # Explicit disclosure: are any external (non-local) calls made? - "external_ai_in_use": ( - settings.transcription_provider == "whisper_http" - and "localhost" not in settings.transcription_base_url - and "127.0.0.1" not in settings.transcription_base_url - ) - or ( - settings.llm_provider == "openai_compat" - and "localhost" not in settings.llm_base_url - and "127.0.0.1" not in settings.llm_base_url - ), - }, - "storage_backend": settings.storage_backend, - "audio_conversion_enabled": settings.audio_conversion_enabled, - "config_warnings": settings.validate_production(), - } diff --git a/backend/shonar/api/v1/models.py b/backend/shonar/api/v1/models.py deleted file mode 100644 index 9c14775..0000000 --- a/backend/shonar/api/v1/models.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Transcription model registry + global default (Stage 1). - -- GET /models — supported models with display metadata, download and - availability status, and which is the default. -- GET /models/default — the current global default model name. -- PUT /models/default — change the global default (affects future - recordings only; saved per-recording models are never rewritten). -- POST /models/{name}/download — fetch a model into the local cache - (needs internet once; runs synchronously and may take minutes for - large models). -""" - -from __future__ import annotations - -import asyncio - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel, Field - -from shonar.api.deps import CurrentUser, SessionDep -from shonar.services.ai import ProviderConfigError -from shonar.services.ai.model_registry import ( - SUPPORTED_TRANSCRIPTION_MODELS, - get_global_default_model, - is_faster_whisper_installed, - is_model_downloaded, - set_global_default_model, - validate_model_name, -) - -router = APIRouter(tags=["models"]) - - -class TranscriptionModelOut(BaseModel): - name: str - display_name: str - description: str - params: str - approx_memory: str - relative_speed: str - is_default: bool - downloaded: bool - available: bool - - -class ModelsOut(BaseModel): - default_model: str - faster_whisper_installed: bool - models: list[TranscriptionModelOut] - - -class DefaultModelUpdate(BaseModel): - model: str = Field(min_length=1, max_length=32) - - -class DefaultModelOut(BaseModel): - default_model: str - - -async def _models_out(session: SessionDep) -> ModelsOut: - default = await get_global_default_model(session) - installed = is_faster_whisper_installed() - return ModelsOut( - default_model=default, - faster_whisper_installed=installed, - models=[ - TranscriptionModelOut( - name=info.name, - display_name=info.display_name, - description=info.description, - params=info.params, - approx_memory=info.approx_memory, - relative_speed=info.relative_speed, - is_default=info.name == default, - downloaded=is_model_downloaded(info.name), - available=installed and is_model_downloaded(info.name), - ) - for info in SUPPORTED_TRANSCRIPTION_MODELS.values() - ], - ) - - -@router.get("/models", response_model=ModelsOut) -async def list_models(user: CurrentUser, session: SessionDep): - return await _models_out(session) - - -@router.get("/models/default", response_model=DefaultModelOut) -async def get_default_model(user: CurrentUser, session: SessionDep): - return DefaultModelOut(default_model=await get_global_default_model(session)) - - -@router.put("/models/default", response_model=DefaultModelOut) -async def put_default_model(body: DefaultModelUpdate, user: CurrentUser, session: SessionDep): - try: - name = await set_global_default_model(session, body.model) - except ProviderConfigError as e: - raise HTTPException(422, str(e)) from None - return DefaultModelOut(default_model=name) - - -@router.post("/models/{name}/download", response_model=TranscriptionModelOut) -async def download_model(name: str, user: CurrentUser, session: SessionDep): - try: - clean = validate_model_name(name) - except ProviderConfigError as e: - raise HTTPException(422, str(e)) from None - if not is_faster_whisper_installed(): - raise HTTPException( - 501, - "faster-whisper is not installed on this server " - "(pip install shonar-backend[faster-whisper]).", - ) - from shonar.services.ai.faster_whisper import _load_model - - try: - await asyncio.to_thread(_load_model, clean) - except Exception as e: # noqa: BLE001 — surface download failures plainly - raise HTTPException(500, f"Model download failed: {e}") from None - default = await get_global_default_model(session) - info = SUPPORTED_TRANSCRIPTION_MODELS[clean] - return TranscriptionModelOut( - name=info.name, - display_name=info.display_name, - description=info.description, - params=info.params, - approx_memory=info.approx_memory, - relative_speed=info.relative_speed, - is_default=info.name == default, - downloaded=is_model_downloaded(clean), - available=is_model_downloaded(clean), - ) diff --git a/backend/shonar/api/v1/provider_info.py b/backend/shonar/api/v1/provider_info.py deleted file mode 100644 index 0382900..0000000 --- a/backend/shonar/api/v1/provider_info.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Public provider handshake. - -`GET /api/v1/provider-info` lets SHONAR clients (and platform probes on -Start9/Umbrel) identify this server as SHONAR-compatible and learn its -capabilities BEFORE any credentials are involved. Returns static deployment -metadata only — never user data, never configuration secrets. -""" - -from __future__ import annotations - -from fastapi import APIRouter - -from shonar import __version__ -from shonar.core.config import get_settings - -router = APIRouter(tags=["provider"]) - - -@router.get("/provider-info") -async def provider_info() -> dict: - """Static, unauthenticated deployment identity for client probing.""" - settings = get_settings() - # Capability flags reflect what this deployment can actually do right now. - transcription = settings.transcription_provider != "none" - llm = settings.llm_provider != "none" - return { - "kind": "shonar", - "version": __version__, - "api_version": "v1", - "capabilities": { - "chunked_upload": True, - "server_transcription": transcription, - "server_summary": llm, - "account_deletion": True, - }, - # Storage backend family (never a path): "local" | "s3" - "storage_backend": settings.storage_backend, - } diff --git a/backend/shonar/api/v1/recordings.py b/backend/shonar/api/v1/recordings.py deleted file mode 100644 index af61955..0000000 --- a/backend/shonar/api/v1/recordings.py +++ /dev/null @@ -1,528 +0,0 @@ -"""Upload sessions + recordings CRUD. - -Every endpoint enforces ownership server-side; missing/foreign resources -return 404 (no existence leaks). Storage keys are never exposed. -""" - -from __future__ import annotations - -import contextlib -import uuid -from datetime import datetime - -from fastapi import APIRouter, Header, HTTPException, Query, Request, Response -from sqlalchemy import func, select - -from shonar.api.deps import CurrentUser, SessionDep -from shonar.api.schemas_recordings import ( - ProcessingJobOut, - RecordingFinalize, - RecordingListOut, - RecordingOut, - RecordingUpdate, - SummaryOut, - SummaryUpdate, - TranscriptOut, - TranscriptUpdate, - UploadSessionCreate, - UploadSessionOut, - UploadStatusOut, -) -from shonar.db.models import ( - Asset, - AssetKind, - ProcessingJob, - Recording, - RecordingTag, - Summary, - Tag, - Transcript, - utcnow, -) -from shonar.services import uploads as up - -router = APIRouter(tags=["uploads", "recordings"]) - - -async def _recording_out(session, rec: Recording) -> RecordingOut: # noqa: ANN001 - original = await session.scalar( - select(Asset).where(Asset.recording_id == rec.id, Asset.kind == AssetKind.original) - ) - tags = await session.scalars( - select(Tag.name) - .join(RecordingTag, RecordingTag.tag_id == Tag.id) - .where(RecordingTag.recording_id == rec.id) - .order_by(Tag.name) - ) - return RecordingOut( - id=rec.id, - title=rec.title, - recorded_at=rec.recorded_at, - duration_seconds=rec.duration_seconds, - notes=rec.notes, - latitude=rec.latitude, - longitude=rec.longitude, - processing_status=rec.processing_status.value, - processing_error=rec.processing_error, - transcription_model=rec.transcription_model, - has_audio=original is not None, - mime_type=original.mime_type if original else None, - size_bytes=original.size_bytes if original else None, - tags=list(tags), - created_at=rec.created_at, - updated_at=rec.updated_at, - ) - - -# --- upload sessions --------------------------------------------------------- - - -@router.post("/uploads", response_model=UploadSessionOut, status_code=201) -async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: SessionDep): - try: - us = await up.create_session( - session, user.id, body.declared_mime_type, body.declared_size_bytes, - body.title, body.client_recording_id, body.transcription_model, - ) - except up.UploadError as e: - raise HTTPException(e.status_code, e.message) from None - return us - - -@router.get("/uploads/{session_id}", response_model=UploadStatusOut) -async def upload_status(session_id: uuid.UUID, user: CurrentUser, session: SessionDep): - try: - us = await up.get_owned_session(session, user.id, session_id) - indexes = await up.received_indexes(session, user.id, session_id) - except up.UploadError as e: - raise HTTPException(e.status_code, e.message) from None - return UploadStatusOut(id=us.id, status=us.status.value, received_chunk_indexes=indexes) - - -@router.put("/uploads/{session_id}/chunks/{chunk_index}", status_code=201) -async def put_chunk( - session_id: uuid.UUID, - chunk_index: int, - request: Request, - user: CurrentUser, - session: SessionDep, - x_chunk_sha256: str | None = Header(default=None), -): - data = await request.body() - try: - chunk = await up.put_chunk(session, user.id, session_id, chunk_index, data, x_chunk_sha256) - except up.UploadError as e: - raise HTTPException(e.status_code, e.message) from None - return {"chunk_index": chunk.chunk_index, "size_bytes": chunk.size_bytes} - - -@router.post("/uploads/{session_id}/finalize", response_model=RecordingOut, status_code=201) -async def finalize_upload( - session_id: uuid.UUID, body: RecordingFinalize, user: CurrentUser, session: SessionDep -): - # Location is stored ONLY with explicit per-user consent. - lat = body.latitude if user.location_storage_enabled else None - lon = body.longitude if user.location_storage_enabled else None - acc = body.location_accuracy_m if user.location_storage_enabled else None - try: - _us, rec, _asset = await up.finalize( - session, user.id, session_id, - recorded_at=body.recorded_at, duration_seconds=body.duration_seconds, - latitude=lat, longitude=lon, location_accuracy_m=acc, notes=body.notes, - transcription_model=body.transcription_model, - ) - except up.UploadError as e: - raise HTTPException(e.status_code, e.message) from None - return await _recording_out(session, rec) - - -@router.delete("/uploads/{session_id}", status_code=204) -async def abort_upload(session_id: uuid.UUID, user: CurrentUser, session: SessionDep): - try: - await up.abort(session, user.id, session_id) - except up.UploadError as e: - raise HTTPException(e.status_code, e.message) from None - - -# --- recordings ---------------------------------------------------------------- - - -@router.get("/recordings", response_model=RecordingListOut) -async def list_recordings( - user: CurrentUser, - session: SessionDep, - limit: int = Query(default=50, ge=1, le=200), - offset: int = Query(default=0, ge=0), - sort: str = Query( - default="recorded_at", - pattern="^(recorded_at|created_at|duration_seconds|title)$", - ), - order: str = Query(default="desc", pattern="^(asc|desc)$"), - # M9 filters (AND-combined). - tag: str | None = Query(default=None, max_length=80), - status: str | None = Query(default=None, max_length=20), - from_date: datetime | None = Query(default=None, description="recorded_at >= (UTC)"), - to_date: datetime | None = Query(default=None, description="recorded_at <= (UTC)"), -): - where = [Recording.user_id == user.id, Recording.deleted_at.is_(None)] - if tag: - from shonar.db.models import RecordingTag as RT - from shonar.db.models import Tag as T - - where.append( - Recording.id.in_( - select(RT.recording_id) - .join(T, T.id == RT.tag_id) - .where(T.user_id == user.id, T.name == tag.strip().lower()) - ) - ) - if status: - from shonar.db.models import ProcessingStatus - - try: - where.append(Recording.processing_status == ProcessingStatus(status)) - except ValueError: - raise HTTPException(422, f"Unknown status: {status}") from None - if from_date is not None: - where.append(Recording.recorded_at >= from_date) - if to_date is not None: - where.append(Recording.recorded_at <= to_date) - total = await session.scalar(select(func.count(Recording.id)).where(*where)) - col = getattr(Recording, sort) - col = col.desc() if order == "desc" else col.asc() - rows = await session.scalars( - select(Recording).where(*where).order_by(col).limit(limit).offset(offset) - ) - items = [await _recording_out(session, r) for r in rows] - return RecordingListOut(items=items, total=total or 0, limit=limit, offset=offset) - - -@router.get("/recordings/{recording_id}", response_model=RecordingOut) -async def get_recording(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep): - rec = await session.get(Recording, recording_id) - if rec is None or rec.user_id != user.id or rec.deleted_at is not None: - raise HTTPException(404, "Recording not found") - return await _recording_out(session, rec) - - -@router.patch("/recordings/{recording_id}", response_model=RecordingOut) -async def update_recording( - recording_id: uuid.UUID, body: RecordingUpdate, user: CurrentUser, session: SessionDep -): - rec = await session.get(Recording, recording_id) - if rec is None or rec.user_id != user.id or rec.deleted_at is not None: - raise HTTPException(404, "Recording not found") - if body.title is not None: - rec.title = body.title - if body.notes is not None: - rec.notes = body.notes - if body.latitude is not None and user.location_storage_enabled: - rec.latitude = body.latitude - if body.longitude is not None and user.location_storage_enabled: - rec.longitude = body.longitude - if body.tags is not None: - # Replace tag set. Tags are per-user, created on demand. - names = sorted(dict.fromkeys(t.strip().lower() for t in body.tags if t.strip()))[:20] - existing = list( - await session.scalars(select(Tag).where(Tag.user_id == user.id, Tag.name.in_(names))) - ) - by_name = {t.name: t for t in existing} - for name in names: - if name not in by_name: - t = Tag(user_id=user.id, name=name) - session.add(t) - await session.flush() - by_name[name] = t - # Deterministic replace: drop all links for this recording, re-add. - from sqlalchemy import delete as sql_delete - - await session.execute( - sql_delete(RecordingTag).where(RecordingTag.recording_id == rec.id) - ) - for name in names: - session.add(RecordingTag(recording_id=rec.id, tag_id=by_name[name].id)) - await session.flush() - return await _recording_out(session, rec) - - -@router.delete("/recordings/{recording_id}", status_code=204) -async def delete_recording( - recording_id: uuid.UUID, - user: CurrentUser, - session: SessionDep, - purge: bool = Query(default=False, description="true also deletes stored audio"), -): - """Soft-delete by default; ?purge=true removes rows + stored files now.""" - rec = await session.get(Recording, recording_id) - if rec is None or rec.user_id != user.id or rec.deleted_at is not None: - raise HTTPException(404, "Recording not found") - if not purge: - rec.deleted_at = utcnow() - await session.flush() - return Response(status_code=204) - - from shonar.storage import get_storage - - storage = get_storage() - assets = list( - await session.scalars(select(Asset).where(Asset.recording_id == rec.id)) - ) - await session.delete(rec) # cascades to assets/transcripts/summaries/jobs - await session.flush() - for a in assets: - with contextlib.suppress(Exception): # best effort - await storage.delete(a.storage_key) - return Response(status_code=204) - - -@router.get("/recordings/{recording_id}/audio") -async def download_audio(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep): - rec = await session.get(Recording, recording_id) - if rec is None or rec.user_id != user.id or rec.deleted_at is not None: - raise HTTPException(404, "Recording not found") - original = await session.scalar( - select(Asset).where(Asset.recording_id == rec.id, Asset.kind == AssetKind.original) - ) - if original is None: - raise HTTPException(404, "No audio stored for this recording") - from fastapi.responses import Response as RawResponse - - from shonar.storage import get_storage - - data = await get_storage().get(original.storage_key) - ext = original.storage_key[original.storage_key.rfind(".") :] - filename = f"{rec.recorded_at:%Y%m%d-%H%M%S}{ext}" - return RawResponse( - content=data, - media_type=original.mime_type, - headers={ - "Content-Disposition": f'attachment; filename="{filename}"', - "Cache-Control": "private, no-store", - }, - ) - - -# --- AI outputs (M7): latest transcript / summary / job states -------------- - - -async def _owned_recording( - session: SessionDep, user: CurrentUser, recording_id: uuid.UUID -) -> Recording: - rec = await session.get(Recording, recording_id) - if rec is None or rec.user_id != user.id or rec.deleted_at is not None: - raise HTTPException(404, "Recording not found") - return rec - - -@router.get("/recordings/{recording_id}/transcript", response_model=TranscriptOut) -async def get_transcript( - recording_id: uuid.UUID, user: CurrentUser, session: SessionDep -): - rec = await _owned_recording(session, user, recording_id) - row = await session.scalar( - select(Transcript) - .where( - Transcript.recording_id == rec.id, - Transcript.superseded_at.is_(None), - ) - .order_by(Transcript.version.desc()) - ) - if row is None: - raise HTTPException(404, "No transcript yet") - return _transcript_out(row) - - -@router.get("/recordings/{recording_id}/summary", response_model=SummaryOut) -async def get_summary(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep): - rec = await _owned_recording(session, user, recording_id) - row = await session.scalar( - select(Summary) - .where( - Summary.recording_id == rec.id, - Summary.superseded_at.is_(None), - ) - .order_by(Summary.version.desc()) - ) - if row is None: - raise HTTPException(404, "No summary yet") - return row - - -@router.get("/recordings/{recording_id}/jobs", response_model=list[ProcessingJobOut]) -async def list_jobs(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep): - rec = await _owned_recording(session, user, recording_id) - rows = await session.scalars( - select(ProcessingJob) - .where(ProcessingJob.recording_id == rec.id) - .order_by(ProcessingJob.id) - ) - return list(rows) - - -@router.post("/recordings/{recording_id}/reprocess", response_model=list[ProcessingJobOut]) -async def reprocess_recording( - recording_id: uuid.UUID, - user: CurrentUser, - session: SessionDep, - job: str = Query(default="summarize", pattern="^(transcribe|summarize)$"), - model: str | None = Query(default=None, max_length=64), -): - """Force one pipeline stage to run again (Summarize / Re-transcribe). - - Unlike the enqueue-on-finalize path, this ignores prior success: a - summary the user wants regenerated (better model, new prompt) is a - deliberate request. Running jobs are left alone (409 instead of a - duplicate). - """ - from shonar.db.models import JobStatus, ProcessingStatus - from shonar.db.models import JobType as JT - from shonar.services import processing as proc - - rec = await _owned_recording(session, user, recording_id) - job_type = JT(job) - if job_type is JT.summarize and await proc.latest_transcript_text( - session, rec.id - ) is None: - raise HTTPException(409, "Transcribe first — there is nothing to summarize.") - existing = await session.scalar( - select(ProcessingJob) - .where( - ProcessingJob.recording_id == rec.id, - ProcessingJob.job_type == job_type, - ) - .order_by(ProcessingJob.id.desc()) - ) - if existing is not None and existing.status in ( - JobStatus.queued, - JobStatus.running, - ): - raise HTTPException(409, "That stage is already running.") - if existing is None: - session.add(ProcessingJob(recording_id=rec.id, job_type=job_type)) - else: - existing.status = JobStatus.queued - existing.attempt = 0 - existing.error = None - existing.stage = None - existing.progress = None - existing.started_at = None - existing.finished_at = None - if job_type is JT.transcribe: - if model is not None: - # A re-transcribe may switch models; the saved per-recording - # override is what the worker reads, so persist it here. - from shonar.services.ai import ProviderConfigError - from shonar.services.ai.model_registry import validate_model_name - - try: - rec.transcription_model = validate_model_name(model) - except ProviderConfigError as e: - raise HTTPException(422, str(e)) from e - rec.processing_status = ProcessingStatus.processing - rec.processing_error = None - await session.flush() - await proc.transport_enqueue(job_type, rec.id) - rows = await session.scalars( - select(ProcessingJob) - .where(ProcessingJob.recording_id == rec.id) - .order_by(ProcessingJob.id) - ) - return list(rows) - - -# --- M8: user edits (new version, edited_by_user=True; pipeline won't clobber) -def _transcript_out(row: Transcript) -> TranscriptOut: - return TranscriptOut( - version=row.version, - language=row.language, - provider=row.provider, - model=row.model, - text=row.text, - segments=[ - s for s in (row.segments or []) if isinstance(s, dict) - ], - edited_by_user=row.edited_by_user, - created_at=row.created_at, - updated_at=row.updated_at, - ) - - -@router.put("/recordings/{recording_id}/transcript", response_model=TranscriptOut) -async def update_transcript( - recording_id: uuid.UUID, body: TranscriptUpdate, user: CurrentUser, session: SessionDep -): - from sqlalchemy import func as sql_func - - rec = await _owned_recording(session, user, recording_id) - existing = list( - await session.scalars( - select(Transcript) - .where( - Transcript.recording_id == rec.id, - Transcript.superseded_at.is_(None), - ) - .order_by(Transcript.version.desc()) - ) - ) - now = utcnow() - max_version = await session.scalar( - select(sql_func.max(Transcript.version)).where(Transcript.recording_id == rec.id) - ) - for row in existing: - row.superseded_at = now - row = Transcript( - recording_id=rec.id, - version=(max_version or 0) + 1, - language=body.language, - provider="user", - model=None, - text=body.text, - segments=( - [ - {"start": s.start, "end": s.end, "text": s.text, "speaker": s.speaker} - for s in (body.segments or []) - ] - if body.segments is not None - else None - ), - edited_by_user=True, - ) - session.add(row) - await session.flush() - return _transcript_out(row) - - -@router.put("/recordings/{recording_id}/summary", response_model=SummaryOut) -async def update_summary( - recording_id: uuid.UUID, body: SummaryUpdate, user: CurrentUser, session: SessionDep -): - from sqlalchemy import func as sql_func - - rec = await _owned_recording(session, user, recording_id) - existing = list( - await session.scalars( - select(Summary) - .where( - Summary.recording_id == rec.id, - Summary.superseded_at.is_(None), - ) - .order_by(Summary.version.desc()) - ) - ) - now = utcnow() - max_version = await session.scalar( - select(sql_func.max(Summary.version)).where(Summary.recording_id == rec.id) - ) - for row in existing: - row.superseded_at = now - row = Summary( - recording_id=rec.id, - version=(max_version or 0) + 1, - provider="user", - model=None, - content=body.content, - edited_by_user=True, - ) - session.add(row) - await session.flush() - return row diff --git a/backend/shonar/api/v1/search_exports.py b/backend/shonar/api/v1/search_exports.py deleted file mode 100644 index c568769..0000000 --- a/backend/shonar/api/v1/search_exports.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Search + exports endpoints (M9). - -Ownership is enforced everywhere: a search only ever sees the caller's own -non-deleted recordings, and an export 404s on anything the caller doesn't -own (no existence leak). -""" - -from __future__ import annotations - -import uuid - -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import Response - -from shonar.api.deps import CurrentUser, SessionDep -from shonar.api.schemas_search import SearchHitOut, SearchOut -from shonar.db.models import Recording -from shonar.services import exports as ex -from shonar.services import search as sr - -router = APIRouter(tags=["search", "exports"]) - - -# --- search ------------------------------------------------------------------- - - -@router.get("/search", response_model=SearchOut) -async def search( - user: CurrentUser, - session: SessionDep, - q: str = Query(..., min_length=1, max_length=200, description="Search text"), - scope: str = Query( - default="all", - pattern="^(all|title|notes|transcript|summary|tag)$", - description="Restrict the search to one field (default: all)", - ), - limit: int = Query(default=20, ge=1, le=100), - offset: int = Query(default=0, ge=0), -): - hits, total = await sr.search_recordings( - session, user.id, q, scope=scope, limit=limit, offset=offset - ) - items = [ - SearchHitOut( - id=h.recording.id, - title=h.recording.title, - field=h.field, - snippet=h.snippet, - ) - for h in hits - ] - return SearchOut(query=q, scope=scope, items=items, total=total, limit=limit, offset=offset) - - -# --- exports ------------------------------------------------------------------ - - -@router.get("/recordings/{recording_id}/export") -async def export_recording( - recording_id: uuid.UUID, - user: CurrentUser, - session: SessionDep, - fmt: str = Query(default="zip", pattern="^(audio|txt|md|zip)$"), -): - """Return one export artifact inline (audio / txt / md / zip bundle).""" - rec = await session.get(Recording, recording_id) - if rec is None or rec.user_id != user.id or rec.deleted_at is not None: - raise HTTPException(404, "Recording not found") - try: - result = await ex.build_export(session, rec, fmt) - except ex.ExportError as e: - raise HTTPException(e.status_code, e.message) from None - await ex.record_export(session, user.id, rec, fmt, result) - return Response( - content=result.data, - media_type=result.mime_type, - headers={ - "Content-Disposition": f'attachment; filename="{result.filename}"', - "Cache-Control": "private, no-store", - }, - ) diff --git a/backend/shonar/api/v1/users.py b/backend/shonar/api/v1/users.py deleted file mode 100644 index 55a16af..0000000 --- a/backend/shonar/api/v1/users.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Users and devices.""" - -from __future__ import annotations - -import uuid - -from fastapi import APIRouter, HTTPException -from sqlalchemy import select, update - -from shonar.api.deps import CurrentUser, SessionDep -from shonar.api.schemas_common import DeviceOut, UserOut, UserUpdate -from shonar.db.models import Device, RefreshToken, utcnow - -router = APIRouter(tags=["users", "devices"]) - - -@router.get("/users/me", response_model=UserOut) -async def get_me(user: CurrentUser): - return user - - -@router.patch("/users/me", response_model=UserOut) -async def update_me(body: UserUpdate, user: CurrentUser, session: SessionDep): - if body.display_name is not None: - user.display_name = body.display_name - if body.location_storage_enabled is not None: - user.location_storage_enabled = body.location_storage_enabled - await session.flush() - return user - - -@router.get("/devices", response_model=list[DeviceOut]) -async def list_devices(user: CurrentUser, session: SessionDep): - rows = await session.scalars( - select(Device).where(Device.user_id == user.id).order_by(Device.last_seen_at.desc()) - ) - return list(rows) - - -@router.delete("/devices/{device_id}", status_code=204) -async def revoke_device(device_id: uuid.UUID, user: CurrentUser, session: SessionDep): - device = await session.get(Device, device_id) - # Ownership check — no cross-user access, and 404 (not 403) to avoid - # leaking existence. - if device is None or device.user_id != user.id: - raise HTTPException(404, "Device not found") - device.revoked_at = utcnow() - # Revoke this device's live refresh tokens. - await session.execute( - update(RefreshToken) - .where(RefreshToken.device_id == device.id, RefreshToken.revoked_at.is_(None)) - .values(revoked_at=utcnow()) - ) - return None diff --git a/backend/shonar/core/__init__.py b/backend/shonar/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/shonar/core/config.py b/backend/shonar/core/config.py deleted file mode 100644 index 89e68a4..0000000 --- a/backend/shonar/core/config.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Application settings. - -All configuration comes from environment variables (and optionally an -admin config file pointed at by SHONAR_CONFIG_FILE). API keys and other -secrets must NEVER be hard-coded. -""" - -from __future__ import annotations - -from functools import lru_cache - -from pydantic import Field, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - model_config = SettingsConfigDict( - env_prefix="SHONAR_", - env_file=".env", - env_file_encoding="utf-8", - extra="ignore", - ) - - # --- Core ------------------------------------------------------------- - app_name: str = "S.H.O.N.A.R." - debug: bool = False - # Secret used to sign access tokens. MUST be set in production. - secret_key: str = Field(default="", repr=False) - access_token_ttl_minutes: int = 15 - refresh_token_ttl_days: int = 30 - # Comma-separated list of allowed registration modes: "open", "invite" - allow_registration: bool = True - - # --- Database ----------------------------------------------------------- - database_url: str = "postgresql+asyncpg://shonar:shonar@localhost:5432/shonar" - db_pool_size: int = 5 - db_max_overflow: int = 10 - - # --- Storage ------------------------------------------------------------ - # "local" or "s3" - storage_backend: str = "local" - storage_path: str = "./data/storage" - # S3 (used when storage_backend == "s3") - s3_endpoint_url: str = "" - s3_bucket: str = "" - s3_region: str = "us-east-1" - s3_access_key_id: str = Field(default="", repr=False) - s3_secret_access_key: str = Field(default="", repr=False) - - # --- Upload limits -------------------------------------------------------- - max_upload_bytes: int = 2 * 1024 * 1024 * 1024 # 2 GiB - max_chunk_bytes: int = 16 * 1024 * 1024 - allowed_audio_mime_types: list[str] = [ - "audio/mp4", - "audio/m4a", - "audio/aac", - "audio/wav", - "audio/x-wav", - "audio/ogg", - "audio/opus", - "audio/webm", - "audio/mpeg", - ] - - # --- Worker / queue ------------------------------------------------------- - redis_url: str = "redis://localhost:6379/0" - # "arq" = Redis transport (server deployments). "inline" = in-process - # asyncio runner (desktop bundled-lite engine: no Redis, one job at a - # time). DB rows are the queue of record either way. - queue_backend: str = "arq" # arq | inline - # Apply 'alembic upgrade head' at startup. The desktop bundled engine - # owns its SQLite file and must self-migrate; server deployments run - # migrations in their deploy flow instead. - auto_migrate: bool = False - - # --- Audio processing ----------------------------------------------------- - # Optional server-side conversion via FFmpeg. Off by default. - audio_conversion_enabled: bool = False - ffmpeg_bin: str = "ffmpeg" - - # --- AI providers ----------------------------------------------------- - # "none" disables all AI processing (recording/sync/playback still work). - transcription_provider: str = "none" # none | whisper_http | faster_whisper - transcription_model: str = "base" - transcription_base_url: str = "" # whisper-compatible HTTP server - transcription_api_key: str = Field(default="", repr=False) - - llm_provider: str = "none" # none | openai_compat | ollama - llm_model: str = "" - llm_base_url: str = "" - llm_api_key: str = Field(default="", repr=False) - - # --- Search --------------------------------------------------------- - search_backend: str = "postgres_fts" # postgres_fts (meilisearch: TODO) - - # --- Retention -------------------------------------------------------- - # Grace window before the sweep hard-deletes soft-deleted recordings - # and accounts (rows + stored files). Cancellations are valid until - # the sweep fires. - retention_grace_days: int = 30 - - # --- Misc ------------------------------------------------------------- - rate_limit_auth: str = "10/minute" - rate_limit_default: str = "120/minute" - - @field_validator("allowed_audio_mime_types", mode="before") - @classmethod - def _split_mime(cls, v): # noqa: ANN001, ANN206 - if isinstance(v, str): - return [item.strip() for item in v.split(",") if item.strip()] - return v - - @property - def ai_enabled(self) -> bool: - return self.transcription_provider != "none" or self.llm_provider != "none" - - def validate_production(self) -> list[str]: - """Return a list of configuration warnings (empty == OK).""" - warnings: list[str] = [] - if not self.secret_key or len(self.secret_key) < 32: - warnings.append( - "SHONAR_SECRET_KEY is missing or shorter than 32 characters. " - "Set a strong random secret in production." - ) - if self.storage_backend == "s3" and not self.s3_bucket: - warnings.append("storage_backend=s3 but SHONAR_S3_BUCKET is empty.") - if self.transcription_provider == "whisper_http" and not self.transcription_base_url: - warnings.append( - "transcription_provider=whisper_http requires SHONAR_TRANSCRIPTION_BASE_URL." - ) - if self.llm_provider == "openai_compat" and not self.llm_base_url: - warnings.append("llm_provider=openai_compat requires SHONAR_LLM_BASE_URL.") - return warnings - - -@lru_cache -def get_settings() -> Settings: - return Settings() diff --git a/backend/shonar/core/ratelimit.py b/backend/shonar/core/ratelimit.py deleted file mode 100644 index 621036b..0000000 --- a/backend/shonar/core/ratelimit.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Rate limiting (slowapi). A single shared limiter instance so counters are -consistent across endpoints; limit strings come from settings/env.""" - -from __future__ import annotations - -from slowapi import Limiter -from slowapi.util import get_remote_address - -from shonar.core.config import get_settings - -_settings = get_settings() - -limiter = Limiter( - key_func=get_remote_address, - default_limits=[], - enabled=True, -) - -auth_limit = limiter.limit(_settings.rate_limit_auth) diff --git a/backend/shonar/core/security.py b/backend/shonar/core/security.py deleted file mode 100644 index 8c08fd9..0000000 --- a/backend/shonar/core/security.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Security primitives: password hashing, access/refresh tokens, rate limit -keying. Secrets come exclusively from settings/env.""" - -from __future__ import annotations - -import hashlib -import hmac -import secrets -import uuid -from datetime import UTC, datetime, timedelta -from typing import Any - -import jwt -from argon2 import PasswordHasher -from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError - -from shonar.core.config import get_settings - -_ph = PasswordHasher() - -ACCESS_TOKEN_TYPE = "access" -REFRESH_TOKEN_TYPE = "refresh" - - -# --------------------------------------------------------------------------- -# Passwords -# --------------------------------------------------------------------------- - - -def hash_password(password: str) -> str: - return _ph.hash(password) - - -def verify_password(password_hash: str, password: str) -> bool: - try: - return _ph.verify(password_hash, password) - except (VerifyMismatchError, VerificationError, InvalidHashError): - return False - - -# --------------------------------------------------------------------------- -# Access tokens (JWT) -# --------------------------------------------------------------------------- - - -def create_access_token(user_id: uuid.UUID, device_id: uuid.UUID | None = None) -> tuple[str, int]: - """Returns (token, ttl_seconds).""" - settings = get_settings() - ttl = settings.access_token_ttl_minutes * 60 - now = datetime.now(UTC) - payload: dict[str, Any] = { - "sub": str(user_id), - "typ": ACCESS_TOKEN_TYPE, - "iat": now, - "exp": now + timedelta(seconds=ttl), - "jti": uuid.uuid4().hex, - } - if device_id is not None: - payload["dev"] = str(device_id) - token = jwt.encode(payload, settings.secret_key, algorithm="HS256") - return token, ttl - - -class TokenError(Exception): - """Invalid or expired token.""" - - -def decode_access_token(token: str) -> dict[str, Any]: - settings = get_settings() - try: - payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) - except jwt.PyJWTError as exc: - raise TokenError("invalid access token") from exc - if payload.get("typ") != ACCESS_TOKEN_TYPE: - raise TokenError("wrong token type") - return payload - - -# --------------------------------------------------------------------------- -# Refresh tokens (opaque, rotated, hashed at rest) -# --------------------------------------------------------------------------- - - -def generate_refresh_token() -> str: - """Opaque high-entropy token; only its SHA-256 hash is ever stored.""" - return secrets.token_urlsafe(48) - - -def hash_refresh_token(token: str) -> str: - return hashlib.sha256(token.encode("utf-8")).hexdigest() - - -def refresh_token_ttl() -> timedelta: - return timedelta(days=get_settings().refresh_token_ttl_days) - - -def constant_time_equals(a: str, b: str) -> bool: - return hmac.compare_digest(a, b) diff --git a/backend/shonar/db/__init__.py b/backend/shonar/db/__init__.py deleted file mode 100644 index 0420631..0000000 --- a/backend/shonar/db/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""DB package. Importing this registers every model on Base.metadata.""" - -from shonar.db import models # noqa: F401 diff --git a/backend/shonar/db/base.py b/backend/shonar/db/base.py deleted file mode 100644 index ab9980d..0000000 --- a/backend/shonar/db/base.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Public SQLAlchemy model base.""" - -from __future__ import annotations - -import uuid -from datetime import UTC, datetime - -from sqlalchemy import DateTime, TypeDecorator, Uuid -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column - - -class UTCDT(TypeDecorator): - """Timezone-aware datetime that survives SQLite round-trips. - - Postgres returns aware values (impl is a no-op there); SQLite has no - tz info and hands back naive datetimes, which crash comparisons with - ``utcnow()``. Attach UTC on read whenever the driver lost it. - """ - - impl = DateTime(timezone=True) - cache_ok = True - - def process_result_value(self, value, dialect): # noqa: ANN001, ANN201 - if value is not None and value.tzinfo is None: - return value.replace(tzinfo=UTC) - return value - - -def utcnow() -> datetime: - return datetime.now(UTC) - - -def new_uuid() -> uuid.UUID: - return uuid.uuid4() - - -class Base(DeclarativeBase): - pass - - -class PublicIdMixin: - """Gives every table a UUID public id; integer PKs stay internal.""" - - id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=new_uuid) - created_at: Mapped[datetime] = mapped_column(UTCDT(), default=utcnow, nullable=False) - # default= as well as onupdate=: SQLite enforces NOT NULL on insert - # (Postgres silently stored NULL for rows that never got an UPDATE). - updated_at: Mapped[datetime] = mapped_column( - UTCDT(), default=utcnow, onupdate=utcnow, nullable=False - ) diff --git a/backend/shonar/db/migrate.py b/backend/shonar/db/migrate.py deleted file mode 100644 index f297f16..0000000 --- a/backend/shonar/db/migrate.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Programmatic schema migrations for the bundled desktop engine. - -Server deployments run ``alembic upgrade head`` in their deploy flow; -the desktop app owns its SQLite file end-to-end, so the API applies -migrations itself at startup (``SHONAR_AUTO_MIGRATE=1``). -""" - -from __future__ import annotations - -import asyncio -import logging -from pathlib import Path - -from alembic import command -from alembic.config import Config - -logger = logging.getLogger("shonar.migrate") - -# backend/ dir: migrations/ + alembic.ini live here relative to this file. -_BACKEND_DIR = Path(__file__).resolve().parents[2] - - -async def upgrade_head(database_url: str) -> None: - """Run 'alembic upgrade head' for `database_url` without blocking the loop.""" - - def _run() -> None: - cfg = Config(str(_BACKEND_DIR / "alembic.ini")) - cfg.set_main_option("script_location", str(_BACKEND_DIR / "migrations")) - cfg.set_main_option("sqlalchemy.url", database_url) - command.upgrade(cfg, "head") - - await asyncio.to_thread(_run) - logger.info("database schema migrated to head") diff --git a/backend/shonar/db/models.py b/backend/shonar/db/models.py deleted file mode 100644 index a19f4c2..0000000 --- a/backend/shonar/db/models.py +++ /dev/null @@ -1,453 +0,0 @@ -"""All persistent models. - -Design rules: -- Every client-visible identifier is a UUID (``PublicIdMixin.id``). -- Filesystem/storage paths are NEVER exposed to clients. -- Original uploads are immutable; processed audio lives in separate - ``Asset`` rows and never replaces an original. -""" - -from __future__ import annotations - -import enum -import uuid -from datetime import datetime - -# JSON that renders JSONB on PostgreSQL and plain JSON on SQLite (the -# desktop bundled-lite engine runs on SQLite; JSONB does not compile there). -from sqlalchemy import JSON as _JSON -from sqlalchemy import ( - BigInteger, - Boolean, - Enum, - Float, - ForeignKey, - Index, - String, - Text, - UniqueConstraint, -) -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from shonar.db.base import UTCDT, Base, PublicIdMixin, utcnow - -JSONType = _JSON().with_variant(JSONB(), "postgresql") - - -# --------------------------------------------------------------------------- -# Users & auth -# --------------------------------------------------------------------------- - - -class User(Base, PublicIdMixin): - __tablename__ = "users" - - email: Mapped[str] = mapped_column(String(320), unique=True, index=True, nullable=False) - password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - display_name: Mapped[str | None] = mapped_column(String(120)) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - # Feature switches the user controls (mirror of app settings, server truth - # for e.g. whether location metadata may be stored at all). - location_storage_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - deleted_at: Mapped[datetime | None] = mapped_column(UTCDT()) - - devices: Mapped[list[Device]] = relationship(back_populates="user") - recordings: Mapped[list[Recording]] = relationship(back_populates="user") - - -class Device(Base, PublicIdMixin): - __tablename__ = "devices" - - user_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False - ) - name: Mapped[str] = mapped_column(String(120), nullable=False) - platform: Mapped[str] = mapped_column(String(40), default="android", nullable=False) - last_seen_at: Mapped[datetime] = mapped_column(UTCDT(), default=utcnow) - revoked_at: Mapped[datetime | None] = mapped_column(UTCDT()) - - user: Mapped[User] = relationship(back_populates="devices") - - -class RefreshToken(Base, PublicIdMixin): - """Rotating refresh tokens, stored hashed, grouped into families for - reuse detection. A refresh consumes one row and issues its replacement - with the same ``family``.""" - - __tablename__ = "refresh_tokens" - - user_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False - ) - token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) - family: Mapped[uuid.UUID] = mapped_column(index=True, nullable=False) - device_id: Mapped[uuid.UUID | None] = mapped_column( - ForeignKey("devices.id", ondelete="SET NULL") - ) - expires_at: Mapped[datetime] = mapped_column(UTCDT(), nullable=False) - revoked_at: Mapped[datetime | None] = mapped_column(UTCDT()) - replaced_by: Mapped[uuid.UUID | None] = mapped_column( - ForeignKey("refresh_tokens.id", ondelete="SET NULL") - ) - - -# --------------------------------------------------------------------------- -# Recordings, assets, uploads -# --------------------------------------------------------------------------- - - -class ProcessingStatus(enum.StrEnum): - pending_upload = "pending_upload" - uploaded = "uploaded" - queued = "queued" - processing = "processing" - completed = "completed" - failed = "failed" - # No AI configured / requested — audio-only recording, fully usable. - ai_disabled = "ai_disabled" - - -class Recording(Base, PublicIdMixin): - __tablename__ = "recordings" - - user_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False - ) - device_id: Mapped[uuid.UUID | None] = mapped_column( - ForeignKey("devices.id", ondelete="SET NULL") - ) - # Client-generated idempotency id so retried uploads update, not duplicate. - client_recording_id: Mapped[str | None] = mapped_column(String(64), index=True) - - title: Mapped[str] = mapped_column(String(300), nullable=False, default="Untitled recording") - recorded_at: Mapped[datetime] = mapped_column(UTCDT(), nullable=False) - duration_seconds: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) - notes: Mapped[str | None] = mapped_column(Text) - # Location is stored ONLY when the user has explicitly enabled it. - latitude: Mapped[float | None] = mapped_column(Float) - longitude: Mapped[float | None] = mapped_column(Float) - location_accuracy_m: Mapped[float | None] = mapped_column(Float) - - processing_status: Mapped[ProcessingStatus] = mapped_column( - Enum( - ProcessingStatus, - name="processing_status", - values_callable=lambda e: [m.value for m in e], - ), - default=ProcessingStatus.pending_upload, - nullable=False, - index=True, - ) - processing_error: Mapped[str | None] = mapped_column(Text) - # Effective faster-whisper model for this recording (e.g. "base"). - # Set at finalize time from the per-recording override or the global - # default; NULL means "server default at processing time" (pre-model - # rows). Never rewritten: changing the default affects future rows only. - transcription_model: Mapped[str | None] = mapped_column(String(32)) - # The original audio is the Asset row with kind=original for this - # recording (at most one, enforced by a partial unique index). Keeping - # the pointer one-directional avoids a recordings<->assets FK cycle. - deleted_at: Mapped[datetime | None] = mapped_column(UTCDT()) - - user: Mapped[User] = relationship(back_populates="recordings") - assets: Mapped[list[Asset]] = relationship( - back_populates="recording", cascade="all, delete-orphan" - ) - transcripts: Mapped[list[Transcript]] = relationship( - back_populates="recording", cascade="all, delete-orphan" - ) - summaries: Mapped[list[Summary]] = relationship( - back_populates="recording", cascade="all, delete-orphan" - ) - tags: Mapped[list[Tag]] = relationship(secondary="recording_tags", back_populates="recordings") - - __table_args__ = ( - Index("ix_recordings_user_recorded", "user_id", "recorded_at"), - Index( - "ix_recordings_user_client_id", - "user_id", - "client_recording_id", - unique=True, - postgresql_where="client_recording_id IS NOT NULL", - ), - ) - - -class AssetKind(enum.StrEnum): - original = "original" # user's uploaded file — NEVER modified - normalized = "normalized" # derivative for processing (ffmpeg) - export = "export" # generated export bundle - - -class Asset(Base, PublicIdMixin): - """A stored binary object. ``storage_key`` is server-internal only.""" - - __tablename__ = "assets" - - recording_id: Mapped[uuid.UUID | None] = mapped_column( - ForeignKey("recordings.id", ondelete="CASCADE"), index=True - ) - user_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False - ) - kind: Mapped[AssetKind] = mapped_column( - Enum(AssetKind, name="asset_kind", values_callable=lambda e: [m.value for m in e]), - nullable=False, - ) - storage_key: Mapped[str] = mapped_column(String(500), nullable=False) - mime_type: Mapped[str] = mapped_column(String(100), nullable=False) - size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) - checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False) - - recording: Mapped[Recording | None] = relationship(back_populates="assets") - - __table_args__ = ( - # At most one immutable "original" per recording. - Index( - "uq_assets_one_original", - "recording_id", - unique=True, - postgresql_where="kind = 'original'", - ), - ) - - -class UploadSessionStatus(enum.StrEnum): - open = "open" - finalizing = "finalizing" - completed = "completed" - aborted = "aborted" - expired = "expired" - - -class UploadSession(Base, PublicIdMixin): - """Chunked, resumable upload session.""" - - __tablename__ = "upload_sessions" - - user_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False - ) - recording_id: Mapped[uuid.UUID | None] = mapped_column( - ForeignKey("recordings.id", ondelete="CASCADE") - ) - client_recording_id: Mapped[str | None] = mapped_column(String(64)) - title: Mapped[str | None] = mapped_column(String(300)) - # Optional per-recording transcription model override chosen on the - # upload screen. Validated at finalize time; the finalize body wins when - # both specify one. - transcription_model: Mapped[str | None] = mapped_column(String(32)) - declared_mime_type: Mapped[str] = mapped_column(String(100), nullable=False) - declared_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) - chunk_size_bytes: Mapped[int] = mapped_column( - BigInteger, nullable=False, default=8 * 1024 * 1024 - ) - status: Mapped[UploadSessionStatus] = mapped_column( - Enum( - UploadSessionStatus, - name="upload_session_status", - values_callable=lambda e: [m.value for m in e], - ), - default=UploadSessionStatus.open, - nullable=False, - ) - expires_at: Mapped[datetime] = mapped_column(UTCDT(), nullable=False) - completed_asset_id: Mapped[uuid.UUID | None] = mapped_column( - ForeignKey("assets.id", ondelete="SET NULL") - ) - - chunks: Mapped[list[UploadChunk]] = relationship( - back_populates="session", cascade="all, delete-orphan" - ) - - -class UploadChunk(Base): - __tablename__ = "upload_chunks" - - id: Mapped[int] = mapped_column(primary_key=True) - session_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("upload_sessions.id", ondelete="CASCADE"), index=True, nullable=False - ) - chunk_index: Mapped[int] = mapped_column(nullable=False) - size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) - checksum_sha256: Mapped[str] = mapped_column(String(64), nullable=False) - storage_key: Mapped[str] = mapped_column(String(500), nullable=False) - created_at: Mapped[datetime] = mapped_column(UTCDT(), default=utcnow) - - session: Mapped[UploadSession] = relationship(back_populates="chunks") - - __table_args__ = (UniqueConstraint("session_id", "chunk_index"),) - - -# --------------------------------------------------------------------------- -# Transcript / summary / tags -# --------------------------------------------------------------------------- - - -class Transcript(Base, PublicIdMixin): - __tablename__ = "transcripts" - - recording_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("recordings.id", ondelete="CASCADE"), index=True, nullable=False - ) - # Versioned: regenerated transcripts supersede older rows; the newest - # non-superseded row is authoritative. ``edited_by_user`` rows win. - version: Mapped[int] = mapped_column(default=1, nullable=False) - superseded_at: Mapped[datetime | None] = mapped_column(UTCDT()) - language: Mapped[str | None] = mapped_column(String(16)) - provider: Mapped[str] = mapped_column(String(80), nullable=False, default="manual") - model: Mapped[str | None] = mapped_column(String(120)) - # Full text, plus segments: [{"start": 0.0, "end": 2.5, "text": "...", - # "speaker": "S1"|null}, ...] - text: Mapped[str] = mapped_column(Text, nullable=False, default="") - segments: Mapped[list | None] = mapped_column(JSONType) - edited_by_user: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - - recording: Mapped[Recording] = relationship(back_populates="transcripts") - - __table_args__ = (Index("ix_transcripts_recording_version", "recording_id", "version"),) - - -class Summary(Base, PublicIdMixin): - """Structured AI summary — user-editable. - - JSON shape of ``content``: - { - "short": str, - "detailed": str, - "key_points": [str], - "decisions": [str], - "action_items": [str], - "questions": [str] - } - """ - - __tablename__ = "summaries" - - recording_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("recordings.id", ondelete="CASCADE"), index=True, nullable=False - ) - version: Mapped[int] = mapped_column(default=1, nullable=False) - superseded_at: Mapped[datetime | None] = mapped_column(UTCDT()) - provider: Mapped[str] = mapped_column(String(80), nullable=False, default="manual") - model: Mapped[str | None] = mapped_column(String(120)) - content: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) - edited_by_user: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - - recording: Mapped[Recording] = relationship(back_populates="summaries") - - -class Tag(Base, PublicIdMixin): - __tablename__ = "tags" - - user_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False - ) - name: Mapped[str] = mapped_column(String(80), nullable=False) - - recordings: Mapped[list[Recording]] = relationship( - secondary="recording_tags", back_populates="tags" - ) - - __table_args__ = (UniqueConstraint("user_id", "name"),) - - -class RecordingTag(Base): - __tablename__ = "recording_tags" - - recording_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("recordings.id", ondelete="CASCADE"), primary_key=True - ) - tag_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True - ) - - -# --------------------------------------------------------------------------- -# Processing jobs -# --------------------------------------------------------------------------- - - -class JobStatus(enum.StrEnum): - queued = "queued" - running = "running" - succeeded = "succeeded" - failed = "failed" - skipped = "skipped" # e.g. AI disabled, or user-edited output protected - - -class JobType(enum.StrEnum): - normalize_audio = "normalize_audio" - transcribe = "transcribe" - summarize = "summarize" - - -class ProcessingJob(Base, PublicIdMixin): - """One pipeline step for one recording. Idempotent: reruns overwrite - derived outputs (unless user-edited) and never touch originals.""" - - __tablename__ = "processing_jobs" - - recording_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("recordings.id", ondelete="CASCADE"), index=True, nullable=False - ) - job_type: Mapped[JobType] = mapped_column( - Enum(JobType, name="job_type", values_callable=lambda e: [m.value for m in e]), - nullable=False, - ) - status: Mapped[JobStatus] = mapped_column( - Enum(JobStatus, name="job_status", values_callable=lambda e: [m.value for m in e]), - default=JobStatus.queued, - nullable=False, - index=True, - ) - attempt: Mapped[int] = mapped_column(default=0, nullable=False) - max_attempts: Mapped[int] = mapped_column(default=3, nullable=False) - error: Mapped[str | None] = mapped_column(Text) - # Fine-grained phase for progress display (e.g. transcribe jobs report - # "loading-model" then "transcribing"). Nullable: older rows predate it. - # Never parsed by pipeline logic — display only. - stage: Mapped[str | None] = mapped_column(String(32)) - # 0-100 work estimate within the current stage, when known. - progress: Mapped[int | None] = mapped_column() - started_at: Mapped[datetime | None] = mapped_column(UTCDT()) - finished_at: Mapped[datetime | None] = mapped_column(UTCDT()) - # Opaque arq task handle for observability. - task_handle: Mapped[str | None] = mapped_column(String(120)) - - __table_args__ = (Index("ix_processing_jobs_recording_type", "recording_id", "job_type"),) - - -class ExportJob(Base, PublicIdMixin): - __tablename__ = "export_jobs" - - user_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False - ) - recording_id: Mapped[uuid.UUID] = mapped_column( - ForeignKey("recordings.id", ondelete="CASCADE"), index=True, nullable=False - ) - # "audio", "transcript_txt", "notes_md", "bundle_zip" - export_type: Mapped[str] = mapped_column(String(40), nullable=False) - status: Mapped[JobStatus] = mapped_column( - Enum(JobStatus, name="export_job_status", values_callable=lambda e: [m.value for m in e]), - default=JobStatus.queued, - nullable=False, - ) - asset_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("assets.id", ondelete="SET NULL")) - error: Mapped[str | None] = mapped_column(Text) - - -# Ensure full-text search columns exist on Postgres (added via migration as -# tsvector generated columns; see migrations/versions/*_fts.py). - - -class AppSetting(Base, PublicIdMixin): - """Server-wide settings editable at runtime (global transcription model - default, …). Single row per key; the desktop Settings page writes here.""" - - __tablename__ = "app_settings" - - key: Mapped[str] = mapped_column(String(120), nullable=False, unique=True) - value: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) diff --git a/backend/shonar/db/session.py b/backend/shonar/db/session.py deleted file mode 100644 index bba2b68..0000000 --- a/backend/shonar/db/session.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Async SQLAlchemy engine/session management.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator - -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from shonar.core.config import get_settings - -_engine = None -_session_factory: async_sessionmaker[AsyncSession] | None = None - - -def get_engine(): - global _engine, _session_factory - if _engine is None: - settings = get_settings() - kwargs: dict = {"pool_pre_ping": True} - url = settings.database_url - # SQLite (tests, desktop bundled engine) does not support the pg - # pool sizing kwargs. - if url.startswith("sqlite"): - kwargs = {} - else: - kwargs.update(pool_size=settings.db_pool_size, max_overflow=settings.db_max_overflow) - _engine = create_async_engine(url, **kwargs) - if url.startswith("sqlite"): - _configure_sqlite(_engine) - _session_factory = async_sessionmaker(_engine, expire_on_commit=False) - return _engine - - -def _configure_sqlite(engine) -> None: # noqa: ANN001 - """Per-connection SQLite pragmas. - - foreign_keys is OFF by default in SQLite; the schema leans on - ON DELETE CASCADE, so every connection must enable it. WAL lets the - inline-queue writer and API readers coexist without SQLITE_BUSY - storms on the desktop box. - """ - from sqlalchemy import event - - @event.listens_for(engine.sync_engine, "connect") - def _pragmas(dbapi_conn, _record): # noqa: ANN001 - cur = dbapi_conn.cursor() - cur.execute("PRAGMA foreign_keys=ON") - cur.execute("PRAGMA journal_mode=WAL") - cur.execute("PRAGMA busy_timeout=5000") - cur.close() - - -async def dispose_engine() -> None: - global _engine, _session_factory - if _engine is not None: - await _engine.dispose() - _engine = None - _session_factory = None - - -async def get_session() -> AsyncIterator[AsyncSession]: - """FastAPI dependency yielding a database session.""" - assert _session_factory is not None, "engine not initialised" - async with _session_factory() as session: - try: - yield session - await session.commit() - except Exception: - await session.rollback() - raise - - -def session_factory() -> async_sessionmaker[AsyncSession]: - """Shareable session factory for the worker (outside requests).""" - assert _session_factory is not None, "engine not initialised" - return _session_factory diff --git a/backend/shonar/main.py b/backend/shonar/main.py deleted file mode 100644 index 16fb2a8..0000000 --- a/backend/shonar/main.py +++ /dev/null @@ -1,79 +0,0 @@ -"""S.H.O.N.A.R. FastAPI application entrypoint. - -Run (dev): uvicorn shonar.main:app --reload --port 8000 -""" - -from __future__ import annotations - -import logging -from contextlib import asynccontextmanager - -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse -from slowapi import _rate_limit_exceeded_handler -from slowapi.errors import RateLimitExceeded - -from shonar import __version__ -from shonar.api.v1 import api_router -from shonar.core.config import get_settings -from shonar.core.ratelimit import limiter -from shonar.db.session import dispose_engine, get_engine - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("shonar") - - -@asynccontextmanager -async def lifespan(app: FastAPI): - settings = get_settings() - get_engine() # validate URL parses; connections are lazy - for warning in settings.validate_production(): - logger.warning("CONFIG: %s", warning) - if settings.auto_migrate: - from shonar.db.migrate import upgrade_head - - await upgrade_head(settings.database_url) - inline = settings.queue_backend.strip().lower() == "inline" - if inline: - from shonar.services import inline_queue - - await inline_queue.start() - yield - if inline: - from shonar.services import inline_queue - - await inline_queue.stop() - await dispose_engine() - - -app = FastAPI( - title="SHONAR API", - description=( - "SHONAR — Self-hosted Oral Notes and Audio Recorder. REST API. " - "All data stays on your server." - ), - version=__version__, - lifespan=lifespan, - # Interactive docs at the conventional paths. - docs_url="/docs", - redoc_url="/redoc", - openapi_url="/openapi.json", -) - -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - - -@app.exception_handler(Exception) -async def unhandled_exception_handler(request: Request, exc: Exception): - """Safe error surface: log details server-side, never leak internals.""" - logger.exception("Unhandled error on %s %s", request.method, request.url.path) - return JSONResponse(status_code=500, content={"detail": "Internal server error"}) - - -app.include_router(api_router) - - -@app.get("/") -async def root(): - return {"app": "SHONAR", "docs": "/docs", "health": "/api/v1/healthz"} diff --git a/backend/shonar/services/__init__.py b/backend/shonar/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/shonar/services/ai/__init__.py b/backend/shonar/services/ai/__init__.py deleted file mode 100644 index 700254c..0000000 --- a/backend/shonar/services/ai/__init__.py +++ /dev/null @@ -1,160 +0,0 @@ -"""AI provider interfaces (M7). - -Two independent axes, both optional and both configured only through -environment variables (never hard-coded keys): - -- transcription: none | whisper_http | faster_whisper -- LLM (summary/action items): none | openai_compat | ollama - -"none" is a first-class choice: recording, sync, playback, and manual -transcripts work with no AI configured at all. The pipeline treats a -missing provider as "skip this stage", never as an error. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Protocol - -from shonar.core.config import Settings - - -class AIError(Exception): - """Base for AI failures. Messages must be user-safe: they surface in - ``processing_error`` and therefore on screens.""" - - -class ProviderConfigError(AIError): - """Persistent misconfiguration (bad credentials, unknown model, missing - dependency). Fails the job immediately — retrying cannot help.""" - - -class ProviderTransientError(AIError): - """May succeed on retry (timeouts, 429/5xx). The worker requeues these - up to the job's max attempts.""" - - -@dataclass(frozen=True) -class Segment: - start: float - end: float - text: str - speaker: str | None = None - - -@dataclass(frozen=True) -class TranscriptResult: - text: str - language: str | None - segments: list[Segment] = field(default_factory=list) - model: str = "" - - -class TranscriptionProvider(Protocol): - name: str - - async def transcribe( - self, - audio: bytes, - mime: str, - *, - language_hint: str | None = None, - on_progress=None, # optional Callable[[int], None], 0..99 - ) -> TranscriptResult: ... - - -SUMMARY_KEYS = ("short", "detailed", "key_points", "decisions", "action_items", "questions") - - -@dataclass(frozen=True) -class SummaryResult: - short: str = "" - detailed: str = "" - key_points: tuple[str, ...] = () - decisions: tuple[str, ...] = () - action_items: tuple[str, ...] = () - questions: tuple[str, ...] = () - model: str = "" - - def to_dict(self) -> dict: - return { - "short": self.short, - "detailed": self.detailed, - "key_points": list(self.key_points), - "decisions": list(self.decisions), - "action_items": list(self.action_items), - "questions": list(self.questions), - } - - @classmethod - def from_dict(cls, raw: dict, model: str = "") -> SummaryResult: - def text(key: str) -> str: - v = raw.get(key) - return v if isinstance(v, str) else "" - - def strs(key: str) -> tuple[str, ...]: - v = raw.get(key) - if not isinstance(v, list): - return () - return tuple(s for s in v if isinstance(s, str) and s.strip()) - - return cls( - short=text("short"), - detailed=text("detailed"), - key_points=strs("key_points"), - decisions=strs("decisions"), - action_items=strs("action_items"), - questions=strs("questions"), - model=model, - ) - - -class LlmProvider(Protocol): - name: str - - async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: ... - - -def get_transcription_provider(settings: Settings) -> TranscriptionProvider | None: - """None means "transcription stage skipped", never an error.""" - kind = settings.transcription_provider.strip().lower() - if kind in ("", "none"): - return None - if kind == "whisper_http": - from shonar.services.ai.whisper_http import WhisperHttpProvider - - return WhisperHttpProvider( - base_url=settings.transcription_base_url, - model=settings.transcription_model, - api_key=settings.transcription_api_key, - ) - if kind == "faster_whisper": - from shonar.services.ai.faster_whisper import FasterWhisperProvider - - return FasterWhisperProvider(model=settings.transcription_model) - raise ProviderConfigError( - f"Unknown transcription provider: {settings.transcription_provider!r}" - ) - - -def get_llm_provider(settings: Settings) -> LlmProvider | None: - """None means "summary stage skipped", never an error.""" - kind = settings.llm_provider.strip().lower() - if kind in ("", "none"): - return None - if kind == "openai_compat": - from shonar.services.ai.openai_compat import OpenAICompatProvider - - return OpenAICompatProvider( - base_url=settings.llm_base_url, - model=settings.llm_model, - api_key=settings.llm_api_key, - ) - if kind == "ollama": - from shonar.services.ai.ollama import OllamaProvider - - return OllamaProvider( - base_url=settings.llm_base_url, - model=settings.llm_model, - ) - raise ProviderConfigError(f"Unknown LLM provider: {settings.llm_provider!r}") diff --git a/backend/shonar/services/ai/_llm.py b/backend/shonar/services/ai/_llm.py deleted file mode 100644 index 6427a41..0000000 --- a/backend/shonar/services/ai/_llm.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Shared summary contract: one system prompt, one JSON shape. - -The model replies with JSON only; partial replies are accepted and missing -keys default to empty (a terse-but-valid summary beats a failed job). -Transcript input is truncated to bound context — a way in, not a report. -""" - -from __future__ import annotations - -from shonar.services.ai import SummaryResult - -SYSTEM_PROMPT = ( - "You summarize voice recordings for the speaker's own later reference. " - "Reply with JSON only, exactly these keys: " - '{"short": "1-2 sentences", "detailed": "a faithful paragraph", ' - '"key_points": [], "decisions": [], "action_items": [], "questions": []}. ' - "Empty arrays when absent. Never invent names, dates, or commitments " - "not stated in the transcript." -) - -MAX_TRANSCRIPT_CHARS = 12_000 - - -def build_user_message(transcript: str, title: str | None) -> str: - text = transcript[:MAX_TRANSCRIPT_CHARS] - if len(transcript) > MAX_TRANSCRIPT_CHARS: - text += f"\n\n[truncated from {len(transcript)} chars]" - head = f'Title: "{title}"\n\n' if title else "" - return head + "Transcript:\n" + text - - -def parse_summary(data: object, model: str) -> SummaryResult: - if not isinstance(data, dict): - return SummaryResult(model=model) - return SummaryResult.from_dict(data, model=model) diff --git a/backend/shonar/services/ai/faster_whisper.py b/backend/shonar/services/ai/faster_whisper.py deleted file mode 100644 index 3c2b9b7..0000000 --- a/backend/shonar/services/ai/faster_whisper.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Local transcription via faster-whisper (optional dependency). - -Runs fully on this machine: audio never leaves the server for this stage. -The import is lazy so the base install (and every test run) works without -the heavyweight dependency. -""" - -from __future__ import annotations - -import asyncio -import tempfile -import threading -from pathlib import Path - -from shonar.services.ai import ProviderConfigError, Segment, TranscriptResult -from shonar.services.ai.model_registry import ( - download_instructions, - is_model_downloaded, - validate_model_name, -) - -# One loaded model per name, shared across jobs in this worker process. -# ctranslate2 inference is thread-safe; a lock serializes first-load only. -_MODEL_CACHE: dict[str, object] = {} -_MODEL_CACHE_LOCK = threading.Lock() - - -def _load_model(name: str) -> object: - from faster_whisper import WhisperModel - - with _MODEL_CACHE_LOCK: - model = _MODEL_CACHE.get(name) - if model is None: - model = WhisperModel(name, device="auto") - _MODEL_CACHE[name] = model - return model - - -def ensure_model_available(name: str) -> None: - """Fail fast with download instructions instead of triggering a surprise - multi-GB download inside a transcription job.""" - if not is_model_downloaded(name): - raise ProviderConfigError(download_instructions(name)) - - -class FasterWhisperProvider: - name = "faster_whisper" - - def __init__(self, model: str = "base") -> None: - try: - import faster_whisper # noqa: F401 - except ImportError as e: - raise ProviderConfigError( - "faster-whisper is not installed (pip install shonar-backend[faster-whisper])." - ) from e - self.model = validate_model_name(model or "base") - - async def transcribe( - self, - audio: bytes, - mime: str, - *, - language_hint: str | None = None, - on_progress=None, # Callable[[int], None] | None — 0..99 percent - ) -> TranscriptResult: - # faster-whisper is blocking CPU work: keep it off the event loop. - return await asyncio.to_thread(self._run, audio, language_hint, on_progress) - - def _run( - self, - audio: bytes, - language_hint: str | None, - on_progress=None, - ) -> TranscriptResult: - path: Path | None = None - try: - with tempfile.NamedTemporaryFile(suffix=".m4a", delete=False) as f: - f.write(audio) - path = Path(f.name) - ensure_model_available(self.model) - model = _load_model(self.model) - segments_iter, info = model.transcribe( # type: ignore[union-attr] - str(path), - beam_size=5, - language=language_hint, - ) - duration = float(getattr(info, "duration", 0.0) or 0.0) - segments = [] - last_pct = -1 - for s in segments_iter: - segments.append(Segment(start=s.start, end=s.end, text=s.text.strip())) - if on_progress is not None and duration > 0: - # Throttle: report only on whole-percent gains. Capped - # at 99 — the caller commits 100 when the row finishes. - pct = min(99, int(s.end / duration * 100)) - if pct > last_pct: - last_pct = pct - on_progress(pct) - text = " ".join(s.text for s in segments).strip() - return TranscriptResult( - text=text, - language=getattr(info, "language", None), - segments=segments, - model=self.model, - ) - finally: - if path is not None: - path.unlink(missing_ok=True) diff --git a/backend/shonar/services/ai/model_registry.py b/backend/shonar/services/ai/model_registry.py deleted file mode 100644 index a578115..0000000 --- a/backend/shonar/services/ai/model_registry.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Transcription model registry (Stage 1). - -The supported faster-whisper sizes, their display metadata, validation, and -local availability checks. Nothing here downloads anything: faster-whisper -fetches from HuggingFace on first use, so "downloaded" is answered by -inspecting the HF hub cache, and "available" additionally requires the -faster-whisper package itself. - -No silent substitution anywhere: unknown names are rejected, missing -downloads fail fast with instructions. -""" - -from __future__ import annotations - -import os -from dataclasses import dataclass -from pathlib import Path - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from shonar.db.models import AppSetting -from shonar.services.ai import ProviderConfigError - -DEFAULT_MODEL = "base" -DEFAULT_MODEL_KEY = "transcription.default_model" - - -@dataclass(frozen=True) -class TranscriptionModelInfo: - name: str - display_name: str - description: str - params: str - approx_memory: str - relative_speed: str - - -SUPPORTED_TRANSCRIPTION_MODELS: dict[str, TranscriptionModelInfo] = { - "tiny": TranscriptionModelInfo( - name="tiny", - display_name="Tiny", - description="Fastest and lightest. Good for quick drafts and slow machines.", - params="~39M", - approx_memory="~1 GB RAM", - relative_speed="~10x real-time (CPU)", - ), - "base": TranscriptionModelInfo( - name="base", - display_name="Base (default)", - description="Balanced default. Works reasonably well on ordinary computers.", - params="~74M", - approx_memory="~1 GB RAM", - relative_speed="~7x real-time (CPU)", - ), - "small": TranscriptionModelInfo( - name="small", - display_name="Small", - description="Better accuracy with higher resource usage.", - params="~244M", - approx_memory="~2 GB RAM", - relative_speed="~4x real-time (CPU)", - ), - "medium": TranscriptionModelInfo( - name="medium", - display_name="Medium", - description="Higher accuracy and slower performance.", - params="~769M", - approx_memory="~5 GB RAM", - relative_speed="~2x real-time (CPU)", - ), - "large-v3": TranscriptionModelInfo( - name="large-v3", - display_name="Large v3", - description="Highest accuracy and greatest resource requirements.", - params="~1.5B", - approx_memory="~10 GB RAM", - relative_speed="~1x real-time (CPU)", - ), -} - - -def normalize_model_name(raw: str | None) -> str: - """Case/whitespace-tolerant normalization. Never maps one model to another.""" - return (raw or "").strip().lower() - - -def validate_model_name(raw: str | None) -> str: - """Return the normalized name, or raise with a helpful message.""" - name = normalize_model_name(raw) - if name in SUPPORTED_TRANSCRIPTION_MODELS: - return name - supported = ", ".join(sorted(SUPPORTED_TRANSCRIPTION_MODELS)) - raise ProviderConfigError( - f"Unsupported transcription model {raw!r}. Supported models: {supported}. " - "Check the spelling — a different model is never substituted silently." - ) - - -def _hf_hub_cache() -> Path: - try: - from huggingface_hub.constants import HF_HUB_CACHE - - return Path(HF_HUB_CACHE) - except ImportError: - return Path( - os.environ.get("HF_HUB_CACHE", str(Path.home() / ".cache" / "huggingface" / "hub")) - ) - - -def is_model_downloaded(name: str) -> bool: - """True when a non-empty faster-whisper snapshot for `name` sits in the - HuggingFace hub cache (repo Systran/faster-whisper-).""" - repo_dir = _hf_hub_cache() / f"models--Systran--faster-whisper-{name}" - snapshots = repo_dir / "snapshots" - if not snapshots.is_dir(): - return False - return any(s.is_dir() and any(s.iterdir()) for s in snapshots.iterdir()) - - -def is_faster_whisper_installed() -> bool: - try: - import faster_whisper # noqa: F401 - - return True - except ImportError: - return False - - -def download_instructions(name: str) -> str: - return ( - f'Model "{name}" is not downloaded. Download it with: ' - f"POST /api/v1/models/{name}/download " - "(needs internet once), or run any transcription with that model selected — " - "faster-whisper fetches it from HuggingFace automatically." - ) - - -async def get_global_default_model(session: AsyncSession) -> str: - row = await session.scalar(select(AppSetting).where(AppSetting.key == DEFAULT_MODEL_KEY)) - if row is None: - return DEFAULT_MODEL - model = row.value.get("model") if isinstance(row.value, dict) else None - return model if model in SUPPORTED_TRANSCRIPTION_MODELS else DEFAULT_MODEL - - -async def set_global_default_model(session: AsyncSession, raw: str) -> str: - """Validate + persist the global default. Affects future recordings only — - existing rows keep their saved model.""" - name = validate_model_name(raw) - row = await session.scalar(select(AppSetting).where(AppSetting.key == DEFAULT_MODEL_KEY)) - if row is None: - row = AppSetting(key=DEFAULT_MODEL_KEY, value={"model": name}) - session.add(row) - else: - row.value = {"model": name} - await session.flush() - return name - - -def effective_model(recording_model: str | None, global_default: str) -> str: - """Per-recording override wins; otherwise the global default.""" - if recording_model and recording_model in SUPPORTED_TRANSCRIPTION_MODELS: - return recording_model - return global_default diff --git a/backend/shonar/services/ai/ollama.py b/backend/shonar/services/ai/ollama.py deleted file mode 100644 index dd293a5..0000000 --- a/backend/shonar/services/ai/ollama.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Summaries via a local Ollama server (`/api/chat`, JSON mode).""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -import httpx - -from shonar.services.ai import ProviderConfigError, ProviderTransientError, SummaryResult -from shonar.services.ai._llm import SYSTEM_PROMPT, build_user_message, parse_summary - - -class OllamaProvider: - name = "ollama" - - def __init__( - self, - base_url: str, - model: str = "", - timeout_s: float = 900.0, - http_client: httpx.AsyncClient | None = None, - ) -> None: - if not base_url.strip(): - raise ProviderConfigError("ollama needs SHONAR_LLM_BASE_URL.") - if not model.strip(): - raise ProviderConfigError("ollama needs SHONAR_LLM_MODEL.") - self.base_url = base_url.rstrip("/") - self.model = model - self.timeout_s = timeout_s - self.http_client = http_client - - @asynccontextmanager - async def _client(self) -> AsyncIterator[httpx.AsyncClient]: - if self.http_client is not None: - yield self.http_client - else: - async with httpx.AsyncClient(timeout=self.timeout_s) as client: - yield client - - async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: - payload = { - "model": self.model, - "stream": False, - "format": "json", - # qwen3-family models "think" by default: a long reasoning - # chain before the JSON answer, brutally slow on CPU and it - # does not improve the summary. Ask for the answer directly - # (ignored by non-thinking models). - "think": False, - "options": {"num_ctx": 8192}, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": build_user_message(transcript, title)}, - ], - } - try: - async with self._client() as client: - resp = await client.post(f"{self.base_url}/api/chat", json=payload) - except (httpx.TimeoutException, httpx.TransportError) as e: - raise ProviderTransientError(f"Ollama unreachable: {type(e).__name__}") from e - if resp.status_code == 404: - # Missing model and missing route both 404 here; both are - # configuration, not weather. - raise ProviderConfigError("Ollama has no such model or route (HTTP 404).") - if resp.status_code != 200: - raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).") - try: - content = resp.json()["message"]["content"] - except (ValueError, KeyError, TypeError) as e: - raise ProviderTransientError("Ollama sent an unreadable reply.") from e - import json as _json - - try: - data = _json.loads(content) - except ValueError as e: - raise ProviderTransientError("Ollama reply was not JSON.") from e - return parse_summary(data, self.model) diff --git a/backend/shonar/services/ai/openai_compat.py b/backend/shonar/services/ai/openai_compat.py deleted file mode 100644 index 883b567..0000000 --- a/backend/shonar/services/ai/openai_compat.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Summaries via any OpenAI-compatible chat endpoint (self-hosted -vLLM/llama.cpp server, commercial API, …) with JSON mode. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -import httpx - -from shonar.services.ai import ProviderConfigError, ProviderTransientError, SummaryResult -from shonar.services.ai._llm import SYSTEM_PROMPT, build_user_message, parse_summary - - -class OpenAICompatProvider: - name = "openai_compat" - - def __init__( - self, - base_url: str, - model: str = "", - api_key: str = "", - timeout_s: float = 180.0, - http_client: httpx.AsyncClient | None = None, - ) -> None: - if not base_url.strip(): - raise ProviderConfigError("openai_compat needs SHONAR_LLM_BASE_URL.") - if not model.strip(): - raise ProviderConfigError("openai_compat needs SHONAR_LLM_MODEL.") - self.base_url = base_url.rstrip("/") - self.model = model - self.api_key = api_key - self.timeout_s = timeout_s - self.http_client = http_client - - @asynccontextmanager - async def _client(self) -> AsyncIterator[httpx.AsyncClient]: - if self.http_client is not None: - yield self.http_client - else: - async with httpx.AsyncClient(timeout=self.timeout_s) as client: - yield client - - async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: - headers = ( - {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} - ) - payload = { - "model": self.model, - "temperature": 0.2, - "response_format": {"type": "json_object"}, - "messages": [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": build_user_message(transcript, title)}, - ], - } - try: - async with self._client() as client: - resp = await client.post( - f"{self.base_url}/v1/chat/completions", headers=headers, json=payload - ) - except (httpx.TimeoutException, httpx.TransportError) as e: - raise ProviderTransientError(f"LLM unreachable: {type(e).__name__}") from e - if resp.status_code in (401, 403, 404): - raise ProviderConfigError(f"LLM refused the request (HTTP {resp.status_code}).") - if resp.status_code == 429 or resp.status_code >= 500: - raise ProviderTransientError(f"LLM busy (HTTP {resp.status_code}).") - if resp.status_code != 200: - raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).") - try: - body = resp.json() - content = body["choices"][0]["message"]["content"] - except (ValueError, KeyError, IndexError, TypeError) as e: - raise ProviderTransientError("LLM sent an unreadable reply.") from e - import json as _json - - try: - data = _json.loads(content) - except ValueError as e: - raise ProviderTransientError("LLM reply was not JSON.") from e - return parse_summary(data, self.model) diff --git a/backend/shonar/services/ai/whisper_http.py b/backend/shonar/services/ai/whisper_http.py deleted file mode 100644 index b25e81f..0000000 --- a/backend/shonar/services/ai/whisper_http.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Transcription via any OpenAI-compatible `/v1/audio/transcriptions` -endpoint (self-hosted whisper.cpp server, commercial Whisper API, …). -Sends `verbose_json` so segment timings come back with the text. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -import httpx - -from shonar.services.ai import ( - ProviderConfigError, - ProviderTransientError, - Segment, - TranscriptResult, -) - - -class WhisperHttpProvider: - name = "whisper_http" - - def __init__( - self, - base_url: str, - model: str = "base", - api_key: str = "", - timeout_s: float = 300.0, - http_client: httpx.AsyncClient | None = None, - ) -> None: - if not base_url.strip(): - raise ProviderConfigError( - "whisper_http needs SHONAR_TRANSCRIPTION_BASE_URL." - ) - self.base_url = base_url.rstrip("/") - self.model = model - self.api_key = api_key - self.timeout_s = timeout_s - self.http_client = http_client - - @asynccontextmanager - async def _client(self) -> AsyncIterator[httpx.AsyncClient]: - if self.http_client is not None: - yield self.http_client - else: - async with httpx.AsyncClient(timeout=self.timeout_s) as client: - yield client - - async def transcribe( - self, - audio: bytes, - mime: str, - *, - language_hint: str | None = None, - on_progress=None, # accepted for protocol parity; not reported - ) -> TranscriptResult: - headers = ( - {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} - ) - data: dict[str, str] = {"model": self.model, "response_format": "verbose_json"} - if language_hint: - data["language"] = language_hint - files = {"file": (f"audio.{_ext(mime)}", audio, mime or "application/octet-stream")} - try: - async with self._client() as client: - resp = await client.post( - f"{self.base_url}/v1/audio/transcriptions", - headers=headers, - data=data, - files=files, - ) - except (httpx.TimeoutException, httpx.TransportError) as e: - raise ProviderTransientError( - f"Transcription service unreachable: {type(e).__name__}" - ) from e - if resp.status_code in (401, 403, 404): - raise ProviderConfigError( - f"Transcription service refused the request (HTTP {resp.status_code})." - ) - if resp.status_code == 429 or resp.status_code >= 500: - raise ProviderTransientError( - f"Transcription service busy (HTTP {resp.status_code})." - ) - if resp.status_code != 200: - raise ProviderTransientError( - f"Transcription failed (HTTP {resp.status_code})." - ) - try: - body = resp.json() - except ValueError as e: - raise ProviderTransientError("Transcription service sent no JSON.") from e - segments = [] - raw_segs = body.get("segments") - if isinstance(raw_segs, list): - for s in raw_segs: - if not isinstance(s, dict): - continue - try: - segments.append( - Segment( - start=float(s.get("start", 0.0)), - end=float(s.get("end", 0.0)), - text=str(s.get("text", "")), - ) - ) - except (TypeError, ValueError): - continue - text = body.get("text") - return TranscriptResult( - text=text if isinstance(text, str) else "", - language=body.get("language") if isinstance(body.get("language"), str) else None, - segments=segments, - model=self.model, - ) - - -def _ext(mime: str) -> str: - return { - "audio/mp4": "m4a", - "audio/m4a": "m4a", - "audio/wav": "wav", - "audio/x-wav": "wav", - "audio/ogg": "ogg", - "audio/opus": "ogg", - "audio/webm": "webm", - "audio/mpeg": "mp3", - }.get(mime.lower().split(";")[0].strip(), "bin") diff --git a/backend/shonar/services/auth.py b/backend/shonar/services/auth.py deleted file mode 100644 index 6640259..0000000 --- a/backend/shonar/services/auth.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Authentication service: registration, login, rotating refresh tokens with -reuse detection, logout, account deletion.""" - -from __future__ import annotations - -import uuid -from datetime import UTC, datetime - -from sqlalchemy import select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from shonar.core.security import ( - generate_refresh_token, - hash_password, - hash_refresh_token, - refresh_token_ttl, - verify_password, -) -from shonar.db.models import Device, RefreshToken, User, utcnow - - -class AuthError(Exception): - """Safe, client-displayable auth failure (never leaks which half failed - beyond what the flow requires).""" - - def __init__(self, message: str, status_code: int = 401): - super().__init__(message) - self.message = message - self.status_code = status_code - - -async def register_user( - session: AsyncSession, email: str, password: str, display_name: str | None -) -> User: - email = email.strip().lower() - existing = await session.scalar(select(User).where(User.email == email)) - if existing is not None: - # Use a generic message; do not reveal whether the account exists in - # flows where that matters. For self-hosted registration the UX cost - # of "email already registered" is acceptable and helpful. - raise AuthError("An account with this email already exists.", 409) - user = User( - email=email, - password_hash=hash_password(password), - display_name=display_name, - ) - session.add(user) - await session.flush() - return user - - -async def issue_refresh_token( - session: AsyncSession, - user_id: uuid.UUID, - family: uuid.UUID | None, - device_id: uuid.UUID | None, -) -> tuple[str, RefreshToken]: - token = generate_refresh_token() - rt = RefreshToken( - user_id=user_id, - token_hash=hash_refresh_token(token), - family=family or uuid.uuid4(), - device_id=device_id, - expires_at=datetime.now(UTC) + refresh_token_ttl(), - ) - session.add(rt) - await session.flush() - return token, rt - - -async def login( - session: AsyncSession, - email: str, - password: str, - device_name: str | None, - platform: str, -) -> tuple[User, str, Device]: - """Returns (user, refresh_token, device). Raises AuthError safely.""" - email = email.strip().lower() - user = await session.scalar(select(User).where(User.email == email)) - if user is None or user.deleted_at is not None or not user.is_active: - raise AuthError("Invalid email or password.") - if not verify_password(user.password_hash, password): - raise AuthError("Invalid email or password.") - - device = Device(user_id=user.id, name=device_name or "Android device", platform=platform) - session.add(device) - await session.flush() - - refresh_token, _ = await issue_refresh_token(session, user.id, None, device.id) - return user, refresh_token, device - - -async def rotate_refresh_token( - session: AsyncSession, presented_token: str -) -> tuple[User, str, uuid.UUID | None]: - """Consume a refresh token and issue a replacement in the same family. - - Reuse detection: presenting an already-consumed/revoked token revokes the - entire family (an attacker's stolen token dies along with the real one). - """ - token_hash = hash_refresh_token(presented_token) - rt = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) - now = utcnow() - - if rt is None: - raise AuthError("Invalid refresh token.") - - if rt.revoked_at is not None or rt.replaced_by is not None: - # REUSE DETECTED — revoke the whole family. Commit BEFORE raising: - # the request's transaction would otherwise roll back on the 401 and - # silently undo the security-revocation. - await session.execute( - update(RefreshToken) - .where(RefreshToken.family == rt.family, RefreshToken.revoked_at.is_(None)) - .values(revoked_at=now) - ) - await session.commit() - raise AuthError("Refresh token reuse detected. Please log in again.", 401) - - if rt.expires_at < now: - raise AuthError("Refresh token expired.", 401) - - user = await session.get(User, rt.user_id) - if user is None or user.deleted_at is not None or not user.is_active: - raise AuthError("Account unavailable.", 401) - - new_token, new_rt = await issue_refresh_token(session, user.id, rt.family, rt.device_id) - rt.revoked_at = now - rt.replaced_by = new_rt.id - - if rt.device_id is not None: - device = await session.get(Device, rt.device_id) - if device is not None: - device.last_seen_at = now - await session.flush() - return user, new_token, rt.device_id - - -async def logout(session: AsyncSession, presented_token: str) -> None: - """Revoke the presented token's whole family (logs the device out).""" - token_hash = hash_refresh_token(presented_token) - rt = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) - if rt is None: - return # idempotent - await session.execute( - update(RefreshToken) - .where(RefreshToken.family == rt.family, RefreshToken.revoked_at.is_(None)) - .values(revoked_at=utcnow()) - ) - - -async def delete_account(session: AsyncSession, user: User, password: str) -> None: - if not verify_password(user.password_hash, password): - raise AuthError("Invalid password.", 403) - now = utcnow() - user.deleted_at = now - user.is_active = False - # Revoke every refresh token for the user. - await session.execute( - update(RefreshToken) - .where(RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None)) - .values(revoked_at=now) - ) - # NOTE: hard deletion of rows/files is performed by the retention sweep - # (services/retention.py) so an accidental deletion can be cancelled - # within the grace window (see docs/security.md). diff --git a/backend/shonar/services/exports.py b/backend/shonar/services/exports.py deleted file mode 100644 index 270e791..0000000 --- a/backend/shonar/services/exports.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Exports (M9): audio, transcript txt, notes markdown, bundle zip. - -Synchronous generation — every artifact is small enough (text, or one -audio file) that a background job adds failure modes, not speed. Each -successful export records an ExportJob row and stores the produced bytes -as an Asset(kind=export) so the audit trail exists; the response is the -file itself (no separate download-asset round trip). - -Formats: - audio — the original upload, byte-identical, original mime/extension - txt — current transcript text - md — notes.md: title, metadata, notes, summary sections, transcript - zip — bundle: original audio + transcript.txt + notes.md -""" - -from __future__ import annotations - -import io -import uuid -import zipfile -from dataclasses import dataclass - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from shonar.db.models import ( - Asset, - AssetKind, - ExportJob, - JobStatus, - Recording, - Summary, - Transcript, -) - - -class ExportError(Exception): - def __init__(self, status_code: int, message: str): - super().__init__(message) - self.status_code = status_code - self.message = message - - -EXPORT_FORMATS = ("audio", "txt", "md", "zip") - - -@dataclass -class ExportResult: - filename: str - mime_type: str - data: bytes - - -def _safe_filename(rec: Recording) -> str: - """Slug the title; fall back to the recorded_at stamp. Never leaks ids.""" - base = "".join( - c if (c.isalnum() or c in "-_ ") else " " for c in (rec.title or "") - ).strip() - if not base: - base = f"recording-{rec.recorded_at:%Y%m%d-%H%M%S}" - return base[:120] - - -async def _current_transcript(session: AsyncSession, rec_id: uuid.UUID) -> Transcript | None: - return await session.scalar( - select(Transcript) - .where(Transcript.recording_id == rec_id, Transcript.superseded_at.is_(None)) - .order_by(Transcript.version.desc()) - ) - - -async def _current_summary(session: AsyncSession, rec_id: uuid.UUID) -> Summary | None: - return await session.scalar( - select(Summary) - .where(Summary.recording_id == rec_id, Summary.superseded_at.is_(None)) - .order_by(Summary.version.desc()) - ) - - -async def _original_asset(session: AsyncSession, rec_id: uuid.UUID) -> Asset | None: - return await session.scalar( - select(Asset).where(Asset.recording_id == rec_id, Asset.kind == AssetKind.original) - ) - - -def _summary_md(summary: Summary | None) -> str: - if summary is None: - return "" - c = summary.content or {} - out = ["## Summary\n"] - if c.get("short"): - out.append(f"{c['short']}\n") - if c.get("detailed"): - out.append(f"### Detailed\n\n{c['detailed']}\n") - for key, header in ( - ("key_points", "Key Points"), - ("decisions", "Decisions"), - ("action_items", "Action Items"), - ("questions", "Questions"), - ): - items = c.get(key) - if isinstance(items, list) and items: - out.append(f"### {header}\n") - out.extend(f"- {x}" for x in items) - out.append("") - return "\n".join(out) - - -def _transcript_md(t: Transcript | None) -> str: - if t is None: - return "" - lines = ["## Transcript\n"] - segs = [s for s in (t.segments or []) if isinstance(s, dict)] - if segs: - for s in segs: - start = float(s.get("start", 0.0)) - stamp = f"{int(start // 60):02d}:{start % 60:04.1f}" - speaker = f"**{s['speaker']}**: " if s.get("speaker") else "" - lines.append(f"- `[{stamp}]` {speaker}{s.get('text', '').strip()}") - else: - lines.append(t.text or "") - return "\n".join(lines) + "\n" - - -def _notes_md( - rec: Recording, t: Transcript | None, summary: Summary | None, - tag_names: list[str] | None = None, -) -> str: - parts = [ - f"# {rec.title}\n", - f"- Recorded: {rec.recorded_at:%Y-%m-%d %H:%M} UTC", - f"- Duration: {rec.duration_seconds:.1f}s", - ] - if tag_names: - parts.append("- Tags: " + ", ".join(f"`{x}`" for x in tag_names)) - parts.append("") - if rec.notes: - parts.append(f"## Notes\n\n{rec.notes}\n") - s = _summary_md(summary) - if s: - parts.append(s) - tr = _transcript_md(t) - if tr: - parts.append(tr) - return "\n".join(parts) - - -def _zip(files: list[tuple[str, bytes]]) -> bytes: - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: - for name, data in files: - z.writestr(name, data) - return buf.getvalue() - - -async def build_export( - session: AsyncSession, rec: Recording, export_format: str -) -> ExportResult: - """Build one export artifact for an owned, non-deleted recording.""" - from shonar.storage import get_storage - - if export_format not in EXPORT_FORMATS: - raise ExportError(422, f"Unknown export format. Use one of: {', '.join(EXPORT_FORMATS)}") - - original = await _original_asset(session, rec.id) - t = await _current_transcript(session, rec.id) - summary = await _current_summary(session, rec.id) - from shonar.services.search import tag_names - - tags = await tag_names(session, rec.id) - base = _safe_filename(rec) - - if export_format == "audio": - if original is None: - raise ExportError(404, "No audio stored for this recording") - data = await get_storage().get(original.storage_key) - ext = original.storage_key[original.storage_key.rfind(".") :] - return ExportResult(filename=f"{base}{ext}", mime_type=original.mime_type, data=data) - - if export_format == "txt": - if t is None: - raise ExportError(404, "No transcript yet — transcribe first") - return ExportResult( - filename=f"{base}.txt", mime_type="text/plain; charset=utf-8", - data=(t.text or "").encode("utf-8"), - ) - - if export_format == "md": - if t is None and summary is None and not rec.notes: - raise ExportError( - 404, "Nothing to export — this recording has no notes, transcript, or summary" - ) - return ExportResult( - filename=f"{base}.md", mime_type="text/markdown; charset=utf-8", - data=_notes_md(rec, t, summary, tags).encode("utf-8"), - ) - - # zip bundle: whatever exists, always at least the audio when present. - if original is None and t is None and summary is None and not rec.notes: - raise ExportError(404, "Nothing to export for this recording") - files: list[tuple[str, bytes]] = [] - if original is not None: - audio = await get_storage().get(original.storage_key) - ext = original.storage_key[original.storage_key.rfind(".") :] - files.append((f"{base}{ext}", audio)) - if t is not None: - files.append(("transcript.txt", (t.text or "").encode("utf-8"))) - files.append(("notes.md", _notes_md(rec, t, summary, tags).encode("utf-8"))) - return ExportResult( - filename=f"{base}.zip", mime_type="application/zip", data=_zip(files) - ) - - -async def record_export( - session: AsyncSession, user_id: uuid.UUID, rec: Recording, - export_format: str, result: ExportResult, -) -> None: - """Persist the audit trail: ExportJob(succeeded) + Asset(kind=export). - - Best-effort storage of the artifact bytes; a storage failure never - fails the download the user already received. - """ - from shonar.storage import get_storage - - job = ExportJob( - user_id=user_id, - recording_id=rec.id, - export_type=export_format, - status=JobStatus.succeeded, - ) - session.add(job) - try: - key = f"exports/{user_id}/{rec.id}/{export_format}-{uuid.uuid4().hex}" - await get_storage().put(key, result.data) - import hashlib - - asset = Asset( - recording_id=rec.id, - user_id=user_id, - kind=AssetKind.export, - storage_key=key, - mime_type=result.mime_type, - size_bytes=len(result.data), - checksum_sha256=hashlib.sha256(result.data).hexdigest(), - ) - session.add(asset) - await session.flush() - job.asset_id = asset.id - except Exception: # noqa: BLE001 — audit copy is best-effort - pass - await session.flush() diff --git a/backend/shonar/services/inline_queue.py b/backend/shonar/services/inline_queue.py deleted file mode 100644 index 501711c..0000000 --- a/backend/shonar/services/inline_queue.py +++ /dev/null @@ -1,141 +0,0 @@ -"""In-process job runner (desktop bundled-lite engine). - -``queue_backend=inline`` replaces the arq/Redis transport with a single -asyncio consumer inside the uvicorn process: DB rows stay the source of -truth (ProcessingJob), this just runs the work. One job at a time — -local faster-whisper is multi-GB per pass, mirroring the worker's -``max_jobs=1`` rule. - -Transient failures retry in-process with backoff up to MAX_TRIES (the -same budget arq gives via ``max_tries``); the final failure is recorded -by the task body itself (see processing._fail). -""" - -from __future__ import annotations - -import asyncio -import contextlib -import logging - -from shonar.db.models import JobType -from shonar.services import processing - -logger = logging.getLogger("shonar.inline_queue") - -RETRY_DELAY_SECONDS = 5.0 -# Desktop engine: hard-delete expired soft-deletes once the app has been up -# for a day, then daily. Delayed first run keeps startup snappy. -RETENTION_INTERVAL_SECONDS = 24 * 3600.0 - -_queue: asyncio.Queue[tuple[str, str, int]] | None = None -_consumer: asyncio.Task | None = None -_retention: asyncio.Task | None = None - - -def _get_queue() -> asyncio.Queue[tuple[str, str, int]]: - global _queue - if _queue is None: - _queue = asyncio.Queue() - return _queue - - -async def start() -> None: - """Start the consumer and re-run anything the DB says is pending.""" - global _consumer, _retention - _get_queue() - # Inline engine = single process: any row still marked `running` at - # startup is a corpse from the previous process (the worker died with - # it). sweep_stale's 2h live-worker grace — correct for multi-worker - # arq deployments — would starve these jobs, so reclaim them first. - from sqlalchemy import update - - from shonar.db.models import JobStatus, ProcessingJob - from shonar.db.session import session_factory - - async with session_factory()() as s: - await s.execute( - update(ProcessingJob) - .where(ProcessingJob.status == JobStatus.running) - .values(status=JobStatus.queued, started_at=None) - ) - await s.commit() - if _consumer is None or _consumer.done(): - _consumer = asyncio.create_task(_consume(), name="shonar-inline-queue") - if _retention is None or _retention.done(): - _retention = asyncio.create_task(_retention_loop(), name="shonar-retention") - # Crash recovery: queued rows (and orphaned running rows requeued by - # the sweep) go back on the in-process queue. - count = await processing.sweep_stale() - if count: - logger.info("inline queue startup sweep requeued %d jobs", count) - - -async def _retention_loop() -> None: - """Daily hard-delete sweep for the desktop engine (no arq cron here). - - First pass a few minutes after start (the app may only run for hours - at a time, so a full-day initial sleep could starve the sweep), then - once a day while running. - """ - from shonar.services import retention - - await asyncio.sleep(120.0) - while True: - try: - purged = await retention.sweep_deleted() - if purged["recordings"] or purged["users"]: - logger.info("inline retention sweep: %s", purged) - except asyncio.CancelledError: - raise - except Exception: # noqa: BLE001 — the loop must survive any failure - logger.exception("inline retention sweep failed") - await asyncio.sleep(RETENTION_INTERVAL_SECONDS) - - -async def stop() -> None: - global _consumer, _retention - if _consumer is not None: - _consumer.cancel() - with contextlib.suppress(BaseException): # noqa: BLE001 — shutdown is best-effort - await _consumer - _consumer = None - if _retention is not None: - _retention.cancel() - with contextlib.suppress(BaseException): # noqa: BLE001 - await _retention - _retention = None - - -async def enqueue(job_type: JobType, recording_id: str) -> None: - await _get_queue().put((job_type.value, str(recording_id), 1)) - - -async def _consume() -> None: - q = _get_queue() - while True: - job_value, recording_id, attempt = await q.get() - ctx = {"job_try": attempt} - try: - if job_value == JobType.transcribe.value: - await processing.run_transcribe(ctx, recording_id) - else: - await processing.run_summarize(ctx, recording_id) - except processing.ProviderTransientError as e: - if attempt < processing.MAX_TRIES: - logger.warning( - "inline job %s:%s transient failure (%s); retry %d/%d", - job_value, recording_id, e, attempt + 1, processing.MAX_TRIES, - ) - await asyncio.sleep(RETRY_DELAY_SECONDS) - await q.put((job_value, recording_id, attempt + 1)) - else: - logger.error( - "inline job %s:%s failed after %d tries: %s", - job_value, recording_id, attempt, e, - ) - except asyncio.CancelledError: - raise - except Exception: # noqa: BLE001 — consumer must survive any task crash - logger.exception("inline job %s:%s crashed", job_value, recording_id) - finally: - q.task_done() diff --git a/backend/shonar/services/media.py b/backend/shonar/services/media.py deleted file mode 100644 index 9c8dda4..0000000 --- a/backend/shonar/services/media.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Audio format validation: declared MIME type vs magic bytes. - -Families decouple declared MIME aliases (audio/mp4 vs audio/m4a) from what -the bytes actually are. The original file is stored exactly as uploaded — -validation never rewrites it. -""" - -from __future__ import annotations - -FAMILY_BY_MIME = { - "audio/mp4": "mp4", - "audio/m4a": "mp4", - "audio/aac": "aac", - "audio/wav": "wav", - "audio/x-wav": "wav", - "audio/ogg": "ogg", - "audio/opus": "ogg", - "audio/webm": "webm", - "audio/mpeg": "mpeg", -} - -EXT_BY_FAMILY = { - "mp4": ".m4a", - "aac": ".aac", - "wav": ".wav", - "ogg": ".ogg", - "webm": ".webm", - "mpeg": ".mp3", -} - - -def sniff_audio_family(data: bytes) -> str | None: - """Return the audio family from magic bytes, or None if unrecognized.""" - if len(data) >= 12: - if data[4:8] == b"ftyp": - return "mp4" - if data[:4] == b"RIFF" and data[8:12] == b"WAVE": - return "wav" - if data[:4] == b"OggS": - return "ogg" - if data[:4] == b"\x1a\x45\xdf\xa3": - return "webm" - if data[:3] == b"ID3": - return "mpeg" - if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xF6) == 0xF0: - # ADTS frame sync: AAC (also accepted as mpeg-family audio) - return "aac" - return None - - -def declared_family(mime_type: str) -> str | None: - return FAMILY_BY_MIME.get(mime_type.lower().split(";")[0].strip()) - - -def is_compatible(mime_type: str, data: bytes) -> bool: - """True when the declared MIME matches the sniffed magic bytes. - - mpeg and aac are treated as one family: Android records AAC in ADTS or - in MP4 containers and MIME reporting around these is inconsistent. - """ - declared = declared_family(mime_type) - sniffed = sniff_audio_family(data) - if declared is None or sniffed is None: - return False - if {declared, sniffed} == {"mpeg", "aac"}: - return True - return declared == sniffed - - -def extension_for(mime_type: str, data: bytes) -> str: - sniffed = sniff_audio_family(data) - if sniffed is not None: - return EXT_BY_FAMILY[sniffed] - return EXT_BY_FAMILY.get(declared_family(mime_type) or "", ".bin") diff --git a/backend/shonar/services/processing.py b/backend/shonar/services/processing.py deleted file mode 100644 index 27a5789..0000000 --- a/backend/shonar/services/processing.py +++ /dev/null @@ -1,591 +0,0 @@ -"""AI processing pipeline (M7): uploaded -> transcribed -> summarized. - -State lives in the database (ProcessingJob rows); arq/redis is transport -only. That ordering is deliberate: if redis is down, uploads still succeed -(the jobs sit queued) and the worker sweep picks them up. The only race is -a task running before the API transaction commits (recording invisible) — -missing rows are transient failures, so arq retry absorbs it. - -Entry points: -- ``enqueue_for_recording``: called from upload finalize AND re-callable - later (manual transcripts in M8 re-enter here). Idempotent. -- ``run_transcribe`` / ``run_summarize``: arq task bodies. ``ctx`` is an - arq context in production and a plain dict in tests; only - ``ctx.get("job_try", 1)`` is read. -""" - -from __future__ import annotations - -import asyncio -import logging -import uuid -from datetime import timedelta - -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from shonar.core.config import get_settings -from shonar.db.models import ( - Asset, - AssetKind, - JobStatus, - JobType, - ProcessingJob, - ProcessingStatus, - Recording, - Summary, - Transcript, - utcnow, -) -from shonar.services.ai import ( - AIError, - ProviderTransientError, - get_llm_provider, - get_transcription_provider, -) -from shonar.storage import get_storage - -logger = logging.getLogger("shonar.processing") - - -def _accepts_on_progress(provider) -> bool: - """True when the provider's transcribe() accepts on_progress=.""" - import inspect - - try: - return "on_progress" in inspect.signature(provider.transcribe).parameters - except (TypeError, ValueError): # builtins / exotic callables - return False - - -def _thread_progress_reporter(job_id): - """Callback safe to invoke from a worker thread (faster-whisper runs - via asyncio.to_thread): schedules a tiny session update on the loop. - - Progress writes are best-effort display state — failures are logged - and swallowed, never allowed to disturb the transcription itself.""" - loop = asyncio.get_running_loop() - - def report(pct: int) -> None: - async def _write() -> None: - from sqlalchemy import update - - from shonar.db.session import session_factory - - try: - async with session_factory()() as s: - await s.execute( - update(ProcessingJob) - .where(ProcessingJob.id == job_id) - .values(progress=int(pct)) - ) - await s.commit() - except Exception: # pragma: no cover - display state only - logger.debug("progress write failed for job %s", job_id, exc_info=True) - - try: - asyncio.run_coroutine_threadsafe(_write(), loop) - except RuntimeError: # loop already gone (shutdown race) - logger.debug("progress dropped for job %s (loop gone)", job_id) - - return report - -MAX_TRIES = 3 - -# A `running` job younger than this is treated as live work, not a crash -# relic — long transcriptions legitimately outrun the 5-minute sweep, and -# re-enqueuing them mid-flight caused a copy storm (one whisper per copy). -STALE_RUNNING_AFTER = timedelta(hours=2) - - -# --- entry ----------------------------------------------------------------- - - -async def enqueue_for_recording(session: AsyncSession, rec: Recording) -> list[JobType]: - """Queue whatever AI stages apply. Safe to call repeatedly: completed - work is never redone, failed work is reset for another attempt.""" - if rec.deleted_at is not None: - return [] - settings = get_settings() - tprov = get_transcription_provider(settings) - lprov = get_llm_provider(settings) - queued: list[JobType] = [] - if tprov is not None and await reset_or_create(session, rec, JobType.transcribe): - queued.append(JobType.transcribe) - if ( - lprov is not None - and await latest_transcript_text(session, rec.id) is not None - and await reset_or_create(session, rec, JobType.summarize) - ): - queued.append(JobType.summarize) - if tprov is None and lprov is None: - if rec.processing_status != ProcessingStatus.ai_disabled: - rec.processing_status = ProcessingStatus.ai_disabled - rec.processing_error = None - elif queued and rec.processing_status not in ( - ProcessingStatus.processing, - ProcessingStatus.completed, - ): - rec.processing_status = ProcessingStatus.queued - rec.processing_error = None - await session.flush() - for jt in queued: - await transport_enqueue(jt, rec.id) - return queued - - -async def reset_or_create( - session: AsyncSession, rec: Recording, job_type: JobType -) -> bool: - """Ensure a queued job row. Returns True when (re)queued now: new rows, - plus failed/skipped rows (a re-upload or re-entry deserves another - attempt). Queued/running/succeeded rows are left alone.""" - existing = await session.scalar( - select(ProcessingJob) - .where( - ProcessingJob.recording_id == rec.id, - ProcessingJob.job_type == job_type, - ) - .order_by(ProcessingJob.id.desc()) - ) - if existing is None: - session.add(ProcessingJob(recording_id=rec.id, job_type=job_type)) - return True - if existing.status in (JobStatus.queued, JobStatus.running, JobStatus.succeeded): - return False - existing.status = JobStatus.queued - existing.attempt = 0 - existing.error = None - existing.stage = None - existing.progress = None - existing.started_at = None - existing.finished_at = None - return True - - -async def latest_transcript_text( - session: AsyncSession, recording_id: uuid.UUID -) -> str | None: - """Newest non-superseded transcript; user-edited rows win over newer - machine rows (an edit is a verdict, not a draft).""" - rows = ( - await session.scalars( - select(Transcript) - .where( - Transcript.recording_id == recording_id, - Transcript.superseded_at.is_(None), - ) - .order_by(Transcript.version.desc()) - ) - ).all() - if not rows: - return None - for r in rows: - if r.edited_by_user and r.text.strip(): - return r.text - text = rows[0].text - return text if text.strip() else None - - -async def transport_enqueue(job_type: JobType, recording_id: uuid.UUID) -> None: - """Best-effort trigger for the configured backend ('arq' or 'inline'). - Failure only logs: the DB rows are the real queue and the sweep picks - up anything the transport missed.""" - if get_settings().queue_backend.strip().lower() == "inline": - from shonar.services import inline_queue - - await inline_queue.enqueue(job_type, str(recording_id)) - return - from arq import create_pool - from arq.connections import RedisSettings - from arq.constants import result_key_prefix - - job_id = f"{job_type.value}:{recording_id}" - try: - pool = await create_pool(RedisSettings.from_dsn(get_settings().redis_url)) - try: - # Deterministic _job_id: arq refuses a duplicate while a copy of - # (job_type, recording) is queued or running, so sweep pokes and - # retried enqueues can never pile up concurrent copies of the - # same work. - # - # arq dedupes on job key OR result key, and failed runs leave a - # result behind for keep_result days — that would silently swallow - # deliberate re-runs of previously-finished work. Drop only the - # stale RESULT key first (never the job key: that is exactly what - # shields a live copy from duplicate enqueue). - await pool.delete(result_key_prefix + job_id) - await pool.enqueue_job( - "run_" + job_type.value, - str(recording_id), - _job_id=job_id, - ) - finally: - await pool.aclose() - except Exception as e: # noqa: BLE001 — transport must never break uploads - logger.warning("arq enqueue failed (%s); worker sweep will pick it up", e) - - -# --- tasks ----------------------------------------------------------------- - - -def _job_try(ctx: dict) -> int: - try: - return int(ctx.get("job_try", 1)) - except (TypeError, ValueError): - return 1 - - -async def _load(session: AsyncSession, recording_id: str) -> Recording | None: - try: - rid = uuid.UUID(recording_id) - except ValueError: - return None - return await session.get(Recording, rid) - - -async def _job( - session: AsyncSession, rec: Recording, job_type: JobType -) -> ProcessingJob: - job = await session.scalar( - select(ProcessingJob) - .where( - ProcessingJob.recording_id == rec.id, - ProcessingJob.job_type == job_type, - ) - .order_by(ProcessingJob.id.desc()) - ) - if job is None: - job = ProcessingJob(recording_id=rec.id, job_type=job_type) - session.add(job) - await session.flush() - return job - - -async def _fail( - session: AsyncSession, - rec: Recording, - job: ProcessingJob, - message: str, - ctx: dict, - exc: AIError | None = None, -) -> None: - """Config errors fail now; transient errors fail only on the last try - (returning normally), otherwise they raise for arq retry.""" - transient = exc is None or isinstance(exc, ProviderTransientError) - if transient and _job_try(ctx) < MAX_TRIES: - # Running state was committed before the long phase; put the row - # back to queued for the retry and persist that (a raise no longer - # rolls the pre-phase commit back). - job.status = JobStatus.queued - job.attempt = _job_try(ctx) - job.stage = None - await session.commit() - raise ProviderTransientError(message) - job.status = JobStatus.failed - job.error = message - job.stage = None - job.finished_at = utcnow() - if job.job_type == JobType.summarize and await latest_transcript_text( - session, rec.id - ): - # The transcript is usable; a summary timeout must not mark the - # whole recording failed (the summary can be re-run separately). - rec.processing_status = ProcessingStatus.completed - rec.processing_error = f"Summary failed: {message}" - else: - rec.processing_status = ProcessingStatus.failed - rec.processing_error = message - await session.flush() - - -async def run_transcribe(ctx: dict, recording_id: str) -> None: - """Transcribe the original audio; chain into summarization when an LLM - is configured.""" - from shonar.db.session import session_factory - - settings = get_settings() - async with session_factory()() as session: - rec = await _load(session, recording_id) - if rec is None or rec.deleted_at is not None: - # Finalize race: the API transaction may not have committed yet. - raise ProviderTransientError("Recording not ready; retrying.") - job = await _job(session, rec, JobType.transcribe) - provider = get_transcription_provider(settings) - if provider is None: - job.status = JobStatus.skipped - await session.flush() - await session.commit() - await _maybe_chain_summarize(session, rec) - return - # The recording's saved model is authoritative: a per-recording - # override wins, otherwise the global default in force at finalize - # time (changing the default never rewrites history). - from shonar.services.ai.model_registry import ( - effective_model, - get_global_default_model, - ) - - model = effective_model(rec.transcription_model, await get_global_default_model(session)) - if provider.name == "faster_whisper": - from shonar.services.ai.faster_whisper import FasterWhisperProvider - - try: - provider = FasterWhisperProvider(model=model) - except AIError as e: - await _fail(session, rec, job, str(e), ctx, e) - await session.commit() - return - original = await session.scalar( - select(Asset).where( - Asset.recording_id == rec.id, Asset.kind == AssetKind.original - ) - ) - if original is None: - raise ProviderTransientError("Audio not ready; retrying.") - job.status = JobStatus.running - job.attempt = _job_try(ctx) - job.started_at = utcnow() - job.stage = "loading-model" - job.progress = None - rec.processing_status = ProcessingStatus.processing - rec.processing_error = None - # Commit before the long CPU phase: an open transaction is invisible - # to other readers (and on SQLite it locks out the progress writer). - await session.commit() - try: - audio = await get_storage().get(original.storage_key) - job.stage = "transcribing" - await session.commit() - kwargs = {} - if _accepts_on_progress(provider): - kwargs["on_progress"] = _thread_progress_reporter(job.id) - result = await provider.transcribe(audio, original.mime_type, **kwargs) - except AIError as e: - await _fail(session, rec, job, str(e), ctx, e) - await session.commit() - return - await store_transcript( - session, rec, result.text, result.segments, result.language, - provider.name, getattr(result, "model", ""), - ) - job.status = JobStatus.succeeded - job.stage = None - job.progress = 100 - job.finished_at = utcnow() - await session.flush() - await _maybe_chain_summarize(session, rec) - await session.commit() - - -async def _maybe_chain_summarize(session: AsyncSession, rec: Recording) -> None: - """After transcription (or a skip): summarize when possible, else finish.""" - settings = get_settings() - if get_llm_provider(settings) is None: - if rec.processing_status != ProcessingStatus.completed: - rec.processing_status = ProcessingStatus.completed - rec.processing_error = None - await session.flush() - return - if await latest_transcript_text(session, rec.id) is None: - # LLM configured but nothing to summarize (e.g. empty transcript). - existing = await session.scalar( - select(ProcessingJob).where( - ProcessingJob.recording_id == rec.id, - ProcessingJob.job_type == JobType.summarize, - ) - ) - if existing is not None and existing.status == JobStatus.queued: - existing.status = JobStatus.skipped - if rec.processing_status != ProcessingStatus.completed: - rec.processing_status = ProcessingStatus.completed - await session.flush() - return - if await reset_or_create(session, rec, JobType.summarize): - await transport_enqueue(JobType.summarize, rec.id) - # Status stays `processing` until the summarize task lands. - - -async def run_summarize(ctx: dict, recording_id: str) -> None: - """Summarize the latest transcript into the structured summary shape.""" - from shonar.db.session import session_factory - - settings = get_settings() - async with session_factory()() as session: - rec = await _load(session, recording_id) - if rec is None or rec.deleted_at is not None: - raise ProviderTransientError("Recording not ready; retrying.") - job = await _job(session, rec, JobType.summarize) - provider = get_llm_provider(settings) - if provider is None: - job.status = JobStatus.skipped - await session.flush() - await session.commit() - return - text = await latest_transcript_text(session, rec.id) - if text is None: - job.status = JobStatus.skipped - await session.flush() - if rec.processing_status != ProcessingStatus.completed: - rec.processing_status = ProcessingStatus.completed - await session.commit() - return - job.status = JobStatus.running - job.attempt = _job_try(ctx) - job.started_at = utcnow() - job.stage = "summarizing" - rec.processing_status = ProcessingStatus.processing - await session.commit() # visible before the long LLM call - try: - result = await provider.summarize(text, title=rec.title) - except AIError as e: - await _fail(session, rec, job, str(e), ctx, e) - await session.commit() - return - await store_summary(session, rec, result.to_dict(), provider.name, result.model) - job.status = JobStatus.succeeded - job.stage = None - job.progress = 100 - job.finished_at = utcnow() - rec.processing_status = ProcessingStatus.completed - rec.processing_error = None - await session.flush() - await session.commit() - - -async def store_transcript( - session: AsyncSession, - rec: Recording, - text: str, - segments: list, - language: str | None, - provider_name: str, - model: str, -) -> None: - """Insert a new auto version; a newest user-edited row wins instead and - nothing is inserted (edits are verdicts).""" - existing = ( - await session.scalars( - select(Transcript) - .where( - Transcript.recording_id == rec.id, - Transcript.superseded_at.is_(None), - ) - .order_by(Transcript.version.desc()) - ) - ).all() - if existing and existing[0].edited_by_user: - return - now = utcnow() - max_version = await session.scalar( - select(func.max(Transcript.version)).where(Transcript.recording_id == rec.id) - ) - for row in existing: - row.superseded_at = now - session.add( - Transcript( - recording_id=rec.id, - version=(max_version or 0) + 1, - language=language, - provider=provider_name, - model=model, - text=text, - segments=[ - {"start": s.start, "end": s.end, "text": s.text, "speaker": s.speaker} - for s in segments - ], - edited_by_user=False, - ) - ) - await session.flush() - - -async def store_summary( - session: AsyncSession, - rec: Recording, - content: dict, - provider_name: str, - model: str, -) -> None: - existing = ( - await session.scalars( - select(Summary) - .where( - Summary.recording_id == rec.id, - Summary.superseded_at.is_(None), - ) - .order_by(Summary.version.desc()) - ) - ).all() - if existing and existing[0].edited_by_user: - return - now = utcnow() - max_version = await session.scalar( - select(func.max(Summary.version)).where(Summary.recording_id == rec.id) - ) - for row in existing: - row.superseded_at = now - session.add( - Summary( - recording_id=rec.id, - version=(max_version or 0) + 1, - provider=provider_name, - model=model, - content=content, - edited_by_user=False, - ) - ) - await session.flush() - - -async def sweep_stale(limit: int = 100) -> int: - """Crash recovery + transport-loss backstop: requeue jobs stuck running - or sitting queued, newest catastrophe first. Returns jobs re-enqueued.""" - from shonar.db.session import session_factory - - count = 0 - async with session_factory()() as session: - rows = ( - await session.scalars( - select(ProcessingJob) - .where(ProcessingJob.status.in_([JobStatus.queued, JobStatus.running])) - .order_by(ProcessingJob.id.desc()) - .limit(limit) - ) - ).all() - for job in rows: - rec = await session.get(Recording, job.recording_id) - if rec is None or rec.deleted_at is not None: - job.status = JobStatus.skipped - continue - if job.status == JobStatus.running: - if job.attempt >= MAX_TRIES: - job.status = JobStatus.failed - job.error = "Worker died too many times." - job.finished_at = utcnow() - continue - # Live work, not a crash relic: a running job that started - # recently belongs to a worker still chewing it (long audio - # outruns the sweep cadence). Only orphaned runs — no - # started_at, or older than the grace window — get requeued. - if job.started_at is not None and ( - utcnow() - job.started_at - ) < STALE_RUNNING_AFTER: - continue - job.status = JobStatus.queued - job.error = None - count += 1 - await session.commit() - # Transport outside the transaction: rows are the queue, this just pokes. - async with session_factory()() as session: - rows = ( - await session.scalars( - select(ProcessingJob) - .where(ProcessingJob.status == JobStatus.queued) - .order_by(ProcessingJob.id.desc()) - .limit(limit) - ) - ).all() - for job in rows: - await transport_enqueue(job.job_type, job.recording_id) - return count diff --git a/backend/shonar/services/retention.py b/backend/shonar/services/retention.py deleted file mode 100644 index 9547ca0..0000000 --- a/backend/shonar/services/retention.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Retention sweep (M9): hard-delete what passed its grace window. - -Two policies, both driven by ``deleted_at``: - -* **Recordings** soft-deleted more than ``retention_grace_days`` ago have - their rows removed (cascade cleans transcripts/summaries/jobs/tags) and - every stored asset file deleted best-effort. -* **Accounts** deleted more than ``retention_grace_days`` ago are hard - deleted (user cascade takes their recordings/assets/devices/tokens); - their storage files are collected the same way. - -Running inside the grace window is a no-op, so an accidental delete stays -cancellable until the sweep actually fires. The sweep is idempotent and -safe to run on any cadence (worker cron + inline-queue timer). -""" - -from __future__ import annotations - -import contextlib -import logging -from datetime import timedelta - -from sqlalchemy import delete as sql_delete -from sqlalchemy import select - -from shonar.core.config import get_settings -from shonar.db.models import Asset, Recording, User, utcnow -from shonar.db.session import session_factory -from shonar.storage import get_storage - -logger = logging.getLogger("shonar.retention") - - -async def sweep_deleted(limit: int = 200) -> dict[str, int]: - """Hard-purge expired recordings and accounts. Returns counts.""" - grace = timedelta(days=get_settings().retention_grace_days) - cutoff = utcnow() - grace - purged = {"recordings": 0, "users": 0, "files": 0} - storage = get_storage() - - async with session_factory()() as session: - # --- recordings (skip rows whose account is also expiring: the - # user cascade below collects their files in one pass) --- - expiring_users = select(User.id).where( - User.deleted_at.is_not(None), User.deleted_at < cutoff - ) - recs = list( - await session.scalars( - select(Recording) - .where( - Recording.deleted_at.is_not(None), - Recording.deleted_at < cutoff, - Recording.user_id.notin_(expiring_users), - ) - .limit(limit) - ) - ) - for rec in recs: - assets = list( - await session.scalars(select(Asset).where(Asset.recording_id == rec.id)) - ) - await session.delete(rec) - await session.flush() - for a in assets: - with contextlib.suppress(Exception): # best effort; DB row is gone - await storage.delete(a.storage_key) - purged["files"] += 1 - purged["recordings"] += 1 - - # --- accounts --- - users = list( - await session.scalars( - select(User) - .where(User.deleted_at.is_not(None), User.deleted_at < cutoff) - .limit(limit) - ) - ) - for user in users: - assets = list( - await session.scalars(select(Asset).where(Asset.user_id == user.id)) - ) - keys = [a.storage_key for a in assets] - # DB-level delete: the ORM would null the NOT NULL FKs of the - # user's recordings before the ON DELETE CASCADE could fire. - await session.execute(sql_delete(User).where(User.id == user.id)) - await session.flush() - for key in keys: - with contextlib.suppress(Exception): - await storage.delete(key) - purged["files"] += 1 - purged["users"] += 1 - - await session.commit() - - if purged["recordings"] or purged["users"]: - logger.info( - "retention sweep purged %d recordings, %d accounts, %d files (grace %dd)", - purged["recordings"], purged["users"], purged["files"], - get_settings().retention_grace_days, - ) - return purged diff --git a/backend/shonar/services/search.py b/backend/shonar/services/search.py deleted file mode 100644 index 2340188..0000000 --- a/backend/shonar/services/search.py +++ /dev/null @@ -1,292 +0,0 @@ -"""Full-text search across recordings (M9). - -Postgres uses the tsvector columns from migration ``fts0000000001`` -(title/notes, transcript text, summary content, tag names). SQLite (the -desktop bundled-lite engine) falls back to a substring scan; libraries -there are single-user and small, and the endpoint contract is identical. - -This module is the whole SearchBackend seam — a Meilisearch/OpenSearch -implementation would replace it, not the callers. -""" - -from __future__ import annotations - -import uuid -from dataclasses import dataclass - -from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import AsyncSession - -from shonar.db.models import Recording, RecordingTag, Summary, Tag, Transcript - -# ``scope`` values accepted by the endpoint. -SCOPES = ("all", "title", "notes", "transcript", "summary", "tag") - -# Hit ordering: a title hit outranks a transcript hit. -_FIELD_RANK = {"title": 0, "tag": 1, "notes": 2, "summary": 3, "transcript": 4} - - -@dataclass -class SearchHit: - recording: Recording - field: str # where the best match landed - snippet: str - - -def _snippet_around(value: str, at: int, width: int = 160) -> str: - """~``width`` chars centred on ``at``, word-bounded, with ellipses.""" - half = width // 2 - start = max(0, at - half) - end = min(len(value), at + half) - if start > 0: - start = value.rfind(" ", 0, start) + 1 or start - if end < len(value): - nxt = value.find(" ", end) - end = nxt if nxt != -1 else end - prefix = "…" if start > 0 else "" - suffix = "…" if end < len(value) else "" - return f"{prefix}{value[start:end].strip()}{suffix}" - - -async def tag_names(session: AsyncSession, recording_id: uuid.UUID) -> list[str]: - return list( - await session.scalars( - select(Tag.name) - .join(RecordingTag, RecordingTag.tag_id == Tag.id) - .where(RecordingTag.recording_id == recording_id) - ) - ) - - -async def _current_transcript_text(session: AsyncSession, recording_id: uuid.UUID) -> str: - row = await session.scalar( - select(Transcript.text) - .where(Transcript.recording_id == recording_id, Transcript.superseded_at.is_(None)) - .order_by(Transcript.version.desc()) - ) - return row or "" - - -def _summary_flat(content: dict | None) -> str: - if not content: - return "" - parts = [str(content.get("short", "")), str(content.get("detailed", ""))] - for key in ("key_points", "decisions", "action_items", "questions"): - v = content.get(key) - if isinstance(v, list): - parts.extend(str(x) for x in v) - return " ".join(p for p in parts if p) - - -# --- SQLite fallback ---------------------------------------------------------- - - -async def _search_sqlite( - session: AsyncSession, user_id: uuid.UUID, q: str, scope: str, - limit: int, offset: int, -) -> tuple[list[SearchHit], int]: - needles = [t for t in q.lower().split() if t] - if not needles: - return [], 0 - recs = list( - await session.scalars( - select(Recording) - .where(Recording.user_id == user_id, Recording.deleted_at.is_(None)) - .order_by(Recording.recorded_at.desc()) - ) - ) - hits: list[SearchHit] = [] - for rec in recs: - fields: list[tuple[str, str]] = [] - if scope in ("all", "title"): - fields.append(("title", rec.title or "")) - if scope in ("all", "notes"): - fields.append(("notes", rec.notes or "")) - if scope in ("all", "tag"): - fields.append(("tag", " ".join(await tag_names(session, rec.id)))) - if scope in ("all", "transcript"): - fields.append(("transcript", await _current_transcript_text(session, rec.id))) - if scope in ("all", "summary"): - content = await session.scalar( - select(Summary.content) - .where(Summary.recording_id == rec.id, Summary.superseded_at.is_(None)) - .order_by(Summary.version.desc()) - ) - fields.append(("summary", _summary_flat(content))) - # A field matches when EVERY needle appears in it (AND semantics, - # matching plainto_tsquery on the Postgres side). - best: SearchHit | None = None - for field, value in fields: - low = value.lower() - if not all(n in low for n in needles): - continue - at = low.find(needles[0]) - cand = SearchHit( - recording=rec, field=field, snippet=_snippet_around(value, max(at, 0)) - ) - if best is None or _FIELD_RANK[field] < _FIELD_RANK[best.field]: - best = cand - if best is not None: - hits.append(best) - hits.sort(key=lambda h: (_FIELD_RANK[h.field], h.recording.recorded_at), reverse=False) - return hits[offset : offset + limit], len(hits) - - -# --- PostgreSQL tsvector path ------------------------------------------------- - -# CTE ``q`` carries the parsed tsquery so it is computed once. Notes are not -# in the recordings search_vector weights the way we want headlines, so notes -# match by substring like the SQLite path (title/notes share the vector; the -# field classifier prefers 'title' when the vector hits). -_PG_MATCHES = """ -WITH q AS (SELECT plainto_tsquery('simple', :q) AS ts), -lt AS ( - SELECT DISTINCT ON (recording_id) recording_id, search_vector, text - FROM transcripts WHERE superseded_at IS NULL - ORDER BY recording_id, version DESC -), -ls AS ( - SELECT DISTINCT ON (recording_id) recording_id, content, search_vector - FROM summaries WHERE superseded_at IS NULL - ORDER BY recording_id, version DESC -), -tm AS ( - SELECT DISTINCT rt.recording_id, - ts_headline('simple', t.name, q.ts, - 'StartSel=,StopSel=,MaxFragments=0') AS snip - FROM recording_tags rt - JOIN tags t ON t.id = rt.tag_id AND t.user_id = :uid, q -), -matched AS ( - SELECT r.id AS id, - CASE - WHEN :want_title AND r.search_vector @@ q.ts - AND coalesce(r.title, '') <> '' - THEN 'title' - WHEN :want_tag AND tm.recording_id IS NOT NULL THEN 'tag' - WHEN :want_notes AND coalesce(r.notes, '') ILIKE '%' || :raw || '%' THEN 'notes' - WHEN :want_summary AND ls.search_vector @@ q.ts THEN 'summary' - WHEN :want_transcript AND lt.search_vector @@ q.ts THEN 'transcript' - END AS field, - CASE - WHEN :want_title AND r.search_vector @@ q.ts - AND coalesce(r.title, '') <> '' - THEN ts_headline('simple', r.title, q.ts, - 'StartSel=,StopSel=,MaxFragments=0,MaxWords=25') - WHEN :want_tag AND tm.recording_id IS NOT NULL THEN tm.snip - WHEN :want_notes AND coalesce(r.notes, '') ILIKE '%' || :raw || '%' - THEN left(r.notes, 200) - WHEN :want_summary AND ls.search_vector @@ q.ts - THEN ts_headline('simple', - coalesce(ls.content->>'short', '') || ' ' || coalesce(ls.content->>'detailed', ''), - q.ts, 'StartSel=,StopSel=,MaxFragments=1,MinWords=10,MaxWords=25') - WHEN :want_transcript AND lt.search_vector @@ q.ts - THEN ts_headline('simple', coalesce(lt.text, ''), q.ts, - 'StartSel=,StopSel=,MaxFragments=1,MinWords=10,MaxWords=25') - END AS snippet - FROM recordings r - CROSS JOIN q - LEFT JOIN lt ON lt.recording_id = r.id - LEFT JOIN ls ON ls.recording_id = r.id - LEFT JOIN tm ON tm.recording_id = r.id - WHERE r.user_id = :uid AND r.deleted_at IS NULL -) -SELECT id, field, snippet FROM matched -WHERE field IS NOT NULL -ORDER BY CASE field WHEN 'title' THEN 0 WHEN 'tag' THEN 1 WHEN 'notes' THEN 2 - WHEN 'summary' THEN 3 ELSE 4 END, - id -LIMIT :limit OFFSET :offset -""" - -_PG_COUNT = """ -WITH q AS (SELECT plainto_tsquery('simple', :q) AS ts), -lt AS ( - SELECT DISTINCT ON (recording_id) recording_id, search_vector - FROM transcripts WHERE superseded_at IS NULL -), -ls AS ( - SELECT DISTINCT ON (recording_id) recording_id, search_vector - FROM summaries WHERE superseded_at IS NULL -), -tm AS ( - SELECT DISTINCT rt.recording_id - FROM recording_tags rt - JOIN tags t ON t.id = rt.tag_id AND t.user_id = :uid, q - WHERE t.search_vector @@ q.ts -) -SELECT count(*) -FROM recordings r -CROSS JOIN q -LEFT JOIN lt ON lt.recording_id = r.id -LEFT JOIN ls ON ls.recording_id = r.id -LEFT JOIN tm ON tm.recording_id = r.id -WHERE r.user_id = :uid AND r.deleted_at IS NULL - AND ( - (:want_title AND r.search_vector @@ q.ts) OR - (:want_transcript AND lt.search_vector @@ q.ts) OR - (:want_summary AND ls.search_vector @@ q.ts) OR - (:want_tag AND tm.recording_id IS NOT NULL) OR - (:want_notes AND coalesce(r.notes, '') ILIKE '%' || :raw || '%') - ) -""" - - -async def _search_postgres( - session: AsyncSession, user_id: uuid.UUID, q: str, scope: str, - limit: int, offset: int, -) -> tuple[list[SearchHit], int]: - # NOTE: the title field matches anything the recordings vector hits - # (title + notes); notes-only hits surface under 'title' headlines from - # the title text. Acceptable precision tradeoff for a GIN-indexed path. - params = { - "uid": user_id, - "q": q, - "raw": q, - "limit": limit, - "offset": offset, - "want_title": scope in ("all", "title"), - "want_transcript": scope in ("all", "transcript"), - "want_summary": scope in ("all", "summary"), - "want_tag": scope in ("all", "tag"), - "want_notes": scope in ("all", "notes"), - } - rows = (await session.execute(text(_PG_MATCHES), params)).all() - total = await session.scalar(text(_PG_COUNT), params) or 0 - if not rows: - return [], total - ids = [r[0] for r in rows] - by_id = { - rec.id: rec - for rec in ( - await session.scalars(select(Recording).where(Recording.id.in_(ids))) - ).all() - } - hits = [ - SearchHit(recording=by_id[r.id], field=r.field, snippet=r.snippet or "") - for r in rows - if r.id in by_id - ] - return hits, total - - -# --- public API ---------------------------------------------------------------- - - -async def search_recordings( - session: AsyncSession, - user_id: uuid.UUID, - q: str, - *, - scope: str = "all", - limit: int = 20, - offset: int = 0, -) -> tuple[list[SearchHit], int]: - """Returns (page of hits ordered by field rank, total). Empty q → no hits.""" - q = q.strip() - if not q: - return [], 0 - dialect = session.bind.dialect.name if session.bind else "sqlite" - if dialect == "postgresql": - return await _search_postgres(session, user_id, q, scope, limit, offset) - return await _search_sqlite(session, user_id, q, scope, limit, offset) diff --git a/backend/shonar/services/uploads.py b/backend/shonar/services/uploads.py deleted file mode 100644 index 864059c..0000000 --- a/backend/shonar/services/uploads.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Chunked, resumable upload sessions. - -Flow: - 1. POST /uploads -> session (uuid), chunk size, expiry - 2. PUT /uploads/{id}/chunks/{n} (idempotent per index; GET status lists - received indexes so clients resume) - 3. POST /uploads/{id}/finalize -> validates size + magic bytes, - assembles the object, creates the - immutable original Asset and the - Recording (or updates the existing - recording for a retried - client_recording_id). - -Storage keys are server-generated UUID paths; clients never see them. -Originals are immutable: finalize never overwrites an existing original. -""" - -from __future__ import annotations - -import contextlib -import hashlib -import uuid -from datetime import datetime, timedelta - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from shonar.core.config import get_settings -from shonar.db.models import ( - Asset, - AssetKind, - ProcessingStatus, - Recording, - UploadChunk, - UploadSession, - UploadSessionStatus, - utcnow, -) -from shonar.services.media import is_compatible -from shonar.storage import get_storage - -SESSION_TTL = timedelta(hours=24) - - -class UploadError(Exception): - def __init__(self, message: str, status_code: int = 400): - super().__init__(message) - self.message = message - self.status_code = status_code - - -async def create_session( - session: AsyncSession, - user_id: uuid.UUID, - declared_mime_type: str, - declared_size_bytes: int, - title: str | None, - client_recording_id: str | None, - transcription_model: str | None = None, -) -> UploadSession: - settings = get_settings() - if declared_size_bytes <= 0 or declared_size_bytes > settings.max_upload_bytes: - raise UploadError( - f"Declared size must be between 1 and {settings.max_upload_bytes} bytes.", 413 - ) - allowed = [m.lower() for m in settings.allowed_audio_mime_types] - if declared_mime_type.lower() not in allowed: - raise UploadError(f"MIME type not allowed. Allowed: {', '.join(allowed)}", 415) - - us = UploadSession( - user_id=user_id, - client_recording_id=client_recording_id, - title=title, - declared_mime_type=declared_mime_type.lower(), - declared_size_bytes=declared_size_bytes, - chunk_size_bytes=settings.max_chunk_bytes, - expires_at=utcnow() + SESSION_TTL, - transcription_model=(transcription_model.strip() if transcription_model else None), - ) - session.add(us) - await session.flush() - return us - - -async def get_owned_session( - session: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID -) -> UploadSession: - us = await session.get(UploadSession, session_id) - if us is None or us.user_id != user_id: - raise UploadError("Upload session not found.", 404) - if us.status == UploadSessionStatus.expired or ( - us.status != UploadSessionStatus.completed and us.expires_at < utcnow() - ): - us.status = UploadSessionStatus.expired - raise UploadError("Upload session expired. Start a new upload.", 410) - return us - - -async def put_chunk( - session: AsyncSession, - user_id: uuid.UUID, - session_id: uuid.UUID, - chunk_index: int, - data: bytes, - checksum_sha256: str | None, -) -> UploadChunk: - settings = get_settings() - us = await get_owned_session(session, user_id, session_id) - if us.status != UploadSessionStatus.open: - raise UploadError(f"Session is {us.status.value}; cannot accept chunks.", 409) - if chunk_index < 0: - raise UploadError("Chunk index must be >= 0.", 400) - if not data: - raise UploadError("Empty chunk.", 400) - if len(data) > settings.max_chunk_bytes: - raise UploadError(f"Chunk exceeds max size {settings.max_chunk_bytes}.", 413) - if checksum_sha256 and hashlib.sha256(data).hexdigest() != checksum_sha256.lower(): - raise UploadError("Chunk checksum mismatch.", 422) - - existing = await session.scalar( - select(UploadChunk).where( - UploadChunk.session_id == us.id, UploadChunk.chunk_index == chunk_index - ) - ) - if existing is not None: - # Idempotent retry: same index re-sent replaces the stored bytes. - if existing.size_bytes != len(data): - await get_storage().delete(existing.storage_key) - existing.size_bytes = len(data) - existing.checksum_sha256 = hashlib.sha256(data).hexdigest() - await get_storage().put(existing.storage_key, data) - await session.flush() - return existing - - key = f"uploads/{us.id}/{chunk_index:06d}.part" - await get_storage().put(key, data) - chunk = UploadChunk( - session_id=us.id, - chunk_index=chunk_index, - size_bytes=len(data), - checksum_sha256=hashlib.sha256(data).hexdigest(), - storage_key=key, - ) - session.add(chunk) - await session.flush() - return chunk - - -async def received_indexes( - session: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID -) -> list[int]: - us = await get_owned_session(session, user_id, session_id) - rows = await session.scalars( - select(UploadChunk.chunk_index).where(UploadChunk.session_id == us.id) - ) - return sorted(rows) - - -async def finalize( - session: AsyncSession, - user_id: uuid.UUID, - session_id: uuid.UUID, - *, - recorded_at: datetime | None, - duration_seconds: float, - latitude: float | None = None, - longitude: float | None = None, - location_accuracy_m: float | None = None, - notes: str | None = None, - transcription_model: str | None = None, -) -> tuple[UploadSession, Recording, Asset]: - """Assemble chunks, validate, store the immutable original, and create or - update the recording. Idempotent per client_recording_id.""" - from shonar.services.ai import ProviderConfigError - from shonar.services.ai.model_registry import ( - effective_model, - get_global_default_model, - validate_model_name, - ) - - us = await get_owned_session(session, user_id, session_id) - # Resolve + validate the transcription model before touching audio. - # Finalize body wins over the session's upload-screen choice; an - # explicit override updates history, the default never rewrites it. - override_raw = transcription_model or us.transcription_model - override: str | None = None - if override_raw: - try: - override = validate_model_name(override_raw) - except ProviderConfigError as e: - raise UploadError(str(e), 422) from None - model = effective_model(override, await get_global_default_model(session)) - if us.status == UploadSessionStatus.completed and us.completed_asset_id: - # Already finalized: return existing recording (retry-safe client). - asset = await session.get(Asset, us.completed_asset_id) - rec = await session.scalar( - select(Recording).where(Recording.id == asset.recording_id) - ) - if asset and rec: - return us, rec, asset - if us.status != UploadSessionStatus.open: - raise UploadError(f"Session is {us.status.value}.", 409) - - chunks = list( - await session.scalars( - select(UploadChunk).where(UploadChunk.session_id == us.id).order_by( - UploadChunk.chunk_index - ) - ) - ) - total = sum(c.size_bytes for c in chunks) - if total != us.declared_size_bytes: - raise UploadError( - f"Size mismatch: received {total} of declared {us.declared_size_bytes} bytes. " - "Upload missing chunks and retry.", - 422, - ) - expected_indexes = list(range(len(chunks))) - if [c.chunk_index for c in chunks] != expected_indexes: - raise UploadError("Chunk sequence has gaps. Upload missing chunks and retry.", 422) - - storage = get_storage() - # Validate magic bytes from the first chunk. - first = await storage.get(chunks[0].storage_key) - if not is_compatible(us.declared_mime_type, first): - raise UploadError( - "File contents do not match the declared audio MIME type.", 415 - ) - - # Idempotency: same client_recording_id => update existing recording. - recording: Recording | None = None - if us.client_recording_id: - recording = await session.scalar( - select(Recording).where( - Recording.user_id == user_id, - Recording.client_recording_id == us.client_recording_id, - ) - ) - - # NOTE(original-immutability): if the recording already has an original - # asset we do NOT replace it; a re-upload with the same client id after - # local edits updates metadata only, and the new audio is rejected as a - # duplicate (the existing original is returned instead). - if recording is not None: - existing_original = await session.scalar( - select(Asset).where( - Asset.recording_id == recording.id, Asset.kind == AssetKind.original - ) - ) - if existing_original is not None: - us.status = UploadSessionStatus.completed - us.completed_asset_id = existing_original.id - recording.title = us.title or recording.title - if override is not None: - # Explicit re-choice replaces the saved model; the default - # never rewrites history. - recording.transcription_model = override - await session.flush() - from shonar.services import processing as _processing - - # Same audio, maybe new metadata — and a failed pipeline deserves - # another attempt. Idempotent: completed work is never redone. - await _processing.enqueue_for_recording(session, recording) - return us, recording, existing_original - - # Assemble into the final object (streamed per chunk to bound memory). - from shonar.services.media import extension_for - - digest = hashlib.sha256() - parts: list[bytes] = [] - for c in chunks: - data = await storage.get(c.storage_key) - digest.update(data) - parts.append(data) - checksum = digest.hexdigest() - ext = extension_for(us.declared_mime_type, parts[0]) - final_key = f"recordings/{user_id}/{uuid.uuid4()}{ext}" - blob = b"".join(parts) - await storage.put(final_key, blob) - - asset = Asset( - recording_id=recording.id if recording else None, - user_id=user_id, - kind=AssetKind.original, - storage_key=final_key, - mime_type=us.declared_mime_type, - size_bytes=total, - checksum_sha256=checksum, - ) - session.add(asset) - - if recording is None: - recording = Recording( - user_id=user_id, - client_recording_id=us.client_recording_id, - title=us.title or "Untitled recording", - recorded_at=recorded_at or utcnow(), - duration_seconds=duration_seconds, - notes=notes, - latitude=latitude, - longitude=longitude, - location_accuracy_m=location_accuracy_m, - processing_status=ProcessingStatus.uploaded, - transcription_model=model, - ) - session.add(recording) - await session.flush() - asset.recording_id = recording.id - else: - recording.title = us.title or recording.title - recording.duration_seconds = duration_seconds or recording.duration_seconds - recording.processing_status = ProcessingStatus.uploaded - recording.processing_error = None - - us.status = UploadSessionStatus.completed - us.completed_asset_id = asset.id - - # Clean up chunk parts (the assembled object is the source of truth). - for c in chunks: # best-effort cleanup of chunk parts - with contextlib.suppress(Exception): - await storage.delete(c.storage_key) - await session.flush() - from shonar.services import processing as _processing - - # New audio on disk: queue whatever AI stages apply (none configured = - # ai_disabled, never an error). - await _processing.enqueue_for_recording(session, recording) - return us, recording, asset - - -async def abort(session: AsyncSession, user_id: uuid.UUID, session_id: uuid.UUID) -> None: - us = await get_owned_session(session, user_id, session_id) - if us.status in (UploadSessionStatus.completed, UploadSessionStatus.aborted): - return - storage = get_storage() - chunks = list( - await session.scalars(select(UploadChunk).where(UploadChunk.session_id == us.id)) - ) - for c in chunks: # best-effort cleanup - with contextlib.suppress(Exception): - await storage.delete(c.storage_key) - us.status = UploadSessionStatus.aborted diff --git a/backend/shonar/storage/__init__.py b/backend/shonar/storage/__init__.py deleted file mode 100644 index b9aa77b..0000000 --- a/backend/shonar/storage/__init__.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Storage abstraction. - -Backends: -- ``LocalStorage``: filesystem under ``SHONAR_STORAGE_PATH`` (dev + default). -- ``S3Storage``: any S3-compatible object store (extra: ``pip install - shonar-backend[s3]``). - -Storage keys are server-internal and validated against path traversal: -they are UUID-based by construction and never derived from user input. -At-rest encryption is NOT implemented; see docs/security.md for the -documented optional design. -""" - -from __future__ import annotations - -import asyncio -import shutil -from pathlib import Path, PurePosixPath -from typing import Protocol - -from shonar.core.config import get_settings - -_KEY_CHARSET = set("abcdefghijklmnopqrstuvwxyz0123456789-./") - - -class StorageError(Exception): - pass - - -def validate_storage_key(key: str) -> str: - """Reject anything that could escape the storage root.""" - if not key or len(key) > 500: - raise StorageError("invalid storage key") - pure = PurePosixPath(key) - if pure.is_absolute() or ".." in pure.parts: - raise StorageError("invalid storage key") - if not set(key) <= _KEY_CHARSET: - raise StorageError("invalid storage key") - return key - - -class StorageBackend(Protocol): - async def put(self, key: str, data: bytes) -> int: ... - async def put_file(self, key: str, src_path: Path) -> int: ... - async def get(self, key: str) -> bytes: ... - async def open_path(self, key: str) -> Path | None: - """Local file path if the backend can provide one, else None.""" - ... - - async def delete(self, key: str) -> None: ... - async def exists(self, key: str) -> bool: ... - - -class LocalStorage: - def __init__(self, root: str | Path): - self.root = Path(root).resolve() - self.root.mkdir(parents=True, exist_ok=True) - - def _path(self, key: str) -> Path: - validate_storage_key(key) - path = (self.root / key).resolve() - # Defense in depth: resolved path must stay under root. - if not path.is_relative_to(self.root): - raise StorageError("storage key escapes storage root") - return path - - async def put(self, key: str, data: bytes) -> int: - path = self._path(key) - path.parent.mkdir(parents=True, exist_ok=True) - await asyncio.to_thread(path.write_bytes, data) - return len(data) - - async def put_file(self, key: str, src_path: Path) -> int: - path = self._path(key) - path.parent.mkdir(parents=True, exist_ok=True) - await asyncio.to_thread(shutil.copyfile, src_path, path) - return path.stat().st_size - - async def get(self, key: str) -> bytes: - path = self._path(key) - if not path.exists(): - raise StorageError("object not found") - return await asyncio.to_thread(path.read_bytes) - - async def open_path(self, key: str) -> Path | None: - path = self._path(key) - return path if path.exists() else None - - async def delete(self, key: str) -> None: - path = self._path(key) - if path.exists(): - await asyncio.to_thread(path.unlink) - - async def exists(self, key: str) -> bool: - return self._path(key).exists() - - -class S3Storage: # pragma: no cover - requires boto3 + a real/mini endpoint - def __init__( - self, endpoint_url: str, bucket: str, region: str, access_key: str, secret_key: str - ): - import boto3 # optional extra - - self.bucket = bucket - self.s3 = boto3.client( - "s3", - endpoint_url=endpoint_url or None, - region_name=region, - aws_access_key_id=access_key or None, - aws_secret_access_key=secret_key or None, - ) - - async def put(self, key: str, data: bytes) -> int: - validate_storage_key(key) - await asyncio.to_thread(self.s3.put_object, Bucket=self.bucket, Key=key, Body=data) - return len(data) - - async def put_file(self, key: str, src_path: Path) -> int: - validate_storage_key(key) - await asyncio.to_thread(self.s3.upload_file, str(src_path), self.bucket, key) - return src_path.stat().st_size - - async def get(self, key: str) -> bytes: - validate_storage_key(key) - - def _get() -> bytes: - obj = self.s3.get_object(Bucket=self.bucket, Key=key) - return obj["Body"].read() - - return await asyncio.to_thread(_get) - - async def open_path(self, key: str) -> Path | None: - return None # callers must use get()/streaming - - async def delete(self, key: str) -> None: - validate_storage_key(key) - await asyncio.to_thread(self.s3.delete_object, Bucket=self.bucket, Key=key) - - async def exists(self, key: str) -> bool: - validate_storage_key(key) - - def _head() -> bool: - from botocore.exceptions import ClientError - - try: - self.s3.head_object(Bucket=self.bucket, Key=key) - return True - except ClientError: - return False - - return await asyncio.to_thread(_head) - - -_backend: StorageBackend | None = None - - -def get_storage() -> StorageBackend: - global _backend - if _backend is None: - settings = get_settings() - if settings.storage_backend == "local": - _backend = LocalStorage(settings.storage_path) - elif settings.storage_backend == "s3": - _backend = S3Storage( - settings.s3_endpoint_url, - settings.s3_bucket, - settings.s3_region, - settings.s3_access_key_id, - settings.s3_secret_access_key, - ) - else: - raise StorageError(f"unknown storage backend: {settings.storage_backend}") - return _backend - - -def set_storage(backend: StorageBackend | None) -> None: - """Test seam.""" - global _backend - _backend = backend diff --git a/backend/shonar/worker.py b/backend/shonar/worker.py deleted file mode 100644 index d06b37d..0000000 --- a/backend/shonar/worker.py +++ /dev/null @@ -1,79 +0,0 @@ -"""arq worker entrypoint (M7): runs the AI pipeline tasks. - -Run: arq shonar.worker.WorkerSettings -""" - -from __future__ import annotations - -import logging - -from arq import cron -from arq.connections import RedisSettings - -from shonar.core.config import get_settings -from shonar.db.session import dispose_engine, get_engine -from shonar.services import processing - -logger = logging.getLogger("shonar.worker") - - -async def run_transcribe(ctx: dict, recording_id: str) -> None: - await processing.run_transcribe(ctx, recording_id) - - -async def run_summarize(ctx: dict, recording_id: str) -> None: - await processing.run_summarize(ctx, recording_id) - - -async def sweep(ctx: dict) -> None: # noqa: ARG001 — arq cron signature - count = await processing.sweep_stale() - if count: - logger.info("sweep re-enqueued %d stale jobs", count) - - -async def retention_sweep(ctx: dict) -> None: # noqa: ARG001 — arq cron signature - from shonar.services import retention - - purged = await retention.sweep_deleted() - if purged["recordings"] or purged["users"]: - logger.info("retention sweep: %s", purged) - - -async def startup(ctx: dict) -> None: - get_engine() - # Crash recovery before accepting new work: jobs stuck `running` and - # queued rows the transport missed go back through arq. - count = await processing.sweep_stale() - if count: - logger.info("startup sweep re-enqueued %d stale jobs", count) - - -async def shutdown(ctx: dict) -> None: # noqa: ARG001 - await dispose_engine() - - -def _redis() -> RedisSettings: - return RedisSettings.from_dsn(get_settings().redis_url) - - -class WorkerSettings: - functions = [run_transcribe, run_summarize, sweep, retention_sweep] - # Transport-loss backstop beyond the startup sweep: anything still - # queued (missed enqueue, dead worker between runs) goes back through - # arq every 5 minutes. Rows are the queue; this just pokes. - cron_jobs = [ - cron(sweep, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}), - # Hard-delete expired soft-deletes once a day (3:17 local, off-peak). - cron(retention_sweep, hour=3, minute=17), - ] - on_startup = startup - on_shutdown = shutdown - redis_settings = _redis() - # Retry budget for transient provider failures; the tasks themselves - # mark jobs failed on the last try (see processing.MAX_TRIES). - max_tries = 3 - # One job per worker process at a time. Local faster-whisper is - # multi-GB per concurrent pass; default max_jobs=10 let one worker run - # ~8 transcriptions at once and OOM-swap the box. Scale by running more - # worker processes, never by raising this. - max_jobs = 1 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index a19a81e..0000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Pytest fixtures. - -Tests run against a real PostgreSQL (deploy/docker-compose.dev.yml) using a -dedicated ``shonar_test`` database, plus a temporary local storage root. -""" - -from __future__ import annotations - -import os -import tempfile -from collections.abc import AsyncIterator -from pathlib import Path - -import pytest -import pytest_asyncio - -# Configure env BEFORE importing the app so Settings picks it up. -TEST_DB = os.environ.get( - "SHONAR_TEST_DATABASE_URL", - "postgresql+asyncpg://shonar:shonar@localhost:5432/shonar_test", -) -os.environ["SHONAR_DATABASE_URL"] = TEST_DB -os.environ["SHONAR_SECRET_KEY"] = "test-secret-key-0123456789abcdef0123456789abcdef" -os.environ["SHONAR_STORAGE_BACKEND"] = "local" -# Effectively disable the auth rate limit under test (dedicated tests cover -# the limiter behaviour itself). -os.environ["SHONAR_RATE_LIMIT_AUTH"] = "10000/minute" - -_tmp_storage = tempfile.mkdtemp(prefix="shonar-test-storage-") -os.environ["SHONAR_STORAGE_PATH"] = _tmp_storage - - -@pytest_asyncio.fixture(scope="session", loop_scope="session") -async def _setup_db() -> AsyncIterator[None]: - from shonar.db import models # noqa: F401 - from shonar.db.base import Base - from shonar.db.session import dispose_engine, get_engine - - engine = get_engine() - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - await conn.run_sync(Base.metadata.create_all) - # The tsvector generated columns live only in migration - # fts0000000001 (not in the ORM models), so create_all misses them. - # Apply the same DDL the migration applies (M9 search needs them). - if engine.dialect.name == "postgresql": - # Reuse the real migration's DDL (not a copy) via a sync - # MigrationContext — op.execute() is synchronous there. - import importlib.util - from pathlib import Path - - def _apply(sync_conn): - from alembic.migration import MigrationContext - from alembic.operations import Operations - - spec = importlib.util.spec_from_file_location( - "fts_migration", - Path(__file__).resolve().parents[1] - / "migrations/versions/fts0000000001_fts_columns.py", - ) - assert spec is not None and spec.loader is not None - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - ctx = MigrationContext.configure(sync_conn) - with Operations.context(ctx): - mod.upgrade() - - await conn.run_sync(_apply) - yield - await dispose_engine() - - -@pytest_asyncio.fixture(loop_scope="session", autouse=True) -async def clean_db(_setup_db: None) -> AsyncIterator[None]: - """Truncate between tests for isolation.""" - yield - from sqlalchemy import delete, text - - from shonar.db.base import Base - from shonar.db.session import _session_factory, get_engine # type: ignore[attr-defined] - - assert _session_factory is not None - async with _session_factory() as s: - if get_engine().dialect.name == "postgresql": - await s.execute( - text( - "TRUNCATE users, devices, refresh_tokens, recordings, assets, " - "upload_sessions, upload_chunks, transcripts, summaries, tags, " - "recording_tags, processing_jobs, export_jobs, app_settings " - "RESTART IDENTITY CASCADE" - ) - ) - else: - # SQLite: delete child-first (reverse dependency order). - for table in reversed(Base.metadata.sorted_tables): - await s.execute(delete(table)) - await s.commit() - - -@pytest_asyncio.fixture(loop_scope="session") -async def client(_setup_db) -> AsyncIterator: - from httpx import ASGITransport, AsyncClient - - from shonar.main import app - - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as c: - yield c - - -@pytest.fixture() -def storage_root() -> Path: - return Path(_tmp_storage) diff --git a/backend/tests/test_ai_adapters.py b/backend/tests/test_ai_adapters.py deleted file mode 100644 index e9a4599..0000000 --- a/backend/tests/test_ai_adapters.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Adapter wire-protocol tests (M7): JSON shapes in/out, error mapping. - -HTTP adapters take an injectable httpx client; faster-whisper is an -optional dependency and is only exercised when installed. -""" - -from __future__ import annotations - -import importlib.util - -import httpx -import pytest - -from shonar.core.config import Settings -from shonar.services import ai -from shonar.services.ai import ProviderConfigError, SummaryResult -from shonar.services.ai.ollama import OllamaProvider -from shonar.services.ai.openai_compat import OpenAICompatProvider -from shonar.services.ai.whisper_http import WhisperHttpProvider - - -def mock_client(handler) -> httpx.AsyncClient: - return httpx.AsyncClient(transport=httpx.MockTransport(handler)) - - -# --- factories ----------------------------------------------------------------- - - -def test_factories_none_means_skip(): - s = Settings(transcription_provider="none", llm_provider="none") - assert ai.get_transcription_provider(s) is None - assert ai.get_llm_provider(s) is None - - -def test_factories_unknown_names_raise_config_error(): - s = Settings(transcription_provider="whisper-9k") - with pytest.raises(ProviderConfigError): - ai.get_transcription_provider(s) - s = Settings(llm_provider="clippy") - with pytest.raises(ProviderConfigError): - ai.get_llm_provider(s) - - -def test_factories_missing_urls_raise_config_error(): - s = Settings(transcription_provider="whisper_http", transcription_base_url="") - with pytest.raises(ProviderConfigError): - ai.get_transcription_provider(s) - s = Settings(llm_provider="openai_compat", llm_base_url="http://x", llm_model="") - with pytest.raises(ProviderConfigError): - ai.get_llm_provider(s) - - -def test_faster_whisper_missing_dep_is_config_error(): - if importlib.util.find_spec("faster_whisper") is not None: - pytest.skip("faster-whisper installed; missing-dep path N/A") - # Constructor validates eagerly so misconfiguration fails at startup, - # not on the first recording. - with pytest.raises(ProviderConfigError): - ai.get_transcription_provider(Settings(transcription_provider="faster_whisper")) - - -# --- whisper_http ----------------------------------------------------------------- - - -def whisper_ok(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/v1/audio/transcriptions" - assert request.method == "POST" - return httpx.Response(200, json={ - "text": "hello world", - "language": "en", - "segments": [{"start": 0.0, "end": 1.2, "text": "hello world"}], - }) - - -async def test_whisper_http_happy_path(): - p = WhisperHttpProvider("http://stt:8000", model="small", - http_client=mock_client(whisper_ok)) - res = await p.transcribe(b"\x00" * 16, "audio/wav") - assert res.text == "hello world" - assert res.language == "en" - assert [(s.start, s.end, s.text) for s in res.segments] == [(0.0, 1.2, "hello world")] - assert res.model == "small" - - -async def test_whisper_http_401_is_config_error(): - async def denied(request: httpx.Request) -> httpx.Response: - return httpx.Response(401, json={"detail": "nope"}) - p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(denied)) - with pytest.raises(ProviderConfigError): - await p.transcribe(b"\x00" * 16, "audio/wav") - - -async def test_whisper_http_503_is_transient(): - async def busy(request: httpx.Request) -> httpx.Response: - return httpx.Response(503, text="overloaded") - from shonar.services.ai import ProviderTransientError - - p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(busy)) - with pytest.raises(ProviderTransientError): - await p.transcribe(b"\x00" * 16, "audio/wav") - - -# --- openai_compat ------------------------------------------------------------------ - - -def chat_ok(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/v1/chat/completions" - body = { - "short": "Standup.", - "detailed": "The team met.", - "key_points": ["a", "b"], - "decisions": ["ship"], - "action_items": [{"not": "a string"}, "call ana"], - "questions": [], - "extra_key": "ignored", - } - import json as _json - - return httpx.Response(200, json={"choices": [{"message": {"content": _json.dumps(body)}}]}) - - -async def test_openai_compat_parses_and_sanitizes(): - p = OpenAICompatProvider("http://llm:8000", model="qwen", - http_client=mock_client(chat_ok)) - res = await p.summarize("a very long meeting transcript", title="Standup") - assert isinstance(res, SummaryResult) - assert res.short == "Standup." - assert res.action_items == ("call ana",) # non-strings dropped - assert res.model == "qwen" - - -async def test_openai_compat_partial_json_gets_defaults(): - import json as _json - - async def partial(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={ - "choices": [{"message": {"content": _json.dumps({"short": "Hi."})}}] - }) - p = OpenAICompatProvider("http://llm:8000", model="qwen", - http_client=mock_client(partial)) - res = await p.summarize("hi") - assert res.short == "Hi." - assert res.detailed == "" and res.key_points == () - - -async def test_openai_compat_non_json_is_transient(): - async def garbage(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={ - "choices": [{"message": {"content": "Sure! Here it is..."}}] - }) - from shonar.services.ai import ProviderTransientError - - p = OpenAICompatProvider("http://llm:8000", model="qwen", - http_client=mock_client(garbage)) - with pytest.raises(ProviderTransientError): - await p.summarize("hi") - - -# --- ollama --------------------------------------------------------------------------- - - -async def test_ollama_happy_path(): - import json as _json - - async def ok(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/api/chat" - payload = _json.loads(request.content.decode()) - assert payload["format"] == "json" and payload["stream"] is False - return httpx.Response(200, json={ - "message": {"content": _json.dumps({"short": "S.", "detailed": "D."})} - }) - p = OllamaProvider("http://ollama:11434", model="llama3", - http_client=mock_client(ok)) - res = await p.summarize("meeting notes") - assert res.short == "S." and res.model == "llama3" - - -async def test_ollama_404_is_config_error(): - async def missing(request: httpx.Request) -> httpx.Response: - return httpx.Response(404, text="model not found") - p = OllamaProvider("http://ollama:11434", model="nope", - http_client=mock_client(missing)) - with pytest.raises(ProviderConfigError): - await p.summarize("hi") diff --git a/backend/tests/test_ai_pipeline.py b/backend/tests/test_ai_pipeline.py deleted file mode 100644 index 52e4755..0000000 --- a/backend/tests/test_ai_pipeline.py +++ /dev/null @@ -1,410 +0,0 @@ -"""AI pipeline tests (M7): status flow, versioning, failure modes, endpoints. - -Provider fakes stand in for real STT/LLM services (no network, no model -downloads); adapter wire-protocol tests live in test_ai_adapters.py. -""" - -from __future__ import annotations - -import struct -import uuid - -import pytest -from sqlalchemy import func, select - -from shonar.db import session as db_session -from shonar.db.models import ( - JobStatus, - JobType, - ProcessingJob, - Transcript, -) -from shonar.services import processing -from shonar.services.ai import ( - ProviderConfigError, - ProviderTransientError, - Segment, - SummaryResult, - TranscriptResult, -) - - -def wav_bytes(payload_len: int = 64) -> bytes: - data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1) - data = data[:payload_len] - header = ( - b"RIFF" + struct.pack(" 0 and mime == "audio/wav" - if self.fail is not None: - raise self.fail - return TranscriptResult( - text=self.text, language="en", - segments=[Segment(0.0, 1.0, self.text)], model="fake-stt-1", - ) - - -class FakeLlm: - name = "fake-llm" - - def __init__(self, fail=None): - self.fail = fail - self.seen = [] - - async def summarize(self, transcript, *, title=None): - self.seen.append(transcript) - if self.fail is not None: - raise self.fail - return SummaryResult( - short="Standup happened.", detailed="The team met and spoke.", - key_points=("a",), decisions=(), action_items=("ship it",), - questions=(), model="fake-llm-1", - ) - - -_UNSET = object() - - -def use_fakes(monkeypatch, stt=_UNSET, llm=_UNSET): - tprov = FakeTranscriber() if stt is _UNSET else stt - lprov = FakeLlm() if llm is _UNSET else llm - monkeypatch.setattr(processing, "get_transcription_provider", lambda settings: tprov) - monkeypatch.setattr(processing, "get_llm_provider", lambda settings: lprov) - - -async def jobs_for(recording_id): - async with db_session._session_factory() as s: - rows = (await s.scalars( - select(ProcessingJob).where(ProcessingJob.recording_id == uuid.UUID(recording_id)) - )).all() - return {j.job_type: j for j in rows} - - -# --- no AI configured -------------------------------------------------------- - - -async def test_none_configured_marks_ai_disabled(client): - token = await user_tokens(client) - rec = await upload_recording(client, token) - assert rec["processing_status"] == "ai_disabled" - assert await jobs_for(rec["id"]) == {} - # Status endpoints: nothing there yet, but the recording exists. - h = {"Authorization": f"Bearer {token}"} - r = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h) - assert r.status_code == 404 - r = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h) - assert r.status_code == 404 - r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h) - assert r.status_code == 200 and r.json() == [] - - -# --- happy path ---------------------------------------------------------------- - - -async def test_full_pipeline_transcribe_then_summarize(client, monkeypatch): - stt, llm = FakeTranscriber(), FakeLlm() - use_fakes(monkeypatch, stt, llm) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-full-1") - assert rec["processing_status"] == "queued" - jobs = await jobs_for(rec["id"]) - assert set(jobs) == {JobType.transcribe} - assert jobs[JobType.transcribe].status == JobStatus.queued - - await processing.run_transcribe({}, rec["id"]) - jobs = await jobs_for(rec["id"]) - assert jobs[JobType.transcribe].status == JobStatus.succeeded - assert set(jobs) == {JobType.transcribe, JobType.summarize} - h = {"Authorization": f"Bearer {token}"} - r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) - assert r.json()["processing_status"] == "processing" - - t = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h) - assert t.status_code == 200, t.text - body = t.json() - assert body["text"] == "hello world from the meeting" - assert body["version"] == 1 and body["provider"] == "fake-stt" - assert body["segments"][0]["text"].startswith("hello") - assert llm.seen == [] - - await processing.run_summarize({}, rec["id"]) - jobs = await jobs_for(rec["id"]) - assert jobs[JobType.summarize].status == JobStatus.succeeded - s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h) - assert s.status_code == 200, s.text - content = s.json()["content"] - assert content["short"] == "Standup happened." - assert content["action_items"] == ["ship it"] - assert set(content) == {"short", "detailed", "key_points", "decisions", - "action_items", "questions"} - r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) - assert r.json()["processing_status"] == "completed" - assert llm.seen == ["hello world from the meeting"] - - jobs_rows = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h) - assert jobs_rows.status_code == 200 - assert {j["job_type"] for j in jobs_rows.json()} == {"transcribe", "summarize"} - - -async def test_enqueue_is_idempotent(client, monkeypatch): - use_fakes(monkeypatch, FakeTranscriber(), FakeLlm()) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-idem-1") - await processing.run_transcribe({}, rec["id"]) - await processing.run_summarize({}, rec["id"]) - async with db_session._session_factory() as s: - from shonar.db.models import Recording - - row = await s.get(Recording, uuid.UUID(rec["id"])) - queued = await processing.enqueue_for_recording(s, row) - assert queued == [] - n = await s.scalar( - select(func.count(ProcessingJob.id)).where( - ProcessingJob.recording_id == uuid.UUID(rec["id"])) - ) - assert n == 2 - await s.commit() - - -# --- failure modes ------------------------------------------------------------- - - -async def test_transient_failure_retries_then_fails(client, monkeypatch): - stt = FakeTranscriber(fail=ProviderTransientError("stt down")) - use_fakes(monkeypatch, stt, FakeLlm()) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-fail-1") - # First tries raise for arq retry; the attempt rolls back with the - # transaction, so the row still reads queued (arq tracks the tries). - with pytest.raises(ProviderTransientError): - await processing.run_transcribe({"job_try": 1}, rec["id"]) - jobs = await jobs_for(rec["id"]) - assert jobs[JobType.transcribe].status == JobStatus.queued - # Last try marks the job (and recording) failed with a safe message. - await processing.run_transcribe({"job_try": 3}, rec["id"]) - jobs = await jobs_for(rec["id"]) - assert jobs[JobType.transcribe].status == JobStatus.failed - assert jobs[JobType.transcribe].error == "stt down" - h = {"Authorization": f"Bearer {token}"} - r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) - assert r.json()["processing_status"] == "failed" - assert r.json()["processing_error"] == "stt down" - - -async def test_config_error_fails_fast(client, monkeypatch): - stt = FakeTranscriber(fail=ProviderConfigError("bad credentials")) - use_fakes(monkeypatch, stt, FakeLlm()) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-cfg-1") - await processing.run_transcribe({"job_try": 1}, rec["id"]) # no raise - jobs = await jobs_for(rec["id"]) - assert jobs[JobType.transcribe].status == JobStatus.failed - assert stt.calls == 1 - - -# --- versioning ----------------------------------------------------------------- - - -async def test_rerun_supersedes_auto_but_not_user_edits(client, monkeypatch): - use_fakes(monkeypatch, FakeTranscriber("v1 text"), FakeLlm()) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-ver-1") - await processing.run_transcribe({}, rec["id"]) - rid = uuid.UUID(rec["id"]) - - async def versions(): - async with db_session._session_factory() as s: - rows = (await s.scalars( - select(Transcript).where(Transcript.recording_id == rid) - .order_by(Transcript.version))).all() - return [(r.version, r.text, r.superseded_at is not None, r.edited_by_user) - for r in rows] - - assert await versions() == [(1, "v1 text", False, False)] - - # Queue another transcription run manually (re-entry path). - async with db_session._session_factory() as s: - from shonar.db.models import Recording - - row = await s.get(Recording, rid) - await processing.enqueue_for_recording(s, row) - await s.commit() - use_fakes(monkeypatch, FakeTranscriber("v2 text"), FakeLlm()) - await processing.run_transcribe({}, rec["id"]) - assert await versions() == [(1, "v1 text", True, False), (2, "v2 text", False, False)] - - # A user edit wins: the next auto run inserts nothing. - async with db_session._session_factory() as s: - v2 = await s.scalar( - select(Transcript).where(Transcript.recording_id == rid, - Transcript.version == 2)) - v2.edited_by_user = True - v2.text = "user corrected text" - from shonar.db.models import Recording - - row = await s.get(Recording, rid) - await processing.enqueue_for_recording(s, row) - await s.commit() - use_fakes(monkeypatch, FakeTranscriber("v3 text"), FakeLlm()) - await processing.run_transcribe({}, rec["id"]) - got = await versions() - assert len(got) == 2 and got[1][1] == "user corrected text" - - -# --- llm-only and skips ---------------------------------------------------------- - - -async def test_llm_only_without_transcript_queues_nothing(client, monkeypatch): - use_fakes(monkeypatch, None, FakeLlm()) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-llm-1") - assert rec["processing_status"] == "uploaded" - assert await jobs_for(rec["id"]) == {} - - -async def test_llm_only_with_manual_transcript_summarizes(client, monkeypatch): - use_fakes(monkeypatch, None, FakeLlm()) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-llm-2") - rid = uuid.UUID(rec["id"]) - async with db_session._session_factory() as s: - from shonar.db.models import Recording - - row = await s.get(Recording, rid) - s.add(Transcript(recording_id=rid, version=1, provider="manual", - text="handwritten notes", edited_by_user=True)) - await s.flush() - queued = await processing.enqueue_for_recording(s, row) - assert queued == [JobType.summarize] - await s.commit() - await processing.run_summarize({}, rec["id"]) - h = {"Authorization": f"Bearer {token}"} - s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h) - assert s.status_code == 200 - r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) - assert r.json()["processing_status"] == "completed" - - -# --- sweep ------------------------------------------------------------------------ - - -async def test_sweep_requeues_stale_jobs(client, monkeypatch): - use_fakes(monkeypatch, FakeTranscriber(), FakeLlm()) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-sweep-1") - rid = uuid.UUID(rec["id"]) - async with db_session._session_factory() as s: - from shonar.db.models import Recording - - row = await s.get(Recording, rid) - # Simulate a crashed worker + a lost transport, respectively. - await processing.reset_or_create(s, row, JobType.transcribe) - jobs = await jobs_for(rec["id"]) - jobs[JobType.transcribe].status = JobStatus.running - await s.commit() - # reset_or_create leaves running rows alone, so force the second shape: - async with db_session._session_factory() as s: - extra = ProcessingJob(recording_id=rid, job_type=JobType.summarize, - status=JobStatus.queued) - s.add(extra) - await s.commit() - count = await processing.sweep_stale() - assert count == 2 - jobs = await jobs_for(rec["id"]) - assert jobs[JobType.transcribe].status == JobStatus.queued - assert jobs[JobType.summarize].status == JobStatus.queued - - -async def test_sweep_leaves_live_running_jobs_alone(client, monkeypatch): - """Regression: sweep re-enqueued every in-flight transcription every - 5 minutes; with local whisper each copy ran concurrently and one worker - OOM-swap-swelled the box. A running job started recently is live work.""" - from datetime import UTC, datetime, timedelta - - use_fakes(monkeypatch, FakeTranscriber(), FakeLlm()) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-sweep-live") - rid = uuid.UUID(rec["id"]) - async with db_session._session_factory() as s: - from shonar.db.models import Recording - - row = await s.get(Recording, rid) - await processing.reset_or_create(s, row, JobType.transcribe) - live = (await s.scalars( - select(ProcessingJob).where( - ProcessingJob.recording_id == rid, - ProcessingJob.job_type == JobType.transcribe) - )).one() - live.status = JobStatus.running - live.started_at = datetime.now(UTC) # worker is chewing it right now - stale = ProcessingJob(recording_id=rid, job_type=JobType.summarize, - status=JobStatus.running, attempt=1) - stale.started_at = datetime.now(UTC) - timedelta( - hours=processing.STALE_RUNNING_AFTER.total_seconds() / 3600 + 1) - s.add(stale) - await s.commit() - count = await processing.sweep_stale() - assert count == 1 # only the orphaned old run - jobs = await jobs_for(rec["id"]) - assert jobs[JobType.transcribe].status == JobStatus.running - assert jobs[JobType.summarize].status == JobStatus.queued - - -# --- ownership --------------------------------------------------------------------- - - -async def test_ai_endpoints_enforce_ownership(client, monkeypatch): - use_fakes(monkeypatch, FakeTranscriber(), FakeLlm()) - token = await user_tokens(client) - rec = await upload_recording(client, token, client_id="m7-own-1") - await processing.run_transcribe({}, rec["id"]) - other = await user_tokens(client, email="m7-other@example.com", - password="m7-test-passw0rd-456") - h = {"Authorization": f"Bearer {other}"} - for path in ("transcript", "summary", "jobs"): - r = await client.get(f"/api/v1/recordings/{rec['id']}/{path}", headers=h) - assert r.status_code == 404, path diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py deleted file mode 100644 index 1f09bdf..0000000 --- a/backend/tests/test_auth.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Auth flow tests: register, login, refresh rotation + reuse detection, -logout, protected access, account guards.""" - -AUTH = {"email": "test@example.com", "password": "correct-horse-battery"} - - -async def register(client, email=AUTH["email"], password=AUTH["password"]): - return await client.post( - "/api/v1/auth/register", - json={"email": email, "password": password, "display_name": "Tester"}, - ) - - -async def test_register_returns_token_pair(client): - r = await register(client) - assert r.status_code == 201, r.text - body = r.json() - assert body["token_type"] == "bearer" - assert body["expires_in"] == 15 * 60 - assert body["access_token"] and body["refresh_token"] - - -async def test_register_rejects_duplicate_email(client): - assert (await register(client)).status_code == 201 - r = await register(client) - assert r.status_code == 409 - - -async def test_register_rejects_weak_password(client): - r = await client.post( - "/api/v1/auth/register", json={"email": "x@example.com", "password": "short"} - ) - assert r.status_code == 422 - - -async def test_login_success_and_failure(client): - await register(client) - r = await client.post( - "/api/v1/auth/login", json={"email": AUTH["email"], "password": AUTH["password"]} - ) - assert r.status_code == 200 - assert r.json()["device_id"] - - bad = await client.post("/api/v1/auth/login", json={"email": AUTH["email"], "password": "***"}) - assert bad.status_code == 401 - # Same generic message either way (no user enumeration via password check) - bad2 = await client.post( - "/api/v1/auth/login", json={"email": "nobody@example.com", "password": "***"} - ) - assert bad2.status_code == 401 - assert bad.json()["detail"] == bad2.json()["detail"] - - -async def test_me_requires_valid_token(client): - r = await client.get("/api/v1/auth/me") - assert r.status_code == 401 - tok = (await register(client)).json()["access_token"] - r = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {tok}"}) - assert r.status_code == 200 - assert r.json()["email"] == AUTH["email"] - - -async def test_refresh_rotates_and_detects_reuse(client): - tok = (await register(client)).json() - old_refresh = tok["refresh_token"] - - r = await client.post("/api/v1/auth/refresh", json={"refresh_token": old_refresh}) - assert r.status_code == 200 - new_refresh = r.json()["refresh_token"] - assert new_refresh != old_refresh - - # Old token is dead; reuse kills the whole family. - r2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": old_refresh}) - assert r2.status_code == 401 - - # The replacement is also revoked (family revocation). - r3 = await client.post("/api/v1/auth/refresh", json={"refresh_token": new_refresh}) - assert r3.status_code == 401 - - -async def test_logout_revokes_refresh(client): - tok = (await register(client)).json() - r = await client.post("/api/v1/auth/logout", json={"refresh_token": tok["refresh_token"]}) - assert r.status_code == 204 - r2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": tok["refresh_token"]}) - assert r2.status_code == 401 - - -async def test_access_token_with_refresh_type_rejected(client): - tok = (await register(client)).json() - r = await client.get( - "/api/v1/auth/me", headers={"Authorization": f"Bearer {tok['refresh_token']}"} - ) - assert r.status_code == 401 - - -async def test_delete_account_requires_password(client): - tok = (await register(client)).json() - h = {"Authorization": f"Bearer {tok['access_token']}"} - r = await client.post("/api/v1/auth/delete-account", json={"password": "***"}, headers=h) - assert r.status_code == 403 - r = await client.post( - "/api/v1/auth/delete-account", json={"password": AUTH["password"]}, headers=h - ) - assert r.status_code == 202 - # Deleted account can no longer log in. - r = await client.post( - "/api/v1/auth/login", json={"email": AUTH["email"], "password": AUTH["password"]} - ) - assert r.status_code == 401 diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py deleted file mode 100644 index cc65da8..0000000 --- a/backend/tests/test_health.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Health-check and system-status tests.""" - - -async def test_healthz(client): - r = await client.get("/api/v1/healthz") - assert r.status_code == 200 - body = r.json() - assert body["status"] == "ok" - assert body["uptime_seconds"] >= 0 - - -async def test_readyz_with_db(client): - r = await client.get("/api/v1/readyz") - assert r.status_code == 200 - assert r.json() == {"status": "ok", "database": True} - - -async def test_system_status_reports_ai_state_honestly(client): - r = await client.get("/api/v1/system/status") - assert r.status_code == 200 - body = r.json() - # Default test config: AI disabled, no external calls. - assert body["ai"]["transcription_enabled"] is False - assert body["ai"]["llm_enabled"] is False - assert body["ai"]["external_ai_in_use"] is False - # No secrets ever present in the public status payload. - body_text = r.text.lower() - for leak in ("api_key", "password", "secret_key"): - assert leak not in body_text diff --git a/backend/tests/test_inline_queue.py b/backend/tests/test_inline_queue.py deleted file mode 100644 index d71c277..0000000 --- a/backend/tests/test_inline_queue.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Inline queue tests (desktop bundled-lite engine): with -``queue_backend=inline`` an upload runs transcribe→summarize automatically -in-process — no arq, no Redis. Providers are fakes (see test_ai_pipeline). -""" - -from __future__ import annotations - -import asyncio -import uuid - -from sqlalchemy import select - -from shonar.core.config import get_settings -from shonar.db import session as db_session -from shonar.db.models import JobStatus, JobType, ProcessingJob -from shonar.services import inline_queue, processing - -from .test_ai_pipeline import FakeLlm, FakeTranscriber, upload_recording, use_fakes, user_tokens - - -def use_inline(monkeypatch): - """Force queue_backend=inline for everything resolved via processing.""" - inline_settings = get_settings().model_copy(update={"queue_backend": "inline"}) - monkeypatch.setattr(processing, "get_settings", lambda: inline_settings) - - -async def _wait_terminal(recording_id: str, want: int = 2, timeout: float = 20.0): - """Poll the DB until `want` jobs reach a terminal state.""" - deadline = asyncio.get_running_loop().time() + timeout - rows: list[ProcessingJob] = [] - while asyncio.get_running_loop().time() < deadline: - async with db_session._session_factory() as s: - rows = list( - ( - await s.scalars( - select(ProcessingJob).where( - ProcessingJob.recording_id == uuid.UUID(recording_id) - ) - ) - ).all() - ) - if sum( - 1 - for j in rows - if j.status in (JobStatus.succeeded, JobStatus.failed, JobStatus.skipped) - ) >= want: - return {j.job_type: j.status for j in rows} - await asyncio.sleep(0.1) - raise AssertionError( - f"jobs did not reach terminal state in {timeout}s: " - f"{[(j.job_type, j.status) for j in rows]}" - ) - - -async def test_inline_backend_processes_upload_without_arq(client, monkeypatch): - use_fakes(monkeypatch, FakeTranscriber(), FakeLlm()) - use_inline(monkeypatch) - await inline_queue.start() - try: - token = await user_tokens(client, email="inline@shonar.dev") - rec = await upload_recording(client, token, client_id="inline-1") - - statuses = await _wait_terminal(rec["id"], want=2) - assert statuses[JobType.transcribe] == JobStatus.succeeded - assert statuses[JobType.summarize] == JobStatus.succeeded - - h = {"Authorization": f"Bearer {token}"} - r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) - assert r.json()["processing_status"] == "completed" - t = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h) - assert t.json()["text"] == "hello world from the meeting" - s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h) - assert s.json()["content"]["short"] == "Standup happened." - finally: - await inline_queue.stop() - - -async def test_inline_retries_transient_failure(client, monkeypatch): - stt = FakeTranscriber() - state = {"tries": 0} - original = stt.transcribe - - async def flaky(audio, mime, *, language_hint=None): - state["tries"] += 1 - if state["tries"] == 1: - raise processing.ProviderTransientError("temporary hiccup") - return await original(audio, mime, language_hint=language_hint) - - stt.transcribe = flaky - use_fakes(monkeypatch, stt, FakeLlm()) - use_inline(monkeypatch) - monkeypatch.setattr(inline_queue, "RETRY_DELAY_SECONDS", 0.05) - await inline_queue.start() - try: - token = await user_tokens(client, email="inline2@shonar.dev") - rec = await upload_recording(client, token, client_id="inline-2") - statuses = await _wait_terminal(rec["id"], want=2) - assert statuses[JobType.transcribe] == JobStatus.succeeded - assert state["tries"] >= 2 # the retry actually happened - finally: - await inline_queue.stop() - - -async def test_reprocess_reruns_in_place_with_model(client, monkeypatch): - """POST /reprocess?job=transcribe&model= re-runs the pipeline on the - SAME recording (no re-upload) and persists the model override.""" - stt = FakeTranscriber() - use_fakes(monkeypatch, stt, FakeLlm()) - use_inline(monkeypatch) - await inline_queue.start() - try: - token = await user_tokens(client, email="reproc@shonar.dev") - h = {"Authorization": f"Bearer {token}"} - rec = await upload_recording(client, token, client_id="reproc-1") - await _wait_terminal(rec["id"], want=2) - calls_before = stt.calls - - r = await client.post( - f"/api/v1/recordings/{rec['id']}/reprocess?job=transcribe&model=small", - headers=h, - ) - assert r.status_code == 200, r.text - statuses = await _wait_terminal(rec["id"], want=2) - assert statuses[JobType.transcribe] == JobStatus.succeeded - assert stt.calls == calls_before + 1 # re-ran, no new recording - - # Same recording id, model override persisted on the row. - r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) - assert r.json()["id"] == rec["id"] - assert r.json()["transcription_model"] == "small" - finally: - await inline_queue.stop() - - -async def test_reprocess_validation(client, monkeypatch): - token = await user_tokens(client, email="reproc2@shonar.dev") - h = {"Authorization": f"Bearer {token}"} - rec = await upload_recording(client, token, client_id="reproc-2") - # Nothing transcribed yet -> summarize refuses. - r = await client.post( - f"/api/v1/recordings/{rec['id']}/reprocess?job=summarize", headers=h) - assert r.status_code == 409 - # Bad model name -> 422 (never silently substituted). - r = await client.post( - f"/api/v1/recordings/{rec['id']}/reprocess?job=transcribe&model=not-a-model", - headers=h) - assert r.status_code == 422 - # Not the owner -> 404. - other = await user_tokens(client, email="reproc3@shonar.dev") - r = await client.post( - f"/api/v1/recordings/{rec['id']}/reprocess?job=transcribe", - headers={"Authorization": f"Bearer {other}"}) - assert r.status_code == 404 diff --git a/backend/tests/test_m9.py b/backend/tests/test_m9.py deleted file mode 100644 index 6cc5339..0000000 --- a/backend/tests/test_m9.py +++ /dev/null @@ -1,304 +0,0 @@ -"""M9 tests: search, exports, retention sweep. - -Search runs against the real backend dialect (Postgres in CI/dev, SQLite -via SHONAR_TEST_DATABASE_URL) — both paths share the endpoint contract. -""" - -from __future__ import annotations - -import io -import zipfile - -from tests.test_recordings import auth, user_tokens, wav_bytes - - -async def make_recording(client, token, title, notes=None, tags=None, recorded_at=None): - h = await auth(token) - audio = wav_bytes() - body = {"declared_mime_type": "audio/wav", "declared_size_bytes": len(audio), "title": title} - r = await client.post("/api/v1/uploads", json=body, headers=h) - sid = r.json()["id"] - await client.put( - f"/api/v1/uploads/{sid}/chunks/0", content=audio, - headers={**h, "content-type": "application/octet-stream"}, - ) - fin = {"duration_seconds": 5.0} - if recorded_at: - fin["recorded_at"] = recorded_at - if notes: - fin["notes"] = notes - r = await client.post(f"/api/v1/uploads/{sid}/finalize", json=fin, headers=h) - assert r.status_code == 201, r.text - rec = r.json() - if tags: - r = await client.patch( - f"/api/v1/recordings/{rec['id']}", json={"tags": tags}, headers=h - ) - assert r.status_code == 200 - return rec["id"] - - -async def add_transcript(rec_id: str, text: str, segments=None): - """Insert a transcript row directly (no AI provider under test).""" - from shonar.db.models import Transcript - from shonar.db.session import session_factory - - async with session_factory()() as s: - s.add(Transcript(recording_id=rec_id, text=text, segments=segments, provider="test")) - await s.commit() - - -async def add_summary(rec_id: str, content: dict): - from shonar.db.models import Summary - from shonar.db.session import session_factory - - async with session_factory()() as s: - s.add(Summary(recording_id=rec_id, content=content, provider="test")) - await s.commit() - - -# --- search ------------------------------------------------------------------- - - -async def test_search_title_and_transcript(client): - token = await user_tokens(client, email="m9s1@example.com") - rid_t = await make_recording(client, token, "Quarterly budget review") - rid_x = await make_recording(client, token, "Grocery list") - await add_transcript(rid_x, "remember to buy kale chips and quinoa tonight") - - r = await client.get("/api/v1/search", params={"q": "budget"}, headers=await auth(token)) - assert r.status_code == 200, r.text - body = r.json() - assert body["total"] == 1 - assert body["items"][0]["id"] == rid_t - assert body["items"][0]["field"] == "title" - - r = await client.get("/api/v1/search", params={"q": "quinoa"}, headers=await auth(token)) - body = r.json() - assert body["total"] == 1 - assert body["items"][0]["id"] == rid_x - assert body["items"][0]["field"] == "transcript" - assert "quinoa" in body["items"][0]["snippet"].lower() - - -async def test_search_scope_and_tag(client): - token = await user_tokens(client, email="m9s2@example.com") - rid = await make_recording(client, token, "Standup", tags=["daily"]) - await add_transcript(rid, "we discussed the daily standup format") - - # tag scope finds by tag name - r = await client.get( - "/api/v1/search", params={"q": "daily", "scope": "tag"}, headers=await auth(token) - ) - assert r.json()["total"] == 1 - assert r.json()["items"][0]["field"] == "tag" - - # a transcript-only word does NOT match under scope=title - r = await client.get( - "/api/v1/search", params={"q": "discussed", "scope": "title"}, headers=await auth(token) - ) - assert r.json()["total"] == 0 - r = await client.get( - "/api/v1/search", params={"q": "discussed", "scope": "transcript"}, - headers=await auth(token), - ) - assert r.json()["total"] == 1 - - -async def test_search_isolation_and_deleted(client): - token_a = await user_tokens(client, email="m9s3a@example.com") - token_b = await user_tokens(client, email="m9s3b@example.com") - rid = await make_recording(client, token_a, "secret sauce recipe") - - h_b = await auth(token_b) - r = await client.get("/api/v1/search", params={"q": "secret"}, headers=h_b) - assert r.json()["total"] == 0 # other users' data invisible - - # soft-deleted rows drop out of search - await client.delete(f"/api/v1/recordings/{rid}", headers=await auth(token_a)) - r = await client.get( - "/api/v1/search", params={"q": "secret"}, headers=await auth(token_a) - ) - assert r.json()["total"] == 0 - - -async def test_search_summary_and_notes(client): - token = await user_tokens(client, email="m9s4@example.com") - rid = await make_recording(client, token, "Meeting", notes="bring the projector cable") - await add_summary(rid, {"short": "sprint retro", "action_items": ["fix flaky test"]}) - - r = await client.get("/api/v1/search", params={"q": "projector"}, headers=await auth(token)) - assert r.json()["total"] == 1 - r = await client.get("/api/v1/search", params={"q": "flaky"}, headers=await auth(token)) - assert r.json()["total"] == 1 - - -# --- list filters --------------------------------------------------------------- - - -async def test_list_filters_tag_status_date(client): - token = await user_tokens(client, email="m9f1@example.com") - await make_recording(client, token, "Old one", tags=["keep"], - recorded_at="2020-01-01T10:00:00Z") - rid_new = await make_recording(client, token, "New one", tags=["keep"], - recorded_at="2026-01-01T10:00:00Z") - h = await auth(token) - - r = await client.get("/api/v1/recordings", params={"tag": "keep"}, headers=h) - assert r.json()["total"] == 2 - r = await client.get( - "/api/v1/recordings", params={"tag": "keep", "from_date": "2025-06-01T00:00:00Z"}, - headers=h, - ) - body = r.json() - assert body["total"] == 1 and body["items"][0]["id"] == rid_new - - r = await client.get("/api/v1/recordings", params={"status": "ai_disabled"}, headers=h) - assert r.json()["total"] == 2 - r = await client.get("/api/v1/recordings", params={"status": "bogus"}, headers=h) - assert r.status_code == 422 - r = await client.get("/api/v1/recordings", params={"tag": "nope"}, headers=h) - assert r.json()["total"] == 0 - - -# --- exports -------------------------------------------------------------------- - - -async def test_export_formats(client): - token = await user_tokens(client, email="m9e1@example.com") - rid = await make_recording(client, token, "Retro & Planning", notes="retro notes here", - tags=["team"]) - await add_transcript( - rid, "first segment second segment", - segments=[{"start": 0.0, "end": 2.5, "text": "first segment", "speaker": None}, - {"start": 2.5, "end": 5.0, "text": "second segment", "speaker": "S1"}], - ) - await add_summary(rid, {"short": "one line", "action_items": ["do the thing"]}) - h = await auth(token) - - r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "txt"}, headers=h) - assert r.status_code == 200 - assert r.text == "first segment second segment" - assert "attachment" in r.headers["content-disposition"] - assert ".txt" in r.headers["content-disposition"] - - r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "md"}, headers=h) - assert r.status_code == 200 - assert "# Retro & Planning" in r.text - assert "do the thing" in r.text - assert "`[00:02.5]` **S1**: second segment" in r.text - assert "`team`" in r.text - - r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "zip"}, headers=h) - assert r.status_code == 200 - assert r.headers["content-type"] == "application/zip" - with zipfile.ZipFile(io.BytesIO(r.content)) as z: - names = z.namelist() - assert "transcript.txt" in names and "notes.md" in names - assert any(n.endswith(".wav") for n in names) - - r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "audio"}, headers=h) - assert r.status_code == 200 - assert r.content.startswith(b"RIFF") - - -async def test_export_missing_and_foreign(client): - token = await user_tokens(client, email="m9e2@example.com") - rid = await make_recording(client, token, "Bare") # no transcript/summary/notes - token2 = await user_tokens(client, email="m9e2b@example.com") - - r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "txt"}, - headers=await auth(token)) - assert r.status_code == 404 # no transcript yet - r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "md"}, - headers=await auth(token)) - assert r.status_code == 404 - # zip still works with just the audio - r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "zip"}, - headers=await auth(token)) - assert r.status_code == 200 - - r = await client.get(f"/api/v1/recordings/{rid}/export", params={"fmt": "audio"}, - headers=await auth(token2)) - assert r.status_code == 404 # not yours - - -# --- retention sweep -------------------------------------------------------------- - - -async def test_retention_sweep_purges_expired(client): - from datetime import timedelta - - from shonar.db.models import Asset, Recording, utcnow - from shonar.db.session import session_factory - from shonar.services import retention - - token = await user_tokens(client, email="m9r1@example.com") - rid = await make_recording(client, token, "Doomed") - h = await auth(token) - - # Soft-delete, then push deleted_at past the grace window directly. - r = await client.delete(f"/api/v1/recordings/{rid}", headers=h) - assert r.status_code == 204 - async with session_factory()() as s: - rec = await s.get(Recording, rid) - rec.deleted_at = utcnow() - timedelta(days=31) - # remember the storage key before the row vanishes - from shonar.db.models import Asset - - asset = ( - await s.execute( - Asset.__table__.select().where(Asset.recording_id == rec.id) # noqa: SLF001 - ) - ).first() - storage_key = asset.storage_key if asset else None - await s.commit() - assert storage_key is not None - - purged = await retention.sweep_deleted() - assert purged["recordings"] == 1 - assert purged["files"] >= 1 - - async with session_factory()() as s: - assert await s.get(Recording, rid) is None - from shonar.storage import get_storage - - assert not await get_storage().exists(storage_key) # file gone too - - # Within the window, nothing is purged (cancellable). - rid2 = await make_recording(client, token, "Fresh delete") - await client.delete(f"/api/v1/recordings/{rid2}", headers=h) - purged = await retention.sweep_deleted() - assert purged["recordings"] == 0 - async with session_factory()() as s: - assert await s.get(Recording, rid2) is not None - - -async def test_retention_sweep_account(client): - from datetime import timedelta - - from shonar.db.models import Recording, utcnow - from shonar.db.session import session_factory - from shonar.services import retention - - token = await user_tokens(client, email="m9r2@example.com") - rid = await make_recording(client, token, "Gone with user") - - async with session_factory()() as s: - user = await s.scalar(select_user("m9r2@example.com")) - user.deleted_at = utcnow() - timedelta(days=31) - await s.commit() - - purged = await retention.sweep_deleted() - assert purged["users"] == 1 - async with session_factory()() as s: - assert await s.get(Recording, rid) is None # cascade - assert await s.scalar(select_user("m9r2@example.com")) is None - - -def select_user(email: str): - from sqlalchemy import select - - from shonar.db.models import User - - return select(User).where(User.email == email) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py deleted file mode 100644 index 44a96a4..0000000 --- a/backend/tests/test_models.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Stage 1: per-recording transcription models + models API. - -Global default (base) with optional per-recording overrides; the worker -uses the exact saved model; history is never rewritten; unknown models -are rejected; missing downloads fail fast with instructions. -""" - -from __future__ import annotations - -import struct - -import pytest -from sqlalchemy import select - -from shonar.db import session as db_session -from shonar.db.models import Recording -from shonar.services import processing - - -def wav_bytes(payload_len: int = 64) -> bytes: - data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1) - data = data[:payload_len] - return ( - b"RIFF" + struct.pack(" flags must be false - assert caps["server_transcription"] is False - assert caps["server_summary"] is False - assert body["storage_backend"] in ("local", "s3") - - -async def test_provider_info_leaks_no_paths_or_secrets(client): - r = await client.get("/api/v1/provider-info") - body = r.json() - # every string value must be a bare identifier, never a filesystem path - def walk(v): - if isinstance(v, str): - assert not v.startswith("/"), f"path-like value in handshake: {v!r}" - low = v.lower() - for banned in ("secret", "token", "password", "key"): - assert banned not in low - elif isinstance(v, dict): - for x in v.values(): - walk(x) - elif isinstance(v, list): - for x in v: - walk(x) - walk(body) diff --git a/backend/tests/test_recordings.py b/backend/tests/test_recordings.py deleted file mode 100644 index 448095e..0000000 --- a/backend/tests/test_recordings.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Upload sessions + recordings CRUD tests (M2). - -Uses real WAV magic bytes; validation is byte-level, so fakes would be -testing the wrong thing. -""" - -from __future__ import annotations - -import struct -import uuid - - -def wav_bytes(payload_len: int = 64) -> bytes: - data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1) - data = data[:payload_len] - header = ( - b"RIFF" + struct.pack(" bytes: - return b"\x00\x00\x00 ftypM4A " + b"\x00" * 64 - - -AUTH = {"email": "m2@example.com", - "password": "m2-test-" + "passw0rd-123"} - - -async def user_tokens(client, email=AUTH["email"], password=AUTH["password"]): - r = await client.post("/api/v1/auth/register", json={"email": email, "password": password}) - assert r.status_code == 201, r.text - return r.json()["access_token"] - - -async def auth(token: str) -> dict: - return {"Authorization": f"Bearer {token}"} - - -async def upload_full(client, token: str, data: bytes, mime="audio/wav", - client_id=None, title=None): - h = await auth(token) - r = await client.post( - "/api/v1/uploads", - json={"declared_mime_type": mime, "declared_size_bytes": len(data), - "client_recording_id": client_id, "title": title}, - headers=h, - ) - assert r.status_code == 201, r.text - sid = r.json()["id"] - r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, - headers={**h, "content-type": "application/octet-stream"}) - assert r.status_code == 201, r.text - r = await client.post( - f"/api/v1/uploads/{sid}/finalize", - json={"duration_seconds": 12.5}, - headers=h, - ) - return sid, r - - -# --- upload session ---------------------------------------------------------- - - -async def test_upload_happy_path_creates_recording(client): - token = await user_tokens(client) - data = wav_bytes() - sid, r = await upload_full(client, token, data, title="Standup") - assert r.status_code == 201, r.text - rec = r.json() - assert rec["title"] == "Standup" - assert rec["has_audio"] is True - # M7: with no AI configured the pipeline marks audio-only explicitly. - assert rec["processing_status"] == "ai_disabled" - assert rec["duration_seconds"] == 12.5 - # No storage keys or internals leak. - assert "storage" not in r.text and "key" not in r.text.lower().replace("chunk", "") - - -async def test_upload_rejects_bad_mime_declared(client): - token = await user_tokens(client) - h = await auth(token) - r = await client.post("/api/v1/uploads", - json={"declared_mime_type": "application/x-msdownload", - "declared_size_bytes": 100}, headers=h) - assert r.status_code == 415 - - -async def test_upload_rejects_oversize(client): - token = await user_tokens(client) - h = await auth(token) - r = await client.post("/api/v1/uploads", - json={"declared_mime_type": "audio/wav", - "declared_size_bytes": 5 * 1024**3}, headers=h) - assert r.status_code == 413 - - -async def test_finalize_rejects_bytes_not_matching_mime(client): - token = await user_tokens(client) - data = mp4_bytes() - _sid, r = await upload_full(client, token, data, mime="audio/wav") - assert r.status_code == 415 - - -async def test_finalize_rejects_size_mismatch(client): - token = await user_tokens(client) - h = await auth(token) - data = wav_bytes() - r = await client.post("/api/v1/uploads", - json={"declared_mime_type": "audio/wav", - "declared_size_bytes": len(data) + 10}, headers=h) - sid = r.json()["id"] - await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, - headers={**h, "content-type": "application/octet-stream"}) - r = await client.post(f"/api/v1/uploads/{sid}/finalize", json={}, headers=h) - assert r.status_code == 422 - detail = r.json()["detail"].lower() - assert "missing chunks" in detail or "size mismatch" in detail - - -async def test_chunk_resume_status_and_idempotency(client): - token = await user_tokens(client) - h = await auth(token) - data = wav_bytes() - r = await client.post("/api/v1/uploads", - json={"declared_mime_type": "audio/wav", - "declared_size_bytes": len(data)}, headers=h) - sid = r.json()["id"] - r = await client.get(f"/api/v1/uploads/{sid}", headers=h) - assert r.status_code == 200 - assert r.json()["received_chunk_indexes"] == [] - hdr = {**h, "content-type": "application/octet-stream"} - await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, headers=hdr) - # Duplicate PUT of chunk 0 (retry) must not duplicate or corrupt. - await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, headers=hdr) - r = await client.get(f"/api/v1/uploads/{sid}", headers=h) - assert r.json()["received_chunk_indexes"] == [0] - r = await client.post(f"/api/v1/uploads/{sid}/finalize", json={}, headers=h) - assert r.status_code == 201 - - -async def test_chunk_checksum_enforced(client): - token = await user_tokens(client) - h = await auth(token) - data = wav_bytes() - r = await client.post("/api/v1/uploads", - json={"declared_mime_type": "audio/wav", - "declared_size_bytes": len(data)}, headers=h) - sid = r.json()["id"] - r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data, - headers={**h, "content-type": "application/octet-stream", - "x-chunk-sha256": "0" * 64}) - assert r.status_code == 422 - - -async def test_finalize_idempotent_per_client_recording_id(client): - token = await user_tokens(client) - cid = str(uuid.uuid4()) - _sid1, r1 = await upload_full(client, token, wav_bytes(64), client_id=cid) - _sid2, r2 = await upload_full(client, token, wav_bytes(64), client_id=cid, title="Renamed") - assert r1.status_code == 201 and r2.status_code == 201 - # Same recording id, original preserved, metadata updated. - assert r1.json()["id"] == r2.json()["id"] - assert r2.json()["title"] == "Renamed" - - -# --- ownership --------------------------------------------------------------- - - -async def test_cross_user_isolation(client): - ta = await user_tokens(client, "a@example.com") - tb = await user_tokens(client, "b@example.com") - _sid, r = await upload_full(client, ta, wav_bytes()) - rec_id = r.json()["id"] - r = await client.get(f"/api/v1/recordings/{rec_id}", headers=await auth(tb)) - assert r.status_code == 404 - r = await client.get(f"/api/v1/recordings/{rec_id}/audio", headers=await auth(tb)) - assert r.status_code == 404 - r = await client.get("/api/v1/recordings", headers=await auth(tb)) - assert r.json()["total"] == 0 - - -async def test_uploads_require_auth(client): - r = await client.get("/api/v1/recordings") - assert r.status_code == 401 - - -# --- recordings CRUD ----------------------------------------------------------- - - -async def test_update_metadata_and_tags(client): - token = await user_tokens(client) - h = await auth(token) - _sid, r = await upload_full(client, token, wav_bytes()) - rec_id = r.json()["id"] - r = await client.patch(f"/api/v1/recordings/{rec_id}", - json={"title": "Sync meeting", "notes": "n1", - "tags": ["Work", " meeting ", "work"]}, headers=h) - assert r.status_code == 200 - body = r.json() - assert body["title"] == "Sync meeting" - assert body["tags"] == ["meeting", "work"] # normalized, deduped, sorted - # listing shows same - r = await client.get("/api/v1/recordings", headers=h) - assert r.json()["total"] == 1 - assert r.json()["items"][0]["tags"] == ["meeting", "work"] - - -async def test_location_dropped_without_consent(client): - token = await user_tokens(client) - h = await auth(token) - _sid, r = await upload_full(client, token, wav_bytes()) - rec_id = r.json()["id"] - r = await client.patch(f"/api/v1/recordings/{rec_id}", - json={"latitude": 41.8, "longitude": -87.6}, headers=h) - assert r.json()["latitude"] is None - # enable consent - await client.patch("/api/v1/users/me", json={"location_storage_enabled": True}, headers=h) - _sid2, r2 = await upload_full(client, token, wav_bytes(128), client_id=str(uuid.uuid4())) - rid2 = r2.json()["id"] - r = await client.patch(f"/api/v1/recordings/{rid2}", - json={"latitude": 41.8, "longitude": -87.6}, headers=h) - assert r.json()["latitude"] == 41.8 - - -async def test_soft_then_purge_delete(client, storage_root): - token = await user_tokens(client) - h = await auth(token) - _sid, r = await upload_full(client, token, wav_bytes()) - rec_id = r.json()["id"] - - files_before = list(storage_root.rglob("*")) - assert any(p.is_file() for p in files_before) - - r = await client.delete(f"/api/v1/recordings/{rec_id}", headers=h) - assert r.status_code == 204 - r = await client.get(f"/api/v1/recordings/{rec_id}", headers=h) - assert r.status_code == 404 - - # Purge deletes rows AND stored files. - token2 = await user_tokens(client, "p2@example.com") - h2 = await auth(token2) - _sid, r = await upload_full(client, token2, wav_bytes(96)) - rec2 = r.json()["id"] - r = await client.delete(f"/api/v1/recordings/{rec2}?purge=true", headers=h2) - assert r.status_code == 204 - remaining = [p for p in storage_root.rglob("*") if p.is_file() and f"{rec2}" in str(p)] - assert remaining == [] - - -async def test_download_audio_roundtrip(client): - token = await user_tokens(client) - h = await auth(token) - data = wav_bytes(128) - _sid, r = await upload_full(client, token, data) - rec_id = r.json()["id"] - r = await client.get(f"/api/v1/recordings/{rec_id}/audio", headers=h) - assert r.status_code == 200 - assert r.content == data - assert r.headers["content-type"] == "audio/wav" - assert "attachment" in r.headers["content-disposition"] - assert "no-store" in r.headers["cache-control"] diff --git a/backend/tests/test_transcript_edits.py b/backend/tests/test_transcript_edits.py deleted file mode 100644 index afa043b..0000000 --- a/backend/tests/test_transcript_edits.py +++ /dev/null @@ -1,189 +0,0 @@ -"""M8: user edits to transcript/summary via PUT endpoints. - -Edits create a new version with edited_by_user=True; the AI pipeline -must not overwrite them afterwards. -""" - -from __future__ import annotations - -import struct -import uuid - -from sqlalchemy import select - -from shonar.db import session as db_session -from shonar.db.models import Transcript -from shonar.services import processing - -AUTH = {"email": "m8@example.com", "password": "m8-test-passw0rd-123"} - - -async def user_tokens(client): - r = await client.post( - "/api/v1/auth/register", - json={"email": AUTH["email"], "password": AUTH["password"]}, - ) - assert r.status_code == 201, r.text - return r.json()["access_token"] - - -def wav_bytes(payload_len: int = 64) -> bytes: - data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1) - data = data[:payload_len] - header = ( - b"RIFF" + struct.pack(" { - useJUnit() -} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt deleted file mode 100644 index 17db9eb..0000000 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ /dev/null @@ -1,1049 +0,0 @@ -package com.shonar.desktop - -import com.shonar.provider.AuthState -import com.shonar.provider.CustomShonarProvider -import com.shonar.provider.ProviderCredential -import com.shonar.provider.ProviderError -import com.shonar.provider.RecordingDraft -import com.shonar.provider.ServerUrl -import com.shonar.provider.ShonarAuthStore -import com.shonar.provider.ShonarHandshake -import com.shonar.recording.JobInfo -import com.shonar.recording.ModelsData -import com.shonar.recording.SummaryData -import com.shonar.recording.TranscriptData -import com.shonar.recording.parseJobs -import com.shonar.recording.parseModels -import com.shonar.recording.parseSummary -import com.shonar.recording.parseTranscript -import com.shonar.settings.FileSettingsStore -import java.io.File -import java.util.UUID -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import okhttp3.OkHttpClient - -/** - * Desktop app state (Stage 3). Plain class + StateFlows — no ViewModel - * framework. Talks to the SHONAR backend through the *shared* - * [CustomShonarProvider] (same files the Android app compiles). - */ -class DesktopState(private val appDir: File = defaultAppDir()) { - - enum class Screen { ENGINE, LIBRARY, DETAIL, SETTINGS } - - /** Local transcription engine lifecycle (the bundled backend+worker). */ - enum class EngineState { UNKNOWN, STARTING, RUNNING, STOPPED, FAILED } - - data class LibraryEntry( - val file: File, - val hasReport: Boolean, - val status: FileStatus = FileStatus.NEW, - val statusNote: String? = null, - /** 0..1 while transcribing (backend-reported); null otherwise. */ - val progress: Float? = null, - ) - - data class DetailUi( - val file: File, - /** "Use default" is null; otherwise the override model name. */ - var overrideModel: String? = null, - var busy: String? = null, - var uploadProgress: Float? = null, - var remoteId: String? = null, - var jobs: List = emptyList(), - var transcript: TranscriptData? = null, - var summary: SummaryData? = null, - /** Contents of the saved .transcript.md report, shown when opening - * an already-transcribed file (no live transcript in memory). */ - var reportText: String? = null, - var error: String? = null, - ) - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - private val prefs = FileSettingsStore(File(appDir, "settings.json")) - private val authStore = ShonarAuthStore(FileSettingsStore(File(appDir, "auth.json"))) - - private val http = OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(300, TimeUnit.SECONDS) - .writeTimeout(300, TimeUnit.SECONDS) - .build() - - val provider = CustomShonarProvider( - auth = authStore, - sidecarRoot = File(appDir, "sidecars"), - client = http, - handshake = ShonarHandshake(http), - deviceName = "SHONAR Desktop", - platform = "desktop", - ) - - private val _screen = MutableStateFlow(Screen.LIBRARY) - val screen: StateFlow = _screen.asStateFlow() - - private val _serverUrl = MutableStateFlow("http://localhost:8000") - val serverUrl: StateFlow = _serverUrl.asStateFlow() - - private val _engine = MutableStateFlow(EngineState.UNKNOWN) - val engine: StateFlow = _engine.asStateFlow() - - private val _engineError = MutableStateFlow(null) - val engineError: StateFlow = _engineError.asStateFlow() - - private val _connected = MutableStateFlow(false) - val connected: StateFlow = _connected.asStateFlow() - - private val _folder = MutableStateFlow(null) - val folder: StateFlow = _folder.asStateFlow() - - private val _entries = MutableStateFlow>(emptyList()) - val entries: StateFlow> = _entries.asStateFlow() - - private val _models = MutableStateFlow(null) - val models: StateFlow = _models.asStateFlow() - - private val _modelsError = MutableStateFlow(null) - val modelsError: StateFlow = _modelsError.asStateFlow() - - private val _detail = MutableStateFlow(null) - val detail: StateFlow = _detail.asStateFlow() - - private val _query = MutableStateFlow("") - val query: StateFlow = _query.asStateFlow() - - private val _searchResults = MutableStateFlow>(emptyList()) - val searchResults: StateFlow> = _searchResults.asStateFlow() - - private var pollJob: Job? = null - - /** Auto-queue new files as they appear (persisted preference). */ - private val _autoTranscribe = MutableStateFlow(true) - val autoTranscribe: StateFlow = _autoTranscribe.asStateFlow() - - /** Files the pump is running or has queued this session (by name). */ - private val inFlight = mutableSetOf() - - /** Files whose last auto attempt failed (no hot-loop retries). */ - private val failedFiles = mutableSetOf() - - /** Serial pump: one file at a time (engine runs one job anyway). */ - private val queue = java.util.concurrent.ConcurrentLinkedQueue() - private var pumpJob: Job? = null - private var watchJob: Job? = null - - init { - scope.launch { - _serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000" - _autoTranscribe.value = prefs.getString(KEY_AUTO) != "0" - prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } } - ?.let { setFolder(it, silent = true) } - ensureReady() - } - Runtime.getRuntime().addShutdownHook(Thread { stopEngine() }) - } - - /** - * Startup chain, no questions asked: saved session → local account on a - * running engine → start the engine ourselves → engine screen on failure. - * There is deliberately no login UI: the desktop owns a local account - * (email + random password in its own settings file) that exists only - * to satisfy the engine's auth. - */ - fun ensureReady() { - scope.launch { - if (runCatching { provider.reconnect() }.getOrNull() == AuthState.CONNECTED) { - onReady() - return@launch - } - if (engineUp()) { - if (provisionLocalAccount()) { - onReady() - return@launch - } - } - _engine.value = EngineState.STOPPED - _screen.value = Screen.ENGINE - // The app owns the engine: start it instead of waiting. - startEngine() - } - } - - private suspend fun onReady() { - _connected.value = true - refreshModels() - if (_screen.value == Screen.ENGINE) _screen.value = Screen.LIBRARY - if (_autoTranscribe.value) pumpNewFiles() - } - - private fun engineUp(): Boolean = runCatching { - java.net.URL("${_serverUrl.value}/api/v1/healthz").readText() - .contains("\"ok\"") - }.getOrDefault(false) - - /** Readiness (not just liveness): the engine's database is usable. */ - private fun engineReady(): Boolean = runCatching { - java.net.URL("${_serverUrl.value}/api/v1/readyz").readText() - .contains("\"database\":true") - }.getOrDefault(false) - - /** Persistent engine log so failures are actually debuggable. */ - fun engineLogFile(): File = File(appDir, "logs/engine.log") - - /** Register-or-login the private local account. True when connected. */ - private suspend fun provisionLocalAccount(): Boolean { - val parsed = ServerUrl.parse(_serverUrl.value).getOrNull() - if (parsed == null) { - _engineError.value = "Engine address is not a valid URL." - return false - } - if (provider.probe(parsed) !is com.shonar.provider.ProbeResult.Compatible) { - _engineError.value = "Engine answered but doesn't look like SHONAR." - return false - } - var password = prefs.getString(KEY_PASSWORD) - if (password.isNullOrBlank()) { - password = UUID.randomUUID().toString() + UUID.randomUUID().toString() - prefs.putString(KEY_PASSWORD, password) - } - val cred = ProviderCredential.ShonarLogin( - accountLabel = LOCAL_EMAIL, serverUrl = parsed, - email = LOCAL_EMAIL, password = password, - ) - if (runCatching { provider.connect(cred) }.isSuccess) return true - // Stored password is stale — register anew, then connect. - val registered = runCatching { registerLocalAccount(parsed, password) }.isSuccess - if (!registered) { - _engineError.value = "Engine is up but the local account was refused." - return false - } - return runCatching { - provider.connect(cred) - true - }.getOrElse { - _engineError.value = it.message ?: "Engine is up but sign-in failed." - false - } - } - - private fun registerLocalAccount(base: ServerUrl, password: String) { - val body = """{"email":${jsonStr(LOCAL_EMAIL)},"password":${jsonStr(password)}}""" - val url = java.net.URL(base.origin + "/api/v1/auth/register") - (url.openConnection() as java.net.HttpURLConnection).apply { - requestMethod = "POST" - setRequestProperty("Content-Type", "application/json") - doOutput = true - outputStream.use { it.write(body.toByteArray()) } - if (responseCode != 201) throw IllegalStateException("register: HTTP $responseCode") - }.disconnect() - } - - private fun jsonStr(raw: String): String = buildString { - append('"') - for (c in raw) when (c) { - '"' -> append("\\\"") - '\\' -> append("\\\\") - else -> append(c) - } - append('"') - } - - fun go(screen: Screen) { - _screen.value = screen - if (screen == Screen.LIBRARY) rescan() - } - - // ---- account (invisible local account; no login UI) --------------------- - - // ---- library ----------------------------------------------------------- - - // ---- local engine ------------------------------------------------------ - // The transcription engine is the SHONAR backend running on this - // machine in bundled-lite mode: SQLite + inline job queue, no - // Postgres/Redis/Docker. The app starts it on demand; the user never - // sees "servers". Summaries use Ollama when it's reachable locally. - - private var apiProc: Process? = null - - fun startEngine() { - scope.launch { - _engine.value = EngineState.STARTING - _engineError.value = null - val repo = repoRoot() - if (repo == null) { - _engine.value = EngineState.FAILED - _engineError.value = "Could not find the SHONAR checkout " + - "(set SHONAR_REPO to its path)." - return@launch - } - try { - val backend = File(repo, "backend") - val venv = File(backend, ".venv/bin") - require(File(venv, "uvicorn").canExecute()) { - "Backend not installed at ${backend.path}." - } - val secret = prefs.getString(KEY_SECRET) ?: run { - val fresh = UUID.randomUUID().toString() + UUID.randomUUID().toString() - prefs.putString(KEY_SECRET, fresh) - fresh - } - val env = mutableMapOf( - "SHONAR_SECRET_KEY" to secret, - // Bundled-lite: one process, SQLite, in-process queue, - // schema migrates itself on boot. - "SHONAR_DATABASE_URL" to - "sqlite+aiosqlite:///${File(appDir, "engine.db").absolutePath}", - "SHONAR_QUEUE_BACKEND" to "inline", - "SHONAR_AUTO_MIGRATE" to "1", - "SHONAR_STORAGE_PATH" to File(appDir, "storage").absolutePath, - "SHONAR_TRANSCRIPTION_PROVIDER" to "faster_whisper", - "SHONAR_TRANSCRIPTION_MODEL" to "base", - ) - // Summarizer preference: LAN inference server (H200) when - // its key is present in the environment, then local Ollama. - // LAN-only: the key never leaves the network boundary and - // is read from the environment, never stored by this app. - val lanKey = System.getenv(ENV_LAN_LLM_KEY)?.takeIf { it.isNotBlank() } - ?: readLanKeyFromHermesEnv() - if (lanKey.isNotBlank()) { - env["SHONAR_LLM_PROVIDER"] = "openai_compat" - env["SHONAR_LLM_BASE_URL"] = LAN_LLM_BASE_URL - env["SHONAR_LLM_MODEL"] = LAN_LLM_MODEL - env["SHONAR_LLM_API_KEY"] = lanKey - } else if (ollamaUp()) { - env["SHONAR_LLM_PROVIDER"] = "ollama" - env["SHONAR_LLM_BASE_URL"] = "http://127.0.0.1:11434" - env["SHONAR_LLM_MODEL"] = ollamaModel() - } else { - env["SHONAR_LLM_PROVIDER"] = "none" - } - stopEngine() - apiProc = spawn(backend, File(venv, "uvicorn").absolutePath, - listOf("shonar.main:app", "--port", "8000") + - // Dev mode: engine hot-reloads on backend source edits. - (if (System.getenv("SHONAR_DEV") == "1") - listOf("--reload", "--reload-dir", "shonar") - else emptyList()), - env) - var up = false - var waited = 0.0 - while (waited < 120.0) { - delay(1000) - waited += 1.0 - if (apiProc?.isAlive == false) break - if (engineReady()) { - up = true - break - } - } - if (!up) { - stopEngine() - _engine.value = EngineState.FAILED - _engineError.value = "Engine started but never became " + - "ready at ${_serverUrl.value}. See ${engineLogFile()}." - return@launch - } - _engine.value = EngineState.RUNNING - if (provisionLocalAccount()) onReady() - else if (_engineError.value == null) { - _engine.value = EngineState.FAILED - _engineError.value = "Engine is up but sign-in failed." - } else { - _engine.value = EngineState.FAILED - } - } catch (e: Exception) { - stopEngine() - _engine.value = EngineState.FAILED - _engineError.value = e.message ?: "Could not start the engine." - } - } - } - - fun stopEngine() { - apiProc?.destroy() - apiProc = null - } - - /** The H200 key lives in ~/.hermes/.env (0600, same machine); desktop - * launches from a plain session don't carry it as an env var, so read - * it from there. Never logged, never written anywhere else. */ - private fun readLanKeyFromHermesEnv(): String = runCatching { - File(System.getProperty("user.home"), ".hermes/.env") - .readLines() - .firstOrNull { it.trimStart().startsWith("$ENV_LAN_LLM_KEY=") } - ?.substringAfter('=')?.trim() - .orEmpty() - }.getOrDefault("") - - private fun ollamaUp(): Boolean = runCatching { - java.net.URL("http://127.0.0.1:11434/api/tags").readText().contains("\"models\"") - }.getOrDefault(false) - - /** Prefer the tuned default; fall back to any qwen3 the user has. */ - private fun ollamaModel(): String { - val names = runCatching { - val body = java.net.URL("http://127.0.0.1:11434/api/tags").readText() - val arr = org.json.JSONObject(body).getJSONArray("models") - (0 until arr.length()).map { arr.getJSONObject(it).getString("name") } - }.getOrDefault(emptyList()) - return when { - names.any { it == DEFAULT_LLM_MODEL } -> DEFAULT_LLM_MODEL - else -> names.firstOrNull { it.startsWith("qwen3") } ?: DEFAULT_LLM_MODEL - } - } - - private fun repoRoot(): File? { - val env = System.getenv("SHONAR_REPO")?.let { File(it) } - val home = File("/home/avi/Projects/Shonar") - return listOfNotNull(env, home).firstOrNull { - File(it, "backend/.venv/bin/uvicorn").canExecute() - } - } - - private fun spawn(dir: File, bin: String, args: List, env: Map): Process = - ProcessBuilder(listOf(bin) + args).directory(dir).apply { - environment().putAll(env) - val log = engineLogFile() - log.parentFile?.mkdirs() - redirectOutput(log) - redirectErrorStream(true) - }.start() - - // ---- library ----------------------------------------------------------- - - fun pickFolder(dir: File) = setFolder(dir) - - private fun setFolder(dir: File, silent: Boolean = false) { - _folder.value = dir - if (!silent) scope.launch { prefs.putString(KEY_FOLDER, dir.absolutePath) } - rescan() - } - - fun rescan() { - val dir = _folder.value ?: return - val files = dir.listFiles() - ?.filter { it.isFile && !it.name.startsWith(".") && it.extension.lowercase() in AUDIO_EXTS } - ?.sortedBy { it.name.lowercase() } - .orEmpty() - _entries.value = files.map { f -> - val done = reportFile(f).exists() - LibraryEntry( - file = f, - hasReport = done, - status = when { - done -> FileStatus.DONE - f.name in failedFiles -> FileStatus.FAILED - f.name in inFlight -> FileStatus.RUNNING - else -> FileStatus.NEW - }, - ) - } - startWatcher() - if (_autoTranscribe.value && _connected.value) pumpNewFiles() - } - - /** Poll the folder for changes (drop-ins appear without a manual rescan). */ - private fun startWatcher() { - if (watchJob?.isActive == true) return - watchJob = scope.launch { - var lastStamp = _folder.value?.lastModified() ?: 0L - var lastCount = _entries.value.size - while (true) { - delay(4000) - val dir = _folder.value ?: break - val count = dir.listFiles()?.size ?: 0 - val stamp = dir.lastModified() - if (stamp != lastStamp || count != lastCount) { - lastStamp = stamp - lastCount = count - rescan() - } - } - } - } - - fun setAutoTranscribe(on: Boolean) { - _autoTranscribe.value = on - scope.launch { prefs.putString(KEY_AUTO, if (on) "1" else "0") } - if (on) pumpNewFiles() - } - - /** Queue every candidate file that has no report and isn't in flight. */ - fun pumpNewFiles() { - val candidates = LibraryQueue.autoQueueCandidates( - entries = _entries.value.map { it.file }, - reportExists = { reportFile(it).exists() }, - inFlightOrDone = inFlight, - failed = failedFiles, - ) - if (candidates.isEmpty()) return - queue.addAll(candidates) - candidates.forEach { inFlight += it.name } - rescanStatuses() - startPump() - } - - /** Queue one specific file (manual Transcribe button). */ - fun pumpFile(f: File) { - if (f.name in inFlight) return - queue.add(f) - inFlight += f.name - rescanStatuses() - startPump() - } - - private fun startPump() { - if (pumpJob?.isActive == true) return - pumpJob = scope.launch { - while (queue.isNotEmpty() && _connected.value) { - val f = queue.poll() ?: break - val ok = transcribeFile(f) - if (!ok) failedFiles += f.name - else failedFiles -= f.name - inFlight -= f.name - rescanStatuses() - } - } - } - - /** file name -> live activity from job polling. */ - private val _liveProgress = MutableStateFlow>(emptyMap()) - val liveProgress = _liveProgress.asStateFlow() - - private fun setLive(name: String, live: LiveProgress?) { - _liveProgress.value = _liveProgress.value.toMutableMap().apply { - if (live == null) remove(name) else put(name, live) - } - } - - private fun rescanStatuses() { - val live = _liveProgress.value - _entries.value = _entries.value.map { e -> - val done = reportFile(e.file).exists() - e.copy( - hasReport = done, - statusNote = live[e.file.name]?.label, - progress = live[e.file.name]?.fraction, - status = when { - done -> FileStatus.DONE - e.file.name in failedFiles -> FileStatus.FAILED - live.containsKey(e.file.name) -> FileStatus.RUNNING - e.file.name in inFlight -> FileStatus.QUEUED - else -> FileStatus.NEW - }, - ) - } - } - - /** - * Background transcription of one file (no Detail UI coupling). - * Returns true when the report was written. Waits for BOTH the - * transcribe and summarize jobs to settle so the report is complete. - */ - suspend fun transcribeFile(f: File): Boolean { - if (_connected.value && runCatching { provider.reconnect() } - .getOrNull() != AuthState.CONNECTED) { - _connected.value = false - _engine.value = EngineState.STOPPED - return false - } - return try { - val draft = RecordingDraft( - id = UUID.randomUUID().toString(), - title = f.nameWithoutExtension, - createdAtEpochMs = f.lastModified().takeIf { it > 0 } - ?: System.currentTimeMillis(), - durationMs = 0, - mime = AUDIO_EXTS[f.extension.lowercase()] ?: "audio/mp4", - sourceFile = f, - sizeBytes = f.length(), - ) - val ref = provider.upload(draft) {} - saveMapping(f, RemoteMapping(ref.key)) - var jobs: List = emptyList() - var polls = 0 - while (polls < 600) { // up to ~30 min per file at 2s polls - polls++ - delay(2000) - jobs = runCatching { parseJobs(provider.fetchJobs(ref.key)) }.getOrNull().orEmpty() - setLive(f.name, jobProgress(jobs)) - rescanStatuses() - val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status - val s = jobs.firstOrNull { it.jobType == "summarize" }?.status - if ((t == null || t in TERMINAL) && (s == null || s in TERMINAL)) break - } - setLive(f.name, null) - val transcribeFailed = jobs.any { - it.jobType == "transcribe" && it.status == "failed" - } - if (transcribeFailed) return false - val transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) } - .getOrNull() ?: return false - val summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull() - saveReport(DetailUi(file = f, transcript = transcript, summary = summary)) - true - } catch (e: Exception) { - setLive(f.name, null) - // Surface pump failures for diagnosis instead of swallowing them. - runCatching { - File(appDir, "pump-error.log").appendText( - "${java.time.Instant.now()} ${f.name}: $e\n", - ) - } - false - } - } - - /** - * Rename a library file (extension fixed) and carry its report along. - * Refuses while the file is queued/transcribing so the pump can't - * race the rename. Returns null on success or a human-readable reason. - */ - fun renameFile(old: File, newBase: String): String? { - if (old.name in inFlight) return "Wait — this file is transcribing." - val target = LibraryQueue.renameTarget(old, newBase) - .getOrElse { return it.message ?: "Invalid name." } - val ok = runCatching { - old.renameTo(target).also { moved -> - if (moved) { - val oldReport = reportFile(old) - if (oldReport.exists()) { - oldReport.renameTo(reportFile(target)) - } - moveMapping(old, target) - } - } - }.getOrElse { return "Rename failed: ${it.message}" } - if (!ok) return "Rename failed (disk said no)." - // Sets/flows are keyed by file name — carry entries over. - if (old.name in failedFiles) { failedFiles -= old.name; failedFiles += target.name } - _liveProgress.value = _liveProgress.value.toMutableMap().apply { - remove(old.name) - } - if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target) - rescan() - return null - } - - fun setQuery(q: String) { - _query.value = q - val needle = q.trim().lowercase() - val dir = _folder.value - if (needle.isEmpty() || dir == null) { - _searchResults.value = emptyList() - return - } - val hits = mutableListOf() - // Name matches come first: they find untranscribed recordings too - // (searching "tycos" should surface "…Tycos Space.m4a" immediately, - // whether or not a report exists yet). - _entries.value.forEach { e -> - if (e.file.nameWithoutExtension.lowercase().contains(needle)) { - hits += SearchHit( - report = e.file.nameWithoutExtension, - line = 0, // 0 = name match (no transcript line) - snippet = "recording name match", - audioPath = e.file.absolutePath, - ) - } - } - dir.listFiles() - ?.filter { it.isFile && it.name.endsWith(".transcript.md") } - ?.sortedBy { it.name.lowercase() } - ?.forEach { report -> - report.readLines().forEachIndexed { i, line -> - val at = line.lowercase().indexOf(needle) - if (at >= 0) { - val start = (at - 40).coerceAtLeast(0) - val end = (at + needle.length + 80).coerceAtMost(line.length) - hits += SearchHit(report.nameWithoutExtension, i + 1, line.substring(start, end).trim()) - if (hits.size >= 200) return@forEach - } - } - } - _searchResults.value = hits - } - - // ---- models ------------------------------------------------------------ - - fun refreshModels() { - scope.launch { - _modelsError.value = null - _models.value = runCatching { parseModels(provider.fetchModels()) }.getOrElse { - _modelsError.value = it.message ?: "Could not load models." - return@launch - } - } - } - - fun setDefaultModel(name: String) { - scope.launch { - _modelsError.value = null - runCatching { provider.setDefaultModel(name) }.onFailure { - _modelsError.value = it.message ?: "Could not change the default." - return@launch - } - refreshModels() - } - } - - fun downloadModel(name: String) { - scope.launch { - _modelsError.value = "Downloading $name — this can take a while…" - runCatching { provider.downloadModel(name) }.onFailure { - _modelsError.value = it.message ?: "Download failed." - return@launch - } - _modelsError.value = null - refreshModels() - } - } - - // ---- detail / transcribe ----------------------------------------------- - - fun openDetail(file: File) { - pollJob?.cancel() - // Show the saved report immediately — otherwise opening a finished - // file looks empty and the summary appears unreachable. - val saved = runCatching { reportFile(file).readText() }.getOrNull() - val mapping = loadMapping(file) - val d = DetailUi( - file = file, reportText = saved, - remoteId = mapping?.recordingId, - overrideModel = mapping?.model, - ) - _detail.value = d - go(Screen.DETAIL) - pollJob = scope.launch { - // Files that predate remote-id mapping have no sidecar: adopt - // the server recording by exact title match so Re-summarize - // appears and finished AI outputs become reachable. - var remoteId = d.remoteId - if (remoteId == null) { - remoteId = adoptServerRecording(d) - if (_detail.value?.file != file) return@launch - } - if (remoteId == null) return@launch - // Report written before a summary existed (or lost a race): - // pull finished outputs so the detail view isn't stale. - val report = d.reportText - if (d.transcript == null && !report.isNullOrBlank() && !report.contains("## Summary")) { - val t = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } - .getOrNull() - val s = runCatching { parseSummary(provider.fetchSummary(remoteId)) } - .getOrNull() - if (s != null) { - d.transcript = t ?: d.transcript - d.summary = s - saveReport(d) - if (_detail.value?.file == file) _detail.value = d.copy() - } - } - // If the file is mid-pipeline (pump running, or reprocess started - // elsewhere), show live stage/progress instead of a stale report. - while (true) { - val fetched = runCatching { parseJobs(provider.fetchJobs(remoteId)) } - if (fetched.isFailure && d.jobs.isEmpty()) { - // Don't fail silently: a dead session used to render as - // an idle screen, indistinguishable from "never started". - d.error = "Couldn't read job status from the engine: " + - (fetched.exceptionOrNull()?.message ?: "unknown error") - } else if (fetched.isSuccess) { - d.error = null - } - val jobs = fetched.getOrNull().orEmpty() - if (_detail.value?.file != file) break - // Pick up report rewrites made by the pump/completion paths - // while this screen sits open (they write the file, not us). - if (d.transcript == null) { - val fresh = runCatching { reportFile(file).readText() }.getOrNull() - if (fresh != null && fresh != d.reportText) d.reportText = fresh - } - d.jobs = jobs - jobLabel(jobs)?.let { d.busy = it } ?: run { d.busy = null } - _detail.value = d.copy() - val active = jobs.any { it.status == "running" || it.status == "queued" } - if (!active) break - delay(2000) - } - } - } - - /** Find the server recording matching [d]'s file by title and persist - * the mapping. Returns the recording id, or null when no server-side - * row carries this exact title. */ - private suspend fun adoptServerRecording(d: DetailUi): String? { - val wanted = d.file.nameWithoutExtension - return runCatching { - var cursor: String? = null - var adopted: String? = null - var scanned = 0 - do { - val page = provider.list(cursor) - val matches = page.items.filter { it.title == wanted } - // Only adopt when the title is unambiguous server-side. - if (matches.size == 1) adopted = matches.first().ref.key - if (matches.size > 1) return@runCatching null - cursor = page.nextCursor - scanned += page.items.size - } while (cursor != null && scanned < 400) - adopted?.let { - saveMapping(d.file, RemoteMapping(it, title = wanted)) - d.remoteId = it - } - adopted - }.getOrNull() - } - - fun setOverride(model: String?) { - _detail.value?.let { it.overrideModel = model; _detail.value = it.copy() } - } - - fun transcribe() { - val d = _detail.value ?: return - pollJob?.cancel() - // A file with a known server recording re-runs the pipeline IN - // PLACE (the endpoint takes the model override too) — re-uploading - // would duplicate the recording server-side. Only never-uploaded - // files take the upload path below. - val mapping = loadMapping(d.file) - if (mapping != null) { - d.remoteId = mapping.recordingId - reprocessInPlace(d, job = "transcribe", model = d.overrideModel) - return - } - pollJob = scope.launch { - d.busy = "Uploading…" - d.uploadProgress = 0f - d.error = null - _detail.value = d.copy() - val draft = RecordingDraft( - id = UUID.randomUUID().toString(), - title = d.file.nameWithoutExtension, - createdAtEpochMs = d.file.lastModified().takeIf { it > 0 } - ?: System.currentTimeMillis(), - durationMs = 0, - mime = AUDIO_EXTS[d.file.extension.lowercase()] ?: "audio/mp4", - sourceFile = d.file, - sizeBytes = d.file.length(), - transcriptionModel = d.overrideModel, - ) - val ref = runCatching { - provider.upload(draft) { p -> - d.uploadProgress = p - // Row shows the upload too — pressing Transcribe must - // always change something on screen, immediately. - setLive(d.file.name, LiveProgress("uploading…", p)) - rescanStatuses() - _detail.value = d.copy() - } - }.getOrElse { - d.busy = null - d.error = it.message ?: "Upload failed." - _detail.value = d.copy() - return@launch - } - d.remoteId = ref.key - saveMapping(d.file, RemoteMapping(ref.key, model = d.overrideModel, - title = d.file.nameWithoutExtension)) - d.busy = "Transcribing…" - d.uploadProgress = null - _detail.value = d.copy() - // Poll jobs until the transcribe step reaches a terminal state. - while (true) { - delay(2000) - val jobs = runCatching { parseJobs(provider.fetchJobs(ref.key)) }.getOrNull() - .orEmpty() - d.jobs = jobs - jobLabel(jobs)?.let { d.busy = it } - // Mirror live progress onto the library row (same as the - // pump + reprocess paths) so leaving the detail screen - // doesn't make the job look dead. - setLive(d.file.name, jobProgress(jobs)) - rescanStatuses() - _detail.value = d.copy() - val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status - if (t == null || t in TERMINAL) break - } - setLive(d.file.name, null) - rescanStatuses() - val failed = d.jobs.firstOrNull { it.jobType == "transcribe" && it.status == "failed" } - if (failed != null) { - d.busy = null - d.error = failed.error ?: "Transcription failed." - _detail.value = d.copy() - return@launch - } - d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) } - .getOrNull() - d.summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull() - d.busy = null - _detail.value = d.copy() - if (d.transcript != null) { - saveReport(d) - rescan() - } else { - d.error = "Transcription finished but no transcript was returned." - _detail.value = d.copy() - } - } - } - - fun retry() = transcribe() - - /** - * Re-run one pipeline stage on the EXISTING server recording via - * POST /reprocess — no upload, no duplicate. Polls the same jobs - * endpoint as the upload path. [model] is sent for "transcribe" so a - * model override switch persists on the server row. - */ - fun reprocessInPlace(d0: DetailUi, job: String, model: String? = null) { - val remoteId = d0.remoteId ?: return - pollJob?.cancel() - pollJob = scope.launch { - d0.busy = if (job == "transcribe") "Re-transcribing…" else "Summarizing…" - d0.uploadProgress = null - d0.error = null - _detail.value = d0.copy() - runCatching { provider.reprocess(remoteId, job, model) }.onFailure { - if (job == "transcribe" && it is ProviderError.NotFound) { - // Mapping points at a deleted recording: drop it so the - // next attempt re-uploads instead of failing forever. - mappingFile(d0.file).delete() - d0.remoteId = null - d0.busy = null - d0.error = "That server recording no longer exists — press Transcribe to upload again." - } else { - d0.busy = null - d0.error = it.message ?: "Reprocess failed." - } - _detail.value = d0.copy() - return@launch - } - // Poll the target job to a terminal state. - while (true) { - delay(2000) - val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrNull() - .orEmpty() - d0.jobs = jobs - jobLabel(jobs)?.let { d0.busy = it } - // Mirror live progress onto the library row too — otherwise - // reprocess looks like "nothing happened" after leaving the - // detail screen (the pump path already does this via setLive). - setLive(d0.file.name, jobProgress(jobs)) - rescanStatuses() - _detail.value = d0.copy() - val st = jobs.firstOrNull { it.jobType == job }?.status - if (st == null || st in TERMINAL) break - } - setLive(d0.file.name, null) - rescanStatuses() - val failed = d0.jobs.firstOrNull { it.jobType == job && it.status == "failed" } - if (failed != null) { - d0.busy = null - d0.error = failed.error ?: "That stage failed." - _detail.value = d0.copy() - return@launch - } - d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } - .getOrNull() - d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) } - .getOrNull() - d0.busy = null - _detail.value = d0.copy() - if (d0.transcript != null || d0.summary != null) { - saveReport(d0) - saveMapping(d0.file, RemoteMapping(remoteId, model = d0.overrideModel, - title = d0.file.nameWithoutExtension)) - rescan() - } - } - } - - /** "Summarize" button: regenerate the summary in place from the - * current transcript. Requires a known server recording. */ - fun summarize() { - val d = _detail.value ?: return - val remoteId = d.remoteId ?: loadMapping(d.file)?.recordingId ?: return - d.remoteId = remoteId - reprocessInPlace(d, job = "summarize") - } - - /** Server recording id for a library file, if it was ever uploaded. */ - fun remoteIdFor(file: File): String? = - _detail.value?.takeIf { it.file == file }?.remoteId ?: loadMapping(file)?.recordingId - - fun saveReport(d: DetailUi) { - reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary)) - } - - companion object { - /** Live pipeline activity for a recording: human label plus the - * 0..1 fraction when the backend reports one. Null = idle. */ - fun jobProgress(jobs: List): LiveProgress? { - val t = jobs.firstOrNull { it.jobType == "transcribe" } - val s = jobs.firstOrNull { it.jobType == "summarize" } - return when { - t != null && t.status == "running" -> when { - t.stage == "loading-model" -> LiveProgress("loading model…", null) - t.progress != null -> - LiveProgress("transcribing… ${t.progress}%", t.progress / 100f) - else -> LiveProgress("transcribing…", null) - } - s != null && s.status == "running" -> LiveProgress("summarizing…", null) - else -> null - } - } - - /** Label-only convenience for the Detail screen's busy text. */ - fun jobLabel(jobs: List): String? = jobProgress(jobs)?.label - - const val KEY_URL = "server.url" - const val KEY_FOLDER = "library.folder" - const val KEY_PASSWORD = "local.password" - const val KEY_SECRET = "local.secret" - const val KEY_AUTO = "library.auto_transcribe" - /** Preferred local summarization model (Ollama). */ - const val DEFAULT_LLM_MODEL = "qwen3:4b" - /** LAN H200 inference server (private 10.x network, never internet). */ - const val LAN_LLM_BASE_URL = "http://10.50.200.200:8100" - const val LAN_LLM_MODEL = "qwen3.8-flash-next" - const val ENV_LAN_LLM_KEY = "HERMES_CUSTOM_10_50_200_200_8100_API_KEY" - // Must satisfy the engine's email validation (period in domain). - const val LOCAL_EMAIL = "desktop@app.shonar" - - val AUDIO_EXTS = mapOf( - "m4a" to "audio/mp4", "mp4" to "audio/mp4", "aac" to "audio/aac", - "wav" to "audio/wav", "ogg" to "audio/ogg", "opus" to "audio/ogg", - "webm" to "audio/webm", "mp3" to "audio/mpeg", - ) - - val TERMINAL = setOf("succeeded", "failed", "skipped") - - fun defaultAppDir(): File = - File(System.getProperty("user.home"), ".config/shonar-desktop").apply { mkdirs() } - - fun reportFile(audio: File): File = - File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md") - } -} - -data class SearchHit( - val report: String, - val line: Int, - val snippet: String, - /** Set for name matches so the hit can open the recording. */ - val audioPath: String? = null, -) - -/** What the pipeline is doing for a library file right now. */ -data class LiveProgress(val label: String, val fraction: Float?) diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt deleted file mode 100644 index ea12cc0..0000000 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.shonar.desktop - -import java.io.File - -/** - * Pure library-queue logic (unit-tested; no coroutines, no Android). - * - * [plan] decides which files on disk are transcription candidates, and - * the queue pump consumes them in a stable order. All policy lives here - * so the state class stays plumbing. - */ - -enum class FileStatus { - /** No report yet — a candidate. */ - NEW, - - /** Queued for auto-transcription. */ - QUEUED, - - /** Upload/transcribe/summarize running now. */ - RUNNING, - - /** Report written next to the audio. */ - DONE, - - /** Last attempt failed (error message kept beside it). */ - FAILED, - ; -} - -/** - * Diff between two scans of the library folder. [added] preserves sorted - * file-name order so the queue is deterministic across rescans. - */ -class LibraryDiff( - val added: List, - /** Names that disappeared since the previous scan. */ - val removed: Set, -) - -object LibraryQueue { - - /** - * Validate renaming [oldFile] to base name [newBase] (no extension; - * a typed extension is stripped). Returns the target File or a - * human-readable failure. The extension always stays the original's. - */ - fun renameTarget(oldFile: File, newBase: String): Result { - val base = newBase.trim() - .let { s -> - val ext = oldFile.extension - if (ext.isNotEmpty() && s.length > ext.length + 1 && - s.endsWith(".$ext", ignoreCase = true) - ) s.dropLast(ext.length + 1) else s - } - .trim() - fun fail(msg: String): Result = - Result.failure(IllegalArgumentException(msg)) - if (base.isEmpty()) return fail("Name is empty.") - if (base.any { it == '/' || it == '\\' }) return fail("Name can't contain a slash.") - if (base == "." || base == "..") return fail("Not a valid name.") - if (base.startsWith(".")) return fail("Name can't start with a dot.") - if (base.length > 120) return fail("Name is too long.") - val target = File(oldFile.parentFile, "$base.${oldFile.extension}") - if (target == oldFile) return fail("That's already the name.") - if (target.exists()) return fail("A file with that name already exists.") - return Result.success(target) - } - - /** Audio candidates: regular files, known extension, not hidden. */ - fun isAudioCandidate(f: File, audioExts: Set): Boolean = - f.isFile && !f.name.startsWith(".") && - f.extension.lowercase() in audioExts - - fun diff(before: Set, after: List): LibraryDiff { - val afterNames = after.map { it.name }.toSet() - val added = after.filter { it.name !in before } - val removed = before - afterNames - return LibraryDiff(added, removed) - } - - /** - * Files to (re)queue for auto-transcription: candidates without a - * report, minus files already in-flight or previously failed since - * the last scan (a failure must not hot-loop; a rescan after the - * user edits/renames clears it naturally when the report appears). - */ - fun autoQueueCandidates( - entries: List, - reportExists: (File) -> Boolean, - inFlightOrDone: Set, - failed: Set, - ): List = entries.filter { - !reportExists(it) && it.name !in inFlightOrDone && it.name !in failed - } -} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Main.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Main.kt deleted file mode 100644 index cccc3c6..0000000 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/Main.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.shonar.desktop - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Surface -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.isCtrlPressed -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import androidx.compose.ui.window.rememberWindowState - -/** Scales every dp/sp in the app uniformly (fonts, paddings, controls). */ -private data class ScaledDensity(val base: Density, val zoom: Float) : Density { - override val density: Float get() = base.density * zoom - override val fontScale: Float get() = base.fontScale * zoom -} - -fun main() = application { - val state = remember { DesktopState() } - val screen by state.screen.collectAsState() - // Ctrl+= / Ctrl+- zoom the whole app; Ctrl+0 resets (after a zoom). - var zoom by remember { mutableStateOf(1f) } - Window( - onCloseRequest = ::exitApplication, - title = "SHONAR Desktop" + run { - // Build stamp so "which window is this?" is answerable at a glance. - val jar = java.io.File( - System.getProperty("java.class.path", "").split(":") - .firstOrNull { it.endsWith(".jar") } ?: "") - if (jar.exists()) { - val fmt = java.time.format.DateTimeFormatter.ofPattern("HH:mm") - .withZone(java.time.ZoneId.systemDefault()) - " · build " + fmt.format(java.time.Instant.ofEpochMilli(jar.lastModified())) - } else "" - }, - state = rememberWindowState(width = 1100.dp, height = 800.dp), - ) { - val baseDensity = LocalDensity.current - ShonarTheme { - Surface( - Modifier.fillMaxSize().onPreviewKeyEvent { e -> - if (e.type != KeyEventType.KeyDown || !e.isCtrlPressed) return@onPreviewKeyEvent false - when (e.key) { - Key.Equals, Key.Plus, Key.NumPadAdd -> { - zoom = (zoom * 1.1f).coerceAtMost(3f); true - } - Key.Minus, Key.NumPadSubtract -> { - zoom = (zoom / 1.1f).coerceAtLeast(0.4f); true - } - Key.Zero, Key.NumPad0 -> { - if (zoom != 1f) { zoom = 1f; true } else false - } - else -> false - } - }, - ) { - CompositionLocalProvider( - LocalDensity provides ScaledDensity(baseDensity, zoom), - ) { - Box(Modifier.fillMaxSize().padding(20.dp)) { - when (screen) { - DesktopState.Screen.ENGINE -> EngineScreen(state) - DesktopState.Screen.LIBRARY -> LibraryScreen(state) - DesktopState.Screen.DETAIL -> DetailScreen(state) - DesktopState.Screen.SETTINGS -> SettingsScreen(state) - } - } - } - } - } - } -} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/RemoteMapping.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/RemoteMapping.kt deleted file mode 100644 index 250391d..0000000 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/RemoteMapping.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.shonar.desktop - -import java.io.File -import org.json.JSONObject - -/** - * Sidecar mapping a library file to its server recording id. Written next - * to the audio as `.shonar.json` (excluded from the library listing - * by the audio-extension filter; travels with the file through renames - * and Syncthing). - * - * Why persisted: without it, "Re-transcribe" after a restart uploads a - * brand-new recording (duplicate on the server). With it, the desktop - * re-runs the pipeline in place via POST /reprocess. - * - * Pure logic — unit-tested. - */ -data class RemoteMapping( - val recordingId: String, - /** Model override the last transcribe used, if any (informational). */ - val model: String? = null, - val title: String? = null, -) - -fun mappingFile(audio: File): File = - File(audio.parentFile, "${audio.nameWithoutExtension}.shonar.json") - -fun loadMapping(audio: File): RemoteMapping? = runCatching { - val o = JSONObject(mappingFile(audio).readText()) - val id = o.optString("recording_id") - if (id.isBlank()) null - else RemoteMapping( - recordingId = id, - model = o.optString("model").takeIf { it.isNotBlank() }, - title = o.optString("title").takeIf { it.isNotBlank() }, - ) -}.getOrNull() - -fun saveMapping(audio: File, mapping: RemoteMapping) { - val o = JSONObject() - o.put("recording_id", mapping.recordingId) - mapping.model?.let { o.put("model", it) } - mapping.title?.let { o.put("title", it) } - mappingFile(audio).writeText(o.toString(2) + "\n") -} - -/** Carry the mapping across a file rename (returns true if one moved). */ -fun moveMapping(old: File, new: File): Boolean { - val src = mappingFile(old) - return src.exists() && src.renameTo(mappingFile(new)) -} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Reports.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Reports.kt deleted file mode 100644 index a2f3fd4..0000000 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/Reports.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.shonar.desktop - -import com.shonar.recording.SUMMARY_LIST_KEYS -import com.shonar.recording.SummaryData -import com.shonar.recording.TranscriptData - -/** - * Plaud-style Markdown report written next to the audio file. - * Pure logic — unit-tested. - */ -fun renderReport(title: String, transcript: TranscriptData?, summary: SummaryData?): String { - val out = mutableListOf("# $title", "") - if (summary != null) { - if (summary.short.isNotBlank()) { - out += listOf("## Summary", "", summary.short, "") - } - for (key in SUMMARY_LIST_KEYS) { - val items = summary.list(key) - if (items.isNotEmpty()) { - out += listOf("## " + key.replace('_', ' ').replaceFirstChar { it.uppercase() }, "") - out += items.map { "- $it" } + listOf("") - } - } - if (summary.detailed.isNotBlank()) { - out += listOf("## Details", "", summary.detailed, "") - } - } - if (transcript != null) { - out += listOf("## Transcript", "") - val segs = transcript.segments - if (segs.isNotEmpty()) { - segs.forEach { s -> out += "[${fmtTs(s.startSec)}] ${s.text}" } - out += "" - } else { - out += listOf(transcript.text, "") - } - val prov = transcript.provider.ifBlank { "?" } - out += "*Transcribed with $prov, v${transcript.version}.*" - } else { - out += listOf("## Transcript", "", "_No transcript available._", "") - } - return out.joinToString("\n") -} - -fun fmtTs(sec: Double): String { - val total = sec.toLong().coerceAtLeast(0) - return "%02d:%02d:%02d".format(total / 3600, (total % 3600) / 60, total % 60) -} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt deleted file mode 100644 index de4066b..0000000 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ /dev/null @@ -1,525 +0,0 @@ -package com.shonar.desktop - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.text.style.TextOverflow -import java.io.File -import javax.swing.JFileChooser - -// ---- engine ----------------------------------------------------------------- - -@androidx.compose.runtime.Composable -fun EngineScreen(state: DesktopState) { - val engine by state.engine.collectAsState() - val error by state.engineError.collectAsState() - - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Starting the transcription engine…", - style = MaterialTheme.typography.headlineSmall) - Text( - "SHONAR transcribes on this computer with local models — nothing " + - "is uploaded anywhere. The engine runs in the background while " + - "this app is open.", - style = MaterialTheme.typography.bodyMedium, - ) - when (engine) { - DesktopState.EngineState.STARTING, DesktopState.EngineState.UNKNOWN -> { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) - Spacer(Modifier.width(10.dp)) - Text("Starting…") - } - } - else -> { - error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button({ state.startEngine() }) { Text("Start engine") } - OutlinedButton({ state.ensureReady() }) { Text("Retry") } - } - } - } - } -} - -// ---- library --------------------------------------------------------------- - -private fun chooseFolder(): File? { - val chooser = JFileChooser().apply { - dialogTitle = "Choose your recordings folder" - fileSelectionMode = JFileChooser.DIRECTORIES_ONLY - isAcceptAllFileFilterUsed = false - // Without an explicit size the dialog comes up tiny on HiDPI - // compositors (and there is no resize handle), so pin a usable one. - preferredSize = java.awt.Dimension(1040, 700) - } - return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null -} - -@androidx.compose.runtime.Composable -fun LibraryScreen(state: DesktopState) { - val folder by state.folder.collectAsState() - val entries by state.entries.collectAsState() - val query by state.query.collectAsState() - val hits by state.searchResults.collectAsState() - val connected by state.connected.collectAsState() - var renaming by remember { mutableStateOf(null) } - - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Recordings", style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f)) - TextButton({ state.go(DesktopState.Screen.SETTINGS) }) { Text("Settings") } - } - if (!connected) { - Card(Modifier.fillMaxWidth()) { - Row(Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically) { - Text("Transcription engine isn't running.", - modifier = Modifier.weight(1f)) - TextButton({ state.go(DesktopState.Screen.ENGINE) }) { - Text("Start it") - } - } - } - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text(folder?.absolutePath ?: "No folder chosen", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f)) - Spacer(Modifier.width(8.dp)) - OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) { - Text("Choose folder…") - } - Spacer(Modifier.width(8.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - val auto = state.autoTranscribe.collectAsState().value - Checkbox(auto, { state.setAutoTranscribe(it) }) - Text("Auto-transcribe new files", - style = MaterialTheme.typography.bodySmall) - } - } - OutlinedTextField(query, { state.setQuery(it) }, - label = { Text("Search saved transcripts") }, - singleLine = true, modifier = Modifier.fillMaxWidth()) - if (query.isNotBlank()) { - if (hits.isEmpty()) { - Text("No matches.", style = MaterialTheme.typography.bodySmall) - } else { - LazyColumn(Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp)) { - items(hits, key = { "${it.audioPath ?: "text"}:${it.report}:${it.line}" }) { h -> - val openable = h.audioPath?.let { File(it).exists() } == true - Card( - Modifier.fillMaxWidth().then( - if (openable) Modifier.clickable { - state.openDetail(File(h.audioPath)) - } else Modifier - ), - ) { - Column(Modifier.padding(10.dp)) { - Text(h.report, style = MaterialTheme.typography.titleSmall) - Text( - if (h.line == 0) h.snippet else "line ${h.line}: …${h.snippet}…", - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - } - } - } else if (folder == null) { - Text("Pick the folder with your .m4a recordings to begin.", - color = MaterialTheme.colorScheme.onSurfaceVariant) - } else if (entries.isEmpty()) { - Text("No audio files in this folder.", - color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - LazyColumn(Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp)) { - items(entries, key = { it.file.absolutePath }) { e -> - Card(Modifier.fillMaxWidth().clickable { state.openDetail(e.file) }) { - Row(Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(e.file.nameWithoutExtension, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis) - Text( - "%.1f MB • %s".format( - e.file.length() / 1e6, - when (e.status) { - FileStatus.DONE -> "transcript saved" - FileStatus.QUEUED -> "queued for transcription" - // statusNote carries the live - // "transcribing… 42%" label. - FileStatus.RUNNING -> - e.statusNote ?: "transcribing…" - FileStatus.FAILED -> - "failed" + (e.statusNote?.let { " ($it)" } ?: "") - FileStatus.NEW -> "not transcribed" - }, - ), - style = MaterialTheme.typography.bodySmall, - color = when (e.status) { - FileStatus.FAILED -> MaterialTheme.colorScheme.error - FileStatus.DONE -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - if (e.status == FileStatus.RUNNING) { - Spacer(Modifier.height(6.dp)) - val frac = e.progress - if (frac != null) { - LinearProgressIndicator( - progress = { frac }, - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } else { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } - } - } - if (e.status == FileStatus.NEW || e.status == FileStatus.FAILED) { - TextButton({ state.pumpFile(e.file) }) { Text("Transcribe") } - } - if (e.status != FileStatus.RUNNING && - e.status != FileStatus.QUEUED) { - TextButton({ renaming = e.file }) { Text("Rename") } - } - TextButton({ state.openDetail(e.file) }) { Text("Open") } - } - } - } - } - } - - renaming?.let { target -> - var text by remember(target) { mutableStateOf(target.nameWithoutExtension) } - var error by remember(target) { mutableStateOf(null) } - AlertDialog( - onDismissRequest = { renaming = null }, - title = { Text("Rename recording") }, - text = { - Column { - OutlinedTextField( - value = text, - onValueChange = { text = it; error = null }, - label = { Text("New name") }, - singleLine = true, - ) - error?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall) - } - } - }, - confirmButton = { - TextButton({ - error = state.renameFile(target, text) - if (error == null) renaming = null - }) { Text("Rename") } - }, - dismissButton = { - TextButton({ renaming = null }) { Text("Cancel") } - }, - ) - } - } -} - -// ---- detail ----------------------------------------------------------------- - -@androidx.compose.runtime.Composable -fun DetailScreen(state: DesktopState) { - val detail = state.detail.collectAsState().value ?: run { - state.go(DesktopState.Screen.LIBRARY) - return - } - val models = state.models.collectAsState().value - var showModels by remember { mutableStateOf(false) } - - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } - Text(detail.file.nameWithoutExtension, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.weight(1f)) - } - // Model override: "Use default" or a specific size. - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Model: ", style = MaterialTheme.typography.bodyMedium) - val current = detail.overrideModel - ?: models?.defaultModel - ?: "base" - TextButton({ showModels = !showModels }) { - Text(if (detail.overrideModel == null) "Use default ($current)" else current) - } - } - if (showModels && models != null) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - ModelRow(state, detail, null, models.defaultModel, - "Use default (${models.defaultModel})", "") - models.models.forEach { m -> - ModelRow(state, detail, m.name, models.defaultModel, - m.displayName, m.description + - (if (!m.available) " — not downloaded" else "")) - } - } - Spacer(Modifier.height(4.dp)) - } - detail.uploadProgress?.let { - LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth()) - Text("Uploading… ${(it * 100).toInt()}%") - } - detail.busy?.takeIf { detail.uploadProgress == null }?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) - Spacer(Modifier.width(10.dp)) - Text(it) - } - } - val tJob = detail.jobs.firstOrNull { it.jobType == "transcribe" } - tJob?.let { - val running = it.status == "running" - val label = when { - it.status == "succeeded" -> "Transcribed" - it.status == "failed" -> "Failed" - it.stage == "loading-model" -> "Loading model…" - running && it.progress != null -> "Transcribing… ${it.progress}%" - it.stage == "transcribing" -> "Transcribing…" - running -> "Working… (attempt ${it.attempt}/${it.maxAttempts})" - else -> "Queued" - } - Text(label, style = MaterialTheme.typography.bodyMedium, - color = if (it.status == "failed") MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.primary) - if (running) { - Spacer(Modifier.height(4.dp)) - if (it.progress != null) { - LinearProgressIndicator( - progress = { it.progress / 100f }, - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } else { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } - } - } - val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } - sJob?.takeIf { it.status == "running" || it.status == "queued" }?.let { - val label = if (it.status == "running") "Summarizing…" else "Summary queued" - Text(label, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.height(4.dp)) - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } - detail.error?.let { - Text(it, color = MaterialTheme.colorScheme.error) - if (tJob?.status == "failed") { - Button({ state.retry() }) { Text("Retry transcription") } - } - } - if (detail.busy == null && detail.transcript == null && - detail.reportText == null && detail.error == null) { - Button({ state.transcribe() }) { Text("Transcribe") } - } - detail.transcript?.let { t -> - Text("Transcript", style = MaterialTheme.typography.titleMedium) - LazyColumn(Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp)) { - if (t.segments.isNotEmpty()) { - items(t.segments.size) { i -> - val s = t.segments[i] - Text("[${fmtTs(s.startSec)}] ${s.text}", - style = MaterialTheme.typography.bodyMedium) - } - } else { - item { Text(t.text) } - } - } - Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - detail.summary?.let { s -> - if (s.short.isNotBlank()) { - Text("Summary", style = MaterialTheme.typography.titleMedium) - Text(s.short) - } - } - // No live transcript (file opened from the library after the fact): - // show the saved report so summary + transcript are actually visible. - val savedReport = detail.reportText - if (detail.transcript == null && savedReport != null) { - Text("Saved report", style = MaterialTheme.typography.titleMedium) - val reportLines = savedReport.lines() - LazyColumn(Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp)) { - items(reportLines.size) { i -> - Text(reportLines[i], - style = MaterialTheme.typography.bodyMedium) - } - } - Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Spacer(Modifier.height(4.dp)) - if (detail.transcript != null || detail.reportText != null) { - // Honest labels: "Re-…" only when that output actually exists. - val report = detail.reportText - val hasTranscript = detail.transcript != null || - (report != null && !report.contains("_No transcript available._")) - val hasSummary = detail.summary != null || - (report?.contains("## Summary") == true) - val remoteKnown = state.remoteIdFor(detail.file) != null - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton({ state.transcribe() }) { - Text(if (hasTranscript) "Re-transcribe" else "Transcribe") - } - TextButton({ state.summarize() }, - enabled = remoteKnown && hasTranscript) { - Text(if (hasSummary) "Re-summarize" else "Summarize") - } - } - if (!remoteKnown || !hasTranscript) { - Text(if (!hasTranscript) - "Summarize unlocks once this recording has a transcript." - else - "Summarize unlocks once this file is uploaded (press Transcribe).", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} - -@androidx.compose.runtime.Composable -private fun ModelRow( - state: DesktopState, - detail: DesktopState.DetailUi, - value: String?, - default: String, - title: String, - subtitle: String, -) { - val selected = detail.overrideModel == value || - (value == null && detail.overrideModel == null) - Card( - Modifier.fillMaxWidth().clickable { state.setOverride(value) }, - ) { - Row(Modifier.fillMaxWidth().padding(10.dp), - verticalAlignment = Alignment.CenterVertically) { - Text(if (selected) "◉ " else "○ ") - Column { - Text(title, style = MaterialTheme.typography.bodyMedium) - if (subtitle.isNotBlank()) { - Text(subtitle, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } -} - -// ---- settings ----------------------------------------------------------------- - -@androidx.compose.runtime.Composable -fun SettingsScreen(state: DesktopState) { - val models = state.models.collectAsState().value - val modelsError = state.modelsError.collectAsState().value - val url by state.serverUrl.collectAsState() - - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } - Text("Settings", style = MaterialTheme.typography.headlineSmall) - } - Text("Local engine: $url", style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.height(4.dp)) - Text("Default transcription model", style = MaterialTheme.typography.titleMedium) - Text("Applies to future transcriptions. Each recording keeps the model it used.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - if (models == null) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) - Spacer(Modifier.width(10.dp)) - Text("Loading models…") - } - } else { - if (!models.fasterWhisperInstalled) { - Text("faster-whisper is not installed on the server " + - "(pip install shonar-backend[faster-whisper]).", - color = MaterialTheme.colorScheme.error) - } - models.models.forEach { m -> - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - (if (m.isDefault) "◉ " else "○ ") + m.displayName, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - if (!m.isDefault) { - TextButton({ state.setDefaultModel(m.name) }) { - Text("Set default") - } - } - if (!m.downloaded) { - TextButton({ state.downloadModel(m.name) }) { - Text("Download") - } - } - } - Text(m.description, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - Text("${m.params} • ${m.approxMemory} • ${m.relativeSpeed}" + - (if (m.available) " • ready" else " • not downloaded"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } - modelsError?.let { Text(it, color = MaterialTheme.colorScheme.error) } - } -} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Theme.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Theme.kt deleted file mode 100644 index 5c200a4..0000000 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/Theme.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.shonar.desktop - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -/** - * SHONAR Desktop theme — "warm graphite": near-black charcoal surfaces - * with an olive-gold accent, inspired by the Skills Hub hero design. - * Desktop-only for now (Android keeps its own ui/theme/Theme.kt). - * - * Status semantics in the library list map to scheme roles: - * done -> primary (gold), failed -> error (red), queued/running -> - * onSurfaceVariant with a primary progress bar. - */ -private val ShonarGold = Color(0xFFE0B23A) // text-safe gold (brighter for AA on cards) -private val ShonarGoldBright = Color(0xFFF0C419) -private val GoldInk = Color(0xFF1F1800) // text on gold fills (AA) -private val GraphiteBg = Color(0xFF1A1A1A) -private val GraphiteSurface = Color(0xFF222222) -private val GraphiteHigh = Color(0xFF2B2B2B) -private val InkPrimary = Color(0xFFF4F4F4) -private val InkSecondary = Color(0xFFB3B3B3) -private val InkMuted = Color(0xFF8A8A8A) -private val OutlineWarm = Color(0xFF3A3A3A) -private val DangerWarm = Color(0xFFE5544F) - -private val ShonarDarkScheme = darkColorScheme( - primary = ShonarGold, - onPrimary = GoldInk, - primaryContainer = Color(0xFF3A3216), - onPrimaryContainer = ShonarGoldBright, - - secondary = Color(0xFFB99B5E), - onSecondary = GoldInk, - secondaryContainer = Color(0xFF332C1A), - onSecondaryContainer = Color(0xFFE8D9AE), - - tertiary = Color(0xFF8FA6B2), // cool counterpoint (info-ish) - onTertiary = Color(0xFF0E1A20), - tertiaryContainer = Color(0xFF2A3B45), - onTertiaryContainer = Color(0xFFCDE2EC), - - background = GraphiteBg, - onBackground = InkPrimary, - - surface = GraphiteSurface, - onSurface = InkPrimary, - surfaceVariant = Color(0xFF2E2A21), - onSurfaceVariant = InkSecondary, - surfaceContainer = GraphiteHigh, // cards lift off the bg - surfaceContainerHigh = Color(0xFF333333), - surfaceContainerHighest = Color(0xFF3A3A3A), - - outline = OutlineWarm, - outlineVariant = Color(0xFF2E2E2E), - - error = DangerWarm, - onError = Color(0xFF2B0A08), - errorContainer = Color(0xFF47201E), - onErrorContainer = Color(0xFFFFB4AF), -) - -/** Wrap the app content in the SHONAR dark-gold theme. */ -@Composable -fun ShonarTheme(content: @Composable () -> Unit) { - MaterialTheme(colorScheme = ShonarDarkScheme, content = content) -} diff --git a/desktop/app/src/main/kotlin/com/shonar/settings/DesktopSettingsStore.kt b/desktop/app/src/main/kotlin/com/shonar/settings/DesktopSettingsStore.kt deleted file mode 100644 index 7b67c49..0000000 --- a/desktop/app/src/main/kotlin/com/shonar/settings/DesktopSettingsStore.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.shonar.settings - -import java.io.File -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 -import org.json.JSONObject - -/** - * Desktop copy of the settings-store contract. The Android original - * (DataStore/Keystore implementations) cannot compile on JVM desktop, so - * this file provides the same fully-qualified names backed by plain files: - * [SettingsStore], [InMemorySettingsStore], and [FileSettingsStore]. - * - * Secrets note: desktop v1 keeps server tokens in a 0600 JSON file next to - * ordinary settings. Good enough for a local single-user app; a keyring - * backend can replace [FileSettingsStore] later without touching callers. - */ -interface SettingsStore { - suspend fun getString(key: String): String? - suspend fun putString(key: String, value: String) - suspend fun remove(key: String) - suspend fun keys(): Set - - /** Emits whenever any value changes (for reactive UI). */ - val changes: StateFlow -} - -/** In-memory store: used by unit tests. */ -class InMemorySettingsStore : SettingsStore { - private val map = mutableMapOf() - private val changeFlow = MutableStateFlow(0L) - override val changes: StateFlow = changeFlow.asStateFlow() - - override suspend fun getString(key: String): String? = map[key] - override suspend fun putString(key: String, value: String) { - map[key] = value - changeFlow.value += 1 - } - - override suspend fun remove(key: String) { - map.remove(key) - changeFlow.value += 1 - } - - override suspend fun keys(): Set = map.keys.toSet() -} - -/** JSON file store (0600). Keys map 1:1 to the JSON object members. */ -class FileSettingsStore(private val file: File) : SettingsStore { - private val mutex = Mutex() - private val changeFlow = MutableStateFlow(0L) - override val changes: StateFlow = changeFlow.asStateFlow() - - private fun readAll(): MutableMap { - if (!file.isFile) return mutableMapOf() - return runCatching { - val obj = JSONObject(file.readText()) - buildMap { - for (key in obj.keys()) put(key, obj.optString(key, "")) - }.toMutableMap() - }.getOrDefault(mutableMapOf()) - } - - private fun writeAll(map: Map) { - file.parentFile?.mkdirs() - file.writeText(JSONObject(map).toString()) - runCatching { - file.setReadable(false, false) - file.setReadable(true, true) - file.setWritable(false, false) - file.setWritable(true, true) - } - } - - override suspend fun getString(key: String): String? = mutex.withLock { - readAll()[key] - } - - override suspend fun putString(key: String, value: String) = mutex.withLock { - val map = readAll() - map[key] = value - writeAll(map) - changeFlow.value += 1 - } - - override suspend fun remove(key: String) { - mutex.withLock { - val map = readAll() - map.remove(key) - writeAll(map) - changeFlow.value += 1 - } - } - - override suspend fun keys(): Set = mutex.withLock { - readAll().keys.toSet() - } -} diff --git a/desktop/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt b/desktop/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt deleted file mode 100644 index d600e18..0000000 --- a/desktop/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.shonar.desktop - -import com.shonar.recording.JobInfo -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class JobProgressTest { - - private fun job(type: String, status: String, stage: String? = null, progress: Int? = null) = - JobInfo(jobType = type, status = status, stage = stage, progress = progress) - - @Test fun `idle when nothing running`() { - assertNull(DesktopState.jobProgress(emptyList())) - assertNull(DesktopState.jobProgress(listOf(job("transcribe", "succeeded")))) - } - - @Test fun `loading model has no percentage`() { - val p = DesktopState.jobProgress( - listOf(job("transcribe", "running", stage = "loading-model")), - ) - assertEquals("loading model…", p?.label) - assertNull(p?.fraction) - } - - @Test fun `transcribing shows backend percentage`() { - val p = DesktopState.jobProgress( - listOf(job("transcribe", "running", stage = "transcribing", progress = 42)), - ) - assertEquals("transcribing… 42%", p?.label) - assertEquals(0.42f, p!!.fraction!!, 0.0001f) - } - - @Test fun `transcribe failure falls back to indeterminate`() { - val p = DesktopState.jobProgress( - listOf(job("transcribe", "running", stage = "transcribing")), - ) - assertEquals("transcribing…", p?.label) - assertNull(p?.fraction) - } - - @Test fun `summarizing stage`() { - val p = DesktopState.jobProgress( - listOf(job("transcribe", "succeeded"), job("summarize", "running")), - ) - assertEquals("summarizing…", p?.label) - } -} diff --git a/desktop/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt b/desktop/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt deleted file mode 100644 index 6e7b673..0000000 --- a/desktop/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.shonar.desktop - -import java.io.File -import kotlin.io.path.createTempDirectory -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class LibraryQueueTest { - - private val exts = setOf("m4a", "wav", "mp3") - - @Test fun candidateOnlyForKnownAudioExt() { - val dir = createTempDirectory("lq").toFile() - val audio = File(dir, "a.m4a").apply { writeText("x") } - val note = File(dir, "notes.txt").apply { writeText("x") } - val hidden = File(dir, ".hidden.m4a").apply { writeText("x") } - assertTrue(LibraryQueue.isAudioCandidate(audio, exts)) - assertTrue(!LibraryQueue.isAudioCandidate(note, exts)) - assertTrue(!LibraryQueue.isAudioCandidate(hidden, exts)) - assertTrue(!LibraryQueue.isAudioCandidate(File(dir, "sub"), exts)) - } - - @Test fun diffDetectsAddedAndRemoved() { - val dir = createTempDirectory("lq").toFile() - val a = File(dir, "a.m4a").apply { writeText("x") } - val b = File(dir, "b.m4a").apply { writeText("x") } - val d = LibraryQueue.diff(setOf("gone.m4a"), listOf(a, b)) - assertEquals(listOf("a.m4a", "b.m4a"), d.added.map { it.name }) - assertEquals(setOf("gone.m4a"), d.removed) - } - - @Test fun autoQueueSkipsReportsInFlightAndFailed() { - val dir = createTempDirectory("lq").toFile() - val fresh = File(dir, "fresh.m4a").apply { writeText("x") } - val done = File(dir, "done.m4a").apply { writeText("x") } - File(dir, "done.transcript.md").writeText("# done") - val running = File(dir, "running.m4a").apply { writeText("x") } - val broken = File(dir, "broken.m4a").apply { writeText("x") } - - val picked = LibraryQueue.autoQueueCandidates( - entries = listOf(fresh, done, running, broken), - reportExists = { f -> - File(f.parentFile, "${f.nameWithoutExtension}.transcript.md").exists() - }, - inFlightOrDone = setOf("running.m4a"), - failed = setOf("broken.m4a"), - ) - assertEquals(listOf("fresh.m4a"), picked.map { it.name }) - } -} diff --git a/desktop/app/src/test/kotlin/com/shonar/desktop/RemoteMappingTest.kt b/desktop/app/src/test/kotlin/com/shonar/desktop/RemoteMappingTest.kt deleted file mode 100644 index 5c68501..0000000 --- a/desktop/app/src/test/kotlin/com/shonar/desktop/RemoteMappingTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.shonar.desktop - -import java.io.File -import kotlin.io.path.createTempDirectory -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class RemoteMappingTest { - private fun tmpAudio(name: String = "rec.m4a"): File { - val dir = createTempDirectory().toFile() - val f = File(dir, name) - f.writeText("audio") - return f - } - - @Test fun `round trip save and load`() { - val f = tmpAudio() - saveMapping(f, RemoteMapping("uuid-1", model = "small", title = "rec")) - val m = loadMapping(f) - assertEquals("uuid-1", m?.recordingId) - assertEquals("small", m?.model) - assertEquals("rec", m?.title) - } - - @Test fun `load returns null when absent`() { - assertNull(loadMapping(tmpAudio())) - } - - @Test fun `load returns null on corrupt json`() { - val f = tmpAudio() - mappingFile(f).writeText("{not json") - assertNull(loadMapping(f)) - } - - @Test fun `load returns null on blank id`() { - val f = tmpAudio() - mappingFile(f).writeText("""{"recording_id": ""}""") - assertNull(loadMapping(f)) - } - - @Test fun `move follows the audio rename`() { - val f = tmpAudio("old.m4a") - saveMapping(f, RemoteMapping("uuid-2")) - val renamed = File(f.parentFile, "new.m4a") - f.renameTo(renamed) - assertTrue(moveMapping(f, renamed)) - assertEquals("uuid-2", loadMapping(renamed)?.recordingId) - assertNull(loadMapping(f)) - } - - @Test fun `move is a no-op without an existing mapping`() { - val f = tmpAudio("a.m4a") - val g = File(f.parentFile, "b.m4a") - assertTrue(!moveMapping(f, g)) - assertNull(loadMapping(g)) - } -} diff --git a/desktop/app/src/test/kotlin/com/shonar/desktop/RenameTest.kt b/desktop/app/src/test/kotlin/com/shonar/desktop/RenameTest.kt deleted file mode 100644 index e12ae98..0000000 --- a/desktop/app/src/test/kotlin/com/shonar/desktop/RenameTest.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.shonar.desktop - -import java.io.File -import kotlin.io.path.createTempDirectory -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class RenameTest { - - private fun tmp(): File = createTempDirectory("rn").toFile() - - @Test fun `plain rename keeps extension`() { - val dir = tmp() - val f = File(dir, "Recording 20260803.m4a").apply { writeText("x") } - val target = LibraryQueue.renameTarget(f, "standup intro").getOrThrow() - assertEquals(File(dir, "standup intro.m4a"), target) - } - - @Test fun `typed extension is stripped`() { - val dir = tmp() - val f = File(dir, "a.m4a").apply { writeText("x") } - assertEquals(File(dir, "b.m4a"), LibraryQueue.renameTarget(f, "b.M4A").getOrThrow()) - assertEquals(File(dir, "b.m4a"), LibraryQueue.renameTarget(f, "b.m4a").getOrThrow()) - } - - @Test fun `rejects empty path and dot names`() { - val dir = tmp() - val f = File(dir, "a.m4a").apply { writeText("x") } - for (bad in listOf("", " ", "x/y", "x\\y", ".", "..", ".hidden")) { - val r = LibraryQueue.renameTarget(f, bad) - assertTrue("should reject '$bad'", r.isFailure) - } - } - - @Test fun `rejects collisions and no-op renames`() { - val dir = tmp() - val f = File(dir, "a.m4a").apply { writeText("x") } - File(dir, "taken.m4a").writeText("y") - assertTrue(LibraryQueue.renameTarget(f, "taken").isFailure) - assertTrue(LibraryQueue.renameTarget(f, "a").isFailure) - } -} diff --git a/desktop/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt b/desktop/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt deleted file mode 100644 index a3e837b..0000000 --- a/desktop/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.shonar.desktop - -import com.shonar.recording.SummaryData -import com.shonar.recording.TranscriptData -import com.shonar.recording.TranscriptSegment -import java.io.File -import java.nio.file.Files -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class ReportsTest { - - @Test fun renderTranscriptWithTimestamps() { - val t = TranscriptData( - version = 1, provider = "faster_whisper", model = "base", - text = "hello world", - segments = listOf( - TranscriptSegment(0.0, 1.5, "hello"), - TranscriptSegment(65.0, 67.0, "world"), - ), - ) - val md = renderReport("Evening notes", t, null) - assertTrue(md.startsWith("# Evening notes")) - assertTrue("[00:00:00] hello" in md) - assertTrue("[00:01:05] world" in md) - assertTrue("faster_whisper, v1" in md) - } - - @Test fun renderEmptyTranscript() { - val md = renderReport("x", null, null) - assertTrue("_No transcript available._" in md) - } - - @Test fun fmtTsShape() { - assertEquals("00:00:00", fmtTs(0.0)) - assertEquals("00:01:05", fmtTs(65.0)) - assertEquals("01:02:03", fmtTs(3723.0)) - } - - @Test fun reportFileName() { - val dir = Files.createTempDirectory("rep").toFile() - try { - val audio = File(dir, "chat.m4a") - assertEquals("chat.transcript.md", DesktopState.reportFile(audio).name) - } finally { - dir.deleteRecursively() - } - } - - @Test fun sharedParsersWorkOnDesktop() { - val raw = """{"version":2,"provider":"user","text":"hi","segments":[], - "edited_by_user":true}""" - val t = com.shonar.recording.parseTranscript(raw)!! - assertEquals(2, t.version) - assertTrue(t.editedByUser) - val summary = SummaryData(version = 1, content = mapOf("short" to "s")) - assertEquals("s", summary.short) - } -} diff --git a/desktop/desktop-dev.sh b/desktop/desktop-dev.sh deleted file mode 100755 index 5a8a153..0000000 --- a/desktop/desktop-dev.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# SHONAR desktop dev loop: runs the app and auto-restarts it when any -# Kotlin/Gradle source changes (inotifywait). Engine runs in dev mode -# (SHONAR_DEV=1 -> uvicorn --reload), so backend edits apply live too. -# -# Stop with Ctrl-C (or pkill -f desktop-dev.sh). -set -uo pipefail -cd "$(dirname "$0")" - -export SHONAR_DEV=1 -export DISPLAY="${DISPLAY:-:0}" - -FLAG="$(pwd)/.dev-restart-flag" - -# Watch targets resolved to absolute paths, non-existent dirs skipped at -# start but retried each cycle (inotifywait -r tolerates a dir appearing). -watch_targets() { - local t=() - for d in "$PWD/app/src/main/kotlin" \ - "$PWD/../android/app/src/main/java/com/shonar" \ - "$PWD/../backend/shonar"; do - [ -d "$d" ] && t+=("$d") - done - printf '%s\n' "${t[@]}" -} - -pkill -f "com.shonar.desktop.MainKt" 2>/dev/null || true -rm -f "$FLAG" -sleep 1 - -( - while true; do - mapfile -t TARGETS < <(watch_targets) - [ ${#TARGETS[@]} -eq 0 ] && { sleep 5; continue; } - if inotifywait -qq -e close_write,move --include '\.kt$|\.kts$' \ - -r "${TARGETS[@]}"; then - echo "[dev] source change -> restarting app…" - touch "$FLAG" - pkill -f "com.shonar.desktop.MainKt" 2>/dev/null || true - sleep 2 # debounce bursts (editor writes can fire several events) - else - sleep 5 # targets vanished; retry with a fresh list - fi - done -) & -WATCHER=$! -trap 'kill $WATCHER 2>/dev/null || true; rm -f "$FLAG"' EXIT - -while true; do - rm -f "$FLAG" - ./gradlew run --offline - if [ -f "$FLAG" ]; then - continue # killed by a source edit: rebuild now - fi - rm -f "$FLAG" - # App closed cleanly or build failed — wait for the next edit. - mapfile -t TARGETS < <(watch_targets) - [ ${#TARGETS[@]} -eq 0 ] && { sleep 5; continue; } - inotifywait -qq -e close_write,move --include '\.kt$|\.kts$' -r "${TARGETS[@]}" || sleep 5 -done diff --git a/desktop/gradle/wrapper/gradle-wrapper.jar b/desktop/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 1b33c55..0000000 Binary files a/desktop/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/desktop/gradle/wrapper/gradle-wrapper.properties b/desktop/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index e2847c8..0000000 --- a/desktop/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/desktop/gradlew b/desktop/gradlew deleted file mode 100755 index 23d15a9..0000000 --- a/desktop/gradlew +++ /dev/null @@ -1,251 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH="\\\"\\\"" - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/desktop/launch-desktop.sh b/desktop/launch-desktop.sh deleted file mode 100755 index 1e74196..0000000 --- a/desktop/launch-desktop.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -# Launch the SHONAR desktop app (packaged binary, no Gradle needed). -set -euo pipefail -cd "$(dirname "$0")" -BIN="app/build/compose/binaries/main/app/shonar-desktop/bin/shonar-desktop" -if [ ! -x "$BIN" ]; then - echo "Desktop build not found. Run: ./gradlew :app:createDistributable" >&2 - exit 1 -fi -export DISPLAY="${DISPLAY:-:0}" -exec "$BIN" diff --git a/desktop/settings.gradle.kts b/desktop/settings.gradle.kts deleted file mode 100644 index 1add82b..0000000 --- a/desktop/settings.gradle.kts +++ /dev/null @@ -1,17 +0,0 @@ -pluginManagement { - repositories { - mavenCentral() - gradlePluginPortal() - google() - } -} - -dependencyResolutionManagement { - repositories { - mavenCentral() - google() - } -} - -rootProject.name = "shonar-desktop" -include(":app") diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md deleted file mode 100644 index 9b5f0d8..0000000 --- a/docs/ROADMAP.md +++ /dev/null @@ -1,36 +0,0 @@ -# Roadmap & feature status - -Every checked item is implemented, tested, and present at the current HEAD. -Anything not listed is aspirational. This table is maintained by hand and -updated in the same commit as the work it describes. - -| Milestone | Scope | Status | -|---|---|---| -| M0 | Repo scaffold, license, docs, Docker dev stack, `/healthz` `/readyz`, Alembic schema | done | -| M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done | -| M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done | -| M3 | Android: server URL config, login, token persistence + auto-refresh | implemented via P3 `CustomShonarProvider` (Keystore token store, transparent refresh, login UI in provider selection); on-device verification pending | -| S-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) — 19 unit tests green | done | -| ~~HA-1/HA-2~~ | Home Assistant integration (client, repository, devices screen, e2e scripts) | **DEFERRED — out of initial product scope; preserved under `deferred/home-assistant/` and branch `deferred/home-assistant`** | -| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | implemented; on-device verification pending | -| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | done — `SyncWorker` + `SyncScheduler` (15-min periodic, one-shot on startup/constraint change, backoff on retry), Context-free `SyncDrain` engine (drains QUEUED + due ERROR incl. P7 leftovers, exponential backoff 1m–1h, cancel reverts to QUEUED), pause as resting state; full suite 167 green. On-device behaviour pending | -| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | partial; local list, playback, and delete implemented | -| M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | done — provider protocols + 4 adapters (faster-whisper lazy optional), versioned transcripts/summaries (user edits win), arq worker (`run_transcribe`/`run_summarize` + startup/5-min sweep, max 3 tries), transcript/summary/jobs endpoints, `none` means skipped; 50 backend tests green, ruff clean | -| M8 | Android: details screen — transcript synced to playback, summary, action items, editing | done — `detail/{id}` route (transcript/summary/status tabs, tap-to-seek, speed control, edit dialogs, title rename) on `CustomShonarProvider` AI methods (fetch/update transcript+summary, jobs, PATCH title/notes) against new backend `PUT transcript/summary` user-edit endpoints (versioned, pipeline won't clobber); pure `AiContent` parsing/sync mapping; 19 new Android tests + 5 backend edit tests, full suites green (186 Android, 55 backend), `assembleDebug` clean. Local-only/unsynced rows get honest empty states; on-device verification pending | -| T1 | Backend transcription model support — registry (tiny/base/small/medium/large-v3), global default (`base`, runtime-editable), per-recording overrides, `GET /api/v1/models`, worker uses saved model, job stage/progress | done — migration (`recordings`+`upload_sessions.transcription_model`, `processing_jobs.stage/progress`, `app_settings`); `PUT /models/default` (future rows only); finalize/session override (finalize wins); faster-whisper model cache + fail-fast unavailable errors; 10 new tests, backend suite 64 green + ruff clean | -| M9 | Backend: full-text search endpoints + filters, exports (audio/txt/md/zip), deletion sweep | done — `GET /search` (Postgres tsvector via migration fts0000000001, SQLite LIKE fallback for the desktop engine; scopes title/notes/transcript/summary/tag, ranked + snippets); list filters on `GET /recordings` (tag/status/from/to); `GET /recordings/{id}/export?fmt=audio|txt|md|zip` (inline artifacts, ExportJob+Asset audit trail); retention sweep (`services/retention.py`, 30-day grace `SHONAR_RETENTION_GRACE_DAYS`, wired into arq cron daily + inline-queue timer); 9 new tests, backend suite 78 green + ruff clean | -| D-1 | Desktop: in-place reprocess (no re-upload) — `.shonar.json` sidecar maps library file → server recording id; Re-transcribe/Re-summarize hit `POST /reprocess` (model override switches persist server-side); rename carries mapping; deleted-remote mapping self-heals to re-upload | done — provider `reprocess()`, endpoint `?model=` param, 2 backend + 6 desktop tests, suites green (69 backend, 23 desktop) | -| M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO | - -## Explicit TODOs (not yet implemented) - -- **Speaker diarization**: adapter interface planned; next step is a - `DiarizationProvider` protocol + pyannote-audio adapter behind - `SHONAR_DIARIZATION_PROVIDER`. -- **At-rest encryption**: design in `docs/security.md`; implementation not - started. Files are stored unencrypted unless you encrypt the volume. -- **Meilisearch/OpenSearch search backend**: only Postgres FTS is planned to - ship first, behind a `SearchBackend` protocol. -- ~~**Account purge sweep**~~: done in M9 — `services/retention.py` - hard-deletes expired soft-deletes (recordings + accounts + files); - runs daily via arq cron and the desktop inline-queue timer. diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 860eeb7..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,64 +0,0 @@ -# Architecture - -``` - Android app Your server -┌───────────────────┐ ┌──────────────────────────────────┐ -│ Compose UI │ │ nginx (TLS) │ -│ ViewModels │ HTTPS│ └─ FastAPI app ──► PostgreSQL │ -│ Room (offline DB) │◄─────┤ │ (records, │ -│ WorkManager │ │ │ FTS) │ -│ └ upload workers │ │ Redis◄┘ │ -│ MediaRecorder │ │ └─ arq worker ──► storage/ │ -│ Foreground service│ │ │ (originals + │ -│ Media3 player │ │ │ derivatives) │ -└───────────────────┘ │ AI adapters (optional): │ - │ STT: whisper_http|faster-whisper│ - │ LLM: ollama|openai_compat|none │ - └──────────────────────────────────┘ -``` - -## Principles - -1. **Offline-first.** The Android app is fully usable with no server: - recordings live in Room + app storage; sync is a background concern. -2. **Originals are sacred.** Uploads are stored immutably. Every processing - step writes a new derivative asset. A failed job can never corrupt input. -3. **Idempotent pipeline.** Jobs are keyed (recording, job_type); reruns - overwrite derived outputs unless the user edited them; client-supplied - `client_recording_id` makes retried uploads update-not-duplicate. -4. **Provider independence.** Transcription and LLM live behind Protocols; - adapters are selected by env config. `none` is a first-class provider. -5. **UUID everywhere.** No integer IDs, no filesystem paths, no internals in - API payloads. - -## Processing pipeline - -``` -upload finalize ──► [normalize (optional ffmpeg)] ──► transcribe - │ - transcript (versioned, timestamped segments) - │ - summarize + extract - │ - short / detailed / key_points / decisions / action_items / questions -``` - -Each arrow is one `processing_jobs` row: queued → running → succeeded/failed, -with attempts and error text. Recording status aggregates the steps -(`uploaded → queued → processing → completed | failed | ai_disabled`). - -## Sync protocol (Android ⇄ server) - -- Room table holds `sync_state` (LOCAL_ONLY, PENDING_UPLOAD, UPLOADING, - SYNCED, ERROR) + `client_recording_id` (UUID, stable across retries). -- WorkManager `UploadWorker`: constraints (Wi-Fi/charging) from user prefs; - exponential backoff retries; manual pause cancels work, state preserved. -- Upload = create session → PUT chunks (skip ones the session already has) → - finalize with checksum. Resumable at chunk granularity. - -## Why adapters-as-protocols - -The AI layer's only job is: audio in → text out; text in → structured JSON -out. Protocols (`sonar/ai/interfaces.py`) keep the pipeline testable with a -`StubProvider` and make new providers (e.g. whisperX, llama.cpp) a single -file + one env value. diff --git a/docs/recording-consent.md b/docs/recording-consent.md index 0da3c45..609799b 100644 --- a/docs/recording-consent.md +++ b/docs/recording-consent.md @@ -23,8 +23,9 @@ following the law where you are. - Provides an always-visible stop control on the recording screen and in the notification. - Has no remote-trigger recording capability of any kind. -- Collects no telemetry and sends no data anywhere except the server URL you - configure. +- Collects no telemetry and sends no data anywhere. There is no server, + no account, no network permission — recordings never leave the phone + except through file sync you set up yourself. ## Recommended practice diff --git a/docs/security.md b/docs/security.md deleted file mode 100644 index b2b695a..0000000 --- a/docs/security.md +++ /dev/null @@ -1,81 +0,0 @@ -# Security model (honest version) - -This document describes what S.H.O.N.A.R. actually implements, what it does -not, and the threat model assumptions. No claims beyond the code. - -## Implemented - -**Authentication** -- Passwords hashed with Argon2id (`argon2-cffi` defaults). -- Access tokens: short-lived (15 min) JWT HS256, `typ` claim prevents - cross-type use. -- Refresh tokens: opaque 384-bit values; only SHA-256 hashes stored; rotated - on every use; token families with **reuse detection** (presenting a spent - token revokes the entire family — commits before the 401 so the revocation - survives the failed request). -- Logout and device revocation revoke refresh families server-side. -- Auth endpoints rate-limited per client IP (slowapi; `SHONAR_RATE_LIMIT_AUTH`). - -**Authorization** -- Every recording/transcript/summary/tag/upload-session endpoint filters by - `user_id` server-side; ownership failures return 404 (no existence leaks). -- User enumeration is mitigated on login (identical error for unknown email - vs wrong password). - -**Uploads & files** -- Upload size caps (`SHONAR_MAX_UPLOAD_BYTES`, per-chunk caps). -- MIME allow-list (`SHONAR_ALLOWED_AUDIO_MIME_TYPES`) + magic-byte sniffing - on finalize (ftyp/RIFF/OggS/ID3). -- Storage keys are server-generated UUID paths; `validate_storage_key()` - rejects absolute paths, `..`, and any character outside `[a-z0-9-./]`; - LocalStorage additionally verifies the resolved path stays under the root. -- Originals are immutable: processing writes separate derivative assets. -- File downloads go through authenticated, ownership-checked endpoints; no - static file route exposes storage. - -**API surface** -- Pydantic input validation on every endpoint. -- Global exception handler returns generic `Internal server error`; details - are logged server-side only. -- `/api/v1/system/status` discloses AI provider state but never secrets. - -**Privacy** -- No telemetry, no analytics, no crash reporting, no update pings — in the - app or the server. -- No network calls to AI providers unless configured; external usage is - disclosed via `external_ai_in_use` in system status and shown in the app's - Settings screen. -- Location metadata is stored only when the user explicitly enables it - (`location_storage_enabled`, default off, enforced server-side). - -## NOT implemented (do not assume otherwise) - -- **At-rest encryption: NOT implemented.** Files on disk and database rows - are plaintext. Documented design if you want it: - 1. Envelope encryption: per-user data key, AES-256-GCM; - 2. DEKs wrapped by a KEK derived from a passphrase entered at app unlock - (Argon2id), never stored server-side; - 3. Chunk-level encryption during upload so the server stores ciphertext; - 4. Trade-offs: breaks server-side transcription unless the worker holds - keys — choose client-side-only (private but no AI) vs worker-side - (AI works, worker is trusted). - Until this ships, **encrypt the volume** (LUKS/zfs-encrypt) or the bucket - (SSE) at the infrastructure layer. -- **TLS termination:** the app enforces HTTPS for non-localhost servers, but - the backend container serves plain HTTP — you must terminate TLS at a - reverse proxy (example in `deploy/nginx.conf`). -- **OIDC / 2FA:** not implemented. -- **Account hard purge:** deletion applies a 30-day grace marker; the - scheduled purge job is TODO (see ROADMAP). Rows and files are retained - until it lands — do not rely on deletion as immediate erasure. - -## Deployment assumptions - -- Single-instance API + worker behind a trusted reverse proxy. -- Postgres and Redis are not exposed publicly (compose binds 127.0.0.1). -- `SHONAR_SECRET_KEY` is secret and ≥32 chars (startup warns otherwise). - -> Accuracy note: the items under “Implemented → Uploads & files” and the -> HTTPS enforcement in the app ship with milestones M2/M3 and are described -> here as the design they implement; check `docs/ROADMAP.md` for what is -> live at HEAD today. diff --git a/docs/server-providers.md b/docs/server-providers.md deleted file mode 100644 index f3249f0..0000000 --- a/docs/server-providers.md +++ /dev/null @@ -1,198 +0,0 @@ -# Server provider architecture - -SHONAR is an open-source mobile AI app for recording, transcribing, -summarizing, and searching voice notes and conversations. Where your data -lives is a **provider** decision, hard-coded nowhere in the app. - -**Default provider: Nextcloud.** Alternatives: Start9, Umbrel, custom SHONAR -server, or local-only storage. Home Assistant is explicitly out of initial -scope (see `deferred/home-assistant/README.md`). - ---- - -## 1. Provider interface - -One Kotlin interface (`com.shonar.provider`) — nothing else in the app talks -to a server directly: - -```kotlin -interface ShonarProvider { - val descriptor: ProviderDescriptor // id, display name, capabilities - - // lifecycle - suspend fun probe(baseUrl: ServerUrl): ProbeResult // server reachable & is a SHONAR service? - suspend fun connect(credential: ProviderCredential): Unit // validate + persist (secure store) - suspend fun reconnect(): AuthState - suspend fun disconnect(revokeOnServer: Boolean): Unit - suspend fun deleteAccountAndData(): Unit - - // storage (opaque keys — provider maps to its own layout) - suspend fun upload(recording: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef - suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit): Unit - suspend fun delete(ref: RemoteRef): Unit - suspend fun list(cursor: PageCursor?): Page - - // syncable sidecars (transcript/summary JSON, metadata) - suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray): Unit - suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? - - // status shown to the user - suspend fun storageLocationSummary(): StorageLocation // "Where is my data?" screen - val authState: StateFlow // CONNECTED / EXPIRED / REVOKED / OFFLINE -} -``` - -`ProviderRegistry` maps `ProviderDescriptor.id -> factory`. The UI, sync -engine, and database reference **providers by id only**. `LocalOnlyProvider` -is a first-class implementation (no network at all), so the app never has a -"no provider" special case. - -## 2. Data model (Room) - -- `recording(id UUID, title, createdAt, durationMs, localFile, mime, tags, - notes, originProviderId, remoteRef, syncState, downloadState)` -- `syncState`: `LOCAL_ONLY → QUEUED → UPLOADING → UPLOADED → SYNCED`, plus - `ERROR(retryAt, reasonCode)`. Pausing/canceling is a state, not a job kill. -- `provider(id, descriptorId, baseUrl, authKind, credentialAlias, - certPin?, lastSeenAt, enabled)` — one row per configured provider; at most - one `enabled` (the active one). Credential **alias** only; secrets live in - Android Keystore-backed storage, never in Room. -- `sidecar(recordingId, kind ∈ {TRANSCRIPT, SUMMARY, ACTION_ITEMS, KEYWORDS, - NOTES}, contentHash, updatedAt)` — synced independently of audio. -- Switching providers never touches `recording.localFile`; `remoteRef` + - `originProviderId` change only, so **provider switch is lossless**. - -## 3. Authentication flow - -Safest-compatible-first ladder, decided per probe: - -1. **OAuth 2 / OIDC** (Nextcloud supports it on many hosts) — authorization - code + PKCE via Custom Tabs. App never sees the password. -2. **Nextcloud "Login flow v2"** (`/index.php/login/v2`): server-owned - browser consent screen, returns app password + endpoint. This is the - default for vanilla Nextcloud and is the recommended path. -3. **API token paste** (user already created an app password) — fallback. -4. **Custom SHONAR server**: SHONAR's own OAuth2-compatible API (backend - already has rotating refresh tokens, M1). - -Rules: the user's *normal* account password is never requested, typed into -an app screen, or stored. Tokens go to Keystore-backed storage. Token -expiry → `EXPIRED` state with a one-tap re-auth that reuses the same flow. -`disconnect(revokeOnServer=true)` calls the provider's revoke endpoint (e.g. -Nextcloud `DELETE /index.php/core/auth/` via the app-password self-delete -endpoint) before clearing local state. Account deletion goes through the -provider API first, local wipe second. - -## 4. TLS policy - -- HTTPS required for any non-private host (validated in `ServerUrl`: scheme, - host, no credentials in URL, no path traversal; private ranges per RFC - 1918/loopback allowed for LAN servers). -- **Never** a global cert bypass. Self-signed LAN certs handled as explicit - **trust-on-first-use**: connection attempt fails → user sees the cert - fingerprint + hostname and must approve → pin stored (SPKI SHA-256) in - secure storage → OkHttp pinned to that cert for that host only. -- Cleartext HTTP permitted only for private/LAN hosts the user typed, and - the UI shows a visible warning banner on `http://` servers. -- Logging: OkHttp logging interceptor is **off by default** and, when a - debug setting is enabled, redacts `Authorization`, `Cookie`, tokens, and - bodies. Audio bytes and transcripts are never logged in any mode. - -## 5. Provider selection UX (onboarding + settings) - -1. First launch → consent notice (already shipped) → **Choose where your - recordings live**: - - **Nextcloud** *(default, marked Recommended)* — URL → probe → pick auth - method → browser consent → connected. - - **Start9** — URL → probe → the app discovers which SHONAR-compatible - service is running (Server APIs are startOS app-specific; there is no - universal Start9 storage API) → the user **identifies/confirms the - service** from the probe result → that service's provider is bound. - - **Umbrel** — same pattern as Start9 (Umbrel apps each expose their own - API; probe + explicit service selection, never a universal assumption). - - **Custom server** — URL of a SHONAR backend (`/api/v1/healthz` + - `/api/v1/provider-info` probe identifies it as SHONAR-compatible). - - **Local only** — nothing leaves the phone; sync features hidden, - everything else fully functional. -2. The chosen provider is shown persistently on Home ("Stored: Nextcloud at - cloud.example.com"), and Settings → Server shows the full - **StorageLocation** screen: which provider, URL, what is synced vs local, - account, disconnect, delete account & data. -3. Switching provider: re-run selection; existing local recordings queue - against the new provider; old remote refs are kept in history until the - user chooses to migrate or forget them. - -## 6. Nextcloud provider (implement first) - -- **APIs** (official, supported): WebDAV `PROPFIND`/`GET`/`PUT`/`MOVE`/`DELETE` - under `/remote.php/dav/files/{user}/SHONAR/…`; login flow v2; - `OCS /ocs/v2.php/cloud/user` for identity/quota. No proprietary calls. -- **Layout**: `SHONAR/audio/{yyyy}/{recording-uuid}.m4a`, - `SHONAR/sidecars/{uuid}/transcript.json` etc. Remote ref = the DAV path - plus etag; originals are immutable (versions handled by Nextcloud's own - versioning, never overwritten by processing artifacts). -- **Chunked/resumable**: Nextcloud chunked-upload protocol - (`/remote.php/dav/uploads/…`) for large recordings; WorkManager drives it - with the same pause/resume/retry semantics already built for the SHONAR - backend (M2 upload sessions map 1:1 onto chunked DAV uploads). -- Probe: `GET /.well-known/webfinger` + `PROPFIND` depth 0 on the SHONAR - folder to verify endpoint + auth before first write. -- Auth per §3 (login flow v2 default). Quota surfaced in the StorageLocation - screen from OCS. - -## 7. Start9 / Umbrel / custom adapters - -> Status: P6a shipped the generic path — Start9/Umbrel cards take a service -> URL and auto-detect Nextcloud vs SHONAR, handing off to those providers. -> The platform-RPC discovery below, multi-service picking, and Tor onion -> access (Orbot SOCKS) are deferred to P6b. - -- **Start9 & Umbrel are platforms, not APIs.** The adapter pattern is - "platform probe + service binding": a `PlatformProbe` (Start9: Server API - over its RPC; Umbrel: its app manifest endpoints, where available) - enumerates *running apps*, and each app that embeds a **SHONAR-compatible - service** (the same `/api/v1/provider-info` handshake) is offered to the - user for explicit selection. Once selected, that binding reuses the - custom-SHONAR-server provider. If no SHONAR-compatible service is found, - the UI says so plainly and offers local-only or custom URL — we do **not** - invent universal storage semantics for these platforms. -- **Custom server** = the SHONAR FastAPI backend in this repo (already - M0–M2): auth, chunked uploads, recordings CRUD, FTS. `provider-info` - returns `{kind: "shonar", version, capabilities[]}` so the app can feature - -gate (e.g. server-side transcription available?). -- All three share the `ShonarProvider` contract; only discovery differs. - -## 8. Test strategy - -- **Provider contract suite** (shared, data-driven): every implementation - (`LocalOnly`, `CustomShonar`, `Nextcloud`) runs the same test list against - MockWebServer / fake FS — upload/resume/pause/retry, sidecars, delete, - auth-state transitions (CONNECTED→EXPIRED→re-auth→CONNECTED), revocation, - cert-pin TOFU, URL validation rejects (cleartext public host, credentials - in URL, traversal). -- **Nextcloud-specific**: login-flow v2 handshake, DAV path layout, chunked - upload protocol, quota parsing — against recorded MockWebServer fixtures. -- **Leak tests**: assert no credential, token, audio byte, or transcript - string ever appears in app logs (logcat capture under debug setting - enabled + redaction assertions) and in Room tables. -- **Local-only invariant**: device in airplane mode → full record/playback/ - search functionality passes. -- Backend keeps its pytest suite (26 green as of M2). Android unit tests - keep 19 settings tests green; provider tests added per phase. - -## 9. Phased plan - -| Phase | Deliverable | Verify | -|---|---|---| -| P0 (done) | HA isolated to `deferred/` + branch; removed from build, onboarding, defaults, tests | build + unit tests green | -| P1 | `provider/` module: `ShonarProvider` interface, `ServerUrl` validation, `LocalOnlyProvider`, `ProviderRegistry` | contract suite (local-only) | -| P2 | Provider-selection onboarding screen + StorageLocation screen wired to registry | on-device | -| P3 (done) | `CustomShonarProvider` against this repo's backend (auth M1 + uploads M2), WorkManager sync states | contract suite (10 shared + 12 custom) + 9 auth tests vs MockWebServer fixtures mirroring `schemas_*.py`; backend pytest 28 green on live Postgres; `assembleDebug` clean | -| P4 (done) | `NextcloudProvider`: login flow v2, DAV upload/download/delete, chunking — plus `FolderSyncProvider` (sync-folder for Syncthing etc., requested alongside) | contract suite (10 shared + 6 custom) + 6 auth + 4 protocol tests vs MockWebServer fake; DAV/chunking-v2 protocol verified live against Nextcloud 34 (MKCOL/PUT/MOVE-assemble/PROPFIND/OCS/login-v2 via curl); full suite 120 green, `assembleDebug` clean. On-device browser-approval tap-through still pending | -| P5 (done) | TLS TOFU pinning + redaction/logging + leak tests | TOFU trust manager (system-first, per-host DER pins in secure store, explicit approval UI with fingerprint + issuer + validity) + redacting logger gated by `log_http_bodies` (transcript/sidecar bodies never log, in any mode) + HeldCertificate TLS fixtures; full suite 138 green, `assembleDebug` clean. Self-signed hosts now connect after one approval; hostname verification stays strict | -| P6a (done) | Generic hosted setup: Start9/Umbrel cards take a service URL, auto-detect Nextcloud vs SHONAR, hand off to the existing provider flows; the platform is an entry path, the persisted provider is the protocol | routing matrix unit tests (`routeHosted`); full suite + `assembleDebug` | -| P6b (deferred) | Platform RPC auto-discovery (Start9 Server API, Umbrel manifests), multi-service picker, Tor onion access via Orbot SOCKS | probe fakes + on-device, when needed | -| P7 (done) | Provider switching w/ migration prompts, account deletion, revocation | Room v2 provider slot per recording (origin + remote ref + sync state, honest migration) + "Where is my data?" screen (summary, reconnect, disconnect+revoke, delete account) + foreground migrate-now uploader (bounded: cancellable, one attempt/file, upload-only) + switch-time prompt (upload / keep / forget links); migration matrix + slots + converter tests, full suite 156 green, `assembleDebug` clean. Background/scheduled sync stays M5; pull-down and old-remote wipe are explicit non-goals (a future wipe is safe: everything lives under `SHONAR/` prefixes) | - -Recording engine (M4) and playback (M6) proceed independently on top of the -same Room model; provider work is orthogonal. diff --git a/scripts/dev_bootstrap.sh b/scripts/dev_bootstrap.sh deleted file mode 100755 index 72d9d5a..0000000 --- a/scripts/dev_bootstrap.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# One-shot local dev bootstrap: infra, deps, migrations, API. -set -euo pipefail -cd "$(dirname "$0")/.." - -echo "==> starting postgres + redis" -docker compose -f deploy/docker-compose.dev.yml up -d - -echo "==> backend deps" -cd backend -if command -v uv >/dev/null 2>&1; then - uv venv .venv 2>/dev/null || true - uv pip install -e ".[dev]" -else - python3 -m venv .venv - .venv/bin/python -m pip install -e ".[dev]" -fi - -echo "==> test database" -docker exec shonar-dev-postgres-1 psql -U shonar -d postgres -tc \ - "SELECT 1 FROM pg_database WHERE datname='shonar_test'" | grep -q 1 || \ - docker exec shonar-dev-postgres-1 psql -U shonar -d postgres -c "CREATE DATABASE shonar_test" - -echo "==> migrations" -export SHONAR_SECRET_KEY="${SHONAR_SECRET_KEY:-dev-only-0123456789abcdef0123456789abcdef}" -.venv/bin/alembic upgrade head - -echo -echo "ready. start the API with:" -echo " cd backend && .venv/bin/uvicorn shonar.main:app --reload --port 8000" diff --git a/scripts/gen_openapi.sh b/scripts/gen_openapi.sh deleted file mode 100755 index e2172f4..0000000 --- a/scripts/gen_openapi.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -# Regenerate the OpenAPI contract shared with the Android client. -# Run from repo root after backend API changes; commit the result. -set -euo pipefail -cd "$(dirname "$0")/.." -backend/.venv/bin/python - <<'PY' -import json -from shonar.main import app -spec = app.openapi() -with open("shared/openapi.json", "w") as f: - json.dump(spec, f, indent=2, sort_keys=True) - f.write("\n") -print(f"wrote shared/openapi.json ({len(spec['paths'])} paths)") -PY diff --git a/scripts/transcribe_folder.py b/scripts/transcribe_folder.py deleted file mode 100644 index e4bf704..0000000 --- a/scripts/transcribe_folder.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -"""Transcribe a folder of audio files with the local SHONAR stack. - -Usage: - python3 scripts/transcribe_folder.py /home/avi/Music/Recordings [--api URL] [--email E] [--password P] - -For each recognised audio file it: registers/logs in, uploads chunked, -finalizes, waits for the worker's transcription job, then writes a -Plaud-style `{stem}.transcript.md` (summary section included only when an -LLM provider is configured server-side) next to the audio. Files that -already have a report are skipped unless --redo is passed. - -Needs: API + worker running against the same DB, e.g. - cd backend && SHONAR_SECRET_KEY=... SHONAR_TRANSCRIPTION_PROVIDER=faster_whisper \ - SHONAR_TRANSCRIPTION_MODEL=base .venv/bin/uvicorn shonar.main:app --port 8000 - cd backend && .venv/bin/arq shonar.worker.WorkerSettings -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import sys -import time -import urllib.error -import urllib.request -from pathlib import Path - -CHUNK = 8 * 1024 * 1024 -MIME_BY_SUFFIX = { - ".m4a": "audio/mp4", - ".mp4": "audio/mp4", - ".aac": "audio/aac", - ".wav": "audio/wav", - ".ogg": "audio/ogg", - ".opus": "audio/ogg", - ".webm": "audio/webm", - ".mp3": "audio/mpeg", -} - - -class Api: - def __init__(self, base: str, token: str = ""): - self.base = base.rstrip("/") - self.token = token - self.login_fn = None # set by main: () -> fresh access token - - def _req(self, method: str, path: str, body: bytes | None = None, - ctype: str = "application/json", extra: dict | None = None, - _retried: bool = False): - req = urllib.request.Request(self.base + path, data=body, method=method) - if body is not None: - req.add_header("Content-Type", ctype) - if self.token: - req.add_header("Authorization", f"Bearer {self.token}") - for k, v in (extra or {}).items(): - req.add_header(k, v) - try: - with urllib.request.urlopen(req, timeout=300) as r: - raw = r.read() - return r.status, json.loads(raw) if raw else None - except urllib.error.HTTPError as e: - if e.code == 401 and self.login_fn is not None and not _retried: - self.token = self.login_fn() # access tokens expire; log back in - return self._req(method, path, body, ctype, extra, _retried=True) - try: - detail = e.read().decode()[:300] - except Exception: - detail = "" - raise RuntimeError(f"{method} {path} -> HTTP {e.code} {detail}") from None - - def post_json(self, path, obj): - return self._req("POST", path, json.dumps(obj).encode()) - - def get(self, path): - return self._req("GET", path) - - -def ensure_login(api_base: str, email: str, password: str) -> str: - api = Api(api_base) - try: - status, body = api.post_json("/api/v1/auth/register", - {"email": email, "password": password}) - assert status == 201 - return body["access_token"] - except RuntimeError as e: - if "HTTP 409" not in str(e) and "HTTP 400" not in str(e): - raise - status, body = api.post_json("/api/v1/auth/login", - {"email": email, "password": password, - "device_name": "folder-script", "platform": "desktop"}) - assert status == 200, body - return body["access_token"] - - -def upload_file(api: Api, path: Path) -> str: - size = path.stat().st_size - mime = MIME_BY_SUFFIX[path.suffix.lower()] - client_id = "folder-" + hashlib.sha1(str(path).encode()).hexdigest()[:40] - status, sess = api.post_json("/api/v1/uploads", { - "declared_mime_type": mime, "declared_size_bytes": size, - "title": path.stem, "client_recording_id": client_id, - }) - assert status == 201, sess - sid, chunk_size = sess["id"], sess["chunk_size_bytes"] - data = path.read_bytes() - n = (size + chunk_size - 1) // chunk_size - for i in range(n): - part = data[i * chunk_size:(i + 1) * chunk_size] - digest = hashlib.sha256(part).hexdigest() - api._req("PUT", f"/api/v1/uploads/{sid}/chunks/{i}", part, - ctype="application/octet-stream", extra={"X-Chunk-Sha256": digest}) - status, rec = api.post_json(f"/api/v1/uploads/{sid}/finalize", - {"duration_seconds": 0.0}) - assert status == 201, rec - return rec["id"] - - -def wait_transcribed(api: Api, rid: str, timeout_s: int) -> str: - """Poll jobs until transcribe reaches a terminal state; return recording status.""" - deadline = time.time() + timeout_s - while time.time() < deadline: - status, jobs = api.get(f"/api/v1/recordings/{rid}/jobs") - by_type = {j["job_type"]: j["status"] for j in jobs} if isinstance(jobs, list) else {} - t = by_type.get("transcribe") - if t in ("succeeded", "failed", "skipped"): - status, rec = api.get(f"/api/v1/recordings/{rid}") - return rec["processing_status"] - time.sleep(5) - return "timeout" - - -def fetch_optional(api: Api, path: str): - try: - status, body = api.get(path) - return body if status == 200 else None - except RuntimeError as e: - if "HTTP 404" in str(e): - return None - raise - - -def fmt_ts(sec: float) -> str: - sec = max(0, int(sec)) - return f"{sec // 3600:02d}:{(sec % 3600) // 60:02d}:{sec % 60:02d}" - - -def render_report(title: str, transcript: dict | None, summary: dict | None) -> str: - out = [f"# {title}", ""] - if summary and isinstance(summary.get("content"), dict): - c = summary["content"] - if c.get("short"): - out += ["## Summary", "", str(c["short"]), ""] - for key in ("key_points", "decisions", "action_items", "questions"): - items = c.get(key) or [] - if items: - out += ["## " + key.replace("_", " ").title(), ""] - out += [f"- {i}" for i in items] + [""] - if c.get("detailed"): - out += ["## Details", "", str(c["detailed"]), ""] - if transcript: - out += ["## Transcript", ""] - segs = transcript.get("segments") or [] - if segs: - for s in segs: - try: - out.append(f"[{fmt_ts(float(s.get('start', 0)))}] {s.get('text', '')}") - except (TypeError, ValueError): - continue - out.append("") - else: - out += [transcript.get("text", ""), ""] - prov = transcript.get("provider") or "?" - out.append(f"*Transcribed with {prov}, v{transcript.get('version', '?')}.*") - else: - out += ["## Transcript", "", "_No transcript available._", ""] - return "\n".join(out) - - -def main() -> int: - ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - ap.add_argument("folder", help="directory of audio files") - ap.add_argument("--api", default="http://localhost:8000") - ap.add_argument("--email", default="folder@example.com") - ap.add_argument("--password", default="folder-test-passw0rd-123") - ap.add_argument("--timeout", type=int, default=1800, - help="seconds to wait per file for transcription") - ap.add_argument("--redo", action="store_true", help="re-transcribe even if report exists") - ap.add_argument("--limit", type=int, default=0, help="only process first N files (0 = all)") - args = ap.parse_args() - - folder = Path(args.folder).expanduser() - if not folder.is_dir(): - print(f"error: not a directory: {folder}", file=sys.stderr) - return 2 - files = sorted(p for p in folder.iterdir() - if p.is_file() and not p.name.startswith(".") - and p.suffix.lower() in MIME_BY_SUFFIX) - # report name: ".transcript.md" next to the audio - todo = [] - for p in files: - report = p.parent / (p.stem + ".transcript.md") - if report.exists() and not args.redo: - continue - todo.append((p, report)) - if args.limit: - todo = todo[:args.limit] - if not todo: - print("nothing to do (no new audio files).") - return 0 - - print(f"logging in as {args.email} ...") - token = ensure_login(args.api, args.email, args.password) - api = Api(args.api, token) - api.login_fn = lambda: ensure_login(args.api, args.email, args.password) - - for i, (path, report) in enumerate(todo, 1): - print(f"[{i}/{len(todo)}] {path.name} ({path.stat().st_size / 1e6:.1f} MB) ...", - flush=True) - try: - rid = upload_file(api, path) - state = wait_transcribed(api, rid, args.timeout) - tr = fetch_optional(api, f"/api/v1/recordings/{rid}/transcript") - su = fetch_optional(api, f"/api/v1/recordings/{rid}/summary") - report.write_text(render_report(path.stem, tr, su)) - print(f" status={state} -> {report.name}") - except Exception as e: - print(f" FAILED: {e}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/shared/openapi.json b/shared/openapi.json deleted file mode 100644 index bb6af76..0000000 --- a/shared/openapi.json +++ /dev/null @@ -1,2657 +0,0 @@ -{ - "components": { - "schemas": { - "DefaultModelOut": { - "properties": { - "default_model": { - "title": "Default Model", - "type": "string" - } - }, - "required": [ - "default_model" - ], - "title": "DefaultModelOut", - "type": "object" - }, - "DefaultModelUpdate": { - "properties": { - "model": { - "maxLength": 32, - "minLength": 1, - "title": "Model", - "type": "string" - } - }, - "required": [ - "model" - ], - "title": "DefaultModelUpdate", - "type": "object" - }, - "DeleteAccountRequest": { - "properties": { - "password": { - "title": "Password", - "type": "string" - } - }, - "required": [ - "password" - ], - "title": "DeleteAccountRequest", - "type": "object" - }, - "DeviceOut": { - "properties": { - "id": { - "format": "uuid", - "title": "Id", - "type": "string" - }, - "last_seen_at": { - "format": "date-time", - "title": "Last Seen At", - "type": "string" - }, - "name": { - "title": "Name", - "type": "string" - }, - "platform": { - "title": "Platform", - "type": "string" - }, - "revoked_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Revoked At" - } - }, - "required": [ - "id", - "name", - "platform", - "last_seen_at", - "revoked_at" - ], - "title": "DeviceOut", - "type": "object" - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "title": "Detail", - "type": "array" - } - }, - "title": "HTTPValidationError", - "type": "object" - }, - "LoginRequest": { - "properties": { - "device_name": { - "anyOf": [ - { - "maxLength": 120, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Device Name" - }, - "email": { - "format": "email", - "title": "Email", - "type": "string" - }, - "password": { - "title": "Password", - "type": "string" - }, - "platform": { - "default": "android", - "maxLength": 40, - "title": "Platform", - "type": "string" - } - }, - "required": [ - "email", - "password" - ], - "title": "LoginRequest", - "type": "object" - }, - "LogoutRequest": { - "properties": { - "refresh_token": { - "title": "Refresh Token", - "type": "string" - } - }, - "required": [ - "refresh_token" - ], - "title": "LogoutRequest", - "type": "object" - }, - "ModelsOut": { - "properties": { - "default_model": { - "title": "Default Model", - "type": "string" - }, - "faster_whisper_installed": { - "title": "Faster Whisper Installed", - "type": "boolean" - }, - "models": { - "items": { - "$ref": "#/components/schemas/TranscriptionModelOut" - }, - "title": "Models", - "type": "array" - } - }, - "required": [ - "default_model", - "faster_whisper_installed", - "models" - ], - "title": "ModelsOut", - "type": "object" - }, - "ProcessingJobOut": { - "properties": { - "attempt": { - "title": "Attempt", - "type": "integer" - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Error" - }, - "finished_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Finished At" - }, - "job_type": { - "title": "Job Type", - "type": "string" - }, - "max_attempts": { - "title": "Max Attempts", - "type": "integer" - }, - "progress": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Progress" - }, - "stage": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Stage" - }, - "started_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Started At" - }, - "status": { - "title": "Status", - "type": "string" - } - }, - "required": [ - "job_type", - "status", - "attempt", - "max_attempts", - "error", - "stage", - "progress", - "started_at", - "finished_at" - ], - "title": "ProcessingJobOut", - "type": "object" - }, - "RecordingFinalize": { - "properties": { - "duration_seconds": { - "default": 0.0, - "minimum": 0.0, - "title": "Duration Seconds", - "type": "number" - }, - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Latitude" - }, - "location_accuracy_m": { - "anyOf": [ - { - "minimum": 0.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Location Accuracy M" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Longitude" - }, - "notes": { - "anyOf": [ - { - "maxLength": 20000, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Notes" - }, - "recorded_at": { - "anyOf": [ - { - "format": "date-time", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Recorded At" - }, - "transcription_model": { - "anyOf": [ - { - "maxLength": 32, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Transcription Model" - } - }, - "title": "RecordingFinalize", - "type": "object" - }, - "RecordingListOut": { - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/RecordingOut" - }, - "title": "Items", - "type": "array" - }, - "limit": { - "title": "Limit", - "type": "integer" - }, - "offset": { - "title": "Offset", - "type": "integer" - }, - "total": { - "title": "Total", - "type": "integer" - } - }, - "required": [ - "items", - "total", - "limit", - "offset" - ], - "title": "RecordingListOut", - "type": "object" - }, - "RecordingOut": { - "properties": { - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "duration_seconds": { - "title": "Duration Seconds", - "type": "number" - }, - "has_audio": { - "title": "Has Audio", - "type": "boolean" - }, - "id": { - "format": "uuid", - "title": "Id", - "type": "string" - }, - "latitude": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Longitude" - }, - "mime_type": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Mime Type" - }, - "notes": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Notes" - }, - "processing_error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Processing Error" - }, - "processing_status": { - "title": "Processing Status", - "type": "string" - }, - "recorded_at": { - "format": "date-time", - "title": "Recorded At", - "type": "string" - }, - "size_bytes": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Size Bytes" - }, - "tags": { - "items": { - "type": "string" - }, - "title": "Tags", - "type": "array" - }, - "title": { - "title": "Title", - "type": "string" - }, - "transcription_model": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Transcription Model" - }, - "updated_at": { - "format": "date-time", - "title": "Updated At", - "type": "string" - } - }, - "required": [ - "id", - "title", - "recorded_at", - "duration_seconds", - "notes", - "latitude", - "longitude", - "processing_status", - "processing_error", - "transcription_model", - "has_audio", - "mime_type", - "size_bytes", - "tags", - "created_at", - "updated_at" - ], - "title": "RecordingOut", - "type": "object" - }, - "RecordingUpdate": { - "properties": { - "latitude": { - "anyOf": [ - { - "maximum": 90.0, - "minimum": -90.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Latitude" - }, - "longitude": { - "anyOf": [ - { - "maximum": 180.0, - "minimum": -180.0, - "type": "number" - }, - { - "type": "null" - } - ], - "title": "Longitude" - }, - "notes": { - "anyOf": [ - { - "maxLength": 20000, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Notes" - }, - "tags": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "maxItems": 20, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Tags" - }, - "title": { - "anyOf": [ - { - "maxLength": 300, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Title" - } - }, - "title": "RecordingUpdate", - "type": "object" - }, - "RefreshRequest": { - "properties": { - "refresh_token": { - "title": "Refresh Token", - "type": "string" - } - }, - "required": [ - "refresh_token" - ], - "title": "RefreshRequest", - "type": "object" - }, - "RegisterRequest": { - "properties": { - "display_name": { - "anyOf": [ - { - "maxLength": 120, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Display Name" - }, - "email": { - "format": "email", - "title": "Email", - "type": "string" - }, - "password": { - "maxLength": 256, - "minLength": 10, - "title": "Password", - "type": "string" - } - }, - "required": [ - "email", - "password" - ], - "title": "RegisterRequest", - "type": "object" - }, - "SegmentOut": { - "properties": { - "end": { - "title": "End", - "type": "number" - }, - "speaker": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Speaker" - }, - "start": { - "title": "Start", - "type": "number" - }, - "text": { - "title": "Text", - "type": "string" - } - }, - "required": [ - "start", - "end", - "text" - ], - "title": "SegmentOut", - "type": "object" - }, - "SummaryOut": { - "properties": { - "content": { - "additionalProperties": true, - "title": "Content", - "type": "object" - }, - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "edited_by_user": { - "title": "Edited By User", - "type": "boolean" - }, - "model": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Model" - }, - "provider": { - "title": "Provider", - "type": "string" - }, - "updated_at": { - "format": "date-time", - "title": "Updated At", - "type": "string" - }, - "version": { - "title": "Version", - "type": "integer" - } - }, - "required": [ - "version", - "provider", - "model", - "content", - "edited_by_user", - "created_at", - "updated_at" - ], - "title": "SummaryOut", - "type": "object" - }, - "SummaryUpdate": { - "description": "User edit for the structured summary (same versioning rule).", - "properties": { - "content": { - "additionalProperties": true, - "maxProperties": 100, - "title": "Content", - "type": "object" - } - }, - "required": [ - "content" - ], - "title": "SummaryUpdate", - "type": "object" - }, - "TokenPair": { - "properties": { - "access_token": { - "title": "Access Token", - "type": "string" - }, - "device_id": { - "anyOf": [ - { - "format": "uuid", - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Device Id" - }, - "expires_in": { - "title": "Expires In", - "type": "integer" - }, - "refresh_token": { - "title": "Refresh Token", - "type": "string" - }, - "token_type": { - "default": "bearer", - "title": "Token Type", - "type": "string" - } - }, - "required": [ - "access_token", - "expires_in", - "refresh_token" - ], - "title": "TokenPair", - "type": "object" - }, - "TranscriptOut": { - "properties": { - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "edited_by_user": { - "title": "Edited By User", - "type": "boolean" - }, - "language": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Language" - }, - "model": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Model" - }, - "provider": { - "title": "Provider", - "type": "string" - }, - "segments": { - "items": { - "$ref": "#/components/schemas/SegmentOut" - }, - "title": "Segments", - "type": "array" - }, - "text": { - "title": "Text", - "type": "string" - }, - "updated_at": { - "format": "date-time", - "title": "Updated At", - "type": "string" - }, - "version": { - "title": "Version", - "type": "integer" - } - }, - "required": [ - "version", - "language", - "provider", - "model", - "text", - "segments", - "edited_by_user", - "created_at", - "updated_at" - ], - "title": "TranscriptOut", - "type": "object" - }, - "TranscriptUpdate": { - "description": "User edit: stored as a new version with edited_by_user=True.\n\nThe AI pipeline never overwrites a newest user-edited row, so edits\nare verdicts. Segments are optional \u2014 when omitted the previous\nsegments are dropped (plain-text correction).", - "properties": { - "language": { - "anyOf": [ - { - "maxLength": 16, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Language" - }, - "segments": { - "anyOf": [ - { - "items": { - "$ref": "#/components/schemas/SegmentOut" - }, - "maxItems": 2000, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Segments" - }, - "text": { - "maxLength": 200000, - "minLength": 1, - "title": "Text", - "type": "string" - } - }, - "required": [ - "text" - ], - "title": "TranscriptUpdate", - "type": "object" - }, - "TranscriptionModelOut": { - "properties": { - "approx_memory": { - "title": "Approx Memory", - "type": "string" - }, - "available": { - "title": "Available", - "type": "boolean" - }, - "description": { - "title": "Description", - "type": "string" - }, - "display_name": { - "title": "Display Name", - "type": "string" - }, - "downloaded": { - "title": "Downloaded", - "type": "boolean" - }, - "is_default": { - "title": "Is Default", - "type": "boolean" - }, - "name": { - "title": "Name", - "type": "string" - }, - "params": { - "title": "Params", - "type": "string" - }, - "relative_speed": { - "title": "Relative Speed", - "type": "string" - } - }, - "required": [ - "name", - "display_name", - "description", - "params", - "approx_memory", - "relative_speed", - "is_default", - "downloaded", - "available" - ], - "title": "TranscriptionModelOut", - "type": "object" - }, - "UploadSessionCreate": { - "properties": { - "client_recording_id": { - "anyOf": [ - { - "maxLength": 64, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Client Recording Id" - }, - "declared_mime_type": { - "maxLength": 100, - "title": "Declared Mime Type", - "type": "string" - }, - "declared_size_bytes": { - "exclusiveMinimum": 0.0, - "title": "Declared Size Bytes", - "type": "integer" - }, - "title": { - "anyOf": [ - { - "maxLength": 300, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Title" - }, - "transcription_model": { - "anyOf": [ - { - "maxLength": 32, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Transcription Model" - } - }, - "required": [ - "declared_mime_type", - "declared_size_bytes" - ], - "title": "UploadSessionCreate", - "type": "object" - }, - "UploadSessionOut": { - "properties": { - "chunk_size_bytes": { - "title": "Chunk Size Bytes", - "type": "integer" - }, - "declared_mime_type": { - "title": "Declared Mime Type", - "type": "string" - }, - "declared_size_bytes": { - "title": "Declared Size Bytes", - "type": "integer" - }, - "expires_at": { - "format": "date-time", - "title": "Expires At", - "type": "string" - }, - "id": { - "format": "uuid", - "title": "Id", - "type": "string" - }, - "status": { - "title": "Status", - "type": "string" - } - }, - "required": [ - "id", - "status", - "chunk_size_bytes", - "declared_mime_type", - "declared_size_bytes", - "expires_at" - ], - "title": "UploadSessionOut", - "type": "object" - }, - "UploadStatusOut": { - "properties": { - "id": { - "format": "uuid", - "title": "Id", - "type": "string" - }, - "received_chunk_indexes": { - "items": { - "type": "integer" - }, - "title": "Received Chunk Indexes", - "type": "array" - }, - "status": { - "title": "Status", - "type": "string" - } - }, - "required": [ - "id", - "status", - "received_chunk_indexes" - ], - "title": "UploadStatusOut", - "type": "object" - }, - "UserOut": { - "properties": { - "created_at": { - "format": "date-time", - "title": "Created At", - "type": "string" - }, - "display_name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Display Name" - }, - "email": { - "format": "email", - "title": "Email", - "type": "string" - }, - "id": { - "format": "uuid", - "title": "Id", - "type": "string" - }, - "location_storage_enabled": { - "title": "Location Storage Enabled", - "type": "boolean" - } - }, - "required": [ - "id", - "email", - "display_name", - "location_storage_enabled", - "created_at" - ], - "title": "UserOut", - "type": "object" - }, - "UserUpdate": { - "properties": { - "display_name": { - "anyOf": [ - { - "maxLength": 120, - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Display Name" - }, - "location_storage_enabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "Location Storage Enabled" - } - }, - "title": "UserUpdate", - "type": "object" - }, - "ValidationError": { - "properties": { - "ctx": { - "title": "Context", - "type": "object" - }, - "input": { - "title": "Input" - }, - "loc": { - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, - "title": "Location", - "type": "array" - }, - "msg": { - "title": "Message", - "type": "string" - }, - "type": { - "title": "Error Type", - "type": "string" - } - }, - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError", - "type": "object" - } - }, - "securitySchemes": { - "HTTPBearer": { - "scheme": "bearer", - "type": "http" - } - } - }, - "info": { - "description": "SHONAR \u2014 Self-hosted Oral Notes and Audio Recorder. REST API. All data stays on your server.", - "title": "SHONAR API", - "version": "0.1.0" - }, - "openapi": "3.1.0", - "paths": { - "/": { - "get": { - "operationId": "root__get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": {} - } - }, - "description": "Successful Response" - } - }, - "summary": "Root" - } - }, - "/api/v1/auth/delete-account": { - "post": { - "operationId": "delete_account_api_v1_auth_delete_account_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteAccountRequest" - } - } - }, - "required": true - }, - "responses": { - "202": { - "content": { - "application/json": { - "schema": {} - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Delete Account", - "tags": [ - "auth" - ] - } - }, - "/api/v1/auth/login": { - "post": { - "operationId": "login_api_v1_auth_login_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TokenPair" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "summary": "Login", - "tags": [ - "auth" - ] - } - }, - "/api/v1/auth/logout": { - "post": { - "operationId": "logout_api_v1_auth_logout_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LogoutRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "summary": "Logout", - "tags": [ - "auth" - ] - } - }, - "/api/v1/auth/me": { - "get": { - "operationId": "me_api_v1_auth_me_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserOut" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Me", - "tags": [ - "auth" - ] - } - }, - "/api/v1/auth/refresh": { - "post": { - "operationId": "refresh_api_v1_auth_refresh_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RefreshRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TokenPair" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "summary": "Refresh", - "tags": [ - "auth" - ] - } - }, - "/api/v1/auth/register": { - "post": { - "operationId": "register_api_v1_auth_register_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegisterRequest" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TokenPair" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "summary": "Register", - "tags": [ - "auth" - ] - } - }, - "/api/v1/devices": { - "get": { - "operationId": "list_devices_api_v1_devices_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/DeviceOut" - }, - "title": "Response List Devices Api V1 Devices Get", - "type": "array" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "List Devices", - "tags": [ - "users", - "devices" - ] - } - }, - "/api/v1/devices/{device_id}": { - "delete": { - "operationId": "revoke_device_api_v1_devices__device_id__delete", - "parameters": [ - { - "in": "path", - "name": "device_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Device Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Revoke Device", - "tags": [ - "users", - "devices" - ] - } - }, - "/api/v1/healthz": { - "get": { - "description": "Liveness: process up. No auth, no dependencies.", - "operationId": "healthz_api_v1_healthz_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "title": "Response Healthz Api V1 Healthz Get", - "type": "object" - } - } - }, - "description": "Successful Response" - } - }, - "summary": "Healthz", - "tags": [ - "health" - ] - } - }, - "/api/v1/models": { - "get": { - "operationId": "list_models_api_v1_models_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ModelsOut" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "List Models", - "tags": [ - "models" - ] - } - }, - "/api/v1/models/default": { - "get": { - "operationId": "get_default_model_api_v1_models_default_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DefaultModelOut" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Get Default Model", - "tags": [ - "models" - ] - }, - "put": { - "operationId": "put_default_model_api_v1_models_default_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DefaultModelUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DefaultModelOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Put Default Model", - "tags": [ - "models" - ] - } - }, - "/api/v1/models/{name}/download": { - "post": { - "operationId": "download_model_api_v1_models__name__download_post", - "parameters": [ - { - "in": "path", - "name": "name", - "required": true, - "schema": { - "title": "Name", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TranscriptionModelOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Download Model", - "tags": [ - "models" - ] - } - }, - "/api/v1/provider-info": { - "get": { - "description": "Static, unauthenticated deployment identity for client probing.", - "operationId": "provider_info_api_v1_provider_info_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "title": "Response Provider Info Api V1 Provider Info Get", - "type": "object" - } - } - }, - "description": "Successful Response" - } - }, - "summary": "Provider Info", - "tags": [ - "provider" - ] - } - }, - "/api/v1/readyz": { - "get": { - "description": "Readiness: database reachable. Config warnings surfaced for admins\nvia /api/v1/system/status instead of failing readiness.", - "operationId": "readyz_api_v1_readyz_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "title": "Response Readyz Api V1 Readyz Get", - "type": "object" - } - } - }, - "description": "Successful Response" - } - }, - "summary": "Readyz", - "tags": [ - "health" - ] - } - }, - "/api/v1/recordings": { - "get": { - "operationId": "list_recordings_api_v1_recordings_get", - "parameters": [ - { - "in": "query", - "name": "limit", - "required": false, - "schema": { - "default": 50, - "maximum": 200, - "minimum": 1, - "title": "Limit", - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "required": false, - "schema": { - "default": 0, - "minimum": 0, - "title": "Offset", - "type": "integer" - } - }, - { - "in": "query", - "name": "sort", - "required": false, - "schema": { - "default": "recorded_at", - "pattern": "^(recorded_at|created_at|duration_seconds|title)$", - "title": "Sort", - "type": "string" - } - }, - { - "in": "query", - "name": "order", - "required": false, - "schema": { - "default": "desc", - "pattern": "^(asc|desc)$", - "title": "Order", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordingListOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "List Recordings", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/recordings/{recording_id}": { - "delete": { - "description": "Soft-delete by default; ?purge=true removes rows + stored files now.", - "operationId": "delete_recording_api_v1_recordings__recording_id__delete", - "parameters": [ - { - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Recording Id", - "type": "string" - } - }, - { - "description": "true also deletes stored audio", - "in": "query", - "name": "purge", - "required": false, - "schema": { - "default": false, - "description": "true also deletes stored audio", - "title": "Purge", - "type": "boolean" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Delete Recording", - "tags": [ - "uploads", - "recordings" - ] - }, - "get": { - "operationId": "get_recording_api_v1_recordings__recording_id__get", - "parameters": [ - { - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Recording Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordingOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Get Recording", - "tags": [ - "uploads", - "recordings" - ] - }, - "patch": { - "operationId": "update_recording_api_v1_recordings__recording_id__patch", - "parameters": [ - { - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Recording Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordingUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordingOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Update Recording", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/recordings/{recording_id}/audio": { - "get": { - "operationId": "download_audio_api_v1_recordings__recording_id__audio_get", - "parameters": [ - { - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Recording Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": {} - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Download Audio", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/recordings/{recording_id}/jobs": { - "get": { - "operationId": "list_jobs_api_v1_recordings__recording_id__jobs_get", - "parameters": [ - { - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Recording Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/ProcessingJobOut" - }, - "title": "Response List Jobs Api V1 Recordings Recording Id Jobs Get", - "type": "array" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "List Jobs", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/recordings/{recording_id}/summary": { - "get": { - "operationId": "get_summary_api_v1_recordings__recording_id__summary_get", - "parameters": [ - { - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Recording Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SummaryOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Get Summary", - "tags": [ - "uploads", - "recordings" - ] - }, - "put": { - "operationId": "update_summary_api_v1_recordings__recording_id__summary_put", - "parameters": [ - { - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Recording Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SummaryUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SummaryOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Update Summary", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/recordings/{recording_id}/transcript": { - "get": { - "operationId": "get_transcript_api_v1_recordings__recording_id__transcript_get", - "parameters": [ - { - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Recording Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TranscriptOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Get Transcript", - "tags": [ - "uploads", - "recordings" - ] - }, - "put": { - "operationId": "update_transcript_api_v1_recordings__recording_id__transcript_put", - "parameters": [ - { - "in": "path", - "name": "recording_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Recording Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TranscriptUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TranscriptOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Update Transcript", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/system/status": { - "get": { - "description": "Public-ish status: which AI features are enabled (never any secrets).\nThe app uses this to show honest AI-processing state to users.", - "operationId": "system_status_api_v1_system_status_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": true, - "title": "Response System Status Api V1 System Status Get", - "type": "object" - } - } - }, - "description": "Successful Response" - } - }, - "summary": "System Status", - "tags": [ - "health" - ] - } - }, - "/api/v1/uploads": { - "post": { - "operationId": "create_upload_api_v1_uploads_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadSessionCreate" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadSessionOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Create Upload", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/uploads/{session_id}": { - "delete": { - "operationId": "abort_upload_api_v1_uploads__session_id__delete", - "parameters": [ - { - "in": "path", - "name": "session_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Session Id", - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Abort Upload", - "tags": [ - "uploads", - "recordings" - ] - }, - "get": { - "operationId": "upload_status_api_v1_uploads__session_id__get", - "parameters": [ - { - "in": "path", - "name": "session_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Session Id", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadStatusOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Upload Status", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/uploads/{session_id}/chunks/{chunk_index}": { - "put": { - "operationId": "put_chunk_api_v1_uploads__session_id__chunks__chunk_index__put", - "parameters": [ - { - "in": "path", - "name": "session_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Session Id", - "type": "string" - } - }, - { - "in": "path", - "name": "chunk_index", - "required": true, - "schema": { - "title": "Chunk Index", - "type": "integer" - } - }, - { - "in": "header", - "name": "x-chunk-sha256", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "X-Chunk-Sha256" - } - } - ], - "responses": { - "201": { - "content": { - "application/json": { - "schema": {} - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Put Chunk", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/uploads/{session_id}/finalize": { - "post": { - "operationId": "finalize_upload_api_v1_uploads__session_id__finalize_post", - "parameters": [ - { - "in": "path", - "name": "session_id", - "required": true, - "schema": { - "format": "uuid", - "title": "Session Id", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordingFinalize" - } - } - }, - "required": true - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RecordingOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Finalize Upload", - "tags": [ - "uploads", - "recordings" - ] - } - }, - "/api/v1/users/me": { - "get": { - "operationId": "get_me_api_v1_users_me_get", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserOut" - } - } - }, - "description": "Successful Response" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Get Me", - "tags": [ - "users", - "devices" - ] - }, - "patch": { - "operationId": "update_me_api_v1_users_me_patch", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UserOut" - } - } - }, - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Update Me", - "tags": [ - "users", - "devices" - ] - } - } - } -} diff --git a/worker/README.md b/worker/README.md deleted file mode 100644 index 10e213e..0000000 --- a/worker/README.md +++ /dev/null @@ -1,22 +0,0 @@ -"""Worker entrypoint: runs the arq queue on the backend image. - -Run (from `backend/`, venv active): - - arq shonar.worker.WorkerSettings - -Needs Redis (`SHONAR_REDIS_URL`, wired automatically in compose) and the -same `SHONAR_*` env as the API — the worker reads AI provider config from -the same variables, so transcription/summary behavior matches what -`/api/v1/provider-info` advertises. - -What it runs (M7): -- `run_transcribe(recording_id)` — original audio through the configured - transcription provider, stored versioned (user edits win, never touched). -- `run_summarize(recording_id)` — latest transcript through the LLM into - the structured summary shape. -- `sweep` on startup + every 5 min — crash recovery and transport-loss - backstop: stuck `running` and orphaned `queued` rows go back through arq. - -With no AI configured the pipeline marks recordings `ai_disabled` and the -worker idles: recording, sync, and playback never need it. -"""