From aad7cad78d0f571810f197425c1d9ba3fc7ff191 Mon Sep 17 00:00:00 2001 From: avi Date: Sat, 12 Sep 2026 11:29:55 -0500 Subject: [PATCH] P3-M7: providers, background sync, AI pipeline P3 CustomShonarProvider (auth, chunked uploads, secure token store). P4 NextcloudProvider (login flow v2, DAV, chunking) + FolderSyncProvider (Syncthing-style). P5 TOFU pinning + redacting logger + leak tests. P6a generic hosted setup (Start9/Umbrel URL + auto-detect). P7 provider switching (Room v2 slot, Storage screen, foreground migrator). M5 WorkManager drain (Room v3 backoff). M7 AI pipeline (4 adapters, arq worker, transcript/summary/jobs endpoints). Docs updated throughout. --- .github/workflows/ci.yml | 2 - .gitignore | 1 + CONTRIBUTING.md | 4 +- README.md | 6 +- android/app/build.gradle.kts | 14 + android/app/src/main/AndroidManifest.xml | 6 +- .../src/main/java/com/shonar/MainActivity.kt | 24 +- .../main/java/com/shonar/ShonarApplication.kt | 99 ++- .../shonar/provider/CustomShonarProvider.kt | 573 ++++++++++++++++ .../com/shonar/provider/FolderSyncProvider.kt | 160 +++++ .../java/com/shonar/provider/NextcloudAuth.kt | 157 +++++ .../com/shonar/provider/NextcloudAuthStore.kt | 55 ++ .../com/shonar/provider/NextcloudProvider.kt | 644 ++++++++++++++++++ .../com/shonar/provider/ProviderRegistry.kt | 56 +- .../java/com/shonar/provider/ProviderTypes.kt | 27 + .../com/shonar/provider/ShonarAuthStore.kt | 83 +++ .../com/shonar/provider/ShonarHandshake.kt | 91 +++ .../java/com/shonar/provider/SyncState.kt | 42 ++ .../java/com/shonar/provider/TlsPolicy.kt | 186 +++++ .../java/com/shonar/provider/TofuTrust.kt | 287 ++++++++ .../com/shonar/recording/MigrationRunner.kt | 89 +++ .../java/com/shonar/recording/RecordingDao.kt | 29 + .../com/shonar/recording/RecordingEntity.kt | 38 ++ .../shonar/recording/RecordingRepository.kt | 76 +++ .../com/shonar/recording/RecordingService.kt | 242 +++++++ .../com/shonar/recording/ShonarDatabase.kt | 41 ++ .../java/com/shonar/recording/SyncDrain.kt | 94 +++ .../java/com/shonar/recording/SyncSlots.kt | 116 ++++ .../shonar/recording/SyncStateConverter.kt | 16 + .../java/com/shonar/recording/SyncWorker.kt | 141 ++++ .../com/shonar/settings/BuiltInSettings.kt | 22 + .../java/com/shonar/ui/home/HomeScreen.kt | 180 ++++- .../com/shonar/ui/home/RecordingViewModel.kt | 70 ++ .../ui/provider/ProviderSelectionScreen.kt | 357 ++++++++++ .../ui/provider/ProviderSelectionViewModel.kt | 564 +++++++++++++++ .../com/shonar/ui/provider/StorageScreen.kt | 250 +++++++ .../shonar/ui/provider/StorageViewModel.kt | 202 ++++++ .../provider/CustomShonarProviderAuthTest.kt | 199 ++++++ .../provider/CustomShonarProviderTest.kt | 315 +++++++++ .../shonar/provider/FolderSyncProviderTest.kt | 104 +++ .../com/shonar/provider/LoggingLeakTest.kt | 211 ++++++ .../shonar/provider/NextcloudProviderTest.kt | 531 +++++++++++++++ .../shonar/provider/ProviderContractTest.kt | 3 + .../java/com/shonar/provider/ServerUrlTest.kt | 15 + .../test/java/com/shonar/provider/TofuTest.kt | 241 +++++++ .../java/com/shonar/recording/Fixtures.kt | 87 +++ .../com/shonar/recording/MigrationTest.kt | 199 ++++++ .../com/shonar/recording/SyncDrainTest.kt | 204 ++++++ .../com/shonar/ui/provider/HostedRouteTest.kt | 79 +++ android/build.gradle.kts | 1 + backend/shonar/api/schemas_recordings.py | 39 ++ backend/shonar/api/v1/__init__.py | 5 +- backend/shonar/api/v1/provider_info.py | 38 ++ backend/shonar/api/v1/recordings.py | 75 ++ backend/shonar/db/session.py | 6 + backend/shonar/services/ai/__init__.py | 159 +++++ backend/shonar/services/ai/_llm.py | 35 + backend/shonar/services/ai/faster_whisper.py | 66 ++ backend/shonar/services/ai/ollama.py | 72 ++ backend/shonar/services/ai/openai_compat.py | 82 +++ backend/shonar/services/ai/whisper_http.py | 127 ++++ backend/shonar/services/processing.py | 461 +++++++++++++ backend/shonar/services/uploads.py | 10 + backend/shonar/worker.py | 62 ++ backend/tests/test_ai_adapters.py | 184 +++++ backend/tests/test_ai_pipeline.py | 375 ++++++++++ backend/tests/test_provider_info.py | 35 + backend/tests/test_recordings.py | 3 +- docs/ROADMAP.md | 10 +- docs/server-providers.md | 16 +- scripts/dev_bootstrap.sh | 9 +- shared/openapi.json | 400 +++++++++++ worker/README.md | 22 +- 73 files changed, 9470 insertions(+), 54 deletions(-) create mode 100644 android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt create mode 100644 android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt create mode 100644 android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt create mode 100644 android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt create mode 100644 android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt create mode 100644 android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt create mode 100644 android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt create mode 100644 android/app/src/main/java/com/shonar/provider/SyncState.kt create mode 100644 android/app/src/main/java/com/shonar/provider/TlsPolicy.kt create mode 100644 android/app/src/main/java/com/shonar/provider/TofuTrust.kt create mode 100644 android/app/src/main/java/com/shonar/recording/MigrationRunner.kt create mode 100644 android/app/src/main/java/com/shonar/recording/RecordingDao.kt create mode 100644 android/app/src/main/java/com/shonar/recording/RecordingEntity.kt create mode 100644 android/app/src/main/java/com/shonar/recording/RecordingRepository.kt create mode 100644 android/app/src/main/java/com/shonar/recording/RecordingService.kt create mode 100644 android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt create mode 100644 android/app/src/main/java/com/shonar/recording/SyncDrain.kt create mode 100644 android/app/src/main/java/com/shonar/recording/SyncSlots.kt create mode 100644 android/app/src/main/java/com/shonar/recording/SyncStateConverter.kt create mode 100644 android/app/src/main/java/com/shonar/recording/SyncWorker.kt create mode 100644 android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt create mode 100644 android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt create mode 100644 android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt create mode 100644 android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt create mode 100644 android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt create mode 100644 android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt create mode 100644 android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt create mode 100644 android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt create mode 100644 android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt create mode 100644 android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt create mode 100644 android/app/src/test/java/com/shonar/provider/TofuTest.kt create mode 100644 android/app/src/test/java/com/shonar/recording/Fixtures.kt create mode 100644 android/app/src/test/java/com/shonar/recording/MigrationTest.kt create mode 100644 android/app/src/test/java/com/shonar/recording/SyncDrainTest.kt create mode 100644 android/app/src/test/java/com/shonar/ui/provider/HostedRouteTest.kt create mode 100644 backend/shonar/api/v1/provider_info.py create mode 100644 backend/shonar/services/ai/__init__.py create mode 100644 backend/shonar/services/ai/_llm.py create mode 100644 backend/shonar/services/ai/faster_whisper.py create mode 100644 backend/shonar/services/ai/ollama.py create mode 100644 backend/shonar/services/ai/openai_compat.py create mode 100644 backend/shonar/services/ai/whisper_http.py create mode 100644 backend/shonar/services/processing.py create mode 100644 backend/shonar/worker.py create mode 100644 backend/tests/test_ai_adapters.py create mode 100644 backend/tests/test_ai_pipeline.py create mode 100644 backend/tests/test_provider_info.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cddbcef..2ee6c1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,4 @@ jobs: - uses: actions/setup-java@v4 with: { distribution: temurin, java-version: 17 } - uses: gradle/actions/setup-gradle@v4 - # Skipped until the Android project lands (M3). - run: ./gradlew test assembleDebug --no-daemon - continue-on-error: ${{ !hashFiles('android/**/build.gradle.kts') }} diff --git a/.gitignore b/.gitignore index ffde9f1..f174b13 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ data/ # --- Android / Gradle --- android/.gradle/ +android/.kotlin/ android/build/ android/app/build/ *.apk diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da7c87f..879c659 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,10 +18,8 @@ Thanks for your interest in improving S.H.O.N.A.R.! ```bash # Backend +./scripts/dev_bootstrap.sh 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 && .venv/bin/ruff check . diff --git a/README.md b/README.md index c71ad90..4d86d7d 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,11 @@ buildable. See [docs/ROADMAP.md](docs/ROADMAP.md) for the maintained matrix. - 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`. -- Recording engine, provider sync, playback, AI pipeline: TODO per roadmap. +- 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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cf321c9..ad7b917 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.kapt") id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.serialization") } @@ -48,6 +49,9 @@ dependencies { implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.navigation:navigation-compose:2.8.4") + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + kapt("androidx.room:room-compiler:2.6.1") implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") @@ -62,12 +66,22 @@ dependencies { // networking (server provider APIs over HTTPS) implementation("com.squareup.okhttp3:okhttp:4.12.0") + // background sync driver (M5) + implementation("androidx.work:work-runtime-ktx:2.9.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") // tests testImplementation("junit:junit:4.13.2") + // Real org.json for JVM unit tests: the android.jar stubs throw + // ("Method ... not mocked"), which would kill any MockWebServer + // dispatcher or provider JSON parsing under test. + testImplementation("org.json:json:20240303") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + // In-test certificate authority for the P5 TOFU tests (self-signed + // fixtures generated at runtime, no checked-in keys). + testImplementation("com.squareup.okhttp3:okhttp-tls:4.12.0") + testImplementation("androidx.room:room-testing:2.6.1") androidTestImplementation(composeBom) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f2164b4..ef485e9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,8 +4,6 @@ - @@ -32,5 +30,9 @@ + diff --git a/android/app/src/main/java/com/shonar/MainActivity.kt b/android/app/src/main/java/com/shonar/MainActivity.kt index 9ee0703..d279e4d 100644 --- a/android/app/src/main/java/com/shonar/MainActivity.kt +++ b/android/app/src/main/java/com/shonar/MainActivity.kt @@ -10,16 +10,16 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.shonar.ui.home.HomeScreen +import com.shonar.ui.provider.ProviderSelectionScreen +import com.shonar.ui.provider.StorageScreen import com.shonar.ui.settings.SettingsScreen import com.shonar.ui.theme.ShonarTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val app = application as ShonarApplication setContent { ShonarTheme { val nav = rememberNavController() @@ -28,7 +28,24 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background, ) { NavHost(navController = nav, startDestination = "home") { - composable("home") { HomeScreen(onOpenSettings = { nav.navigate("settings") }) } + composable("home") { + HomeScreen( + onOpenSettings = { nav.navigate("settings") }, + onOpenStorage = { nav.navigate("storage") }, + ) + } + composable("storage") { + StorageScreen( + onBack = { nav.popBackStack() }, + onSwitchProvider = { nav.navigate("provider") }, + ) + } + composable("provider") { + ProviderSelectionScreen( + onDone = { nav.popBackStack() }, + onBack = { nav.popBackStack() }, + ) + } composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) } } } @@ -36,4 +53,3 @@ class MainActivity : ComponentActivity() { } } } - diff --git a/android/app/src/main/java/com/shonar/ShonarApplication.kt b/android/app/src/main/java/com/shonar/ShonarApplication.kt index 4773c1b..6f81b1c 100644 --- a/android/app/src/main/java/com/shonar/ShonarApplication.kt +++ b/android/app/src/main/java/com/shonar/ShonarApplication.kt @@ -1,16 +1,113 @@ package com.shonar import android.app.Application +import androidx.room.Room +import com.shonar.recording.RecordingRepository +import com.shonar.recording.ShonarDatabase import com.shonar.settings.DataStoreSettingsStore import com.shonar.settings.SecureSettingsStore import com.shonar.settings.SettingsManager +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch class ShonarApplication : Application() { + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val database: ShonarDatabase by lazy { + Room.databaseBuilder(this, ShonarDatabase::class.java, "shonar.db") + .addMigrations( + com.shonar.recording.MIGRATION_1_2, + com.shonar.recording.MIGRATION_2_3, + ) + .build() + } + + val recordingRepository: RecordingRepository by lazy { + RecordingRepository(this, database.recordingDao()) + } + + val secureStore: SecureSettingsStore by lazy { SecureSettingsStore(this) } + val settingsManager: SettingsManager by lazy { SettingsManager( store = DataStoreSettingsStore(this), - secureStore = SecureSettingsStore(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 + * run on OkHttp threads that cannot suspend, so the flag is cached here + * and refreshed whenever settings change — toggling needs no restart. + */ + private val bodyLogging = AtomicBoolean(false) + + /** Server providers (P1 local-only, P3 custom SHONAR, P4 Nextcloud + sync folder). */ + val providerRegistry: com.shonar.provider.ProviderRegistry by lazy { + com.shonar.provider.ProviderRegistry.withDefaults( + appFilesDir = filesDir, + secureStore = secureStore, + plainStore = DataStoreSettingsStore(this), + tlsPolicy = com.shonar.provider.TlsPolicy( + tofu = tofu, + bodiesEnabled = bodyLogging::get, + ), + ) + } + + override fun onCreate() { + super.onCreate() + appScope.launch { + settingsManager.ensureLoaded() + // TOFU pins must be in cache before any TLS handshake needs them. + tofu.refresh() + syncBodyLoggingFlag() + scheduleSync() + settingsManager.valuesChanged.collect { + syncBodyLoggingFlag() + scheduleSync() + } + } + } + + /** M5: steady-state periodic drain plus an immediate drain at startup + * and whenever sync constraints change. Re-running on unrelated + * settings edits only refreshes the periodic schedule (cheap UPDATE), + * never a drain. */ + private var lastSyncFlags: Pair? = null + + private suspend fun scheduleSync() { + val flags = wifiOnly() to chargingOnly() + com.shonar.recording.SyncScheduler.ensurePeriodic(this, flags.first, flags.second) + if (lastSyncFlags == null || lastSyncFlags != flags) { + com.shonar.recording.SyncScheduler.requestNow(this, flags.first, flags.second) + } + lastSyncFlags = flags + } + + private suspend fun wifiOnly(): Boolean = runCatching { + settingsManager.bool(com.shonar.settings.BuiltInSettings.WIFI_ONLY_UPLOAD) + }.getOrDefault(true) + + private suspend fun chargingOnly(): Boolean = runCatching { + settingsManager.bool(com.shonar.settings.BuiltInSettings.CHARGING_ONLY_UPLOAD) + }.getOrDefault(false) + + private suspend fun syncBodyLoggingFlag() { + bodyLogging.set( + runCatching { settingsManager.bool("log_http_bodies") }.getOrDefault(false) ) } } diff --git a/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt b/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt new file mode 100644 index 0000000..a9d9624 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt @@ -0,0 +1,573 @@ +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: the backend has no sidecar endpoints until M7 (transcripts), + * so transcript/summary JSON is cached in app-private storage keyed by the + * remote recording id. Same layout as LocalOnlyProvider, so the M7 + * migration is a file walk, not a format change. + * + * 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(), +) : ShonarProvider { + + override val descriptor = ProviderDescriptor( + id = ProviderRegistry.CUSTOM_SHONAR_ID, + displayName = "Custom SHONAR server", + capabilities = setOf( + ProviderDescriptor.Capability.CHUNKED_UPLOAD, + ProviderDescriptor.Capability.ACCOUNT_DELETION, + ), + ) + + private val _authState = MutableStateFlow(AuthState.DISCONNECTED) + override val authState: StateFlow = _authState + + /** Base origin, e.g. https://shonar.example.com. Set by connect/reconnect. */ + private var origin: String? = null + + private val refreshMutex = Mutex() + + // ---- lifecycle --------------------------------------------------------- + + override suspend fun probe(baseUrl: ServerUrl): ProbeResult = handshake.probe(baseUrl) + + override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) { + when (credential) { + is ProviderCredential.ShonarLogin -> login( + credential.serverUrl.origin, credential.email, credential.password + ) + is ProviderCredential.OAuthTokens -> resumeWithTokens(credential) + else -> throw ProviderError.InvalidUrl( + "Custom SHONAR server needs an email + password login" + ) + } + } + + override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) { + val saved = auth.load() + if (saved == null || saved.refreshToken.isBlank()) { + _authState.value = AuthState.DISCONNECTED + return@withContext _authState.value + } + origin = saved.baseUrl + val base = saved.baseUrl + try { + executeAuthed(base) { token -> get(base, "/api/v1/auth/me", token) }.use { resp -> + if (resp.code != 200) throw ProviderError.AuthExpired() + } + _authState.value = AuthState.CONNECTED + } catch (e: ProviderError.AuthExpired) { + // Refresh already failed inside executeAuthed: tokens are dead. + auth.clearTokens() + _authState.value = AuthState.EXPIRED + } catch (e: ProviderError) { + _authState.value = AuthState.OFFLINE + } + _authState.value + } + + override suspend fun disconnect(revokeOnServer: Boolean) = withContext(Dispatchers.IO) { + if (revokeOnServer) { + // Best effort: local state is cleared even if revoke fails. + runCatching { + val saved = auth.load() + if (saved != null && saved.refreshToken.isNotBlank()) { + postUnauthed( + saved.baseUrl, "/api/v1/auth/logout", + """{"refresh_token":${jsonStr(saved.refreshToken)}}""", + ).close() + } + } + } + auth.clearTokens() + _authState.value = AuthState.DISCONNECTED + } + + override suspend fun deleteAccountAndData() { + // No password is available here (never stored), so server-side + // account purge needs the explicit [deleteAccount] call below. + // This path revokes the session and wipes everything local. + withContext(Dispatchers.IO) { + disconnect(revokeOnServer = true) + sidecarRoot.deleteRecursively() + } + } + + /** + * Full server-side account purge (backend: 30-day grace, then hard + * delete). Needs the password because it is never stored — call this + * from a confirmation screen that asks for it once. + */ + suspend fun deleteAccount(password: String) = withContext(Dispatchers.IO) { + val base = origin ?: auth.load()?.baseUrl ?: throw ProviderError.NotConnected() + executeAuthed(base) { token -> + post(base, "/api/v1/auth/delete-account", token, + """{"password":${jsonStr(password)}}""") + }.use { resp -> + if (resp.code != 202 && resp.code != 204) { + throw ProviderError.Transient("Account deletion refused (HTTP ${resp.code})") + } + } + auth.clearAll() + origin = null + sidecarRoot.deleteRecursively() + _authState.value = AuthState.DISCONNECTED + } + + // ---- storage ----------------------------------------------------------- + + override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef = + withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + val total = draft.sizeBytes.coerceAtLeast(1) + + val (sessionId, chunkSize) = createSession(base, draft) + val received = uploadStatus(base, sessionId).toMutableSet() + + var sent = 0L + // Account progress for already-present chunks so resume continues + // the bar instead of restarting it. + var idx = 0 + val chunkCount = ((draft.sizeBytes + chunkSize - 1) / chunkSize).toInt().coerceAtLeast(1) + var resumedBytes = 0L + // Estimate resumed bytes from the received set (last chunk may be short). + for (i in received) { + resumedBytes += if (i < chunkCount - 1) chunkSize.toLong() + else (draft.sizeBytes - chunkSize * (chunkCount - 1)).coerceAtLeast(0) + } + sent = resumedBytes + if (sent > 0) onProgress((sent.toFloat() / total).coerceIn(0f, 1f)) + + while (idx < chunkCount) { + // Cooperative cancellation between chunks; a cancelled + // upload leaves a resumable server session, never a + // half-visible object. + ensureActive() + if (idx !in received) { + val slice = readSlice(draft.sourceFile, idx.toLong() * chunkSize, chunkSize) + putChunk(base, sessionId, idx, slice) + sent += slice.size + onProgress((sent.toFloat() / total).coerceIn(0f, 1f)) + } + idx++ + } + + val recordingId = finalize(base, sessionId, draft) + onProgress(1f) + RemoteRef(ProviderRegistry.CUSTOM_SHONAR_ID, recordingId, etag = null, sizeBytes = draft.sizeBytes) + } + + override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) = + withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + executeAuthed(base) { token -> get(base, "/api/v1/recordings/${ref.key}/audio", token) }.use { resp -> + when (resp.code) { + 200 -> { + val body = resp.body ?: throw ProviderError.Transient("Empty download response") + val total = body.contentLength().takeIf { it > 0 } ?: -1 + dest.parentFile?.mkdirs() + body.byteStream().use { input -> + dest.outputStream().use { output -> + val buf = ByteArray(64 * 1024) + var written = 0L + var lastReported = -1f + while (true) { + val n = input.read(buf) + if (n < 0) break + output.write(buf, 0, n) + written += n + if (total > 0) { + val p = (written.toFloat() / total).coerceIn(0f, 1f) + if (p > lastReported) { + onProgress(p) + lastReported = p + } + } + } + } + } + onProgress(1f) + } + 404 -> throw ProviderError.NotFound(ref.key) + else -> throw ProviderError.Transient("Download failed (HTTP ${resp.code})") + } + } + } + + override suspend fun delete(ref: RemoteRef) { + withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + executeAuthed(base) { token -> + Request.Builder().url("$base/api/v1/recordings/${ref.key}?purge=true") + .delete().header("Authorization", "Bearer $token").build() + }.use { resp -> + when (resp.code) { + 204, 200 -> { + sidecarDir(ref).deleteRecursively() + } + 404 -> throw ProviderError.NotFound(ref.key) + else -> throw ProviderError.Transient("Delete failed (HTTP ${resp.code})") + } + } + } + } + + override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + val offset = cursor?.toIntOrNull()?.coerceAtLeast(0) ?: 0 + val limit = 200 + executeAuthed(base) { token -> + get(base, "/api/v1/recordings?limit=$limit&offset=$offset&sort=recorded_at&order=desc", token) + }.use { resp -> + if (resp.code != 200) throw ProviderError.Transient("Listing failed (HTTP ${resp.code})") + val root = org.json.JSONObject(resp.body?.string().orEmpty()) + val total = root.optInt("total", 0) + val items = root.optJSONArray("items") ?: org.json.JSONArray() + val out = mutableListOf() + for (i in 0 until items.length()) { + parseRecording(items.getJSONObject(i))?.let { out += it } + } + val next = if (offset + limit < total) (offset + limit).toString() else null + Page(out, next) + } + } + + // ---- 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":"SHONAR Android","platform":"android"}""" + 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 postUnauthed(base: String, path: String, json: String): Response { + val req = Request.Builder().url(base + path) + .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build() + return client.newCall(req).execute() + } + + // Upload-session flow returns Triple(sessionId, chunkSize, declaredMime echo not needed). + private suspend fun createSession(base: String, draft: RecordingDraft): Pair { + val body = """{"declared_mime_type":${jsonStr(draft.mime)},""" + + """"declared_size_bytes":${draft.sizeBytes},""" + + """"title":${jsonStr(draft.title)},""" + + """"client_recording_id":${jsonStr(draft.id)}}""" + executeAuthed(base) { token -> post(base, "/api/v1/uploads", token, body) }.use { resp -> + when (resp.code) { + 201 -> { + val json = org.json.JSONObject(resp.body?.string().orEmpty()) + return json.getString("id") to json.optInt("chunk_size_bytes", 16 * 1024 * 1024) + } + 413 -> throw ProviderError.Transient("Recording exceeds the server size limit") + 415 -> throw ProviderError.Transient("Audio type not accepted by the server") + else -> throw ProviderError.Transient("Upload rejected (HTTP ${resp.code})") + } + } + } + + private suspend fun uploadStatus(base: String, sessionId: String): Set { + executeAuthed(base) { token -> get(base, "/api/v1/uploads/$sessionId", token) }.use { resp -> + if (resp.code != 200) throw ProviderError.Transient("Upload status failed (HTTP ${resp.code})") + val arr = org.json.JSONObject(resp.body?.string().orEmpty()) + .optJSONArray("received_chunk_indexes") ?: return emptySet() + return (0 until arr.length()).map { arr.getInt(it) }.toSet() + } + } + + private suspend fun putChunk(base: String, sessionId: String, index: Int, bytes: ByteArray) { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes).toHex() + val req = { token: String -> + Request.Builder().url("$base/api/v1/uploads/$sessionId/chunks/$index") + .put(bytes.toRequestBody("application/octet-stream".toMediaType())) + .header("Authorization", "Bearer $token") + .header("X-Chunk-Sha256", digest).build() + } + executeAuthed(base, req).use { resp -> + if (resp.code != 201) throw ProviderError.Transient("Chunk $index rejected (HTTP ${resp.code})") + } + } + + private suspend fun finalize(base: String, sessionId: String, draft: RecordingDraft): String { + val recordedAt = Instant.ofEpochMilli(draft.createdAtEpochMs).toString() + val body = """{"recorded_at":${jsonStr(recordedAt)},""" + + """"duration_seconds":${draft.durationMs / 1000.0}}""" + executeAuthed(base) { token -> post(base, "/api/v1/uploads/$sessionId/finalize", token, body) } + .use { resp -> + if (resp.code != 201) { + throw ProviderError.Transient("Upload finalize failed (HTTP ${resp.code})") + } + return org.json.JSONObject(resp.body?.string().orEmpty()).getString("id") + } + } + + private fun parseRecording(json: org.json.JSONObject): RemoteRecording? { + if (!json.optBoolean("has_audio", false)) return null + val id = json.optString("id", "") + if (id.isBlank()) return null + val createdAt = runCatching { + Instant.parse(json.getString("recorded_at")).toEpochMilli() + }.getOrDefault(0L) + return RemoteRecording( + ref = RemoteRef( + providerId = ProviderRegistry.CUSTOM_SHONAR_ID, + key = id, + etag = null, + sizeBytes = json.optLong("size_bytes", 0), + ), + title = json.optString("title", id), + createdAtEpochMs = createdAt, + durationMs = (json.optDouble("duration_seconds", 0.0) * 1000).toLong(), + mime = json.optString("mime_type", null) ?: "application/octet-stream", + ) + } + + companion object { + fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + // No logging interceptor by policy (docs/server-providers.md §4): + // bodies would carry audio bytes and transcripts. + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + + /** JSON string literal with escaping; never pass secrets to message strings. */ + internal fun jsonStr(raw: String): String = buildString { + append('"') + for (c in raw) when (c) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (c < ' ') append("\\u%04x".format(c.code)) else append(c) + } + append('"') + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + + private fun readSlice(file: File, offset: Long, max: Int): ByteArray { + file.inputStream().use { input -> + var skipped = 0L + while (skipped < offset) { + val n = input.skip(offset - skipped) + if (n <= 0) break + skipped += n + } + val buf = ByteArray(max) + var read = 0 + while (read < max) { + val n = input.read(buf, read, max - read) + if (n < 0) break + read += n + } + return if (read == max) buf else buf.copyOf(read) + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt b/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt new file mode 100644 index 0000000..b4269c8 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt @@ -0,0 +1,160 @@ +package com.shonar.provider + +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext + +/** + * P4: sync-folder provider — "bring your own sync". The app reads and + * writes plain files under a user-chosen directory; an external tool + * (Syncthing, the Nextcloud desktop client, rsync, …) moves those bytes + * between devices. The app never talks to a network for this provider — + * provable by construction (no HTTP imports in this file). + * + * The on-disk layout is identical to [LocalOnlyProvider] (`audio/{id}`, + * `sidecars/…`), so switching between local-only and a sync folder is a + * copy, not a migration, and anything already syncing the folder picks + * the recordings up with no special handling. + * + * Two deliberate differences from local-only: + * - [connect] validates the directory (must exist, be a directory, be + * readable AND writable) and refuses anything else. A typo must be an + * error, never a silently created folder somewhere surprising. Paths + * escaping via `..` are rejected for the same reason. + * - [deleteAccountAndData] NEVER deletes the folder's contents. That + * directory belongs to the user and their sync tool, not to the app — + * forgetting the path is the whole operation. + */ +class FolderSyncProvider( + private val pathStore: com.shonar.settings.SettingsStore = + com.shonar.settings.InMemorySettingsStore(), +) : ShonarProvider { + + override val descriptor = ProviderDescriptor( + id = ID, + displayName = "Sync folder", + capabilities = setOf(), // the sync tool owns the protocol, not us + ) + + private val _authState = MutableStateFlow(AuthState.DISCONNECTED) + override val authState: StateFlow = _authState + + private suspend fun storedRoot(): File? { + val raw = pathStore.getString(KEY_ROOT) ?: return null + return File(raw) + } + + private fun checkDir(dir: File): File { + if (".." in dir.path.split(File.separatorChar)) { + throw ProviderError.InvalidUrl("Folder path must not contain '..'") + } + if (!dir.exists()) throw ProviderError.InvalidUrl( + "Folder does not exist: ${dir.path}. Create it (or let Syncthing create it) first." + ) + if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}") + if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl( + "Folder is not readable and writable: ${dir.path}" + ) + return dir + } + + override suspend fun probe(baseUrl: ServerUrl): ProbeResult = + ProbeResult.Incompatible // no server involved — nothing to probe + + override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) { + val folder = credential as? ProviderCredential.FolderPath + ?: throw ProviderError.InvalidUrl( + "Sync folder needs a folder path to sync through" + ) + val dir = checkDir(File(folder.path)) + pathStore.putString(KEY_ROOT, dir.canonicalPath) + _authState.value = AuthState.CONNECTED + } + + override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) { + val root = storedRoot() + if (root == null) { + _authState.value = AuthState.DISCONNECTED + return@withContext _authState.value + } + runCatching { checkDir(root) } + .onSuccess { _authState.value = AuthState.CONNECTED } + .onFailure { _authState.value = AuthState.DISCONNECTED } + _authState.value + } + + override suspend fun disconnect(revokeOnServer: Boolean) { + // Nothing remote to revoke; the path is kept so reconnect is one tap. + _authState.value = AuthState.DISCONNECTED + } + + override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) { + // Forget the folder. The files stay — they belong to the user and + // their sync tool, and deleting someone's Syncthing folder because + // they tapped "disconnect" would be unforgivable. + pathStore.remove(KEY_ROOT) + _authState.value = AuthState.DISCONNECTED + } + + // ---- storage: delegate with rewritten identity -------------------------- + + private suspend fun root(): File { + if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected() + return storedRoot()?.let { checkDir(it) } ?: throw ProviderError.NotConnected() + } + + override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef = + withContext(Dispatchers.IO) { + val ref = LocalOnlyProvider(root()).upload(draft, onProgress) + ref.copy(providerId = ID) + } + + override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) = + withContext(Dispatchers.IO) { + LocalOnlyProvider(root()).download(ref.copy(providerId = LocalOnlyProvider.ID), dest, onProgress) + } + + override suspend fun delete(ref: RemoteRef) = withContext(Dispatchers.IO) { + LocalOnlyProvider(root()).delete(ref.copy(providerId = LocalOnlyProvider.ID)) + } + + override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) { + val page = LocalOnlyProvider(root()).list(cursor) + Page( + page.items.map { it.copy(ref = it.ref.copy(providerId = ID)) }, + page.nextCursor, + ) + } + + override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) = + withContext(Dispatchers.IO) { + LocalOnlyProvider(root()) + .putSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind, bytes) + } + + override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = + withContext(Dispatchers.IO) { + LocalOnlyProvider(root()) + .getSidecar(ref.copy(providerId = LocalOnlyProvider.ID), kind) + } + + override suspend fun storageLocationSummary(): StorageLocation = + withContext(Dispatchers.IO) { + val r = root() + val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList() + StorageLocation( + headline = "Sync folder", + detail = "${r.path} · ${audio.size} recordings — synced by your sync tool, not by this app.", + syncedCount = 0, + localOnlyCount = audio.size, + bytesUsed = audio.sumOf { it.length() }, + ) + } + + companion object { + const val ID = "sync-folder" + const val KEY_ROOT = "provider.sync-folder.root" + } +} diff --git a/android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt b/android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt new file mode 100644 index 0000000..9750e75 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/NextcloudAuth.kt @@ -0,0 +1,157 @@ +package com.shonar.provider + +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +/** + * Nextcloud server identification + login flow v2 (docs/server-providers.md + * §3, §6). No credentials are ever sent here: [startLogin] is anonymous, + * [poll] carries only the one-time poll token. + * + * Login flow v2 (official, supported): + * 1. POST {base}/index.php/login/v2 -> {poll:{token,endpoint}, login:url} + * 2. user approves {login} in a browser (server-owned consent screen) + * 3. POST {poll.endpoint} {"token":...} -> 404 while pending, + * 200 {server, loginName, appPassword} once approved + */ +class NextcloudAuth( + private val client: OkHttpClient = defaultClient(), + private val tofu: TofuManager? = null, +) { + + /** Is there a Nextcloud at [url]? Read-only, no credentials. */ + suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) { + val req = Request.Builder().url(url.origin + "/status.php").get().build() + try { + client.newCall(req).execute().use { resp -> + if (resp.code != 200) return@withContext ProbeResult.Incompatible + val body = JSONObject(resp.body?.string().orEmpty()) + // status.php: {productname, versionstring, ...}. Some forks + // report "Nextcloud" with different casing — accept any. + val product = body.optString("productname", "") + if (!product.equals("nextcloud", ignoreCase = true)) { + return@withContext ProbeResult.Incompatible + } + ProbeResult.Compatible( + descriptor = ProviderDescriptor( + id = ProviderRegistry.NEXTCLOUD_ID, + displayName = "Nextcloud", + isDefault = true, + capabilities = setOf( + ProviderDescriptor.Capability.CHUNKED_UPLOAD, + ProviderDescriptor.Capability.QUOTA_INFO, + ), + ), + serverName = product, + version = body.optString("versionstring", "unknown"), + ) + } + } catch (e: javax.net.ssl.SSLHandshakeException) { + ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown") + } catch (e: javax.net.ssl.SSLPeerUnverifiedException) { + ProbeResult.TlsFailure(tofu?.failureFor(url.host)?.spkiHex ?: "unknown") + } catch (e: Exception) { + ProbeResult.NetworkError(e.javaClass.simpleName) + } + } + + /** Begins login flow v2. Returns the browser URL + poll handle. */ + suspend fun startLogin(url: ServerUrl): LoginFlowSession = withContext(Dispatchers.IO) { + val req = Request.Builder().url(url.origin + "/index.php/login/v2") + .post(ByteArray(0).toRequestBody(null)).build() + try { + client.newCall(req).execute().use { resp -> + if (resp.code != 200) { + throw ProviderError.Transient( + "Server refused the login request (HTTP ${resp.code}). " + + "Is this a Nextcloud?" + ) + } + val body = JSONObject(resp.body?.string().orEmpty()) + val poll = body.optJSONObject("poll") + val login = body.optString("login", "") + val token = poll?.optString("token", "").orEmpty() + val endpoint = poll?.optString("endpoint", "").orEmpty() + if (login.isBlank() || token.isBlank() || endpoint.isBlank()) { + throw ProviderError.Transient("Server gave an incomplete login response") + } + LoginFlowSession( + baseUrl = url.origin, + loginUrl = login, + pollToken = token, + pollEndpoint = endpoint, + ) + } + } catch (e: ProviderError) { + throw e + } catch (e: Exception) { + throw ProviderError.Transient("Could not reach the server (${e.javaClass.simpleName})") + } + } + + /** One poll attempt. Call repeatedly until [PollResult.Approved]. */ + suspend fun poll(flow: LoginFlowSession): PollResult = withContext(Dispatchers.IO) { + val json = """{"token":"${flow.pollToken}"}""" + val req = Request.Builder().url(flow.pollEndpoint) + .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build() + try { + client.newCall(req).execute().use { resp -> + when (resp.code) { + 200 -> { + val body = JSONObject(resp.body?.string().orEmpty()) + val server = body.optString("server", flow.baseUrl) + val name = body.optString("loginName", "") + val pass = body.optString("appPassword", "") + if (name.isBlank() || pass.isBlank()) { + return@withContext PollResult.Failed("Server approved but sent no credentials") + } + PollResult.Approved( + ProviderCredential.AppPassword( + accountLabel = "$name@${baseHost(server)}", + loginUrl = server, + user = name, + password = pass, + ) + ) + } + 404 -> PollResult.Pending + else -> PollResult.Failed("Login poll failed (HTTP ${resp.code})") + } + } + } catch (e: Exception) { + PollResult.Failed("Login poll failed (${e.javaClass.simpleName})") + } + } + + companion object { + fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .followRedirects(false) + .build() + + private fun baseHost(server: String): String = + runCatching { java.net.URI(server).host }.getOrNull() ?: server + } +} + +/** Browser URL + one-time poll handle from [NextcloudAuth.startLogin]. */ +data class LoginFlowSession( + val baseUrl: String, + val loginUrl: String, + val pollToken: String, + val pollEndpoint: String, +) + +/** One poll attempt's outcome. The token itself never appears in messages. */ +sealed class PollResult { + data object Pending : PollResult() + data class Approved(val credential: ProviderCredential.AppPassword) : PollResult() + data class Failed(val reason: String) : PollResult() +} diff --git a/android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt b/android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt new file mode 100644 index 0000000..572cd45 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/NextcloudAuthStore.kt @@ -0,0 +1,55 @@ +package com.shonar.provider + +import com.shonar.settings.SettingsStore + +/** + * Secure persistence for the Nextcloud session: server origin, DAV user id, + * display name, and the app password from login flow v2. The user's *normal* + * account password is never requested, typed, or stored — only the + * server-issued app password lives here (docs/server-providers.md §3). + */ +class NextcloudAuthStore(private val secure: SettingsStore) { + + suspend fun save(session: NcSession) { + secure.putString(KEY_BASE_URL, session.baseUrl) + secure.putString(KEY_USER_ID, session.userId) + secure.putString(KEY_USERNAME, session.username) + secure.putString(KEY_APP_PASSWORD, session.appPassword) + } + + suspend fun load(): NcSession? { + val base = secure.getString(KEY_BASE_URL) ?: return null + val pass = secure.getString(KEY_APP_PASSWORD) ?: return null + return NcSession( + baseUrl = base, + userId = secure.getString(KEY_USER_ID).orEmpty(), + username = secure.getString(KEY_USERNAME).orEmpty(), + appPassword = pass, + ) + } + + suspend fun clear() { + secure.remove(KEY_BASE_URL) + secure.remove(KEY_USER_ID) + secure.remove(KEY_USERNAME) + secure.remove(KEY_APP_PASSWORD) + } + + companion object { + private const val PREFIX = "provider.nextcloud." + const val KEY_BASE_URL = PREFIX + "base_url" + const val KEY_USER_ID = PREFIX + "user_id" + const val KEY_USERNAME = PREFIX + "username" + const val KEY_APP_PASSWORD = PREFIX + "app_password" + } +} + +/** Authenticated Nextcloud session. Never logged (see toString). */ +data class NcSession( + val baseUrl: String, + val userId: String, // DAV path user (OCS `id`, not the display name) + val username: String, // human hint only + val appPassword: String, +) { + override fun toString(): String = "NcSession(server=$baseUrl, user=$username, [redacted])" +} diff --git a/android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt b/android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt new file mode 100644 index 0000000..5ab2e9a --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/NextcloudProvider.kt @@ -0,0 +1,644 @@ +package com.shonar.provider + +import java.io.File +import java.time.format.DateTimeFormatter +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.json.JSONObject + +/** + * P4: Nextcloud provider — the product default. Talks only official, + * supported endpoints (docs/server-providers.md §6): + * + * - GET {base}/status.php (probe) + * - login flow v2 (see [NextcloudAuth]) + * - GET {base}/ocs/v2.php/cloud/user (identity + quota) + * - DELETE {base}/ocs/v2.php/core/apppassword (revoke own app password) + * - WebDAV {base}/remote.php/dav/files/{user}/… (PROPFIND/GET/PUT/MKCOL/MOVE/DELETE) + * - Chunked upload v2 {base}/remote.php/dav/uploads/{user}/{transfer}/ + * (MKCOL, PUT chunks 00001..N, MOVE {transfer}/.file -> destination) + * + * Layout: `SHONAR/audio/{uuid}.m4a`, `SHONAR/sidecars/{uuid}/{kind}.json`. + * Originals are never overwritten by processing artifacts — uploads with + * the same draft id MOVE onto the same key (idempotent replace). + * + * Chunk naming follows the developer manual: chunks are numbered 1..10000 + * and assembled in name order, so names are zero-padded to 5 digits + * ("00001".."10000") to keep lexical order == numeric order. Chunk size + * defaults to 16 MiB — the server requires 5 MiB..5 GiB per chunk (last + * chunk exempt), so never lower the default for production use; the + * constructor parameter exists for tests only. + * + * Transfer ids are deterministic per recording (`shonar-{draft.id}`), so a + * killed upload resumes by PROPFIND-ing the transfer folder and skipping + * present chunks — including across process restarts. + */ +class NextcloudProvider( + private val auth: NextcloudAuthStore, + private val client: OkHttpClient = NextcloudAuth.defaultClient(), + private val loginFlow: NextcloudAuth = NextcloudAuth(client), + private val chunkSizeBytes: Long = 16 * 1024 * 1024, +) : ShonarProvider { + + override val descriptor = ProviderDescriptor( + id = ProviderRegistry.NEXTCLOUD_ID, + displayName = "Nextcloud", + isDefault = true, + capabilities = setOf( + ProviderDescriptor.Capability.CHUNKED_UPLOAD, + ProviderDescriptor.Capability.QUOTA_INFO, + ), + ) + + private val _authState = MutableStateFlow(AuthState.DISCONNECTED) + override val authState: StateFlow = _authState + + // ---- lifecycle --------------------------------------------------------- + + override suspend fun probe(baseUrl: ServerUrl): ProbeResult = loginFlow.probe(baseUrl) + + override suspend fun connect(credential: ProviderCredential) = withContext(Dispatchers.IO) { + val app = credential as? ProviderCredential.AppPassword + ?: throw ProviderError.InvalidUrl( + "Nextcloud connects with an app password from the browser login flow" + ) + val origin = ServerUrl.parse(app.loginUrl).getOrNull()?.origin + ?: throw ProviderError.InvalidUrl("Not a valid server URL") + // Validate before persisting: a wrong password must not overwrite a + // working session. + val userId = try { + ocsUserId(origin, app.user, app.password) + } catch (e: ProviderError.AuthExpired) { + throw ProviderError.Transient( + "Nextcloud rejected the login — approve it in the browser again" + ) + } + auth.save( + NcSession( + baseUrl = origin, + userId = userId, + username = app.user, + appPassword = app.password, + ) + ) + _authState.value = AuthState.CONNECTED + } + + override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) { + val saved = auth.load() + if (saved == null || saved.appPassword.isBlank() || saved.userId.isBlank()) { + _authState.value = AuthState.DISCONNECTED + return@withContext _authState.value + } + try { + ocsUserId(saved.baseUrl, saved.userId, saved.appPassword) + _authState.value = AuthState.CONNECTED + } catch (e: ProviderError.AuthExpired) { + _authState.value = AuthState.EXPIRED + } catch (e: ProviderError) { + _authState.value = AuthState.OFFLINE + } + _authState.value + } + + override suspend fun disconnect(revokeOnServer: Boolean) = withContext(Dispatchers.IO) { + if (revokeOnServer) { + // Best effort: local state is cleared even if revoke fails. + runCatching { + val saved = auth.load() + if (saved != null && saved.appPassword.isNotBlank()) { + dav(saved, "DELETE", ocsPath("/core/apppassword"), null).close() + } + } + } + auth.clear() + _authState.value = AuthState.DISCONNECTED + } + + /** + * Revokes the app password and forgets the session. There is no API for + * deleting the whole Nextcloud account — that stays a manual step on the + * server, and the message says so. + */ + override suspend fun deleteAccountAndData() = withContext(Dispatchers.IO) { + disconnect(revokeOnServer = true) + } + + // ---- storage ----------------------------------------------------------- + + override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef = + withContext(Dispatchers.IO) { + val session = connectedSession() + val key = "SHONAR/audio/${draft.id}${extFor(draft.mime)}" + val total = draft.sizeBytes.coerceAtLeast(1) + ensureDir(session, "SHONAR") + ensureDir(session, "SHONAR/audio") + + val transfer = "shonar-${draft.id}" + mkcol(session, transfer) + val received = transferChunks(session, transfer) + val chunkCount = ((draft.sizeBytes + chunkSizeBytes - 1) / chunkSizeBytes) + .toInt().coerceAtLeast(1) + var sent = 0L + for (i in received) { + sent += if (i < chunkCount) chunkSizeBytes else 0L + } + sent = sent.coerceAtMost(draft.sizeBytes) + if (sent > 0) onProgress((sent.toFloat() / total).coerceIn(0f, 1f)) + + var idx = 1 + while (idx <= chunkCount) { + ensureActive() + if (idx !in received) { + val slice = readSlice(draft.sourceFile, (idx - 1) * chunkSizeBytes, chunkSizeBytes) + putChunk(session, transfer, idx, slice, draft.sizeBytes, key) + sent += slice.size + onProgress((sent.toFloat() / total).coerceIn(0f, 1f)) + } + idx++ + } + assemble(session, transfer, key, draft) + // Best-effort cleanup of the transfer folder; the server also + // expires stale upload dirs on its own. + runCatching { + dav(session, "DELETE", uploadsPath(session, transfer) + "/", null).close() + } + onProgress(1f) + RemoteRef(ProviderRegistry.NEXTCLOUD_ID, key, etag = null, sizeBytes = draft.sizeBytes) + } + + override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) = + withContext(Dispatchers.IO) { + val session = connectedSession() + dav(session, "GET", filesPath(session, ref.key), null).use { resp -> + when (resp.code) { + 200 -> { + val body = resp.body ?: throw ProviderError.Transient("Empty download response") + val total = body.contentLength().takeIf { it > 0 } ?: -1 + dest.parentFile?.mkdirs() + body.byteStream().use { input -> + dest.outputStream().use { output -> + val buf = ByteArray(64 * 1024) + var written = 0L + var last = -1f + while (true) { + val n = input.read(buf) + if (n < 0) break + output.write(buf, 0, n) + written += n + if (total > 0) { + val p = (written.toFloat() / total).coerceIn(0f, 1f) + if (p > last) { + onProgress(p) + last = p + } + } + } + } + } + onProgress(1f) + } + 401 -> throw ProviderError.AuthExpired() + 404 -> throw ProviderError.NotFound(ref.key) + else -> throw ProviderError.Transient("Download failed (HTTP ${resp.code})") + } + } + } + + override suspend fun delete(ref: RemoteRef) { + withContext(Dispatchers.IO) { + val session = connectedSession() + dav(session, "DELETE", filesPath(session, ref.key), null).use { resp -> + when (resp.code) { + 200, 201, 204 -> Unit + 401 -> throw ProviderError.AuthExpired() + 404 -> throw ProviderError.NotFound(ref.key) + else -> throw ProviderError.Transient("Delete failed (HTTP ${resp.code})") + } + } + // Sidecars go with the recording; ignore failures (may not exist). + runCatching { + dav(session, "DELETE", filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/"), null) + .close() + } + } + } + + override suspend fun list(cursor: String?): Page = withContext(Dispatchers.IO) { + val session = connectedSession() + // Single page: a PROPFIND Depth:1 returns the whole folder. Paging + // stays null until a library outgrows one response. + if (cursor != null) return@withContext Page(emptyList(), null) + val items = propfind(session, filesPath(session, "SHONAR/audio/"), depth = "1") + .filter { it.isFile && it.relativePath != "SHONAR/audio/" && !it.relativePath.removePrefix("SHONAR/audio/").contains('/') } + .map { e -> + val name = e.relativePath.removePrefix("SHONAR/audio/") + RemoteRecording( + ref = RemoteRef(ProviderRegistry.NEXTCLOUD_ID, e.relativePath, e.etag, e.size), + title = name.substringBeforeLast('.'), + createdAtEpochMs = e.lastModified, + durationMs = 0, // duration is tracked locally, not over DAV + mime = e.contentType ?: "application/octet-stream", + ) + } + Page(items, nextCursor = null) + } + + // ---- sidecars: real remote files under SHONAR/sidecars/{uuid}/ -------- + + override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) = + withContext(Dispatchers.IO) { + val session = connectedSession() + val dir = "SHONAR/sidecars/${uuidForKey(ref.key)}" + ensureDir(session, "SHONAR/sidecars") + ensureDir(session, dir) + dav( + session, "PUT", filesPath(session, "$dir/${kind.fileName}"), + bytes.toRequestBody("application/json; charset=utf-8".toMediaType()), + sensitiveBody = true, // transcripts are never logged, in any mode + ).use { resp -> + if (resp.code !in 200..201 && resp.code != 204) { + throw mapError(resp.code, "Sidecar upload failed") + } + } + } + + override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = + withContext(Dispatchers.IO) { + val session = connectedSession() + dav( + session, "GET", + filesPath(session, "SHONAR/sidecars/${uuidForKey(ref.key)}/${kind.fileName}"), null, + sensitiveBody = true, // transcripts are never logged, in any mode + ).use { resp -> + when (resp.code) { + 200 -> resp.body?.bytes() + 401 -> throw ProviderError.AuthExpired() + 404 -> null + else -> throw mapError(resp.code, "Sidecar download failed") + } + } + } + + // ---- status ------------------------------------------------------------ + + override suspend fun storageLocationSummary(): StorageLocation = + withContext(Dispatchers.IO) { + val session = connectedSession() + val quota = ocsQuota(session) + var count = 0 + var bytes = 0L + // list() is single-page for now; keep the loop for when it pages. + var cursor: String? = null + do { + val page = list(cursor) + count += page.items.size + bytes += page.items.sumOf { it.ref.sizeBytes } + cursor = page.nextCursor + } while (cursor != null) + val host = runCatching { java.net.URI(session.baseUrl).host }.getOrNull() + ?: session.baseUrl + StorageLocation( + headline = "Nextcloud at $host", + detail = "${session.username} · $count recordings synced" + + (quota?.let { " · ${formatBytes(it.free)} free of ${formatBytes(it.total)}" } ?: ""), + syncedCount = count, + localOnlyCount = 0, + bytesUsed = bytes, + ) + } + + // ---- HTTP + DAV plumbing ----------------------------------------------- + + private suspend fun connectedSession(): NcSession { + if (_authState.value != AuthState.CONNECTED) throw ProviderError.NotConnected() + return auth.load()?.takeIf { it.appPassword.isNotBlank() && it.userId.isNotBlank() } + ?: throw ProviderError.NotConnected() + } + + private fun basic(session: NcSession): String { + val raw = "${session.userId}:${session.appPassword}".toByteArray(Charsets.UTF_8) + return "Basic " + java.util.Base64.getEncoder().encodeToString(raw) + } + + /** Raw DAV/OCS call. Caller closes the response. [path] starts with '/'. */ + private fun dav( + session: NcSession, + method: String, + path: String, + body: okhttp3.RequestBody?, + sensitiveBody: Boolean = false, + ): Response { + val builder = Request.Builder().url(session.baseUrl + path) + .header("Authorization", basic(session)) + if (sensitiveBody) builder.header(RedactingLogger.SENSITIVE_BODY, "1") + if (method == "GET") builder.get() + else builder.method(method, body) + if (path.startsWith("/ocs/")) { + builder.header("OCS-APIRequest", "true") + builder.header("Accept", "application/json") + } + return client.newCall(builder.build()).execute() + } + + private fun filesPath(session: NcSession, relative: String): String { + val segs = relative.split('/').filter { it.isNotEmpty() }.joinToString("/") { enc(it) } + return "/remote.php/dav/files/${enc(session.userId)}/$segs" + } + + private fun uploadsPath(session: NcSession, transfer: String): String = + "/remote.php/dav/uploads/${enc(session.userId)}/${enc(transfer)}" + + private fun ocsPath(suffix: String): String = "/ocs/v2.php$suffix" + + /** OCS identity check; returns the DAV user id or throws. */ + private suspend fun ocsUserId(origin: String, user: String, appPassword: String): String = + withContext(Dispatchers.IO) { + val raw = "$user:$appPassword".toByteArray(Charsets.UTF_8) + val req = Request.Builder().url(origin + ocsPath("/cloud/user")).get() + .header("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString(raw)) + .header("OCS-APIRequest", "true") + .header("Accept", "application/json").build() + client.newCall(req).execute().use { resp -> + when (resp.code) { + 200 -> { + val data = JSONObject(resp.body?.string().orEmpty()) + .optJSONObject("ocs")?.optJSONObject("data") + val id = data?.optString("id", "").orEmpty() + if (id.isBlank()) throw ProviderError.Transient("Server identity reply was empty") + id + } + 401 -> throw ProviderError.AuthExpired() + else -> throw ProviderError.Transient("Server identity check failed (HTTP ${resp.code})") + } + } + } + + private data class Quota(val free: Long, val total: Long) + + private suspend fun ocsQuota(session: NcSession): Quota? = withContext(Dispatchers.IO) { + // Quota is informational; never fail the summary over it. + runCatching { + dav(session, "GET", ocsPath("/cloud/user"), null).use { resp -> + if (resp.code != 200) return@runCatching null + val q = JSONObject(resp.body?.string().orEmpty()) + .optJSONObject("ocs")?.optJSONObject("data")?.optJSONObject("quota") + ?: return@runCatching null + // Quota values may be numbers or numeric strings; total -3 + // (or "unknown") means unlimited. + fun num(v: Any?): Long = when (v) { + is Number -> v.toLong() + is String -> v.toLongOrNull() ?: -3L + else -> -3L + } + val free = num(q.opt("free")) + val total = num(q.opt("total")) + if (total < 0) null else Quota(free.coerceAtLeast(0), total) + } + }.getOrNull() + } + + /** MKCOL tolerant of "already exists". */ + private suspend fun ensureDir(session: NcSession, relative: String) { + // Create level by level so a missing parent reads as progress, not 409. + val parts = relative.split('/').filter { it.isNotEmpty() } + var prefix = "" + for (part in parts) { + prefix = if (prefix.isEmpty()) part else "$prefix/$part" + dav(session, "MKCOL", filesPath(session, prefix), null).use { resp -> + if (resp.code == 401) throw ProviderError.AuthExpired() + if (resp.code != 201 && resp.code != 405) { + throw mapError(resp.code, "Could not create folder $prefix") + } + } + } + } + + private suspend fun mkcol(session: NcSession, transfer: String) { + dav(session, "MKCOL", uploadsPath(session, transfer) + "/", null).use { resp -> + // 405: transfer folder from a previous attempt — resume into it. + if (resp.code == 401) throw ProviderError.AuthExpired() + if (resp.code != 201 && resp.code != 405) { + throw mapError(resp.code, "Could not start the upload") + } + } + } + + /** Chunk names present in the transfer folder (resume). */ + private suspend fun transferChunks(session: NcSession, transfer: String): Set { + val prefix = "/remote.php/dav/uploads/${session.userId}/" + val entries = propfind(session, uploadsPath(session, transfer) + "/", depth = "1", prefix = prefix) + return entries.mapNotNullTo(mutableSetOf()) { e -> + // Chunk names are "00001".. — compare by numeric value. + e.name.trimStart('0').ifEmpty { "0" }.toIntOrNull() + ?.takeIf { it in 1..10000 } + } + } + + private suspend fun putChunk( + session: NcSession, + transfer: String, + index: Int, // 1-based + bytes: ByteArray, + totalBytes: Long, + destKey: String, + ) { + val name = "%05d".format(index) + val dest = session.baseUrl + filesPath(session, destKey) + val req = Request.Builder() + .url(session.baseUrl + uploadsPath(session, transfer) + "/" + name) + .put(bytes.toRequestBody("application/octet-stream".toMediaType())) + .header("Authorization", basic(session)) + .header("OC-Total-Length", totalBytes.toString()) + .header("Destination", dest).build() + client.newCall(req).execute().use { resp -> + when (resp.code) { + 200, 201, 204 -> Unit + 401 -> throw ProviderError.AuthExpired() + 507 -> throw ProviderError.QuotaExceeded() + else -> throw mapError(resp.code, "Chunk $index rejected") + } + } + } + + private suspend fun assemble( + session: NcSession, + transfer: String, + destKey: String, + draft: RecordingDraft, + ) { + val dest = session.baseUrl + filesPath(session, destKey) + val req = Request.Builder() + .url(session.baseUrl + uploadsPath(session, transfer) + "/.file") + .method("MOVE", null) + .header("Authorization", basic(session)) + .header("Destination", dest) + .header("Overwrite", "T") + .header("OC-Total-Length", draft.sizeBytes.toString()) + // Server mtime = recording time, so listings sort by when it + // was recorded, not when it finished uploading. + .header("X-OC-Mtime", (draft.createdAtEpochMs / 1000).toString()).build() + client.newCall(req).execute().use { resp -> + when (resp.code) { + 200, 201, 204 -> Unit + 401 -> throw ProviderError.AuthExpired() + 404 -> throw ProviderError.Transient("Upload assembly failed — retry the upload") + 507 -> throw ProviderError.QuotaExceeded() + else -> throw mapError(resp.code, "Upload assembly failed") + } + } + } + + internal data class DavEntry( + val relativePath: String, // relative to files/{user}/, decoded + val name: String, + val isFile: Boolean, + val size: Long, + val etag: String?, + val contentType: String?, + val lastModified: Long, + ) + + private suspend fun propfind( + session: NcSession, + path: String, + depth: String, + prefix: String = "/remote.php/dav/files/${session.userId}/", + ): List = + withContext(Dispatchers.IO) { + val body = """ +""" + val req = Request.Builder().url(session.baseUrl + path) + .method("PROPFIND", body.toRequestBody("application/xml; charset=utf-8".toMediaType())) + .header("Authorization", basic(session)) + .header("Depth", depth).build() + client.newCall(req).execute().use { resp -> + when (resp.code) { + 200, 207 -> parseMultistatus(resp.body?.string().orEmpty(), prefix = prefix) + 401 -> throw ProviderError.AuthExpired() + 404 -> emptyList() + else -> throw mapError(resp.code, "Listing failed") + } + } + } + + private fun mapError(code: Int, fallback: String): ProviderError = when (code) { + 401 -> ProviderError.AuthExpired() + 507 -> ProviderError.QuotaExceeded() + else -> ProviderError.Transient("$fallback (HTTP $code)") + } + + companion object { + /** One path segment, percent-encoded (spaces as %20, not '+'). */ + internal fun enc(segment: String): String = + java.net.URLEncoder.encode(segment, "UTF-8").replace("+", "%20") + + internal fun uuidForKey(key: String): String = + key.substringAfterLast('/').substringBeforeLast('.') + + internal fun extFor(mime: String): String = when (mime.lowercase().substringBefore(';').trim()) { + "audio/mp4", "audio/m4a" -> ".m4a" + "audio/aac" -> ".aac" + "audio/wav", "audio/x-wav" -> ".wav" + "audio/ogg", "audio/opus" -> ".ogg" + "audio/webm" -> ".webm" + "audio/mpeg" -> ".mp3" + else -> ".m4a" // the app records m4a; unknown mimes keep a playable suffix + } + + internal fun formatBytes(n: Long): String { + if (n < 1024) return "$n B" + val units = arrayOf("KB", "MB", "GB", "TB") + var v = n.toDouble() / 1024 + var u = 0 + while (v >= 1024 && u < units.size - 1) { + v /= 1024 + u++ + } + return "%s %s".format(if (v >= 100) "%.0f" else "%.1f".format(v), units[u]) + } + + internal fun parseMultistatus(xml: String, prefix: String): List { + if (xml.isBlank()) return emptyList() + val out = mutableListOf() + try { + val factory = javax.xml.parsers.DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = true + // Harden against XXE: multistatus docs never need doctypes. + runCatching { + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + } + val doc = factory.newDocumentBuilder() + .parse(java.io.ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8))) + val responses = doc.getElementsByTagNameNS("DAV:", "response") + for (i in 0 until responses.length) { + val el = responses.item(i) as? org.w3c.dom.Element ?: continue + fun text(tag: String): String? { + val nodes = el.getElementsByTagNameNS("DAV:", tag) + if (nodes.length == 0) return null + return nodes.item(0).textContent?.trim()?.takeIf { it.isNotEmpty() } + } + val href = text("href") ?: continue + val decoded = runCatching { + java.net.URLDecoder.decode(href, "UTF-8") + }.getOrNull() ?: href + // Strip scheme+host when the server returns absolute hrefs. + val pathOnly = runCatching { java.net.URI(decoded).path }.getOrNull() ?: decoded + val relative = pathOnly.removePrefix(prefix).trim('/') + val isCollection = runCatching { + val rt = el.getElementsByTagNameNS("DAV:", "resourcetype") + rt.length > 0 && (rt.item(0) as org.w3c.dom.Element) + .getElementsByTagNameNS("DAV:", "collection").length > 0 + }.getOrDefault(false) + val lastMod = text("getlastmodified")?.let { + runCatching { + java.time.ZonedDateTime.parse(it, DateTimeFormatter.RFC_1123_DATE_TIME) + .toInstant().toEpochMilli() + }.getOrNull() + } ?: 0L + out += DavEntry( + relativePath = relative, + name = relative.substringAfterLast('/'), + isFile = !isCollection, + size = text("getcontentlength")?.toLongOrNull() ?: 0L, + etag = text("getetag")?.trim('"'), + contentType = text("getcontenttype"), + lastModified = lastMod, + ) + } + } catch (e: Exception) { + throw ProviderError.Transient("Could not read the server listing") + } + return out + } + + private fun readSlice(file: File, offset: Long, max: Long): ByteArray { + file.inputStream().use { input -> + var skipped = 0L + while (skipped < offset) { + val n = input.skip(offset - skipped) + if (n <= 0) break + skipped += n + } + val cap = max.coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + val buf = ByteArray(cap) + var read = 0 + while (read < cap) { + val n = input.read(buf, read, cap - read) + if (n < 0) break + read += n + } + return if (read == cap) buf else buf.copyOf(read) + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt b/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt index d301e94..9cf7c9b 100644 --- a/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt +++ b/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt @@ -8,7 +8,11 @@ import kotlinx.coroutines.flow.StateFlow * user selected during setup; switching is just changing this id — the app * never branches on concrete provider classes. */ -class ProviderRegistry(private val factories: Map ShonarProvider>) { +class ProviderRegistry( + private val factories: Map ShonarProvider>, + /** Builds every provider HTTP client (TOFU trust + redacting logger). */ + val tls: TlsPolicy = defaultTls(), +) { private val _activeId = MutableStateFlow(LocalOnlyProvider.ID) val activeId: StateFlow = _activeId @@ -37,14 +41,50 @@ class ProviderRegistry(private val factories: Map ShonarProvider>) companion object { const val NEXTCLOUD_ID = "nextcloud" const val CUSTOM_SHONAR_ID = "custom-shonar" + const val SYNC_FOLDER_ID = FolderSyncProvider.ID - /** Production factory map. P1 registers local-only; later phases add more. */ - fun withDefaults(appFilesDir: java.io.File): ProviderRegistry = ProviderRegistry( - mapOf( - LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) }, - // P3: CUSTOM_SHONAR_ID to { CustomShonarProvider(...) } - // P4: NEXTCLOUD_ID to { NextcloudProvider(...) } - ), + /** Behaviour-identical default: system trust, logging off, no pins. */ + fun defaultTls(): TlsPolicy = TlsPolicy( + tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())), ) + + /** + * 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")) }, + CUSTOM_SHONAR_ID to { custom }, + NEXTCLOUD_ID to { nextcloud }, + SYNC_FOLDER_ID to { folder }, + // P6: start9 / umbrel platform probes + ), + tls = tlsPolicy, + ) + } } } diff --git a/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt b/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt index 82774f0..d1a5e47 100644 --- a/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt +++ b/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt @@ -108,11 +108,38 @@ sealed class 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. */ diff --git a/android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt b/android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt new file mode 100644 index 0000000..af028ec --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/ShonarAuthStore.kt @@ -0,0 +1,83 @@ +package com.shonar.provider + +import com.shonar.settings.SettingsStore + +/** + * Secure persistence for the custom SHONAR server session. + * + * Stored (all in the Keystore-backed secure store, never in Room or logs): + * - base URL origin (e.g. https://shonar.example.com) + * - account email (hint only) + * - access token + expiry + * - refresh token + device id + * + * The user's password is NEVER stored here: it lives in memory for exactly + * one login call (see [ProviderCredential.ShonarLogin]). + * + * Rotation discipline (backend M1 has refresh reuse detection): every + * successful refresh overwrites the stored pair immediately, so a stored + * refresh token is always the newest one the server has issued. + */ +class ShonarAuthStore(private val secure: SettingsStore) { + + suspend fun save(session: ShonarSession) { + secure.putString(KEY_BASE_URL, session.baseUrl) + secure.putString(KEY_EMAIL, session.email) + secure.putString(KEY_ACCESS, session.accessToken) + secure.putString(KEY_REFRESH, session.refreshToken) + secure.putString(KEY_EXPIRES_AT, session.expiresAtEpochSec.toString()) + if (session.deviceId != null) secure.putString(KEY_DEVICE_ID, session.deviceId) + else secure.remove(KEY_DEVICE_ID) + } + + suspend fun load(): ShonarSession? { + val baseUrl = secure.getString(KEY_BASE_URL) ?: return null + val access = secure.getString(KEY_ACCESS) ?: return null + val refresh = secure.getString(KEY_REFRESH) ?: return null + return ShonarSession( + baseUrl = baseUrl, + email = secure.getString(KEY_EMAIL).orEmpty(), + accessToken = access, + refreshToken = refresh, + expiresAtEpochSec = secure.getString(KEY_EXPIRES_AT)?.toLongOrNull(), + deviceId = secure.getString(KEY_DEVICE_ID), + ) + } + + /** Drop tokens but keep the URL + email so re-login is one step. */ + suspend fun clearTokens() { + secure.remove(KEY_ACCESS) + secure.remove(KEY_REFRESH) + secure.remove(KEY_EXPIRES_AT) + secure.remove(KEY_DEVICE_ID) + } + + /** Forget everything, including which server was configured. */ + suspend fun clearAll() { + secure.remove(KEY_BASE_URL) + secure.remove(KEY_EMAIL) + clearTokens() + } + + companion object { + private const val PREFIX = "provider.custom-shonar." + const val KEY_BASE_URL = PREFIX + "base_url" + const val KEY_EMAIL = PREFIX + "email" + const val KEY_ACCESS = PREFIX + "access_token" + const val KEY_REFRESH = PREFIX + "refresh_token" + const val KEY_EXPIRES_AT = PREFIX + "expires_at" + const val KEY_DEVICE_ID = PREFIX + "device_id" + } +} + +/** In-memory session handed between login/refresh calls and the store. */ +data class ShonarSession( + val baseUrl: String, + val email: String, + val accessToken: String, + val refreshToken: String, + val expiresAtEpochSec: Long?, + val deviceId: String?, +) { + override fun toString(): String = "ShonarSession(server=$baseUrl, account=$email, [redacted])" +} diff --git a/android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt b/android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt new file mode 100644 index 0000000..522095a --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/ShonarHandshake.kt @@ -0,0 +1,91 @@ +package com.shonar.provider + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLHandshakeException + +/** + * Unauthenticated handshake against the SHONAR backend's + * GET /api/v1/provider-info. Used by the provider-selection screen and by + * Start9/Umbrel platform probes to identify SHONAR-compatible services. + * + * TLS policy (docs/server-providers.md §4): + * - full system verification by default, no bypass, ever. + * - a handshake failure yields ProbeResult.TlsFailure with the peer cert's + * SPKI SHA-256 (recorded by the TOFU trust manager) so the UI can run + * explicit trust-on-first-use approval; this client NEVER retries with + * verification disabled. + */ +class ShonarHandshake( + private val client: OkHttpClient = defaultClient(), + private val tofu: TofuManager? = null, +) { + + suspend fun probe(url: ServerUrl): ProbeResult = withContext(Dispatchers.IO) { + val endpoint = url.origin + "/api/v1/provider-info" + val request = Request.Builder().url(endpoint).get().build() + try { + client.newCall(request).execute().use { resp -> + if (resp.code == 200) { + parseBody(resp.body?.string().orEmpty()) + } else { + ProbeResult.Incompatible + } + } + } catch (e: SSLHandshakeException) { + ProbeResult.TlsFailure(fingerprintFor(url.host)) + } catch (e: javax.net.ssl.SSLPeerUnverifiedException) { + // Hostname mismatch: strict verifier stays strict, but the UI + // should still say *why* instead of a generic network error. + ProbeResult.TlsFailure(fingerprintFor(url.host)) + } catch (e: Exception) { + // message must stay generic: exception text can contain URLs but + // never credentials (this call sends no credentials at all) + ProbeResult.NetworkError(e.javaClass.simpleName) + } + } + + private fun parseBody(json: String): ProbeResult = try { + val root = org.json.JSONObject(json) + if (root.optString("kind") != "shonar") { + ProbeResult.Incompatible + } else { + val caps = root.optJSONObject("capabilities") ?: org.json.JSONObject() + val set = mutableSetOf() + if (caps.optBoolean("chunked_upload")) set += ProviderDescriptor.Capability.CHUNKED_UPLOAD + if (caps.optBoolean("server_transcription")) set += ProviderDescriptor.Capability.SERVER_TRANSCRIPTION + if (caps.optBoolean("server_summary")) set += ProviderDescriptor.Capability.SERVER_SUMMARY + if (caps.optBoolean("account_deletion")) set += ProviderDescriptor.Capability.ACCOUNT_DELETION + ProbeResult.Compatible( + descriptor = ProviderDescriptor( + id = ProviderRegistry.CUSTOM_SHONAR_ID, + displayName = "Custom SHONAR server", + capabilities = set, + ), + serverName = root.optString("storage_backend", "server"), + version = root.optString("version", "unknown"), + ) + } + } catch (e: Exception) { + ProbeResult.Incompatible + } + + /** + * SPKI SHA-256 recorded by the TOFU trust manager during the failed + * handshake, or "unknown" when nothing was captured (plain HTTP, + * pre-handshake failure, or no TOFU manager wired). + */ + private fun fingerprintFor(host: String): String = + tofu?.failureFor(host)?.spkiHex ?: "unknown" + + companion object { + fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .followRedirects(false) // do not silently follow redirects to other hosts + .build() + } +} diff --git a/android/app/src/main/java/com/shonar/provider/SyncState.kt b/android/app/src/main/java/com/shonar/provider/SyncState.kt new file mode 100644 index 0000000..02cda21 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/SyncState.kt @@ -0,0 +1,42 @@ +package com.shonar.provider + +/** + * Sync lifecycle for one local recording against the active provider + * (docs/server-providers.md §2). P3 defines the vocabulary and the legal + * transitions; the WorkManager driver that moves recordings through it is + * M5 — until then uploads go through [ShonarProvider.upload] directly and + * land in UPLOADED. + * + * Pausing/canceling is a state, not a job kill: QUEUED and ERROR are stable + * resting states a later run resumes from. + */ +enum class SyncState { + LOCAL_ONLY, // provider is local-only, or user never enabled sync + QUEUED, // waiting for constraints (network, Wi-Fi-only, charging-only) + UPLOADING, // bytes in flight (resumable via the provider's session) + UPLOADED, // audio on the server; sidecars may still be pending + SYNCED, // audio + all sidecars confirmed server-side + ERROR, // failed; [SyncStatus.reasonCode] says why, [SyncStatus.retryAtEpochMs] when +} + +data class SyncStatus( + val state: SyncState, + val retryAtEpochMs: Long? = null, + val reasonCode: String? = null, +) + +/** + * Legal transitions. Anything not listed here is a programming error, not + * a state the UI should ever render. + */ +fun SyncStatus.canTransitionTo(next: SyncState): Boolean = when (state) { + SyncState.LOCAL_ONLY -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY + SyncState.QUEUED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY || + next == SyncState.ERROR + SyncState.UPLOADING -> next == SyncState.UPLOADED || next == SyncState.QUEUED || + next == SyncState.ERROR || next == SyncState.LOCAL_ONLY + SyncState.UPLOADED -> next == SyncState.SYNCED || next == SyncState.UPLOADING || + next == SyncState.ERROR || next == SyncState.LOCAL_ONLY + SyncState.SYNCED -> next == SyncState.UPLOADING || next == SyncState.LOCAL_ONLY + SyncState.ERROR -> next == SyncState.QUEUED || next == SyncState.LOCAL_ONLY +} diff --git a/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt b/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt new file mode 100644 index 0000000..f4d8178 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt @@ -0,0 +1,186 @@ +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. + */ +class TlsPolicy( + val tofu: TofuManager, + private val bodiesEnabled: () -> Boolean = { false }, + private val sink: (String) -> Unit = { msg -> android.util.Log.d("ShonarNet", msg) }, +) { + fun newClient( + connectTimeoutS: Long, + readTimeoutS: Long, + writeTimeoutS: Long = readTimeoutS, + followRedirects: Boolean = true, + ): OkHttpClient { + val tm = tofu.trustManager + val sslContext = javax.net.ssl.SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(tm), null) + return OkHttpClient.Builder() + .sslSocketFactory(sslContext.socketFactory, tm) + .connectTimeout(connectTimeoutS, TimeUnit.SECONDS) + .readTimeout(readTimeoutS, TimeUnit.SECONDS) + .writeTimeout(writeTimeoutS, TimeUnit.SECONDS) + .followRedirects(followRedirects) + .addInterceptor(RedactingLogger(bodiesEnabled, sink)) + .build() + } + + /** P3-era API shape (15/60/60s). */ + fun apiClient(): OkHttpClient = newClient(15, 60, 60) + + /** Short probing shape (10/15s, no redirects). */ + fun probeClient(): OkHttpClient = newClient(10, 15, 15, followRedirects = false) + + /** Nextcloud shape (10/20/60s, no redirects). */ + fun nextcloudClient(): OkHttpClient = newClient(10, 20, 60, followRedirects = false) +} + +/** + * Logging is OFF by default; when the `log_http_bodies` debug setting is + * on, requests log in redacted form. Invariants (leak-tested): + * - Authorization / Cookie / Set-Cookie headers are never logged. + * - bodies log only for JSON/XML/text under [MAX_BODY] bytes; audio and + * other binary bodies never log. + * - token-shaped JSON values (`…token…`, `password`, `appPassword`) are + * masked, and Basic credentials are masked, even inside bodies. + */ +class RedactingLogger( + private val bodiesEnabled: () -> Boolean, + private val sink: (String) -> Unit, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val sensitive = chain.request().header(SENSITIVE_BODY) != null + val req = chain.request().newBuilder().removeHeader(SENSITIVE_BODY).build() + if (!bodiesEnabled()) return chain.proceed(req) + val t0 = System.currentTimeMillis() + val reqBody = req.body + val reqLen = reqBody?.contentLength()?.takeIf { it >= 0 } + sink("→ ${req.method} ${req.url.host}${req.url.encodedPath} body=${reqLen?.let { "$it B" } ?: "?"}") + for (i in 0 until req.headers.size) { + val name = req.headers.name(i) + sink(" $name: ${if (isSensitiveHeader(name)) "[redacted]" else req.headers.value(i)}") + } + if (sensitive) { + sink(" request-body: [sensitive, not logged]") + } else { + logRequestBody(reqBody?.contentType()?.toString(), reqBody) + } + try { + val resp = chain.proceed(req) + val ms = System.currentTimeMillis() - t0 + sink("← ${resp.code} ${req.url.encodedPath} (${ms}ms)") + if (sensitive) { + sink(" response-body: [sensitive, not logged]") + return resp + } + val peek = resp.peekBody(MAX_BODY + 1) + logBody( + " response", + peek.contentType()?.toString(), + peek.bytes().toList(), + ) + return resp + } catch (e: Exception) { + sink("✕ ${req.url.encodedPath} failed (${e.javaClass.simpleName})") + throw e + } + } + + /** + * Request bodies need a size gate BEFORE reading: uploads are file + * bodies that must never be buffered just to log a prefix of them. + */ + private fun logRequestBody(contentType: String?, body: okhttp3.RequestBody?) { + if (body == null) { + sink(" request-body: none") + return + } + val len = try { + body.contentLength() + } catch (e: Exception) { + -1L + } + if (len < 0 || len > MAX_BODY) { + val what = if (len < 0) "streaming" else "$len bytes" + sink(" request-body: [$what, not logged]") + return + } + val bytes = try { + val buf = okio.Buffer() + body.writeTo(buf) + buf.readByteArray().toList() + } catch (e: Exception) { + null + } + logBody(" request", contentType, bytes) + } + + private fun logBody(prefix: String, contentType: String?, bytes: List?) { + if (bytes == null) { + sink("$prefix-body: none") + return + } + if (!isLoggableType(contentType)) { + sink("$prefix-body: [${bytes.size} bytes, not logged]") + return + } + if (bytes.size > MAX_BODY) { + sink("$prefix-body: [${bytes.size} bytes, over the $MAX_BODY B cap, not logged]") + return + } + sink("$prefix-body: ${redact(bytes.toByteArray().toString(Charsets.UTF_8))}") + } + + companion object { + const val MAX_BODY: Long = 8192 + + /** + * Opt out of body logging per request (both directions). Providers + * set this on calls whose bodies are transcripts or other sensitive + * payloads; the logger strips it before sending so it never reaches + * the wire. + */ + const val SENSITIVE_BODY = "X-Shonar-Sensitive-Body" + + internal fun isSensitiveHeader(name: String): Boolean = when (name.lowercase()) { + "authorization", "cookie", "set-cookie", "x-chunk-sha256" -> true + else -> false + } + + internal fun isLoggableType(contentType: String?): Boolean { + if (contentType == null) return false + val t = contentType.lowercase().substringBefore(';').trim() + return t.startsWith("application/json") || t.endsWith("+json") || + t.startsWith("text/") || t.endsWith("+xml") || + t == "application/xml" || t == "application/x-www-form-urlencoded" + } + + private val SECRET_JSON = Regex( + """"[^"]*(token|password|secret)[^"]*"\s*:\s*"[^"]*"""", + RegexOption.IGNORE_CASE, + ) + private val BASIC = Regex("""Basic\s+[A-Za-z0-9+/=]{8,}""") + private val BEARER = Regex("""Bearer\s+[A-Za-z0-9\-_.~+/=]{8,}""") + + /** Mask token-shaped values; safe to run on any text. */ + internal fun redact(text: String): String { + var out = SECRET_JSON.replace(text) { m -> + val key = m.value.substringBefore(':') + """$key:"***"""" + } + out = BASIC.replace(out, "Basic [redacted]") + out = BEARER.replace(out, "Bearer [redacted]") + return out + } + } +} diff --git a/android/app/src/main/java/com/shonar/provider/TofuTrust.kt b/android/app/src/main/java/com/shonar/provider/TofuTrust.kt new file mode 100644 index 0000000..f684b10 --- /dev/null +++ b/android/app/src/main/java/com/shonar/provider/TofuTrust.kt @@ -0,0 +1,287 @@ +package com.shonar.provider + +import com.shonar.settings.SettingsStore +import java.net.Socket +import java.security.MessageDigest +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.concurrent.ConcurrentHashMap +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLSession +import javax.net.ssl.X509ExtendedTrustManager +import javax.net.ssl.X509TrustManager + +/** + * P5: trust-on-first-use (TOFU) for self-signed LAN servers + * (docs/server-providers.md §4). + * + * Policy, enforced by construction: + * - system CAs are always tried first; TOFU pins are a fallback, never a + * replacement. A host that later gets a real certificate just works. + * - pins are per-host: an approved cert for `nas.local` is trusted ONLY + * when `nas.local` presents it. Byte-equality on the leaf DER, so a + * rotation needs a fresh approval (correct TOFU semantics). + * - nothing is ever trusted silently: the first failure only RECORDS the + * chain, and trust requires an explicit [TofuManager.approve] call from + * a UI that showed the fingerprint. + * - the normal TLS hostname verifier stays strict; TOFU covers unknown + * CAs, not name mismatches. + */ +class TofuStore(private val secure: SettingsStore) { + + suspend fun addPin(host: String, derBase64: String) { + val pins = pins(host).toMutableSet() + pins += derBase64 + secure.putString(pinKey(host), org.json.JSONArray(pins.toList()).toString()) + val hosts = hosts().toMutableSet() + if (hosts.add(host.lowercase())) { + secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString()) + } + } + + suspend fun pins(host: String): Set { + val raw = secure.getString(pinKey(host.lowercase())) ?: return emptySet() + return runCatching { + val arr = org.json.JSONArray(raw) + (0 until arr.length()).map { arr.getString(it) }.toSet() + }.getOrDefault(emptySet()) + } + + suspend fun hosts(): Set { + val raw = secure.getString(KEY_HOSTS) ?: return emptySet() + return runCatching { + val arr = org.json.JSONArray(raw) + (0 until arr.length()).map { arr.getString(it) }.toSet() + }.getOrDefault(emptySet()) + } + + suspend fun removeHost(host: String) { + secure.remove(pinKey(host.lowercase())) + val hosts = hosts().toMutableSet() + if (hosts.remove(host.lowercase())) { + secure.putString(KEY_HOSTS, org.json.JSONArray(hosts.toList()).toString()) + } + } + + companion object { + private const val PREFIX = "tofu." + const val KEY_HOSTS = PREFIX + "hosts" + fun pinKey(host: String): String = PREFIX + "pins." + host.lowercase() + } +} + +/** A recorded untrusted chain, shown to the user for approval. */ +data class TofuFailure( + val host: String, + /** SPKI SHA-256, browser-style `AB:CD:…` uppercase hex. */ + val spkiHex: String, + val subject: String, + val issuer: String, + val validFrom: String, + val validUntil: String, + val leafDer: ByteArray, + val recordedAtMs: Long = System.currentTimeMillis(), +) { + override fun toString(): String = + "TofuFailure(host=$host, spki=$spkiHex, subject=$subject)" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TofuFailure) return false + return host == other.host && spkiHex == other.spkiHex && + leafDer.contentEquals(other.leafDer) + } + + override fun hashCode(): Int = 31 * host.hashCode() + spkiHex.hashCode() +} + +/** + * Thrown (as a [CertificateException], so it propagates through the TLS + * stack untouched) when a chain is neither system-trusted nor pinned. + * Carries no secrets — DNs and fingerprints are public cert contents. + */ +class TofuUntrustedException( + val failure: TofuFailure, + message: String = "Untrusted certificate for ${failure.host} (SPKI ${failure.spkiHex})", +) : CertificateException(message) + +/** + * System-first trust manager with per-host TOFU fallback. The pin cache is + * in-memory (handshakes run on TLS threads that cannot suspend); it is + * filled by [TofuManager.refresh] at startup and kept in sync by + * approve/forget. + */ +class TofuTrustManager( + private val system: X509TrustManager, + private val manager: TofuManager, +) : X509ExtendedTrustManager() { + + override fun checkClientTrusted(chain: Array, authType: String) { + system.checkClientTrusted(chain, authType) + } + + // Client-auth paths: this app is always the TLS client, so these only + // need to exist (newer JDKs/Android declare them abstract). Delegate to + // the 2-arg system check. + override fun checkClientTrusted( + chain: Array, authType: String, socket: Socket, + ) { + system.checkClientTrusted(chain, authType) + } + + override fun checkClientTrusted( + chain: Array, authType: String, engine: SSLEngine, + ) { + system.checkClientTrusted(chain, authType) + } + + override fun checkServerTrusted(chain: Array, authType: String) { + checkServerTrusted(chain, authType, host = null) + } + + override fun checkServerTrusted( + chain: Array, authType: String, socket: Socket, + ) { + val host = runCatching { + (socket as? javax.net.ssl.SSLSocket)?.handshakeSession?.peerHost + }.getOrNull() + checkServerTrusted(chain, authType, host = host) + } + + override fun checkServerTrusted( + chain: Array, authType: String, engine: SSLEngine, + ) { + val host = runCatching { + (engine.session as? javax.net.ssl.ExtendedSSLSession)?.peerHost + }.getOrNull() + checkServerTrusted(chain, authType, host = host) + } + + private fun checkServerTrusted( + chain: Array, authType: String, host: String?, + ) { + // pins are checked first so an approved self-signed cert keeps + // working even where a system path would also exist; system trust + // is the fallback that makes later real certs frictionless. + if (chain.isNotEmpty() && host != null && manager.isPinned(host, chain[0])) { + return + } + try { + system.checkServerTrusted(chain, authType) + } catch (e: CertificateException) { + val failure = TofuFailure( + host = (host ?: "").lowercase(), + spkiHex = spkiHex(chain[0]), + subject = chain[0].subjectX500Principal.name, + issuer = chain[0].issuerX500Principal.name, + validFrom = chain[0].notBefore.toString(), + validUntil = chain[0].notAfter.toString(), + leafDer = chain[0].encoded, + ) + manager.record(failure) + throw TofuUntrustedException(failure) + } + } + + override fun getAcceptedIssuers(): Array = system.acceptedIssuers + + companion object { + /** SPKI SHA-256 as browser-style colon-separated uppercase hex. */ + internal fun spkiHex(cert: X509Certificate): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(cert.publicKey.encoded) + return digest.joinToString(":") { "%02X".format(it) } + } + } +} + +/** + * Owns the pin store, the in-memory cache the trust manager reads, and the + * recorded failures the approval UI consumes. + */ +class TofuManager( + private val store: TofuStore, + system: X509TrustManager = defaultSystemTrustManager(), +) { + private val cache = ConcurrentHashMap>() + private val failures = ConcurrentHashMap() + + val trustManager: TofuTrustManager by lazy { TofuTrustManager(system, this) } + + /** Fill the cache from the store. Call at startup (and only there). */ + suspend fun refresh() { + val fresh = mutableMapOf>() + for (host in store.hosts()) { + fresh[host.lowercase()] = store.pins(host) + } + cache.clear() + cache.putAll(fresh) + } + + internal fun isPinned(host: String, leaf: X509Certificate): Boolean { + val pins = cache[host.lowercase()] ?: return false + val der = runCatching { leaf.encoded }.getOrNull() ?: return false + return pins.any { pin -> + runCatching { + MessageDigest.isEqual( + der, + java.util.Base64.getDecoder().decode(pin), + ) + }.getOrDefault(false) + } + } + + internal fun record(failure: TofuFailure) { + failures[failure.host] = failure + } + + /** Most recent failure for [host], else a fresh hostless one, else null. */ + fun failureFor(host: String): TofuFailure? { + val key = host.lowercase() + failures[key]?.let { return it } + val fallback = failures[""] ?: return null + // A hostless record only belongs to this probe if it just happened + // (single onboarding flow, no concurrency to speak of). + if (System.currentTimeMillis() - fallback.recordedAtMs > FRESH_MS) return null + return fallback + } + + /** + * Trust [host]'s recorded leaf from now on. Returns false when there is + * nothing recorded (never invent trust). + */ + suspend fun approve(host: String): Boolean { + val key = host.lowercase() + val failure = failureFor(key) ?: return false + val der = java.util.Base64.getEncoder().encodeToString(failure.leafDer) + store.addPin(key, der) + cache[key] = store.pins(key) + failures.remove(key) + failures.remove("") + return true + } + + suspend fun decline(host: String) { + failures.remove(host.lowercase()) + failures.remove("") + } + + suspend fun forget(host: String) { + store.removeHost(host) + cache.remove(host.lowercase()) + } + + suspend fun pinnedHosts(): Set = store.hosts() + + companion object { + private const val FRESH_MS = 30_000L + + fun defaultSystemTrustManager(): X509TrustManager { + val factory = javax.net.ssl.TrustManagerFactory.getInstance( + javax.net.ssl.TrustManagerFactory.getDefaultAlgorithm() + ) + factory.init(null as java.security.KeyStore?) + return factory.trustManagers.filterIsInstance().first() + } + } +} diff --git a/android/app/src/main/java/com/shonar/recording/MigrationRunner.kt b/android/app/src/main/java/com/shonar/recording/MigrationRunner.kt new file mode 100644 index 0000000..89541e2 --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/MigrationRunner.kt @@ -0,0 +1,89 @@ +package com.shonar.recording + +import com.shonar.provider.LocalOnlyProvider +import com.shonar.provider.ProviderRegistry +import com.shonar.provider.ShonarProvider +import com.shonar.provider.SyncState +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive + +/** + * P7 foreground "migrate now" uploader. Deliberately bounded — this is NOT + * the M5 background driver: + * + * - runs while the user watches (progress callback), cancellable; the + * in-flight file finishes, files never started stay exactly as they + * were, and the rest can run later (or under M5). + * - one attempt per file, no retry, no network/constraint checks. + * - upload-only: the local file is the source of truth and is never + * modified; success overwrites the row's remote slot with the new + * provider, failure records ERROR + the provider's message and moves on. + * - local-only is rejected as a target: there is nothing to upload to. + * ("Keep everything local" is a migration *choice*, handled by leaving + * rows untouched — not by this runner.) + */ +class MigrationRunner( + private val slots: SyncSlots, + private val registry: ProviderRegistry, +) { + data class Progress(val done: Int, val total: Int, val currentTitle: String) + data class Result(val uploaded: Int, val failed: List, val cancelled: Boolean) + + suspend fun migrateAllTo( + providerId: String, + onProgress: (Progress) -> Unit = {}, + ): Result { + require(providerId != LocalOnlyProvider.ID) { "cannot migrate to local-only" } + val target: ShonarProvider = registry.provider(providerId) + val rows = slots.rows() + var uploaded = 0 + val failed = mutableListOf() + var cancelled = false + rows.forEachIndexed { index, row -> + // Cancellation lands here between files: counted, honest, resumable. + try { + currentCoroutineContext().ensureActive() + } catch (e: kotlinx.coroutines.CancellationException) { + cancelled = true + return Result(uploaded, failed, cancelled) + } + onProgress(Progress(index, rows.size, row.title)) + val draft = slots.draftFor(row) + if (draft == null) { + slots.markFailed(row, "local file is gone") + failed += row.title + return@forEachIndexed + } + stepToUploading(row) + try { + val ref = target.upload(draft) {} + slots.markUploaded(slots.rows().firstOrNull { it.id == row.id } ?: row, providerId, ref) + uploaded++ + } catch (e: kotlinx.coroutines.CancellationException) { + // Interrupted mid-file: leave the row UPLOADING so a later + // run retries it explicitly rather than assuming either + // outcome. + throw e + } catch (e: Exception) { + val fresh = slots.rows().firstOrNull { it.id == row.id } ?: row + // Backoff included: the M5 drain picks ERROR rows up when due. + slots.markFailed(fresh, e.message ?: e.javaClass.simpleName) + failed += row.title + } + } + onProgress(Progress(rows.size, rows.size, "")) + return Result(uploaded, failed, cancelled) + } + + /** + * Legal first steps toward UPLOADING from any resting state, per the + * transition table (shared with the M5 drain). + */ + private suspend fun stepToUploading(row: RecordingEntity) { + var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row + for (step in stepsToUploading(fresh.syncState)) { + slots.markState(fresh, step) + fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh + } + } +} diff --git a/android/app/src/main/java/com/shonar/recording/RecordingDao.kt b/android/app/src/main/java/com/shonar/recording/RecordingDao.kt new file mode 100644 index 0000000..22a86ad --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/RecordingDao.kt @@ -0,0 +1,29 @@ +package com.shonar.recording + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +@Dao +interface RecordingDao { + @Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC") + fun observeAll(): Flow> + + @Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC") + suspend fun getAll(): List + + @Query("SELECT * FROM recordings WHERE id = :id") + suspend fun getById(id: String): RecordingEntity? + + @Insert + suspend fun insert(recording: RecordingEntity) + + @Update + suspend fun update(recording: RecordingEntity) + + @Delete + suspend fun delete(recording: RecordingEntity) +} diff --git a/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt b/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt new file mode 100644 index 0000000..aa8ac3d --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/RecordingEntity.kt @@ -0,0 +1,38 @@ +package com.shonar.recording + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.shonar.provider.SyncState + +/** + * Metadata for a completed local recording. Audio remains in app-private + * storage. P7: each row carries its provider slot — + * + * - [originProviderId] + [remoteKey]/[remoteEtag]/[remoteSizeBytes] name the + * server copy, if any. 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. + * - [syncState]/[syncReason] is the §2 lifecycle. Until the M5 background + * driver exists, the foreground migrator (P7) is the only writer besides + * these defaults. + */ +@Entity(tableName = "recordings") +data class RecordingEntity( + @PrimaryKey val id: String, + val title: String, + val createdAtEpochMs: Long, + val durationMs: Long, + val filePath: String, + val mimeType: String, + val sizeBytes: Long, + val originProviderId: String = "local-only", + val remoteKey: String? = null, + val remoteEtag: String? = null, + val remoteSizeBytes: Long? = null, + val syncState: SyncState = SyncState.LOCAL_ONLY, + val syncReason: String? = null, + /** Consecutive failed attempts (M5 backoff). Reset on success/forget. */ + val syncAttempts: Int = 0, + /** Next eligible retry, epoch ms. Null = due immediately. */ + val syncRetryAtMs: Long? = null, +) diff --git a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt new file mode 100644 index 0000000..822e0e3 --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt @@ -0,0 +1,76 @@ +package com.shonar.recording + +import android.content.Context +import com.shonar.provider.RecordingDraft +import com.shonar.provider.RemoteRef +import com.shonar.provider.SyncState +import kotlinx.coroutines.flow.Flow +import java.io.File +import java.text.DateFormat +import java.util.Date +import java.util.UUID + +class RecordingRepository( + private val context: Context, + private val dao: RecordingDao, +) { + private val slots = SyncSlots(dao) + private val recordingsDir = File(context.filesDir, "recordings").apply { mkdirs() } + private val tempDir = File(recordingsDir, ".in-progress").apply { mkdirs() } + + val recordings: Flow> = dao.observeAll() + + fun newRecordingId(): String = UUID.randomUUID().toString() + + fun tempFile(id: String): File = File(tempDir, "$id.m4a") + + suspend fun finish( + id: String, + durationMs: Long, + tempFile: File, + createdAtEpochMs: Long = System.currentTimeMillis(), + ) { + val finalFile = File(recordingsDir, "$id.m4a") + if (!tempFile.renameTo(finalFile)) { + tempFile.copyTo(finalFile, overwrite = true) + tempFile.delete() + } + dao.insert( + RecordingEntity( + id = id, + title = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT) + .format(Date(createdAtEpochMs)), + createdAtEpochMs = createdAtEpochMs, + durationMs = durationMs.coerceAtLeast(1L), + filePath = finalFile.absolutePath, + mimeType = "audio/mp4", + sizeBytes = finalFile.length(), + ), + ) + } + + suspend fun delete(recording: RecordingEntity) { + val canonicalRoot = recordingsDir.canonicalFile + val canonicalFile = File(recording.filePath).canonicalFile + require(canonicalFile.toPath().startsWith(canonicalRoot.toPath())) { "Invalid recording path" } + canonicalFile.delete() + dao.delete(recording) + } + + // ---- P7 provider slot (delegates to SyncSlots; Context-free logic) ----- + + /** Draft for upload, or null when the local file is gone. */ + fun toDraft(entity: RecordingEntity): RecordingDraft? = slots.draftFor(entity) + + suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) = + slots.markState(entity, state, reason) + + suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) = + slots.markUploaded(entity, providerId, ref) + + /** + * Forget every remote link (P7 "start fresh" choice). Files stay; + * nothing remote is touched — forgetting is metadata-only. + */ + suspend fun forgetAllRemotes(newOriginId: String) = slots.forgetAllRemotes(newOriginId) +} diff --git a/android/app/src/main/java/com/shonar/recording/RecordingService.kt b/android/app/src/main/java/com/shonar/recording/RecordingService.kt new file mode 100644 index 0000000..3c1de60 --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/RecordingService.kt @@ -0,0 +1,242 @@ +package com.shonar.recording + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.media.MediaRecorder +import android.os.Build +import android.os.IBinder +import android.os.SystemClock +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import com.shonar.MainActivity +import com.shonar.R +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.isActive +import java.io.File + +data class RecordingSnapshot( + val phase: Phase = Phase.IDLE, + val recordingId: String? = null, + val elapsedMs: Long = 0, +) { + enum class Phase { IDLE, RECORDING, PAUSED } +} + +/** Owns microphone access and survives the activity leaving the foreground. */ +class RecordingService : Service() { + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var recorder: MediaRecorder? = null + private var currentId: String? = null + private var currentTempFile: File? = null + private var accumulatedMs = 0L + private var resumedAtElapsed = 0L + private var tickerJob: kotlinx.coroutines.Job? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> startRecording() + ACTION_PAUSE -> pauseRecording() + ACTION_RESUME -> resumeRecording() + ACTION_STOP -> stopRecording() + } + return START_NOT_STICKY + } + + private fun startRecording() { + if (snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != + PackageManager.PERMISSION_GRANTED + ) { + stopSelf() + return + } + + val app = application as com.shonar.ShonarApplication + val id = app.recordingRepository.newRecordingId() + val temp = app.recordingRepository.tempFile(id) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification("Recording in progress"), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + } else { + startForeground(NOTIFICATION_ID, notification("Recording in progress")) + } + recorder = MediaRecorder().apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setOutputFile(temp.absolutePath) + prepare() + start() + } + currentId = id + currentTempFile = temp + resumedAtElapsed = SystemClock.elapsedRealtime() + accumulatedMs = 0L + updateSnapshot(RecordingSnapshot.Phase.RECORDING) + tickerJob = serviceScope.launch { + while (isActive) { + delay(500) + _snapshot.value = snapshot.value.copy(elapsedMs = elapsedMs()) + } + } + } catch (_: Exception) { + recorder?.release() + recorder = null + temp.delete() + stopSelf() + } + } + + private fun pauseRecording() { + if (snapshot.value.phase != RecordingSnapshot.Phase.RECORDING) return + recorder?.pause() + accumulatedMs += SystemClock.elapsedRealtime() - resumedAtElapsed + updateSnapshot(RecordingSnapshot.Phase.PAUSED) + updateNotification("Recording paused") + } + + private fun resumeRecording() { + if (snapshot.value.phase != RecordingSnapshot.Phase.PAUSED) return + recorder?.resume() + resumedAtElapsed = SystemClock.elapsedRealtime() + updateSnapshot(RecordingSnapshot.Phase.RECORDING) + updateNotification("Recording in progress") + } + + private fun stopRecording() { + if (snapshot.value.phase == RecordingSnapshot.Phase.IDLE) { + stopSelf() + return + } + val id = currentId + val temp = currentTempFile + val duration = elapsedMs() + try { + recorder?.stop() + } catch (_: RuntimeException) { + temp?.delete() + } + recorder?.release() + recorder = null + currentId = null + currentTempFile = null + tickerJob?.cancel() + tickerJob = null + updateSnapshot(RecordingSnapshot.Phase.IDLE) + serviceScope.launch { + val app = application as com.shonar.ShonarApplication + if (id != null && temp != null && temp.exists() && temp.length() > 0) { + runCatching { app.recordingRepository.finish(id, duration, temp) } + .onFailure { temp.delete() } + } + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun elapsedMs(): Long = when (snapshot.value.phase) { + RecordingSnapshot.Phase.RECORDING -> + accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed) + RecordingSnapshot.Phase.PAUSED -> accumulatedMs + RecordingSnapshot.Phase.IDLE -> 0L + } + + private fun updateSnapshot(phase: RecordingSnapshot.Phase) { + _snapshot.value = RecordingSnapshot(phase, currentId, elapsedMs()) + updateNotification(if (phase == RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress") + } + + private fun updateNotification(text: String) { + if (snapshot.value.phase == RecordingSnapshot.Phase.IDLE) return + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(text)) + } + + private fun notification(text: String): Notification { + val open = PendingIntent.getActivity( + this, 1, Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val stop = PendingIntent.getService( + this, 2, Intent(this, RecordingService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("SHONAR") + .setContentText(text) + .setOngoing(true) + .setContentIntent(open) + .addAction(0, "Stop", stop) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + getSystemService(NotificationManager::class.java).createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Recording", NotificationManager.IMPORTANCE_LOW), + ) + } + } + + override fun onDestroy() { + if (snapshot.value.phase != RecordingSnapshot.Phase.IDLE) { + recorder?.reset() + recorder?.release() + currentTempFile?.delete() + recorder = null + tickerJob?.cancel() + tickerJob = null + _snapshot.value = RecordingSnapshot() + } + serviceScope.cancel() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + private const val CHANNEL_ID = "recording" + private const val NOTIFICATION_ID = 1001 + const val ACTION_START = "com.shonar.recording.START" + const val ACTION_PAUSE = "com.shonar.recording.PAUSE" + const val ACTION_RESUME = "com.shonar.recording.RESUME" + const val ACTION_STOP = "com.shonar.recording.STOP" + + private val _snapshot = MutableStateFlow(RecordingSnapshot()) + val snapshot: StateFlow = _snapshot.asStateFlow() + + fun command(context: Context, action: String) { + val intent = Intent(context, RecordingService::class.java).setAction(action) + if (action == ACTION_START) { + androidx.core.content.ContextCompat.startForegroundService(context, intent) + } else { + context.startService(intent) + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt b/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt new file mode 100644 index 0000000..9e4c82e --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/ShonarDatabase.kt @@ -0,0 +1,41 @@ +package com.shonar.recording + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +@Database(entities = [RecordingEntity::class], version = 3, exportSchema = false) +@TypeConverters(SyncStateConverter::class) +abstract class ShonarDatabase : RoomDatabase() { + abstract fun recordingDao(): RecordingDao +} + +/** + * v1 -> v2 (P7): provider slot per recording. Existing rows predate sync + * tracking, so they read as never-synced locals — which is exactly what + * they are. NOT NULL columns carry defaults; no data moves. + */ +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recordings ADD COLUMN originProviderId TEXT NOT NULL DEFAULT 'local-only'") + db.execSQL("ALTER TABLE recordings ADD COLUMN remoteKey TEXT") + db.execSQL("ALTER TABLE recordings ADD COLUMN remoteEtag TEXT") + db.execSQL("ALTER TABLE recordings ADD COLUMN remoteSizeBytes INTEGER") + db.execSQL("ALTER TABLE recordings ADD COLUMN syncState TEXT NOT NULL DEFAULT 'LOCAL_ONLY'") + db.execSQL("ALTER TABLE recordings ADD COLUMN syncReason TEXT") + } +} + +/** + * v2 -> v3 (M5): consecutive-failure backoff. attempts/retryAt default to + * "never failed, due immediately", which is exactly right for pre-existing + * ERROR rows left by P7 runs. + */ +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE recordings ADD COLUMN syncAttempts INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE recordings ADD COLUMN syncRetryAtMs INTEGER") + } +} diff --git a/android/app/src/main/java/com/shonar/recording/SyncDrain.kt b/android/app/src/main/java/com/shonar/recording/SyncDrain.kt new file mode 100644 index 0000000..7b36682 --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/SyncDrain.kt @@ -0,0 +1,94 @@ +package com.shonar.recording + +import com.shonar.provider.LocalOnlyProvider +import com.shonar.provider.ShonarProvider +import com.shonar.provider.SyncState +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive + +/** + * M5 background drain engine. Context-free on purpose: the [SyncWorker] + * (Context-bound) builds the inputs, unit tests drive this directly. + * + * One pass over due rows — QUEUED, plus ERROR whose backoff expired (null + * retryAt = due immediately, which is how P7 leftovers read). SYNCED, + * UPLOADED and LOCAL_ONLY rows are never touched. Local-only target is a + * no-op. Failures record backoff via [SyncSlots.markFailed]; cancellation + * between files stops honestly, and a row interrupted mid-upload reverts + * to QUEUED so a later pass retries it explicitly. + */ +class SyncDrain( + private val slots: SyncSlots, + private val clock: () -> Long = System::currentTimeMillis, +) { + /** Snapshot of device conditions, taken by the worker. Pure to evaluate. */ + data class Gates(val online: Boolean, val unmetered: Boolean, val charging: Boolean) + + data class Progress(val done: Int, val total: Int, val currentTitle: String) + data class Result(val uploaded: Int, val failed: List, val postponed: Boolean) + + suspend fun drainOnce( + provider: ShonarProvider, + wifiOnly: Boolean, + chargingOnly: Boolean, + gates: Gates, + onProgress: (Progress) -> Unit = {}, + ): Result { + if (provider.descriptor.id == LocalOnlyProvider.ID) { + return Result(0, emptyList(), postponed = false) + } + val now = clock() + val due = slots.rows().filter { row -> + row.syncState == SyncState.QUEUED || + (row.syncState == SyncState.ERROR && + (row.syncRetryAtMs == null || row.syncRetryAtMs <= now)) + } + if (due.isEmpty()) return Result(0, emptyList(), postponed = false) + if (!gates.satisfiedBy(wifiOnly, chargingOnly)) { + return Result(0, emptyList(), postponed = true) + } + var uploaded = 0 + val failed = mutableListOf() + due.forEachIndexed { index, row -> + try { + currentCoroutineContext().ensureActive() + } catch (e: kotlinx.coroutines.CancellationException) { + return Result(uploaded, failed, postponed = false) + } + onProgress(Progress(index, due.size, row.title)) + val draft = slots.draftFor(row) + if (draft == null) { + slots.markFailed(row, "local file is gone", now) + failed += row.title + return@forEachIndexed + } + var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row + for (step in stepsToUploading(fresh.syncState)) { + slots.markState(fresh, step) + fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh + } + try { + val ref = provider.upload(draft) {} + val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh + slots.markUploaded(done, provider.descriptor.id, ref) + uploaded++ + } catch (e: kotlinx.coroutines.CancellationException) { + // Interrupted mid-file: revert so a later pass retries + // explicitly instead of assuming either outcome. + val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh + slots.markState(done, SyncState.QUEUED) + throw e + } catch (e: Exception) { + val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh + slots.markFailed(done, e.message ?: e.javaClass.simpleName, now) + failed += row.title + } + } + onProgress(Progress(due.size, due.size, "")) + return Result(uploaded, failed, postponed = false) + } +} + +/** Pure gate evaluation, unit-tested. */ +internal fun SyncDrain.Gates.satisfiedBy(wifiOnly: Boolean, chargingOnly: Boolean): Boolean = + online && (!wifiOnly || unmetered) && (!chargingOnly || charging) diff --git a/android/app/src/main/java/com/shonar/recording/SyncSlots.kt b/android/app/src/main/java/com/shonar/recording/SyncSlots.kt new file mode 100644 index 0000000..67543ef --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/SyncSlots.kt @@ -0,0 +1,116 @@ +package com.shonar.recording + +import com.shonar.provider.RecordingDraft +import com.shonar.provider.RemoteRef +import com.shonar.provider.SyncState +import java.io.File + +/** + * P7 provider-slot bookkeeping over [RecordingDao]. Context-free on purpose: + * the repository (Context-bound) delegates here, and the migration runner + * and unit tests drive it directly. + * + * One slot is the whole history: switching providers never touches it until + * the user migrates (slot overwritten) or forgets (slot cleared). The local + * file is always the source of truth. + */ +class SyncSlots(private val dao: RecordingDao) { + + suspend fun rows(): List = dao.getAll() + + /** Draft for upload, or null when the local file is gone. */ + fun draftFor(entity: RecordingEntity): RecordingDraft? { + val file = File(entity.filePath) + if (!file.isFile) return null + return RecordingDraft( + id = entity.id, + title = entity.title, + createdAtEpochMs = entity.createdAtEpochMs, + durationMs = entity.durationMs, + mime = entity.mimeType, + sourceFile = file, + sizeBytes = file.length(), + ) + } + + suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) { + dao.update(entity.copy(syncState = state, syncReason = reason)) + } + + suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) { + dao.update( + entity.copy( + originProviderId = providerId, + remoteKey = ref.key, + remoteEtag = ref.etag, + remoteSizeBytes = ref.sizeBytes, + syncState = SyncState.UPLOADED, + syncReason = null, + syncAttempts = 0, + syncRetryAtMs = null, + ) + ) + } + + /** + * Record a failed attempt with exponential backoff. [nowMs] is injected + * (default wall-clock) so tests run on a fixed clock. + */ + suspend fun markFailed( + entity: RecordingEntity, + reason: String, + nowMs: Long = System.currentTimeMillis(), + ) { + val attempts = (entity.syncAttempts + 1).coerceAtLeast(1) + dao.update( + entity.copy( + syncState = SyncState.ERROR, + syncReason = reason, + syncAttempts = attempts, + syncRetryAtMs = nowMs + backoffDelayMs(attempts), + ) + ) + } + + /** + * Forget every remote link ("start fresh"). Files stay; rows read as + * never-synced under [newOriginId], ready for a later upload run. + * Nothing remote is touched — forgetting is metadata-only. + */ + suspend fun forgetAllRemotes(newOriginId: String) { for (row in dao.getAll()) { + dao.update( + row.copy( + originProviderId = newOriginId, + remoteKey = null, + remoteEtag = null, + remoteSizeBytes = null, + syncState = SyncState.QUEUED, + syncReason = null, + syncAttempts = 0, + syncRetryAtMs = null, + ) + ) + } + } +} + +/** + * Legal steps from any resting state toward UPLOADING, per the transition + * table. Shared by the P7 foreground migrator and the M5 background drain + * so both move rows through identical states. + */ +internal fun stepsToUploading(from: SyncState): List = when (from) { + SyncState.LOCAL_ONLY, SyncState.ERROR -> listOf(SyncState.QUEUED, SyncState.UPLOADING) + // A leftover UPLOADING row (killed run) has no self-loop: detour via QUEUED. + SyncState.UPLOADING -> listOf(SyncState.QUEUED, SyncState.UPLOADING) + else -> listOf(SyncState.UPLOADING) +} + +/** + * Consecutive-failure backoff: 1m, 2m, 4m … capped at 1h. [attempt] is the + * 1-based count *including* the failure just recorded. + */ +internal fun backoffDelayMs(attempt: Int): Long { + val shift = (attempt.coerceAtLeast(1) - 1).coerceAtMost(6) + return (60_000L shl shift).coerceAtMost(3_600_000L) +} diff --git a/android/app/src/main/java/com/shonar/recording/SyncStateConverter.kt b/android/app/src/main/java/com/shonar/recording/SyncStateConverter.kt new file mode 100644 index 0000000..381a75f --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/SyncStateConverter.kt @@ -0,0 +1,16 @@ +package com.shonar.recording + +import androidx.room.TypeConverter +import com.shonar.provider.SyncState + +/** SyncState <-> String by enum name. Unknown names (future states from a + * newer app reading this DB are impossible — same app writes and reads) fall + * back to LOCAL_ONLY rather than crashing the library. */ +class SyncStateConverter { + @TypeConverter + fun toString(state: SyncState): String = state.name + + @TypeConverter + fun toState(raw: String?): SyncState = + runCatching { SyncState.valueOf(raw!!) }.getOrDefault(SyncState.LOCAL_ONLY) +} diff --git a/android/app/src/main/java/com/shonar/recording/SyncWorker.kt b/android/app/src/main/java/com/shonar/recording/SyncWorker.kt new file mode 100644 index 0000000..1553dd4 --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/SyncWorker.kt @@ -0,0 +1,141 @@ +package com.shonar.recording + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.shonar.ShonarApplication +import com.shonar.provider.AuthState +import com.shonar.provider.LocalOnlyProvider +import com.shonar.settings.BuiltInSettings +import java.util.concurrent.TimeUnit + +/** + * M5 background sync driver. Thin by design: it resolves settings, the + * active provider, and live device gates, then hands one pass to the + * Context-free [SyncDrain] (which owns all state transitions and is where + * the unit tests live). + * + * Result contract: + * - success: the pass completed (per-file failures are recorded in the DB + * with backoff — retrying the whole worker immediately would hammer). + * - retry: transient only — offline at start, postponed by gates. Auth + * states needing the user (disconnected/expired/revoked) are failure. + * - cancellation propagates: the drain reverts the in-flight row to QUEUED. + */ +class SyncWorker(appContext: Context, params: WorkerParameters) : + CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val app = applicationContext as ShonarApplication + app.settingsManager.ensureLoaded() + val settings = app.settingsManager + val providerId = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" } + if (providerId == LocalOnlyProvider.ID) return Result.success() + val wifiOnly = settings.bool(BuiltInSettings.WIFI_ONLY_UPLOAD) + val chargingOnly = settings.bool(BuiltInSettings.CHARGING_ONLY_UPLOAD) + + val gates = readGates(applicationContext) + val provider = try { + app.providerRegistry.provider(providerId) + } catch (e: Exception) { + return Result.failure() + } + val auth = try { + provider.reconnect() + } catch (e: Exception) { + return Result.retry() + } + when (auth) { + AuthState.DISCONNECTED, AuthState.EXPIRED, AuthState.REVOKED -> + return Result.failure() + AuthState.OFFLINE -> return Result.retry() + AuthState.CONNECTED -> Unit + } + val drain = SyncDrain(SyncSlots(app.database.recordingDao())) + val res = drain.drainOnce(provider, wifiOnly, chargingOnly, gates) + return if (res.postponed) Result.retry() else Result.success() + } + + companion object { + /** Snapshot of device conditions for [SyncDrain.Gates]. */ + internal fun readGates(context: Context): SyncDrain.Gates { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) + as? android.net.ConnectivityManager + val caps = cm?.getNetworkCapabilities(cm.activeNetwork) + val online = caps?.hasCapability( + android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED + ) == true + val unmetered = caps?.hasCapability( + android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED + ) == true + val bm = context.getSystemService(Context.BATTERY_SERVICE) + as? android.os.BatteryManager + return SyncDrain.Gates( + online = online, + unmetered = unmetered, + charging = bm?.isCharging == true, + ) + } + } +} + +/** + * WorkManager wiring: one-shot drains on demand, a periodic 15-minute + * drain (the platform minimum) while constraints hold, and pause. + * Constraint mapping is a pure function ([workConstraints]) so the + * settings-to-WorkManager translation is unit-tested. + */ +object SyncScheduler { + const val ONCE_NAME = "shonar-sync-drain-once" + const val PERIODIC_NAME = "shonar-sync-drain-periodic" + + /** Pure settings -> constraints mapping. */ + internal fun workConstraints(wifiOnly: Boolean, chargingOnly: Boolean): Constraints = + Constraints.Builder() + .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + .setRequiresCharging(chargingOnly) + .build() + + /** Drain now (startup, after a switch, after settings change). */ + fun requestNow(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) { + val req = OneTimeWorkRequestBuilder() + .setConstraints(workConstraints(wifiOnly, chargingOnly)) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .addTag(ONCE_NAME) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(ONCE_NAME, ExistingWorkPolicy.REPLACE, req) + } + + /** Steady-state background drain. Idempotent; safe to call on every launch. */ + fun ensurePeriodic(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) { + val req = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setConstraints(workConstraints(wifiOnly, chargingOnly)) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .addTag(PERIODIC_NAME) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + PERIODIC_NAME, ExistingPeriodicWorkPolicy.UPDATE, req + ) + } + + /** Pause: cancel scheduled work and revert in-flight rows to QUEUED. */ + suspend fun pauseAll(context: Context, dao: RecordingDao) { + val wm = WorkManager.getInstance(context) + wm.cancelUniqueWork(ONCE_NAME) + wm.cancelUniqueWork(PERIODIC_NAME) + for (row in dao.getAll()) { + if (row.syncState == com.shonar.provider.SyncState.UPLOADING) { + dao.update(row.copy(syncState = com.shonar.provider.SyncState.QUEUED)) + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt index cbb7577..d88e5e0 100644 --- a/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt +++ b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt @@ -16,6 +16,10 @@ object BuiltInSettings { // ids other code depends on (single source of truth) const val CONSENT = "consent_notice_seen" + const val PROVIDER_ID = "provider_id" + const val PROVIDER_URL = "provider_url" + const val WIFI_ONLY_UPLOAD = "wifi_only_upload" + const val CHARGING_ONLY_UPLOAD = "charging_only_upload" val all: List = listOf( // --- General ------------------------------------------------------- @@ -55,6 +59,24 @@ object BuiltInSettings { type = SettingType.COLOR, defaultJson = "\"#4FD1C5\"", ), + // --- Storage provider (selection persisted here; see ProviderRegistry) --- + SettingDefinition( + id = PROVIDER_ID, + name = "Storage provider", + description = "Where your recordings live.", + category = CAT_GENERAL, + type = SettingType.SELECT, + choices = listOf("local-only", "nextcloud", "custom-shonar", "sync-folder", "start9", "umbrel"), + defaultJson = "\"local-only\"", + ), + SettingDefinition( + id = PROVIDER_URL, + name = "Provider server URL", + description = "Server address for the selected provider (empty for local-only).", + category = CAT_GENERAL, + type = SettingType.URL, + defaultJson = "\"\"", + ), // --- Network ------------------------------------------------------------- SettingDefinition( id = "wifi_only_upload", diff --git a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt index 3ee8a83..d45deb8 100644 --- a/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/home/HomeScreen.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog @@ -34,32 +36,74 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import android.Manifest +import android.content.pm.PackageManager import com.shonar.ShonarApplication import com.shonar.settings.BuiltInSettings import kotlinx.coroutines.launch +import androidx.lifecycle.viewmodel.compose.viewModel /** - * Home: big record button (functional UI; recording engine lands in M4), + * Home: offline recording, local library, provider selection, and settings. * a Settings entry, and the first-launch recording-consent notice which must * be acknowledged before anything else. */ @Composable -fun HomeScreen(onOpenSettings: () -> Unit) { - val app = LocalContext.current.applicationContext as ShonarApplication +fun HomeScreen( + onOpenSettings: () -> Unit, + onOpenStorage: () -> Unit, +) { + val context = LocalContext.current + val app = context.applicationContext as ShonarApplication + val vm: RecordingViewModel = viewModel(factory = RecordingViewModel.Factory) + val recorderState by vm.recorderState.collectAsState() + val recordings by vm.recordings.collectAsState() + val playingId by vm.playingId.collectAsState() val scope = rememberCoroutineScope() var consentSeen by remember { mutableStateOf(null) } + var storageHeadline by remember { mutableStateOf(null) } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> if (granted) vm.start() } + + fun startRecording() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + vm.start() + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } LaunchedEffect(Unit) { app.settingsManager.ensureLoaded() consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT) + val pid = app.settingsManager.string(BuiltInSettings.PROVIDER_ID) + val purl = app.settingsManager.string(BuiltInSettings.PROVIDER_URL) + storageHeadline = com.shonar.ui.provider.ProviderSelectionViewModel + .headlineFor(pid.ifBlank { "local-only" }, purl) } Scaffold( floatingActionButton = { ExtendedFloatingActionButton( - onClick = { /* M4: start recording flow */ }, + onClick = { + if (consentSeen == true) { + if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) { + startRecording() + } else { + vm.stop() + } + } + }, icon = { Icon(Icons.Filled.Mic, contentDescription = null) }, - text = { Text("Record") }, + text = { + Text(if (recorderState.phase == com.shonar.recording.RecordingSnapshot.Phase.IDLE) "Record" else "Stop") + }, ) }, ) { padding -> @@ -67,8 +111,9 @@ fun HomeScreen(onOpenSettings: () -> Unit) { modifier = Modifier .fillMaxSize() .padding(padding) - .padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text("SHONAR", style = MaterialTheme.typography.headlineMedium) @@ -77,15 +122,40 @@ fun HomeScreen(onOpenSettings: () -> Unit) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + storageHeadline?.let { headline -> + StorageChip(headline = headline, onClick = onOpenStorage) + } Spacer(Modifier.height(24.dp)) - QuickCard("Settings", "Server, sync, appearance, custom settings", + QuickCard("Choose storage", storageHeadline ?: "Where recordings live", + Icons.Filled.Cloud, onOpenStorage) + QuickCard("Settings", "Appearance, custom settings", Icons.Filled.Settings, onOpenSettings) - Spacer(Modifier.height(16.dp)) - Text( - "Recording is coming in the next milestone. Nothing records today.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (recorderState.phase != com.shonar.recording.RecordingSnapshot.Phase.IDLE) { + RecordingStatusCard( + state = recorderState, + onPause = vm::pause, + onResume = vm::resume, + onStop = vm::stop, + ) + } + if (recordings.isNotEmpty()) { + Text("Your recordings", style = MaterialTheme.typography.titleLarge, + modifier = Modifier.align(Alignment.Start)) + recordings.forEach { recording -> + RecordingRow( + recording = recording, + playing = playingId == recording.id, + onPlay = { vm.togglePlayback(recording) }, + onDelete = { vm.delete(recording) }, + ) + } + } else { + Text( + "Your recordings will appear here and stay on this device until you choose a provider.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @@ -99,6 +169,86 @@ fun HomeScreen(onOpenSettings: () -> Unit) { } } +@Composable +private fun RecordingStatusCard( + state: com.shonar.recording.RecordingSnapshot, + onPause: () -> Unit, + onResume: () -> Unit, + onStop: () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Recording paused" else "Recording in progress", + style = MaterialTheme.typography.titleMedium, + ) + Text(formatDuration(state.elapsedMs), style = MaterialTheme.typography.bodyLarge) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) onResume else onPause) { + Text(if (state.phase == com.shonar.recording.RecordingSnapshot.Phase.PAUSED) "Resume" else "Pause") + } + TextButton(onClick = onStop) { Text("Finish") } + } + } + } +} + +@Composable +private fun RecordingRow( + recording: com.shonar.recording.RecordingEntity, + playing: Boolean, + onPlay: () -> Unit, + onDelete: () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(recording.title, style = MaterialTheme.typography.titleMedium) + Text( + "${formatDuration(recording.durationMs)} • ${formatBytes(recording.sizeBytes)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(onClick = onPlay) { Text(if (playing) "Pause" else "Play") } + TextButton(onClick = onDelete) { Text("Delete", color = MaterialTheme.colorScheme.error) } + } + } +} + +private fun formatDuration(durationMs: Long): String { + val totalSeconds = (durationMs / 1000).coerceAtLeast(0) + return "%d:%02d".format(totalSeconds / 60, totalSeconds % 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)) +} + +/** Persistent "Stored: …" indicator — users always see where data goes. */ +@Composable +private fun StorageChip(headline: String, onClick: () -> Unit) { + Card(onClick = onClick, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer)) { + Row(Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Cloud, contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer) + Spacer(Modifier.size(8.dp)) + Text("Stored: $headline", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer) + } + } +} + @Composable private fun ConsentDialog(onAcknowledge: () -> Unit) { AlertDialog( diff --git a/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt b/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt new file mode 100644 index 0000000..7387cf7 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt @@ -0,0 +1,70 @@ +package com.shonar.ui.home + +import android.media.MediaPlayer +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.recording.RecordingEntity +import com.shonar.recording.RecordingService +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class RecordingViewModel(private val app: ShonarApplication) : ViewModel() { + val recordings: StateFlow> = app.recordingRepository.recordings + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val recorderState = RecordingService.snapshot + private val _playingId = kotlinx.coroutines.flow.MutableStateFlow(null) + val playingId: StateFlow = _playingId + private var player: MediaPlayer? = null + + fun start() = RecordingService.command(app, RecordingService.ACTION_START) + fun pause() = RecordingService.command(app, RecordingService.ACTION_PAUSE) + fun resume() = RecordingService.command(app, RecordingService.ACTION_RESUME) + fun stop() = RecordingService.command(app, RecordingService.ACTION_STOP) + + fun togglePlayback(recording: RecordingEntity) { + if (_playingId.value == recording.id) { + player?.let { if (it.isPlaying) it.pause() else it.start() } + return + } + player?.release() + player = runCatching { + MediaPlayer().apply { + setDataSource(recording.filePath) + setOnPreparedListener { it.start(); _playingId.value = recording.id } + setOnCompletionListener { _playingId.value = null; it.release(); player = null } + prepareAsync() + } + }.getOrNull() + } + + fun delete(recording: RecordingEntity) { + if (_playingId.value == recording.id) stopPlayback() + viewModelScope.launch { app.recordingRepository.delete(recording) } + } + + private fun stopPlayback() { + player?.release() + player = null + _playingId.value = null + } + + override fun onCleared() { + stopPlayback() + super.onCleared() + } + + companion object { + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication + RecordingViewModel(app) + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt new file mode 100644 index 0000000..e5860ec --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt @@ -0,0 +1,357 @@ +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.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.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), +) { + val state by vm.state.collectAsState() + val context = LocalContext.current + var selectedId by remember { mutableStateOf(null) } + var urlInput by remember { mutableStateOf("") } + var pathInput by remember { mutableStateOf("") } + + 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( + "You stay in control: recordings, transcripts, and summaries go where " + + "you choose. You can switch providers later without losing anything.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } + items(vm.options) { opt -> + ProviderCard( + option = opt, + selected = selectedId == opt.id, + onSelect = { + selectedId = opt.id + if (!opt.available) { + // surface the honest reason immediately + vm.choose(opt, "") + } + }, + ) + } + val sel = vm.options.firstOrNull { it.id == selectedId } + 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 (created by your sync tool)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + item { + Button( + onClick = { vm.connectFolder(pathInput) }, + enabled = state !is SelectionUiState.Probing && pathInput.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { Text("Use this folder") } + } + } + if (state is SelectionUiState.NeedsNcApproval) { + item { + val approval = state as SelectionUiState.NeedsNcApproval + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Approve SHONAR in your browser, then come back here. " + + "Nextcloud issues an app password — your normal password " + + "never touches this app.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(approval.loginUrl)) + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Open Nextcloud login") } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = { vm.cancelFlow() }) { Text("Cancel") } + Spacer(Modifier.weight(1f)) + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) + Spacer(Modifier.width(10.dp)) + TextButton(onClick = { vm.pollNow() }) { Text("I've approved — check now") } + } + } + } + } + } + if (state is SelectionUiState.NeedsCredentials) { + item { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Server email") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Server password") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + vm.login( + (state as SelectionUiState.NeedsCredentials).url, + email, password, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Sign in") } + } + } + } + when (val s = state) { + is SelectionUiState.Probing -> item { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) + Spacer(Modifier.width(10.dp)) + Text("Checking ${s.url} …") + } + } + is SelectionUiState.Error -> item { + Text(s.message, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium) + } + is SelectionUiState.NeedsTlsApproval -> item { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "The server at ${s.url} uses a certificate this app doesn't trust yet. " + + "Only approve if you run this server (or you verified the " + + "fingerprint with whoever does) — and only on a network you trust.", + color = MaterialTheme.colorScheme.error, + ) + if (s.fingerprint != "unknown") { + Text( + "SHA-256: ${s.fingerprint}", + style = MaterialTheme.typography.bodySmall, + ) + } + if (s.details.isNotBlank()) { + Text( + s.details, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton(onClick = { vm.declineCert() }) { Text("Cancel") } + Spacer(Modifier.weight(1f)) + Button( + onClick = { vm.approvePendingCert() }, + enabled = s.fingerprint != "unknown", + ) { Text("Trust once") } + } + } + } + is SelectionUiState.Saved -> item { + Text( + "Connected: ${s.headline}", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + ) + } + is SelectionUiState.NeedsCredentials -> {} + is SelectionUiState.NeedsNcApproval -> {} + is SelectionUiState.NeedsMigration -> {} + is SelectionUiState.Migrating -> {} + is SelectionUiState.Idle -> {} + } + item { + Button( + onClick = onDone, + enabled = state !is SelectionUiState.Probing && + state !is SelectionUiState.NeedsTlsApproval && + state !is SelectionUiState.NeedsMigration && + state !is SelectionUiState.Migrating, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (state is SelectionUiState.Saved) "Continue" else "Keep current setting") } + Spacer(Modifier.height(24.dp)) + } + } + } + + val migration = state as? SelectionUiState.NeedsMigration + if (migration != null) { + AlertDialog( + onDismissRequest = { vm.keepAsIs() }, + title = { Text("Move your library?") }, + text = { + Text( + "You have ${migration.count} recordings. Upload them to the new " + + "provider now, keep them as they are, or forget old server links " + + "and start fresh? Your local files never move either way." + ) + }, + confirmButton = { + TextButton( + onClick = { vm.migrateNow() }, + enabled = migration.canUpload, + ) { Text("Upload ${migration.count} now") } + }, + dismissButton = { + Row { + TextButton(onClick = { vm.forgetLinks() }) { Text("Forget links") } + TextButton(onClick = { vm.keepAsIs() }) { Text("Keep as-is") } + } + }, + ) + } + + val migrating = state as? SelectionUiState.Migrating + if (migrating != null) { + AlertDialog( + onDismissRequest = { }, + title = { Text("Uploading ${migrating.done} of ${migrating.total}") }, + text = { + Column { + LinearProgressIndicator( + progress = { (migrating.done.toFloat() / migrating.total.coerceAtLeast(1)) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Text( + migrating.current.ifBlank { "Finishing…" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton(onClick = { vm.cancelMigration() }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun ProviderCard(option: ProviderOption, selected: Boolean, onSelect: () -> Unit) { + Card(onClick = onSelect) { + Row( + Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.Top, + ) { + RadioButton(selected = selected, onClick = onSelect) + Spacer(Modifier.width(8.dp)) + Column(Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(option.name, style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold) + if (!option.available) { + Spacer(Modifier.width(8.dp)) + Text( + "coming soon", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + ) + } + } + Text(option.blurb, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} diff --git a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt new file mode 100644 index 0000000..a7898bc --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt @@ -0,0 +1,564 @@ +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, // sync-folder style: a local directory path +) + +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 val folderProvider: com.shonar.provider.ShonarProvider by lazy { + app.providerRegistry.provider(ProviderRegistry.SYNC_FOLDER_ID) + } + private var pollJob: kotlinx.coroutines.Job? = null + private var migrationJob: kotlinx.coroutines.Job? = null + private var migratingTo: String? = null + private var pendingOption: ProviderOption? = null + private var pendingUrl: String = "" + private var pendingMigration: SelectionUiState.NeedsMigration? = null + + private val registry = app.providerRegistry + private val repository = app.recordingRepository + private val dao = app.database.recordingDao() + + private val _state = MutableStateFlow(SelectionUiState.Idle) + val state: StateFlow = _state.asStateFlow() + + val options: List = listOf( + ProviderOption( + id = ProviderRegistry.NEXTCLOUD_ID, + name = "Nextcloud", + blurb = "Recommended. Your files on your Nextcloud via WebDAV. Connects with an app password — never your normal password.", + needsUrl = true, + available = true, // P4 + ), + 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 + ), + ProviderOption( + id = ProviderRegistry.SYNC_FOLDER_ID, + name = "Sync folder", + blurb = "A folder on this device kept in sync by Syncthing (or the Nextcloud " + + "desktop client, rsync…). The app writes plain files; your sync tool moves them.", + needsUrl = false, + available = true, // P4 + needsPath = 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) + ), + 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) + ), + ProviderOption( + id = com.shonar.provider.LocalOnlyProvider.ID, + name = "Local only", + blurb = "Nothing leaves this phone. Recording, playback, and search work fully offline.", + needsUrl = false, + available = true, + ), + ) + + 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; the sync folder has its + // own path step (connectFolder). + if (option.needsPath) 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 + } + + /** + * P4 sync folder: validate the directory and remember it. The folder + * must already exist (created by you or by Syncthing) — a typo is an + * error, never a silently created directory. + */ + fun connectFolder(rawPath: String) { + viewModelScope.launch { + val path = rawPath.trim() + if (path.isEmpty()) { + _state.value = SelectionUiState.Error( + "Enter the folder path — e.g. the Syncthing folder for SHONAR." + ) + return@launch + } + _state.value = SelectionUiState.Probing(path) + runCatching { + folderProvider.connect( + com.shonar.provider.ProviderCredential.FolderPath( + accountLabel = path, + path = path, + ) + ) + }.onFailure { + _state.value = SelectionUiState.Error(it.message ?: "Could not use $path") + return@launch + } + save(ProviderRegistry.SYNC_FOLDER_ID, "") + } + } + + 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("\"", "\\\"") + "\"", + ) + } + // P7: switching providers with a non-empty library asks what happens + // to it — nothing moves or is forgotten silently. + val count = runCatching { dao.getAll().size }.getOrDefault(0) + if (previous != providerId && count > 0) { + val ask = SelectionUiState.NeedsMigration( + providerId = providerId, + count = count, + canUpload = providerId != com.shonar.provider.LocalOnlyProvider.ID, + ) + pendingMigration = ask + _state.value = ask + } else { + _state.value = SelectionUiState.Saved(providerId, headlineFor(providerId, url)) + } + } + + /** P7 "keep everything as it is": history stays, states stay. */ + fun keepAsIs() { + viewModelScope.launch { + val ask = pendingMigration ?: return@launch + pendingMigration = null + _state.value = SelectionUiState.Saved( + ask.providerId, headlineFor(ask.providerId, pendingUrl) + ) + } + } + + /** P7 "start fresh": clear every remote link. Files stay; nothing remote + * is touched — forgetting is metadata-only. */ + fun forgetLinks() { + viewModelScope.launch { + val ask = pendingMigration ?: return@launch + pendingMigration = null + runCatching { repository.forgetAllRemotes(ask.providerId) } + _state.value = SelectionUiState.Saved( + ask.providerId, headlineFor(ask.providerId, pendingUrl) + ) + } + } + + /** + * P7 "upload now": foreground migration to the new provider. Cancellable; + * already-finished files stay uploaded, the rest stay QUEUED for a later + * run. Failures are recorded per recording and reported, never retried + * here — retry policy is M5's job. + */ + fun migrateNow() { + viewModelScope.launch { + val ask = pendingMigration ?: return@launch + pendingMigration = null + migrationJob?.cancel() + val runner = com.shonar.recording.MigrationRunner( + com.shonar.recording.SyncSlots(dao), registry + ) + migratingTo = ask.providerId + migrationJob = viewModelScope.launch { + val res = runner.migrateAllTo(ask.providerId) { p -> + _state.value = SelectionUiState.Migrating(p.done, p.total, p.currentTitle) + } + migratingTo = null + _state.value = if (res.failed.isEmpty()) { + SelectionUiState.Saved( + ask.providerId, headlineFor(ask.providerId, pendingUrl) + ) + } else { + SelectionUiState.Error( + "Uploaded ${res.uploaded} of ${res.uploaded + res.failed.size}. " + + "Failed (kept locally, retried by a future sync): " + + res.failed.take(3).joinToString(", ") + + if (res.failed.size > 3) ", …" else "" + ) + } + } + } + } + + /** Cancel a running migration. Finished files stay uploaded. */ + fun cancelMigration() { + migrationJob?.cancel() + viewModelScope.launch { + // The runner reports cancellation itself by leaving Migrating; + // this is only the fallback if it somehow didn't. + kotlinx.coroutines.delay(100) + val target = migratingTo + if (_state.value is SelectionUiState.Migrating && target != null) { + migratingTo = null + _state.value = SelectionUiState.Saved(target, headlineFor(target, pendingUrl)) + } + } + } + + companion object { + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication + ProviderSelectionViewModel(app) + } + } + + fun headlineFor(providerId: String, url: String): String = when (providerId) { + com.shonar.provider.LocalOnlyProvider.ID -> "On this device only" + ProviderRegistry.SYNC_FOLDER_ID -> "Sync folder" + else -> { + val host = ServerUrl.parse(url).getOrNull()?.host + if (host.isNullOrBlank()) providerId else "$providerId at $host" + } + } + } +} + +/** Where a hosted-service URL routes after probing. Pure; unit-tested. */ +enum class HostedRoute { NEXT_CLOUD, SHONAR, TLS, NONE } + +/** + * P6a routing: a compatible match wins on either probe; otherwise a TLS + * failure on either blocks everything (approval retries the whole flow); + * anything else means "nothing we speak lives here". + */ +fun routeHosted(nc: ProbeResult, shonar: ProbeResult): HostedRoute = when { + nc is ProbeResult.Compatible -> HostedRoute.NEXT_CLOUD + shonar is ProbeResult.Compatible -> HostedRoute.SHONAR + nc is ProbeResult.TlsFailure || shonar is ProbeResult.TlsFailure -> HostedRoute.TLS + else -> HostedRoute.NONE +} diff --git a/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt b/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt new file mode 100644 index 0000000..3f0a7e6 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt @@ -0,0 +1,250 @@ +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 + +/** + * 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) + 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 + if (ready.auth != AuthState.CONNECTED) { + Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) { + Text("Reconnect") + } + } + Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) { + Text("Switch provider") + } + OutlinedButton( + onClick = onPauseSync, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) { Text("Pause sync") } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = revoke, onCheckedChange = { revoke = it }, enabled = enabled) + Text("Also revoke on the server") + } + OutlinedButton( + onClick = { onDisconnect(revoke) }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) { Text("Disconnect") } + OutlinedButton( + onClick = onAskDelete, + enabled = enabled, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { Text("Delete account & data") } +} + +private fun authLabel(auth: AuthState): String = when (auth) { + AuthState.CONNECTED -> "connected" + AuthState.DISCONNECTED -> "disconnected" + AuthState.EXPIRED -> "expired — reconnect" + AuthState.REVOKED -> "revoked — connect again" + AuthState.OFFLINE -> "offline" +} + +@Composable +private fun DeleteDialog( + ready: StorageViewModel.Ready, + onCancel: () -> Unit, + onConfirm: (String) -> Unit, +) { + var password by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onCancel, + title = { Text("Delete account & data?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + if (ready.needPassword) { + "This asks the server to purge the account (30-day grace), " + + "revokes this device, and forgets every remote link. " + + "Your local library files stay on this phone." + } else { + "This revokes access and forgets every remote link on " + + "${ready.displayName}. Your local library files stay " + + "on this phone; server copies are left alone." + } + ) + if (ready.needPassword) { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Server password") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + ready.deleteError?.let { + Text(it, color = MaterialTheme.colorScheme.error) + } + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(password) }, + enabled = !ready.needPassword || password.isNotEmpty(), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + ) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = onCancel) { Text("Keep") } + }, + ) +} diff --git a/android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt b/android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt new file mode 100644 index 0000000..4b748f8 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/provider/StorageViewModel.kt @@ -0,0 +1,202 @@ +package com.shonar.ui.provider + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.shonar.ShonarApplication +import com.shonar.provider.AuthState +import com.shonar.provider.StorageLocation +import com.shonar.settings.BuiltInSettings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * P7 "Where is my data?" screen: the active provider's live summary plus + * the account lifecycle — reconnect, disconnect (optionally revoking + * server-side), and delete-account-and-data. Switching providers happens + * in the selection screen; the migration question is asked there, at the + * moment of switching. + */ +class StorageViewModel(private val app: ShonarApplication) : ViewModel() { + + data class Ready( + val providerId: String, + val displayName: String, + val auth: AuthState, + val summary: StorageLocation?, + val summaryError: String?, + val busy: String? = null, + val confirmDelete: Boolean = false, + val needPassword: Boolean = false, + val deleteError: String? = null, + ) + + sealed interface UiState { + data object Loading : UiState + data class ReadyState(val ready: Ready) : UiState + data class Error(val message: String) : UiState + } + + private val settings = app.settingsManager + private val registry = app.providerRegistry + private val repository = app.recordingRepository + private val dao = app.database.recordingDao() + + private val _state = MutableStateFlow(UiState.Loading) + val state: StateFlow = _state.asStateFlow() + + fun refresh() { + viewModelScope.launch { + _state.value = UiState.Loading + _state.value = load() + } + } + + private suspend fun load(): UiState { + settings.ensureLoaded() + val id = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" } + val provider = try { + registry.provider(id) + } catch (e: Exception) { + return UiState.Error("Unknown provider \"$id\" — pick one again.") + } + val auth = provider.authState.value + var summary: StorageLocation? = null + var summaryError: String? = null + try { + summary = provider.storageLocationSummary() + } catch (e: com.shonar.provider.ProviderError.NotConnected) { + summaryError = "Not connected — reconnect to see live numbers." + } catch (e: Exception) { + summaryError = e.message ?: "Could not load storage info." + } + return UiState.ReadyState( + Ready( + providerId = id, + displayName = provider.descriptor.displayName, + auth = auth, + summary = summary, + summaryError = summaryError, + ) + ) + } + + fun reconnect() { + viewModelScope.launch { + val cur = currentReady() ?: return@launch + setBusy(cur, "Reconnecting…") + val provider = registry.provider(cur.providerId) + runCatching { provider.reconnect() } + _state.value = loadWith(cur) + } + } + + fun disconnect(revokeOnServer: Boolean) { + viewModelScope.launch { + val cur = currentReady() ?: return@launch + setBusy(cur, "Disconnecting…") + runCatching { registry.provider(cur.providerId).disconnect(revokeOnServer) } + _state.value = loadWith(cur) + } + } + + /** + * M5 pause: cancel scheduled sync work and revert in-flight rows to + * QUEUED. Pause is a resting state, not a job kill — the next drain + * (or a settings change) resumes cleanly. + */ + fun pauseSync() { + viewModelScope.launch { + val cur = currentReady() ?: return@launch + setBusy(cur, "Pausing…") + runCatching { + com.shonar.recording.SyncScheduler.pauseAll(app.applicationContext, dao) + } + _state.value = loadWith(cur) + } + } + + fun askDelete() { + viewModelScope.launch { + val cur = currentReady() ?: return@launch + val needsPassword = registry.provider(cur.providerId) is + com.shonar.provider.CustomShonarProvider + _state.value = UiState.ReadyState( + cur.copy(confirmDelete = true, needPassword = needsPassword, deleteError = null) + ) + } + } + + fun cancelDelete() { + viewModelScope.launch { + val cur = currentReady() ?: return@launch + _state.value = UiState.ReadyState(cur.copy(confirmDelete = false, deleteError = null)) + } + } + + /** + * Delete account & data on the active provider, then fall back to + * local-only with cleared remote links. Provider semantics differ and + * the confirm dialog says which apply; the local library files always + * stay — only the remote links are forgotten. + */ + fun confirmDelete(password: String = "") { + viewModelScope.launch { + val cur = currentReady() ?: return@launch + setBusy(cur, "Deleting…") + val provider = registry.provider(cur.providerId) + val failure = runCatching { + val custom = provider as? com.shonar.provider.CustomShonarProvider + if (custom != null) { + if (password.isEmpty()) throw IllegalArgumentException("Password required") + custom.deleteAccount(password) + } else { + provider.deleteAccountAndData() + } + }.exceptionOrNull() + if (failure != null) { + _state.value = UiState.ReadyState( + cur.copy( + busy = null, + deleteError = failure.message ?: "Delete failed.", + ) + ) + return@launch + } + settings.ensureLoaded() + settings.setValue(BuiltInSettings.PROVIDER_ID, "\"local-only\"") + settings.reset(BuiltInSettings.PROVIDER_URL) + repository.forgetAllRemotes("local-only") + _state.value = load() + } + } + + private suspend fun currentReady(): Ready? = + (_state.value as? UiState.ReadyState)?.ready + + private suspend fun setBusy(cur: Ready, label: String) { + _state.value = UiState.ReadyState(cur.copy(busy = label)) + } + + private suspend fun loadWith(cur: Ready): UiState { + val fresh = load() + // Preserve an open confirm dialog across refreshes. + if (fresh is UiState.ReadyState && cur.confirmDelete) { + return UiState.ReadyState(fresh.ready.copy(confirmDelete = true)) + } + return fresh + } + + companion object { + val Factory: ViewModelProvider.Factory = viewModelFactory { + initializer { + val app = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as ShonarApplication + StorageViewModel(app) + } + } + } +} diff --git a/android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt new file mode 100644 index 0000000..4242708 --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderAuthTest.kt @@ -0,0 +1,199 @@ +package com.shonar.provider + +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** + * Auth lifecycle of CustomShonarProvider against the fake backend: + * persistence, transparent refresh, reuse-detection burn, revocation, + * restore, and purge. The storage happy path is covered by + * [CustomShonarProviderContractTest]. + */ +class CustomShonarProviderAuthTest { + + private val backend = FakeShonarBackend() + private lateinit var dir: File + private lateinit var store: com.shonar.settings.InMemorySettingsStore + + @Before fun setUp() { + backend.start() + dir = File(System.getProperty("java.io.tmpdir"), "custom-auth-${System.nanoTime()}").apply { mkdirs() } + store = com.shonar.settings.InMemorySettingsStore() + } + + @After fun tearDown() { + backend.stop() + dir.deleteRecursively() + } + + private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow() + + private fun loginCred() = ProviderCredential.ShonarLogin( + accountLabel = "test@example.com", + serverUrl = serverUrl(), + email = "test@example.com", + password = "correct-horse-battery", + ) + + private fun connectedProvider(): CustomShonarProvider = runBlocking { + val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars")) + p.connect(loginCred()) + p + } + + @Test fun login_persistsSessionSecurely() = runBlocking { + connectedProvider() + val saved = ShonarAuthStore(store).load() + assertNotNull(saved) + assertEquals(backend.url, saved!!.baseUrl) + assertEquals("test@example.com", saved.email) + assertEquals(backend.currentAccess, saved.accessToken) + assertEquals(backend.currentRefresh, saved.refreshToken) + // password must never be persisted + assertFalse(store.keys().flatMap { listOf(store.getString(it).orEmpty()) } + .any { it.contains("correct-horse-battery") }) + } + + @Test fun staleAccessToken_triggersSingleRefreshAndRetry() = runBlocking { + val p = connectedProvider() + // Simulate an expired access token without touching the refresh token. + val saved = ShonarAuthStore(store).load()!! + ShonarAuthStore(store).save(saved.copy(accessToken = "access-stale")) + val before = backend.accessSeq + + val page = p.list(null) // 401 -> refresh -> retry, transparently + assertTrue(page.items.isEmpty()) + assertEquals(before + 1, backend.accessSeq) + assertEquals(backend.currentAccess, ShonarAuthStore(store).load()!!.accessToken) + assertEquals(AuthState.CONNECTED, p.authState.value) + } + + @Test fun deadRefreshToken_burnsToExpired() = runBlocking { + val p = connectedProvider() + val saved = ShonarAuthStore(store).load()!! + // Both tokens unknown to the server (reuse-detection burn / revocation). + ShonarAuthStore(store).save(saved.copy(accessToken = "dead", refreshToken = "dead")) + + try { + p.list(null) + fail("dead tokens must raise AuthExpired") + } catch (expected: ProviderError.AuthExpired) { + } + assertEquals(AuthState.EXPIRED, p.authState.value) + // Dead tokens are dropped; URL + email stay for one-step re-login. + val after = ShonarAuthStore(store).load() + assertNull(after?.accessToken?.takeIf { it.isNotBlank() }) + } + + @Test fun disconnect_revokesServerSideAndClearsTokens() = runBlocking { + val p = connectedProvider() + val refresh = ShonarAuthStore(store).load()!!.refreshToken + p.disconnect(revokeOnServer = true) + assertEquals(AuthState.DISCONNECTED, p.authState.value) + assertTrue("logout must carry the refresh token", backend.logouts.contains(refresh)) + assertNull(ShonarAuthStore(store).load()?.refreshToken?.takeIf { it.isNotBlank() }) + } + + @Test fun disconnectWithoutRevoke_keepsServerSession() = runBlocking { + val p = connectedProvider() + p.disconnect(revokeOnServer = false) + assertEquals(AuthState.DISCONNECTED, p.authState.value) + assertTrue(backend.logouts.isEmpty()) + } + + @Test fun reconnect_restoresPersistedSession() = runBlocking { + connectedProvider() + // Fresh provider object, same secure store — like an app restart. + val p2 = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars")) + assertEquals(AuthState.CONNECTED, p2.reconnect()) + } + + @Test fun reconnect_withNothingStored_staysDisconnected() = runBlocking { + val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars")) + assertEquals(AuthState.DISCONNECTED, p.reconnect()) + } + + @Test fun deleteAccount_purgesServerSideAndWipesLocal() = runBlocking { + val p = connectedProvider() + p.deleteAccount("correct-horse-battery") + assertEquals(1, backend.deleteAccountCalls) + assertEquals(AuthState.DISCONNECTED, p.authState.value) + assertNull(ShonarAuthStore(store).load()) + } + + @Test fun operationsWhileDisconnected_throwNotConnected() = runBlocking { + val p = CustomShonarProvider(ShonarAuthStore(store), File(dir, "sidecars")) + try { + p.list(null) + fail("must throw NotConnected") + } catch (expected: ProviderError.NotConnected) { + } + } +} + +class ShonarAuthStoreTest { + + private val store = com.shonar.settings.InMemorySettingsStore() + private val auth = ShonarAuthStore(store) + + @Test fun save_load_roundtrip() = runBlocking { + auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 123L, "d1")) + val loaded = auth.load() + assertEquals("https://s.example.com", loaded!!.baseUrl) + assertEquals("a1", loaded.accessToken) + assertEquals("r1", loaded.refreshToken) + assertEquals("d1", loaded.deviceId) + } + + @Test fun load_empty_isNull() = runBlocking { + assertNull(auth.load()) + } + + @Test fun clearTokens_keepsUrlAndEmail() = runBlocking { + auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 1L, null)) + auth.clearTokens() + assertNull(auth.load()) // tokens gone -> no usable session + assertEquals("https://s.example.com", store.getString(ShonarAuthStore.KEY_BASE_URL)) + assertEquals("u@x.com", store.getString(ShonarAuthStore.KEY_EMAIL)) + } + + @Test fun clearAll_forgetsEverything() = runBlocking { + auth.save(ShonarSession("https://s.example.com", "u@x.com", "a1", "r1", 1L, null)) + auth.clearAll() + assertNull(auth.load()) + assertNull(store.getString(ShonarAuthStore.KEY_BASE_URL)) + } +} + +class SyncStateTest { + + @Test fun queued_canStartUploading() { + assertTrue(SyncStatus(SyncState.QUEUED).canTransitionTo(SyncState.UPLOADING)) + } + + @Test fun uploading_canFinishOrFailButNotSkipToSynced() { + assertTrue(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.UPLOADED)) + assertTrue(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.ERROR)) + assertFalse(SyncStatus(SyncState.UPLOADING).canTransitionTo(SyncState.SYNCED)) + } + + @Test fun error_onlyRetriesOrGoesLocal() { + assertTrue(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.QUEUED)) + assertFalse(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.UPLOADING)) + assertFalse(SyncStatus(SyncState.ERROR).canTransitionTo(SyncState.SYNCED)) + } + + @Test fun synced_canReuploadAfterLocalEdit() { + assertTrue(SyncStatus(SyncState.SYNCED).canTransitionTo(SyncState.UPLOADING)) + assertFalse(SyncStatus(SyncState.SYNCED).canTransitionTo(SyncState.QUEUED)) + } +} diff --git a/android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt new file mode 100644 index 0000000..7d7816d --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/CustomShonarProviderTest.kt @@ -0,0 +1,315 @@ +package com.shonar.provider + +import java.io.File +import java.util.UUID +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * In-memory fake of the SHONAR backend's M1 (auth) + M2 (uploads/recordings) + * endpoints. Response shapes mirror the real schemas + * (`backend/shonar/api/schemas_*.py`) so the contract suite below exercises + * the same JSON the app will meet in production: + * + * - rotating refresh tokens with reuse detection (old refresh dies on use) + * - chunk sessions with resume status + idempotent finalize per + * client_recording_id + */ +internal class FakeShonarBackend { + + val server = MockWebServer() + + var accessSeq = 0 + var currentAccess = "access-0" + var currentRefresh = "refresh-0" + + data class Session( + val id: String, + val declaredSize: Long, + val mime: String, + val title: String?, + val clientRecordingId: String?, + val chunks: MutableMap = mutableMapOf(), + ) + + data class Recording( + val id: String, + val title: String, + val mime: String, + val bytes: ByteArray, + val durationSeconds: Double, + ) + + val sessions = mutableMapOf() + val recordings = mutableMapOf() // id -> recording + val finalizedByClientId = mutableMapOf() // client id -> recording id + val logouts = mutableListOf() + var deleteAccountCalls = 0 + + val url: String get() = server.url("/").toString().removeSuffix("/") + + fun start() { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + handle(request) + } + server.start() + } + + fun stop() = server.shutdown() + + private fun authed(request: RecordedRequest): Boolean = + request.getHeader("Authorization") == "Bearer $currentAccess" + + private fun handle(request: RecordedRequest): MockResponse { + val path = request.path?.substringBefore('?').orEmpty() + val method = request.method.orEmpty() + return when { + method == "GET" && path == "/api/v1/provider-info" -> json(200, JSONObject() + .put("kind", "shonar").put("version", "test") + .put("api_version", "v1") + .put("capabilities", JSONObject() + .put("chunked_upload", true).put("server_transcription", false) + .put("server_summary", false).put("account_deletion", true)) + .put("storage_backend", "local")) + + method == "POST" && path == "/api/v1/auth/login" -> { + val body = JSONObject(request.body.readUtf8()) + if (body.optString("email") == "test@example.com" && + body.optString("password") == "correct-horse-battery" + ) { + accessSeq++ + currentAccess = "access-$accessSeq" + currentRefresh = "refresh-$accessSeq" + json(200, tokenPair()) + } else { + json(401, JSONObject().put("detail", "Invalid credentials")) + } + } + + method == "POST" && path == "/api/v1/auth/refresh" -> { + val body = JSONObject(request.body.readUtf8()) + if (body.optString("refresh_token") == currentRefresh) { + accessSeq++ + currentAccess = "access-$accessSeq" + currentRefresh = "refresh-$accessSeq" + json(200, tokenPair()) + } else { + // reuse detection: unknown/rotated token burns the family + json(401, JSONObject().put("detail", "Invalid refresh token")) + } + } + + method == "POST" && path == "/api/v1/auth/logout" -> { + logouts += JSONObject(request.body.readUtf8()).optString("refresh_token") + MockResponse().setResponseCode(204) + } + + method == "POST" && path == "/api/v1/auth/delete-account" -> { + if (!authed(request)) return json(401, JSONObject().put("detail", "Nope")) + deleteAccountCalls++ + json(202, JSONObject().put("detail", "scheduled")) + } + + method == "GET" && path == "/api/v1/auth/me" -> + if (authed(request)) json(200, JSONObject() + .put("id", UUID.randomUUID().toString()).put("email", "test@example.com")) + else json(401, JSONObject().put("detail", "Invalid or expired token")) + + method == "POST" && path == "/api/v1/uploads" -> { + if (!authed(request)) return unauthorized() + val body = JSONObject(request.body.readUtf8()) + val id = UUID.randomUUID().toString() + sessions[id] = Session( + id = id, + declaredSize = body.getLong("declared_size_bytes"), + mime = body.getString("declared_mime_type"), + title = body.optString("title", null), + clientRecordingId = body.optString("client_recording_id", null) + .takeUnless { it.isNullOrBlank() }, + ) + json(201, JSONObject().put("id", id).put("status", "open") + .put("chunk_size_bytes", 64 * 1024) + .put("declared_mime_type", body.getString("declared_mime_type")) + .put("declared_size_bytes", body.getLong("declared_size_bytes"))) + } + + method == "GET" && path.startsWith("/api/v1/uploads/") -> { + if (!authed(request)) return unauthorized() + val id = path.removePrefix("/api/v1/uploads/") + val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone")) + json(200, JSONObject().put("id", id).put("status", "open") + .put("received_chunk_indexes", JSONArray(s.chunks.keys.sorted()))) + } + + method == "PUT" && "/chunks/" in path -> { + if (!authed(request)) return unauthorized() + val rest = path.removePrefix("/api/v1/uploads/") // {id}/chunks/{n} + val id = rest.substringBefore("/chunks/") + val idx = rest.substringAfter("/chunks/").toInt() + val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone")) + s.chunks[idx] = request.body.readByteArray() + json(201, JSONObject().put("chunk_index", idx) + .put("size_bytes", s.chunks[idx]!!.size)) + } + + method == "POST" && path.endsWith("/finalize") -> { + if (!authed(request)) return unauthorized() + val id = path.removePrefix("/api/v1/uploads/").removeSuffix("/finalize") + val s = sessions[id] ?: return json(404, JSONObject().put("detail", "gone")) + val total = s.chunks.toSortedMap().values.sumOf { it.size.toLong() } + if (total != s.declaredSize) { + return json(422, JSONObject().put("detail", "Size mismatch")) + } + val existing = s.clientRecordingId?.let { finalizedByClientId[it] } + if (existing != null) return json(201, recordingJson(recordings[existing]!!)) + val assembled = s.chunks.toSortedMap().values + .fold(byteArrayOf()) { acc, b -> acc + b } + val body = JSONObject(request.body.readUtf8()) + val rec = Recording( + id = UUID.randomUUID().toString(), + title = s.title ?: "Untitled recording", + mime = s.mime, + bytes = assembled, + durationSeconds = body.optDouble("duration_seconds", 0.0), + ) + recordings[rec.id] = rec + s.clientRecordingId?.let { finalizedByClientId[it] = rec.id } + json(201, recordingJson(rec)) + } + + method == "GET" && path == "/api/v1/recordings" -> { + if (!authed(request)) return unauthorized() + val q = request.path?.substringAfter('?', "").orEmpty() + .split('&').associate { + val (k, v) = it.split('=', limit = 2) + "" + k to v + } + val limit = q["limit"]?.toIntOrNull() ?: 50 + val offset = q["offset"]?.toIntOrNull() ?: 0 + val all = recordings.values.sortedByDescending { it.id } + val page = all.drop(offset).take(limit) + json(200, JSONObject() + .put("items", JSONArray(page.map { recordingJson(it) })) + .put("total", all.size).put("limit", limit).put("offset", offset)) + } + + method == "GET" && path.endsWith("/audio") -> { + if (!authed(request)) return unauthorized() + val id = path.removePrefix("/api/v1/recordings/").removeSuffix("/audio") + val rec = recordings[id] ?: return json(404, JSONObject().put("detail", "gone")) + MockResponse().setResponseCode(200) + .setHeader("Content-Type", rec.mime) + .setBody(okio.Buffer().write(rec.bytes)) + } + + method == "DELETE" && path.startsWith("/api/v1/recordings/") -> { + if (!authed(request)) return unauthorized() + val id = path.removePrefix("/api/v1/recordings/") + if (recordings.remove(id) == null) return json(404, JSONObject().put("detail", "gone")) + finalizedByClientId.entries.removeIf { it.value == id } + MockResponse().setResponseCode(204) + } + + else -> json(404, JSONObject().put("detail", "unknown $method $path")) + } + } + + private fun unauthorized() = json(401, JSONObject().put("detail", "Invalid or expired token")) + + private fun tokenPair() = JSONObject() + .put("access_token", currentAccess).put("token_type", "bearer") + .put("expires_in", 900).put("refresh_token", currentRefresh) + + private fun recordingJson(r: Recording) = JSONObject() + .put("id", r.id).put("title", r.title) + .put("recorded_at", "2026-01-02T03:04:05Z") + .put("duration_seconds", r.durationSeconds) + .put("has_audio", true).put("mime_type", r.mime).put("size_bytes", r.bytes.size) + + private fun json(code: Int, obj: JSONObject) = MockResponse() + .setResponseCode(code).setHeader("Content-Type", "application/json") + .setBody(obj.toString()) +} + +/** Runs the shared provider contract suite against CustomShonarProvider. */ +class CustomShonarProviderContractTest : ProviderContractTest() { + + private val backend = FakeShonarBackend() + private lateinit var dir: File + private lateinit var store: com.shonar.settings.InMemorySettingsStore + + @org.junit.Before fun setUp() { + backend.start() + dir = File(System.getProperty("java.io.tmpdir"), "custom-contract-${System.nanoTime()}").apply { mkdirs() } + store = com.shonar.settings.InMemorySettingsStore() + } + + @After fun tearDown() { + backend.stop() + dir.deleteRecursively() + } + + private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow() + + override fun initialCredential(): ProviderCredential = ProviderCredential.ShonarLogin( + accountLabel = "test@example.com", + serverUrl = serverUrl(), + email = "test@example.com", + password = "correct-horse-battery", + ) + + override suspend fun makeProvider(): ShonarProvider = CustomShonarProvider( + auth = ShonarAuthStore(store), + sidecarRoot = File(dir, "sidecars"), + ) + + override suspend fun makeDraft(id: String): RecordingDraft { + val src = File(dir, "src-$id.m4a") + // mp4 magic (ftyp at [4:8]) like backend/tests/test_recordings.py mp4_bytes() + val rnd = java.util.Random(id.hashCode().toLong()) + val payload = ByteArray(256 * 1024).also { rnd.nextBytes(it) } + src.writeBytes(byteArrayOf(0, 0, 0, 0x20.toByte()) + "ftypM4A ".toByteArray() + payload) + return RecordingDraft( + id = id, + title = "Contract recording", + createdAtEpochMs = 1_700_000_000_000, + durationMs = 12_345, + mime = "audio/mp4", + sourceFile = src, + sizeBytes = src.length(), + ) + } + + override fun credentialSecretStrings(cred: ProviderCredential): List = + super.credentialSecretStrings(cred) + + @Test fun probe_identifiesShonarServer() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + val probe = p.probe(serverUrl()) + assertTrue(probe is ProbeResult.Compatible) + assertEquals(ProviderRegistry.CUSTOM_SHONAR_ID, (probe as ProbeResult.Compatible).descriptor.id) + } + + @Test fun badPassword_loginRefusesWithoutLeaking() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + val bad = ProviderCredential.ShonarLogin("t", serverUrl(), "test@example.com", "wrong-pw-123") + try { + p.connect(bad) + org.junit.Assert.fail("bad password must not connect") + } catch (e: ProviderError.Transient) { + assertFalse(e.message!!.contains("wrong-pw-123")) + } + } +} diff --git a/android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt b/android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt new file mode 100644 index 0000000..d58a4e0 --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/FolderSyncProviderTest.kt @@ -0,0 +1,104 @@ +package com.shonar.provider + +import java.io.File +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** Runs the shared provider contract suite against FolderSyncProvider. */ +class FolderSyncProviderContractTest : ProviderContractTest() { + + private lateinit var root: File + private lateinit var store: com.shonar.settings.InMemorySettingsStore + + @Before fun setUp() { + root = File(System.getProperty("java.io.tmpdir"), "foldersync-${System.nanoTime()}") + .apply { mkdirs() } + store = com.shonar.settings.InMemorySettingsStore() + } + + @After fun tearDown() { + root.deleteRecursively() + } + + override fun initialCredential(): ProviderCredential = + ProviderCredential.FolderPath(accountLabel = root.path, path = root.path) + + override suspend fun makeProvider(): ShonarProvider = FolderSyncProvider(store) + + override suspend fun makeDraft(id: String): RecordingDraft { + val src = File.createTempFile("draft-$id", ".bin") + val rnd = java.util.Random(7) + src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) }) + src.deleteOnExit() + return RecordingDraft( + id = id, + title = "Contract recording", + createdAtEpochMs = 1_700_000_000_000, + durationMs = 12_345, + mime = "audio/mp4", + sourceFile = src, + sizeBytes = src.length(), + ) + } + + @Test fun refs_carrySyncFolderId() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + p.connect(initialCredential()) + val ref = p.upload(makeDraft("dddddddd-dddd-4ddd-8ddd-dddddddddddd")) { } + assertEquals(FolderSyncProvider.ID, ref.providerId) + assertEquals(1, p.list(null).items.count { it.ref.providerId == FolderSyncProvider.ID }) + } + + @Test fun missingFolder_refusedNotCreated() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + val missing = File(root, "not-there") + try { + p.connect(ProviderCredential.FolderPath("x", missing.path)) + fail("missing folder must be refused") + } catch (expected: ProviderError.InvalidUrl) { + } + assertFalse("a typo must never create directories", missing.exists()) + } + + @Test fun traversal_refused() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + try { + p.connect(ProviderCredential.FolderPath("x", root.path + "/../evil")) + fail(".. must be refused") + } catch (expected: ProviderError.InvalidUrl) { + } + } + + @Test fun deleteAccount_forgetsButKeepsFiles() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + p.connect(initialCredential()) + val ref = p.upload(makeDraft("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")) { } + val onDisk = File(root, ref.key) + assertTrue(onDisk.exists()) + p.deleteAccountAndData() + // The files stay — they belong to the user and their sync tool. + assertTrue("user files must survive disconnect", onDisk.exists()) + assertEquals(AuthState.DISCONNECTED, p.authState.value) + assertEquals(AuthState.DISCONNECTED, p.reconnect()) + } + + @Test fun reconnect_restoresPersistedFolder() = kotlinx.coroutines.runBlocking { + makeProvider().connect(initialCredential()) + val p2 = makeProvider() + assertEquals(AuthState.CONNECTED, p2.reconnect()) + } + + @Test fun wrongCredentialType_rejected() = kotlinx.coroutines.runBlocking { + try { + makeProvider().connect(ProviderCredential.None) + fail("None must be rejected") + } catch (expected: ProviderError.InvalidUrl) { + } + } +} diff --git a/android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt b/android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt new file mode 100644 index 0000000..abe392b --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/LoggingLeakTest.kt @@ -0,0 +1,211 @@ +package com.shonar.provider + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * P5 leak tests (docs/server-providers.md §4, §8): with debug logging ON, + * no credential, token, audio byte, or transcript may reach the log sink; + * with it OFF, nothing logs at all. + */ +class RedactingLoggerTest { + + private lateinit var server: MockWebServer + private val logs = mutableListOf() + private var bodiesOn = false + + @Before fun setUp() { + server = MockWebServer() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"access_token":"resp-token-abc","ok":true}""") + } + server.start() + logs.clear() + bodiesOn = false + } + + @After fun tearDown() { + server.shutdown() + } + + private fun client(): OkHttpClient = OkHttpClient.Builder() + .addInterceptor(RedactingLogger(bodiesEnabled = { bodiesOn }, sink = logs::add)) + .build() + + private fun joined(): String = logs.joinToString("\n") + + @Test fun off_byDefault_logsNothing() { + client().newCall(Request.Builder().url(server.url("/x")).get().build()) + .execute().close() + assertTrue("logging off must be silent", logs.isEmpty()) + } + + @Test fun on_logsSummaryButRedactsAuthHeader() { + bodiesOn = true + client().newCall( + Request.Builder().url(server.url("/api/v1/auth/me")).get() + .header("Authorization", "Bearer header-secret-123").build() + ).execute().close() + val out = joined() + assertTrue(out.contains("GET") && out.contains("200")) + assertTrue("header name stays, value goes", out.contains("Authorization: [redacted]")) + assertFalse(out.contains("header-secret-123")) + } + + @Test fun on_redactsTokenJsonAndBasic() { + bodiesOn = true + val body = """{"email":"a@b.c","password":"pw-secret-1","refresh_token":"rt-secret-2"}""" + client().newCall( + Request.Builder().url(server.url("/api/v1/auth/login")) + .post(body.toRequestBody("application/json".toMediaType())) + .header("Authorization", "Basic dGVzdHVzZXI6YXBwLXBhc3M=").build() + ).execute().close() + val out = joined() + assertFalse(out.contains("pw-secret-1")) + assertFalse(out.contains("rt-secret-2")) + assertFalse("response token must be masked too", out.contains("resp-token-abc")) + assertFalse(out.contains("dGVzdHVzZXI6YXBwLXBhc3M=")) + assertTrue(out.contains("***")) + } + + @Test fun on_neverLogsAudioBytes() { + bodiesOn = true + val marker = "LEAKMARKER-AUDIO-0123456789".toByteArray() + val payload = marker + ByteArray(1024) { 0x7F } + client().newCall( + Request.Builder().url(server.url("/put")) + .put(payload.toRequestBody("audio/mp4".toMediaType())).build() + ).execute().close() + assertFalse("audio marker must not appear", joined().contains("LEAKMARKER-AUDIO")) + assertTrue(joined().contains("not logged")) + } + + @Test fun on_neverLogsBigJson() { + bodiesOn = true + val big = """{"blob":"${"x".repeat(9000)}"}""" + client().newCall( + Request.Builder().url(server.url("/big")) + .post(big.toRequestBody("application/json".toMediaType())).build() + ).execute().close() + assertTrue(joined().contains("not logged")) + } + + @Test fun sensitiveFlag_strippedAndSkipsBodies() { + bodiesOn = true + // JSON body that WOULD log (small, loggable type) is suppressed by + // the flag, and the flag itself never reaches the wire. + val transcript = """{"text":"LEAKMARKER-TRANSCRIPT-transcribed words here"}""" + client().newCall( + Request.Builder().url(server.url("/sidecar")) + .put(transcript.toRequestBody("application/json".toMediaType())) + .header(RedactingLogger.SENSITIVE_BODY, "1").build() + ).execute().close() + val out = joined() + assertFalse(out.contains("LEAKMARKER-TRANSCRIPT")) + // Response body (echo JSON with token) is suppressed too. + assertFalse(out.contains("resp-token-abc")) + assertTrue(out.contains("[sensitive, not logged]")) + val sent = server.takeRequest() + assertNull("flag must be stripped before sending", sent.getHeader(RedactingLogger.SENSITIVE_BODY)) + } + + @Test fun failure_logsClassOnly() { + bodiesOn = true + try { + // Nothing listens on this port: fast refusal, no secrets involved. + client().newCall(Request.Builder().url("http://127.0.0.1:1/unreachable").get().build()) + .execute().close() + } catch (ignored: Exception) { + } + assertTrue(joined().contains("failed (")) + } +} + +/** + * Provider-level leak test: a full Nextcloud upload + sidecar + download + * with debug logging ON must leave no credential, audio, or transcript + * material in the sink. + */ +class ProviderLeakTest { + + private val backend = FakeNextcloud() + private lateinit var store: com.shonar.settings.InMemorySettingsStore + private val logs = mutableListOf() + + private val audioMarker = "LEAKMARKER-AUDIO-555" + private val transcriptMarker = "LEAKMARKER-TRANSCRIPT-777" + + @Before fun setUp() { + backend.start() + store = com.shonar.settings.InMemorySettingsStore() + logs.clear() + } + + @After fun tearDown() { + backend.stop() + } + + @Test fun nextcloudFlow_leaksNothing() = kotlinx.coroutines.runBlocking { + // Distinctive markers stand in for real secrets/audio/transcripts; + // the fake's fixed creds exercise the same code paths. + val tls = TlsPolicy( + tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())), + bodiesEnabled = { true }, + sink = logs::add, + ) + val p = NextcloudProvider( + auth = NextcloudAuthStore(store), + client = tls.nextcloudClient(), + loginFlow = NextcloudAuth(tls.nextcloudClient(), tls.tofu), + chunkSizeBytes = 64 * 1024, + ) + p.connect( + ProviderCredential.AppPassword( + "t", backend.url, backend.userId, backend.appPassword + ) + ) + val dir = java.io.File(System.getProperty("java.io.tmpdir"), "leak-${System.nanoTime()}") + .apply { mkdirs() } + try { + val payload = (audioMarker + "|").toByteArray() + ByteArray(256 * 1024 - 32) { 0x3C } + val src = java.io.File(dir, "s.m4a").apply { writeBytes(payload) } + val draft = RecordingDraft( + "ffffffff-ffff-4fff-8fff-ffffffffffff", "t", 1_700_000_000_000, + 1000, "audio/mp4", src, src.length(), + ) + val ref = p.upload(draft) { } + p.putSidecar( + ref, SidecarKind.TRANSCRIPT, + """{"text":"$transcriptMarker transcribed words"}""".toByteArray(), + ) + val dest = java.io.File(dir, "out.m4a") + p.download(ref, dest) { } + val out = logs.joinToString("\n") + assertFalse("audio bytes in logs", out.contains(audioMarker)) + assertFalse("transcript in logs", out.contains(transcriptMarker)) + assertFalse("app password in logs", out.contains(backend.appPassword)) + assertFalse("basic credentials in logs", + out.contains(java.util.Base64.getEncoder().encodeToString( + "${backend.userId}:${backend.appPassword}".toByteArray() + ))) + assertTrue("something logged (logger was on)", out.isNotEmpty()) + } finally { + dir.deleteRecursively() + } + } +} diff --git a/android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt b/android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt new file mode 100644 index 0000000..6e06fa7 --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/NextcloudProviderTest.kt @@ -0,0 +1,531 @@ +package com.shonar.provider + +import java.io.File +import java.util.Base64 +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.Buffer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** + * In-memory fake of a Nextcloud server: status.php, login flow v2, OCS + * user/quota, and the WebDAV file + chunking-v2 APIs. Shapes mirror the + * official developer manual (chunk names 1..10000 assembled in name order, + * `OC-Total-Length`, `MOVE …/.file` + `Destination`): + * + * - MKCOL {dav}uploads/{user}/{transfer}/ (405 when present) + * - PUT {dav}uploads/{user}/{transfer}/{00001..} (201) + * - MOVE {dav}uploads/{user}/{transfer}/.file + Destination (201) + * - PROPFIND Depth:1 multistatus, hrefs percent-encoded like the real thing + */ +internal class FakeNextcloud { + + val server = MockWebServer() + + var productName = "Nextcloud" + var versionString = "30.0.0" + val userId = "testuser" + val appPassword = "app-pass-123" + var quotaFree = 10L * 1024 * 1024 * 1024 + var quotaTotal = 50L * 1024 * 1024 * 1024 + + /** Decoded DAV paths -> bytes. Dirs end with '/'. */ + val files = mutableMapOf() + val dirs = mutableSetOf() + + var pollApproved = false + val pollToken = "poll-token-1" + + var chunkPuts = 0 + val chunkNames = mutableListOf() + val chunkHeaders = mutableListOf>() + var lastMove: Map = emptyMap() + var revokeCalls = 0 + var failNextPut507 = false + + val url: String get() = server.url("/").toString().removeSuffix("/") + private val davRoot get() = "/remote.php/dav/files/$userId/" + private val uploadsRoot get() = "/remote.php/dav/uploads/$userId/" + + fun start() { + dirs += davRoot + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + handle(request) + } + server.start() + } + + fun stop() = server.shutdown() + + private fun basicOk(request: RecordedRequest): Boolean { + val expect = "Basic " + Base64.getEncoder() + .encodeToString("$userId:$appPassword".toByteArray(Charsets.UTF_8)) + return request.getHeader("Authorization") == expect + } + + private fun dec(path: String): String = runCatching { + java.net.URLDecoder.decode(path, "UTF-8") + }.getOrDefault(path) + + private fun encPath(path: String): String = path.split('/') + .joinToString("/") { + java.net.URLEncoder.encode(it, "UTF-8").replace("+", "%20") + } + + private fun parentOf(dir: String): String { + val t = dir.trimEnd('/') + return t.substringBeforeLast('/', "") + "/" + } + + private fun handle(request: RecordedRequest): MockResponse { + val rawPath = request.path?.substringBefore('?').orEmpty() + val path = dec(rawPath) + val method = request.method.orEmpty() + return when { + method == "GET" && path == "/status.php" -> json(200, JSONObject() + .put("productname", productName) + .put("versionstring", versionString)) + + method == "POST" && path == "/index.php/login/v2" -> json(200, JSONObject() + .put("poll", JSONObject() + .put("token", pollToken) + .put("endpoint", "$url/index.php/login/v2/poll")) + .put("login", "$url/login/v2/flow/abc123")) + + method == "POST" && path == "/index.php/login/v2/poll" -> { + val token = runCatching { JSONObject(request.body.readUtf8()).optString("token") } + .getOrDefault("") + if (token == pollToken && pollApproved) { + json(200, JSONObject() + .put("server", url) + .put("loginName", userId) + .put("appPassword", appPassword)) + } else { + json(404, JSONObject()) + } + } + + path == "/ocs/v2.php/cloud/user" -> { + if (!basicOk(request)) return json(401, JSONObject().put("message", "Unauthorized")) + json(200, JSONObject().put("ocs", JSONObject().put("data", JSONObject() + .put("id", userId) + .put("display-name", "Test User") + .put("quota", JSONObject() + .put("free", quotaFree).put("used", quotaTotal - quotaFree) + .put("total", quotaTotal).put("relative", 80.0))))) + } + + method == "DELETE" && path == "/ocs/v2.php/core/apppassword" -> { + if (!basicOk(request)) return json(401, JSONObject().put("message", "Unauthorized")) + revokeCalls++ + json(200, JSONObject()) + } + + // ---- chunked upload v2 ------------------------------------------------- + method == "MKCOL" && path.startsWith(uploadsRoot) -> { + if (!basicOk(request)) return MockResponse().setResponseCode(401) + val dir = path.trimEnd('/') + '/' + if (dir in dirs) return MockResponse().setResponseCode(405) + dirs += dir + MockResponse().setResponseCode(201) + } + + method == "PUT" && path.startsWith(uploadsRoot) -> { + if (!basicOk(request)) return MockResponse().setResponseCode(401) + if (failNextPut507) { + failNextPut507 = false + return MockResponse().setResponseCode(507) + } + val dir = parentOf(path) + if (dir !in dirs) return MockResponse().setResponseCode(404) + files[path] = request.body.readByteArray() + chunkPuts++ + chunkNames += path.substringAfterLast('/') + chunkHeaders += mapOf( + "OC-Total-Length" to request.getHeader("OC-Total-Length"), + "Destination" to request.getHeader("Destination"), + ) + MockResponse().setResponseCode(201) + } + + method == "MOVE" && path.startsWith(uploadsRoot) && path.endsWith("/.file") -> { + if (!basicOk(request)) return MockResponse().setResponseCode(401) + lastMove = mapOf( + "Destination" to request.getHeader("Destination"), + "Overwrite" to request.getHeader("Overwrite"), + "OC-Total-Length" to request.getHeader("OC-Total-Length"), + ) + val destUrl = request.getHeader("Destination") ?: return MockResponse().setResponseCode(400) + val destPath = dec(runCatching { java.net.URI(destUrl).path }.getOrDefault(destUrl)) + // Assemble transfer chunks in name order (server semantics). + val transferDir = path.removeSuffix(".file") + val chunks = files.keys.filter { it.startsWith(transferDir) && it != path } + .sortedBy { it.substringAfterLast('/') } + if (chunks.isEmpty()) return MockResponse().setResponseCode(404) + val assembled = chunks.fold(byteArrayOf()) { acc, k -> acc + files[k]!! } + files[destPath] = assembled + MockResponse().setResponseCode(201) + } + + // ---- plain WebDAV ------------------------------------------------------ + method == "MKCOL" && path.startsWith(davRoot) -> { + if (!basicOk(request)) return MockResponse().setResponseCode(401) + val dir = path.trimEnd('/') + '/' + if (dir in dirs) return MockResponse().setResponseCode(405) + if (parentOf(dir) !in dirs) return MockResponse().setResponseCode(409) + dirs += dir + MockResponse().setResponseCode(201) + } + + method == "PUT" && path.startsWith(davRoot) -> { + if (!basicOk(request)) return MockResponse().setResponseCode(401) + if (parentOf(path) !in dirs && parentOf(path) != davRoot) { + return MockResponse().setResponseCode(409) + } + files[path] = request.body.readByteArray() + MockResponse().setResponseCode(201) + } + + method == "GET" && path.startsWith(davRoot) -> { + if (!basicOk(request)) return MockResponse().setResponseCode(401) + val bytes = files[path] ?: return MockResponse().setResponseCode(404) + MockResponse().setResponseCode(200) + .setHeader("Content-Type", "audio/mp4") + .setBody(Buffer().write(bytes)) + } + + method == "DELETE" && path.startsWith(davRoot) -> { + if (!basicOk(request)) return MockResponse().setResponseCode(401) + // Collections may arrive with or without the trailing slash; + // real servers treat both the same. + val asDir = path.trimEnd('/') + '/' + val fileGone = files.remove(path) != null + val dirGone = if (asDir in dirs) { + dirs.removeIf { it == asDir || it.startsWith(asDir) } + files.keys.removeIf { it.startsWith(asDir) } + true + } else false + if (!fileGone && !dirGone) return MockResponse().setResponseCode(404) + MockResponse().setResponseCode(204) + } + + method == "DELETE" && path.startsWith(uploadsRoot) -> { + if (!basicOk(request)) return MockResponse().setResponseCode(401) + files.keys.removeIf { it.startsWith(path) } + dirs.removeIf { it == path || it.startsWith(path) } + MockResponse().setResponseCode(204) + } + + method == "PROPFIND" -> { + if (!basicOk(request)) return MockResponse().setResponseCode(401) + val dir = if (path.endsWith('/')) path else "$path/" + val children = mutableListOf() + // Self first, then direct children — like the real server. + if (dir in dirs || dir == davRoot) children += dir + val seen = mutableSetOf() + for (d in dirs) { + if (d != dir && parentOf(d) == dir && seen.add(d)) children += d + } + for (f in files.keys) { + val d = parentOf(f) + if (d == dir && seen.add(f)) children += f + } + if (children.isEmpty()) return MockResponse().setResponseCode(404) + val items = children.joinToString("") { child -> + val isDir = child.endsWith('/') + val size = files[child]?.size ?: 0 + """${encPath(child)}""" + + (if (!isDir) "$size" else "") + + (if (!isDir) """"etag-${child.hashCode()}"""" else "") + + (if (!isDir) """audio/mp4""" else "") + + """Tue, 02 Jan 2026 03:04:05 GMT""" + + (if (isDir) "" else "") + + """HTTP/1.1 200 OK""" + } + MockResponse().setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody("""$items""") + } + + path.startsWith(uploadsRoot) -> MockResponse().setResponseCode(404) + + else -> json(404, JSONObject().put("message", "unknown $method $rawPath")) + } + } + + private fun json(code: Int, obj: JSONObject) = MockResponse() + .setResponseCode(code).setHeader("Content-Type", "application/json") + .setBody(obj.toString()) +} + +/** Runs the shared provider contract suite against NextcloudProvider. */ +class NextcloudProviderContractTest : ProviderContractTest() { + + private val backend = FakeNextcloud() + private lateinit var dir: File + private lateinit var store: com.shonar.settings.InMemorySettingsStore + + @Before fun setUp() { + backend.start() + dir = File(System.getProperty("java.io.tmpdir"), "nc-contract-${System.nanoTime()}").apply { mkdirs() } + store = com.shonar.settings.InMemorySettingsStore() + } + + @After fun tearDown() { + backend.stop() + dir.deleteRecursively() + } + + private fun serverUrl(): ServerUrl = ServerUrl.parse(backend.url)!!.getOrThrow() + + override fun initialCredential(): ProviderCredential = ProviderCredential.AppPassword( + accountLabel = "testuser", + loginUrl = backend.url, + user = backend.userId, + password = backend.appPassword, + ) + + override suspend fun makeProvider(): ShonarProvider = NextcloudProvider( + auth = NextcloudAuthStore(store), + chunkSizeBytes = 64 * 1024, // 4 chunks per 256 KiB draft + ) + + override suspend fun makeDraft(id: String): RecordingDraft { + val src = File(dir, "src-$id.m4a") + val rnd = java.util.Random(id.hashCode().toLong()) + src.writeBytes(ByteArray(256 * 1024).also { rnd.nextBytes(it) }) + return RecordingDraft( + id = id, + title = "Contract recording", + createdAtEpochMs = 1_700_000_000_000, + durationMs = 12_345, + mime = "audio/mp4", + sourceFile = src, + sizeBytes = src.length(), + ) + } + + @Test fun probe_identifiesNextcloud() = kotlinx.coroutines.runBlocking { + val probe = makeProvider().probe(serverUrl()) + assertTrue(probe is ProbeResult.Compatible) + probe as ProbeResult.Compatible + assertEquals(ProviderRegistry.NEXTCLOUD_ID, probe.descriptor.id) + assertTrue(probe.descriptor.isDefault) + assertTrue( + ProviderDescriptor.Capability.CHUNKED_UPLOAD in probe.descriptor.capabilities + ) + } + + @Test fun probe_rejectsNonNextcloud() = kotlinx.coroutines.runBlocking { + backend.productName = "ownCloud" + assertEquals(ProbeResult.Incompatible, makeProvider().probe(serverUrl())) + } + + @Test fun wrongAppPassword_failsWithoutLeaking() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + try { + p.connect( + ProviderCredential.AppPassword("t", backend.url, backend.userId, "wrong-pass-1") + ) + fail("wrong app password must not connect") + } catch (e: ProviderError.Transient) { + assertFalse(e.message!!.contains("wrong-pass-1")) + } + } + + @Test fun upload_usesNumberedChunksAndMoveAssembly() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + p.connect(initialCredential()) + val draft = makeDraft("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + p.upload(draft) { } + // 4 chunks named 00001..00004, each carrying quota + destination headers. + assertEquals(listOf("00001", "00002", "00003", "00004"), backend.chunkNames) + assertTrue(backend.chunkHeaders.all { it["OC-Total-Length"] == draft.sizeBytes.toString() }) + assertTrue(backend.chunkHeaders.all { (it["Destination"] ?: "").endsWith(".m4a") }) + // Assembly: MOVE …/.file with Destination + Overwrite + total length. + assertTrue(backend.lastMove["Destination"]!!.endsWith("SHONAR/audio/${draft.id}.m4a")) + assertEquals("T", backend.lastMove["Overwrite"]) + assertEquals(draft.sizeBytes.toString(), backend.lastMove["OC-Total-Length"]) + } + + @Test fun reupload_skipsPresentChunks() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + p.connect(initialCredential()) + // Simulate an interrupted upload: transfer folder with 2 of 4 chunks + // already on the server (e.g. left by a killed process). + val draft = makeDraft("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") + val transferDir = "/remote.php/dav/uploads/${backend.userId}/shonar-${draft.id}/" + backend.dirs += transferDir + val src = draft.sourceFile.readBytes() + val quarter = src.size / 4 + backend.files[transferDir + "00001"] = src.copyOfRange(0, quarter) + backend.files[transferDir + "00002"] = src.copyOfRange(quarter, quarter * 2) + p.upload(draft) { } + assertEquals("only the 2 missing chunks are sent", 2, backend.chunkPuts) + assertEquals(listOf("00003", "00004"), backend.chunkNames) + // And the assembled file is complete. + val dest = java.io.File.createTempFile("resume-dl", ".bin") + try { + val ref = p.list(null).items.single().ref + p.download(ref, dest) { } + assertTrue(dest.readBytes().contentEquals(src)) + } finally { + dest.delete() + } + } + + @Test fun quotaExceeded_surfaces() = kotlinx.coroutines.runBlocking { + val p = makeProvider() + p.connect(initialCredential()) + backend.failNextPut507 = true + try { + p.upload(makeDraft("cccccccc-cccc-4ccc-8ccc-cccccccccccc")) { } + fail("507 must surface as QuotaExceeded") + } catch (expected: ProviderError.QuotaExceeded) { + } + } +} + +/** Auth lifecycle + store for the Nextcloud provider. */ +class NextcloudProviderAuthTest { + + private val backend = FakeNextcloud() + private lateinit var dir: File + private lateinit var store: com.shonar.settings.InMemorySettingsStore + + @Before fun setUp() { + backend.start() + dir = File(System.getProperty("java.io.tmpdir"), "nc-auth-${System.nanoTime()}").apply { mkdirs() } + store = com.shonar.settings.InMemorySettingsStore() + } + + @After fun tearDown() { + backend.stop() + dir.deleteRecursively() + } + + private fun provider() = NextcloudProvider(auth = NextcloudAuthStore(store)) + + private fun cred() = ProviderCredential.AppPassword( + accountLabel = "testuser", loginUrl = backend.url, + user = backend.userId, password = backend.appPassword, + ) + + @Test fun loginFlow_endToEnd() = kotlinx.coroutines.runBlocking { + val flow = NextcloudAuth() + val url = ServerUrl.parse(backend.url)!!.getOrThrow() + assertTrue(flow.probe(url) is ProbeResult.Compatible) + val session = flow.startLogin(url) + assertTrue(session.loginUrl.isNotBlank()) + assertEquals(com.shonar.provider.PollResult.Pending, flow.poll(session)) + backend.pollApproved = true + val approved = flow.poll(session) + assertTrue(approved is com.shonar.provider.PollResult.Approved) + approved as com.shonar.provider.PollResult.Approved + assertEquals(backend.appPassword, approved.credential.password) + + val p = provider() + p.connect(approved.credential) + assertEquals(AuthState.CONNECTED, p.authState.value) + val saved = NextcloudAuthStore(store).load() + assertNotNull(saved) + assertEquals(backend.url, saved!!.baseUrl) + assertEquals(backend.userId, saved.userId) + // The normal password was never involved; only the app password persists. + assertFalse( + store.keys().flatMap { listOf(store.getString(it).orEmpty()) } + .any { it.contains("normal-password") } + ) + } + + @Test fun login_persistsUserIdForDavPaths() = kotlinx.coroutines.runBlocking { + val p = provider() + p.connect(cred()) + assertEquals(backend.userId, NextcloudAuthStore(store).load()!!.userId) + } + + @Test fun reconnect_restoresSession() = kotlinx.coroutines.runBlocking { + provider().connect(cred()) + val p2 = provider() + assertEquals(AuthState.CONNECTED, p2.reconnect()) + } + + @Test fun revokedPassword_reconnectReportsExpired() = kotlinx.coroutines.runBlocking { + val p = provider() + p.connect(cred()) + // Server-side revocation: the stored app password stops working. + val saved = NextcloudAuthStore(store).load()!! + NextcloudAuthStore(store).save(saved.copy(appPassword = "revoked")) + assertEquals(AuthState.EXPIRED, p.reconnect()) + } + + @Test fun disconnect_revokesAppPassword() = kotlinx.coroutines.runBlocking { + val p = provider() + p.connect(cred()) + p.disconnect(revokeOnServer = true) + assertEquals(1, backend.revokeCalls) + assertEquals(AuthState.DISCONNECTED, p.authState.value) + assertNull(NextcloudAuthStore(store).load()) + } + + @Test fun summary_carriesQuota() = kotlinx.coroutines.runBlocking { + val p = provider() + p.connect(cred()) + val s = p.storageLocationSummary() + assertTrue(s.headline.startsWith("Nextcloud at")) + assertTrue("quota must be shown" , "free of" in s.detail) + } +} + +class NextcloudAuthStoreTest { + + private val store = com.shonar.settings.InMemorySettingsStore() + private val auth = NextcloudAuthStore(store) + + @Test fun save_load_clear() = kotlinx.coroutines.runBlocking { + assertNull(auth.load()) + auth.save(NcSession("https://nc.example.com", "u1", "User One", "pw")) + val loaded = auth.load() + assertEquals("https://nc.example.com", loaded!!.baseUrl) + assertEquals("u1", loaded.userId) + assertEquals("pw", loaded.appPassword) + auth.clear() + assertNull(auth.load()) + } +} + +class NextcloudProtocolTest { + + @Test fun enc_spacesNotPlus() { + assertEquals("a%20b", NextcloudProvider.enc("a b")) + assertEquals("100%25", NextcloudProvider.enc("100%")) + } + + @Test fun uuidForKey_stripsDirsAndSuffix() { + assertEquals("abc", NextcloudProvider.uuidForKey("SHONAR/audio/abc.m4a")) + } + + @Test fun extFor_commonMimes() { + assertEquals(".m4a", NextcloudProvider.extFor("audio/mp4")) + assertEquals(".wav", NextcloudProvider.extFor("audio/x-wav")) + assertEquals(".ogg", NextcloudProvider.extFor("audio/opus")) + assertEquals(".mp3", NextcloudProvider.extFor("audio/mpeg")) + } + + @Test fun formatBytes_units() { + assertEquals("512 B", NextcloudProvider.formatBytes(512)) + assertEquals("10.0 GB", NextcloudProvider.formatBytes(10L * 1024 * 1024 * 1024)) + } +} diff --git a/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt b/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt index e2de484..5edc726 100644 --- a/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt +++ b/android/app/src/test/java/com/shonar/provider/ProviderContractTest.kt @@ -174,6 +174,9 @@ abstract class ProviderContractTest { 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() } diff --git a/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt b/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt index 52ca8a1..0f6fb07 100644 --- a/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt +++ b/android/app/src/test/java/com/shonar/provider/ServerUrlTest.kt @@ -81,6 +81,11 @@ class ServerUrlTest { 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")) } } @@ -112,6 +117,16 @@ class ProviderRegistryTest { @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 = diff --git a/android/app/src/test/java/com/shonar/provider/TofuTest.kt b/android/app/src/test/java/com/shonar/provider/TofuTest.kt new file mode 100644 index 0000000..e1e7cf0 --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/TofuTest.kt @@ -0,0 +1,241 @@ +package com.shonar.provider + +import java.security.MessageDigest +import okhttp3.Request +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** + * P5 cert-fixture tests. Certificates are generated at runtime with + * okhttp-tls HeldCertificate (no checked-in keys, nothing to expire): + * a private CA the system does NOT trust, so every handshake exercises + * the TOFU path. + */ +class TofuTrustTest { + + private lateinit var server: MockWebServer + private lateinit var held: HeldCertificate + private lateinit var store: com.shonar.settings.InMemorySettingsStore + private lateinit var tofu: TofuManager + private lateinit var tls: TlsPolicy + private lateinit var base: String + private var port: Int = 0 + + @Before fun setUp() { + held = HeldCertificate.Builder() + .commonName("shonar-test") + .addSubjectAlternativeName("localhost") + .addSubjectAlternativeName("127.0.0.1") + .build() + val serverCerts = HandshakeCertificates.Builder() + .heldCertificate(held) + .build() + server = MockWebServer() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"kind":"shonar","version":"t","capabilities":{}}""") + } + server.useHttps(serverCerts.sslSocketFactory(), false) + server.start() + port = server.port + base = "https://localhost:$port" + store = com.shonar.settings.InMemorySettingsStore() + tofu = TofuManager(TofuStore(store)) + tls = TlsPolicy(tofu, bodiesEnabled = { false }, sink = {}) + } + + @After fun tearDown() { + server.shutdown() + } + + private fun expectedSpki(): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(held.certificate.publicKey.encoded) + return digest.joinToString(":") { "%02X".format(it) } + } + + private fun get(url: String): Int { + val client = tls.newClient(10, 10, 10) + client.newCall(Request.Builder().url(url).get().build()).execute().use { resp -> + return resp.code + } + } + + @Test fun untrusted_recordsFailureWithRealSpki() { + try { + get("$base/api/v1/provider-info") + fail("self-signed must not connect unapproved") + } catch (expected: javax.net.ssl.SSLHandshakeException) { + } + val failure = tofu.failureFor("localhost") + assertNotNull("failure must be recorded for approval UI", failure) + assertEquals(expectedSpki(), failure!!.spkiHex) + assertTrue("subject must name the test cert", failure.subject.contains("shonar-test")) + } + + @Test fun approve_thenConnects() { + try { + get("$base/api/v1/provider-info") + } catch (ignored: Exception) { + } + assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) + assertEquals(200, get("$base/api/v1/provider-info")) + assertEquals(setOf("localhost"), tofu.pinnedHostsForTest()) + } + + @Test fun pin_isPerHost() { + try { + get("$base/api/v1/provider-info") + } catch (ignored: Exception) { + } + assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) + assertEquals(200, get("$base/api/v1/provider-info")) + // Same cert, different host: still untrusted (per-host binding). + try { + get("https://127.0.0.1:$port/api/v1/provider-info") + fail("pin for localhost must not cover 127.0.0.1") + } catch (expected: javax.net.ssl.SSLHandshakeException) { + } + assertNotNull(tofu.failureFor("127.0.0.1")) + } + + @Test fun decline_clearsWithoutPinning() { + try { + get("$base/api/v1/provider-info") + } catch (ignored: Exception) { + } + assertNotNull(tofu.failureFor("localhost")) + kotlinx.coroutines.runBlocking { tofu.decline("localhost") } + assertNull(tofu.failureFor("localhost")) + } + + @Test fun approve_withoutFailure_returnsFalse() { + assertFalse(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) + } + + @Test fun forget_revokesTrust() { + try { + get("$base/api/v1/provider-info") + } catch (ignored: Exception) { + } + assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) + assertEquals(200, get("$base/api/v1/provider-info")) + kotlinx.coroutines.runBlocking { tofu.forget("localhost") } + try { + get("$base/api/v1/provider-info") + fail("forgotten host must fail again") + } catch (expected: javax.net.ssl.SSLHandshakeException) { + } + } + + @Test fun systemTrust_unaffectedForPlainHttp() { + // The TOFU trust manager must be invisible on non-TLS and on + // system-trusted paths: plain HTTP MockWebServer still works. + val plain = MockWebServer() + plain.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = + MockResponse().setResponseCode(200).setBody("ok") + } + plain.start() + try { + assertEquals(200, get("http://localhost:${plain.port}/x")) + } finally { + plain.shutdown() + } + } + + // test-only accessor without growing the public API + private fun TofuManager.pinnedHostsForTest(): Set = + kotlinx.coroutines.runBlocking { pinnedHosts() } +} + +class TofuProbeTest { + + private lateinit var server: MockWebServer + private lateinit var tofu: TofuManager + private lateinit var tls: TlsPolicy + private var port: Int = 0 + + @Before fun setUp() { + val held = HeldCertificate.Builder() + .commonName("shonar-test") + .addSubjectAlternativeName("localhost") + .build() + val serverCerts = HandshakeCertificates.Builder().heldCertificate(held).build() + server = MockWebServer() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val body = if (request.path == "/status.php") { + """{"productname":"Nextcloud","versionstring":"30.0.0"}""" + } else { + """{"kind":"shonar","version":"t","capabilities":{"chunked_upload":true}}""" + } + return MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json").setBody(body) + } + } + server.useHttps(serverCerts.sslSocketFactory(), false) + server.start() + port = server.port + tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())) + tls = TlsPolicy(tofu, bodiesEnabled = { false }, sink = {}) + } + + @After fun tearDown() { + server.shutdown() + } + + @Test fun probe_reportsRealFingerprint_thenApproves() = kotlinx.coroutines.runBlocking { + val url = ServerUrl.parse("https://localhost:$port")!!.getOrThrow() + val handshake = ShonarHandshake(tls.probeClient(), tofu) + val first = handshake.probe(url) + assertTrue(first is ProbeResult.TlsFailure) + first as ProbeResult.TlsFailure + assertTrue("fingerprint must be real, not a placeholder", + first.fingerprintSha256 != "unknown" && ":" in first.fingerprintSha256) + assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) + val second = handshake.probe(url) + assertTrue("approved host probes cleanly: $second", second is ProbeResult.Compatible) + } + + @Test fun nextcloudProbe_sameFlow() = kotlinx.coroutines.runBlocking { + val url = ServerUrl.parse("https://localhost:$port")!!.getOrThrow() + val auth = NextcloudAuth(tls.nextcloudClient(), tofu) + assertTrue(auth.probe(url) is ProbeResult.TlsFailure) + assertTrue(kotlinx.coroutines.runBlocking { tofu.approve("localhost") }) + val second = auth.probe(url) + assertTrue("approved Nextcloud probes cleanly: $second", second is ProbeResult.Compatible) + } +} + +class TofuStoreTest { + + private val store = com.shonar.settings.InMemorySettingsStore() + private val tofu = TofuStore(store) + + @Test fun roundtrip() = kotlinx.coroutines.runBlocking { + assertEquals(emptySet(), tofu.hosts()) + tofu.addPin("Nas.Local", "DER-1") + tofu.addPin("nas.local", "DER-2") + assertEquals(setOf("DER-1", "DER-2"), tofu.pins("NAS.LOCAL")) + assertEquals(setOf("nas.local"), tofu.hosts()) + tofu.removeHost("nas.local") + assertEquals(emptySet(), tofu.hosts()) + assertEquals(emptySet(), tofu.pins("nas.local")) + } +} diff --git a/android/app/src/test/java/com/shonar/recording/Fixtures.kt b/android/app/src/test/java/com/shonar/recording/Fixtures.kt new file mode 100644 index 0000000..0a8187f --- /dev/null +++ b/android/app/src/test/java/com/shonar/recording/Fixtures.kt @@ -0,0 +1,87 @@ +package com.shonar.recording + +import com.shonar.provider.AuthState +import com.shonar.provider.ProviderCredential +import com.shonar.provider.ProviderDescriptor +import com.shonar.provider.ProviderRegistry +import com.shonar.provider.RecordingDraft +import com.shonar.provider.RemoteRef +import com.shonar.provider.Page +import com.shonar.provider.ProbeResult +import com.shonar.provider.ServerUrl +import com.shonar.provider.ShonarProvider +import com.shonar.provider.SidecarKind +import com.shonar.provider.StorageLocation +import com.shonar.provider.SyncState +import java.io.File +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf + +/** In-memory RecordingDao: no Android needed. Shared by recording tests. */ +internal class FakeDao(initial: List = emptyList()) : RecordingDao { + private val rows = initial.associateBy { it.id }.toMutableMap() + override fun observeAll(): Flow> = flowOf(rows.values.toList()) + override suspend fun getAll(): List = rows.values.sortedByDescending { it.createdAtEpochMs } + override suspend fun getById(id: String): RecordingEntity? = rows[id] + override suspend fun insert(recording: RecordingEntity) { + rows[recording.id] = recording + } + override suspend fun update(recording: RecordingEntity) { + check(rows.containsKey(recording.id)) { "no such row ${recording.id}" } + rows[recording.id] = recording + } + override suspend fun delete(recording: RecordingEntity) { + rows.remove(recording.id) + } +} + +/** Scripted provider: per-draft behaviour for upload, inert elsewhere. */ +internal class ScriptProvider( + val id: String = "nextcloud", + var failWith: (RecordingDraft) -> Exception? = { null }, +) : ShonarProvider { + override val descriptor = ProviderDescriptor(id, id) + private val states = MutableStateFlow(AuthState.CONNECTED) + override val authState: kotlinx.coroutines.flow.StateFlow = states + val uploadedKeys = mutableListOf() + override suspend fun probe(baseUrl: ServerUrl): ProbeResult = ProbeResult.Incompatible + override suspend fun connect(credential: ProviderCredential) {} + override suspend fun reconnect(): AuthState = AuthState.CONNECTED + override suspend fun disconnect(revokeOnServer: Boolean) {} + override suspend fun deleteAccountAndData() {} + override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef { + failWith(draft)?.let { throw it } + onProgress(1f) + return RemoteRef(id, "SHONAR/audio/${draft.id}.m4a", etag = "e-${draft.id}", sizeBytes = draft.sizeBytes) + .also { uploadedKeys += draft.id } + } + override suspend fun download(ref: RemoteRef, dest: File, onProgress: (Float) -> Unit) = + throw UnsupportedOperationException() + override suspend fun delete(ref: RemoteRef) = throw UnsupportedOperationException() + override suspend fun list(cursor: String?): Page = + Page(emptyList(), null) + override suspend fun putSidecar(ref: RemoteRef, kind: SidecarKind, bytes: ByteArray) = + throw UnsupportedOperationException() + override suspend fun getSidecar(ref: RemoteRef, kind: SidecarKind): ByteArray? = null + override suspend fun storageLocationSummary(): StorageLocation = StorageLocation(id, "", 0, 0, 0) +} + +internal fun registryOf(vararg providers: ShonarProvider): ProviderRegistry = + ProviderRegistry(providers.associateBy({ it.descriptor.id }) { { it } }) + +internal fun entity( + id: String, + dir: File, + state: SyncState = SyncState.LOCAL_ONLY, +): RecordingEntity { + val f = File(dir, "$id.m4a").apply { writeBytes(ByteArray(128) { 0x1 }) } + return RecordingEntity( + id = id, title = "t-$id", createdAtEpochMs = 1_700_000_000_000L, + durationMs = 1000, filePath = f.absolutePath, mimeType = "audio/mp4", + sizeBytes = f.length(), syncState = state, + ) +} + +internal fun tmpDir(prefix: String): File = + File(System.getProperty("java.io.tmpdir"), "$prefix-${System.nanoTime()}").apply { mkdirs() } diff --git a/android/app/src/test/java/com/shonar/recording/MigrationTest.kt b/android/app/src/test/java/com/shonar/recording/MigrationTest.kt new file mode 100644 index 0000000..9bc46b8 --- /dev/null +++ b/android/app/src/test/java/com/shonar/recording/MigrationTest.kt @@ -0,0 +1,199 @@ +package com.shonar.recording + +import com.shonar.provider.LocalOnlyProvider +import com.shonar.provider.ProviderError +import com.shonar.provider.ProviderRegistry +import com.shonar.provider.SyncState +import java.io.File +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class MigrationRunnerTest { + + @Test fun allSucceed_marksUploadedWithSlots() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("mig-ok") + try { + val dao = FakeDao(listOf(entity("a", dir), entity("b", dir))) + val script = ScriptProvider() + val res = MigrationRunner(SyncSlots(dao), registryOf(script)) + .migrateAllTo("nextcloud") + assertEquals(2, res.uploaded) + assertTrue(res.failed.isEmpty()) + assertEquals( + listOf("a", "b"), + dao.getAll().sortedBy { it.id }.map { + assertEquals(SyncState.UPLOADED, it.syncState) + assertEquals("nextcloud", it.originProviderId) + assertEquals("SHONAR/audio/${it.id}.m4a", it.remoteKey) + it.id + }, + ) + } finally { + dir.deleteRecursively() + } + } + + @Test fun failure_recordsErrorAndContinues() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("mig-partial") + try { + val dao = FakeDao(listOf(entity("a", dir), entity("b", dir), entity("c", dir))) + val script = ScriptProvider().apply { + failWith = { if (it.id == "b") ProviderError.Transient("boom") else null } + } + val res = MigrationRunner(SyncSlots(dao), registryOf(script)) + .migrateAllTo("nextcloud") + assertEquals(2, res.uploaded) + assertEquals(listOf("t-b"), res.failed) + val b = dao.getById("b")!! + assertEquals(SyncState.ERROR, b.syncState) + assertEquals("boom", b.syncReason) + assertEquals(SyncState.UPLOADED, dao.getById("a")!!.syncState) + assertEquals(SyncState.UPLOADED, dao.getById("c")!!.syncState) + } finally { + dir.deleteRecursively() + } + } + + @Test fun missingFile_recordsErrorWithoutUpload() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("mig-gone") + try { + val ghost = RecordingEntity( + id = "g", title = "ghost", createdAtEpochMs = 1L, durationMs = 1, + filePath = File(dir, "nope.m4a").absolutePath, mimeType = "audio/mp4", sizeBytes = 0, + ) + val dao = FakeDao(listOf(ghost)) + val script = ScriptProvider() + val res = MigrationRunner(SyncSlots(dao), registryOf(script)).migrateAllTo("nextcloud") + assertEquals(0, res.uploaded) + assertEquals(listOf("ghost"), res.failed) + assertTrue(script.uploadedKeys.isEmpty()) + assertEquals(SyncState.ERROR, dao.getById("g")!!.syncState) + } finally { + dir.deleteRecursively() + } + } + + @Test fun errorRow_rerunsThroughLegalTransitions() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("mig-retry") + try { + val dao = FakeDao(listOf(entity("a", dir, SyncState.ERROR))) + val res = MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())) + .migrateAllTo("nextcloud") + assertEquals(1, res.uploaded) + assertEquals(SyncState.UPLOADED, dao.getById("a")!!.syncState) + } finally { + dir.deleteRecursively() + } + } + + @Test fun cancel_leavesRestUntouched() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("mig-cancel") + try { + val dao = FakeDao(listOf(entity("a", dir), entity("b", dir), entity("c", dir))) + val runner = MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())) + var job: Job? = null + job = launch { + runner.migrateAllTo("nextcloud") { + // Cancel as soon as the first file reports progress. + if (it.done == 0) job?.cancel() + } + } + job.join() + // First file may or may not have finished (upload is quick); the + // point is later files were never started. + val states = dao.getAll().associate { it.id to it.syncState } + assertTrue( + "unstarted rows keep their state, got $states", + states["c"] == SyncState.LOCAL_ONLY, + ) + } finally { + dir.deleteRecursively() + } + } + + @Test fun localOnlyTarget_rejected() = kotlinx.coroutines.runBlocking { + val dao = FakeDao() + try { + MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())).migrateAllTo("local-only") + fail("must reject local-only target") + } catch (expected: IllegalArgumentException) { + } + } + + @Test fun unknownTarget_throws() = kotlinx.coroutines.runBlocking { + val dao = FakeDao() + try { + MigrationRunner(SyncSlots(dao), registryOf(ScriptProvider())).migrateAllTo("dropbox") + fail("must reject unknown provider") + } catch (expected: ProviderError.InvalidUrl) { + } + } + + @Test fun forget_clearsSlotsKeepsFiles() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("mig-forget") + try { + val synced = entity("a", dir, SyncState.UPLOADED).copy( + originProviderId = "nextcloud", + remoteKey = "SHONAR/audio/a.m4a", + remoteEtag = "e", + remoteSizeBytes = 128, + ) + val dao = FakeDao(listOf(synced)) + SyncSlots(dao).forgetAllRemotes("custom-shonar") + val row = dao.getById("a")!! + assertNull(row.remoteKey) + assertNull(row.remoteEtag) + assertNull(row.remoteSizeBytes) + assertEquals("custom-shonar", row.originProviderId) + assertEquals(SyncState.QUEUED, row.syncState) + assertTrue("files stay", File(row.filePath).exists()) + } finally { + dir.deleteRecursively() + } + } + + @Test fun localOnlyId_rejectedAsTargetConstant() { + assertEquals("local-only", LocalOnlyProvider.ID) + } +} + +class SyncSlotsTest { + + @Test fun draftFor_missingFileIsNull() = kotlinx.coroutines.runBlocking { + val ghost = RecordingEntity( + id = "g", title = "ghost", createdAtEpochMs = 1L, durationMs = 1, + filePath = "/nonexistent-dir-xyz/nope.m4a", mimeType = "audio/mp4", sizeBytes = 0, + ) + assertNull(SyncSlots(FakeDao()).draftFor(ghost)) + } + + @Test fun entityDefaults_readAsNeverSyncedLocal() { + // A v1-shaped row (new columns absent) must behave as an unsynced local. + val row = RecordingEntity( + id = "v1", title = "old", createdAtEpochMs = 1L, durationMs = 1, + filePath = "/x.m4a", mimeType = "audio/mp4", sizeBytes = 10, + ) + assertEquals("local-only", row.originProviderId) + assertNull(row.remoteKey) + assertEquals(SyncState.LOCAL_ONLY, row.syncState) + assertNull(row.syncReason) + } + + @Test fun converter_roundtripsAndFallsBack() { + val c = SyncStateConverter() + assertEquals(SyncState.SYNCED, c.toState(c.toString(SyncState.SYNCED))) + assertEquals("UPLOADED", c.toString(SyncState.UPLOADED)) + assertEquals(SyncState.LOCAL_ONLY, c.toState("FUTURE_STATE")) + assertEquals(SyncState.LOCAL_ONLY, c.toState(null)) + } + + @Test fun migration_movesV1ToV2() { + assertEquals(1, MIGRATION_1_2.startVersion) + assertEquals(2, MIGRATION_1_2.endVersion) + } +} diff --git a/android/app/src/test/java/com/shonar/recording/SyncDrainTest.kt b/android/app/src/test/java/com/shonar/recording/SyncDrainTest.kt new file mode 100644 index 0000000..03119f3 --- /dev/null +++ b/android/app/src/test/java/com/shonar/recording/SyncDrainTest.kt @@ -0,0 +1,204 @@ +package com.shonar.recording + +import androidx.work.NetworkType +import com.shonar.provider.ProviderError +import com.shonar.provider.SyncState +import com.shonar.provider.SyncStatus +import com.shonar.provider.canTransitionTo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +private val OPEN = SyncDrain.Gates(online = true, unmetered = true, charging = true) +private const val NOW = 1_000_000L + +class SyncDrainTest { + + @Test fun drainsQueuedAndDueErrors_skipsTheRest() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("drain-mix") + try { + val dueError = entity("b", dir, SyncState.ERROR) + .copy(syncRetryAtMs = NOW - 1, syncAttempts = 1) + val futureError = entity("c", dir, SyncState.ERROR) + .copy(syncRetryAtMs = NOW + 60_000, syncAttempts = 1) + val rows = listOf( + entity("a", dir, SyncState.QUEUED), + dueError, + futureError, + entity("d", dir, SyncState.SYNCED), + entity("e", dir, SyncState.UPLOADED), + entity("f", dir), // LOCAL_ONLY: the drain never volunteers rows + ) + val dao = FakeDao(rows) + val res = SyncDrain(SyncSlots(dao)) { NOW } + .drainOnce(ScriptProvider(), wifiOnly = false, chargingOnly = false, gates = OPEN) + assertEquals(2, res.uploaded) + assertTrue(res.failed.isEmpty()) + // P7 leftovers (ERROR, no retryAt) are due immediately too. + val states = dao.getAll().associate { it.id to it.syncState } + assertEquals(SyncState.UPLOADED, states["a"]) + assertEquals(SyncState.UPLOADED, states["b"]) + assertEquals(SyncState.ERROR, states["c"]) + assertEquals(SyncState.SYNCED, states["d"]) + assertEquals(SyncState.UPLOADED, states["e"]) + assertEquals(SyncState.LOCAL_ONLY, states["f"]) + } finally { + dir.deleteRecursively() + } + } + + @Test fun legacyErrorWithoutRetryAt_isDue() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("drain-legacy") + try { + // Exactly how a P7-run failure reads after the v2->v3 upgrade. + val legacy = entity("a", dir, SyncState.ERROR) + .copy(syncReason = "boom", syncAttempts = 0, syncRetryAtMs = null) + val dao = FakeDao(listOf(legacy)) + val res = SyncDrain(SyncSlots(dao)) { NOW } + .drainOnce(ScriptProvider(), false, false, OPEN) + assertEquals(1, res.uploaded) + } finally { + dir.deleteRecursively() + } + } + + @Test fun localOnlyTarget_isNoop() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("drain-local") + try { + val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED))) + val script = ScriptProvider("local-only") + val res = SyncDrain(SyncSlots(dao)) { NOW } + .drainOnce(script, false, false, OPEN) + assertEquals(0, res.uploaded) + assertTrue(script.uploadedKeys.isEmpty()) + assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState) + } finally { + dir.deleteRecursively() + } + } + + @Test fun gatesBlocked_postponesWithoutTouching() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("drain-gates") + try { + val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED))) + val script = ScriptProvider() + val res = SyncDrain(SyncSlots(dao)) { NOW }.drainOnce( + script, wifiOnly = true, chargingOnly = false, + gates = SyncDrain.Gates(online = true, unmetered = false, charging = true), + ) + assertTrue(res.postponed) + assertTrue(script.uploadedKeys.isEmpty()) + assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState) + } finally { + dir.deleteRecursively() + } + } + + @Test fun failure_recordsBackoffAttempts() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("drain-backoff") + try { + val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED))) + val script = ScriptProvider().apply { + failWith = { ProviderError.Transient("nope") } + } + val drain = SyncDrain(SyncSlots(dao)) { NOW } + val res = drain.drainOnce(script, false, false, OPEN) + assertEquals(0, res.uploaded) + assertEquals(listOf("t-a"), res.failed) + val first = dao.getById("a")!! + assertEquals(SyncState.ERROR, first.syncState) + assertEquals(1, first.syncAttempts) + assertEquals(NOW + 60_000L, first.syncRetryAtMs) + // Second failure doubles the delay. + val res2 = SyncDrain(SyncSlots(dao)) { NOW + 61_000L } + .drainOnce(script, false, false, OPEN) + assertEquals(0, res2.uploaded) + val second = dao.getById("a")!! + assertEquals(2, second.syncAttempts) + assertEquals(NOW + 61_000L + 120_000L, second.syncRetryAtMs) + } finally { + dir.deleteRecursively() + } + } + + @Test fun interruption_revertsInflightToQueued() = kotlinx.coroutines.runBlocking { + val dir = tmpDir("drain-cancel") + try { + val dao = FakeDao(listOf(entity("a", dir, SyncState.QUEUED))) + val script = ScriptProvider().apply { + failWith = { throw kotlinx.coroutines.CancellationException("stopped") } + } + try { + SyncDrain(SyncSlots(dao)) { NOW }.drainOnce(script, false, false, OPEN) + org.junit.Assert.fail("cancellation must propagate") + } catch (expected: kotlinx.coroutines.CancellationException) { + } + assertEquals(SyncState.QUEUED, dao.getById("a")!!.syncState) + } finally { + dir.deleteRecursively() + } + } +} + +class SyncGatesTest { + + @Test fun matrix() { + val onlineMetered = SyncDrain.Gates(online = true, unmetered = false, charging = false) + assertTrue(onlineMetered.satisfiedBy(wifiOnly = false, chargingOnly = false)) + assertFalse(onlineMetered.satisfiedBy(wifiOnly = true, chargingOnly = false)) + assertFalse(onlineMetered.satisfiedBy(wifiOnly = false, chargingOnly = true)) + assertFalse( + SyncDrain.Gates(online = false, unmetered = false, charging = true) + .satisfiedBy(false, false) + ) + assertTrue(OPEN.satisfiedBy(true, true)) + } +} + +class BackoffTest { + + @Test fun exponentialCappedAtOneHour() { + assertEquals(60_000L, backoffDelayMs(1)) + assertEquals(120_000L, backoffDelayMs(2)) + assertEquals(240_000L, backoffDelayMs(3)) + assertEquals(3_600_000L, backoffDelayMs(7)) + assertEquals(3_600_000L, backoffDelayMs(100)) + assertEquals(60_000L, backoffDelayMs(0)) + } + + @Test fun stepsToUploading_areLegalTransitions() { + for (from in SyncState.values()) { + val steps = stepsToUploading(from) + assertTrue("must end uploading, from=$from", steps.last() == SyncState.UPLOADING) + var cur = SyncStatus(from) + for (next in steps) { + assertTrue( + "$from -> $next must be legal", + cur.canTransitionTo(next), + ) + cur = SyncStatus(next) + } + } + } +} + +class SchedulerMappingTest { + + @Test fun constraintsFollowSettings() { + val wifi = SyncScheduler.workConstraints(wifiOnly = true, chargingOnly = true) + assertEquals(NetworkType.UNMETERED, wifi.requiredNetworkType) + assertTrue(wifi.requiresCharging()) + val any = SyncScheduler.workConstraints(wifiOnly = false, chargingOnly = false) + assertEquals(NetworkType.CONNECTED, any.requiredNetworkType) + assertFalse(any.requiresCharging()) + } +} + +class MigrationVersionsTest { + + @Test fun v3() { + assertEquals(2, MIGRATION_2_3.startVersion) + assertEquals(3, MIGRATION_2_3.endVersion) + } +} diff --git a/android/app/src/test/java/com/shonar/ui/provider/HostedRouteTest.kt b/android/app/src/test/java/com/shonar/ui/provider/HostedRouteTest.kt new file mode 100644 index 0000000..095e25c --- /dev/null +++ b/android/app/src/test/java/com/shonar/ui/provider/HostedRouteTest.kt @@ -0,0 +1,79 @@ +package com.shonar.ui.provider + +import com.shonar.provider.ProbeResult +import com.shonar.provider.ProviderDescriptor +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * P6a hosted-routing matrix: a Start9/Umbrel URL (or anything reachable) + * routes to whichever protocol answered, TLS blocks everything, and + * anything else is an honest "nothing here". + */ +class HostedRouteTest { + + private fun ncCompatible(): ProbeResult = ProbeResult.Compatible( + descriptor = ProviderDescriptor(id = "nextcloud", displayName = "Nextcloud"), + serverName = "Nextcloud", + version = "30", + ) + + private fun shonarCompatible(): ProbeResult = ProbeResult.Compatible( + descriptor = ProviderDescriptor(id = "custom-shonar", displayName = "Custom SHONAR server"), + serverName = "server", + version = "test", + ) + + @Test fun nextcloudWinsWhenBothAnswer() { + assertEquals( + HostedRoute.NEXT_CLOUD, + routeHosted(ncCompatible(), shonarCompatible()), + ) + } + + @Test fun shonarUsedWhenNextcloudDoesNotMatch() { + assertEquals( + HostedRoute.SHONAR, + routeHosted(ProbeResult.Incompatible, shonarCompatible()), + ) + } + + @Test fun tlsBlocksOnlyWhenNothingMatched() { + val tls = ProbeResult.TlsFailure("AB:CD") + assertEquals(HostedRoute.TLS, routeHosted(tls, ProbeResult.Incompatible)) + assertEquals( + HostedRoute.TLS, + routeHosted(ProbeResult.Incompatible, tls), + ) + assertEquals( + HostedRoute.TLS, + routeHosted(tls, ProbeResult.NetworkError("timeout")), + ) + } + + @Test fun compatiblesBeatTls() { + // A match is actionable; TLS only blocks when nothing matched. + assertEquals( + HostedRoute.NEXT_CLOUD, + routeHosted(ncCompatible(), ProbeResult.TlsFailure("AB:CD")), + ) + assertEquals( + HostedRoute.SHONAR, + routeHosted(ProbeResult.TlsFailure("AB:CD"), shonarCompatible()), + ) + } + + @Test fun nothingMatchingIsNone() { + assertEquals( + HostedRoute.NONE, + routeHosted(ProbeResult.Incompatible, ProbeResult.Incompatible), + ) + assertEquals( + HostedRoute.NONE, + routeHosted( + ProbeResult.NetworkError("timeout"), + ProbeResult.Incompatible, + ), + ) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 70dfb63..7304e65 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") version "8.7.3" apply false id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.kapt") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false } diff --git a/backend/shonar/api/schemas_recordings.py b/backend/shonar/api/schemas_recordings.py index a2ce176..35b722e 100644 --- a/backend/shonar/api/schemas_recordings.py +++ b/backend/shonar/api/schemas_recordings.py @@ -76,3 +76,42 @@ class RecordingListOut(BaseModel): 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 + started_at: datetime | None + finished_at: datetime | None diff --git a/backend/shonar/api/v1/__init__.py b/backend/shonar/api/v1/__init__.py index b2674a5..a58f2be 100644 --- a/backend/shonar/api/v1/__init__.py +++ b/backend/shonar/api/v1/__init__.py @@ -2,13 +2,14 @@ from fastapi import APIRouter -from shonar.api.v1 import auth, health, recordings, users +from shonar.api.v1 import auth, health, provider_info, recordings, 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(provider_info.router) # Included as later milestones land: -# - transcripts, summaries, tags, search, exports, jobs +# - tags, search, exports (M9) diff --git a/backend/shonar/api/v1/provider_info.py b/backend/shonar/api/v1/provider_info.py new file mode 100644 index 0000000..0382900 --- /dev/null +++ b/backend/shonar/api/v1/provider_info.py @@ -0,0 +1,38 @@ +"""Public provider handshake. + +`GET /api/v1/provider-info` lets SHONAR clients (and platform probes on +Start9/Umbrel) identify this server as SHONAR-compatible and learn its +capabilities BEFORE any credentials are involved. Returns static deployment +metadata only — never user data, never configuration secrets. +""" + +from __future__ import annotations + +from fastapi import APIRouter + +from shonar import __version__ +from shonar.core.config import get_settings + +router = APIRouter(tags=["provider"]) + + +@router.get("/provider-info") +async def provider_info() -> dict: + """Static, unauthenticated deployment identity for client probing.""" + settings = get_settings() + # Capability flags reflect what this deployment can actually do right now. + transcription = settings.transcription_provider != "none" + llm = settings.llm_provider != "none" + return { + "kind": "shonar", + "version": __version__, + "api_version": "v1", + "capabilities": { + "chunked_upload": True, + "server_transcription": transcription, + "server_summary": llm, + "account_deletion": True, + }, + # Storage backend family (never a path): "local" | "s3" + "storage_backend": settings.storage_backend, + } diff --git a/backend/shonar/api/v1/recordings.py b/backend/shonar/api/v1/recordings.py index d3b0255..a0e1958 100644 --- a/backend/shonar/api/v1/recordings.py +++ b/backend/shonar/api/v1/recordings.py @@ -14,10 +14,13 @@ from sqlalchemy import func, select from shonar.api.deps import CurrentUser, SessionDep from shonar.api.schemas_recordings import ( + ProcessingJobOut, RecordingFinalize, RecordingListOut, RecordingOut, RecordingUpdate, + SummaryOut, + TranscriptOut, UploadSessionCreate, UploadSessionOut, UploadStatusOut, @@ -25,9 +28,12 @@ from shonar.api.schemas_recordings import ( from shonar.db.models import ( Asset, AssetKind, + ProcessingJob, Recording, RecordingTag, + Summary, Tag, + Transcript, utcnow, ) from shonar.services import uploads as up @@ -262,3 +268,72 @@ async def download_audio(recording_id: uuid.UUID, user: CurrentUser, session: Se "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 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.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) diff --git a/backend/shonar/db/session.py b/backend/shonar/db/session.py index a13687e..595945a 100644 --- a/backend/shonar/db/session.py +++ b/backend/shonar/db/session.py @@ -46,3 +46,9 @@ async def get_session() -> AsyncIterator[AsyncSession]: except Exception: await session.rollback() raise + + +def session_factory() -> async_sessionmaker[AsyncSession]: + """Shareable session factory for the worker (outside requests).""" + assert _session_factory is not None, "engine not initialised" + return _session_factory diff --git a/backend/shonar/services/ai/__init__.py b/backend/shonar/services/ai/__init__.py new file mode 100644 index 0000000..cba72b7 --- /dev/null +++ b/backend/shonar/services/ai/__init__.py @@ -0,0 +1,159 @@ +"""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, + ) -> TranscriptResult: ... + + +SUMMARY_KEYS = ("short", "detailed", "key_points", "decisions", "action_items", "questions") + + +@dataclass(frozen=True) +class SummaryResult: + short: str = "" + detailed: str = "" + key_points: tuple[str, ...] = () + decisions: tuple[str, ...] = () + action_items: tuple[str, ...] = () + questions: tuple[str, ...] = () + model: str = "" + + def to_dict(self) -> dict: + return { + "short": self.short, + "detailed": self.detailed, + "key_points": list(self.key_points), + "decisions": list(self.decisions), + "action_items": list(self.action_items), + "questions": list(self.questions), + } + + @classmethod + def from_dict(cls, raw: dict, model: str = "") -> SummaryResult: + def text(key: str) -> str: + v = raw.get(key) + return v if isinstance(v, str) else "" + + def strs(key: str) -> tuple[str, ...]: + v = raw.get(key) + if not isinstance(v, list): + return () + return tuple(s for s in v if isinstance(s, str) and s.strip()) + + return cls( + short=text("short"), + detailed=text("detailed"), + key_points=strs("key_points"), + decisions=strs("decisions"), + action_items=strs("action_items"), + questions=strs("questions"), + model=model, + ) + + +class LlmProvider(Protocol): + name: str + + async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: ... + + +def get_transcription_provider(settings: Settings) -> TranscriptionProvider | None: + """None means "transcription stage skipped", never an error.""" + kind = settings.transcription_provider.strip().lower() + if kind in ("", "none"): + return None + if kind == "whisper_http": + from shonar.services.ai.whisper_http import WhisperHttpProvider + + return WhisperHttpProvider( + base_url=settings.transcription_base_url, + model=settings.transcription_model, + api_key=settings.transcription_api_key, + ) + if kind == "faster_whisper": + from shonar.services.ai.faster_whisper import FasterWhisperProvider + + return FasterWhisperProvider(model=settings.transcription_model) + raise ProviderConfigError( + f"Unknown transcription provider: {settings.transcription_provider!r}" + ) + + +def get_llm_provider(settings: Settings) -> LlmProvider | None: + """None means "summary stage skipped", never an error.""" + kind = settings.llm_provider.strip().lower() + if kind in ("", "none"): + return None + if kind == "openai_compat": + from shonar.services.ai.openai_compat import OpenAICompatProvider + + return OpenAICompatProvider( + base_url=settings.llm_base_url, + model=settings.llm_model, + api_key=settings.llm_api_key, + ) + if kind == "ollama": + from shonar.services.ai.ollama import OllamaProvider + + return OllamaProvider( + base_url=settings.llm_base_url, + model=settings.llm_model, + ) + raise ProviderConfigError(f"Unknown LLM provider: {settings.llm_provider!r}") diff --git a/backend/shonar/services/ai/_llm.py b/backend/shonar/services/ai/_llm.py new file mode 100644 index 0000000..6427a41 --- /dev/null +++ b/backend/shonar/services/ai/_llm.py @@ -0,0 +1,35 @@ +"""Shared summary contract: one system prompt, one JSON shape. + +The model replies with JSON only; partial replies are accepted and missing +keys default to empty (a terse-but-valid summary beats a failed job). +Transcript input is truncated to bound context — a way in, not a report. +""" + +from __future__ import annotations + +from shonar.services.ai import SummaryResult + +SYSTEM_PROMPT = ( + "You summarize voice recordings for the speaker's own later reference. " + "Reply with JSON only, exactly these keys: " + '{"short": "1-2 sentences", "detailed": "a faithful paragraph", ' + '"key_points": [], "decisions": [], "action_items": [], "questions": []}. ' + "Empty arrays when absent. Never invent names, dates, or commitments " + "not stated in the transcript." +) + +MAX_TRANSCRIPT_CHARS = 12_000 + + +def build_user_message(transcript: str, title: str | None) -> str: + text = transcript[:MAX_TRANSCRIPT_CHARS] + if len(transcript) > MAX_TRANSCRIPT_CHARS: + text += f"\n\n[truncated from {len(transcript)} chars]" + head = f'Title: "{title}"\n\n' if title else "" + return head + "Transcript:\n" + text + + +def parse_summary(data: object, model: str) -> SummaryResult: + if not isinstance(data, dict): + return SummaryResult(model=model) + return SummaryResult.from_dict(data, model=model) diff --git a/backend/shonar/services/ai/faster_whisper.py b/backend/shonar/services/ai/faster_whisper.py new file mode 100644 index 0000000..53c9c83 --- /dev/null +++ b/backend/shonar/services/ai/faster_whisper.py @@ -0,0 +1,66 @@ +"""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 +from pathlib import Path + +from shonar.services.ai import ProviderConfigError, Segment, TranscriptResult + + +class FasterWhisperProvider: + name = "faster_whisper" + + def __init__(self, model: str = "base") -> None: + try: + from faster_whisper import WhisperModel # noqa: F401 + except ImportError as e: + raise ProviderConfigError( + "faster-whisper is not installed (pip install shonar-backend[faster-whisper])." + ) from e + self.model = model or "base" + + async def transcribe( + self, + audio: bytes, + mime: str, + *, + language_hint: str | None = None, + ) -> TranscriptResult: + # faster-whisper is blocking CPU work: keep it off the event loop. + return await asyncio.to_thread(self._run, audio, language_hint) + + def _run(self, audio: bytes, language_hint: str | None) -> TranscriptResult: + from faster_whisper import WhisperModel + + path: Path | None = None + try: + with tempfile.NamedTemporaryFile(suffix=".m4a", delete=False) as f: + f.write(audio) + path = Path(f.name) + model = WhisperModel(self.model, device="auto") + segments_iter, info = model.transcribe( + str(path), + beam_size=5, + language=language_hint, + ) + segments = [ + Segment(start=s.start, end=s.end, text=s.text.strip()) + for s in segments_iter + ] + text = " ".join(s.text for s in segments).strip() + return TranscriptResult( + text=text, + language=getattr(info, "language", None), + segments=segments, + model=self.model, + ) + finally: + if path is not None: + path.unlink(missing_ok=True) diff --git a/backend/shonar/services/ai/ollama.py b/backend/shonar/services/ai/ollama.py new file mode 100644 index 0000000..574f77a --- /dev/null +++ b/backend/shonar/services/ai/ollama.py @@ -0,0 +1,72 @@ +"""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 = 300.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", + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": build_user_message(transcript, title)}, + ], + } + try: + async with self._client() as client: + resp = await client.post(f"{self.base_url}/api/chat", json=payload) + except (httpx.TimeoutException, httpx.TransportError) as e: + raise ProviderTransientError(f"Ollama unreachable: {type(e).__name__}") from e + if resp.status_code == 404: + # Missing model and missing route both 404 here; both are + # configuration, not weather. + raise ProviderConfigError("Ollama has no such model or route (HTTP 404).") + if resp.status_code != 200: + raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).") + try: + content = resp.json()["message"]["content"] + except (ValueError, KeyError, TypeError) as e: + raise ProviderTransientError("Ollama sent an unreadable reply.") from e + import json as _json + + try: + data = _json.loads(content) + except ValueError as e: + raise ProviderTransientError("Ollama reply was not JSON.") from e + return parse_summary(data, self.model) diff --git a/backend/shonar/services/ai/openai_compat.py b/backend/shonar/services/ai/openai_compat.py new file mode 100644 index 0000000..883b567 --- /dev/null +++ b/backend/shonar/services/ai/openai_compat.py @@ -0,0 +1,82 @@ +"""Summaries via any OpenAI-compatible chat endpoint (self-hosted +vLLM/llama.cpp server, commercial API, …) with JSON mode. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import httpx + +from shonar.services.ai import ProviderConfigError, ProviderTransientError, SummaryResult +from shonar.services.ai._llm import SYSTEM_PROMPT, build_user_message, parse_summary + + +class OpenAICompatProvider: + name = "openai_compat" + + def __init__( + self, + base_url: str, + model: str = "", + api_key: str = "", + timeout_s: float = 180.0, + http_client: httpx.AsyncClient | None = None, + ) -> None: + if not base_url.strip(): + raise ProviderConfigError("openai_compat needs SHONAR_LLM_BASE_URL.") + if not model.strip(): + raise ProviderConfigError("openai_compat needs SHONAR_LLM_MODEL.") + self.base_url = base_url.rstrip("/") + self.model = model + self.api_key = api_key + self.timeout_s = timeout_s + self.http_client = http_client + + @asynccontextmanager + async def _client(self) -> AsyncIterator[httpx.AsyncClient]: + if self.http_client is not None: + yield self.http_client + else: + async with httpx.AsyncClient(timeout=self.timeout_s) as client: + yield client + + async def summarize(self, transcript: str, *, title: str | None = None) -> SummaryResult: + headers = ( + {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} + ) + payload = { + "model": self.model, + "temperature": 0.2, + "response_format": {"type": "json_object"}, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": build_user_message(transcript, title)}, + ], + } + try: + async with self._client() as client: + resp = await client.post( + f"{self.base_url}/v1/chat/completions", headers=headers, json=payload + ) + except (httpx.TimeoutException, httpx.TransportError) as e: + raise ProviderTransientError(f"LLM unreachable: {type(e).__name__}") from e + if resp.status_code in (401, 403, 404): + raise ProviderConfigError(f"LLM refused the request (HTTP {resp.status_code}).") + if resp.status_code == 429 or resp.status_code >= 500: + raise ProviderTransientError(f"LLM busy (HTTP {resp.status_code}).") + if resp.status_code != 200: + raise ProviderTransientError(f"Summarization failed (HTTP {resp.status_code}).") + try: + body = resp.json() + content = body["choices"][0]["message"]["content"] + except (ValueError, KeyError, IndexError, TypeError) as e: + raise ProviderTransientError("LLM sent an unreadable reply.") from e + import json as _json + + try: + data = _json.loads(content) + except ValueError as e: + raise ProviderTransientError("LLM reply was not JSON.") from e + return parse_summary(data, self.model) diff --git a/backend/shonar/services/ai/whisper_http.py b/backend/shonar/services/ai/whisper_http.py new file mode 100644 index 0000000..a5e791a --- /dev/null +++ b/backend/shonar/services/ai/whisper_http.py @@ -0,0 +1,127 @@ +"""Transcription via any OpenAI-compatible `/v1/audio/transcriptions` +endpoint (self-hosted whisper.cpp server, commercial Whisper API, …). +Sends `verbose_json` so segment timings come back with the text. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import httpx + +from shonar.services.ai import ( + ProviderConfigError, + ProviderTransientError, + Segment, + TranscriptResult, +) + + +class WhisperHttpProvider: + name = "whisper_http" + + def __init__( + self, + base_url: str, + model: str = "base", + api_key: str = "", + timeout_s: float = 300.0, + http_client: httpx.AsyncClient | None = None, + ) -> None: + if not base_url.strip(): + raise ProviderConfigError( + "whisper_http needs SHONAR_TRANSCRIPTION_BASE_URL." + ) + self.base_url = base_url.rstrip("/") + self.model = model + self.api_key = api_key + self.timeout_s = timeout_s + self.http_client = http_client + + @asynccontextmanager + async def _client(self) -> AsyncIterator[httpx.AsyncClient]: + if self.http_client is not None: + yield self.http_client + else: + async with httpx.AsyncClient(timeout=self.timeout_s) as client: + yield client + + async def transcribe( + self, + audio: bytes, + mime: str, + *, + language_hint: str | None = None, + ) -> TranscriptResult: + headers = ( + {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} + ) + data: dict[str, str] = {"model": self.model, "response_format": "verbose_json"} + if language_hint: + data["language"] = language_hint + files = {"file": (f"audio.{_ext(mime)}", audio, mime or "application/octet-stream")} + try: + async with self._client() as client: + resp = await client.post( + f"{self.base_url}/v1/audio/transcriptions", + headers=headers, + data=data, + files=files, + ) + except (httpx.TimeoutException, httpx.TransportError) as e: + raise ProviderTransientError( + f"Transcription service unreachable: {type(e).__name__}" + ) from e + if resp.status_code in (401, 403, 404): + raise ProviderConfigError( + f"Transcription service refused the request (HTTP {resp.status_code})." + ) + if resp.status_code == 429 or resp.status_code >= 500: + raise ProviderTransientError( + f"Transcription service busy (HTTP {resp.status_code})." + ) + if resp.status_code != 200: + raise ProviderTransientError( + f"Transcription failed (HTTP {resp.status_code})." + ) + try: + body = resp.json() + except ValueError as e: + raise ProviderTransientError("Transcription service sent no JSON.") from e + segments = [] + raw_segs = body.get("segments") + if isinstance(raw_segs, list): + for s in raw_segs: + if not isinstance(s, dict): + continue + try: + segments.append( + Segment( + start=float(s.get("start", 0.0)), + end=float(s.get("end", 0.0)), + text=str(s.get("text", "")), + ) + ) + except (TypeError, ValueError): + continue + text = body.get("text") + return TranscriptResult( + text=text if isinstance(text, str) else "", + language=body.get("language") if isinstance(body.get("language"), str) else None, + segments=segments, + model=self.model, + ) + + +def _ext(mime: str) -> str: + return { + "audio/mp4": "m4a", + "audio/m4a": "m4a", + "audio/wav": "wav", + "audio/x-wav": "wav", + "audio/ogg": "ogg", + "audio/opus": "ogg", + "audio/webm": "webm", + "audio/mpeg": "mp3", + }.get(mime.lower().split(";")[0].strip(), "bin") diff --git a/backend/shonar/services/processing.py b/backend/shonar/services/processing.py new file mode 100644 index 0000000..cf888ca --- /dev/null +++ b/backend/shonar/services/processing.py @@ -0,0 +1,461 @@ +"""AI processing pipeline (M7): uploaded -> transcribed -> summarized. + +State lives in the database (ProcessingJob rows); arq/redis is transport +only. That ordering is deliberate: if redis is down, uploads still succeed +(the jobs sit queued) and the worker sweep picks them up. The only race is +a task running before the API transaction commits (recording invisible) — +missing rows are transient failures, so arq retry absorbs it. + +Entry points: +- ``enqueue_for_recording``: called from upload finalize AND re-callable + later (manual transcripts in M8 re-enter here). Idempotent. +- ``run_transcribe`` / ``run_summarize``: arq task bodies. ``ctx`` is an + arq context in production and a plain dict in tests; only + ``ctx.get("job_try", 1)`` is read. +""" + +from __future__ import annotations + +import logging +import uuid + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from shonar.core.config import get_settings +from shonar.db.models import ( + Asset, + AssetKind, + JobStatus, + JobType, + ProcessingJob, + ProcessingStatus, + Recording, + Summary, + Transcript, + utcnow, +) +from shonar.services.ai import ( + AIError, + ProviderTransientError, + get_llm_provider, + get_transcription_provider, +) +from shonar.storage import get_storage + +logger = logging.getLogger("shonar.processing") + +MAX_TRIES = 3 + + +# --- entry ----------------------------------------------------------------- + + +async def enqueue_for_recording(session: AsyncSession, rec: Recording) -> list[JobType]: + """Queue whatever AI stages apply. Safe to call repeatedly: completed + work is never redone, failed work is reset for another attempt.""" + if rec.deleted_at is not None: + return [] + settings = get_settings() + tprov = get_transcription_provider(settings) + lprov = get_llm_provider(settings) + queued: list[JobType] = [] + if tprov is not None and await reset_or_create(session, rec, JobType.transcribe): + queued.append(JobType.transcribe) + if ( + lprov is not None + and await latest_transcript_text(session, rec.id) is not None + and await reset_or_create(session, rec, JobType.summarize) + ): + queued.append(JobType.summarize) + if tprov is None and lprov is None: + if rec.processing_status != ProcessingStatus.ai_disabled: + rec.processing_status = ProcessingStatus.ai_disabled + rec.processing_error = None + elif queued and rec.processing_status not in ( + ProcessingStatus.processing, + ProcessingStatus.completed, + ): + rec.processing_status = ProcessingStatus.queued + rec.processing_error = None + await session.flush() + for jt in queued: + await transport_enqueue(jt, rec.id) + return queued + + +async def reset_or_create( + session: AsyncSession, rec: Recording, job_type: JobType +) -> bool: + """Ensure a queued job row. Returns True when (re)queued now: new rows, + plus failed/skipped rows (a re-upload or re-entry deserves another + attempt). Queued/running/succeeded rows are left alone.""" + existing = await session.scalar( + select(ProcessingJob) + .where( + ProcessingJob.recording_id == rec.id, + ProcessingJob.job_type == job_type, + ) + .order_by(ProcessingJob.id.desc()) + ) + if existing is None: + session.add(ProcessingJob(recording_id=rec.id, job_type=job_type)) + return True + if existing.status in (JobStatus.queued, JobStatus.running, JobStatus.succeeded): + return False + existing.status = JobStatus.queued + existing.attempt = 0 + existing.error = None + existing.started_at = None + existing.finished_at = None + return True + + +async def latest_transcript_text( + session: AsyncSession, recording_id: uuid.UUID +) -> str | None: + """Newest non-superseded transcript; user-edited rows win over newer + machine rows (an edit is a verdict, not a draft).""" + rows = ( + await session.scalars( + select(Transcript) + .where( + Transcript.recording_id == recording_id, + Transcript.superseded_at.is_(None), + ) + .order_by(Transcript.version.desc()) + ) + ).all() + if not rows: + return None + for r in rows: + if r.edited_by_user and r.text.strip(): + return r.text + text = rows[0].text + return text if text.strip() else None + + +async def transport_enqueue(job_type: JobType, recording_id: uuid.UUID) -> None: + """Best-effort arq trigger. Failure only logs: the DB rows are the real + queue and the worker sweep picks up anything the transport missed.""" + from arq import create_pool + from arq.connections import RedisSettings + + try: + pool = await create_pool(RedisSettings.from_dsn(get_settings().redis_url)) + try: + await pool.enqueue_job("run_" + job_type.value, str(recording_id)) + finally: + await pool.aclose() + except Exception as e: # noqa: BLE001 — transport must never break uploads + logger.warning("arq enqueue failed (%s); worker sweep will pick it up", e) + + +# --- tasks ----------------------------------------------------------------- + + +def _job_try(ctx: dict) -> int: + try: + return int(ctx.get("job_try", 1)) + except (TypeError, ValueError): + return 1 + + +async def _load(session: AsyncSession, recording_id: str) -> Recording | None: + try: + rid = uuid.UUID(recording_id) + except ValueError: + return None + return await session.get(Recording, rid) + + +async def _job( + session: AsyncSession, rec: Recording, job_type: JobType +) -> ProcessingJob: + job = await session.scalar( + select(ProcessingJob) + .where( + ProcessingJob.recording_id == rec.id, + ProcessingJob.job_type == job_type, + ) + .order_by(ProcessingJob.id.desc()) + ) + if job is None: + job = ProcessingJob(recording_id=rec.id, job_type=job_type) + session.add(job) + await session.flush() + return job + + +async def _fail( + session: AsyncSession, + rec: Recording, + job: ProcessingJob, + message: str, + ctx: dict, + exc: AIError | None = None, +) -> None: + """Config errors fail now; transient errors fail only on the last try + (returning normally), otherwise they raise for arq retry.""" + transient = exc is None or isinstance(exc, ProviderTransientError) + if transient and _job_try(ctx) < MAX_TRIES: + job.attempt = _job_try(ctx) + await session.flush() + raise ProviderTransientError(message) + job.status = JobStatus.failed + job.error = message + job.finished_at = utcnow() + rec.processing_status = ProcessingStatus.failed + rec.processing_error = message + await session.flush() + + +async def run_transcribe(ctx: dict, recording_id: str) -> None: + """Transcribe the original audio; chain into summarization when an LLM + is configured.""" + from shonar.db.session import session_factory + + settings = get_settings() + async with session_factory()() as session: + rec = await _load(session, recording_id) + if rec is None or rec.deleted_at is not None: + # Finalize race: the API transaction may not have committed yet. + raise ProviderTransientError("Recording not ready; retrying.") + job = await _job(session, rec, JobType.transcribe) + provider = get_transcription_provider(settings) + if provider is None: + job.status = JobStatus.skipped + await session.flush() + await session.commit() + await _maybe_chain_summarize(session, rec) + return + original = await session.scalar( + select(Asset).where( + Asset.recording_id == rec.id, Asset.kind == AssetKind.original + ) + ) + if original is None: + raise ProviderTransientError("Audio not ready; retrying.") + job.status = JobStatus.running + job.attempt = _job_try(ctx) + job.started_at = utcnow() + rec.processing_status = ProcessingStatus.processing + rec.processing_error = None + await session.flush() + try: + audio = await get_storage().get(original.storage_key) + result = await provider.transcribe(audio, original.mime_type) + except AIError as e: + await _fail(session, rec, job, str(e), ctx, e) + await session.commit() + return + await store_transcript( + session, rec, result.text, result.segments, result.language, + provider.name, getattr(result, "model", ""), + ) + job.status = JobStatus.succeeded + job.finished_at = utcnow() + await session.flush() + await _maybe_chain_summarize(session, rec) + await session.commit() + + +async def _maybe_chain_summarize(session: AsyncSession, rec: Recording) -> None: + """After transcription (or a skip): summarize when possible, else finish.""" + settings = get_settings() + if get_llm_provider(settings) is None: + if rec.processing_status != ProcessingStatus.completed: + rec.processing_status = ProcessingStatus.completed + rec.processing_error = None + await session.flush() + return + if await latest_transcript_text(session, rec.id) is None: + # LLM configured but nothing to summarize (e.g. empty transcript). + existing = await session.scalar( + select(ProcessingJob).where( + ProcessingJob.recording_id == rec.id, + ProcessingJob.job_type == JobType.summarize, + ) + ) + if existing is not None and existing.status == JobStatus.queued: + existing.status = JobStatus.skipped + if rec.processing_status != ProcessingStatus.completed: + rec.processing_status = ProcessingStatus.completed + await session.flush() + return + if await reset_or_create(session, rec, JobType.summarize): + await transport_enqueue(JobType.summarize, rec.id) + # Status stays `processing` until the summarize task lands. + + +async def run_summarize(ctx: dict, recording_id: str) -> None: + """Summarize the latest transcript into the structured summary shape.""" + from shonar.db.session import session_factory + + settings = get_settings() + async with session_factory()() as session: + rec = await _load(session, recording_id) + if rec is None or rec.deleted_at is not None: + raise ProviderTransientError("Recording not ready; retrying.") + job = await _job(session, rec, JobType.summarize) + provider = get_llm_provider(settings) + if provider is None: + job.status = JobStatus.skipped + await session.flush() + await session.commit() + return + text = await latest_transcript_text(session, rec.id) + if text is None: + job.status = JobStatus.skipped + await session.flush() + if rec.processing_status != ProcessingStatus.completed: + rec.processing_status = ProcessingStatus.completed + await session.commit() + return + job.status = JobStatus.running + job.attempt = _job_try(ctx) + job.started_at = utcnow() + rec.processing_status = ProcessingStatus.processing + await session.flush() + try: + result = await provider.summarize(text, title=rec.title) + except AIError as e: + await _fail(session, rec, job, str(e), ctx, e) + await session.commit() + return + await store_summary(session, rec, result.to_dict(), provider.name, result.model) + job.status = JobStatus.succeeded + job.finished_at = utcnow() + rec.processing_status = ProcessingStatus.completed + rec.processing_error = None + await session.flush() + await session.commit() + + +async def store_transcript( + session: AsyncSession, + rec: Recording, + text: str, + segments: list, + language: str | None, + provider_name: str, + model: str, +) -> None: + """Insert a new auto version; a newest user-edited row wins instead and + nothing is inserted (edits are verdicts).""" + existing = ( + await session.scalars( + select(Transcript) + .where( + Transcript.recording_id == rec.id, + Transcript.superseded_at.is_(None), + ) + .order_by(Transcript.version.desc()) + ) + ).all() + if existing and existing[0].edited_by_user: + return + now = utcnow() + max_version = await session.scalar( + select(func.max(Transcript.version)).where(Transcript.recording_id == rec.id) + ) + for row in existing: + row.superseded_at = now + session.add( + Transcript( + recording_id=rec.id, + version=(max_version or 0) + 1, + language=language, + provider=provider_name, + model=model, + text=text, + segments=[ + {"start": s.start, "end": s.end, "text": s.text, "speaker": s.speaker} + for s in segments + ], + edited_by_user=False, + ) + ) + await session.flush() + + +async def store_summary( + session: AsyncSession, + rec: Recording, + content: dict, + provider_name: str, + model: str, +) -> None: + existing = ( + await session.scalars( + select(Summary) + .where( + Summary.recording_id == rec.id, + Summary.superseded_at.is_(None), + ) + .order_by(Summary.version.desc()) + ) + ).all() + if existing and existing[0].edited_by_user: + return + now = utcnow() + max_version = await session.scalar( + select(func.max(Summary.version)).where(Summary.recording_id == rec.id) + ) + for row in existing: + row.superseded_at = now + session.add( + Summary( + recording_id=rec.id, + version=(max_version or 0) + 1, + provider=provider_name, + model=model, + content=content, + edited_by_user=False, + ) + ) + await session.flush() + + +async def sweep_stale(limit: int = 100) -> int: + """Crash recovery + transport-loss backstop: requeue jobs stuck running + or sitting queued, newest catastrophe first. Returns jobs re-enqueued.""" + from shonar.db.session import session_factory + + count = 0 + async with session_factory()() as session: + rows = ( + await session.scalars( + select(ProcessingJob) + .where(ProcessingJob.status.in_([JobStatus.queued, JobStatus.running])) + .order_by(ProcessingJob.id.desc()) + .limit(limit) + ) + ).all() + for job in rows: + rec = await session.get(Recording, job.recording_id) + if rec is None or rec.deleted_at is not None: + job.status = JobStatus.skipped + continue + if job.status == JobStatus.running and job.attempt >= MAX_TRIES: + job.status = JobStatus.failed + job.error = "Worker died too many times." + job.finished_at = utcnow() + continue + job.status = JobStatus.queued + job.error = None + count += 1 + await session.commit() + # Transport outside the transaction: rows are the queue, this just pokes. + async with session_factory()() as session: + rows = ( + await session.scalars( + select(ProcessingJob) + .where(ProcessingJob.status == JobStatus.queued) + .order_by(ProcessingJob.id.desc()) + .limit(limit) + ) + ).all() + for job in rows: + await transport_enqueue(job.job_type, job.recording_id) + return count diff --git a/backend/shonar/services/uploads.py b/backend/shonar/services/uploads.py index c991996..44f60ed 100644 --- a/backend/shonar/services/uploads.py +++ b/backend/shonar/services/uploads.py @@ -231,6 +231,11 @@ async def finalize( us.completed_asset_id = existing_original.id recording.title = us.title or recording.title await session.flush() + from shonar.services import processing as _processing + + # Same audio, maybe new metadata — and a failed pipeline deserves + # another attempt. Idempotent: completed work is never redone. + await _processing.enqueue_for_recording(session, recording) return us, recording, existing_original # Assemble into the final object (streamed per chunk to bound memory). @@ -289,6 +294,11 @@ async def finalize( with contextlib.suppress(Exception): await storage.delete(c.storage_key) await session.flush() + from shonar.services import processing as _processing + + # New audio on disk: queue whatever AI stages apply (none configured = + # ai_disabled, never an error). + await _processing.enqueue_for_recording(session, recording) return us, recording, asset diff --git a/backend/shonar/worker.py b/backend/shonar/worker.py new file mode 100644 index 0000000..68fcef6 --- /dev/null +++ b/backend/shonar/worker.py @@ -0,0 +1,62 @@ +"""arq worker entrypoint (M7): runs the AI pipeline tasks. + +Run: arq shonar.worker.WorkerSettings +""" + +from __future__ import annotations + +import logging + +from arq import cron +from arq.connections import RedisSettings + +from shonar.core.config import get_settings +from shonar.db.session import dispose_engine, get_engine +from shonar.services import processing + +logger = logging.getLogger("shonar.worker") + + +async def run_transcribe(ctx: dict, recording_id: str) -> None: + await processing.run_transcribe(ctx, recording_id) + + +async def run_summarize(ctx: dict, recording_id: str) -> None: + await processing.run_summarize(ctx, recording_id) + + +async def sweep(ctx: dict) -> None: # noqa: ARG001 — arq cron signature + count = await processing.sweep_stale() + if count: + logger.info("sweep re-enqueued %d stale jobs", count) + + +async def startup(ctx: dict) -> None: + get_engine() + # Crash recovery before accepting new work: jobs stuck `running` and + # queued rows the transport missed go back through arq. + count = await processing.sweep_stale() + if count: + logger.info("startup sweep re-enqueued %d stale jobs", count) + + +async def shutdown(ctx: dict) -> None: # noqa: ARG001 + await dispose_engine() + + +def _redis() -> RedisSettings: + return RedisSettings.from_dsn(get_settings().redis_url) + + +class WorkerSettings: + functions = [run_transcribe, run_summarize, sweep] + # Transport-loss backstop beyond the startup sweep: anything still + # queued (missed enqueue, dead worker between runs) goes back through + # arq every 5 minutes. Rows are the queue; this just pokes. + cron_jobs = [cron(sweep, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55})] + on_startup = startup + on_shutdown = shutdown + redis_settings = _redis() + # Retry budget for transient provider failures; the tasks themselves + # mark jobs failed on the last try (see processing.MAX_TRIES). + max_tries = 3 diff --git a/backend/tests/test_ai_adapters.py b/backend/tests/test_ai_adapters.py new file mode 100644 index 0000000..e9a4599 --- /dev/null +++ b/backend/tests/test_ai_adapters.py @@ -0,0 +1,184 @@ +"""Adapter wire-protocol tests (M7): JSON shapes in/out, error mapping. + +HTTP adapters take an injectable httpx client; faster-whisper is an +optional dependency and is only exercised when installed. +""" + +from __future__ import annotations + +import importlib.util + +import httpx +import pytest + +from shonar.core.config import Settings +from shonar.services import ai +from shonar.services.ai import ProviderConfigError, SummaryResult +from shonar.services.ai.ollama import OllamaProvider +from shonar.services.ai.openai_compat import OpenAICompatProvider +from shonar.services.ai.whisper_http import WhisperHttpProvider + + +def mock_client(handler) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + +# --- factories ----------------------------------------------------------------- + + +def test_factories_none_means_skip(): + s = Settings(transcription_provider="none", llm_provider="none") + assert ai.get_transcription_provider(s) is None + assert ai.get_llm_provider(s) is None + + +def test_factories_unknown_names_raise_config_error(): + s = Settings(transcription_provider="whisper-9k") + with pytest.raises(ProviderConfigError): + ai.get_transcription_provider(s) + s = Settings(llm_provider="clippy") + with pytest.raises(ProviderConfigError): + ai.get_llm_provider(s) + + +def test_factories_missing_urls_raise_config_error(): + s = Settings(transcription_provider="whisper_http", transcription_base_url="") + with pytest.raises(ProviderConfigError): + ai.get_transcription_provider(s) + s = Settings(llm_provider="openai_compat", llm_base_url="http://x", llm_model="") + with pytest.raises(ProviderConfigError): + ai.get_llm_provider(s) + + +def test_faster_whisper_missing_dep_is_config_error(): + if importlib.util.find_spec("faster_whisper") is not None: + pytest.skip("faster-whisper installed; missing-dep path N/A") + # Constructor validates eagerly so misconfiguration fails at startup, + # not on the first recording. + with pytest.raises(ProviderConfigError): + ai.get_transcription_provider(Settings(transcription_provider="faster_whisper")) + + +# --- whisper_http ----------------------------------------------------------------- + + +def whisper_ok(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/audio/transcriptions" + assert request.method == "POST" + return httpx.Response(200, json={ + "text": "hello world", + "language": "en", + "segments": [{"start": 0.0, "end": 1.2, "text": "hello world"}], + }) + + +async def test_whisper_http_happy_path(): + p = WhisperHttpProvider("http://stt:8000", model="small", + http_client=mock_client(whisper_ok)) + res = await p.transcribe(b"\x00" * 16, "audio/wav") + assert res.text == "hello world" + assert res.language == "en" + assert [(s.start, s.end, s.text) for s in res.segments] == [(0.0, 1.2, "hello world")] + assert res.model == "small" + + +async def test_whisper_http_401_is_config_error(): + async def denied(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"detail": "nope"}) + p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(denied)) + with pytest.raises(ProviderConfigError): + await p.transcribe(b"\x00" * 16, "audio/wav") + + +async def test_whisper_http_503_is_transient(): + async def busy(request: httpx.Request) -> httpx.Response: + return httpx.Response(503, text="overloaded") + from shonar.services.ai import ProviderTransientError + + p = WhisperHttpProvider("http://stt:8000", http_client=mock_client(busy)) + with pytest.raises(ProviderTransientError): + await p.transcribe(b"\x00" * 16, "audio/wav") + + +# --- openai_compat ------------------------------------------------------------------ + + +def chat_ok(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/chat/completions" + body = { + "short": "Standup.", + "detailed": "The team met.", + "key_points": ["a", "b"], + "decisions": ["ship"], + "action_items": [{"not": "a string"}, "call ana"], + "questions": [], + "extra_key": "ignored", + } + import json as _json + + return httpx.Response(200, json={"choices": [{"message": {"content": _json.dumps(body)}}]}) + + +async def test_openai_compat_parses_and_sanitizes(): + p = OpenAICompatProvider("http://llm:8000", model="qwen", + http_client=mock_client(chat_ok)) + res = await p.summarize("a very long meeting transcript", title="Standup") + assert isinstance(res, SummaryResult) + assert res.short == "Standup." + assert res.action_items == ("call ana",) # non-strings dropped + assert res.model == "qwen" + + +async def test_openai_compat_partial_json_gets_defaults(): + import json as _json + + async def partial(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "choices": [{"message": {"content": _json.dumps({"short": "Hi."})}}] + }) + p = OpenAICompatProvider("http://llm:8000", model="qwen", + http_client=mock_client(partial)) + res = await p.summarize("hi") + assert res.short == "Hi." + assert res.detailed == "" and res.key_points == () + + +async def test_openai_compat_non_json_is_transient(): + async def garbage(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={ + "choices": [{"message": {"content": "Sure! Here it is..."}}] + }) + from shonar.services.ai import ProviderTransientError + + p = OpenAICompatProvider("http://llm:8000", model="qwen", + http_client=mock_client(garbage)) + with pytest.raises(ProviderTransientError): + await p.summarize("hi") + + +# --- ollama --------------------------------------------------------------------------- + + +async def test_ollama_happy_path(): + import json as _json + + async def ok(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/api/chat" + payload = _json.loads(request.content.decode()) + assert payload["format"] == "json" and payload["stream"] is False + return httpx.Response(200, json={ + "message": {"content": _json.dumps({"short": "S.", "detailed": "D."})} + }) + p = OllamaProvider("http://ollama:11434", model="llama3", + http_client=mock_client(ok)) + res = await p.summarize("meeting notes") + assert res.short == "S." and res.model == "llama3" + + +async def test_ollama_404_is_config_error(): + async def missing(request: httpx.Request) -> httpx.Response: + return httpx.Response(404, text="model not found") + p = OllamaProvider("http://ollama:11434", model="nope", + http_client=mock_client(missing)) + with pytest.raises(ProviderConfigError): + await p.summarize("hi") diff --git a/backend/tests/test_ai_pipeline.py b/backend/tests/test_ai_pipeline.py new file mode 100644 index 0000000..c4c1af7 --- /dev/null +++ b/backend/tests/test_ai_pipeline.py @@ -0,0 +1,375 @@ +"""AI pipeline tests (M7): status flow, versioning, failure modes, endpoints. + +Provider fakes stand in for real STT/LLM services (no network, no model +downloads); adapter wire-protocol tests live in test_ai_adapters.py. +""" + +from __future__ import annotations + +import struct +import uuid + +import pytest +from sqlalchemy import func, select + +from shonar.db import session as db_session +from shonar.db.models import ( + JobStatus, + JobType, + ProcessingJob, + Transcript, +) +from shonar.services import processing +from shonar.services.ai import ( + ProviderConfigError, + ProviderTransientError, + Segment, + SummaryResult, + TranscriptResult, +) + + +def wav_bytes(payload_len: int = 64) -> bytes: + data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1) + data = data[:payload_len] + header = ( + b"RIFF" + struct.pack(" 0 and mime == "audio/wav" + if self.fail is not None: + raise self.fail + return TranscriptResult( + text=self.text, language="en", + segments=[Segment(0.0, 1.0, self.text)], model="fake-stt-1", + ) + + +class FakeLlm: + name = "fake-llm" + + def __init__(self, fail=None): + self.fail = fail + self.seen = [] + + async def summarize(self, transcript, *, title=None): + self.seen.append(transcript) + if self.fail is not None: + raise self.fail + return SummaryResult( + short="Standup happened.", detailed="The team met and spoke.", + key_points=("a",), decisions=(), action_items=("ship it",), + questions=(), model="fake-llm-1", + ) + + +_UNSET = object() + + +def use_fakes(monkeypatch, stt=_UNSET, llm=_UNSET): + tprov = FakeTranscriber() if stt is _UNSET else stt + lprov = FakeLlm() if llm is _UNSET else llm + monkeypatch.setattr(processing, "get_transcription_provider", lambda settings: tprov) + monkeypatch.setattr(processing, "get_llm_provider", lambda settings: lprov) + + +async def jobs_for(recording_id): + async with db_session._session_factory() as s: + rows = (await s.scalars( + select(ProcessingJob).where(ProcessingJob.recording_id == uuid.UUID(recording_id)) + )).all() + return {j.job_type: j for j in rows} + + +# --- no AI configured -------------------------------------------------------- + + +async def test_none_configured_marks_ai_disabled(client): + token = await user_tokens(client) + rec = await upload_recording(client, token) + assert rec["processing_status"] == "ai_disabled" + assert await jobs_for(rec["id"]) == {} + # Status endpoints: nothing there yet, but the recording exists. + h = {"Authorization": f"Bearer {token}"} + r = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h) + assert r.status_code == 404 + r = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h) + assert r.status_code == 404 + r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h) + assert r.status_code == 200 and r.json() == [] + + +# --- happy path ---------------------------------------------------------------- + + +async def test_full_pipeline_transcribe_then_summarize(client, monkeypatch): + stt, llm = FakeTranscriber(), FakeLlm() + use_fakes(monkeypatch, stt, llm) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-full-1") + assert rec["processing_status"] == "queued" + jobs = await jobs_for(rec["id"]) + assert set(jobs) == {JobType.transcribe} + assert jobs[JobType.transcribe].status == JobStatus.queued + + await processing.run_transcribe({}, rec["id"]) + jobs = await jobs_for(rec["id"]) + assert jobs[JobType.transcribe].status == JobStatus.succeeded + assert set(jobs) == {JobType.transcribe, JobType.summarize} + h = {"Authorization": f"Bearer {token}"} + r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) + assert r.json()["processing_status"] == "processing" + + t = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h) + assert t.status_code == 200, t.text + body = t.json() + assert body["text"] == "hello world from the meeting" + assert body["version"] == 1 and body["provider"] == "fake-stt" + assert body["segments"][0]["text"].startswith("hello") + assert llm.seen == [] + + await processing.run_summarize({}, rec["id"]) + jobs = await jobs_for(rec["id"]) + assert jobs[JobType.summarize].status == JobStatus.succeeded + s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h) + assert s.status_code == 200, s.text + content = s.json()["content"] + assert content["short"] == "Standup happened." + assert content["action_items"] == ["ship it"] + assert set(content) == {"short", "detailed", "key_points", "decisions", + "action_items", "questions"} + r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) + assert r.json()["processing_status"] == "completed" + assert llm.seen == ["hello world from the meeting"] + + jobs_rows = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h) + assert jobs_rows.status_code == 200 + assert {j["job_type"] for j in jobs_rows.json()} == {"transcribe", "summarize"} + + +async def test_enqueue_is_idempotent(client, monkeypatch): + use_fakes(monkeypatch, FakeTranscriber(), FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-idem-1") + await processing.run_transcribe({}, rec["id"]) + await processing.run_summarize({}, rec["id"]) + async with db_session._session_factory() as s: + from shonar.db.models import Recording + + row = await s.get(Recording, uuid.UUID(rec["id"])) + queued = await processing.enqueue_for_recording(s, row) + assert queued == [] + n = await s.scalar( + select(func.count(ProcessingJob.id)).where( + ProcessingJob.recording_id == uuid.UUID(rec["id"])) + ) + assert n == 2 + await s.commit() + + +# --- failure modes ------------------------------------------------------------- + + +async def test_transient_failure_retries_then_fails(client, monkeypatch): + stt = FakeTranscriber(fail=ProviderTransientError("stt down")) + use_fakes(monkeypatch, stt, FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-fail-1") + # First tries raise for arq retry; the attempt rolls back with the + # transaction, so the row still reads queued (arq tracks the tries). + with pytest.raises(ProviderTransientError): + await processing.run_transcribe({"job_try": 1}, rec["id"]) + jobs = await jobs_for(rec["id"]) + assert jobs[JobType.transcribe].status == JobStatus.queued + # Last try marks the job (and recording) failed with a safe message. + await processing.run_transcribe({"job_try": 3}, rec["id"]) + jobs = await jobs_for(rec["id"]) + assert jobs[JobType.transcribe].status == JobStatus.failed + assert jobs[JobType.transcribe].error == "stt down" + h = {"Authorization": f"Bearer {token}"} + r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) + assert r.json()["processing_status"] == "failed" + assert r.json()["processing_error"] == "stt down" + + +async def test_config_error_fails_fast(client, monkeypatch): + stt = FakeTranscriber(fail=ProviderConfigError("bad credentials")) + use_fakes(monkeypatch, stt, FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-cfg-1") + await processing.run_transcribe({"job_try": 1}, rec["id"]) # no raise + jobs = await jobs_for(rec["id"]) + assert jobs[JobType.transcribe].status == JobStatus.failed + assert stt.calls == 1 + + +# --- versioning ----------------------------------------------------------------- + + +async def test_rerun_supersedes_auto_but_not_user_edits(client, monkeypatch): + use_fakes(monkeypatch, FakeTranscriber("v1 text"), FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-ver-1") + await processing.run_transcribe({}, rec["id"]) + rid = uuid.UUID(rec["id"]) + + async def versions(): + async with db_session._session_factory() as s: + rows = (await s.scalars( + select(Transcript).where(Transcript.recording_id == rid) + .order_by(Transcript.version))).all() + return [(r.version, r.text, r.superseded_at is not None, r.edited_by_user) + for r in rows] + + assert await versions() == [(1, "v1 text", False, False)] + + # Queue another transcription run manually (re-entry path). + async with db_session._session_factory() as s: + from shonar.db.models import Recording + + row = await s.get(Recording, rid) + await processing.enqueue_for_recording(s, row) + await s.commit() + use_fakes(monkeypatch, FakeTranscriber("v2 text"), FakeLlm()) + await processing.run_transcribe({}, rec["id"]) + assert await versions() == [(1, "v1 text", True, False), (2, "v2 text", False, False)] + + # A user edit wins: the next auto run inserts nothing. + async with db_session._session_factory() as s: + v2 = await s.scalar( + select(Transcript).where(Transcript.recording_id == rid, + Transcript.version == 2)) + v2.edited_by_user = True + v2.text = "user corrected text" + from shonar.db.models import Recording + + row = await s.get(Recording, rid) + await processing.enqueue_for_recording(s, row) + await s.commit() + use_fakes(monkeypatch, FakeTranscriber("v3 text"), FakeLlm()) + await processing.run_transcribe({}, rec["id"]) + got = await versions() + assert len(got) == 2 and got[1][1] == "user corrected text" + + +# --- llm-only and skips ---------------------------------------------------------- + + +async def test_llm_only_without_transcript_queues_nothing(client, monkeypatch): + use_fakes(monkeypatch, None, FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-llm-1") + assert rec["processing_status"] == "uploaded" + assert await jobs_for(rec["id"]) == {} + + +async def test_llm_only_with_manual_transcript_summarizes(client, monkeypatch): + use_fakes(monkeypatch, None, FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-llm-2") + rid = uuid.UUID(rec["id"]) + async with db_session._session_factory() as s: + from shonar.db.models import Recording + + row = await s.get(Recording, rid) + s.add(Transcript(recording_id=rid, version=1, provider="manual", + text="handwritten notes", edited_by_user=True)) + await s.flush() + queued = await processing.enqueue_for_recording(s, row) + assert queued == [JobType.summarize] + await s.commit() + await processing.run_summarize({}, rec["id"]) + h = {"Authorization": f"Bearer {token}"} + s = await client.get(f"/api/v1/recordings/{rec['id']}/summary", headers=h) + assert s.status_code == 200 + r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) + assert r.json()["processing_status"] == "completed" + + +# --- sweep ------------------------------------------------------------------------ + + +async def test_sweep_requeues_stale_jobs(client, monkeypatch): + use_fakes(monkeypatch, FakeTranscriber(), FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-sweep-1") + rid = uuid.UUID(rec["id"]) + async with db_session._session_factory() as s: + from shonar.db.models import Recording + + row = await s.get(Recording, rid) + # Simulate a crashed worker + a lost transport, respectively. + await processing.reset_or_create(s, row, JobType.transcribe) + jobs = await jobs_for(rec["id"]) + jobs[JobType.transcribe].status = JobStatus.running + await s.commit() + # reset_or_create leaves running rows alone, so force the second shape: + async with db_session._session_factory() as s: + extra = ProcessingJob(recording_id=rid, job_type=JobType.summarize, + status=JobStatus.queued) + s.add(extra) + await s.commit() + count = await processing.sweep_stale() + assert count == 2 + jobs = await jobs_for(rec["id"]) + assert jobs[JobType.transcribe].status == JobStatus.queued + assert jobs[JobType.summarize].status == JobStatus.queued + + +# --- ownership --------------------------------------------------------------------- + + +async def test_ai_endpoints_enforce_ownership(client, monkeypatch): + use_fakes(monkeypatch, FakeTranscriber(), FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-own-1") + await processing.run_transcribe({}, rec["id"]) + other = await user_tokens(client, email="m7-other@example.com", + password="m7-test-passw0rd-456") + h = {"Authorization": f"Bearer {other}"} + for path in ("transcript", "summary", "jobs"): + r = await client.get(f"/api/v1/recordings/{rec['id']}/{path}", headers=h) + assert r.status_code == 404, path diff --git a/backend/tests/test_provider_info.py b/backend/tests/test_provider_info.py new file mode 100644 index 0000000..a761955 --- /dev/null +++ b/backend/tests/test_provider_info.py @@ -0,0 +1,35 @@ +"""provider-info handshake tests (P2/P3 client probing depends on this).""" + + +async def test_provider_info_identifies_shonar(client): + r = await client.get("/api/v1/provider-info") + assert r.status_code == 200 + body = r.json() + assert body["kind"] == "shonar" + assert body["api_version"] == "v1" + caps = body["capabilities"] + assert caps["chunked_upload"] is True + assert caps["account_deletion"] is True + # test env configures no AI providers -> flags must be false + assert caps["server_transcription"] is False + assert caps["server_summary"] is False + assert body["storage_backend"] in ("local", "s3") + + +async def test_provider_info_leaks_no_paths_or_secrets(client): + r = await client.get("/api/v1/provider-info") + body = r.json() + # every string value must be a bare identifier, never a filesystem path + def walk(v): + if isinstance(v, str): + assert not v.startswith("/"), f"path-like value in handshake: {v!r}" + low = v.lower() + for banned in ("secret", "token", "password", "key"): + assert banned not in low + elif isinstance(v, dict): + for x in v.values(): + walk(x) + elif isinstance(v, list): + for x in v: + walk(x) + walk(body) diff --git a/backend/tests/test_recordings.py b/backend/tests/test_recordings.py index d970bbc..448095e 100644 --- a/backend/tests/test_recordings.py +++ b/backend/tests/test_recordings.py @@ -72,7 +72,8 @@ async def test_upload_happy_path_creates_recording(client): rec = r.json() assert rec["title"] == "Standup" assert rec["has_audio"] is True - assert rec["processing_status"] == "uploaded" + # M7: with no AI configured the pipeline marks audio-only explicitly. + assert rec["processing_status"] == "ai_disabled" assert rec["duration_seconds"] == 12.5 # No storage keys or internals leak. assert "storage" not in r.text and "key" not in r.text.lower().replace("chunk", "") diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a3880d7..1e847aa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,13 +9,13 @@ updated in the same commit as the work it describes. | M0 | Repo scaffold, license, docs, Docker dev stack, `/healthz` `/readyz`, Alembic schema | done | | M1 | Auth: register / login / rotating refresh + reuse detection / logout / delete-account, Argon2id, rate limits | done | | M2 | Upload sessions (chunked, resumable), storage abstraction (local + S3), recordings CRUD, ownership checks | done | -| M3 | Android: server URL config, login, token persistence + auto-refresh | TODO | +| M3 | Android: server URL config, login, token persistence + auto-refresh | implemented via P3 `CustomShonarProvider` (Keystore token store, transparent refresh, login UI in provider selection); on-device verification pending | | S-1 | Android: generic Custom Settings engine (8 types, validation, custom CRUD, import/export, secure storage) — 19 unit tests green | done | | ~~HA-1/HA-2~~ | Home Assistant integration (client, repository, devices screen, e2e scripts) | **DEFERRED — out of initial product scope; preserved under `deferred/home-assistant/` and branch `deferred/home-assistant`** | -| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | TODO | -| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | TODO | -| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | TODO | -| M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | TODO | +| M4 | Android: foreground-service recording (pause/resume/stop), metadata, Room | implemented; on-device verification pending | +| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | done — `SyncWorker` + `SyncScheduler` (15-min periodic, one-shot on startup/constraint change, backoff on retry), Context-free `SyncDrain` engine (drains QUEUED + due ERROR incl. P7 leftovers, exponential backoff 1m–1h, cancel reverts to QUEUED), pause as resting state; full suite 167 green. On-device behaviour pending | +| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | partial; local list, playback, and delete implemented | +| M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | done — provider protocols + 4 adapters (faster-whisper lazy optional), versioned transcripts/summaries (user edits win), arq worker (`run_transcribe`/`run_summarize` + startup/5-min sweep, max 3 tries), transcript/summary/jobs endpoints, `none` means skipped; 50 backend tests green, ruff clean | | M8 | Android: details screen — transcript synced to playback, summary, action items, editing | TODO | | M9 | Backend: full-text search endpoints + filters, exports (audio/txt/md/zip), deletion sweep | TODO (schema/FTS columns exist) | | M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO | diff --git a/docs/server-providers.md b/docs/server-providers.md index 2d68d34..f3249f0 100644 --- a/docs/server-providers.md +++ b/docs/server-providers.md @@ -142,6 +142,11 @@ provider API first, local wipe second. ## 7. Start9 / Umbrel / custom adapters +> Status: P6a shipped the generic path — Start9/Umbrel cards take a service +> URL and auto-detect Nextcloud vs SHONAR, handing off to those providers. +> The platform-RPC discovery below, multi-service picking, and Tor onion +> access (Orbot SOCKS) are deferred to P6b. + - **Start9 & Umbrel are platforms, not APIs.** The adapter pattern is "platform probe + service binding": a `PlatformProbe` (Start9: Server API over its RPC; Umbrel: its app manifest endpoints, where available) @@ -182,11 +187,12 @@ provider API first, local wipe second. | P0 (done) | HA isolated to `deferred/` + branch; removed from build, onboarding, defaults, tests | build + unit tests green | | P1 | `provider/` module: `ShonarProvider` interface, `ServerUrl` validation, `LocalOnlyProvider`, `ProviderRegistry` | contract suite (local-only) | | P2 | Provider-selection onboarding screen + StorageLocation screen wired to registry | on-device | -| P3 | `CustomShonarProvider` against this repo's backend (auth M1 + uploads M2), WorkManager sync states | backend + app integration | -| P4 | `NextcloudProvider`: login flow v2, DAV upload/download/delete, chunking | contract suite + a real Nextcloud instance | -| P5 | TLS TOFU pinning + redaction/logging + leak tests | cert fixtures | -| P6 | Platform probes for Start9/Umbrel + service-binding UX | probe fakes | -| P7 | Provider switching w/ migration prompts, account deletion, revocation | e2e | +| P3 (done) | `CustomShonarProvider` against this repo's backend (auth M1 + uploads M2), WorkManager sync states | contract suite (10 shared + 12 custom) + 9 auth tests vs MockWebServer fixtures mirroring `schemas_*.py`; backend pytest 28 green on live Postgres; `assembleDebug` clean | +| P4 (done) | `NextcloudProvider`: login flow v2, DAV upload/download/delete, chunking — plus `FolderSyncProvider` (sync-folder for Syncthing etc., requested alongside) | contract suite (10 shared + 6 custom) + 6 auth + 4 protocol tests vs MockWebServer fake; DAV/chunking-v2 protocol verified live against Nextcloud 34 (MKCOL/PUT/MOVE-assemble/PROPFIND/OCS/login-v2 via curl); full suite 120 green, `assembleDebug` clean. On-device browser-approval tap-through still pending | +| P5 (done) | TLS TOFU pinning + redaction/logging + leak tests | TOFU trust manager (system-first, per-host DER pins in secure store, explicit approval UI with fingerprint + issuer + validity) + redacting logger gated by `log_http_bodies` (transcript/sidecar bodies never log, in any mode) + HeldCertificate TLS fixtures; full suite 138 green, `assembleDebug` clean. Self-signed hosts now connect after one approval; hostname verification stays strict | +| P6a (done) | Generic hosted setup: Start9/Umbrel cards take a service URL, auto-detect Nextcloud vs SHONAR, hand off to the existing provider flows; the platform is an entry path, the persisted provider is the protocol | routing matrix unit tests (`routeHosted`); full suite + `assembleDebug` | +| P6b (deferred) | Platform RPC auto-discovery (Start9 Server API, Umbrel manifests), multi-service picker, Tor onion access via Orbot SOCKS | probe fakes + on-device, when needed | +| P7 (done) | Provider switching w/ migration prompts, account deletion, revocation | Room v2 provider slot per recording (origin + remote ref + sync state, honest migration) + "Where is my data?" screen (summary, reconnect, disconnect+revoke, delete account) + foreground migrate-now uploader (bounded: cancellable, one attempt/file, upload-only) + switch-time prompt (upload / keep / forget links); migration matrix + slots + converter tests, full suite 156 green, `assembleDebug` clean. Background/scheduled sync stays M5; pull-down and old-remote wipe are explicit non-goals (a future wipe is safe: everything lives under `SHONAR/` prefixes) | Recording engine (M4) and playback (M6) proceed independently on top of the same Room model; provider work is orthogonal. diff --git a/scripts/dev_bootstrap.sh b/scripts/dev_bootstrap.sh index 704956a..72d9d5a 100755 --- a/scripts/dev_bootstrap.sh +++ b/scripts/dev_bootstrap.sh @@ -8,8 +8,13 @@ docker compose -f deploy/docker-compose.dev.yml up -d echo "==> backend deps" cd backend -uv venv .venv 2>/dev/null || true -uv pip install -e ".[dev]" +if command -v uv >/dev/null 2>&1; then + uv venv .venv 2>/dev/null || true + uv pip install -e ".[dev]" +else + python3 -m venv .venv + .venv/bin/python -m pip install -e ".[dev]" +fi echo "==> test database" docker exec shonar-dev-postgres-1 psql -U shonar -d postgres -tc \ diff --git a/shared/openapi.json b/shared/openapi.json index da1a40c..f020d45 100644 --- a/shared/openapi.json +++ b/shared/openapi.json @@ -120,6 +120,72 @@ "title": "LogoutRequest", "type": "object" }, + "ProcessingJobOut": { + "properties": { + "attempt": { + "title": "Attempt", + "type": "integer" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + }, + "finished_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finished At" + }, + "job_type": { + "title": "Job Type", + "type": "string" + }, + "max_attempts": { + "title": "Max Attempts", + "type": "integer" + }, + "started_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Started At" + }, + "status": { + "title": "Status", + "type": "string" + } + }, + "required": [ + "job_type", + "status", + "attempt", + "max_attempts", + "error", + "started_at", + "finished_at" + ], + "title": "ProcessingJobOut", + "type": "object" + }, "RecordingFinalize": { "properties": { "duration_seconds": { @@ -475,6 +541,93 @@ "title": "RegisterRequest", "type": "object" }, + "SegmentOut": { + "properties": { + "end": { + "title": "End", + "type": "number" + }, + "speaker": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Speaker" + }, + "start": { + "title": "Start", + "type": "number" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "start", + "end", + "text" + ], + "title": "SegmentOut", + "type": "object" + }, + "SummaryOut": { + "properties": { + "content": { + "additionalProperties": true, + "title": "Content", + "type": "object" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "edited_by_user": { + "title": "Edited By User", + "type": "boolean" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "version": { + "title": "Version", + "type": "integer" + } + }, + "required": [ + "version", + "provider", + "model", + "content", + "edited_by_user", + "created_at", + "updated_at" + ], + "title": "SummaryOut", + "type": "object" + }, "TokenPair": { "properties": { "access_token": { @@ -515,6 +668,78 @@ "title": "TokenPair", "type": "object" }, + "TranscriptOut": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "edited_by_user": { + "title": "Edited By User", + "type": "boolean" + }, + "language": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Language" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "segments": { + "items": { + "$ref": "#/components/schemas/SegmentOut" + }, + "title": "Segments", + "type": "array" + }, + "text": { + "title": "Text", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + }, + "version": { + "title": "Version", + "type": "integer" + } + }, + "required": [ + "version", + "language", + "provider", + "model", + "text", + "segments", + "edited_by_user", + "created_at", + "updated_at" + ], + "title": "TranscriptOut", + "type": "object" + }, "UploadSessionCreate": { "properties": { "client_recording_id": { @@ -1093,6 +1318,30 @@ ] } }, + "/api/v1/provider-info": { + "get": { + "description": "Static, unauthenticated deployment identity for client probing.", + "operationId": "provider_info_api_v1_provider_info_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "title": "Response Provider Info Api V1 Provider Info Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Provider Info", + "tags": [ + "provider" + ] + } + }, "/api/v1/readyz": { "get": { "description": "Readiness: database reachable. Config warnings surfaced for admins\nvia /api/v1/system/status instead of failing readiness.", @@ -1407,6 +1656,157 @@ ] } }, + "/api/v1/recordings/{recording_id}/jobs": { + "get": { + "operationId": "list_jobs_api_v1_recordings__recording_id__jobs_get", + "parameters": [ + { + "in": "path", + "name": "recording_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Recording Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProcessingJobOut" + }, + "title": "Response List Jobs Api V1 Recordings Recording Id Jobs Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Jobs", + "tags": [ + "uploads", + "recordings" + ] + } + }, + "/api/v1/recordings/{recording_id}/summary": { + "get": { + "operationId": "get_summary_api_v1_recordings__recording_id__summary_get", + "parameters": [ + { + "in": "path", + "name": "recording_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Recording Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SummaryOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Summary", + "tags": [ + "uploads", + "recordings" + ] + } + }, + "/api/v1/recordings/{recording_id}/transcript": { + "get": { + "operationId": "get_transcript_api_v1_recordings__recording_id__transcript_get", + "parameters": [ + { + "in": "path", + "name": "recording_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Recording Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TranscriptOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Transcript", + "tags": [ + "uploads", + "recordings" + ] + } + }, "/api/v1/system/status": { "get": { "description": "Public-ish status: which AI features are enabled (never any secrets).\nThe app uses this to show honest AI-processing state to users.", diff --git a/worker/README.md b/worker/README.md index 7bd997b..10e213e 100644 --- a/worker/README.md +++ b/worker/README.md @@ -1,6 +1,22 @@ """Worker entrypoint: runs the arq queue on the backend image. -Pipeline tasks land with milestone M7; until then the worker image runs an -idle loop so `docker compose up` is complete and future-proof. -TODO(M7): implement sonar/processing/tasks.py and wire arq.WorkerSettings. +Run (from `backend/`, venv active): + + arq shonar.worker.WorkerSettings + +Needs Redis (`SHONAR_REDIS_URL`, wired automatically in compose) and the +same `SHONAR_*` env as the API — the worker reads AI provider config from +the same variables, so transcription/summary behavior matches what +`/api/v1/provider-info` advertises. + +What it runs (M7): +- `run_transcribe(recording_id)` — original audio through the configured + transcription provider, stored versioned (user edits win, never touched). +- `run_summarize(recording_id)` — latest transcript through the LLM into + the structured summary shape. +- `sweep` on startup + every 5 min — crash recovery and transport-loss + backstop: stuck `running` and orphaned `queued` rows go back through arq. + +With no AI configured the pipeline marks recordings `ai_disabled` and the +worker idles: recording, sync, and playback never need it. """