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:
avi 2026-09-14 11:56:20 -05:00
commit 8c510f4ab9
154 changed files with 196 additions and 23570 deletions

View file

@ -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

View file

@ -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
View file

@ -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

View file

@ -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)
} }

View file

@ -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"

View file

@ -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() }) }
} }

View file

@ -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)
)
} }
} }

View file

@ -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)
}
}
}
}

View file

@ -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"
}
}

View file

@ -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) }
}
}
}

View file

@ -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()
}

View file

@ -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])"
}

View file

@ -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)
}
}
}
}

View file

@ -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,
)
}
}
}

View file

@ -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)
}

View file

@ -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])"
}

View file

@ -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()
}
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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()
}
}
}

View file

@ -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()
}

View file

@ -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
}
}
}

View file

@ -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)

View file

@ -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,

View file

@ -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. */

View file

@ -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",

View file

@ -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)")
}
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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))
}
}
}
}

View file

@ -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",

View file

@ -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())

View file

@ -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>()

View file

@ -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,
positionMs: Long,
canEdit: Boolean,
busy: String?,
saveError: String?,
onSeek: (Long) -> Unit,
onSave: (String) -> Unit,
onDismissError: () -> Unit,
) {
var editing by remember { mutableStateOf(false) }
when {
transcript == null -> AiEmptyState(ai, what = "transcript")
transcript.segments.isEmpty() -> {
Text(transcript.text.ifBlank { "Transcript is empty." })
EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true })
}
else -> {
val active = activeSegmentIndex(transcript.segments, positionMs)
val listState = rememberLazyListState()
LaunchedEffect(active) {
if (active >= 0) listState.animateScrollToItem(active)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
(if (transcript.editedByUser) "Edited by you" else "Auto transcript") +
" · v${transcript.version}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true })
}
LazyColumn(
Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(transcript.segments, key = { i, s -> "$i-${s.startSec}" }) { i, seg ->
val isActive = i == active
Card(
colors = if (isActive) {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
} else {
CardDefaults.cardColors()
},
modifier = Modifier.fillMaxWidth()
.clickable { onSeek((seg.startSec * 1000).toLong()) },
) {
Column(Modifier.padding(10.dp)) {
Text(
formatMs((seg.startSec * 1000).toLong()) +
(seg.speaker?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(seg.text, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
}
saveError?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.clickable { onDismissError() })
}
if (editing && transcript != null) {
var draft by remember(transcript.version) { mutableStateOf(transcript.text) }
AlertDialog(
onDismissRequest = { editing = false },
title = { Text("Edit transcript") },
text = {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.fillMaxWidth().height(220.dp),
)
},
confirmButton = {
TextButton(
onClick = { onSave(draft); editing = false },
enabled = draft.isNotBlank(),
) { Text("Save") }
},
dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun ColumnScope.SummaryTab(
summary: SummaryData?,
ai: DetailsViewModel.AiState,
canEdit: Boolean,
busy: String?,
saveError: String?,
onSave: (Map<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()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("${job.jobType}: ${job.status}", InfoRow("Length", formatMs(r.durationMs))
style = MaterialTheme.typography.titleSmall) InfoRow("Size", formatBytes(r.sizeBytes))
if (job.error != null) { InfoRow("Recorded", DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
Text(job.error, style = MaterialTheme.typography.bodySmall, .format(Date(r.createdAtEpochMs)))
color = MaterialTheme.colorScheme.error) InfoRow("File", r.filePath.substringAfterLast('/'))
} else { }
Text("attempt ${job.attempt}/${job.maxAttempts}", }
style = MaterialTheme.typography.bodySmall, }
@Composable
private fun InfoRow(label: String, value: String) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
} Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
}
@Composable
private fun AiEmptyState(ai: DetailsViewModel.AiState, what: String) {
when (ai) {
DetailsViewModel.AiState.Loading -> LoadingRow()
is DetailsViewModel.AiState.Unavailable -> Text(
ai.reason.ifBlank { "No $what yet." },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is DetailsViewModel.AiState.Error -> Text(
ai.message,
color = MaterialTheme.colorScheme.error,
)
is DetailsViewModel.AiState.Ready -> Text(
"No $what yet — processing may still be running. Check Status.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
}
@Composable
private fun EditRow(canEdit: Boolean, busy: String?, onEdit: () -> Unit) {
if (!canEdit) return
if (busy != null) {
Text(busy, style = MaterialTheme.typography.labelMedium)
} else {
TextButton(onClick = onEdit) { Text("Edit") }
}
}
@Composable
private fun SummarySection(title: String, body: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(body, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun Bullet(text: String) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("•", style = MaterialTheme.typography.bodyMedium)
Text(text, style = MaterialTheme.typography.bodyMedium)
} }
} }
@ -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))
}

View file

@ -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))
}
} }
} }

View file

@ -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") } },
)
}
}

View file

@ -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)
} }

View file

@ -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)
}
}
}
}

View file

@ -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
}

View file

@ -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") }
},
)
}

View file

@ -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)
}
}
}
}

View file

@ -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>

View file

@ -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"))
}
}

View file

@ -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))
}
}

View file

@ -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"))
}
}
}

View file

@ -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"))
}
}
}

View file

@ -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) {
}
}
}

View file

@ -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())
}
}

View file

@ -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())
}
}

View file

@ -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()
}
}
}

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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() }
}

View file

@ -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"))
}
}

View file

@ -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")))
}
}

View file

@ -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,

View 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)
} }
} }

View file

@ -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

View file

@ -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)
}
}

View file

@ -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()
}
}
}

View file

@ -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,
),
)
}
}

View file

@ -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`

View file

@ -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

View file

@ -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()

View file

@ -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"}

View file

@ -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 ###

View file

@ -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;")

View file

@ -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")

View file

@ -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

View file

@ -1,3 +0,0 @@
"""S.H.O.N.A.R. — Self-hosted Oral Notes and Audio Recorder."""
__version__ = "0.1.0"

View file

@ -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)]

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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}

View file

@ -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(),
}

View file

@ -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),
)

View file

@ -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,
}

View file

@ -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

View file

@ -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",
},
)

View file

@ -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

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -1,3 +0,0 @@
"""DB package. Importing this registers every model on Base.metadata."""
from shonar.db import models # noqa: F401

View file

@ -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
)

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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"}

View file

@ -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}")

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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