M8 + T1: detail screen with transcript/summary editing; transcription model registry
Backend:
- PUT /recordings/{id}/transcript and /summary — user-edited, versioned;
the pipeline never clobbers user versions
- Model registry (tiny/base/small/medium/large-v3), global default via
PUT /models/default (future rows only), per-recording override on
finalize/upload-session (finalize wins), GET /api/v1/models
- processing_jobs gain stage/progress; faster-whisper model cache +
fail-fast on unavailable models; migration m8models000001
- 10 model tests + 5 transcript-edit tests (backend suite 64 green, ruff clean)
Android:
- detail/{id} route: transcript/summary/status tabs, tap-to-seek, speed
control, edit dialogs, title rename via CustomShonarProvider AI methods
- pure AiContent parsing/sync mapping; honest empty states for
local-only/unsynced rows; 19 new unit tests (Android suite green)
This commit is contained in:
parent
23714cfe7c
commit
8873c759e1
41 changed files with 4396 additions and 134 deletions
|
|
@ -9,6 +9,16 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Folder picker (sync-folder provider): browse your own storage to
|
||||
choose where recordings live. API 30+ uses all-files access granted
|
||||
in Settings; older releases use the read permission. SHONAR is
|
||||
sideloaded/self-hosted — no Play policy governs this, and the app
|
||||
touches only the folder you pick. -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:name=".ShonarApplication"
|
||||
android:allowBackup="false"
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.shonar.ui.detail.DetailsScreen
|
||||
import com.shonar.ui.folder.FolderBrowserScreen
|
||||
import com.shonar.ui.home.HomeScreen
|
||||
import com.shonar.ui.provider.ProviderSelectionScreen
|
||||
import com.shonar.ui.provider.StorageScreen
|
||||
|
|
@ -32,6 +37,16 @@ class MainActivity : ComponentActivity() {
|
|||
HomeScreen(
|
||||
onOpenSettings = { nav.navigate("settings") },
|
||||
onOpenStorage = { nav.navigate("storage") },
|
||||
onOpenDetail = { id -> 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<String?>("picked_path", null)
|
||||
.collectAsState()
|
||||
ProviderSelectionScreen(
|
||||
onDone = { nav.popBackStack() },
|
||||
onBack = { nav.popBackStack() },
|
||||
browsePick = pick,
|
||||
onPickConsumed = {
|
||||
entry.savedStateHandle.remove<String>("picked_path")
|
||||
},
|
||||
onBrowse = { nav.navigate("folderBrowser") },
|
||||
)
|
||||
}
|
||||
composable("folderBrowser") {
|
||||
FolderBrowserScreen(
|
||||
onPick = { path ->
|
||||
nav.previousBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.set("picked_path", path)
|
||||
nav.popBackStack()
|
||||
},
|
||||
onBack = { nav.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<String, Int> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> = _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<RemoteRecording> {
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
225
android/app/src/main/java/com/shonar/recording/AiContent.kt
Normal file
225
android/app/src/main/java/com/shonar/recording/AiContent.kt
Normal file
|
|
@ -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<TranscriptSegment> = emptyList(),
|
||||
val editedByUser: Boolean = false,
|
||||
)
|
||||
|
||||
data class SummaryData(
|
||||
val version: Int,
|
||||
val provider: String = "",
|
||||
val model: String? = null,
|
||||
/** Raw content dict; known keys read via [summaryString]/[summaryList]. */
|
||||
val content: Map<String, Any?> = emptyMap(),
|
||||
val editedByUser: Boolean = false,
|
||||
) {
|
||||
val short: String get() = summaryString(content, "short")
|
||||
val detailed: String get() = summaryString(content, "detailed")
|
||||
fun list(key: String): List<String> = summaryList(content, key)
|
||||
}
|
||||
|
||||
data class JobInfo(
|
||||
val jobType: String,
|
||||
val status: String,
|
||||
val attempt: Int = 0,
|
||||
val maxAttempts: Int = 0,
|
||||
val error: String? = null,
|
||||
/** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */
|
||||
val stage: String? = null,
|
||||
val progress: Int? = null,
|
||||
)
|
||||
|
||||
/** Summary content keys, in display order. */
|
||||
val SUMMARY_LIST_KEYS = listOf("key_points", "decisions", "action_items", "questions")
|
||||
|
||||
/**
|
||||
* Index of the segment playing at [positionMs]: the last segment whose
|
||||
* start is at or before the position. -1 when there are no segments or
|
||||
* playback precedes the first one. End times are ignored on purpose —
|
||||
* gaps between segments keep the last-spoken line highlighted instead of
|
||||
* flickering to nothing.
|
||||
*/
|
||||
fun activeSegmentIndex(segments: List<TranscriptSegment>, positionMs: Long): Int {
|
||||
val posSec = positionMs / 1000.0
|
||||
var active = -1
|
||||
for (i in segments.indices) {
|
||||
if (segments[i].startSec <= posSec) active = i else break
|
||||
}
|
||||
return active
|
||||
}
|
||||
|
||||
fun parseTranscript(raw: String): TranscriptData? = runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val segments = mutableListOf<TranscriptSegment>()
|
||||
json.optJSONArray("segments")?.let { arr ->
|
||||
for (i in 0 until arr.length()) {
|
||||
val s = arr.optJSONObject(i) ?: continue
|
||||
segments += TranscriptSegment(
|
||||
startSec = s.optDouble("start", 0.0),
|
||||
endSec = s.optDouble("end", 0.0),
|
||||
text = s.optString("text", ""),
|
||||
speaker = s.optString("speaker", null).takeUnless { it.isNullOrBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
segments.sortBy { it.startSec }
|
||||
TranscriptData(
|
||||
version = json.optInt("version", 0),
|
||||
language = json.optString("language", null).takeUnless { it.isNullOrBlank() },
|
||||
provider = json.optString("provider", ""),
|
||||
model = json.optString("model", null).takeUnless { it.isNullOrBlank() },
|
||||
text = json.optString("text", ""),
|
||||
segments = segments,
|
||||
editedByUser = json.optBoolean("edited_by_user", false),
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
fun parseSummary(raw: String): SummaryData? = runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val content = mutableMapOf<String, Any?>()
|
||||
json.optJSONObject("content")?.let { obj ->
|
||||
for (key in obj.keys()) content[key] = obj.get(key)
|
||||
}
|
||||
SummaryData(
|
||||
version = json.optInt("version", 0),
|
||||
provider = json.optString("provider", ""),
|
||||
model = json.optString("model", null).takeUnless { it.isNullOrBlank() },
|
||||
content = content,
|
||||
editedByUser = json.optBoolean("edited_by_user", false),
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
fun parseJobs(raw: String): List<JobInfo> = runCatching {
|
||||
val arr = JSONArray(raw)
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
JobInfo(
|
||||
jobType = o.optString("job_type", ""),
|
||||
status = o.optString("status", ""),
|
||||
attempt = o.optInt("attempt", 0),
|
||||
maxAttempts = o.optInt("max_attempts", 0),
|
||||
error = o.optString("error", null).takeUnless { it.isNullOrBlank() },
|
||||
stage = o.optString("stage", null).takeUnless { it.isNullOrBlank() },
|
||||
progress = if (o.has("progress") && !o.isNull("progress")) o.optInt("progress") else null,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Payload for PUT /recordings/{id}/transcript. Null segments = text-only correction. */
|
||||
fun transcriptUpdatePayload(text: String, segments: List<TranscriptSegment>? = null): String {
|
||||
val obj = JSONObject().put("text", text)
|
||||
if (segments != null) {
|
||||
obj.put(
|
||||
"segments",
|
||||
JSONArray().also { arr ->
|
||||
segments.forEach { s ->
|
||||
arr.put(
|
||||
JSONObject()
|
||||
.put("start", s.startSec)
|
||||
.put("end", s.endSec)
|
||||
.put("text", s.text)
|
||||
.let { o -> if (s.speaker != null) o.put("speaker", s.speaker) else o },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
/** Payload for PUT /recordings/{id}/summary. */
|
||||
fun summaryUpdatePayload(content: Map<String, Any?>): String =
|
||||
JSONObject().put("content", JSONObject(content)).toString()
|
||||
|
||||
/** Payload for PATCH /recordings/{id} (title/notes rename from details). */
|
||||
fun recordingPatchPayload(title: String? = null, notes: String? = null): String {
|
||||
val obj = JSONObject()
|
||||
if (title != null) obj.put("title", title)
|
||||
if (notes != null) obj.put("notes", notes)
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
// ---- transcription models (Stage 1: GET /api/v1/models) ------------------------
|
||||
|
||||
data class TranscriptionModelInfo(
|
||||
val name: String,
|
||||
val displayName: String,
|
||||
val description: String,
|
||||
val params: String = "",
|
||||
val approxMemory: String = "",
|
||||
val relativeSpeed: String = "",
|
||||
val isDefault: Boolean = false,
|
||||
val downloaded: Boolean = false,
|
||||
val available: Boolean = false,
|
||||
)
|
||||
|
||||
data class ModelsData(
|
||||
val defaultModel: String,
|
||||
val fasterWhisperInstalled: Boolean,
|
||||
val models: List<TranscriptionModelInfo> = emptyList(),
|
||||
) {
|
||||
fun byName(name: String): TranscriptionModelInfo? = models.firstOrNull { it.name == name }
|
||||
}
|
||||
|
||||
fun parseModels(raw: String): ModelsData? = runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val models = mutableListOf<TranscriptionModelInfo>()
|
||||
json.optJSONArray("models")?.let { arr ->
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.optJSONObject(i) ?: continue
|
||||
models += TranscriptionModelInfo(
|
||||
name = o.optString("name", ""),
|
||||
displayName = o.optString("display_name", o.optString("name", "")),
|
||||
description = o.optString("description", ""),
|
||||
params = o.optString("params", ""),
|
||||
approxMemory = o.optString("approx_memory", ""),
|
||||
relativeSpeed = o.optString("relative_speed", ""),
|
||||
isDefault = o.optBoolean("is_default", false),
|
||||
downloaded = o.optBoolean("downloaded", false),
|
||||
available = o.optBoolean("available", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
ModelsData(
|
||||
defaultModel = json.optString("default_model", "base"),
|
||||
fasterWhisperInstalled = json.optBoolean("faster_whisper_installed", false),
|
||||
models = models.filter { it.name.isNotBlank() },
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
private fun summaryString(content: Map<String, Any?>, key: String): String =
|
||||
(content[key] as? String).orEmpty()
|
||||
|
||||
private fun summaryList(content: Map<String, Any?>, key: String): List<String> = when (val v = content[key]) {
|
||||
is JSONArray -> (0 until v.length()).mapNotNull { v.optString(it, null)?.takeIf { it.isNotBlank() } }
|
||||
is List<*> -> v.mapNotNull { (it as? String)?.takeIf { it.isNotBlank() } }
|
||||
else -> emptyList()
|
||||
}
|
||||
|
|
@ -1,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<List<RecordingEntity>> = 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
507
android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt
Normal file
507
android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt
Normal file
|
|
@ -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<String, Any?>) -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
var editing by remember { mutableStateOf(false) }
|
||||
if (summary == null) {
|
||||
AiEmptyState(ai, what = "summary")
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
(if (summary.editedByUser) "Edited by you" else "AI summary") +
|
||||
" · v${summary.version}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true })
|
||||
}
|
||||
}
|
||||
if (summary.short.isNotBlank()) {
|
||||
item {
|
||||
SummarySection(title = "Summary", body = summary.short)
|
||||
}
|
||||
}
|
||||
if (summary.detailed.isNotBlank()) {
|
||||
item {
|
||||
SummarySection(title = "Details", body = summary.detailed)
|
||||
}
|
||||
}
|
||||
SUMMARY_LIST_KEYS.forEach { key ->
|
||||
val items = summary.list(key)
|
||||
if (items.isNotEmpty()) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
key.replace('_', ' ').replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
items.forEach { Bullet(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
saveError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.clickable { onDismissError() })
|
||||
}
|
||||
if (editing) {
|
||||
SummaryEditDialog(summary = summary, onDismiss = { editing = false }, onSave = {
|
||||
onSave(it); editing = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryEditDialog(
|
||||
summary: SummaryData,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Map<String, Any?>) -> Unit,
|
||||
) {
|
||||
var short by remember { mutableStateOf(summary.short) }
|
||||
var detailed by remember { mutableStateOf(summary.detailed) }
|
||||
var actionItems by remember { mutableStateOf(summary.list("action_items").joinToString("\n")) }
|
||||
var keyPoints by remember { mutableStateOf(summary.list("key_points").joinToString("\n")) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit summary") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(value = short, onValueChange = { short = it },
|
||||
label = { Text("Short") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = detailed, onValueChange = { detailed = it },
|
||||
label = { Text("Detailed") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = actionItems, onValueChange = { actionItems = it },
|
||||
label = { Text("Action items (one per line)") },
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = keyPoints, onValueChange = { keyPoints = it },
|
||||
label = { Text("Key points (one per line)") },
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
val content = summary.content.toMutableMap()
|
||||
content["short"] = short
|
||||
content["detailed"] = detailed
|
||||
content["action_items"] = linesOf(actionItems)
|
||||
content["key_points"] = linesOf(keyPoints)
|
||||
onSave(content)
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
private fun linesOf(raw: String): List<String> =
|
||||
raw.lines().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
@Composable
|
||||
private fun StatusTab(ready: DetailsViewModel.Ready) {
|
||||
val jobs = (ready.ai as? DetailsViewModel.AiState.Ready)?.jobs.orEmpty()
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Sync: ${ready.recording.syncState}" +
|
||||
(ready.recording.syncReason?.let { " ($it)" } ?: ""))
|
||||
if (jobs.isEmpty()) {
|
||||
Text("No processing jobs reported.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
jobs.forEach { job ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text("${job.jobType}: ${job.status}",
|
||||
style = MaterialTheme.typography.titleSmall)
|
||||
if (job.error != null) {
|
||||
Text(job.error, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error)
|
||||
} else {
|
||||
Text("attempt ${job.attempt}/${job.maxAttempts}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<JobInfo>) : AiState
|
||||
/** No transcript/summary yet, or AI not applicable here. */
|
||||
data class Unavailable(val reason: String) : AiState
|
||||
data class Error(val message: String) : AiState
|
||||
}
|
||||
|
||||
data class Ready(
|
||||
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>(UiState.Loading)
|
||||
val state: StateFlow<UiState> = _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 <T : ViewModel> create(modelClass: Class<T>): 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<String, Any?>) {
|
||||
val cur = current() ?: return
|
||||
val remoteId = cur.remoteId ?: return
|
||||
val provider = registry.provider(cur.recording.originProviderId) as? CustomShonarProvider
|
||||
?: return
|
||||
viewModelScope.launch {
|
||||
_state.value = UiState.ReadyState(cur.copy(busy = "Saving summary…", saveError = null))
|
||||
val result = runCatching {
|
||||
provider.updateSummary(remoteId, summaryUpdatePayload(content))
|
||||
}
|
||||
val parsed = result.getOrNull()?.let { parseSummary(it) }
|
||||
if (parsed != null) {
|
||||
_state.value = UiState.ReadyState(current()!!.copy(summary = parsed, busy = null))
|
||||
} else {
|
||||
_state.value = UiState.ReadyState(
|
||||
current()!!.copy(busy = null, saveError = "Save failed — try again.")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String?>(null) }
|
||||
var showCreate by remember { mutableStateOf(false) }
|
||||
|
||||
fun reload(dir: File = current) {
|
||||
current = dir
|
||||
error = null
|
||||
entries = listChildDirs(dir)
|
||||
if (!dir.canRead()) error = "Cannot read this folder."
|
||||
}
|
||||
|
||||
Text(
|
||||
current.absolutePath,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { current.parentFile?.let { reload(it) } },
|
||||
enabled = current.parentFile != null,
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text("Up") }
|
||||
OutlinedButton(
|
||||
onClick = { showCreate = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(Icons.Filled.CreateNewFolder, contentDescription = null)
|
||||
Text(" New folder")
|
||||
}
|
||||
}
|
||||
error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
LazyColumn(
|
||||
Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(entries, key = { it.path }) { entry ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 10.dp)
|
||||
.clickable { reload(File(entry.path)) },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Filled.Folder, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary)
|
||||
Text(entry.name, modifier = Modifier.padding(start = 12.dp).weight(1f))
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = { onPick(current.absolutePath) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Use this folder") }
|
||||
|
||||
if (showCreate) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showCreate = false },
|
||||
title = { Text("New folder") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Inside:\n${current.absolutePath}",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Folder name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
createChildDir(current, name)
|
||||
.onSuccess { reload(it) }
|
||||
.onFailure { error = it.message }
|
||||
if (error == null) showCreate = false
|
||||
},
|
||||
enabled = isValidFolderName(name),
|
||||
) { Text("Create") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { showCreate = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
48
android/app/src/main/java/com/shonar/ui/folder/FolderList.kt
Normal file
48
android/app/src/main/java/com/shonar/ui/folder/FolderList.kt
Normal file
|
|
@ -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<DirEntry> = 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<File> {
|
||||
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."))
|
||||
}
|
||||
|
|
@ -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<Boolean?>(null) }
|
||||
var storageHeadline by remember { mutableStateOf<String?>(null) }
|
||||
var storagePath by remember { mutableStateOf<String?>(null) }
|
||||
var headlineTick by remember { mutableStateOf(0) }
|
||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||
// Returning from the folder picker resumes this screen without
|
||||
// recomposition — re-read the headline so it shows the picked folder.
|
||||
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
|
||||
val obs = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) headlineTick++
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(obs)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
|
||||
}
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
|
|||
val recorderState = RecordingService.snapshot
|
||||
private val _playingId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||
val playingId: StateFlow<String?> = _playingId
|
||||
private val _notice = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||
/** Transient rename/delete failures surface here; cleared on next action. */
|
||||
val notice: StateFlow<String?> = _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() {
|
||||
|
|
|
|||
|
|
@ -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<String?>(null) }
|
||||
var urlInput by remember { mutableStateOf("") }
|
||||
var pathInput by remember { mutableStateOf("") }
|
||||
var confirmCreate by remember { mutableStateOf(false) }
|
||||
var showAdvanced by remember { mutableStateOf(false) }
|
||||
|
||||
// A path picked in the folder browser saves immediately — the browse
|
||||
// choice IS the confirmation, no second tap needed. With no card
|
||||
// tapped, the pick means on-this-phone (device storage is the only
|
||||
// non-advanced target).
|
||||
LaunchedEffect(browsePick) {
|
||||
if (browsePick != null) {
|
||||
pathInput = browsePick
|
||||
val localId = com.shonar.provider.LocalOnlyProvider.ID
|
||||
val target = vm.options.firstOrNull { it.id == selectedId }
|
||||
?: vm.options.firstOrNull { it.id == localId }
|
||||
if (target != null && target.needsPath && target.available) {
|
||||
selectedId = target.id
|
||||
vm.connectFolder(browsePick, target.id)
|
||||
}
|
||||
onPickConsumed()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<SelectionUiState> = _state.asStateFlow()
|
||||
|
||||
val options: List<ProviderOption> = listOf(
|
||||
ProviderOption(
|
||||
id = com.shonar.provider.LocalOnlyProvider.ID,
|
||||
name = "On this phone",
|
||||
blurb = "Recordings stay on this phone, in your own folder if you " +
|
||||
"like — sync it yourself however you want. Playback and " +
|
||||
"search work fully offline.",
|
||||
needsUrl = false,
|
||||
available = true,
|
||||
needsPath = true,
|
||||
),
|
||||
ProviderOption(
|
||||
id = ProviderRegistry.NEXTCLOUD_ID,
|
||||
name = "Nextcloud",
|
||||
blurb = "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<ProviderOption> get() = options.filter { !it.advanced }
|
||||
|
||||
/** Self-hosted server choices, behind the Advanced disclosure. */
|
||||
val advancedOptions: List<ProviderOption> get() = options.filter { it.advanced }
|
||||
|
||||
fun choose(option: ProviderOption, rawUrl: String) {
|
||||
viewModelScope.launch {
|
||||
pollJob?.cancel()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
143
android/app/src/test/java/com/shonar/recording/AiContentTest.kt
Normal file
143
android/app/src/test/java/com/shonar/recording/AiContentTest.kt
Normal file
|
|
@ -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")))
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, RecordingEntity>()
|
||||
private val flow = MutableStateFlow<List<RecordingEntity>>(emptyList())
|
||||
private fun emit() {
|
||||
flow.value = rows.values.sortedByDescending { it.createdAtEpochMs }
|
||||
}
|
||||
|
||||
override fun observeAll(): Flow<List<RecordingEntity>> = flow
|
||||
override suspend fun getAll(): List<RecordingEntity> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue