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