Local-only app: remove network, uploads, providers, backend, desktop
Phone is now a pure on-device recorder: no accounts, no servers, no background uploads (INTERNET permission gone). File sync is the user's own tooling; the library adopts externally added files. Android: - Delete provider package (Nextcloud, custom SHONAR, sync-folder, registry, auth, TOFU/TLS), sync stack (SyncWorker/Drain/Slots, MigrationRunner), provider/storage/folder UI, AI-via-server details. - Home shows a fixed 'on this phone' library; details keep playback, rename, file info. Settings lose Network/provider/HTTP-logging. - Library root is the stored folder or Music/Recordings; import is double-scan proof (mutex + unique filePath index, migration v3->v4 dedupes by path) with regression tests. - Drop okhttp/work/security-crypto/media deps; delete their tests. Repo: backend/, desktop/, worker/, deploy/, shared/, server scripts and docs removed; README rewritten; CI keeps the android job only.
This commit is contained in:
parent
7b3436837a
commit
8c510f4ab9
154 changed files with 196 additions and 23570 deletions
67
.env.example
67
.env.example
|
|
@ -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
|
|
||||||
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
|
|
@ -6,34 +6,6 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
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:
|
android:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
|
|
|
||||||
117
README.md
117
README.md
|
|
@ -1,46 +1,26 @@
|
||||||
# S.H.O.N.A.R.
|
# 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
|
Record conversations on your Android phone and keep them in a folder you
|
||||||
services. Record conversations on your Android phone, sync them to a server
|
control. No accounts, no servers, no uploads, no telemetry — the app has
|
||||||
**you** control, transcribe and summarize them with AI providers **you**
|
no network permission at all. If you want your recordings on other
|
||||||
choose, and keep full ownership of your audio, transcripts, and accounts.
|
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.
|
- Record with one tap (app, notification, or home-screen widget), pause /
|
||||||
- Runs fully locally by default (local transcription + local LLM supported).
|
resume, save with rename, play back with seek + speed.
|
||||||
- Works with **no AI configured at all**: recording, sync, playback, download,
|
- Library lives in a folder on the phone (`Music/Recordings` by default);
|
||||||
and manual transcripts still work.
|
rename and delete move together with the audio file.
|
||||||
- Apache-2.0 licensed. All code in this repository is original.
|
- Apache-2.0 licensed.
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|
||||||
```
|
```
|
||||||
android/ Android app (Kotlin, Jetpack Compose, Material 3)
|
android/ Android app (Kotlin, Jetpack Compose, Material 3)
|
||||||
backend/ FastAPI + PostgreSQL API server
|
docs/ Recording-consent notice
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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)
|
## Quick start (Android)
|
||||||
|
|
||||||
Requirements: JDK 17, Android SDK (platform 35). See
|
Requirements: JDK 17, Android SDK (platform 35). See
|
||||||
|
|
@ -52,85 +32,12 @@ cd android
|
||||||
adb install app/build/outputs/apk/debug/app-debug.apk
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
## Legal notice
|
||||||
|
|
||||||
**You are responsible for complying with recording-consent laws in your
|
**You are responsible for complying with recording-consent laws in your
|
||||||
|
|
|
||||||
|
|
@ -61,15 +61,8 @@ dependencies {
|
||||||
|
|
||||||
// persistence
|
// persistence
|
||||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
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)
|
// background work (none — local-only app keeps no workers)
|
||||||
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")
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||||
|
|
||||||
|
|
@ -80,10 +73,6 @@ dependencies {
|
||||||
// dispatcher or provider JSON parsing under test.
|
// dispatcher or provider JSON parsing under test.
|
||||||
testImplementation("org.json:json:20240303")
|
testImplementation("org.json:json:20240303")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
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")
|
testImplementation("androidx.room:room-testing:2.6.1")
|
||||||
androidTestImplementation(composeBom)
|
androidTestImplementation(composeBom)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<!-- Local-only: no INTERNET, no network state. Everything on this phone. -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
|
@ -12,11 +10,9 @@
|
||||||
<!-- Call-aware auto-pause (optional; degrades gracefully without it). -->
|
<!-- Call-aware auto-pause (optional; degrades gracefully without it). -->
|
||||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
|
||||||
<!-- Folder picker (sync-folder provider): browse your own storage to
|
<!-- On-phone library in shared storage (self-hosted/sideloaded):
|
||||||
choose where recordings live. API 30+ uses all-files access granted
|
API 30+ uses all-files access granted in Settings; older releases
|
||||||
in Settings; older releases use the read permission. SHONAR is
|
use the read permission. The app touches only the library folder. -->
|
||||||
sideloaded/self-hosted — no Play policy governs this, and the app
|
|
||||||
touches only the folder you pick. -->
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
|
@ -28,11 +24,7 @@
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Shonar"
|
android:theme="@style/Theme.Shonar">
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
|
||||||
<!-- network_security_config permits cleartext ONLY for private/LAN
|
|
||||||
address ranges (a user-configured self-hosted server on LAN).
|
|
||||||
TLS verification is NOT disabled anywhere. -->
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,7 @@ import com.shonar.recording.RecordingFiles
|
||||||
import com.shonar.recording.RecordingService
|
import com.shonar.recording.RecordingService
|
||||||
import com.shonar.recording.RenamePendingHolder
|
import com.shonar.recording.RenamePendingHolder
|
||||||
import com.shonar.ui.detail.DetailsScreen
|
import com.shonar.ui.detail.DetailsScreen
|
||||||
import com.shonar.ui.folder.FolderBrowserScreen
|
|
||||||
import com.shonar.ui.home.HomeScreen
|
import com.shonar.ui.home.HomeScreen
|
||||||
import com.shonar.ui.provider.ProviderSelectionScreen
|
|
||||||
import com.shonar.ui.provider.StorageScreen
|
|
||||||
import com.shonar.ui.rename.RenameAfterSaveDialog
|
import com.shonar.ui.rename.RenameAfterSaveDialog
|
||||||
import com.shonar.ui.settings.SettingsScreen
|
import com.shonar.ui.settings.SettingsScreen
|
||||||
import com.shonar.ui.theme.ShonarTheme
|
import com.shonar.ui.theme.ShonarTheme
|
||||||
|
|
@ -59,7 +56,6 @@ class MainActivity : ComponentActivity() {
|
||||||
composable("home") {
|
composable("home") {
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
onOpenSettings = { nav.navigate("settings") },
|
onOpenSettings = { nav.navigate("settings") },
|
||||||
onOpenStorage = { nav.navigate("storage") },
|
|
||||||
onOpenDetail = { id -> nav.navigate("detail/$id") },
|
onOpenDetail = { id -> nav.navigate("detail/$id") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -72,37 +68,6 @@ class MainActivity : ComponentActivity() {
|
||||||
onBack = { nav.popBackStack() },
|
onBack = { nav.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable("storage") {
|
|
||||||
StorageScreen(
|
|
||||||
onBack = { nav.popBackStack() },
|
|
||||||
onSwitchProvider = { nav.navigate("provider") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable("provider") { entry ->
|
|
||||||
val pick by entry.savedStateHandle
|
|
||||||
.getStateFlow<String?>("picked_path", null)
|
|
||||||
.collectAsState()
|
|
||||||
ProviderSelectionScreen(
|
|
||||||
onDone = { nav.popBackStack() },
|
|
||||||
onBack = { nav.popBackStack() },
|
|
||||||
browsePick = pick,
|
|
||||||
onPickConsumed = {
|
|
||||||
entry.savedStateHandle.remove<String>("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() }) }
|
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,18 @@ import androidx.room.Room
|
||||||
import com.shonar.recording.RecordingRepository
|
import com.shonar.recording.RecordingRepository
|
||||||
import com.shonar.recording.ShonarDatabase
|
import com.shonar.recording.ShonarDatabase
|
||||||
import com.shonar.settings.DataStoreSettingsStore
|
import com.shonar.settings.DataStoreSettingsStore
|
||||||
import com.shonar.settings.SecureSettingsStore
|
|
||||||
import com.shonar.settings.SettingsManager
|
import com.shonar.settings.SettingsManager
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.io.File
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.launch
|
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() {
|
class ShonarApplication : Application() {
|
||||||
|
|
||||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
@ -23,106 +26,38 @@ class ShonarApplication : Application() {
|
||||||
.addMigrations(
|
.addMigrations(
|
||||||
com.shonar.recording.MIGRATION_1_2,
|
com.shonar.recording.MIGRATION_1_2,
|
||||||
com.shonar.recording.MIGRATION_2_3,
|
com.shonar.recording.MIGRATION_2_3,
|
||||||
|
com.shonar.recording.MIGRATION_3_4,
|
||||||
)
|
)
|
||||||
.build()
|
.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 {
|
val settingsManager: SettingsManager by lazy {
|
||||||
SettingsManager(
|
SettingsManager(store = DataStoreSettingsStore(this))
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP debug logging follows the `log_http_bodies` setting. Interceptors
|
* Library root: the previously picked folder (persisted path) or the
|
||||||
* run on OkHttp threads that cannot suspend, so the flag is cached here
|
* shared Music/Recordings folder. Never throws — null means default.
|
||||||
* and refreshed whenever settings change — toggling needs no restart.
|
|
||||||
*/
|
*/
|
||||||
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 recordingRepository: RecordingRepository by lazy {
|
||||||
val providerRegistry: com.shonar.provider.ProviderRegistry by lazy {
|
RecordingRepository(filesDir, database.recordingDao(), ::libraryRoot)
|
||||||
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) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
appScope.launch {
|
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() }
|
runCatching { recordingRepository.refreshRoot() }
|
||||||
syncBodyLoggingFlag()
|
|
||||||
scheduleSync()
|
|
||||||
settingsManager.valuesChanged.collect {
|
|
||||||
syncBodyLoggingFlag()
|
|
||||||
scheduleSync()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** M5: steady-state periodic drain plus an immediate drain at startup
|
companion object {
|
||||||
* and whenever sync constraints change. Re-running on unrelated
|
const val KEY_LIBRARY_ROOT = "library.root"
|
||||||
* settings edits only refreshes the periodic schedule (cheap UPDATE),
|
const val DEFAULT_LIBRARY_PATH = "/storage/emulated/0/Music/Recordings"
|
||||||
* never a drain. */
|
|
||||||
private var lastSyncFlags: Pair<Boolean, Boolean>? = 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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> = _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<RemoteRecording> = 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<RemoteRecording>()
|
|
||||||
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<String, Int> {
|
|
||||||
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<Int> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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> = _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<RemoteRecording> = 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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> = _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<RemoteRecording> {
|
|
||||||
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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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])"
|
|
||||||
}
|
|
||||||
|
|
@ -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> = _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<RemoteRecording> = 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<Int> {
|
|
||||||
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<DavEntry> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val body = """<?xml version="1.0"?>
|
|
||||||
<d:propfind xmlns:d="DAV:"><d:prop><d:getcontentlength/><d:getetag/><d:resourcetype/><d:getcontenttype/><d:getlastmodified/><d:displayname/></d:prop></d:propfind>"""
|
|
||||||
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<DavEntry> {
|
|
||||||
if (xml.isBlank()) return emptyList()
|
|
||||||
val out = mutableListOf<DavEntry>()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String, () -> ShonarProvider>,
|
|
||||||
/** Builds every provider HTTP client (TOFU trust + redacting logger). */
|
|
||||||
val tls: TlsPolicy = defaultTls(),
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val _activeId = MutableStateFlow(LocalOnlyProvider.ID)
|
|
||||||
val activeId: StateFlow<String> = _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<ProviderDescriptor> =
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Capability> = 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<String>) {
|
|
||||||
|
|
||||||
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<ServerUrl> {
|
|
||||||
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<DiscoveredService>) : 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<T>(val items: List<T>, 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)
|
|
||||||
}
|
|
||||||
|
|
@ -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])"
|
|
||||||
}
|
|
||||||
|
|
@ -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<ProviderDescriptor.Capability>()
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<AuthState>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<RemoteRecording>
|
|
||||||
|
|
||||||
// ---- 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
|
|
||||||
}
|
|
||||||
|
|
@ -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<Byte>?) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String> {
|
|
||||||
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<String> {
|
|
||||||
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<X509Certificate>, 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<X509Certificate>, authType: String, socket: Socket,
|
|
||||||
) {
|
|
||||||
system.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkClientTrusted(
|
|
||||||
chain: Array<X509Certificate>, authType: String, engine: SSLEngine,
|
|
||||||
) {
|
|
||||||
system.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
checkServerTrusted(chain, authType, host = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(
|
|
||||||
chain: Array<X509Certificate>, 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<X509Certificate>, 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<X509Certificate>, 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<X509Certificate> = 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<String, Set<String>>()
|
|
||||||
private val failures = ConcurrentHashMap<String, TofuFailure>()
|
|
||||||
|
|
||||||
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<String, Set<String>>()
|
|
||||||
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<String> = 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<X509TrustManager>().first()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<TranscriptSegment> = 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<String, Any?> = emptyMap(),
|
|
||||||
val editedByUser: Boolean = false,
|
|
||||||
) {
|
|
||||||
val short: String get() = summaryString(content, "short")
|
|
||||||
val detailed: String get() = summaryString(content, "detailed")
|
|
||||||
fun list(key: String): List<String> = 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<TranscriptSegment>, 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<TranscriptSegment>()
|
|
||||||
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<String, Any?>()
|
|
||||||
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<JobInfo> = 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<TranscriptSegment>? = 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, Any?>): 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<TranscriptionModelInfo> = emptyList(),
|
|
||||||
) {
|
|
||||||
fun byName(name: String): TranscriptionModelInfo? = models.firstOrNull { it.name == name }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseModels(raw: String): ModelsData? = runCatching {
|
|
||||||
val json = JSONObject(raw)
|
|
||||||
val models = mutableListOf<TranscriptionModelInfo>()
|
|
||||||
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<String, Any?>, key: String): String =
|
|
||||||
(content[key] as? String).orEmpty()
|
|
||||||
|
|
||||||
private fun summaryList(content: Map<String, Any?>, key: String): List<String> = 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()
|
|
||||||
}
|
|
||||||
|
|
@ -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<String>, 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<String>()
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.shonar.recording
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Delete
|
import androidx.room.Delete
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Update
|
import androidx.room.Update
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -21,6 +22,13 @@ interface RecordingDao {
|
||||||
@Insert
|
@Insert
|
||||||
suspend fun insert(recording: RecordingEntity)
|
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
|
@Update
|
||||||
suspend fun update(recording: RecordingEntity)
|
suspend fun update(recording: RecordingEntity)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.shonar.recording
|
package com.shonar.recording
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
import com.shonar.provider.SyncState
|
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
|
* driver exists, the foreground migrator (P7) is the only writer besides
|
||||||
* these defaults.
|
* these defaults.
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "recordings")
|
@Entity(tableName = "recordings", indices = [Index(value = ["filePath"], unique = true)])
|
||||||
data class RecordingEntity(
|
data class RecordingEntity(
|
||||||
@PrimaryKey val id: String,
|
@PrimaryKey val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ object RecordingFiles {
|
||||||
|
|
||||||
fun defaultDisplayName(nowMs: Long = System.currentTimeMillis()): String {
|
fun defaultDisplayName(nowMs: Long = System.currentTimeMillis()): String {
|
||||||
val s = SimpleDateFormat("yyyy-MM-dd HH-mm", Locale.US).format(Date(nowMs))
|
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. */
|
/** Null = blank / "." / ".." / nothing usable after cleaning. */
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package com.shonar.recording
|
package com.shonar.recording
|
||||||
|
|
||||||
import com.shonar.provider.RecordingDraft
|
|
||||||
import com.shonar.provider.RemoteRef
|
|
||||||
import com.shonar.provider.SyncState
|
import com.shonar.provider.SyncState
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
@ -20,7 +20,6 @@ class RecordingRepository(
|
||||||
*/
|
*/
|
||||||
private val rootResolver: (suspend () -> File?)? = null,
|
private val rootResolver: (suspend () -> File?)? = null,
|
||||||
) {
|
) {
|
||||||
private val slots = SyncSlots(dao)
|
|
||||||
private val sandboxDir: File = appFilesDir
|
private val sandboxDir: File = appFilesDir
|
||||||
private val legacyDir = File(sandboxDir, "recordings").apply { mkdirs() }
|
private val legacyDir = File(sandboxDir, "recordings").apply { mkdirs() }
|
||||||
|
|
||||||
|
|
@ -28,6 +27,9 @@ class RecordingRepository(
|
||||||
private val recordingsDir: File get() = libraryRoot
|
private val recordingsDir: File get() = libraryRoot
|
||||||
private fun tempDir(): File = File(libraryRoot, ".in-progress").apply { mkdirs() }
|
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<List<RecordingEntity>> = dao.observeAll()
|
val recordings: Flow<List<RecordingEntity>> = dao.observeAll()
|
||||||
|
|
||||||
fun newRecordingId(): String = UUID.randomUUID().toString()
|
fun newRecordingId(): String = UUID.randomUUID().toString()
|
||||||
|
|
@ -41,12 +43,16 @@ class RecordingRepository(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adopt audio files already sitting in the library folder (put there by
|
* Adopt audio files already sitting in the library folder (put there by
|
||||||
* another app, USB, …). Flat scan, top level only; dotfiles and the
|
* the user's own file sync, USB, …). Flat scan, top level only;
|
||||||
* in-progress dir are skipped. Files already tracked by path are left
|
* dotfiles and the in-progress dir are skipped. Files already tracked
|
||||||
* alone — renaming a file re-imports it as new, which is honest.
|
* by path are left alone — renaming a file re-imports it as new, which
|
||||||
* Nothing is copied, moved, or modified.
|
* 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 root = libraryRoot
|
||||||
val known = dao.getAll().mapNotNullTo(mutableSetOf()) {
|
val known = dao.getAll().mapNotNullTo(mutableSetOf()) {
|
||||||
runCatching { File(it.filePath).canonicalPath }.getOrNull()
|
runCatching { File(it.filePath).canonicalPath }.getOrNull()
|
||||||
|
|
@ -61,7 +67,7 @@ class RecordingRepository(
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
for (f in files) {
|
for (f in files) {
|
||||||
val ext = f.extension.lowercase()
|
val ext = f.extension.lowercase()
|
||||||
dao.insert(
|
dao.insertIgnore(
|
||||||
RecordingEntity(
|
RecordingEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
title = f.nameWithoutExtension,
|
title = f.nameWithoutExtension,
|
||||||
|
|
@ -155,25 +161,10 @@ class RecordingRepository(
|
||||||
dao.delete(recording)
|
dao.delete(recording)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- P7 provider slot (delegates to SyncSlots; Context-free logic) -----
|
// ---- sync layer removed (local-only app) -----
|
||||||
|
|
||||||
/** 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)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Extensions adopted by [importExistingFiles] (mirrors backend magic check). */
|
/** Extensions adopted by [importExistingFiles]. */
|
||||||
val AUDIO_MIME_BY_EXTENSION = mapOf( "m4a" to "audio/mp4",
|
val AUDIO_MIME_BY_EXTENSION = mapOf( "m4a" to "audio/mp4",
|
||||||
"mp4" to "audio/mp4",
|
"mp4" to "audio/mp4",
|
||||||
"aac" to "audio/aac",
|
"aac" to "audio/aac",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import androidx.room.TypeConverters
|
||||||
import androidx.room.migration.Migration
|
import androidx.room.migration.Migration
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
@Database(entities = [RecordingEntity::class], version = 3, exportSchema = false)
|
@Database(entities = [RecordingEntity::class], version = 4, exportSchema = false)
|
||||||
@TypeConverters(SyncStateConverter::class)
|
@TypeConverters(SyncStateConverter::class)
|
||||||
abstract class ShonarDatabase : RoomDatabase() {
|
abstract class ShonarDatabase : RoomDatabase() {
|
||||||
abstract fun recordingDao(): RecordingDao
|
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")
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String>, 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<String>()
|
|
||||||
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)
|
|
||||||
|
|
@ -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<RecordingEntity> = 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<SyncState> = 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)
|
|
||||||
}
|
|
||||||
|
|
@ -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<SyncWorker>()
|
|
||||||
.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<SyncWorker>(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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,23 +3,18 @@ package com.shonar.settings
|
||||||
/**
|
/**
|
||||||
* Built-in settings. These ship with the app; users may add more at runtime.
|
* Built-in settings. These ship with the app; users may add more at runtime.
|
||||||
*
|
*
|
||||||
* NOTE: Home Assistant settings were removed from the default configuration
|
* NOTE: network/upload/provider settings were removed (product scope change
|
||||||
* (product scope change) and preserved on branch `deferred/home-assistant`
|
* to local-only): no accounts, no servers, no background uploads. File sync
|
||||||
* under `deferred/home-assistant/`.
|
* is the user's own tooling outside this app.
|
||||||
*/
|
*/
|
||||||
object BuiltInSettings {
|
object BuiltInSettings {
|
||||||
|
|
||||||
const val CAT_GENERAL = "General"
|
const val CAT_GENERAL = "General"
|
||||||
const val CAT_APPEARANCE = "Appearance"
|
const val CAT_APPEARANCE = "Appearance"
|
||||||
const val CAT_NETWORK = "Network"
|
|
||||||
const val CAT_ADVANCED = "Advanced"
|
const val CAT_ADVANCED = "Advanced"
|
||||||
|
|
||||||
// ids other code depends on (single source of truth)
|
// ids other code depends on (single source of truth)
|
||||||
const val CONSENT = "consent_notice_seen"
|
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<SettingDefinition> = listOf(
|
val all: List<SettingDefinition> = listOf(
|
||||||
// --- General -------------------------------------------------------
|
// --- General -------------------------------------------------------
|
||||||
|
|
@ -59,51 +54,7 @@ object BuiltInSettings {
|
||||||
type = SettingType.COLOR,
|
type = SettingType.COLOR,
|
||||||
defaultJson = "\"#4FD1C5\"",
|
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 ------------------------------------------------------------
|
// --- 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(
|
SettingDefinition(
|
||||||
id = "app_version",
|
id = "app_version",
|
||||||
name = "App version",
|
name = "App version",
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,11 @@ data class ImportResult(val applied: List<String>, val rejected: Map<String, Str
|
||||||
* from [BuiltInSettings], user-created definitions persist alongside values.
|
* from [BuiltInSettings], user-created definitions persist alongside values.
|
||||||
*
|
*
|
||||||
* Values are stored as JSON strings (per [SettingDefinition.type]).
|
* Values are stored as JSON strings (per [SettingDefinition.type]).
|
||||||
* Sensitive values go to [secureStore]; everything else to [store].
|
* Local-only app: a single store (no secrets exist).
|
||||||
*/
|
*/
|
||||||
class SettingsManager(
|
class SettingsManager(
|
||||||
private val store: SettingsStore,
|
private val store: SettingsStore,
|
||||||
private val secureStore: SettingsStore,
|
private val secureStore: SettingsStore = InMemorySettingsStore(),
|
||||||
) {
|
) {
|
||||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
private val definitionsFlow = MutableStateFlow<List<SettingDefinition>>(emptyList())
|
private val definitionsFlow = MutableStateFlow<List<SettingDefinition>>(emptyList())
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,14 @@ import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
|
||||||
import androidx.security.crypto.MasterKey
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Key/value persistence abstraction. Two implementations:
|
* Key/value persistence abstraction. Local-only app: a single DataStore
|
||||||
* - [DataStoreSettingsStore] for ordinary settings
|
* (no secrets exist — no accounts, no server credentials). In-memory
|
||||||
* - [SecureSettingsStore] (EncryptedSharedPreferences, AndroidKeyStore-
|
* store for unit tests.
|
||||||
* backed) for anything marked sensitive. Secrets never touch the
|
|
||||||
* unencrypted store.
|
|
||||||
*/
|
*/
|
||||||
interface SettingsStore {
|
interface SettingsStore {
|
||||||
suspend fun getString(key: String): String?
|
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()
|
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<Long> = 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<String> = prefs.all.keys
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In-memory store: used by unit tests. */
|
/** In-memory store: used by unit tests. */
|
||||||
class InMemorySettingsStore : SettingsStore {
|
class InMemorySettingsStore : SettingsStore {
|
||||||
private val map = mutableMapOf<String, String>()
|
private val map = mutableMapOf<String, String>()
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
package com.shonar.ui.detail
|
package com.shonar.ui.detail
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Edit
|
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.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
|
@ -29,15 +21,11 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.TabRow
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
|
@ -47,15 +35,12 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.shonar.ShonarApplication
|
import com.shonar.ShonarApplication
|
||||||
import com.shonar.recording.SUMMARY_LIST_KEYS
|
import java.text.DateFormat
|
||||||
import com.shonar.recording.SummaryData
|
import java.util.Date
|
||||||
import com.shonar.recording.TranscriptData
|
|
||||||
import com.shonar.recording.activeSegmentIndex
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M8 recording details: playback synced to the transcript, summary with
|
* Recording details: playback, rename, file info. Local-only — no tabs,
|
||||||
* action items, processing status, and editing. Everything server-side
|
* no server content.
|
||||||
* degrades to an honest empty state — playback and rename always work.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DetailsScreen(recordingId: String, onBack: () -> Unit) {
|
fun DetailsScreen(recordingId: String, onBack: () -> Unit) {
|
||||||
|
|
@ -127,42 +112,18 @@ private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) {
|
private fun ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) {
|
||||||
var tab by remember { mutableIntStateOf(0) }
|
|
||||||
PlaybackCard(ready = ready, onToggle = vm::togglePlayback,
|
PlaybackCard(ready = ready, onToggle = vm::togglePlayback,
|
||||||
onSeek = vm::seekTo, onSpeed = vm::cycleSpeed)
|
onSeek = vm::seekTo, onSpeed = vm::cycleSpeed)
|
||||||
ready.playbackError?.let {
|
ready.playbackError?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error,
|
Text(it, color = MaterialTheme.colorScheme.error,
|
||||||
style = MaterialTheme.typography.bodySmall)
|
style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
TabRow(selectedTabIndex = tab) {
|
ready.saveError?.let {
|
||||||
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Transcript") })
|
Text(it, color = MaterialTheme.colorScheme.error,
|
||||||
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Summary") })
|
style = MaterialTheme.typography.bodySmall)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
InfoCard(ready = ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -203,293 +164,25 @@ private fun PlaybackCard(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.TranscriptTab(
|
private fun InfoCard(ready: DetailsViewModel.Ready) {
|
||||||
transcript: TranscriptData?,
|
val r = ready.recording
|
||||||
ai: DetailsViewModel.AiState,
|
Card(Modifier.fillMaxWidth()) {
|
||||||
positionMs: Long,
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
canEdit: Boolean,
|
InfoRow("Length", formatMs(r.durationMs))
|
||||||
busy: String?,
|
InfoRow("Size", formatBytes(r.sizeBytes))
|
||||||
saveError: String?,
|
InfoRow("Recorded", DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
|
||||||
onSeek: (Long) -> Unit,
|
.format(Date(r.createdAtEpochMs)))
|
||||||
onSave: (String) -> Unit,
|
InfoRow("File", r.filePath.substringAfterLast('/'))
|
||||||
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<String, Any?>) -> 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<String, Any?>) -> 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<String> =
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AiEmptyState(ai: DetailsViewModel.AiState, what: String) {
|
private fun InfoRow(label: String, value: String) {
|
||||||
when (ai) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
DetailsViewModel.AiState.Loading -> LoadingRow()
|
Text(label, style = MaterialTheme.typography.labelMedium,
|
||||||
is DetailsViewModel.AiState.Unavailable -> Text(
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
ai.reason.ifBlank { "No $what yet." },
|
Text(value, style = MaterialTheme.typography.bodyMedium)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,3 +198,9 @@ private fun formatMs(ms: Long): String {
|
||||||
val total = (ms / 1000).coerceAtLeast(0)
|
val total = (ms / 1000).coerceAtLeast(0)
|
||||||
return "%d:%02d".format(total / 60, total % 60)
|
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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.shonar.ShonarApplication
|
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.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.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -28,28 +15,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M8 details screen: local playback (seek + speed) with the server transcript
|
* Details: local playback (seek + speed), rename, file info. No accounts,
|
||||||
* synced to the playhead, summary/action items, processing jobs, and editing.
|
* no servers — everything here works offline on the on-phone library.
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
class DetailsViewModel(
|
class DetailsViewModel(
|
||||||
private val app: ShonarApplication,
|
private val app: ShonarApplication,
|
||||||
private val recordingId: String,
|
private val recordingId: String,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
sealed interface AiState {
|
|
||||||
data object Loading : AiState
|
|
||||||
data class Ready(val jobs: List<JobInfo>) : 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(
|
data class Ready(
|
||||||
val recording: RecordingEntity,
|
val recording: RecordingEntity,
|
||||||
val playing: Boolean = false,
|
val playing: Boolean = false,
|
||||||
|
|
@ -57,12 +30,6 @@ class DetailsViewModel(
|
||||||
val durationMs: Long = 0,
|
val durationMs: Long = 0,
|
||||||
val speed: Float = 1f,
|
val speed: Float = 1f,
|
||||||
val playbackError: String? = null,
|
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,
|
val saveError: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -73,8 +40,6 @@ class DetailsViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
private val dao = app.database.recordingDao()
|
private val dao = app.database.recordingDao()
|
||||||
private val registry = app.providerRegistry
|
|
||||||
private val settings = app.settingsManager
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow<UiState>(UiState.Loading)
|
private val _state = MutableStateFlow<UiState>(UiState.Loading)
|
||||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||||
|
|
@ -105,104 +70,7 @@ class DetailsViewModel(
|
||||||
_state.value = UiState.Error("Recording not found.")
|
_state.value = UiState.Error("Recording not found.")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val ready = Ready(recording = entity, durationMs = entity.durationMs)
|
_state.value = UiState.ReadyState(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<String, Any?>) {
|
|
||||||
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.")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,14 +89,8 @@ class DetailsViewModel(
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
_state.value = UiState.ReadyState(
|
_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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<String?>(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") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
package com.shonar.ui.home
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Mic
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
|
@ -38,25 +42,21 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import android.Manifest
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import com.shonar.ShonarApplication
|
import com.shonar.ShonarApplication
|
||||||
import com.shonar.settings.BuiltInSettings
|
import com.shonar.settings.BuiltInSettings
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home: offline recording, local library, provider selection, and settings.
|
* Home: record, local library, settings. Everything stays on this phone —
|
||||||
* a Settings entry, and the first-launch recording-consent notice which must
|
* no accounts, no servers. File sync (if wanted) is the user's own tooling.
|
||||||
* be acknowledged before anything else.
|
* Includes the first-launch recording-consent notice which must be
|
||||||
|
* acknowledged before anything else.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenStorage: () -> Unit,
|
|
||||||
onOpenDetail: (String) -> Unit = {},
|
onOpenDetail: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
@ -68,19 +68,6 @@ fun HomeScreen(
|
||||||
val notice by vm.notice.collectAsState()
|
val notice by vm.notice.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
|
var consentSeen by remember { mutableStateOf<Boolean?>(null) }
|
||||||
var storageHeadline by remember { mutableStateOf<String?>(null) }
|
|
||||||
var storagePath by remember { mutableStateOf<String?>(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(
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { granted -> if (granted) vm.start() }
|
) { granted -> if (granted) vm.start() }
|
||||||
|
|
@ -95,20 +82,11 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(headlineTick) {
|
LaunchedEffect(Unit) {
|
||||||
app.settingsManager.ensureLoaded()
|
app.settingsManager.ensureLoaded()
|
||||||
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
|
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() }
|
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(
|
Scaffold(
|
||||||
|
|
@ -141,16 +119,12 @@ fun HomeScreen(
|
||||||
) {
|
) {
|
||||||
Text("SHONAR", style = MaterialTheme.typography.headlineMedium)
|
Text("SHONAR", style = MaterialTheme.typography.headlineMedium)
|
||||||
Text(
|
Text(
|
||||||
"Self-hosted Oral Notes and Audio Recorder",
|
"Oral Notes and Audio Recorder — everything stays on this phone",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
storageHeadline?.let { headline ->
|
StorageChip()
|
||||||
StorageChip(headline = headline, onClick = onOpenStorage)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
QuickCard("Choose storage", storagePath ?: storageHeadline ?: "Where recordings live",
|
|
||||||
Icons.Filled.Cloud, onOpenStorage)
|
|
||||||
QuickCard("Settings", "Appearance, custom settings",
|
QuickCard("Settings", "Appearance, custom settings",
|
||||||
Icons.Filled.Settings, onOpenSettings)
|
Icons.Filled.Settings, onOpenSettings)
|
||||||
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) {
|
||||||
|
|
@ -181,7 +155,7 @@ fun HomeScreen(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
@ -311,19 +285,18 @@ private fun formatBytes(bytes: Long): String = when {
|
||||||
else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
|
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
|
@Composable
|
||||||
private fun StorageChip(headline: String, onClick: () -> Unit) {
|
private fun StorageChip() {
|
||||||
Card(onClick = onClick,
|
Card(colors = CardDefaults.cardColors(
|
||||||
colors = CardDefaults.cardColors(
|
containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer)) {
|
|
||||||
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(Icons.Filled.Cloud, contentDescription = null,
|
Icon(Icons.Filled.PhoneAndroid, contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSecondaryContainer)
|
tint = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text("Stored: $headline",
|
Text("Stored: on this phone",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer)
|
color = MaterialTheme.colorScheme.onSecondaryContainer)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<String?>(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>(SelectionUiState.Idle)
|
|
||||||
val state: StateFlow<SelectionUiState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
val options: List<ProviderOption> = 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<ProviderOption> get() = options.filter { !it.advanced }
|
|
||||||
|
|
||||||
/** Self-hosted server choices, behind the Advanced disclosure. */
|
|
||||||
val advancedOptions: List<ProviderOption> 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
|
|
||||||
}
|
|
||||||
|
|
@ -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") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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>(UiState.Loading)
|
|
||||||
val state: StateFlow<UiState> = _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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Cleartext HTTP is permitted ONLY for local self-hosted servers reached on a
|
|
||||||
private network or localhost (a SHONAR provider, Nextcloud, etc.) that a
|
|
||||||
user explicitly configures. Android cannot restrict cleartext to IP ranges,
|
|
||||||
so this is paired with app-level URL validation that rejects cleartext for
|
|
||||||
non-private hosts, and with an explicit in-app warning whenever the
|
|
||||||
configured server URL is http://.
|
|
||||||
|
|
||||||
TLS/certificate verification is NEVER disabled. Self-signed certificates on
|
|
||||||
LAN servers are handled by explicit user approval of the specific
|
|
||||||
certificate (trust-on-first-use, recorded in secure storage), never by a
|
|
||||||
global bypass.
|
|
||||||
-->
|
|
||||||
<network-security-config>
|
|
||||||
<base-config cleartextTrafficPermitted="false" />
|
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
|
||||||
<domain includeSubdomains="true">localhost</domain>
|
|
||||||
<domain includeSubdomains="true">.local</domain>
|
|
||||||
<domain includeSubdomains="true">10.0.0.0</domain>
|
|
||||||
<domain includeSubdomains="true">192.168.0.0</domain>
|
|
||||||
<domain includeSubdomains="true">172.16.0.0</domain>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
|
||||||
|
|
@ -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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Int, ByteArray> = mutableMapOf(),
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Recording(
|
|
||||||
val id: String,
|
|
||||||
val title: String,
|
|
||||||
val mime: String,
|
|
||||||
val bytes: ByteArray,
|
|
||||||
val durationSeconds: Double,
|
|
||||||
)
|
|
||||||
|
|
||||||
val sessions = mutableMapOf<String, Session>()
|
|
||||||
val recordings = mutableMapOf<String, Recording>() // id -> recording
|
|
||||||
val finalizedByClientId = mutableMapOf<String, String>() // client id -> recording id
|
|
||||||
val logouts = mutableListOf<String>()
|
|
||||||
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<String> =
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String>()
|
|
||||||
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<String>()
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<String, ByteArray>()
|
|
||||||
val dirs = mutableSetOf<String>()
|
|
||||||
|
|
||||||
var pollApproved = false
|
|
||||||
val pollToken = "poll-token-1"
|
|
||||||
|
|
||||||
var chunkPuts = 0
|
|
||||||
val chunkNames = mutableListOf<String>()
|
|
||||||
val chunkHeaders = mutableListOf<Map<String, String?>>()
|
|
||||||
var lastMove: Map<String, String?> = 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<String>()
|
|
||||||
// Self first, then direct children — like the real server.
|
|
||||||
if (dir in dirs || dir == davRoot) children += dir
|
|
||||||
val seen = mutableSetOf<String>()
|
|
||||||
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
|
|
||||||
"""<d:response><d:href>${encPath(child)}</d:href><d:propstat><d:prop>""" +
|
|
||||||
(if (!isDir) "<d:getcontentlength>$size</d:getcontentlength>" else "") +
|
|
||||||
(if (!isDir) """<d:getetag>"etag-${child.hashCode()}"</d:getetag>""" else "") +
|
|
||||||
(if (!isDir) """<d:getcontenttype>audio/mp4</d:getcontenttype>""" else "") +
|
|
||||||
"""<d:getlastmodified>Tue, 02 Jan 2026 03:04:05 GMT</d:getlastmodified>""" +
|
|
||||||
(if (isDir) "<d:resourcetype><d:collection/></d:resourcetype>" else "<d:resourcetype/>") +
|
|
||||||
"""</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>"""
|
|
||||||
}
|
|
||||||
MockResponse().setResponseCode(207)
|
|
||||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
|
||||||
.setBody("""<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">$items</d:multistatus>""")
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Float>()
|
|
||||||
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<String> =
|
|
||||||
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<AuthState>) {
|
|
||||||
assertNotNull(flow.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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() }
|
|
||||||
}
|
|
||||||
|
|
@ -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<String> =
|
|
||||||
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<String>(), 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<String>(), tofu.hosts())
|
|
||||||
assertEquals(emptySet<String>(), tofu.pins("nas.local"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,8 @@
|
||||||
package com.shonar.recording
|
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 com.shonar.provider.SyncState
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
/** In-memory RecordingDao: no Android needed. Shared by recording tests. */
|
/** In-memory RecordingDao: no Android needed. Shared by recording tests. */
|
||||||
|
|
@ -27,6 +14,11 @@ internal class FakeDao(initial: List<RecordingEntity> = emptyList()) : Recording
|
||||||
override suspend fun insert(recording: RecordingEntity) {
|
override suspend fun insert(recording: RecordingEntity) {
|
||||||
rows[recording.id] = recording
|
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) {
|
override suspend fun update(recording: RecordingEntity) {
|
||||||
check(rows.containsKey(recording.id)) { "no such row ${recording.id}" }
|
check(rows.containsKey(recording.id)) { "no such row ${recording.id}" }
|
||||||
rows[recording.id] = recording
|
rows[recording.id] = recording
|
||||||
|
|
@ -36,40 +28,6 @@ internal class FakeDao(initial: List<RecordingEntity> = 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<AuthState> = states
|
|
||||||
val uploadedKeys = mutableListOf<String>()
|
|
||||||
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<com.shonar.provider.RemoteRecording> =
|
|
||||||
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(
|
internal fun entity(
|
||||||
id: String,
|
id: String,
|
||||||
dir: File,
|
dir: File,
|
||||||
|
|
|
||||||
|
|
@ -1,199 +1,75 @@
|
||||||
package com.shonar.recording
|
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 com.shonar.provider.SyncState
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Assert.fail
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class MigrationRunnerTest {
|
class MigrationVersionsTest {
|
||||||
|
|
||||||
@Test fun allSucceed_marksUploadedWithSlots() = kotlinx.coroutines.runBlocking {
|
@Test fun v3() {
|
||||||
val dir = tmpDir("mig-ok")
|
assertEquals(2, MIGRATION_2_3.startVersion)
|
||||||
try {
|
assertEquals(3, MIGRATION_2_3.endVersion)
|
||||||
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 failure_recordsErrorAndContinues() = kotlinx.coroutines.runBlocking {
|
@Test fun v4_uniqueFilePath() {
|
||||||
val dir = tmpDir("mig-partial")
|
assertEquals(3, MIGRATION_3_4.startVersion)
|
||||||
try {
|
assertEquals(4, MIGRATION_3_4.endVersion)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private fun repo(dir: File, dao: FakeDao): RecordingRepository {
|
||||||
val ghost = RecordingEntity(
|
// rootResolver returns the dir; refreshRoot/import do the rest.
|
||||||
id = "g", title = "ghost", createdAtEpochMs = 1L, durationMs = 1,
|
return RecordingRepository(dir, dao) { dir }
|
||||||
filePath = "/nonexistent-dir-xyz/nope.m4a", mimeType = "audio/mp4", sizeBytes = 0,
|
}
|
||||||
)
|
|
||||||
assertNull(SyncSlots(FakeDao()).draftFor(ghost))
|
@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() {
|
@Test fun entityDefaults_readAsNeverSyncedLocal() {
|
||||||
// A v1-shaped row (new columns absent) must behave as an unsynced local.
|
|
||||||
val row = RecordingEntity(
|
val row = RecordingEntity(
|
||||||
id = "v1", title = "old", createdAtEpochMs = 1L, durationMs = 1,
|
id = "v1", title = "old", createdAtEpochMs = 1L, durationMs = 1,
|
||||||
filePath = "/x.m4a", mimeType = "audio/mp4", sizeBytes = 10,
|
filePath = "/x.m4a", mimeType = "audio/mp4", sizeBytes = 10,
|
||||||
)
|
)
|
||||||
assertEquals("local-only", row.originProviderId)
|
assertEquals("local-only", row.originProviderId)
|
||||||
assertNull(row.remoteKey)
|
|
||||||
assertEquals(SyncState.LOCAL_ONLY, row.syncState)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ class RecordingRenameTest {
|
||||||
rows[recording.id] = recording
|
rows[recording.id] = recording
|
||||||
emit()
|
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) {
|
override suspend fun update(recording: RecordingEntity) {
|
||||||
rows[recording.id] = recording
|
rows[recording.id] = recording
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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 ###
|
|
||||||
|
|
@ -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;")
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder."""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
|
|
@ -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)]
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -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(),
|
|
||||||
}
|
|
||||||
|
|
@ -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),
|
|
||||||
)
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
"""DB package. Importing this registers every model on Base.metadata."""
|
|
||||||
|
|
||||||
from shonar.db import models # noqa: F401
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -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}")
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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-<name>)."""
|
|
||||||
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
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue