From 8873c759e1c776c792ae7f97c73b9c9bc800a79f Mon Sep 17 00:00:00 2001 From: avi Date: Sat, 12 Sep 2026 22:33:16 -0500 Subject: [PATCH] M8 + T1: detail screen with transcript/summary editing; transcription model registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - PUT /recordings/{id}/transcript and /summary — user-edited, versioned; the pipeline never clobbers user versions - Model registry (tiny/base/small/medium/large-v3), global default via PUT /models/default (future rows only), per-recording override on finalize/upload-session (finalize wins), GET /api/v1/models - processing_jobs gain stage/progress; faster-whisper model cache + fail-fast on unavailable models; migration m8models000001 - 10 model tests + 5 transcript-edit tests (backend suite 64 green, ruff clean) Android: - detail/{id} route: transcript/summary/status tabs, tap-to-seek, speed control, edit dialogs, title rename via CustomShonarProvider AI methods - pure AiContent parsing/sync mapping; honest empty states for local-only/unsynced rows; 19 new unit tests (Android suite green) --- android/app/src/main/AndroidManifest.xml | 10 + .../src/main/java/com/shonar/MainActivity.kt | 36 +- .../main/java/com/shonar/ShonarApplication.kt | 17 +- .../shonar/provider/CustomShonarProvider.kt | 168 +++++- .../com/shonar/provider/FolderSyncProvider.kt | 36 +- .../com/shonar/provider/LocalOnlyProvider.kt | 137 ++++- .../com/shonar/provider/ProviderRegistry.kt | 10 +- .../java/com/shonar/provider/ProviderTypes.kt | 4 + .../java/com/shonar/provider/TlsPolicy.kt | 6 +- .../java/com/shonar/recording/AiContent.kt | 225 ++++++++ .../shonar/recording/RecordingRepository.kt | 138 ++++- .../com/shonar/ui/detail/DetailsScreen.kt | 507 ++++++++++++++++++ .../com/shonar/ui/detail/DetailsViewModel.kt | 356 ++++++++++++ .../shonar/ui/folder/FolderBrowserScreen.kt | 223 ++++++++ .../java/com/shonar/ui/folder/FolderList.kt | 48 ++ .../java/com/shonar/ui/home/HomeScreen.kt | 93 +++- .../com/shonar/ui/home/RecordingViewModel.kt | 17 +- .../ui/provider/ProviderSelectionScreen.kt | 105 +++- .../ui/provider/ProviderSelectionViewModel.kt | 126 +++-- .../com/shonar/ui/provider/StorageScreen.kt | 63 ++- .../com/shonar/provider/CustomShonarAiTest.kt | 180 +++++++ .../shonar/provider/FolderSyncCreateTest.kt | 71 +++ .../shonar/provider/LocalOnlyFolderTest.kt | 87 +++ .../com/shonar/recording/AiContentTest.kt | 143 +++++ .../shonar/recording/RecordingRenameTest.kt | 107 ++++ .../com/shonar/ui/folder/FolderListTest.kt | 63 +++ .../versions/m8models000001_model_support.py | 63 +++ backend/shonar/api/schemas_recordings.py | 32 ++ backend/shonar/api/v1/__init__.py | 3 +- backend/shonar/api/v1/models.py | 132 +++++ backend/shonar/api/v1/recordings.py | 118 +++- backend/shonar/db/models.py | 25 + backend/shonar/services/ai/faster_whisper.py | 40 +- backend/shonar/services/ai/model_registry.py | 165 ++++++ backend/shonar/services/processing.py | 27 + backend/shonar/services/uploads.py | 26 + backend/tests/conftest.py | 3 +- backend/tests/test_models.py | 245 +++++++++ backend/tests/test_transcript_edits.py | 189 +++++++ docs/ROADMAP.md | 3 +- shared/openapi.json | 483 +++++++++++++++++ 41 files changed, 4396 insertions(+), 134 deletions(-) create mode 100644 android/app/src/main/java/com/shonar/recording/AiContent.kt create mode 100644 android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt create mode 100644 android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt create mode 100644 android/app/src/main/java/com/shonar/ui/folder/FolderBrowserScreen.kt create mode 100644 android/app/src/main/java/com/shonar/ui/folder/FolderList.kt create mode 100644 android/app/src/test/java/com/shonar/provider/CustomShonarAiTest.kt create mode 100644 android/app/src/test/java/com/shonar/provider/FolderSyncCreateTest.kt create mode 100644 android/app/src/test/java/com/shonar/provider/LocalOnlyFolderTest.kt create mode 100644 android/app/src/test/java/com/shonar/recording/AiContentTest.kt create mode 100644 android/app/src/test/java/com/shonar/recording/RecordingRenameTest.kt create mode 100644 android/app/src/test/java/com/shonar/ui/folder/FolderListTest.kt create mode 100644 backend/migrations/versions/m8models000001_model_support.py create mode 100644 backend/shonar/api/v1/models.py create mode 100644 backend/shonar/services/ai/model_registry.py create mode 100644 backend/tests/test_models.py create mode 100644 backend/tests/test_transcript_edits.py diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ef485e9..581f284 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,16 @@ + + + + + nav.navigate("detail/$id") }, + ) + } + composable( + "detail/{id}", + arguments = listOf(navArgument("id") { type = androidx.navigation.NavType.StringType }), + ) { backStack -> + DetailsScreen( + recordingId = backStack.arguments?.getString("id").orEmpty(), + onBack = { nav.popBackStack() }, ) } composable("storage") { @@ -40,10 +55,29 @@ class MainActivity : ComponentActivity() { onSwitchProvider = { nav.navigate("provider") }, ) } - composable("provider") { + composable("provider") { entry -> + val pick by entry.savedStateHandle + .getStateFlow("picked_path", null) + .collectAsState() ProviderSelectionScreen( onDone = { nav.popBackStack() }, onBack = { nav.popBackStack() }, + browsePick = pick, + onPickConsumed = { + entry.savedStateHandle.remove("picked_path") + }, + onBrowse = { nav.navigate("folderBrowser") }, + ) + } + composable("folderBrowser") { + FolderBrowserScreen( + onPick = { path -> + nav.previousBackStackEntry + ?.savedStateHandle + ?.set("picked_path", path) + nav.popBackStack() + }, + onBack = { nav.popBackStack() }, ) } composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) } diff --git a/android/app/src/main/java/com/shonar/ShonarApplication.kt b/android/app/src/main/java/com/shonar/ShonarApplication.kt index 6f81b1c..14c8620 100644 --- a/android/app/src/main/java/com/shonar/ShonarApplication.kt +++ b/android/app/src/main/java/com/shonar/ShonarApplication.kt @@ -28,7 +28,19 @@ class ShonarApplication : Application() { } val recordingRepository: RecordingRepository by lazy { - RecordingRepository(this, database.recordingDao()) + RecordingRepository(filesDir, database.recordingDao()) { + // New recordings belong in the picked folder (local-only + // provider), or the app-private library by default / for + // server-backed setups. Never throws — null means default. + runCatching { + settingsManager.ensureLoaded() + val pid = settingsManager.string( + com.shonar.settings.BuiltInSettings.PROVIDER_ID + ).ifBlank { "local-only" } + (providerRegistry.provider(pid) as? com.shonar.provider.LocalOnlyProvider) + ?.currentRoot() + }.getOrNull() + } } val secureStore: SecureSettingsStore by lazy { SecureSettingsStore(this) } @@ -63,6 +75,7 @@ class ShonarApplication : Application() { tlsPolicy = com.shonar.provider.TlsPolicy( tofu = tofu, bodiesEnabled = bodyLogging::get, + sink = { msg -> android.util.Log.d("ShonarNet", msg) }, ), ) } @@ -73,6 +86,8 @@ class ShonarApplication : Application() { settingsManager.ensureLoaded() // TOFU pins must be in cache before any TLS handshake needs them. tofu.refresh() + // Point new recordings at the picked folder (if any). + runCatching { recordingRepository.refreshRoot() } syncBodyLoggingFlag() scheduleSync() settingsManager.valuesChanged.collect { diff --git a/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt b/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt index a9d9624..c26f935 100644 --- a/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt +++ b/android/app/src/main/java/com/shonar/provider/CustomShonarProvider.kt @@ -40,10 +40,10 @@ import okhttp3.Response * 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. + * Sidecars: file cache under [sidecarRoot] keyed by remote recording id + * (same layout as LocalOnlyProvider). AI content (transcript/summary/jobs) + * goes through the real M7 endpoints instead — see [fetchTranscript] and + * friends, consumed by the M8 details screen. * * Cancellation safety: a cancelled upload leaves an open server session * with some chunks stored — invisible until finalize, resumable via the @@ -55,6 +55,9 @@ class CustomShonarProvider( private val sidecarRoot: File, private val client: OkHttpClient = defaultClient(), private val handshake: ShonarHandshake = ShonarHandshake(), + /** Identifies this client to the server (login device_name/platform). */ + private val deviceName: String = "SHONAR Android", + private val platform: String = "android", ) : ShonarProvider { override val descriptor = ProviderDescriptor( @@ -283,6 +286,127 @@ class CustomShonarProvider( } } + // ---- AI content (M7 endpoints, consumed by the M8 details screen) ---- + // + // The recording id here is the server-side UUID — the local row's + // [com.shonar.recording.RecordingEntity.remoteKey] after upload. + // 404 surfaces as [ProviderError.NotFound] ("no transcript yet" is a + // normal state, not a failure). Raw JSON comes back so parsing stays in + // the testable `recording.AiContent` helpers, not in this IO class. + + suspend fun fetchTranscript(recordingId: String): String = getAiJson( + "/api/v1/recordings/$recordingId/transcript", recordingId + ) + + suspend fun fetchSummary(recordingId: String): String = getAiJson( + "/api/v1/recordings/$recordingId/summary", recordingId + ) + + suspend fun fetchJobs(recordingId: String): String = getAiJson( + "/api/v1/recordings/$recordingId/jobs", recordingId + ) + + /** User edit → new server version with edited_by_user=true (pipeline won't clobber). */ + suspend fun updateTranscript( + recordingId: String, + payloadJson: String, + ): String = withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + executeAuthed(base) { token -> put(base, "/api/v1/recordings/$recordingId/transcript", token, payloadJson) } + .use { resp -> + when (resp.code) { + 200 -> resp.body?.string().orEmpty() + 404 -> throw ProviderError.NotFound(recordingId) + else -> throw ProviderError.Transient("Transcript update failed (HTTP ${resp.code})") + } + } + } + + suspend fun updateSummary(recordingId: String, payloadJson: String): String = + withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + executeAuthed(base) { token -> put(base, "/api/v1/recordings/$recordingId/summary", token, payloadJson) } + .use { resp -> + when (resp.code) { + 200 -> resp.body?.string().orEmpty() + 404 -> throw ProviderError.NotFound(recordingId) + else -> throw ProviderError.Transient("Summary update failed (HTTP ${resp.code})") + } + } + } + + /** Title/notes rename from the details screen (server + Room updated by caller). */ + suspend fun patchRecording(recordingId: String, payloadJson: String): String = + withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + executeAuthed(base) { token -> patch(base, "/api/v1/recordings/$recordingId", token, payloadJson) } + .use { resp -> + when (resp.code) { + 200 -> resp.body?.string().orEmpty() + 404 -> throw ProviderError.NotFound(recordingId) + else -> throw ProviderError.Transient("Recording update failed (HTTP ${resp.code})") + } + } + } + + private suspend fun getAiJson(path: String, recordingId: String): String = + withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + executeAuthed(base) { token -> get(base, path, token) }.use { resp -> + when (resp.code) { + 200 -> resp.body?.string().orEmpty() + 404 -> throw ProviderError.NotFound(recordingId) + else -> throw ProviderError.Transient("Request failed (HTTP ${resp.code})") + } + } + } + + // ---- transcription models (Stage 1 endpoints, desktop Settings) -------- + // Raw JSON throughout; parsing lives in the testable AiContent helpers. + + suspend fun fetchModels(): String = withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + executeAuthed(base) { token -> get(base, "/api/v1/models", token) }.use { resp -> + if (resp.code != 200) throw ProviderError.Transient("Models request failed (HTTP ${resp.code})") + resp.body?.string().orEmpty() + } + } + + suspend fun setDefaultModel(model: String): String = withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + val payload = """{"model":${jsonStr(model)}}""" + executeAuthed(base) { token -> put(base, "/api/v1/models/default", token, payload) } + .use { resp -> + when (resp.code) { + 200 -> resp.body?.string().orEmpty() + 422 -> throw ProviderError.InvalidUrl("Unsupported model: $model") + else -> throw ProviderError.Transient("Default model update failed (HTTP ${resp.code})") + } + } + } + + /** Fetch a model into the server cache (needs internet once; may take minutes). */ + suspend fun downloadModel(model: String): String = withContext(Dispatchers.IO) { + ensureConnected() + val base = currentOrigin() + executeAuthed(base) { token -> + post(base, "/api/v1/models/$model/download", token, "{}") + }.use { resp -> + when (resp.code) { + 200 -> resp.body?.string().orEmpty() + 422 -> throw ProviderError.InvalidUrl("Unsupported model: $model") + 501 -> throw ProviderError.Transient("faster-whisper is not installed on the server") + else -> throw ProviderError.Transient("Model download failed (HTTP ${resp.code})") + } + } + } + // ---- sidecars (local cache until the backend gains endpoints in M7) ---- private fun sidecarDir(ref: RemoteRef) = File(sidecarRoot, ref.key) @@ -338,7 +462,7 @@ class CustomShonarProvider( private suspend fun login(base: String, email: String, password: String) { val body = """{"email":${jsonStr(email)},"password":${jsonStr(password)},""" + - """"device_name":"SHONAR Android","platform":"android"}""" + """"device_name":${jsonStr(deviceName)},"platform":${jsonStr(platform)}}""" postUnauthed(base, "/api/v1/auth/login", body).use { resp -> when (resp.code) { 200 -> { @@ -445,6 +569,16 @@ class CustomShonarProvider( .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())) .header("Authorization", "Bearer $access").build() + private fun put(base: String, path: String, access: String, json: String): Request = + Request.Builder().url(base + path) + .put(json.toRequestBody("application/json; charset=utf-8".toMediaType())) + .header("Authorization", "Bearer $access").build() + + private fun patch(base: String, path: String, access: String, json: String): Request = + Request.Builder().url(base + path) + .patch(json.toRequestBody("application/json; charset=utf-8".toMediaType())) + .header("Authorization", "Bearer $access").build() + private fun postUnauthed(base: String, path: String, json: String): Response { val req = Request.Builder().url(base + path) .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build() @@ -453,10 +587,16 @@ class CustomShonarProvider( // 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)}}""" + val body = buildString { + append("""{"declared_mime_type":${jsonStr(draft.mime)},""") + append(""""declared_size_bytes":${draft.sizeBytes},""") + append(""""title":${jsonStr(draft.title)},""") + append(""""client_recording_id":${jsonStr(draft.id)}""") + if (draft.transcriptionModel != null) { + append(""","transcription_model":${jsonStr(draft.transcriptionModel)}""") + } + append("}") + } executeAuthed(base) { token -> post(base, "/api/v1/uploads", token, body) }.use { resp -> when (resp.code) { 201 -> { @@ -494,8 +634,14 @@ class CustomShonarProvider( 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}}""" + val body = buildString { + append("""{"recorded_at":${jsonStr(recordedAt)},""") + append(""""duration_seconds":${draft.durationMs / 1000.0}""") + if (draft.transcriptionModel != null) { + append(""","transcription_model":${jsonStr(draft.transcriptionModel)}""") + } + append("}") + } executeAuthed(base) { token -> post(base, "/api/v1/uploads/$sessionId/finalize", token, body) } .use { resp -> if (resp.code != 201) { diff --git a/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt b/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt index b4269c8..eb002d7 100644 --- a/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt +++ b/android/app/src/main/java/com/shonar/provider/FolderSyncProvider.kt @@ -73,6 +73,39 @@ class FolderSyncProvider( _authState.value = AuthState.CONNECTED } + /** + * Create the folder, then connect. Safety rules (a typo must never + * spray directories across storage): + * - no `..` segments, never a blank path; + * - the direct parent must already exist, be a directory, and be + * writable — only the final segment is ever created; + * - when the folder already exists this is just [connect]. + */ + suspend fun createRoot(rawPath: String) = withContext(Dispatchers.IO) { + val trimmed = rawPath.trim() + if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty") + val dir = File(trimmed) + if (".." in dir.path.split(File.separatorChar)) { + throw ProviderError.InvalidUrl("Folder path must not contain '..'") + } + if (!dir.exists()) { + val parent = dir.absoluteFile.parentFile + ?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed") + if (!parent.isDirectory) throw ProviderError.InvalidUrl( + "Parent is not a folder: ${parent.path}" + ) + if (!parent.canWrite()) throw ProviderError.InvalidUrl( + "Parent folder is not writable: ${parent.path}" + ) + if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl( + "Could not create folder: ${dir.path}" + ) + } + val checked = checkDir(dir) + pathStore.putString(KEY_ROOT, checked.canonicalPath) + _authState.value = AuthState.CONNECTED + } + override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) { val root = storedRoot() if (root == null) { @@ -146,10 +179,11 @@ class FolderSyncProvider( 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.", + detail = "${audio.size} recordings — synced by your sync tool, not by this app.", syncedCount = 0, localOnlyCount = audio.size, bytesUsed = audio.sumOf { it.length() }, + path = r.path, ) } diff --git a/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt b/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt index 8b34815..2e2a3d0 100644 --- a/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt +++ b/android/app/src/main/java/com/shonar/provider/LocalOnlyProvider.kt @@ -8,9 +8,17 @@ import java.io.File * Local-only provider: everything stays in app-private storage. First-class * so the app has no "no server" special case. No networking code paths at * all — provable by construction (this file imports no HTTP library). + * + * The storage root is the app-private [defaultRoot], unless the user picked + * their own folder ([connect]/[createRoot] with a [ProviderCredential.FolderPath]): + * then files live there and [deleteAccountAndData] only forgets the path — + * a user folder is never deleted by this app. */ class LocalOnlyProvider( - private val root: File, + private val defaultRoot: File, + private val pathStore: com.shonar.settings.SettingsStore = + com.shonar.settings.InMemorySettingsStore(), + private val rootKey: String = KEY_ROOT, ) : ShonarProvider { override val descriptor = ProviderDescriptor( @@ -22,17 +30,93 @@ class LocalOnlyProvider( private val _authState = MutableStateFlow(AuthState.CONNECTED) override val authState: StateFlow = _authState + private suspend fun storedRoot(): File? { + val raw = pathStore.getString(rootKey) ?: return null + return File(raw).takeIf { it.exists() } + } + + /** App-private dir by default; the user's own folder once picked. */ + private suspend fun effectiveRoot(): File = storedRoot() ?: defaultRoot + + /** Where new recordings belong right now. Read by the recording repository. */ + suspend fun currentRoot(): File = effectiveRoot() + + private fun checkDir(dir: File): File { + if (".." in dir.path.split(File.separatorChar)) { + throw ProviderError.InvalidUrl("Folder path must not contain '..'") + } + if (!dir.exists()) throw ProviderError.InvalidUrl( + "Folder does not exist: ${dir.path}." + ) + if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}") + if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl( + "Folder is not readable and writable: ${dir.path}" + ) + return dir + } + override suspend fun probe(baseUrl: ServerUrl): ProbeResult = ProbeResult.Incompatible // local-only never talks to servers override suspend fun connect(credential: ProviderCredential) { - if (credential != ProviderCredential.None) throw ProviderError.InvalidUrl( - "Local-only storage takes no credentials" - ) + when (credential) { + ProviderCredential.None -> _authState.value = AuthState.CONNECTED + is ProviderCredential.FolderPath -> { + val dir = checkDir(File(credential.path)) + pathStore.putString(rootKey, dir.canonicalPath) + _authState.value = AuthState.CONNECTED + } + else -> throw ProviderError.InvalidUrl( + "Local-only storage takes no credentials" + ) + } + } + + /** + * Use the app-private folder again (forget a previously picked folder; + * its files stay where they are). + */ + suspend fun useDefaultRoot() { + pathStore.remove(rootKey) _authState.value = AuthState.CONNECTED } - override suspend fun reconnect(): AuthState = AuthState.CONNECTED.also { _authState.value = it } + /** + * Create the folder, then use it. Same safety rules as the sync + * folder: only the final segment is ever created, the parent must + * already exist, no `..`. When it exists this is just [connect]. + */ + suspend fun createRoot(rawPath: String) { + val trimmed = rawPath.trim() + if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty") + val dir = File(trimmed) + if (".." in dir.path.split(File.separatorChar)) { + throw ProviderError.InvalidUrl("Folder path must not contain '..'") + } + if (!dir.exists()) { + val parent = dir.absoluteFile.parentFile + ?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed") + if (!parent.isDirectory) throw ProviderError.InvalidUrl( + "Parent is not a folder: ${parent.path}" + ) + if (!parent.canWrite()) throw ProviderError.InvalidUrl( + "Parent folder is not writable: ${parent.path}" + ) + if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl( + "Could not create folder: ${dir.path}" + ) + } + val checked = checkDir(dir) + pathStore.putString(rootKey, checked.canonicalPath) + _authState.value = AuthState.CONNECTED + } + + override suspend fun reconnect(): AuthState { + // A picked folder may have been deleted out from under us; fall + // back to the app-private dir rather than stranding recordings. + _authState.value = AuthState.CONNECTED + return _authState.value + } override suspend fun disconnect(revokeOnServer: Boolean) { // Nothing to revoke; DISCONNECTED means "user left local mode" — the @@ -41,19 +125,25 @@ class LocalOnlyProvider( } override suspend fun deleteAccountAndData() { - if (root.exists()) root.deleteRecursively() + if (storedRoot() != null) { + // A user-picked folder belongs to the user — forget it, never + // delete it. + pathStore.remove(rootKey) + } else if (defaultRoot.exists()) { + defaultRoot.deleteRecursively() + } _authState.value = AuthState.DISCONNECTED } // ---- storage ----------------------------------------------------------- - private fun audioFile(ref: RemoteRef) = File(root, ref.key) - private fun sidecarFile(ref: RemoteRef, kind: SidecarKind) = - File(root, "sidecars/${ref.key}/${kind.fileName}") + private suspend fun audioFile(ref: RemoteRef) = File(effectiveRoot(), ref.key) + private suspend fun sidecarFile(ref: RemoteRef, kind: SidecarKind) = + File(effectiveRoot(), "sidecars/${ref.key}/${kind.fileName}") override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef { val key = "audio/${draft.id}" - val dest = File(root, key) + val dest = File(effectiveRoot(), key) dest.parentFile?.mkdirs() // "Upload" locally = copy; report progress in slices so UI behaves uniformly draft.sourceFile.inputStream().use { input -> @@ -82,11 +172,11 @@ class LocalOnlyProvider( override suspend fun delete(ref: RemoteRef) { audioFile(ref).delete() - File(root, "sidecars/${ref.key}").deleteRecursively() + File(effectiveRoot(), "sidecars/${ref.key}").deleteRecursively() } override suspend fun list(cursor: String?): Page { - val audioRoot = File(root, "audio") + val audioRoot = File(effectiveRoot(), "audio") val files = audioRoot.listFiles()?.filter { it.isFile } ?: emptyList() // single page; no paging for local storage val items = files.map { f -> @@ -113,18 +203,35 @@ class LocalOnlyProvider( } override suspend fun storageLocationSummary(): StorageLocation { - val audio = File(root, "audio").listFiles()?.filter { it.isFile } ?: emptyList() + val raw = pathStore.getString(rootKey) + val custom = storedRoot() + if (raw != null && custom == null) { + return StorageLocation( + headline = "Folder unavailable", + detail = "Your picked folder is gone or unreadable. " + + "Reconnect storage or pick again — new recordings use the app folder meanwhile.", + syncedCount = 0, + localOnlyCount = 0, + bytesUsed = 0, + path = raw, + ) + } + val r = custom ?: defaultRoot + val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList() return StorageLocation( - headline = "On this device only", - detail = "Recordings and transcripts never leave your phone.", + headline = if (custom != null) "Your folder" else "On this device only", + detail = if (custom != null) "Recordings stay in your folder and never leave your phone." + else "Recordings and transcripts never leave your phone.", syncedCount = 0, localOnlyCount = audio.size, bytesUsed = audio.sumOf { it.length() }, + path = r.absolutePath, ) } companion object { const val ID = "local-only" + const val KEY_ROOT = "provider.local-only.root" private fun File.sha256Hex(): String { val md = java.security.MessageDigest.getInstance("SHA-256") 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 9cf7c9b..d11895b 100644 --- a/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt +++ b/android/app/src/main/java/com/shonar/provider/ProviderRegistry.kt @@ -44,8 +44,9 @@ class ProviderRegistry( const val SYNC_FOLDER_ID = FolderSyncProvider.ID /** Behaviour-identical default: system trust, logging off, no pins. */ - fun defaultTls(): TlsPolicy = TlsPolicy( + fun defaultTls(sink: (String) -> Unit = {}): TlsPolicy = TlsPolicy( tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())), + sink = sink, ) /** @@ -77,7 +78,12 @@ class ProviderRegistry( val folder = FolderSyncProvider(pathStore = plainStore) return ProviderRegistry( mapOf( - LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) }, + LocalOnlyProvider.ID to { + LocalOnlyProvider( + java.io.File(appFilesDir, "shonar-local"), + pathStore = plainStore, + ) + }, CUSTOM_SHONAR_ID to { custom }, NEXTCLOUD_ID to { nextcloud }, SYNC_FOLDER_ID to { folder }, 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 d1a5e47..9bb8650 100644 --- a/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt +++ b/android/app/src/main/java/com/shonar/provider/ProviderTypes.kt @@ -180,6 +180,8 @@ data class RecordingDraft( val mime: String, // audio/mp4 | audio/wav | audio/ogg val sourceFile: File, val sizeBytes: Long, + /** Optional per-recording transcription model override (null = server default). */ + val transcriptionModel: String? = null, ) enum class SidecarKind(val fileName: String) { @@ -200,6 +202,8 @@ data class StorageLocation( val syncedCount: Int, val localOnlyCount: Int, val bytesUsed: Long, + /** Concrete on-device path, when the files live in a real folder. */ + val path: String? = null, ) /** Provider-level errors. Messages must never contain credentials/tokens. */ diff --git a/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt b/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt index f4d8178..73eff4f 100644 --- a/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt +++ b/android/app/src/main/java/com/shonar/provider/TlsPolicy.kt @@ -9,11 +9,15 @@ import okhttp3.Response * P5: builds every provider HTTP client with the TOFU trust manager and the * redacting logger (docs/server-providers.md §4). One place, so no call * site can accidentally build a client that skips either. + * + * The log [sink] is an explicit constructor argument (no platform default) + * so this file compiles on Android and JVM desktop alike; each platform + * passes its own sink (Logcat vs stdout). */ class TlsPolicy( val tofu: TofuManager, private val bodiesEnabled: () -> Boolean = { false }, - private val sink: (String) -> Unit = { msg -> android.util.Log.d("ShonarNet", msg) }, + private val sink: (String) -> Unit, ) { fun newClient( connectTimeoutS: Long, diff --git a/android/app/src/main/java/com/shonar/recording/AiContent.kt b/android/app/src/main/java/com/shonar/recording/AiContent.kt new file mode 100644 index 0000000..4f60ff7 --- /dev/null +++ b/android/app/src/main/java/com/shonar/recording/AiContent.kt @@ -0,0 +1,225 @@ +package com.shonar.recording + +import org.json.JSONArray +import org.json.JSONObject + +/** + * M8: AI content models for the recording details screen. + * + * Pure Kotlin + org.json — no Android imports, so this file runs under + * plain JVM unit tests. The backend shapes mirror + * `backend/shonar/api/schemas_recordings.py`: + * - transcript: {version, language, provider, model, text, + * segments: [{start, end, text, speaker}], edited_by_user} + * - summary: {version, provider, model, content: {short, detailed, + * key_points, decisions, action_items, questions}, edited_by_user} + * - jobs: [{job_type, status, attempt, max_attempts, error, ...}] + */ + +data class TranscriptSegment( + val startSec: Double, + val endSec: Double, + val text: String, + val speaker: String? = null, +) + +data class TranscriptData( + val version: Int, + val language: String? = null, + val provider: String = "", + val model: String? = null, + val text: String = "", + val segments: List = emptyList(), + val editedByUser: Boolean = false, +) + +data class SummaryData( + val version: Int, + val provider: String = "", + val model: String? = null, + /** Raw content dict; known keys read via [summaryString]/[summaryList]. */ + val content: Map = emptyMap(), + val editedByUser: Boolean = false, +) { + val short: String get() = summaryString(content, "short") + val detailed: String get() = summaryString(content, "detailed") + fun list(key: String): List = summaryList(content, key) +} + +data class JobInfo( + val jobType: String, + val status: String, + val attempt: Int = 0, + val maxAttempts: Int = 0, + val error: String? = null, + /** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */ + val stage: String? = null, + val progress: Int? = null, +) + +/** Summary content keys, in display order. */ +val SUMMARY_LIST_KEYS = listOf("key_points", "decisions", "action_items", "questions") + +/** + * Index of the segment playing at [positionMs]: the last segment whose + * start is at or before the position. -1 when there are no segments or + * playback precedes the first one. End times are ignored on purpose — + * gaps between segments keep the last-spoken line highlighted instead of + * flickering to nothing. + */ +fun activeSegmentIndex(segments: List, positionMs: Long): Int { + val posSec = positionMs / 1000.0 + var active = -1 + for (i in segments.indices) { + if (segments[i].startSec <= posSec) active = i else break + } + return active +} + +fun parseTranscript(raw: String): TranscriptData? = runCatching { + val json = JSONObject(raw) + val segments = mutableListOf() + json.optJSONArray("segments")?.let { arr -> + for (i in 0 until arr.length()) { + val s = arr.optJSONObject(i) ?: continue + segments += TranscriptSegment( + startSec = s.optDouble("start", 0.0), + endSec = s.optDouble("end", 0.0), + text = s.optString("text", ""), + speaker = s.optString("speaker", null).takeUnless { it.isNullOrBlank() }, + ) + } + } + segments.sortBy { it.startSec } + TranscriptData( + version = json.optInt("version", 0), + language = json.optString("language", null).takeUnless { it.isNullOrBlank() }, + provider = json.optString("provider", ""), + model = json.optString("model", null).takeUnless { it.isNullOrBlank() }, + text = json.optString("text", ""), + segments = segments, + editedByUser = json.optBoolean("edited_by_user", false), + ) +}.getOrNull() + +fun parseSummary(raw: String): SummaryData? = runCatching { + val json = JSONObject(raw) + val content = mutableMapOf() + json.optJSONObject("content")?.let { obj -> + for (key in obj.keys()) content[key] = obj.get(key) + } + SummaryData( + version = json.optInt("version", 0), + provider = json.optString("provider", ""), + model = json.optString("model", null).takeUnless { it.isNullOrBlank() }, + content = content, + editedByUser = json.optBoolean("edited_by_user", false), + ) +}.getOrNull() + +fun parseJobs(raw: String): List = runCatching { + val arr = JSONArray(raw) + (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + JobInfo( + jobType = o.optString("job_type", ""), + status = o.optString("status", ""), + attempt = o.optInt("attempt", 0), + maxAttempts = o.optInt("max_attempts", 0), + error = o.optString("error", null).takeUnless { it.isNullOrBlank() }, + stage = o.optString("stage", null).takeUnless { it.isNullOrBlank() }, + progress = if (o.has("progress") && !o.isNull("progress")) o.optInt("progress") else null, + ) + } +}.getOrDefault(emptyList()) + +/** Payload for PUT /recordings/{id}/transcript. Null segments = text-only correction. */ +fun transcriptUpdatePayload(text: String, segments: List? = null): String { + val obj = JSONObject().put("text", text) + if (segments != null) { + obj.put( + "segments", + JSONArray().also { arr -> + segments.forEach { s -> + arr.put( + JSONObject() + .put("start", s.startSec) + .put("end", s.endSec) + .put("text", s.text) + .let { o -> if (s.speaker != null) o.put("speaker", s.speaker) else o }, + ) + } + }, + ) + } + return obj.toString() +} + +/** Payload for PUT /recordings/{id}/summary. */ +fun summaryUpdatePayload(content: Map): String = + JSONObject().put("content", JSONObject(content)).toString() + +/** Payload for PATCH /recordings/{id} (title/notes rename from details). */ +fun recordingPatchPayload(title: String? = null, notes: String? = null): String { + val obj = JSONObject() + if (title != null) obj.put("title", title) + if (notes != null) obj.put("notes", notes) + return obj.toString() +} + +// ---- transcription models (Stage 1: GET /api/v1/models) ------------------------ + +data class TranscriptionModelInfo( + val name: String, + val displayName: String, + val description: String, + val params: String = "", + val approxMemory: String = "", + val relativeSpeed: String = "", + val isDefault: Boolean = false, + val downloaded: Boolean = false, + val available: Boolean = false, +) + +data class ModelsData( + val defaultModel: String, + val fasterWhisperInstalled: Boolean, + val models: List = emptyList(), +) { + fun byName(name: String): TranscriptionModelInfo? = models.firstOrNull { it.name == name } +} + +fun parseModels(raw: String): ModelsData? = runCatching { + val json = JSONObject(raw) + val models = mutableListOf() + json.optJSONArray("models")?.let { arr -> + for (i in 0 until arr.length()) { + val o = arr.optJSONObject(i) ?: continue + models += TranscriptionModelInfo( + name = o.optString("name", ""), + displayName = o.optString("display_name", o.optString("name", "")), + description = o.optString("description", ""), + params = o.optString("params", ""), + approxMemory = o.optString("approx_memory", ""), + relativeSpeed = o.optString("relative_speed", ""), + isDefault = o.optBoolean("is_default", false), + downloaded = o.optBoolean("downloaded", false), + available = o.optBoolean("available", false), + ) + } + } + ModelsData( + defaultModel = json.optString("default_model", "base"), + fasterWhisperInstalled = json.optBoolean("faster_whisper_installed", false), + models = models.filter { it.name.isNotBlank() }, + ) +}.getOrNull() + +private fun summaryString(content: Map, key: String): String = + (content[key] as? String).orEmpty() + +private fun summaryList(content: Map, key: String): List = when (val v = content[key]) { + is JSONArray -> (0 until v.length()).mapNotNull { v.optString(it, null)?.takeIf { it.isNotBlank() } } + is List<*> -> v.mapNotNull { (it as? String)?.takeIf { it.isNotBlank() } } + else -> emptyList() +} diff --git a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt index 822e0e3..66c0a54 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt @@ -1,6 +1,5 @@ package com.shonar.recording -import android.content.Context import com.shonar.provider.RecordingDraft import com.shonar.provider.RemoteRef import com.shonar.provider.SyncState @@ -11,18 +10,82 @@ import java.util.Date import java.util.UUID class RecordingRepository( - private val context: Context, + appFilesDir: File, private val dao: RecordingDao, + /** + * Where new recordings go. Defaults to the app-private library; the app + * wires the active local provider's picked folder here. Cached in + * [libraryRoot] (refreshed via [refreshRoot]) so the recording service — + * which must hand MediaRecorder a path synchronously — never suspends. + */ + private val rootResolver: (suspend () -> File?)? = null, ) { private val slots = SyncSlots(dao) - private val recordingsDir = File(context.filesDir, "recordings").apply { mkdirs() } - private val tempDir = File(recordingsDir, ".in-progress").apply { mkdirs() } + private val sandboxDir: File = appFilesDir + private val legacyDir = File(sandboxDir, "recordings").apply { mkdirs() } + + @Volatile private var libraryRoot: File = legacyDir + private val recordingsDir: File get() = libraryRoot + private fun tempDir(): File = File(libraryRoot, ".in-progress").apply { mkdirs() } val recordings: Flow> = dao.observeAll() fun newRecordingId(): String = UUID.randomUUID().toString() - fun tempFile(id: String): File = File(tempDir, "$id.m4a") + /** Re-read the destination (call at startup and after a location change). */ + suspend fun refreshRoot() { + libraryRoot = runCatching { rootResolver?.invoke() }.getOrNull() ?: legacyDir + libraryRoot.mkdirs() + importExistingFiles() + } + + /** + * Adopt audio files already sitting in the library folder (put there by + * another app, USB, …). Flat scan, top level only; dotfiles and the + * in-progress dir are skipped. Files already tracked by path are left + * alone — renaming a file re-imports it as new, which is honest. + * Nothing is copied, moved, or modified. + */ + suspend fun importExistingFiles() { + val root = libraryRoot + val known = dao.getAll().mapNotNullTo(mutableSetOf()) { + runCatching { File(it.filePath).canonicalPath }.getOrNull() + } + val files = root.listFiles() + ?.filter { f -> + f.isFile && !f.name.startsWith(".") && + f.extension.lowercase() in AUDIO_MIME_BY_EXTENSION && + runCatching { f.canonicalPath }.getOrNull()?.let { it !in known } == true + } + ?.sortedBy { it.name.lowercase() } + .orEmpty() + for (f in files) { + val ext = f.extension.lowercase() + dao.insert( + RecordingEntity( + id = UUID.randomUUID().toString(), + title = f.nameWithoutExtension, + createdAtEpochMs = f.lastModified().takeIf { it > 0 } + ?: System.currentTimeMillis(), + durationMs = audioDurationMs(f).coerceAtLeast(1L), + filePath = f.absolutePath, + mimeType = AUDIO_MIME_BY_EXTENSION[ext] ?: "audio/mp4", + sizeBytes = f.length(), + ), + ) + known += runCatching { f.canonicalPath }.getOrNull() ?: f.absolutePath + } + } + + private fun audioDurationMs(f: File): Long = runCatching { + android.media.MediaMetadataRetriever().use { r -> + r.setDataSource(f.absolutePath) + r.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() ?: 0L + } + }.getOrDefault(0L) + + fun tempFile(id: String): File = File(tempDir(), "$id.m4a") suspend fun finish( id: String, @@ -49,10 +112,45 @@ class RecordingRepository( ) } + /** + * Rename the library entry AND its audio file (same folder, extension + * kept). A colliding name gets a " (2)" suffix — nothing is ever + * overwritten. Throws on a blank/invalid name or a missing file. + */ + suspend fun rename(recording: RecordingEntity, newTitle: String): RecordingEntity { + val stem = sanitizeFileStem(newTitle) + ?: throw IllegalArgumentException("Not a valid name.") + val current = File(recording.filePath) + require(current.isFile) { "Original file is gone." } + val parent = current.parentFile + ?: throw IllegalArgumentException("Cannot rename here.") + val ext = current.extension.ifBlank { "m4a" } + var target = File(parent, "$stem.$ext") + var n = 1 + while (target.exists() && target.canonicalPath != current.canonicalPath) { + n++ + target = File(parent, "$stem ($n).$ext") + } + if (target.canonicalPath != current.canonicalPath && !current.renameTo(target)) { + throw java.io.IOException("Could not rename the file.") + } + val updated = recording.copy( + title = target.nameWithoutExtension, + filePath = target.absolutePath, + sizeBytes = target.length(), + ) + dao.update(updated) + return updated + } + suspend fun delete(recording: RecordingEntity) { - val canonicalRoot = recordingsDir.canonicalFile val canonicalFile = File(recording.filePath).canonicalFile - require(canonicalFile.toPath().startsWith(canonicalRoot.toPath())) { "Invalid recording path" } + // Deletable when it lives under the app sandbox or the current + // library folder (older clips may predate a location change). + val allowed = listOf(sandboxDir.canonicalFile, libraryRoot.canonicalFile) + require(allowed.any { canonicalFile.toPath().startsWith(it.toPath()) }) { + "Invalid recording path" + } canonicalFile.delete() dao.delete(recording) } @@ -73,4 +171,30 @@ class RecordingRepository( * nothing remote is touched — forgetting is metadata-only. */ suspend fun forgetAllRemotes(newOriginId: String) = slots.forgetAllRemotes(newOriginId) + + companion object { + /** Extensions adopted by [importExistingFiles] (mirrors backend magic check). */ + val AUDIO_MIME_BY_EXTENSION = mapOf( "m4a" to "audio/mp4", + "mp4" to "audio/mp4", + "aac" to "audio/aac", + "wav" to "audio/wav", + "ogg" to "audio/ogg", + "opus" to "audio/ogg", + "webm" to "audio/webm", + "mp3" to "audio/mpeg", + ) + + /** + * Clean a user-typed name into a safe single file stem, or null when + * it has nothing usable. Pure — unit-tested. + */ + fun sanitizeFileStem(raw: String): String? { + var stem = raw.trim().replace(Regex("[/\\\\]"), "_") + .replace(Regex("\\p{Cntrl}"), "") + .replace(Regex("\\s+"), " ").trim() + if (stem.isEmpty() || stem == "." || stem == "..") return null + if (stem.length > 120) stem = stem.take(120).trimEnd() + return stem.ifEmpty { null } + } + } } diff --git a/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt b/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt new file mode 100644 index 0000000..ccc3630 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt @@ -0,0 +1,507 @@ +package com.shonar.ui.detail + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.shonar.ShonarApplication +import com.shonar.recording.SUMMARY_LIST_KEYS +import com.shonar.recording.SummaryData +import com.shonar.recording.TranscriptData +import com.shonar.recording.activeSegmentIndex + +/** + * M8 recording details: playback synced to the transcript, summary with + * action items, processing status, and editing. Everything server-side + * degrades to an honest empty state — playback and rename always work. + */ +@Composable +fun DetailsScreen(recordingId: String, onBack: () -> Unit) { + val context = LocalContext.current + val app = context.applicationContext as ShonarApplication + val vm: DetailsViewModel = viewModel(factory = DetailsViewModel.factory(app, recordingId)) + val uiState by vm.state.collectAsState() + + Scaffold { padding -> + Column( + Modifier.fillMaxSize().padding(padding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + when (val s = uiState) { + is DetailsViewModel.UiState.ReadyState -> TitleRow( + title = s.ready.recording.title, + onRename = vm::rename, + ) + else -> Text("Details", style = MaterialTheme.typography.titleLarge) + } + } + when (val s = uiState) { + DetailsViewModel.UiState.Loading -> LoadingRow() + is DetailsViewModel.UiState.Error -> Text( + s.message, color = MaterialTheme.colorScheme.error + ) + is DetailsViewModel.UiState.ReadyState -> ReadyBody(vm = vm, ready = s.ready) + } + } + } +} + +@Composable +private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) { + var editing by remember { mutableStateOf(false) } + var draft by remember(title) { mutableStateOf(title) } + Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { + Text( + title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { editing = true }) { + Icon(Icons.Filled.Edit, contentDescription = "Rename") + } + } + if (editing) { + AlertDialog( + onDismissRequest = { editing = false }, + title = { Text("Rename recording") }, + text = { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { onRename(draft); editing = false }) { Text("Save") } + }, + dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun ColumnScope.ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) { + var tab by remember { mutableIntStateOf(0) } + PlaybackCard(ready = ready, onToggle = vm::togglePlayback, + onSeek = vm::seekTo, onSpeed = vm::cycleSpeed) + ready.playbackError?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Transcript") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Summary") }) + Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Status") }) + } + when (tab) { + 0 -> TranscriptTab( + transcript = ready.transcript, + ai = ready.ai, + positionMs = ready.positionMs, + canEdit = ready.remoteId != null, + busy = ready.busy, + saveError = ready.saveError, + onSeek = vm::seekTo, + onSave = vm::saveTranscript, + onDismissError = vm::dismissSaveError, + ) + 1 -> SummaryTab( + summary = ready.summary, + ai = ready.ai, + canEdit = ready.remoteId != null, + busy = ready.busy, + saveError = ready.saveError, + onSave = vm::saveSummary, + onDismissError = vm::dismissSaveError, + ) + else -> StatusTab(ready = ready) + } +} + +@Composable +private fun PlaybackCard( + ready: DetailsViewModel.Ready, + onToggle: () -> Unit, + onSeek: (Long) -> Unit, + onSpeed: () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Row( + Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onToggle) { + Icon( + if (ready.playing) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (ready.playing) "Pause" else "Play", + ) + } + Column(Modifier.weight(1f)) { + Slider( + value = ready.positionMs.toFloat(), + onValueChange = { onSeek(it.toLong()) }, + valueRange = 0f..ready.durationMs.coerceAtLeast(1).toFloat(), + ) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(formatMs(ready.positionMs), style = MaterialTheme.typography.bodySmall) + Text(formatMs(ready.durationMs), style = MaterialTheme.typography.bodySmall) + } + } + TextButton(onClick = onSpeed) { Text("${ready.speed}×") } + } + } +} + +@Composable +private fun ColumnScope.TranscriptTab( + transcript: TranscriptData?, + ai: DetailsViewModel.AiState, + positionMs: Long, + canEdit: Boolean, + busy: String?, + saveError: String?, + onSeek: (Long) -> Unit, + onSave: (String) -> Unit, + onDismissError: () -> Unit, +) { + var editing by remember { mutableStateOf(false) } + when { + transcript == null -> AiEmptyState(ai, what = "transcript") + transcript.segments.isEmpty() -> { + Text(transcript.text.ifBlank { "Transcript is empty." }) + EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true }) + } + else -> { + val active = activeSegmentIndex(transcript.segments, positionMs) + val listState = rememberLazyListState() + LaunchedEffect(active) { + if (active >= 0) listState.animateScrollToItem(active) + } + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + (if (transcript.editedByUser) "Edited by you" else "Auto transcript") + + " · v${transcript.version}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true }) + } + LazyColumn( + Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(transcript.segments, key = { i, s -> "$i-${s.startSec}" }) { i, seg -> + val isActive = i == active + Card( + colors = if (isActive) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + } else { + CardDefaults.cardColors() + }, + modifier = Modifier.fillMaxWidth() + .clickable { onSeek((seg.startSec * 1000).toLong()) }, + ) { + Column(Modifier.padding(10.dp)) { + Text( + formatMs((seg.startSec * 1000).toLong()) + + (seg.speaker?.let { " · $it" } ?: ""), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(seg.text, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + } + } + saveError?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.clickable { onDismissError() }) + } + if (editing && transcript != null) { + var draft by remember(transcript.version) { mutableStateOf(transcript.text) } + AlertDialog( + onDismissRequest = { editing = false }, + title = { Text("Edit transcript") }, + text = { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.fillMaxWidth().height(220.dp), + ) + }, + confirmButton = { + TextButton( + onClick = { onSave(draft); editing = false }, + enabled = draft.isNotBlank(), + ) { Text("Save") } + }, + dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } }, + ) + } +} + +@Composable +private fun ColumnScope.SummaryTab( + summary: SummaryData?, + ai: DetailsViewModel.AiState, + canEdit: Boolean, + busy: String?, + saveError: String?, + onSave: (Map) -> Unit, + onDismissError: () -> Unit, +) { + var editing by remember { mutableStateOf(false) } + if (summary == null) { + AiEmptyState(ai, what = "summary") + return + } + LazyColumn( + Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + (if (summary.editedByUser) "Edited by you" else "AI summary") + + " · v${summary.version}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true }) + } + } + if (summary.short.isNotBlank()) { + item { + SummarySection(title = "Summary", body = summary.short) + } + } + if (summary.detailed.isNotBlank()) { + item { + SummarySection(title = "Details", body = summary.detailed) + } + } + SUMMARY_LIST_KEYS.forEach { key -> + val items = summary.list(key) + if (items.isNotEmpty()) { + item { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + key.replace('_', ' ').replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall, + ) + items.forEach { Bullet(it) } + } + } + } + } + } + saveError?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.clickable { onDismissError() }) + } + if (editing) { + SummaryEditDialog(summary = summary, onDismiss = { editing = false }, onSave = { + onSave(it); editing = false + }) + } +} + +@Composable +private fun SummaryEditDialog( + summary: SummaryData, + onDismiss: () -> Unit, + onSave: (Map) -> Unit, +) { + var short by remember { mutableStateOf(summary.short) } + var detailed by remember { mutableStateOf(summary.detailed) } + var actionItems by remember { mutableStateOf(summary.list("action_items").joinToString("\n")) } + var keyPoints by remember { mutableStateOf(summary.list("key_points").joinToString("\n")) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Edit summary") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(value = short, onValueChange = { short = it }, + label = { Text("Short") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = detailed, onValueChange = { detailed = it }, + label = { Text("Detailed") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = actionItems, onValueChange = { actionItems = it }, + label = { Text("Action items (one per line)") }, + modifier = Modifier.fillMaxWidth()) + OutlinedTextField(value = keyPoints, onValueChange = { keyPoints = it }, + label = { Text("Key points (one per line)") }, + modifier = Modifier.fillMaxWidth()) + } + }, + confirmButton = { + TextButton(onClick = { + val content = summary.content.toMutableMap() + content["short"] = short + content["detailed"] = detailed + content["action_items"] = linesOf(actionItems) + content["key_points"] = linesOf(keyPoints) + onSave(content) + }) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +private fun linesOf(raw: String): List = + raw.lines().map { it.trim() }.filter { it.isNotEmpty() } + +@Composable +private fun StatusTab(ready: DetailsViewModel.Ready) { + val jobs = (ready.ai as? DetailsViewModel.AiState.Ready)?.jobs.orEmpty() + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Sync: ${ready.recording.syncState}" + + (ready.recording.syncReason?.let { " ($it)" } ?: "")) + if (jobs.isEmpty()) { + Text("No processing jobs reported.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + jobs.forEach { job -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(10.dp)) { + Text("${job.jobType}: ${job.status}", + style = MaterialTheme.typography.titleSmall) + if (job.error != null) { + Text(job.error, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error) + } else { + Text("attempt ${job.attempt}/${job.maxAttempts}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } + } +} + +@Composable +private fun AiEmptyState(ai: DetailsViewModel.AiState, what: String) { + when (ai) { + DetailsViewModel.AiState.Loading -> LoadingRow() + is DetailsViewModel.AiState.Unavailable -> Text( + ai.reason.ifBlank { "No $what yet." }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + is DetailsViewModel.AiState.Error -> Text( + ai.message, + color = MaterialTheme.colorScheme.error, + ) + is DetailsViewModel.AiState.Ready -> Text( + "No $what yet — processing may still be running. Check Status.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(4.dp)) +} + +@Composable +private fun EditRow(canEdit: Boolean, busy: String?, onEdit: () -> Unit) { + if (!canEdit) return + if (busy != null) { + Text(busy, style = MaterialTheme.typography.labelMedium) + } else { + TextButton(onClick = onEdit) { Text("Edit") } + } +} + +@Composable +private fun SummarySection(title: String, body: String) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(body, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun Bullet(text: String) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("•", style = MaterialTheme.typography.bodyMedium) + Text(text, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun LoadingRow() { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { CircularProgressIndicator() } +} + +private fun formatMs(ms: Long): String { + val total = (ms / 1000).coerceAtLeast(0) + return "%d:%02d".format(total / 60, total % 60) +} diff --git a/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt b/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt new file mode 100644 index 0000000..5388d34 --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/detail/DetailsViewModel.kt @@ -0,0 +1,356 @@ +package com.shonar.ui.detail + +import android.media.MediaPlayer +import android.media.PlaybackParams +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.shonar.ShonarApplication +import com.shonar.provider.AuthState +import com.shonar.provider.CustomShonarProvider +import com.shonar.provider.ProviderError +import com.shonar.provider.ProviderRegistry +import com.shonar.recording.JobInfo +import com.shonar.recording.RecordingEntity +import com.shonar.recording.SummaryData +import com.shonar.recording.TranscriptData +import com.shonar.recording.parseJobs +import com.shonar.recording.parseSummary +import com.shonar.recording.parseTranscript +import com.shonar.recording.recordingPatchPayload +import com.shonar.recording.summaryUpdatePayload +import com.shonar.recording.transcriptUpdatePayload +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * M8 details screen: local playback (seek + speed) with the server transcript + * synced to the playhead, summary/action items, processing jobs, and editing. + * + * AI content lives on the custom SHONAR server (M7 endpoints) and is + * addressable only after upload — the local row's [RecordingEntity.remoteKey] + * is the server recording id. Anything else (local-only, not yet synced, + * other providers) gets an honest [AiState.Unavailable] instead of a + * spinner: playback and title/notes editing always work offline. + */ +class DetailsViewModel( + private val app: ShonarApplication, + private val recordingId: String, +) : ViewModel() { + + sealed interface AiState { + data object Loading : AiState + data class Ready(val jobs: List) : AiState + /** No transcript/summary yet, or AI not applicable here. */ + data class Unavailable(val reason: String) : AiState + data class Error(val message: String) : AiState + } + + data class Ready( + val recording: RecordingEntity, + val playing: Boolean = false, + val positionMs: Long = 0, + val durationMs: Long = 0, + val speed: Float = 1f, + val playbackError: String? = null, + val transcript: TranscriptData? = null, + val summary: SummaryData? = null, + val ai: AiState = AiState.Loading, + /** Null remote id = editing goes to Room only; non-null = also PUT/PATCH server. */ + val remoteId: String? = null, + val busy: String? = null, + val saveError: String? = null, + ) + + sealed interface UiState { + data object Loading : UiState + data class Error(val message: String) : UiState + data class ReadyState(val ready: Ready) : UiState + } + + private val dao = app.database.recordingDao() + private val registry = app.providerRegistry + private val settings = app.settingsManager + + private val _state = MutableStateFlow(UiState.Loading) + val state: StateFlow = _state.asStateFlow() + + private var player: MediaPlayer? = null + private var pollJob: Job? = null + + companion object { + val SPEEDS = listOf(0.75f, 1f, 1.25f, 1.5f, 2f) + + fun factory(app: ShonarApplication, recordingId: String): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + DetailsViewModel(app, recordingId) as T + } + } + + init { + refresh() + } + + fun refresh() { + viewModelScope.launch { + _state.value = UiState.Loading + val entity = dao.getById(recordingId) + if (entity == null) { + _state.value = UiState.Error("Recording not found.") + return@launch + } + val ready = Ready(recording = entity, durationMs = entity.durationMs) + _state.value = UiState.ReadyState(ready) + loadAi(entity) + } + } + + // ---- AI content ---------------------------------------------------------- + + private suspend fun loadAi(entity: RecordingEntity) { + val cur = current() ?: return + if (entity.originProviderId != ProviderRegistry.CUSTOM_SHONAR_ID || entity.remoteKey == null) { + val reason = if (entity.remoteKey == null && + entity.originProviderId == ProviderRegistry.CUSTOM_SHONAR_ID + ) { + "Not uploaded yet — transcript and summary appear after sync + processing." + } else { + "AI transcript and summary live on a SHONAR server — " + + "connect one and sync this recording to see them here." + } + _state.value = UiState.ReadyState(cur.copy(ai = AiState.Unavailable(reason))) + return + } + val provider = registry.provider(entity.originProviderId) as? CustomShonarProvider + if (provider == null) { + _state.value = UiState.ReadyState( + cur.copy(ai = AiState.Unavailable("This provider has no AI content.")) + ) + return + } + // Best effort: stored tokens may have expired while the app was away. + if (provider.authState.value != AuthState.CONNECTED) { + runCatching { provider.reconnect() } + } + if (provider.authState.value != AuthState.CONNECTED) { + _state.value = UiState.ReadyState( + cur.copy(ai = AiState.Error("Not connected — reconnect, then reopen this recording.")) + ) + return + } + val remoteId = entity.remoteKey + val transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } + .getOrElse { return setAiError(cur, "Could not load transcript.") } + val summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) } + .getOrElse { return setAiError(cur, "Could not load summary.") } + val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrDefault(emptyList()) + _state.value = UiState.ReadyState( + cur.copy( + transcript = transcript, + summary = summary, + ai = AiState.Ready(jobs), + remoteId = remoteId, + ) + ) + } + + private fun setAiError(cur: Ready, message: String) { + _state.value = UiState.ReadyState(cur.copy(ai = AiState.Error(message))) + } + + fun saveTranscript(text: String) { + val cur = current() ?: return + val remoteId = cur.remoteId ?: return + val provider = registry.provider(cur.recording.originProviderId) as? CustomShonarProvider + ?: return + viewModelScope.launch { + _state.value = UiState.ReadyState(cur.copy(busy = "Saving transcript…", saveError = null)) + val result = runCatching { + provider.updateTranscript(remoteId, transcriptUpdatePayload(text.trim())) + } + val parsed = result.getOrNull()?.let { parseTranscript(it) } + if (parsed != null) { + _state.value = UiState.ReadyState(current()!!.copy(transcript = parsed, busy = null)) + } else { + _state.value = UiState.ReadyState( + current()!!.copy(busy = null, saveError = "Save failed — kept your text, try again.") + ) + } + } + } + + fun saveSummary(content: Map) { + val cur = current() ?: return + val remoteId = cur.remoteId ?: return + val provider = registry.provider(cur.recording.originProviderId) as? CustomShonarProvider + ?: return + viewModelScope.launch { + _state.value = UiState.ReadyState(cur.copy(busy = "Saving summary…", saveError = null)) + val result = runCatching { + provider.updateSummary(remoteId, summaryUpdatePayload(content)) + } + val parsed = result.getOrNull()?.let { parseSummary(it) } + if (parsed != null) { + _state.value = UiState.ReadyState(current()!!.copy(summary = parsed, busy = null)) + } else { + _state.value = UiState.ReadyState( + current()!!.copy(busy = null, saveError = "Save failed — try again.") + ) + } + } + } + + fun rename(title: String) { + val cur = current() ?: return + val trimmed = title.trim() + if (trimmed.isEmpty() || trimmed == cur.recording.title) return + viewModelScope.launch { + // Renames the library entry AND its audio file. + val updated = runCatching { + app.recordingRepository.rename(cur.recording, trimmed) + }.getOrElse { + _state.value = UiState.ReadyState( + cur.copy(saveError = it.message ?: "Rename failed.") + ) + return@launch + } + _state.value = UiState.ReadyState( + (current() ?: cur).copy(recording = updated) + ) + val remoteId = (current() ?: cur).remoteId ?: return@launch + val provider = registry.provider(updated.originProviderId) as? CustomShonarProvider + ?: return@launch + runCatching { + provider.patchRecording(remoteId, recordingPatchPayload(title = updated.title)) + } + } + } + + fun dismissSaveError() { + val cur = current() ?: return + _state.value = UiState.ReadyState(cur.copy(saveError = null)) + } + + // ---- playback ------------------------------------------------------------ + + fun togglePlayback() { + val cur = current() ?: return + val p = player + if (p != null) { + if (p.isPlaying) { + p.pause() + stopPolling() + _state.value = UiState.ReadyState(cur.copy(playing = false, positionMs = p.currentPosition.toLong())) + } else { + applySpeed(p, cur.speed) + p.start() + startPolling() + _state.value = UiState.ReadyState(cur.copy(playing = true)) + } + return + } + viewModelScope.launch { + val fresh = (current() ?: return@launch).copy(playbackError = null) + _state.value = UiState.ReadyState(fresh) + val mp = runCatching { + MediaPlayer().apply { + setDataSource(fresh.recording.filePath) + setOnPreparedListener { prepared -> + applySpeed(prepared, fresh.speed) + prepared.start() + startPolling() + _state.value = UiState.ReadyState( + (current() ?: fresh).copy( + playing = true, + durationMs = prepared.duration.toLong().coerceAtLeast(1), + ) + ) + } + setOnCompletionListener { + stopPolling() + _state.value = UiState.ReadyState( + (current() ?: fresh).copy(playing = false, positionMs = 0) + ) + it.seekTo(0) + } + setOnErrorListener { _, _, _ -> + stopPolling() + _state.value = UiState.ReadyState( + (current() ?: fresh).copy( + playing = false, + playbackError = "Could not play this file.", + ) + ) + true + } + prepareAsync() + } + }.getOrNull() + if (mp == null) { + _state.value = UiState.ReadyState(fresh.copy(playbackError = "Could not play this file.")) + } else { + player = mp + } + } + } + + fun seekTo(positionMs: Long) { + val cur = current() ?: return + val duration = cur.durationMs.coerceAtLeast(1) + val clamped = positionMs.coerceIn(0, duration) + player?.seekTo(clamped.toInt()) + _state.value = UiState.ReadyState(cur.copy(positionMs = clamped)) + } + + fun cycleSpeed() { + val cur = current() ?: return + val next = SPEEDS[(SPEEDS.indexOf(cur.speed) + 1).coerceAtLeast(0) % SPEEDS.size] + player?.let { applySpeed(it, next) } + _state.value = UiState.ReadyState(cur.copy(speed = next)) + } + + private fun applySpeed(p: MediaPlayer, speed: Float) { + runCatching { + p.playbackParams = (p.playbackParams ?: PlaybackParams()).apply { this.speed = speed } + } + } + + private fun startPolling() { + pollJob?.cancel() + pollJob = viewModelScope.launch { + while (true) { + delay(250) + val p = player ?: break + if (!p.isPlaying) break + val cur = current() ?: break + _state.value = UiState.ReadyState( + cur.copy( + positionMs = p.currentPosition.toLong(), + durationMs = runCatching { p.duration.toLong() } + .getOrDefault(cur.durationMs).coerceAtLeast(1), + ) + ) + } + } + } + + private fun stopPolling() { + pollJob?.cancel() + pollJob = null + } + + private fun current(): Ready? = (_state.value as? UiState.ReadyState)?.ready + + override fun onCleared() { + stopPolling() + player?.release() + player = null + super.onCleared() + } +} diff --git a/android/app/src/main/java/com/shonar/ui/folder/FolderBrowserScreen.kt b/android/app/src/main/java/com/shonar/ui/folder/FolderBrowserScreen.kt new file mode 100644 index 0000000..08adbac --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/folder/FolderBrowserScreen.kt @@ -0,0 +1,223 @@ +package com.shonar.ui.folder + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import java.io.File + +/** + * Pick one of your own folders: browse shared storage, create a folder + * where you want it, and hand the path back. The caller (provider setup) + * still runs its own validation — the browser never writes recordings. + */ +@Composable +fun FolderBrowserScreen( + onPick: (String) -> Unit, + onBack: () -> Unit, +) { + val context = LocalContext.current + val lifecycle = LocalLifecycleOwner.current.lifecycle + + fun hasAccess(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) == + PackageManager.PERMISSION_GRANTED + } + + var access by remember { mutableStateOf(hasAccess()) } + // Re-check when returning from the Settings grant screen. + DisposableEffect(lifecycle) { + val obs = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) access = hasAccess() + } + lifecycle.addObserver(obs) + onDispose { lifecycle.removeObserver(obs) } + } + + val settingsLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { access = hasAccess() } + val legacyPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> if (granted) access = true } + + fun requestAccess() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + settingsLauncher.launch( + Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:${context.packageName}"), + ) + ) + } else { + legacyPermission.launch(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + Scaffold { padding -> + Column( + Modifier.fillMaxSize().padding(padding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + Text("Choose a folder", style = MaterialTheme.typography.titleLarge) + } + if (!access) { + Text( + "SHONAR needs file access so you can browse your own " + + "folders. Only the folder you pick is ever used.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = ::requestAccess, modifier = Modifier.fillMaxWidth()) { + Text("Grant file access") + } + } else { + BrowserBody(onPick = onPick) + } + } + } +} + +@Composable +private fun ColumnScope.BrowserBody(onPick: (String) -> Unit) { + val start = Environment.getExternalStorageDirectory()?.path ?: "/storage/emulated/0" + var current by remember { mutableStateOf(File(start)) } + var entries by remember { mutableStateOf(listChildDirs(current)) } + var error by remember { mutableStateOf(null) } + var showCreate by remember { mutableStateOf(false) } + + fun reload(dir: File = current) { + current = dir + error = null + entries = listChildDirs(dir) + if (!dir.canRead()) error = "Cannot read this folder." + } + + Text( + current.absolutePath, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { current.parentFile?.let { reload(it) } }, + enabled = current.parentFile != null, + modifier = Modifier.weight(1f), + ) { Text("Up") } + OutlinedButton( + onClick = { showCreate = true }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.CreateNewFolder, contentDescription = null) + Text(" New folder") + } + } + error?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + LazyColumn( + Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(entries, key = { it.path }) { entry -> + Row( + Modifier.fillMaxWidth().padding(vertical = 10.dp) + .clickable { reload(File(entry.path)) }, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Filled.Folder, contentDescription = null, + tint = MaterialTheme.colorScheme.primary) + Text(entry.name, modifier = Modifier.padding(start = 12.dp).weight(1f)) + Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open") + } + } + } + Button( + onClick = { onPick(current.absolutePath) }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Use this folder") } + + if (showCreate) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { showCreate = false }, + title = { Text("New folder") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Inside:\n${current.absolutePath}", + style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Folder name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + createChildDir(current, name) + .onSuccess { reload(it) } + .onFailure { error = it.message } + if (error == null) showCreate = false + }, + enabled = isValidFolderName(name), + ) { Text("Create") } + }, + dismissButton = { TextButton(onClick = { showCreate = false }) { Text("Cancel") } }, + ) + } +} diff --git a/android/app/src/main/java/com/shonar/ui/folder/FolderList.kt b/android/app/src/main/java/com/shonar/ui/folder/FolderList.kt new file mode 100644 index 0000000..975786b --- /dev/null +++ b/android/app/src/main/java/com/shonar/ui/folder/FolderList.kt @@ -0,0 +1,48 @@ +package com.shonar.ui.folder + +import java.io.File + +/** + * Pure filesystem helpers for the in-app folder browser. No Android + * imports — unit-testable on the JVM. + */ + +data class DirEntry(val name: String, val path: String) + +/** Visible child folders of [dir], sorted case-insensitively. Empty when unreadable. */ +fun listChildDirs(dir: File): List = runCatching { + if (!dir.isDirectory || !dir.canRead()) return emptyList() + dir.listFiles() + ?.filter { it.isDirectory && !it.name.startsWith(".") } + ?.sortedBy { it.name.lowercase() } + ?.map { DirEntry(it.name, it.absolutePath) } + .orEmpty() +}.getOrDefault(emptyList()) + +/** True when [name] is safe to create as a single folder segment. */ +fun isValidFolderName(name: String): Boolean { + val t = name.trim() + return t.isNotEmpty() && '/' !in t && '\\' !in t && t != "." && t != ".." +} + +/** + * Create [name] inside [parent]. Returns the new dir, or null with a + * human-readable [reason] when it refuses (bad name, missing parent, + * already-exists-as-file, mkdir failure). + */ +fun createChildDir(parent: File, name: String): Result { + val trimmed = name.trim() + if (!isValidFolderName(trimmed)) { + return Result.failure(IllegalArgumentException("Not a valid folder name.")) + } + if (!parent.isDirectory) { + return Result.failure(IllegalArgumentException("Parent is not a folder.")) + } + val child = File(parent, trimmed) + if (child.exists()) { + return if (child.isDirectory) Result.success(child) + else Result.failure(IllegalArgumentException("A file with that name already exists.")) + } + return if (child.mkdir()) Result.success(child) + else Result.failure(IllegalArgumentException("Could not create the folder.")) +} 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 d45deb8..ba87b0e 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 @@ -14,6 +14,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card @@ -21,6 +22,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -55,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel fun HomeScreen( onOpenSettings: () -> Unit, onOpenStorage: () -> Unit, + onOpenDetail: (String) -> Unit = {}, ) { val context = LocalContext.current val app = context.applicationContext as ShonarApplication @@ -62,9 +65,22 @@ fun HomeScreen( val recorderState by vm.recorderState.collectAsState() val recordings by vm.recordings.collectAsState() val playingId by vm.playingId.collectAsState() + val notice by vm.notice.collectAsState() val scope = rememberCoroutineScope() var consentSeen by remember { mutableStateOf(null) } var storageHeadline by remember { mutableStateOf(null) } + var storagePath by remember { mutableStateOf(null) } + var headlineTick by remember { mutableStateOf(0) } + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + // Returning from the folder picker resumes this screen without + // recomposition — re-read the headline so it shows the picked folder. + androidx.compose.runtime.DisposableEffect(lifecycleOwner) { + val obs = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) headlineTick++ + } + lifecycleOwner.lifecycle.addObserver(obs) + onDispose { lifecycleOwner.lifecycle.removeObserver(obs) } + } val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> if (granted) vm.start() } @@ -79,13 +95,20 @@ fun HomeScreen( } } - LaunchedEffect(Unit) { + LaunchedEffect(headlineTick) { app.settingsManager.ensureLoaded() consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT) - val pid = app.settingsManager.string(BuiltInSettings.PROVIDER_ID) + // Adopt files added outside the app (USB, downloads…) into the library. + runCatching { app.recordingRepository.refreshRoot() } + val pid = app.settingsManager.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" } val purl = app.settingsManager.string(BuiltInSettings.PROVIDER_URL) - storageHeadline = com.shonar.ui.provider.ProviderSelectionViewModel + // Live summary first (shows the picked folder); static label as fallback. + val summary = runCatching { + app.providerRegistry.provider(pid).storageLocationSummary() + }.getOrNull() + storageHeadline = summary?.headline ?: com.shonar.ui.provider.ProviderSelectionViewModel .headlineFor(pid.ifBlank { "local-only" }, purl) + storagePath = summary?.path } Scaffold( @@ -126,7 +149,7 @@ fun HomeScreen( StorageChip(headline = headline, onClick = onOpenStorage) } Spacer(Modifier.height(24.dp)) - QuickCard("Choose storage", storageHeadline ?: "Where recordings live", + QuickCard("Choose storage", storagePath ?: storageHeadline ?: "Where recordings live", Icons.Filled.Cloud, onOpenStorage) QuickCard("Settings", "Appearance, custom settings", Icons.Filled.Settings, onOpenSettings) @@ -141,12 +164,19 @@ fun HomeScreen( if (recordings.isNotEmpty()) { Text("Your recordings", style = MaterialTheme.typography.titleLarge, modifier = Modifier.align(Alignment.Start)) + notice?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.align(Alignment.Start)) + } recordings.forEach { recording -> RecordingRow( recording = recording, playing = playingId == recording.id, onPlay = { vm.togglePlayback(recording) }, onDelete = { vm.delete(recording) }, + onOpen = { onOpenDetail(recording.id) }, + onRename = { vm.rename(recording, it) }, ) } } else { @@ -199,8 +229,12 @@ private fun RecordingRow( playing: Boolean, onPlay: () -> Unit, onDelete: () -> Unit, + onOpen: () -> Unit, + onRename: (String) -> Unit, ) { - Card(Modifier.fillMaxWidth()) { + var menu by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(false) } + Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { Row( Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically, @@ -214,9 +248,56 @@ private fun RecordingRow( ) } TextButton(onClick = onPlay) { Text(if (playing) "Pause" else "Play") } - TextButton(onClick = onDelete) { Text("Delete", color = MaterialTheme.colorScheme.error) } + androidx.compose.material3.IconButton(onClick = { menu = true }) { + androidx.compose.material3.Icon( + androidx.compose.material.icons.Icons.Filled.MoreVert, + contentDescription = "More actions", + ) + } + androidx.compose.material3.DropdownMenu( + expanded = menu, + onDismissRequest = { menu = false }, + ) { + androidx.compose.material3.DropdownMenuItem( + text = { Text("Rename file") }, + onClick = { menu = false; renaming = true }, + ) + androidx.compose.material3.DropdownMenuItem( + text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + onClick = { menu = false; onDelete() }, + ) + } } } + if (renaming) { + var draft by remember(recording.id) { mutableStateOf(recording.title) } + AlertDialog( + onDismissRequest = { renaming = false }, + title = { Text("Rename file") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text( + "Renames the audio file too — same folder, same format.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + TextButton( + onClick = { onRename(draft); renaming = false }, + enabled = draft.isNotBlank(), + ) { Text("Rename") } + }, + dismissButton = { TextButton(onClick = { renaming = false }) { Text("Cancel") } }, + ) + } } private fun formatDuration(durationMs: Long): String { 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 index 7387cf7..8490cfc 100644 --- a/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt +++ b/android/app/src/main/java/com/shonar/ui/home/RecordingViewModel.kt @@ -20,6 +20,9 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() { val recorderState = RecordingService.snapshot private val _playingId = kotlinx.coroutines.flow.MutableStateFlow(null) val playingId: StateFlow = _playingId + private val _notice = kotlinx.coroutines.flow.MutableStateFlow(null) + /** Transient rename/delete failures surface here; cleared on next action. */ + val notice: StateFlow = _notice private var player: MediaPlayer? = null fun start() = RecordingService.command(app, RecordingService.ACTION_START) @@ -44,8 +47,20 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() { } fun delete(recording: RecordingEntity) { + _notice.value = null if (_playingId.value == recording.id) stopPlayback() - viewModelScope.launch { app.recordingRepository.delete(recording) } + viewModelScope.launch { + runCatching { app.recordingRepository.delete(recording) } + .onFailure { _notice.value = it.message ?: "Delete failed." } + } + } + + fun rename(recording: RecordingEntity, newTitle: String) { + _notice.value = null + viewModelScope.launch { + runCatching { app.recordingRepository.rename(recording, newTitle) } + .onFailure { _notice.value = it.message ?: "Rename failed." } + } } private fun stopPlayback() { 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 index e5860ec..8b6cd04 100644 --- a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold @@ -29,6 +30,7 @@ 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 @@ -54,12 +56,35 @@ fun ProviderSelectionScreen( onDone: () -> Unit, onBack: () -> Unit, vm: ProviderSelectionViewModel = viewModel(factory = ProviderSelectionViewModel.Factory), + browsePick: String? = null, + onPickConsumed: () -> Unit = {}, + onBrowse: () -> Unit = {}, ) { val state by vm.state.collectAsState() val context = LocalContext.current var selectedId by remember { mutableStateOf(null) } var urlInput by remember { mutableStateOf("") } var pathInput by remember { mutableStateOf("") } + var confirmCreate by remember { mutableStateOf(false) } + var showAdvanced by remember { mutableStateOf(false) } + + // A path picked in the folder browser saves immediately — the browse + // choice IS the confirmation, no second tap needed. With no card + // tapped, the pick means on-this-phone (device storage is the only + // non-advanced target). + LaunchedEffect(browsePick) { + if (browsePick != null) { + pathInput = browsePick + val localId = com.shonar.provider.LocalOnlyProvider.ID + val target = vm.options.firstOrNull { it.id == selectedId } + ?: vm.options.firstOrNull { it.id == localId } + if (target != null && target.needsPath && target.available) { + selectedId = target.id + vm.connectFolder(browsePick, target.id) + } + onPickConsumed() + } + } Scaffold( topBar = { @@ -79,14 +104,14 @@ fun ProviderSelectionScreen( ) { item { Text( - "You stay in control: recordings, transcripts, and summaries go where " + - "you choose. You can switch providers later without losing anything.", + "Recordings stay on this phone, in your own folder if you " + + "like. You can switch later without losing anything.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp), ) } - items(vm.options) { opt -> + items(vm.simpleOptions) { opt -> ProviderCard( option = opt, selected = selectedId == opt.id, @@ -95,11 +120,40 @@ fun ProviderSelectionScreen( if (!opt.available) { // surface the honest reason immediately vm.choose(opt, "") + } else if (opt.allowsBlankPath) { + // Tapping is the choice: persist the default now. + // Picking a folder below upgrades it. + vm.choose(opt, "") } }, ) } val sel = vm.options.firstOrNull { it.id == selectedId } + item { + TextButton( + onClick = { showAdvanced = !showAdvanced }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (showAdvanced) "Hide self-hosted servers" + else "Self-hosted servers (advanced)" + ) + } + } + if (showAdvanced) { + items(vm.advancedOptions) { opt -> + ProviderCard( + option = opt, + selected = selectedId == opt.id, + onSelect = { + selectedId = opt.id + if (!opt.available) { + vm.choose(opt, "") + } + }, + ) + } + } if (sel != null && sel.available && sel.needsUrl) { item { OutlinedTextField( @@ -123,17 +177,56 @@ fun ProviderSelectionScreen( OutlinedTextField( value = pathInput, onValueChange = { pathInput = it }, - label = { Text("Folder path (created by your sync tool)") }, + label = { Text("Folder path (or browse below)") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) } + item { + OutlinedButton( + onClick = onBrowse, + modifier = Modifier.fillMaxWidth(), + ) { Text("Browse my folders…") } + } item { Button( - onClick = { vm.connectFolder(pathInput) }, + onClick = { vm.connectFolder(pathInput, sel.id) }, + enabled = state !is SelectionUiState.Probing && + (pathInput.isNotBlank() || sel.allowsBlankPath), + modifier = Modifier.fillMaxWidth(), + ) { Text(if (pathInput.isBlank()) "Use default folder" else "Use this folder") } + } + item { + OutlinedButton( + onClick = { confirmCreate = true }, enabled = state !is SelectionUiState.Probing && pathInput.isNotBlank(), modifier = Modifier.fillMaxWidth(), - ) { Text("Use this folder") } + ) { Text("Create folder") } + } + if (confirmCreate) { + item { + AlertDialog( + onDismissRequest = { confirmCreate = false }, + title = { Text("Create folder?") }, + text = { + Text( + "This creates:\n${pathInput.trim()}\n\n" + + "Only the last part is created — its parent " + + "must already exist. Nothing else on your " + + "device is touched." + ) + }, + confirmButton = { + TextButton(onClick = { + confirmCreate = false + vm.createFolder(pathInput, sel.id) + }) { Text("Create") } + }, + dismissButton = { + TextButton(onClick = { confirmCreate = false }) { Text("Cancel") } + }, + ) + } } } if (state is SelectionUiState.NeedsNcApproval) { 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 index a7898bc..b1d8ee8 100644 --- a/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt +++ b/android/app/src/main/java/com/shonar/ui/provider/ProviderSelectionViewModel.kt @@ -24,8 +24,13 @@ data class ProviderOption( 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 -) + val needsPath: Boolean = false, // local folder choice: a directory path + val advanced: Boolean = false, // self-hosted servers, hidden by default +) { + /** On-this-phone accepts a blank path (app-private default folder). */ + val allowsBlankPath: Boolean + get() = id == com.shonar.provider.LocalOnlyProvider.ID && !needsUrl +} sealed interface SelectionUiState { data object Idle : SelectionUiState @@ -69,9 +74,6 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { 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 @@ -87,12 +89,23 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { val state: StateFlow = _state.asStateFlow() val options: List = listOf( + ProviderOption( + id = com.shonar.provider.LocalOnlyProvider.ID, + name = "On this phone", + blurb = "Recordings stay on this phone, in your own folder if you " + + "like — sync it yourself however you want. Playback and " + + "search work fully offline.", + needsUrl = false, + available = true, + needsPath = true, + ), ProviderOption( id = ProviderRegistry.NEXTCLOUD_ID, name = "Nextcloud", - blurb = "Recommended. Your files on your Nextcloud via WebDAV. Connects with an app password — never your normal password.", + blurb = "Your files on your Nextcloud via WebDAV. Connects with an app password — never your normal password.", needsUrl = true, available = true, // P4 + advanced = true, ), ProviderOption( id = ProviderRegistry.CUSTOM_SHONAR_ID, @@ -100,15 +113,7 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { 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, + advanced = true, ), ProviderOption( id = "start9", @@ -117,6 +122,7 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { "Nextcloud or SHONAR automatically.", needsUrl = true, available = true, // P6a: generic hosted setup (no platform RPC) + advanced = true, ), ProviderOption( id = "umbrel", @@ -125,16 +131,16 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { "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, + advanced = true, ), ) + /** Cards shown without opening Advanced. Server choices stay one tap away. */ + val simpleOptions: List get() = options.filter { !it.advanced } + + /** Self-hosted server choices, behind the Advanced disclosure. */ + val advancedOptions: List get() = options.filter { it.advanced } + fun choose(option: ProviderOption, rawUrl: String) { viewModelScope.launch { pollJob?.cancel() @@ -144,9 +150,14 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { return@launch } if (!option.needsUrl) { - // Local-only saves straight away; the sync folder has its - // own path step (connectFolder). - if (option.needsPath) return@launch + // Local-only saves straight away (its folder is optional — + // the path step below only overrides it). The sync folder + // has no default, so it waits for its path step. + if (option.needsPath && + option.id != com.shonar.provider.LocalOnlyProvider.ID + ) { + return@launch + } runCatching { save(option.id, "") } .onFailure { _state.value = SelectionUiState.Error(it.message ?: "Could not save provider") } return@launch @@ -394,22 +405,31 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { } /** - * 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. + * Use a folder for a path-capable provider (on-this-phone or sync + * folder). Blank path means "just the default": valid for on-this-phone + * (app-private dir), an error for the sync folder, which has no default. */ - fun connectFolder(rawPath: String) { + fun connectFolder(rawPath: String, providerId: String = ProviderRegistry.SYNC_FOLDER_ID) { viewModelScope.launch { val path = rawPath.trim() if (path.isEmpty()) { - _state.value = SelectionUiState.Error( - "Enter the folder path — e.g. the Syncthing folder for SHONAR." - ) + if (providerId == com.shonar.provider.LocalOnlyProvider.ID) { + runCatching { save(providerId, "") } + .onFailure { + _state.value = SelectionUiState.Error( + it.message ?: "Could not save provider" + ) + } + } else { + _state.value = SelectionUiState.Error( + "Enter the folder path — e.g. the Syncthing folder for SHONAR." + ) + } return@launch } _state.value = SelectionUiState.Probing(path) runCatching { - folderProvider.connect( + registry.provider(providerId).connect( com.shonar.provider.ProviderCredential.FolderPath( accountLabel = path, path = path, @@ -419,7 +439,42 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { _state.value = SelectionUiState.Error(it.message ?: "Could not use $path") return@launch } - save(ProviderRegistry.SYNC_FOLDER_ID, "") + save(providerId, "") + } + } + + /** + * Create the folder (only the final segment; the parent must already + * exist) and use it for [providerId]. The UI confirms the exact path + * before calling — creation is always explicit, never a typo side + * effect. Blank path falls back to the default, like [connectFolder]. + */ + fun createFolder( + rawPath: String, + providerId: String = ProviderRegistry.SYNC_FOLDER_ID, + ) { + viewModelScope.launch { + val path = rawPath.trim() + if (path.isEmpty()) { + connectFolder("", providerId) + return@launch + } + _state.value = SelectionUiState.Probing(path) + val provider = runCatching { registry.provider(providerId) }.getOrNull() + val failure = runCatching { + when (provider) { + is com.shonar.provider.LocalOnlyProvider -> provider.createRoot(path) + is com.shonar.provider.FolderSyncProvider -> provider.createRoot(path) + else -> throw com.shonar.provider.ProviderError.InvalidUrl( + "This provider has no folder to create" + ) + } + }.exceptionOrNull() + if (failure != null) { + _state.value = SelectionUiState.Error(failure.message ?: "Could not create $path") + return@launch + } + save(providerId, "") } } @@ -438,6 +493,9 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() { "\"" + url.replace("\\", "\\\\").replace("\"", "\\\"") + "\"", ) } + // New recordings follow the picked folder from here on. + // Older clips stay where they were recorded. + runCatching { repository.refreshRoot() } // P7: switching providers with a non-empty library asks what happens // to it — nothing moves or is forgotten silently. val count = runCatching { dao.getAll().size }.getOrDefault(0) 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 index 3f0a7e6..5a388c5 100644 --- a/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt +++ b/android/app/src/main/java/com/shonar/ui/provider/StorageScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.shonar.provider.AuthState +import com.shonar.provider.LocalOnlyProvider /** * P7 "Where is my data?": live provider summary plus the account @@ -136,6 +137,13 @@ private fun ReadyBody( ) ready.summary?.let { Text(it.headline, style = MaterialTheme.typography.titleMedium) + it.path?.let { path -> + Text( + path, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } Text( it.detail, style = MaterialTheme.typography.bodyMedium, @@ -159,34 +167,39 @@ private fun ReadyBody( } val enabled = ready.busy == null - if (ready.auth != AuthState.CONNECTED) { - Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) { - Text("Reconnect") - } - } + val isLocal = ready.providerId == LocalOnlyProvider.ID Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) { - Text("Switch provider") + Text("Location") } - 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") + // On-device storage has no accounts, servers, or sync jobs — just the + // location. Everything below is server/provider lifecycle. + if (!isLocal) { + if (ready.auth != AuthState.CONNECTED) { + Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) { + Text("Reconnect") + } + } + OutlinedButton( + onClick = onPauseSync, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) { Text("Pause sync") } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = revoke, onCheckedChange = { revoke = it }, enabled = enabled) + Text("Also revoke on the server") + } + OutlinedButton( + onClick = { onDisconnect(revoke) }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) { Text("Disconnect") } + OutlinedButton( + onClick = onAskDelete, + enabled = enabled, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { Text("Delete account & data") } } - 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) { diff --git a/android/app/src/test/java/com/shonar/provider/CustomShonarAiTest.kt b/android/app/src/test/java/com/shonar/provider/CustomShonarAiTest.kt new file mode 100644 index 0000000..eda24e1 --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/CustomShonarAiTest.kt @@ -0,0 +1,180 @@ +package com.shonar.provider + +import java.io.File +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** + * M8: CustomShonarProvider AI-content methods against a fake with the M7 + * read endpoints plus the M8 PUT edit endpoints. Shapes mirror + * `backend/shonar/api/schemas_recordings.py`. + */ +class CustomShonarAiTest { + + private val server = MockWebServer() + private lateinit var dir: File + private lateinit var store: com.shonar.settings.InMemorySettingsStore + + private var transcriptText = "auto text" + private var transcriptVersion = 1 + private var summaryContent = JSONObject().put("short", "auto summary") + private var summaryVersion = 1 + + @Before fun setUp() { + dir = File(System.getProperty("java.io.tmpdir"), "custom-ai-${System.nanoTime()}").apply { mkdirs() } + store = com.shonar.settings.InMemorySettingsStore() + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse = handle(request) + } + server.start() + } + + @After fun tearDown() { + server.shutdown() + dir.deleteRecursively() + } + + private val url: String get() = server.url("/").toString().removeSuffix("/") + + private fun provider(): CustomShonarProvider = CustomShonarProvider( + auth = ShonarAuthStore(store), + sidecarRoot = File(dir, "sidecars"), + ) + + private suspend fun connected(): CustomShonarProvider { + val p = provider() + p.connect( + ProviderCredential.ShonarLogin( + accountLabel = "t@example.com", + serverUrl = ServerUrl.parse(url)!!.getOrThrow(), + email = "t@example.com", + password = "pw", + ) + ) + return p + } + + private fun handle(request: RecordedRequest): MockResponse { + val path = request.path?.substringBefore('?').orEmpty() + val method = request.method.orEmpty() + if (method == "POST" && path == "/api/v1/auth/login") { + return json(200, JSONObject() + .put("access_token", "a").put("token_type", "bearer") + .put("expires_in", 900).put("refresh_token", "r")) + } + if (request.getHeader("Authorization") != "Bearer a") { + return json(401, JSONObject().put("detail", "bad token")) + } + val recId = "rec-1" + return when { + method == "GET" && path == "/api/v1/recordings/$recId/transcript" -> + json(200, transcriptJson()) + method == "GET" && path == "/api/v1/recordings/$recId/summary" -> + json(200, summaryJson()) + method == "GET" && path == "/api/v1/recordings/$recId/jobs" -> + MockResponse().setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(JSONArray().apply { + put(JSONObject().put("job_type", "transcribe") + .put("status", "succeeded").put("attempt", 1).put("max_attempts", 3)) + }.toString()) + method == "PUT" && path == "/api/v1/recordings/$recId/transcript" -> { + val body = JSONObject(request.body.readUtf8()) + transcriptText = body.getString("text") + transcriptVersion++ + json(200, transcriptJson()) + } + method == "PUT" && path == "/api/v1/recordings/$recId/summary" -> { + summaryContent = JSONObject(request.body.readUtf8()).getJSONObject("content") + summaryVersion++ + json(200, summaryJson()) + } + method == "PATCH" && path == "/api/v1/recordings/$recId" -> + json(200, JSONObject().put("id", recId) + .put("title", JSONObject(request.body.readUtf8()).optString("title", "x"))) + method == "GET" && path == "/api/v1/recordings/missing/transcript" -> + json(404, JSONObject().put("detail", "No transcript yet")) + else -> json(404, JSONObject().put("detail", "unknown $method $path")) + } + } + + private fun transcriptJson() = JSONObject() + .put("version", transcriptVersion).put("language", "en") + .put("provider", if (transcriptVersion == 1) "faster-whisper" else "user") + .put("text", transcriptText) + .put("segments", JSONArray().apply { + put(JSONObject().put("start", 0.0).put("end", 1.0).put("text", transcriptText)) + }) + .put("edited_by_user", transcriptVersion > 1) + + private fun summaryJson() = JSONObject() + .put("version", summaryVersion) + .put("provider", if (summaryVersion == 1) "ollama" else "user") + .put("content", summaryContent) + .put("edited_by_user", summaryVersion > 1) + + private fun json(code: Int, obj: JSONObject) = MockResponse() + .setResponseCode(code).setHeader("Content-Type", "application/json") + .setBody(obj.toString()) + + @Test fun fetchTranscript_ok() = runBlocking { + val p = connected() + val raw = p.fetchTranscript("rec-1") + assertEquals("auto text", JSONObject(raw).getString("text")) + } + + @Test fun fetchTranscript_missingIsNotFound() = runBlocking { + val p = connected() + try { + p.fetchTranscript("missing") + fail("expected NotFound") + } catch (e: ProviderError.NotFound) { + assertTrue(e.message!!.contains("missing")) + } + } + + @Test fun updateTranscript_roundTrip() = runBlocking { + val p = connected() + val raw = p.updateTranscript( + "rec-1", + com.shonar.recording.transcriptUpdatePayload("user corrected"), + ) + val json = JSONObject(raw) + assertEquals("user corrected", json.getString("text")) + assertEquals(2, json.getInt("version")) + assertEquals(true, json.getBoolean("edited_by_user")) + // server state actually moved + assertEquals("user corrected", JSONObject(p.fetchTranscript("rec-1")).getString("text")) + } + + @Test fun updateSummary_roundTrip() = runBlocking { + val p = connected() + val raw = p.updateSummary( + "rec-1", + com.shonar.recording.summaryUpdatePayload(mapOf("short" to "user short")), + ) + val json = JSONObject(raw) + assertEquals("user short", json.getJSONObject("content").getString("short")) + assertEquals(2, json.getInt("version")) + } + + @Test fun patchRecording_ok() = runBlocking { + val p = connected() + val raw = p.patchRecording( + "rec-1", + com.shonar.recording.recordingPatchPayload(title = "Renamed"), + ) + assertEquals("Renamed", JSONObject(raw).getString("title")) + } +} diff --git a/android/app/src/test/java/com/shonar/provider/FolderSyncCreateTest.kt b/android/app/src/test/java/com/shonar/provider/FolderSyncCreateTest.kt new file mode 100644 index 0000000..63b6b2f --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/FolderSyncCreateTest.kt @@ -0,0 +1,71 @@ +package com.shonar.provider + +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** Folder creation: explicit, leaf-only, typo-safe. Strict connect is untouched. */ +class FolderSyncCreateTest { + + private lateinit var base: File + private lateinit var store: com.shonar.settings.InMemorySettingsStore + + @Before fun setUp() { + base = File(System.getProperty("java.io.tmpdir"), "sync-create-${System.nanoTime()}") + .apply { mkdirs() } + store = com.shonar.settings.InMemorySettingsStore() + } + + @After fun tearDown() { + base.deleteRecursively() + } + + private fun provider() = FolderSyncProvider(pathStore = store) + + @Test fun createRoot_createsLeafAndConnects() = runBlocking { + val p = provider() + val leaf = File(base, "SHONAR-new").path + p.createRoot(leaf) + assertTrue(File(leaf).isDirectory) + assertEquals(AuthState.CONNECTED, p.authState.value) + // second call is just a connect + p.createRoot(leaf) + assertEquals(AuthState.CONNECTED, p.authState.value) + } + + @Test fun createRoot_refusesMissingParent() = runBlocking { + val p = provider() + try { + p.createRoot(File(base, "no-such-parent/leaf").path) + fail("missing parent must not create deep trees") + } catch (e: ProviderError.InvalidUrl) { + assertTrue(e.message!!.contains("Parent")) + } + assertTrue(p.authState.value != AuthState.CONNECTED) + } + + @Test fun createRoot_refusesTraversal() = runBlocking { + val p = provider() + try { + p.createRoot(base.path + "/../escape") + fail(".. must be rejected") + } catch (e: ProviderError.InvalidUrl) { + assertTrue(e.message!!.contains("..")) + } + } + + @Test fun connect_stillRefusesMissing() = runBlocking { + val p = provider() + try { + p.connect(ProviderCredential.FolderPath("x", File(base, "missing").path)) + fail("strict connect must keep refusing") + } catch (e: ProviderError.InvalidUrl) { + assertTrue(e.message!!.contains("does not exist")) + } + } +} diff --git a/android/app/src/test/java/com/shonar/provider/LocalOnlyFolderTest.kt b/android/app/src/test/java/com/shonar/provider/LocalOnlyFolderTest.kt new file mode 100644 index 0000000..1b4eea5 --- /dev/null +++ b/android/app/src/test/java/com/shonar/provider/LocalOnlyFolderTest.kt @@ -0,0 +1,87 @@ +package com.shonar.provider + +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** Local-only with a user-picked folder: files land there, deletion never touches it. */ +class LocalOnlyFolderTest { + + private lateinit var appDir: File + private lateinit var userDir: File + private lateinit var store: com.shonar.settings.InMemorySettingsStore + + @Before fun setUp() { + val base = File(System.getProperty("java.io.tmpdir"), "local-folder-${System.nanoTime()}") + appDir = File(base, "app").apply { mkdirs() } + userDir = File(base, "user").apply { mkdirs() } + store = com.shonar.settings.InMemorySettingsStore() + } + + @After fun tearDown() { + appDir.deleteRecursively() + userDir.deleteRecursively() + } + + private fun provider() = LocalOnlyProvider(appDir, pathStore = store) + + private fun draft(id: String, dir: File): RecordingDraft { + val src = File(dir, "src-$id.bin") + src.writeBytes(ByteArray(1024) { it.toByte() }) + return RecordingDraft(id, "t", 1L, 1000L, "audio/mp4", src, src.length()) + } + + @Test fun defaultRoot_unchangedWithoutPick() = runBlocking { + val p = provider() + p.connect(ProviderCredential.None) + val ref = p.upload(draft("a", appDir)) { } + assertTrue(File(appDir, ref.key).exists()) + assertEquals("On this device only", p.storageLocationSummary().headline) + } + + @Test fun pickedFolder_receivesUploads() = runBlocking { + val p = provider() + p.connect(ProviderCredential.FolderPath("mine", userDir.path)) + val ref = p.upload(draft("b", appDir)) { } + assertTrue(File(userDir, ref.key).exists()) + assertFalse(File(appDir, "audio").exists()) + val summary = p.storageLocationSummary() + assertEquals("Your folder", summary.headline) + assertEquals(userDir.canonicalPath, java.io.File(summary.path!!).canonicalPath) + } + + @Test fun deleteAccount_keepsUserFolder() = runBlocking { + val p = provider() + p.connect(ProviderCredential.FolderPath("mine", userDir.path)) + p.upload(draft("c", appDir)) { } + p.deleteAccountAndData() + assertTrue("user files must survive", File(userDir, "audio").exists()) + } + + @Test fun createRoot_leafOnly() = runBlocking { + val p = provider() + val leaf = File(userDir, "SHONAR-new").path + p.createRoot(leaf) + assertTrue(File(leaf).isDirectory) + try { + p.createRoot(File(userDir, "no-parent/leaf").path) + fail("missing parent must fail") + } catch (e: ProviderError.InvalidUrl) { + assertTrue(e.message!!.contains("Parent")) + } + } + + @Test fun useDefaultRoot_forgetsPick() = runBlocking { + val p = provider() + p.connect(ProviderCredential.FolderPath("mine", userDir.path)) + p.useDefaultRoot() + val ref = p.upload(draft("d", appDir)) { } + assertTrue(File(appDir, ref.key).exists()) + } +} diff --git a/android/app/src/test/java/com/shonar/recording/AiContentTest.kt b/android/app/src/test/java/com/shonar/recording/AiContentTest.kt new file mode 100644 index 0000000..cb39d77 --- /dev/null +++ b/android/app/src/test/java/com/shonar/recording/AiContentTest.kt @@ -0,0 +1,143 @@ +package com.shonar.recording + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** M8: transcript/summary parsing, playback-sync mapping, edit payloads. */ +class AiContentTest { + + private fun seg(start: Double, text: String) = + TranscriptSegment(start, start + 1.0, text) + + // ---- activeSegmentIndex ---------------------------------------------------- + + @Test fun activeSegment_emptyIsMinusOne() { + assertEquals(-1, activeSegmentIndex(emptyList(), 5_000)) + } + + @Test fun activeSegment_beforeFirstIsMinusOne() { + val segs = listOf(seg(2.0, "a"), seg(4.0, "b")) + assertEquals(-1, activeSegmentIndex(segs, 1_999)) + } + + @Test fun activeSegment_exactStartSelectsIt() { + val segs = listOf(seg(2.0, "a"), seg(4.0, "b")) + assertEquals(0, activeSegmentIndex(segs, 2_000)) + assertEquals(1, activeSegmentIndex(segs, 4_000)) + } + + @Test fun activeSegment_gapKeepsLastSpoken() { + val segs = listOf(seg(0.0, "a"), seg(10.0, "b")) + assertEquals(0, activeSegmentIndex(segs, 9_999)) + } + + @Test fun activeSegment_afterLastStaysOnLast() { + val segs = listOf(seg(0.0, "a"), seg(2.0, "b")) + assertEquals(1, activeSegmentIndex(segs, 999_000)) + } + + // ---- parsing ----------------------------------------------------------------- + + @Test fun parseTranscript_fullShape() { + val raw = JSONObject() + .put("version", 3) + .put("language", "en") + .put("provider", "faster-whisper") + .put("model", "small") + .put("text", "hello world") + .put("segments", org.json.JSONArray().apply { + put(JSONObject().put("start", 1.5).put("end", 2.5) + .put("text", "world").put("speaker", "S1")) + put(JSONObject().put("start", 0.0).put("end", 1.0).put("text", "hello")) + }) + .put("edited_by_user", true) + .toString() + val t = parseTranscript(raw)!! + assertEquals(3, t.version) + assertEquals("en", t.language) + assertEquals("hello world", t.text) + assertTrue(t.editedByUser) + // out-of-order segments arrive sorted by start + assertEquals(listOf("hello", "world"), t.segments.map { it.text }) + assertEquals("S1", t.segments[1].speaker) + assertNull(t.segments[0].speaker) + } + + @Test fun parseTranscript_minimalShape() { + val t = parseTranscript(JSONObject().put("version", 1).put("text", "x").toString())!! + assertEquals(1, t.version) + assertTrue(t.segments.isEmpty()) + assertFalse(t.editedByUser) + } + + @Test fun parseTranscript_garbageIsNull() { + assertNull(parseTranscript("not json{")) + } + + @Test fun parseSummary_sections() { + val raw = JSONObject() + .put("version", 2) + .put("provider", "ollama") + .put("edited_by_user", false) + .put("content", JSONObject() + .put("short", "Standup.") + .put("detailed", "Long version.") + .put("key_points", org.json.JSONArray().put("a").put("b")) + .put("action_items", org.json.JSONArray().put("ship it")) + .put("decisions", org.json.JSONArray()) + .put("questions", org.json.JSONArray())) + .toString() + val s = parseSummary(raw)!! + assertEquals("Standup.", s.short) + assertEquals("Long version.", s.detailed) + assertEquals(listOf("a", "b"), s.list("key_points")) + assertEquals(listOf("ship it"), s.list("action_items")) + assertTrue(s.list("decisions").isEmpty()) + } + + @Test fun parseJobs_list() { + val raw = org.json.JSONArray().apply { + put(JSONObject().put("job_type", "transcribe").put("status", "succeeded") + .put("attempt", 1).put("max_attempts", 3)) + put(JSONObject().put("job_type", "summarize").put("status", "failed") + .put("attempt", 3).put("max_attempts", 3).put("error", "boom")) + }.toString() + val jobs = parseJobs(raw) + assertEquals(2, jobs.size) + assertEquals("succeeded", jobs[0].status) + assertEquals("boom", jobs[1].error) + } + + // ---- payloads ------------------------------------------------------------------ + + @Test fun transcriptPayload_textOnly() { + val payload = JSONObject(transcriptUpdatePayload("fixed text")) + assertEquals("fixed text", payload.getString("text")) + assertFalse(payload.has("segments")) + } + + @Test fun transcriptPayload_withSegmentsRoundTrips() { + val segs = listOf(TranscriptSegment(0.0, 1.0, "hi", "S1")) + val payload = JSONObject(transcriptUpdatePayload("hi", segs)) + val arr = payload.getJSONArray("segments") + assertEquals(1, arr.length()) + assertEquals("S1", arr.getJSONObject(0).getString("speaker")) + } + + @Test fun summaryPayload_shape() { + val payload = JSONObject(summaryUpdatePayload(mapOf("short" to "s"))) + assertEquals("s", payload.getJSONObject("content").getString("short")) + } + + @Test fun patchPayload_titleOnly() { + val payload = JSONObject(recordingPatchPayload(title = "New")) + assertEquals("New", payload.getString("title")) + assertFalse(payload.has("notes")) + assertNotNull(JSONObject(recordingPatchPayload(title = "a", notes = "b"))) + } +} diff --git a/android/app/src/test/java/com/shonar/recording/RecordingRenameTest.kt b/android/app/src/test/java/com/shonar/recording/RecordingRenameTest.kt new file mode 100644 index 0000000..632ed40 --- /dev/null +++ b/android/app/src/test/java/com/shonar/recording/RecordingRenameTest.kt @@ -0,0 +1,107 @@ +package com.shonar.recording + +import java.io.File +import java.nio.file.Files +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +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.Test + +/** Rename: file + entry move together, collisions suffix, names sanitized. */ +class RecordingRenameTest { + + private class FakeDao : RecordingDao { + val rows = mutableMapOf() + private val flow = MutableStateFlow>(emptyList()) + private fun emit() { + flow.value = rows.values.sortedByDescending { it.createdAtEpochMs } + } + + override fun observeAll(): Flow> = flow + override suspend fun getAll(): List = rows.values.toList() + override suspend fun getById(id: String): RecordingEntity? = rows[id] + override suspend fun insert(recording: RecordingEntity) { + rows[recording.id] = recording + emit() + } + + override suspend fun update(recording: RecordingEntity) { + rows[recording.id] = recording + emit() + } + + override suspend fun delete(recording: RecordingEntity) { + rows.remove(recording.id) + emit() + } + } + + private fun entityFor(file: File) = RecordingEntity( + id = "id-${file.nameWithoutExtension}", + title = file.nameWithoutExtension, + createdAtEpochMs = 1L, + durationMs = 1000L, + filePath = file.absolutePath, + mimeType = "audio/mp4", + sizeBytes = file.length(), + ) + + @Test fun sanitizeFileStem_basics() { + assertEquals("hello world", RecordingRepository.sanitizeFileStem(" hello world ")) + assertEquals("a_b", RecordingRepository.sanitizeFileStem("a/b")) + assertEquals("a_b", RecordingRepository.sanitizeFileStem("a\\b")) + assertNull(RecordingRepository.sanitizeFileStem(" ")) + assertNull(RecordingRepository.sanitizeFileStem("..")) + assertNull(RecordingRepository.sanitizeFileStem(".")) + } + + @Test fun rename_movesFileAndEntry() = runBlocking { + val dir = Files.createTempDirectory("rename").toFile() + try { + val repo = RecordingRepository(dir, FakeDao()) + val src = File(dir, "abc.m4a").apply { writeBytes(ByteArray(16)) } + val updated = repo.rename(entityFor(src), "Big Raccoon") + assertEquals("Big Raccoon", updated.title) + assertTrue(File(dir, "Big Raccoon.m4a").exists()) + assertFalse(src.exists()) + assertEquals(File(dir, "Big Raccoon.m4a").absolutePath, updated.filePath) + } finally { + dir.deleteRecursively() + } + } + + @Test fun rename_collisionSuffixes() = runBlocking { + val dir = Files.createTempDirectory("rename2").toFile() + try { + val repo = RecordingRepository(dir, FakeDao()) + File(dir, "Taken.m4a").apply { writeBytes(ByteArray(8)) } + val src = File(dir, "abc.m4a").apply { writeBytes(ByteArray(8)) } + val updated = repo.rename(entityFor(src), "Taken") + assertEquals("Taken (2)", updated.title) + assertTrue(File(dir, "Taken (2).m4a").exists()) + } finally { + dir.deleteRecursively() + } + } + + @Test fun rename_blankFails() = runBlocking { + val dir = Files.createTempDirectory("rename3").toFile() + try { + val repo = RecordingRepository(dir, FakeDao()) + val src = File(dir, "abc.m4a").apply { writeBytes(ByteArray(8)) } + try { + repo.rename(entityFor(src), " ") + fail("blank name must fail") + } catch (e: IllegalArgumentException) { + assertTrue(src.exists()) + } + } finally { + dir.deleteRecursively() + } + } +} diff --git a/android/app/src/test/java/com/shonar/ui/folder/FolderListTest.kt b/android/app/src/test/java/com/shonar/ui/folder/FolderListTest.kt new file mode 100644 index 0000000..c9d7ffd --- /dev/null +++ b/android/app/src/test/java/com/shonar/ui/folder/FolderListTest.kt @@ -0,0 +1,63 @@ +package com.shonar.ui.folder + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class FolderListTest { + + private fun tempDir(): File = Files.createTempDirectory("folder-list").toFile() + + @Test fun listChildDirs_sortedSkipsHiddenAndFiles() { + val root = tempDir() + try { + File(root, "b").mkdir() + File(root, "A").mkdir() + File(root, ".hidden").mkdir() + File(root, "note.txt").writeText("x") + assertEquals( + listOf("A", "b"), + listChildDirs(root).map { it.name }, + ) + } finally { + root.deleteRecursively() + } + } + + @Test fun listChildDirs_missingIsEmpty() { + assertTrue(listChildDirs(File("/no/such/dir/xyz")).isEmpty()) + } + + @Test fun folderName_validation() { + assertTrue(isValidFolderName("SHONAR")) + assertTrue(isValidFolderName(" spaced ")) + assertFalse(isValidFolderName("")) + assertFalse(isValidFolderName("a/b")) + assertFalse(isValidFolderName("..")) + assertFalse(isValidFolderName(".")) + } + + @Test fun createChildDir_roundTrip() { + val root = tempDir() + try { + val child = createChildDir(root, "New").getOrThrow() + assertTrue(child.isDirectory) + // idempotent when already a dir + createChildDir(root, "New").getOrThrow() + // refuses file clash + File(root, "f").writeText("x") + try { + createChildDir(root, "f").getOrThrow() + fail("file clash must fail") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("file")) + } + } finally { + root.deleteRecursively() + } + } +} diff --git a/backend/migrations/versions/m8models000001_model_support.py b/backend/migrations/versions/m8models000001_model_support.py new file mode 100644 index 0000000..c984e3c --- /dev/null +++ b/backend/migrations/versions/m8models000001_model_support.py @@ -0,0 +1,63 @@ +"""per-recording transcription model + job progress + app settings + +Revision ID: m8models000001 +Revises: fts0000000001 + +- recordings.transcription_model (nullable; NULL = server default) +- processing_jobs.stage / processing_jobs.progress (display-only) +- app_settings table (global default model, …) +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "m8models000001" +down_revision: str | None = "fts0000000001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "recordings", + sa.Column("transcription_model", sa.String(length=32), nullable=True), + ) + op.add_column( + "upload_sessions", + sa.Column("transcription_model", sa.String(length=32), nullable=True), + ) + op.add_column( + "processing_jobs", + sa.Column("stage", sa.String(length=32), nullable=True), + ) + op.add_column( + "processing_jobs", + sa.Column("progress", sa.Integer(), nullable=True), + ) + op.create_table( + "app_settings", + sa.Column("key", sa.String(length=120), nullable=False), + sa.Column( + "value", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("key"), + ) + + +def downgrade() -> None: + op.drop_table("app_settings") + op.drop_column("processing_jobs", "progress") + op.drop_column("processing_jobs", "stage") + op.drop_column("upload_sessions", "transcription_model") + op.drop_column("recordings", "transcription_model") diff --git a/backend/shonar/api/schemas_recordings.py b/backend/shonar/api/schemas_recordings.py index 35b722e..0eea6cc 100644 --- a/backend/shonar/api/schemas_recordings.py +++ b/backend/shonar/api/schemas_recordings.py @@ -17,6 +17,9 @@ class UploadSessionCreate(BaseModel): declared_size_bytes: int = Field(gt=0) title: str | None = Field(default=None, max_length=300) client_recording_id: str | None = Field(default=None, max_length=64) + # Optional per-recording transcription model override ("Use default" + # when omitted). Validated against the model registry at finalize time. + transcription_model: str | None = Field(default=None, max_length=32) class UploadSessionOut(ORMModel): @@ -43,6 +46,9 @@ class RecordingFinalize(BaseModel): longitude: float | None = Field(default=None, ge=-180, le=180) location_accuracy_m: float | None = Field(default=None, ge=0) notes: str | None = Field(default=None, max_length=20000) + # Optional per-recording transcription model override. When both the + # session and the finalize body specify one, the finalize body wins. + transcription_model: str | None = Field(default=None, max_length=32) class RecordingOut(ORMModel): @@ -55,6 +61,9 @@ class RecordingOut(ORMModel): longitude: float | None processing_status: str processing_error: str | None + # Effective transcription model saved at finalize time (override or the + # global default then in force). None for pre-model rows. + transcription_model: str | None has_audio: bool mime_type: str | None size_bytes: int | None @@ -113,5 +122,28 @@ class ProcessingJobOut(ORMModel): attempt: int max_attempts: int error: str | None + # Display-only phase within a running job ("loading-model", + # "transcribing") plus 0-100 progress when known. + stage: str | None + progress: int | None started_at: datetime | None finished_at: datetime | None + + +class TranscriptUpdate(BaseModel): + """User edit: stored as a new version with edited_by_user=True. + + The AI pipeline never overwrites a newest user-edited row, so edits + are verdicts. Segments are optional — when omitted the previous + segments are dropped (plain-text correction). + """ + + text: str = Field(min_length=1, max_length=200000) + segments: list[SegmentOut] | None = Field(default=None, max_length=2000) + language: str | None = Field(default=None, max_length=16) + + +class SummaryUpdate(BaseModel): + """User edit for the structured summary (same versioning rule).""" + + content: dict = Field(max_length=100) diff --git a/backend/shonar/api/v1/__init__.py b/backend/shonar/api/v1/__init__.py index a58f2be..e17a147 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, provider_info, recordings, users +from shonar.api.v1 import auth, health, models, 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(models.router) api_router.include_router(provider_info.router) # Included as later milestones land: diff --git a/backend/shonar/api/v1/models.py b/backend/shonar/api/v1/models.py new file mode 100644 index 0000000..9c14775 --- /dev/null +++ b/backend/shonar/api/v1/models.py @@ -0,0 +1,132 @@ +"""Transcription model registry + global default (Stage 1). + +- GET /models — supported models with display metadata, download and + availability status, and which is the default. +- GET /models/default — the current global default model name. +- PUT /models/default — change the global default (affects future + recordings only; saved per-recording models are never rewritten). +- POST /models/{name}/download — fetch a model into the local cache + (needs internet once; runs synchronously and may take minutes for + large models). +""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from shonar.api.deps import CurrentUser, SessionDep +from shonar.services.ai import ProviderConfigError +from shonar.services.ai.model_registry import ( + SUPPORTED_TRANSCRIPTION_MODELS, + get_global_default_model, + is_faster_whisper_installed, + is_model_downloaded, + set_global_default_model, + validate_model_name, +) + +router = APIRouter(tags=["models"]) + + +class TranscriptionModelOut(BaseModel): + name: str + display_name: str + description: str + params: str + approx_memory: str + relative_speed: str + is_default: bool + downloaded: bool + available: bool + + +class ModelsOut(BaseModel): + default_model: str + faster_whisper_installed: bool + models: list[TranscriptionModelOut] + + +class DefaultModelUpdate(BaseModel): + model: str = Field(min_length=1, max_length=32) + + +class DefaultModelOut(BaseModel): + default_model: str + + +async def _models_out(session: SessionDep) -> ModelsOut: + default = await get_global_default_model(session) + installed = is_faster_whisper_installed() + return ModelsOut( + default_model=default, + faster_whisper_installed=installed, + models=[ + TranscriptionModelOut( + name=info.name, + display_name=info.display_name, + description=info.description, + params=info.params, + approx_memory=info.approx_memory, + relative_speed=info.relative_speed, + is_default=info.name == default, + downloaded=is_model_downloaded(info.name), + available=installed and is_model_downloaded(info.name), + ) + for info in SUPPORTED_TRANSCRIPTION_MODELS.values() + ], + ) + + +@router.get("/models", response_model=ModelsOut) +async def list_models(user: CurrentUser, session: SessionDep): + return await _models_out(session) + + +@router.get("/models/default", response_model=DefaultModelOut) +async def get_default_model(user: CurrentUser, session: SessionDep): + return DefaultModelOut(default_model=await get_global_default_model(session)) + + +@router.put("/models/default", response_model=DefaultModelOut) +async def put_default_model(body: DefaultModelUpdate, user: CurrentUser, session: SessionDep): + try: + name = await set_global_default_model(session, body.model) + except ProviderConfigError as e: + raise HTTPException(422, str(e)) from None + return DefaultModelOut(default_model=name) + + +@router.post("/models/{name}/download", response_model=TranscriptionModelOut) +async def download_model(name: str, user: CurrentUser, session: SessionDep): + try: + clean = validate_model_name(name) + except ProviderConfigError as e: + raise HTTPException(422, str(e)) from None + if not is_faster_whisper_installed(): + raise HTTPException( + 501, + "faster-whisper is not installed on this server " + "(pip install shonar-backend[faster-whisper]).", + ) + from shonar.services.ai.faster_whisper import _load_model + + try: + await asyncio.to_thread(_load_model, clean) + except Exception as e: # noqa: BLE001 — surface download failures plainly + raise HTTPException(500, f"Model download failed: {e}") from None + default = await get_global_default_model(session) + info = SUPPORTED_TRANSCRIPTION_MODELS[clean] + return TranscriptionModelOut( + name=info.name, + display_name=info.display_name, + description=info.description, + params=info.params, + approx_memory=info.approx_memory, + relative_speed=info.relative_speed, + is_default=info.name == default, + downloaded=is_model_downloaded(clean), + available=is_model_downloaded(clean), + ) diff --git a/backend/shonar/api/v1/recordings.py b/backend/shonar/api/v1/recordings.py index a0e1958..a90fbbe 100644 --- a/backend/shonar/api/v1/recordings.py +++ b/backend/shonar/api/v1/recordings.py @@ -20,7 +20,9 @@ from shonar.api.schemas_recordings import ( RecordingOut, RecordingUpdate, SummaryOut, + SummaryUpdate, TranscriptOut, + TranscriptUpdate, UploadSessionCreate, UploadSessionOut, UploadStatusOut, @@ -61,6 +63,7 @@ async def _recording_out(session, rec: Recording) -> RecordingOut: # noqa: ANN0 longitude=rec.longitude, processing_status=rec.processing_status.value, processing_error=rec.processing_error, + transcription_model=rec.transcription_model, has_audio=original is not None, mime_type=original.mime_type if original else None, size_bytes=original.size_bytes if original else None, @@ -78,7 +81,7 @@ async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: S try: us = await up.create_session( session, user.id, body.declared_mime_type, body.declared_size_bytes, - body.title, body.client_recording_id, + body.title, body.client_recording_id, body.transcription_model, ) except up.UploadError as e: raise HTTPException(e.status_code, e.message) from None @@ -125,6 +128,7 @@ async def finalize_upload( session, user.id, session_id, recorded_at=body.recorded_at, duration_seconds=body.duration_seconds, latitude=lat, longitude=lon, location_accuracy_m=acc, notes=body.notes, + transcription_model=body.transcription_model, ) except up.UploadError as e: raise HTTPException(e.status_code, e.message) from None @@ -297,19 +301,7 @@ async def get_transcript( ) 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, - ) + return _transcript_out(row) @router.get("/recordings/{recording_id}/summary", response_model=SummaryOut) @@ -337,3 +329,101 @@ async def list_jobs(recording_id: uuid.UUID, user: CurrentUser, session: Session .order_by(ProcessingJob.id) ) return list(rows) + + +# --- M8: user edits (new version, edited_by_user=True; pipeline won't clobber) +def _transcript_out(row: Transcript) -> TranscriptOut: + return TranscriptOut( + version=row.version, + language=row.language, + provider=row.provider, + model=row.model, + text=row.text, + segments=[ + s for s in (row.segments or []) if isinstance(s, dict) + ], + edited_by_user=row.edited_by_user, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +@router.put("/recordings/{recording_id}/transcript", response_model=TranscriptOut) +async def update_transcript( + recording_id: uuid.UUID, body: TranscriptUpdate, user: CurrentUser, session: SessionDep +): + from sqlalchemy import func as sql_func + + rec = await _owned_recording(session, user, recording_id) + existing = list( + await session.scalars( + select(Transcript) + .where( + Transcript.recording_id == rec.id, + Transcript.superseded_at.is_(None), + ) + .order_by(Transcript.version.desc()) + ) + ) + now = utcnow() + max_version = await session.scalar( + select(sql_func.max(Transcript.version)).where(Transcript.recording_id == rec.id) + ) + for row in existing: + row.superseded_at = now + row = Transcript( + recording_id=rec.id, + version=(max_version or 0) + 1, + language=body.language, + provider="user", + model=None, + text=body.text, + segments=( + [ + {"start": s.start, "end": s.end, "text": s.text, "speaker": s.speaker} + for s in (body.segments or []) + ] + if body.segments is not None + else None + ), + edited_by_user=True, + ) + session.add(row) + await session.flush() + return _transcript_out(row) + + +@router.put("/recordings/{recording_id}/summary", response_model=SummaryOut) +async def update_summary( + recording_id: uuid.UUID, body: SummaryUpdate, user: CurrentUser, session: SessionDep +): + from sqlalchemy import func as sql_func + + rec = await _owned_recording(session, user, recording_id) + existing = list( + await session.scalars( + select(Summary) + .where( + Summary.recording_id == rec.id, + Summary.superseded_at.is_(None), + ) + .order_by(Summary.version.desc()) + ) + ) + now = utcnow() + max_version = await session.scalar( + select(sql_func.max(Summary.version)).where(Summary.recording_id == rec.id) + ) + for row in existing: + row.superseded_at = now + row = Summary( + recording_id=rec.id, + version=(max_version or 0) + 1, + provider="user", + model=None, + content=body.content, + edited_by_user=True, + ) + session.add(row) + await session.flush() + return row diff --git a/backend/shonar/db/models.py b/backend/shonar/db/models.py index feb8082..2a78dea 100644 --- a/backend/shonar/db/models.py +++ b/backend/shonar/db/models.py @@ -143,6 +143,11 @@ class Recording(Base, PublicIdMixin): index=True, ) processing_error: Mapped[str | None] = mapped_column(Text) + # Effective faster-whisper model for this recording (e.g. "base"). + # Set at finalize time from the per-recording override or the global + # default; NULL means "server default at processing time" (pre-model + # rows). Never rewritten: changing the default affects future rows only. + transcription_model: Mapped[str | None] = mapped_column(String(32)) # The original audio is the Asset row with kind=original for this # recording (at most one, enforced by a partial unique index). Keeping # the pointer one-directional avoids a recordings<->assets FK cycle. @@ -232,6 +237,10 @@ class UploadSession(Base, PublicIdMixin): ) client_recording_id: Mapped[str | None] = mapped_column(String(64)) title: Mapped[str | None] = mapped_column(String(300)) + # Optional per-recording transcription model override chosen on the + # upload screen. Validated at finalize time; the finalize body wins when + # both specify one. + transcription_model: Mapped[str | None] = mapped_column(String(32)) declared_mime_type: Mapped[str] = mapped_column(String(100), nullable=False) declared_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) chunk_size_bytes: Mapped[int] = mapped_column( @@ -399,6 +408,12 @@ class ProcessingJob(Base, PublicIdMixin): attempt: Mapped[int] = mapped_column(default=0, nullable=False) max_attempts: Mapped[int] = mapped_column(default=3, nullable=False) error: Mapped[str | None] = mapped_column(Text) + # Fine-grained phase for progress display (e.g. transcribe jobs report + # "loading-model" then "transcribing"). Nullable: older rows predate it. + # Never parsed by pipeline logic — display only. + stage: Mapped[str | None] = mapped_column(String(32)) + # 0-100 work estimate within the current stage, when known. + progress: Mapped[int | None] = mapped_column() started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Opaque arq task handle for observability. @@ -429,3 +444,13 @@ class ExportJob(Base, PublicIdMixin): # Ensure full-text search columns exist on Postgres (added via migration as # tsvector generated columns; see migrations/versions/*_fts.py). + + +class AppSetting(Base, PublicIdMixin): + """Server-wide settings editable at runtime (global transcription model + default, …). Single row per key; the desktop Settings page writes here.""" + + __tablename__ = "app_settings" + + key: Mapped[str] = mapped_column(String(120), nullable=False, unique=True) + value: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict) diff --git a/backend/shonar/services/ai/faster_whisper.py b/backend/shonar/services/ai/faster_whisper.py index 53c9c83..7ed0ca6 100644 --- a/backend/shonar/services/ai/faster_whisper.py +++ b/backend/shonar/services/ai/faster_whisper.py @@ -9,9 +9,38 @@ from __future__ import annotations import asyncio import tempfile +import threading from pathlib import Path from shonar.services.ai import ProviderConfigError, Segment, TranscriptResult +from shonar.services.ai.model_registry import ( + download_instructions, + is_model_downloaded, + validate_model_name, +) + +# One loaded model per name, shared across jobs in this worker process. +# ctranslate2 inference is thread-safe; a lock serializes first-load only. +_MODEL_CACHE: dict[str, object] = {} +_MODEL_CACHE_LOCK = threading.Lock() + + +def _load_model(name: str) -> object: + from faster_whisper import WhisperModel + + with _MODEL_CACHE_LOCK: + model = _MODEL_CACHE.get(name) + if model is None: + model = WhisperModel(name, device="auto") + _MODEL_CACHE[name] = model + return model + + +def ensure_model_available(name: str) -> None: + """Fail fast with download instructions instead of triggering a surprise + multi-GB download inside a transcription job.""" + if not is_model_downloaded(name): + raise ProviderConfigError(download_instructions(name)) class FasterWhisperProvider: @@ -19,12 +48,12 @@ class FasterWhisperProvider: def __init__(self, model: str = "base") -> None: try: - from faster_whisper import WhisperModel # noqa: F401 + import faster_whisper # noqa: F401 except ImportError as e: raise ProviderConfigError( "faster-whisper is not installed (pip install shonar-backend[faster-whisper])." ) from e - self.model = model or "base" + self.model = validate_model_name(model or "base") async def transcribe( self, @@ -37,15 +66,14 @@ class FasterWhisperProvider: 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( + ensure_model_available(self.model) + model = _load_model(self.model) + segments_iter, info = model.transcribe( # type: ignore[union-attr] str(path), beam_size=5, language=language_hint, diff --git a/backend/shonar/services/ai/model_registry.py b/backend/shonar/services/ai/model_registry.py new file mode 100644 index 0000000..a578115 --- /dev/null +++ b/backend/shonar/services/ai/model_registry.py @@ -0,0 +1,165 @@ +"""Transcription model registry (Stage 1). + +The supported faster-whisper sizes, their display metadata, validation, and +local availability checks. Nothing here downloads anything: faster-whisper +fetches from HuggingFace on first use, so "downloaded" is answered by +inspecting the HF hub cache, and "available" additionally requires the +faster-whisper package itself. + +No silent substitution anywhere: unknown names are rejected, missing +downloads fail fast with instructions. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from shonar.db.models import AppSetting +from shonar.services.ai import ProviderConfigError + +DEFAULT_MODEL = "base" +DEFAULT_MODEL_KEY = "transcription.default_model" + + +@dataclass(frozen=True) +class TranscriptionModelInfo: + name: str + display_name: str + description: str + params: str + approx_memory: str + relative_speed: str + + +SUPPORTED_TRANSCRIPTION_MODELS: dict[str, TranscriptionModelInfo] = { + "tiny": TranscriptionModelInfo( + name="tiny", + display_name="Tiny", + description="Fastest and lightest. Good for quick drafts and slow machines.", + params="~39M", + approx_memory="~1 GB RAM", + relative_speed="~10x real-time (CPU)", + ), + "base": TranscriptionModelInfo( + name="base", + display_name="Base (default)", + description="Balanced default. Works reasonably well on ordinary computers.", + params="~74M", + approx_memory="~1 GB RAM", + relative_speed="~7x real-time (CPU)", + ), + "small": TranscriptionModelInfo( + name="small", + display_name="Small", + description="Better accuracy with higher resource usage.", + params="~244M", + approx_memory="~2 GB RAM", + relative_speed="~4x real-time (CPU)", + ), + "medium": TranscriptionModelInfo( + name="medium", + display_name="Medium", + description="Higher accuracy and slower performance.", + params="~769M", + approx_memory="~5 GB RAM", + relative_speed="~2x real-time (CPU)", + ), + "large-v3": TranscriptionModelInfo( + name="large-v3", + display_name="Large v3", + description="Highest accuracy and greatest resource requirements.", + params="~1.5B", + approx_memory="~10 GB RAM", + relative_speed="~1x real-time (CPU)", + ), +} + + +def normalize_model_name(raw: str | None) -> str: + """Case/whitespace-tolerant normalization. Never maps one model to another.""" + return (raw or "").strip().lower() + + +def validate_model_name(raw: str | None) -> str: + """Return the normalized name, or raise with a helpful message.""" + name = normalize_model_name(raw) + if name in SUPPORTED_TRANSCRIPTION_MODELS: + return name + supported = ", ".join(sorted(SUPPORTED_TRANSCRIPTION_MODELS)) + raise ProviderConfigError( + f"Unsupported transcription model {raw!r}. Supported models: {supported}. " + "Check the spelling — a different model is never substituted silently." + ) + + +def _hf_hub_cache() -> Path: + try: + from huggingface_hub.constants import HF_HUB_CACHE + + return Path(HF_HUB_CACHE) + except ImportError: + return Path( + os.environ.get("HF_HUB_CACHE", str(Path.home() / ".cache" / "huggingface" / "hub")) + ) + + +def is_model_downloaded(name: str) -> bool: + """True when a non-empty faster-whisper snapshot for `name` sits in the + HuggingFace hub cache (repo Systran/faster-whisper-).""" + repo_dir = _hf_hub_cache() / f"models--Systran--faster-whisper-{name}" + snapshots = repo_dir / "snapshots" + if not snapshots.is_dir(): + return False + return any(s.is_dir() and any(s.iterdir()) for s in snapshots.iterdir()) + + +def is_faster_whisper_installed() -> bool: + try: + import faster_whisper # noqa: F401 + + return True + except ImportError: + return False + + +def download_instructions(name: str) -> str: + return ( + f'Model "{name}" is not downloaded. Download it with: ' + f"POST /api/v1/models/{name}/download " + "(needs internet once), or run any transcription with that model selected — " + "faster-whisper fetches it from HuggingFace automatically." + ) + + +async def get_global_default_model(session: AsyncSession) -> str: + row = await session.scalar(select(AppSetting).where(AppSetting.key == DEFAULT_MODEL_KEY)) + if row is None: + return DEFAULT_MODEL + model = row.value.get("model") if isinstance(row.value, dict) else None + return model if model in SUPPORTED_TRANSCRIPTION_MODELS else DEFAULT_MODEL + + +async def set_global_default_model(session: AsyncSession, raw: str) -> str: + """Validate + persist the global default. Affects future recordings only — + existing rows keep their saved model.""" + name = validate_model_name(raw) + row = await session.scalar(select(AppSetting).where(AppSetting.key == DEFAULT_MODEL_KEY)) + if row is None: + row = AppSetting(key=DEFAULT_MODEL_KEY, value={"model": name}) + session.add(row) + else: + row.value = {"model": name} + await session.flush() + return name + + +def effective_model(recording_model: str | None, global_default: str) -> str: + """Per-recording override wins; otherwise the global default.""" + if recording_model and recording_model in SUPPORTED_TRANSCRIPTION_MODELS: + return recording_model + return global_default diff --git a/backend/shonar/services/processing.py b/backend/shonar/services/processing.py index 25a1a13..e31061f 100644 --- a/backend/shonar/services/processing.py +++ b/backend/shonar/services/processing.py @@ -112,6 +112,8 @@ async def reset_or_create( existing.status = JobStatus.queued existing.attempt = 0 existing.error = None + existing.stage = None + existing.progress = None existing.started_at = None existing.finished_at = None return True @@ -227,6 +229,7 @@ async def _fail( raise ProviderTransientError(message) job.status = JobStatus.failed job.error = message + job.stage = None job.finished_at = utcnow() rec.processing_status = ProcessingStatus.failed rec.processing_error = message @@ -252,6 +255,24 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None: await session.commit() await _maybe_chain_summarize(session, rec) return + # The recording's saved model is authoritative: a per-recording + # override wins, otherwise the global default in force at finalize + # time (changing the default never rewrites history). + from shonar.services.ai.model_registry import ( + effective_model, + get_global_default_model, + ) + + model = effective_model(rec.transcription_model, await get_global_default_model(session)) + if provider.name == "faster_whisper": + from shonar.services.ai.faster_whisper import FasterWhisperProvider + + try: + provider = FasterWhisperProvider(model=model) + except AIError as e: + await _fail(session, rec, job, str(e), ctx, e) + await session.commit() + return original = await session.scalar( select(Asset).where( Asset.recording_id == rec.id, Asset.kind == AssetKind.original @@ -262,11 +283,15 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None: job.status = JobStatus.running job.attempt = _job_try(ctx) job.started_at = utcnow() + job.stage = "loading-model" + job.progress = None rec.processing_status = ProcessingStatus.processing rec.processing_error = None await session.flush() try: audio = await get_storage().get(original.storage_key) + job.stage = "transcribing" + await session.flush() result = await provider.transcribe(audio, original.mime_type) except AIError as e: await _fail(session, rec, job, str(e), ctx, e) @@ -277,6 +302,8 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None: provider.name, getattr(result, "model", ""), ) job.status = JobStatus.succeeded + job.stage = None + job.progress = 100 job.finished_at = utcnow() await session.flush() await _maybe_chain_summarize(session, rec) diff --git a/backend/shonar/services/uploads.py b/backend/shonar/services/uploads.py index 44f60ed..864059c 100644 --- a/backend/shonar/services/uploads.py +++ b/backend/shonar/services/uploads.py @@ -56,6 +56,7 @@ async def create_session( declared_size_bytes: int, title: str | None, client_recording_id: str | None, + transcription_model: str | None = None, ) -> UploadSession: settings = get_settings() if declared_size_bytes <= 0 or declared_size_bytes > settings.max_upload_bytes: @@ -74,6 +75,7 @@ async def create_session( declared_size_bytes=declared_size_bytes, chunk_size_bytes=settings.max_chunk_bytes, expires_at=utcnow() + SESSION_TTL, + transcription_model=(transcription_model.strip() if transcription_model else None), ) session.add(us) await session.flush() @@ -165,10 +167,29 @@ async def finalize( longitude: float | None = None, location_accuracy_m: float | None = None, notes: str | None = None, + transcription_model: str | None = None, ) -> tuple[UploadSession, Recording, Asset]: """Assemble chunks, validate, store the immutable original, and create or update the recording. Idempotent per client_recording_id.""" + from shonar.services.ai import ProviderConfigError + from shonar.services.ai.model_registry import ( + effective_model, + get_global_default_model, + validate_model_name, + ) + us = await get_owned_session(session, user_id, session_id) + # Resolve + validate the transcription model before touching audio. + # Finalize body wins over the session's upload-screen choice; an + # explicit override updates history, the default never rewrites it. + override_raw = transcription_model or us.transcription_model + override: str | None = None + if override_raw: + try: + override = validate_model_name(override_raw) + except ProviderConfigError as e: + raise UploadError(str(e), 422) from None + model = effective_model(override, await get_global_default_model(session)) if us.status == UploadSessionStatus.completed and us.completed_asset_id: # Already finalized: return existing recording (retry-safe client). asset = await session.get(Asset, us.completed_asset_id) @@ -230,6 +251,10 @@ async def finalize( us.status = UploadSessionStatus.completed us.completed_asset_id = existing_original.id recording.title = us.title or recording.title + if override is not None: + # Explicit re-choice replaces the saved model; the default + # never rewrites history. + recording.transcription_model = override await session.flush() from shonar.services import processing as _processing @@ -276,6 +301,7 @@ async def finalize( longitude=longitude, location_accuracy_m=location_accuracy_m, processing_status=ProcessingStatus.uploaded, + transcription_model=model, ) session.add(recording) await session.flush() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4cdf287..96c9053 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -58,7 +58,8 @@ async def clean_db(_setup_db: None) -> AsyncIterator[None]: text( "TRUNCATE users, devices, refresh_tokens, recordings, assets, " "upload_sessions, upload_chunks, transcripts, summaries, tags, " - "recording_tags, processing_jobs, export_jobs RESTART IDENTITY CASCADE" + "recording_tags, processing_jobs, export_jobs, app_settings " + "RESTART IDENTITY CASCADE" ) ) await s.commit() diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..44a96a4 --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,245 @@ +"""Stage 1: per-recording transcription models + models API. + +Global default (base) with optional per-recording overrides; the worker +uses the exact saved model; history is never rewritten; unknown models +are rejected; missing downloads fail fast with instructions. +""" + +from __future__ import annotations + +import struct + +import pytest +from sqlalchemy import select + +from shonar.db import session as db_session +from shonar.db.models import Recording +from shonar.services import processing + + +def wav_bytes(payload_len: int = 64) -> bytes: + data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1) + data = data[:payload_len] + return ( + b"RIFF" + struct.pack(" bytes: + data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1) + data = data[:payload_len] + header = ( + b"RIFF" + struct.pack("