diff --git a/android/README.md b/android/README.md index 4f8f2cb..fd68e44 100644 --- a/android/README.md +++ b/android/README.md @@ -27,8 +27,15 @@ Finished recordings (WAV + txt/srt/json) upload automatically to the [meetrec-server](../meetrec-server/) storage service. Set the **Library URL** in the app (`http://100.103.83.12:8090`, persisted) or leave it empty to disable upload. Uploads run in the background after the final -pass; the status line reports the result. (A browse UI for previous -recordings arrives with M3b.) +pass; the status line reports the result. + +The **Library** tab browses everything: + +- *On this phone* — local recordings (with or without transcripts) +- *On the server* — stored bundles with date, duration, language +- Detail view: timestamped transcript, audio playback (streamed from + the server or from the local file), share, and delete with + confirmation. Refresh reloads both lists. ## Requirements diff --git a/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt b/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt new file mode 100644 index 0000000..5a26036 --- /dev/null +++ b/android/app/src/main/java/com/meetrec/android/LibraryScreen.kt @@ -0,0 +1,439 @@ +package com.meetrec.android + +import android.content.Context +import android.content.Intent +import android.media.MediaPlayer +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject + +/** One browsable recording: on the server, or only on the phone. */ +sealed interface LibraryItem { + data class Server(val meta: StorageClient.RecordingMeta) : LibraryItem + + data class Local(val wav: File, val hasTranscript: Boolean) : LibraryItem +} + +@Composable +fun LibraryScreen() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var localItems by remember { mutableStateOf>(emptyList()) } + var serverItems by remember { mutableStateOf?>(null) } + var error by remember { mutableStateOf(null) } + var selected by remember { mutableStateOf(null) } + var refresh by remember { mutableStateOf(0) } + + val storageUrl = remember { + context.getSharedPreferences("meetrec", Context.MODE_PRIVATE) + .getString("storage_url", "") ?: "" + } + + LaunchedEffect(refresh) { + error = null + localItems = withContext(Dispatchers.IO) { scanLocal(context) } + if (storageUrl.isNotBlank()) { + try { + serverItems = withContext(Dispatchers.IO) { + StorageClient.list(storageUrl).map { LibraryItem.Server(it) } + } + } catch (e: Exception) { + error = e.message + serverItems = emptyList() + } + } else { + serverItems = emptyList() + } + } + + val item = selected + if (item != null) { + LibraryDetail( + item = item, + storageUrl = storageUrl, + onBack = { selected = null }, + onDeleted = { selected = null; refresh++ }, + ) + return + } + + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Library", style = MaterialTheme.typography.headlineSmall) + androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) + OutlinedButton(onClick = { refresh++ }) { Text("Refresh") } + } + + error?.let { + Text("Server unreachable: $it", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error) + } + + LazyColumn(Modifier.fillMaxSize()) { + if (localItems.isNotEmpty()) { + item { + Text("On this phone", style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 8.dp)) + } + items(localItems, key = { it.wav.name }) { li -> + RecordingRow( + title = stampToText(li.wav.nameWithoutExtension.removePrefix("meeting-")), + subtitle = (if (li.hasTranscript) "" else "no transcript — ") + + "local file", + onClick = { selected = li }, + ) + } + item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) } + } + item { + Text("On the server", style = MaterialTheme.typography.titleSmall) + } + val servers = serverItems + if (servers == null) { + item { + Row(Modifier.padding(8.dp)) { CircularProgressIndicator() } + } + } else if (servers.isEmpty()) { + item { + Text("No recordings yet — record and stop on the Record tab.", + style = MaterialTheme.typography.bodySmall) + } + } else { + items(servers, key = { it.meta.id }) { si -> + RecordingRow( + title = stampToText(si.meta.startedAt.ifBlank { si.meta.id }), + subtitle = listOfNotNull( + if (si.meta.durationMs > 0) fmtMs(si.meta.durationMs) else null, + si.meta.language.ifBlank { null }, + si.meta.device.ifBlank { null }, + ).joinToString(" · "), + onClick = { selected = si }, + ) + } + } + } + } +} + +@Composable +private fun RecordingRow(title: String, subtitle: String, onClick: () -> Unit) { + Column( + Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 10.dp), + ) { + Text(title, style = MaterialTheme.typography.bodyLarge) + Text(subtitle, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** Transcript detail for display: segments with start time, language, duration. */ +private data class Detail( + val segments: List>, + val language: String, + val durationMs: Long, +) + +@Composable +private fun LibraryDetail( + item: LibraryItem, + storageUrl: String, + onBack: () -> Unit, + onDeleted: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var detail by remember { mutableStateOf(null) } + var detailError by remember { mutableStateOf(null) } + var confirmDelete by remember { mutableStateOf(false) } + + // simple streaming player for the WAV + var playing by remember { mutableStateOf(false) } + val player = remember { MediaPlayer() } + DisposableEffect(Unit) { + onDispose { player.stopSafely(); player.release() } + } + + fun startPlayback(source: () -> Unit) { + try { + player.stopSafely() + player.reset() + source() + player.setOnPreparedListener { + it.start() + playing = true + } + player.setOnCompletionListener { playing = false } + player.prepareAsync() + } catch (e: Exception) { + detailError = "playback failed: ${e.message}" + playing = false + } + } + + LaunchedEffect(item) { + detail = null + detailError = null + playing = false + player.stopSafely() + try { + detail = withContext(Dispatchers.IO) { + when (item) { + is LibraryItem.Server -> { + val url = storageUrl.trim().trimEnd('/') + val bytes = if ("meeting.json" in item.meta.files) { + StorageClient.fetchFile(url, item.meta.id, "meeting.json") + } else { + null + } + val text = StorageClient.fetchFile(url, item.meta.id, "meeting.txt") + .decodeToString() + parseDetail(bytes, text, item.meta.language, item.meta.durationMs) + } + is LibraryItem.Local -> { + val base = item.wav.parentFile!! + val jsonBytes = File(base, item.wav.nameWithoutExtension + ".json") + .takeIf { it.isFile }?.readBytes() + val txt = File(base, item.wav.nameWithoutExtension + ".txt") + .takeIf { it.isFile }?.readText() ?: "" + parseDetail(jsonBytes, txt, "", 0) + } + } + } + } catch (e: Exception) { + detailError = "failed to load transcript: ${e.message}" + } + } + + fun delete() { + scope.launch { + try { + when (item) { + is LibraryItem.Server -> withContext(Dispatchers.IO) { + StorageClient.delete(storageUrl, item.meta.id) + } + is LibraryItem.Local -> withContext(Dispatchers.IO) { + val base = item.wav.parentFile!! + listOf(".wav", ".txt", ".srt", ".json").forEach { + File(base, item.wav.nameWithoutExtension + it).delete() + } + } + } + } catch (e: Exception) { + detailError = "delete failed: ${e.message}" + } + onDeleted() + } + } + + Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = onBack) { Text("← Back") } + + when (item) { + is LibraryItem.Server -> { + Text(stampToText(item.meta.startedAt.ifBlank { item.meta.id }), + style = MaterialTheme.typography.headlineSmall) + val sub = listOfNotNull( + if (item.meta.durationMs > 0) fmtMs(item.meta.durationMs) else null, + item.meta.language.ifBlank { null }, + item.meta.device.ifBlank { null }, + ).joinToString(" · ") + if (sub.isNotBlank()) { + Text(sub, style = MaterialTheme.typography.bodySmall) + } + } + is LibraryItem.Local -> { + Text(stampToText(item.wav.nameWithoutExtension.removePrefix("meeting-")), + style = MaterialTheme.typography.headlineSmall) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { + if (playing) { + player.pause() + playing = false + } else if (player.isPlaying) { + player.start() + playing = true + } else { + when (item) { + is LibraryItem.Server -> { + val url = storageUrl.trim().trimEnd('/') + + "/api/recordings/${item.meta.id}/files/meeting.wav" + startPlayback { player.setDataSource(url) } + } + is LibraryItem.Local -> + startPlayback { player.setDataSource(item.wav.absolutePath) } + } + } + }) { Text(if (playing) "Pause" else "Play audio") } + + OutlinedButton(onClick = { + scope.launch { + try { + val text = detail?.segments?.joinToString("\n") { + "[${fmtMs(it.first)}] ${it.second}" + } ?: "" + if (text.isNotBlank()) { + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_TEXT, text), + "Share transcript", + ), + ) + } + } catch (e: Exception) { + detailError = "share failed: ${e.message}" + } + } + }) { Text("Share") } + + OutlinedButton(onClick = { confirmDelete = true }) { Text("Delete") } + } + + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text("Delete recording?") }, + text = { Text("This deletes the audio and transcripts. It cannot be undone.") }, + confirmButton = { + TextButton(onClick = { confirmDelete = false; delete() }) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, + ) + } + + detailError?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) + } + + val d = detail + when { + d == null && detailError == null -> Row(Modifier.padding(8.dp)) { + CircularProgressIndicator() + } + d != null -> { + if (d.segments.isEmpty()) { + Text("No transcript available for this recording.", + style = MaterialTheme.typography.bodySmall) + } else { + Text("Transcript (${d.segments.size} segments)", + style = MaterialTheme.typography.titleSmall) + LazyColumn(Modifier.fillMaxSize()) { + items(d.segments) { (startMs, text) -> + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(fmtMs(startMs), style = MaterialTheme.typography.labelSmall) + Text(text, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + } + } + } +} + +/** Builds segment list from meeting.json (preferred) or meeting.txt lines. */ +private fun parseDetail( + jsonBytes: ByteArray?, + txt: String, + language: String, + durationMs: Long, +): Detail { + val segments = mutableListOf>() + var lang = language + if (jsonBytes != null) { + val root = JSONObject(String(jsonBytes)) + lang = root.optString("language", language) + val arr = root.optJSONArray("segments") + if (arr != null) { + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + val text = o.optString("text", "").trim() + if (text.isNotEmpty()) { + segments.add((o.optDouble("start", 0.0) * 1000).toLong() to text) + } + } + } + } + if (segments.isEmpty() && txt.isNotBlank()) { + for (line in txt.lineSequence()) { + val m = Regex("^\\[(\\d+):(\\d{2})](.*)$").find(line) + if (m != null) { + val ms = m.groupValues[1].toLong() * 60_000 + m.groupValues[2].toLong() * 1000 + segments.add(ms to m.groupValues[3].trim()) + } + } + if (segments.isEmpty()) { + segments.add(0L to txt.trim()) + } + } + return Detail(segments, lang, durationMs) +} + +private fun scanLocal(context: Context): List { + val dir = File(context.filesDir, "recordings") + val wavs = dir.listFiles { f -> + f.name.startsWith("meeting-") && f.name.endsWith(".wav") + } ?: return emptyList() + return wavs.sortedByDescending { it.name }.map { wav -> + LibraryItem.Local( + wav = wav, + hasTranscript = File(dir, wav.nameWithoutExtension + ".txt").isFile, + ) + } +} + +/** "20260907-150122" -> "2026-09-07 15:01" */ +private fun stampToText(stamp: String): String { + val digits = stamp.take(15) + if (digits.length >= 13 && digits.getOrNull(8) == '-') { + return "${digits.substring(0, 4)}-${digits.substring(4, 6)}-${digits.substring(6, 8)} " + + "${digits.substring(9, 11)}:${digits.substring(11, 13)}" + } + return stamp +} + +private fun MediaPlayer.stopSafely() { + try { + if (isPlaying) stop() + } catch (_: IllegalStateException) { + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/meetrec/android/MainActivity.kt b/android/app/src/main/java/com/meetrec/android/MainActivity.kt index 2f410cf..e61e55e 100644 --- a/android/app/src/main/java/com/meetrec/android/MainActivity.kt +++ b/android/app/src/main/java/com/meetrec/android/MainActivity.kt @@ -1,525 +1,56 @@ package com.meetrec.android -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle -import android.provider.OpenableColumns import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Box 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.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import com.meetrec.core.recording.WavReader -import com.meetrec.core.whisper.RemoteWhisperEngine -import com.meetrec.core.whisper.Transcriber -import com.meetrec.core.whisper.WhisperEngine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -/** - * Record a meeting with live transcript (cheap model, rolling window) and - * an automatic final pass with the selected model on stop — mirroring the - * desktop app's two-pass design. - */ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { MeetRecScreen() } + setContent { MeetRecApp() } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun MeetRecScreen() { - val context = LocalContext.current - val scope = rememberCoroutineScope() +fun MeetRecApp() { + var tab by remember { mutableStateOf(0) } - val prefs = remember { context.getSharedPreferences("meetrec", Context.MODE_PRIVATE) } - var transcribeOn by remember { - mutableStateOf(prefs.getString("transcribe_on", "phone") ?: "phone") - } - var transcribeMenu by remember { mutableStateOf(false) } - var serverUrl by remember { - mutableStateOf( - prefs.getString("server_url", "http://100.103.83.12:8085") - ?: "http://100.103.83.12:8085", - ) - } - var storageUrl by remember { - mutableStateOf( - prefs.getString("storage_url", "http://100.103.83.12:8090") - ?: "http://100.103.83.12:8090", - ) - } - - LaunchedEffect(transcribeOn) { - prefs.edit().putString("transcribe_on", transcribeOn).apply() - } - LaunchedEffect(serverUrl) { - prefs.edit().putString("server_url", serverUrl).apply() - } - LaunchedEffect(storageUrl) { - prefs.edit().putString("storage_url", storageUrl).apply() - } - - var model by remember { mutableStateOf("tiny") } - var modelMenu by remember { mutableStateOf(false) } - var language by remember { mutableStateOf("auto") } - var langMenu by remember { mutableStateOf(false) } - var liveModel by remember { mutableStateOf("tiny") } - var liveMenu by remember { mutableStateOf(false) } - var status by remember { mutableStateOf("Select a model, download and load it.") } - var busy by remember { mutableStateOf(false) } - var engine by remember { mutableStateOf(null) } - var wavName by remember { mutableStateOf(null) } - var wavSamples by remember { mutableStateOf(null) } - var segments by remember { mutableStateOf>(emptyList()) } - - val modelsDir = remember { File(context.filesDir, "models") } - val modelFile = ModelDownloader.modelFile(model, modelsDir) - - val recState by RecorderService.state.collectAsState() - val live by RecorderService.liveLines.collectAsState() - val upload by RecorderService.uploadState.collectAsState() - - LaunchedEffect(upload) { - when (val u = upload) { - is UploadState.Uploading -> status = "Uploading recording to library…" - is UploadState.Done -> status = "Uploaded to library (${u.id})" - is UploadState.Error -> status = "Upload failed: ${u.message}" - UploadState.None -> {} - } - } - - fun runBusy(block: suspend () -> T) { - if (busy) return - busy = true - scope.launch { - try { - block() - } catch (e: Exception) { - status = "Failed: ${e.message}" - } finally { - busy = false - } - } - } - - suspend fun loadManualWav(file: File) { - val samples = withContext(Dispatchers.IO) { - file.inputStream().use { WavReader.read(it) } - } - wavName = file.name - wavSamples = samples - status = "${file.name} loaded — press Transcribe" - } - - // Final result: show the transcript and note the saved files. - LaunchedEffect(recState) { - val fin = recState as? RecordingState.Finished ?: return@LaunchedEffect - segments = emptyList() - wavSamples = null - if (fin.segments.isNotEmpty()) { - segments = fin.segments - status = if (fin.error != null) { - "Final pass failed: ${fin.error}" - } else { - "Saved ${fin.outputs.size} transcript files next to ${fin.file.name}" - } - } else if (fin.error != null) { - status = "Final pass failed: ${fin.error} — the WAV is saved" - runBusy { loadManualWav(fin.file) } - } else { - // no engine was loaded — offer the manual transcribe path - runBusy { loadManualWav(fin.file) } - } - } - - fun startRecording() = context.startForegroundService( - Intent(context, RecorderService::class.java) - .setAction(RecorderService.ACTION_START), - ) - - fun stopRecording() = context.startService( - Intent(context, RecorderService::class.java) - .setAction(RecorderService.ACTION_STOP), - ) - - val permLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions(), - ) { grants -> - if (grants[Manifest.permission.RECORD_AUDIO] == true) { - startRecording() - } else { - status = "Microphone permission is required to record" - } - } - - fun toggleRecording() { - if (recState is RecordingState.Recording) { - stopRecording() - return - } - val wanted = buildList { - add(Manifest.permission.RECORD_AUDIO) - if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS) - } - val missing = wanted.filter { - context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED - } - if (missing.isEmpty()) { - RecorderService.session = SessionConfig( - finalEngine = if (transcribeOn == "phone") engine else null, - serverUrl = if (transcribeOn == "server") { - serverUrl.trim().ifBlank { null } - } else { - null - }, - language = language.takeIf { it != "auto" }, - liveModel = if (transcribeOn == "phone") { - liveModel.takeIf { it != "off" } - } else { - null - }, - storageUrl = storageUrl.trim().ifBlank { null }, - ) - if (transcribeOn == "server") { - status = "Recording — live and final pass on ${serverUrl.trim()}" - } else if (engine == null) { - status = "Recording without transcription — load an engine first " + - "for the automatic final pass" - } - startRecording() - } else { - permLauncher.launch(missing.toTypedArray()) - } - } - - val pickWav = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument(), - ) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - runBusy { - val name = context.contentResolver - .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) - ?.use { c -> if (c.moveToFirst()) c.getString(0) else "audio" } - ?: "audio" - val samples = withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)!!.use { WavReader.read(it) } - } - wavName = name - wavSamples = samples - segments = emptyList() - status = "$name: ${samples.size / 16000} s @ 16 kHz" - } - } - - // what the transcript list shows: live lines while recording, else segments - val transcript: List> = - if (recState is RecordingState.Recording) { - live.map { it.startMs to it.text } - } else { - segments.map { it.startMs to it.text } - } - - val recording = recState as? RecordingState.Recording - val finalizing = recState is RecordingState.Finalizing - - Column( - modifier = Modifier.fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text("MeetRec", style = MaterialTheme.typography.headlineSmall) - - // transcription target: the phone's local engines or a remote server - ExposedDropdownMenuBox( - expanded = transcribeMenu, - onExpandedChange = { transcribeMenu = it }, - modifier = Modifier.fillMaxWidth(0.6f), - ) { - OutlinedTextField( - value = transcribeOn, - onValueChange = {}, - readOnly = true, - label = { Text("Transcribe on") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(transcribeMenu) }, - modifier = Modifier.menuAnchor().fillMaxWidth(), - ) - ExposedDropdownMenu( - expanded = transcribeMenu, - onDismissRequest = { transcribeMenu = false }, - ) { - listOf("phone", "server").forEach { choice -> - DropdownMenuItem( - text = { Text(choice) }, - onClick = { transcribeOn = choice; transcribeMenu = false }, - ) - } - } - } - if (transcribeOn == "server") { - OutlinedTextField( - value = serverUrl, - onValueChange = { serverUrl = it }, - label = { Text("Server URL") }, - placeholder = { Text("http://100.103.83.12:8085") }, - modifier = Modifier.fillMaxWidth(), - ) - } - - // recording library (leave empty to disable upload) - OutlinedTextField( - value = storageUrl, - onValueChange = { storageUrl = it }, - label = { Text("Library URL") }, - placeholder = { Text("http://100.103.83.12:8090") }, - modifier = Modifier.fillMaxWidth(), - ) - - // model + language - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ExposedDropdownMenuBox( - expanded = modelMenu, - onExpandedChange = { modelMenu = it }, - modifier = Modifier.weight(1f), - ) { - OutlinedTextField( - value = model, - onValueChange = {}, - readOnly = true, - enabled = transcribeOn == "phone", - label = { Text(if (transcribeOn == "server") "Model (n/a)" else "Model") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(modelMenu) }, - modifier = Modifier.menuAnchor().fillMaxWidth(), + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = tab == 0, + onClick = { tab = 0 }, + icon = { Text("●") }, + label = { Text("Record") }, + ) + NavigationBarItem( + selected = tab == 1, + onClick = { tab = 1 }, + icon = { Text("☰") }, + label = { Text("Library") }, ) - ExposedDropdownMenu(expanded = modelMenu, onDismissRequest = { modelMenu = false }) { - ModelDownloader.MODELS.keys.forEach { name -> - DropdownMenuItem( - text = { Text(name) }, - onClick = { model = name; modelMenu = false }, - ) - } - } } - ExposedDropdownMenuBox( - expanded = langMenu, - onExpandedChange = { langMenu = it }, - modifier = Modifier.weight(1f), - ) { - OutlinedTextField( - value = language, - onValueChange = {}, - readOnly = true, - label = { Text("Language") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(langMenu) }, - modifier = Modifier.menuAnchor().fillMaxWidth(), - ) - ExposedDropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) { - LANGUAGES.forEach { code -> - DropdownMenuItem( - text = { Text(code) }, - onClick = { language = code; langMenu = false }, - ) - } - } - } - } - - // live model (local engine only; the server owns its own models) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - ExposedDropdownMenuBox( - expanded = liveMenu, - onExpandedChange = { liveMenu = it }, - modifier = Modifier.weight(1f), - ) { - OutlinedTextField( - value = liveModel, - onValueChange = {}, - readOnly = true, - enabled = transcribeOn == "phone", - label = { - Text(if (transcribeOn == "server") "Live model (n/a)" else "Live model") - }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(liveMenu) }, - modifier = Modifier.menuAnchor().fillMaxWidth(), - ) - ExposedDropdownMenu(expanded = liveMenu, onDismissRequest = { liveMenu = false }) { - LIVE_MODELS.forEach { name -> - DropdownMenuItem( - text = { Text(name) }, - onClick = { liveModel = name; liveMenu = false }, - ) - } - } - } - } - - // model actions - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button( - onClick = { - runBusy { - ModelDownloader.download(model, modelsDir) { done, total -> - status = if (total > 0) "Downloading $model: $done / $total bytes" - else "Downloading $model: $done bytes" - } - status = "Model $model ready at ${modelFile.name}" - } - }, - enabled = !busy && !modelFile.isFile && transcribeOn == "phone", - ) { Text("Download") } - Button( - onClick = { - runBusy { - engine?.close() - withContext(Dispatchers.Default) { - engine = WhisperEngine.load(modelFile.absolutePath) - } - status = "$model loaded (${WhisperEngine.DEFAULT_THREADS} threads)" - } - }, - enabled = !busy && modelFile.isFile && transcribeOn == "phone", - ) { Text("Load engine") } - } - - // recording - Button( - onClick = { toggleRecording() }, - enabled = !finalizing && (!busy || recording != null), - colors = if (recording == null && !finalizing) { - ButtonDefaults.buttonColors() - } else { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - when { - recording != null -> "\u25A0 Stop (${fmtMs(recording.elapsedMs)})" - finalizing -> "Transcribing\u2026" - else -> "\u25CF Record" - }, - ) - } - if (recording != null) { - LinearProgressIndicator( - progress = { recording.level.coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth(), - ) - } - - // manual transcribe path (or re-transcribe of picked files) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton( - onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) }, - enabled = !busy, - ) { Text(if (wavName == null) "Pick WAV file" else wavName ?: "WAV") } - Button( - onClick = { - val samples = wavSamples - if (samples != null && (engine != null || transcribeOn == "server")) { - runBusy { - val t: Transcriber = if (transcribeOn == "server") { - RemoteWhisperEngine(serverUrl.trim()) - } else { - engine!! - } - val start = System.currentTimeMillis() - val segs = withContext(Dispatchers.Default) { - t.transcribe( - samples, - WhisperEngine.DEFAULT_THREADS, - beamSize = 5, - language = language.takeIf { it != "auto" }, - ) - } - segments = segs - val secs = (System.currentTimeMillis() - start) / 1000.0 - status = "${segs.size} segments in $secs s " + - "(audio ${samples.size / 16000} s)" - } - } - }, - enabled = !busy && wavSamples != null && - (engine != null || transcribeOn == "server"), - ) { Text("Transcribe") } - } - - if (transcript.isNotEmpty()) { - OutlinedButton(onClick = { - val text = transcript.joinToString("\n") { "[${fmtMs(it.first)}] ${it.second}" } - context.startActivity( - Intent.createChooser( - Intent(Intent.ACTION_SEND) - .setType("text/plain") - .putExtra(Intent.EXTRA_TEXT, text), - "Share transcript", - ), - ) - }) { Text("Share transcript") } - } - - Text(status, style = MaterialTheme.typography.bodySmall) - if (busy || finalizing) CircularProgressIndicator() - - LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) { - items(transcript) { (startMs, text) -> - Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { - Text( - fmtMs(startMs), - style = MaterialTheme.typography.labelSmall, - ) - Text(text, style = MaterialTheme.typography.bodyMedium) - } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when (tab) { + 0 -> RecordScreen() + else -> LibraryScreen() } } } -} - -private val LANGUAGES = listOf("auto", "en", "de") -private val LIVE_MODELS = listOf("off", "tiny", "base") - -private fun fmtMs(ms: Long): String { - val total = ms / 1000 - val h = total / 3600 - val m = (total % 3600) / 60 - val s = total % 60 - return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s) } \ No newline at end of file diff --git a/android/app/src/main/java/com/meetrec/android/RecordScreen.kt b/android/app/src/main/java/com/meetrec/android/RecordScreen.kt new file mode 100644 index 0000000..b189fe2 --- /dev/null +++ b/android/app/src/main/java/com/meetrec/android/RecordScreen.kt @@ -0,0 +1,515 @@ +package com.meetrec.android + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.meetrec.core.recording.WavReader +import com.meetrec.core.whisper.RemoteWhisperEngine +import com.meetrec.core.whisper.Transcriber +import com.meetrec.core.whisper.WhisperEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Record a meeting with live transcript (cheap model, rolling window) and + * an automatic final pass with the selected model on stop — mirroring the + * desktop app's two-pass design. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecordScreen() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val prefs = remember { context.getSharedPreferences("meetrec", Context.MODE_PRIVATE) } + var transcribeOn by remember { + mutableStateOf(prefs.getString("transcribe_on", "phone") ?: "phone") + } + var transcribeMenu by remember { mutableStateOf(false) } + var serverUrl by remember { + mutableStateOf( + prefs.getString("server_url", "http://100.103.83.12:8085") + ?: "http://100.103.83.12:8085", + ) + } + var storageUrl by remember { + mutableStateOf( + prefs.getString("storage_url", "http://100.103.83.12:8090") + ?: "http://100.103.83.12:8090", + ) + } + + LaunchedEffect(transcribeOn) { + prefs.edit().putString("transcribe_on", transcribeOn).apply() + } + LaunchedEffect(serverUrl) { + prefs.edit().putString("server_url", serverUrl).apply() + } + LaunchedEffect(storageUrl) { + prefs.edit().putString("storage_url", storageUrl).apply() + } + + var model by remember { mutableStateOf("tiny") } + var modelMenu by remember { mutableStateOf(false) } + var language by remember { mutableStateOf("auto") } + var langMenu by remember { mutableStateOf(false) } + var liveModel by remember { mutableStateOf("tiny") } + var liveMenu by remember { mutableStateOf(false) } + var status by remember { mutableStateOf("Select a model, download and load it.") } + var busy by remember { mutableStateOf(false) } + var engine by remember { mutableStateOf(null) } + var wavName by remember { mutableStateOf(null) } + var wavSamples by remember { mutableStateOf(null) } + var segments by remember { mutableStateOf>(emptyList()) } + + val modelsDir = remember { File(context.filesDir, "models") } + val modelFile = ModelDownloader.modelFile(model, modelsDir) + + val recState by RecorderService.state.collectAsState() + val live by RecorderService.liveLines.collectAsState() + val upload by RecorderService.uploadState.collectAsState() + + LaunchedEffect(upload) { + when (val u = upload) { + is UploadState.Uploading -> status = "Uploading recording to library…" + is UploadState.Done -> status = "Uploaded to library (${u.id})" + is UploadState.Error -> status = "Upload failed: ${u.message}" + UploadState.None -> {} + } + } + + fun runBusy(block: suspend () -> T) { + if (busy) return + busy = true + scope.launch { + try { + block() + } catch (e: Exception) { + status = "Failed: ${e.message}" + } finally { + busy = false + } + } + } + + suspend fun loadManualWav(file: File) { + val samples = withContext(Dispatchers.IO) { + file.inputStream().use { WavReader.read(it) } + } + wavName = file.name + wavSamples = samples + status = "${file.name} loaded — press Transcribe" + } + + // Final result: show the transcript and note the saved files. + LaunchedEffect(recState) { + val fin = recState as? RecordingState.Finished ?: return@LaunchedEffect + segments = emptyList() + wavSamples = null + if (fin.segments.isNotEmpty()) { + segments = fin.segments + status = if (fin.error != null) { + "Final pass failed: ${fin.error}" + } else { + "Saved ${fin.outputs.size} transcript files next to ${fin.file.name}" + } + } else if (fin.error != null) { + status = "Final pass failed: ${fin.error} — the WAV is saved" + runBusy { loadManualWav(fin.file) } + } else { + // no engine was loaded — offer the manual transcribe path + runBusy { loadManualWav(fin.file) } + } + } + + fun startRecording() = context.startForegroundService( + Intent(context, RecorderService::class.java) + .setAction(RecorderService.ACTION_START), + ) + + fun stopRecording() = context.startService( + Intent(context, RecorderService::class.java) + .setAction(RecorderService.ACTION_STOP), + ) + + val permLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + if (grants[Manifest.permission.RECORD_AUDIO] == true) { + startRecording() + } else { + status = "Microphone permission is required to record" + } + } + + fun toggleRecording() { + if (recState is RecordingState.Recording) { + stopRecording() + return + } + val wanted = buildList { + add(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS) + } + val missing = wanted.filter { + context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED + } + if (missing.isEmpty()) { + RecorderService.session = SessionConfig( + finalEngine = if (transcribeOn == "phone") engine else null, + serverUrl = if (transcribeOn == "server") { + serverUrl.trim().ifBlank { null } + } else { + null + }, + language = language.takeIf { it != "auto" }, + liveModel = if (transcribeOn == "phone") { + liveModel.takeIf { it != "off" } + } else { + null + }, + storageUrl = storageUrl.trim().ifBlank { null }, + ) + if (transcribeOn == "server") { + status = "Recording — live and final pass on ${serverUrl.trim()}" + } else if (engine == null) { + status = "Recording without transcription — load an engine first " + + "for the automatic final pass" + } + startRecording() + } else { + permLauncher.launch(missing.toTypedArray()) + } + } + + val pickWav = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument(), + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + runBusy { + val name = context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { c -> if (c.moveToFirst()) c.getString(0) else "audio" } + ?: "audio" + val samples = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)!!.use { WavReader.read(it) } + } + wavName = name + wavSamples = samples + segments = emptyList() + status = "$name: ${samples.size / 16000} s @ 16 kHz" + } + } + + // what the transcript list shows: live lines while recording, else segments + val transcript: List> = + if (recState is RecordingState.Recording) { + live.map { it.startMs to it.text } + } else { + segments.map { it.startMs to it.text } + } + + val recording = recState as? RecordingState.Recording + val finalizing = recState is RecordingState.Finalizing + + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("MeetRec", style = MaterialTheme.typography.headlineSmall) + + // transcription target: the phone's local engines or a remote server + ExposedDropdownMenuBox( + expanded = transcribeMenu, + onExpandedChange = { transcribeMenu = it }, + modifier = Modifier.fillMaxWidth(0.6f), + ) { + OutlinedTextField( + value = transcribeOn, + onValueChange = {}, + readOnly = true, + label = { Text("Transcribe on") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(transcribeMenu) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = transcribeMenu, + onDismissRequest = { transcribeMenu = false }, + ) { + listOf("phone", "server").forEach { choice -> + DropdownMenuItem( + text = { Text(choice) }, + onClick = { transcribeOn = choice; transcribeMenu = false }, + ) + } + } + } + if (transcribeOn == "server") { + OutlinedTextField( + value = serverUrl, + onValueChange = { serverUrl = it }, + label = { Text("Server URL") }, + placeholder = { Text("http://100.103.83.12:8085") }, + modifier = Modifier.fillMaxWidth(), + ) + } + + // recording library (leave empty to disable upload) + OutlinedTextField( + value = storageUrl, + onValueChange = { storageUrl = it }, + label = { Text("Library URL") }, + placeholder = { Text("http://100.103.83.12:8090") }, + modifier = Modifier.fillMaxWidth(), + ) + + // model + language + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ExposedDropdownMenuBox( + expanded = modelMenu, + onExpandedChange = { modelMenu = it }, + modifier = Modifier.weight(1f), + ) { + OutlinedTextField( + value = model, + onValueChange = {}, + readOnly = true, + enabled = transcribeOn == "phone", + label = { Text(if (transcribeOn == "server") "Model (n/a)" else "Model") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(modelMenu) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = modelMenu, onDismissRequest = { modelMenu = false }) { + ModelDownloader.MODELS.keys.forEach { name -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { model = name; modelMenu = false }, + ) + } + } + } + ExposedDropdownMenuBox( + expanded = langMenu, + onExpandedChange = { langMenu = it }, + modifier = Modifier.weight(1f), + ) { + OutlinedTextField( + value = language, + onValueChange = {}, + readOnly = true, + label = { Text("Language") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(langMenu) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) { + LANGUAGES.forEach { code -> + DropdownMenuItem( + text = { Text(code) }, + onClick = { language = code; langMenu = false }, + ) + } + } + } + } + + // live model (local engine only; the server owns its own models) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + ExposedDropdownMenuBox( + expanded = liveMenu, + onExpandedChange = { liveMenu = it }, + modifier = Modifier.weight(1f), + ) { + OutlinedTextField( + value = liveModel, + onValueChange = {}, + readOnly = true, + enabled = transcribeOn == "phone", + label = { + Text(if (transcribeOn == "server") "Live model (n/a)" else "Live model") + }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(liveMenu) }, + modifier = Modifier.menuAnchor().fillMaxWidth(), + ) + ExposedDropdownMenu(expanded = liveMenu, onDismissRequest = { liveMenu = false }) { + LIVE_MODELS.forEach { name -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { liveModel = name; liveMenu = false }, + ) + } + } + } + } + + // model actions + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + runBusy { + ModelDownloader.download(model, modelsDir) { done, total -> + status = if (total > 0) "Downloading $model: $done / $total bytes" + else "Downloading $model: $done bytes" + } + status = "Model $model ready at ${modelFile.name}" + } + }, + enabled = !busy && !modelFile.isFile && transcribeOn == "phone", + ) { Text("Download") } + Button( + onClick = { + runBusy { + engine?.close() + withContext(Dispatchers.Default) { + engine = WhisperEngine.load(modelFile.absolutePath) + } + status = "$model loaded (${WhisperEngine.DEFAULT_THREADS} threads)" + } + }, + enabled = !busy && modelFile.isFile && transcribeOn == "phone", + ) { Text("Load engine") } + } + + // recording + Button( + onClick = { toggleRecording() }, + enabled = !finalizing && (!busy || recording != null), + colors = if (recording == null && !finalizing) { + ButtonDefaults.buttonColors() + } else { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + when { + recording != null -> "\u25A0 Stop (${fmtMs(recording.elapsedMs)})" + finalizing -> "Transcribing\u2026" + else -> "\u25CF Record" + }, + ) + } + if (recording != null) { + LinearProgressIndicator( + progress = { recording.level.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth(), + ) + } + + // manual transcribe path (or re-transcribe of picked files) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) }, + enabled = !busy, + ) { Text(if (wavName == null) "Pick WAV file" else wavName ?: "WAV") } + Button( + onClick = { + val samples = wavSamples + if (samples != null && (engine != null || transcribeOn == "server")) { + runBusy { + val t: Transcriber = if (transcribeOn == "server") { + RemoteWhisperEngine(serverUrl.trim()) + } else { + engine!! + } + val start = System.currentTimeMillis() + val segs = withContext(Dispatchers.Default) { + t.transcribe( + samples, + WhisperEngine.DEFAULT_THREADS, + beamSize = 5, + language = language.takeIf { it != "auto" }, + ) + } + segments = segs + val secs = (System.currentTimeMillis() - start) / 1000.0 + status = "${segs.size} segments in $secs s " + + "(audio ${samples.size / 16000} s)" + } + } + }, + enabled = !busy && wavSamples != null && + (engine != null || transcribeOn == "server"), + ) { Text("Transcribe") } + } + + if (transcript.isNotEmpty()) { + OutlinedButton(onClick = { + val text = transcript.joinToString("\n") { "[${fmtMs(it.first)}] ${it.second}" } + context.startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_TEXT, text), + "Share transcript", + ), + ) + }) { Text("Share transcript") } + } + + Text(status, style = MaterialTheme.typography.bodySmall) + if (busy || finalizing) CircularProgressIndicator() + + LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) { + items(transcript) { (startMs, text) -> + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text( + fmtMs(startMs), + style = MaterialTheme.typography.labelSmall, + ) + Text(text, style = MaterialTheme.typography.bodyMedium) + } + } + } + } +} + +private val LANGUAGES = listOf("auto", "en", "de") +private val LIVE_MODELS = listOf("off", "tiny", "base") + +internal fun fmtMs(ms: Long): String { + val total = ms / 1000 + val h = total / 3600 + val m = (total % 3600) / 60 + val s = total % 60 + return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s) +} \ No newline at end of file diff --git a/android/app/src/main/java/com/meetrec/android/StorageClient.kt b/android/app/src/main/java/com/meetrec/android/StorageClient.kt index ff2c084..5442644 100644 --- a/android/app/src/main/java/com/meetrec/android/StorageClient.kt +++ b/android/app/src/main/java/com/meetrec/android/StorageClient.kt @@ -9,6 +9,78 @@ import org.json.JSONObject /** Client for the meetrec-server storage API (see server/meetrec-server/). */ object StorageClient { + /** Index metadata for one stored recording. */ + data class RecordingMeta( + val id: String, + val startedAt: String, + val durationMs: Long, + val language: String, + val device: String, + val files: List, + ) + + /** Lists recordings, newest first. */ + fun list(baseUrl: String): List { + val body = get(url(baseUrl, "/api/recordings")) + val arr = org.json.JSONArray(String(body)) + return (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + RecordingMeta( + id = o.getString("id"), + startedAt = o.optString("started_at", ""), + durationMs = o.optLong("duration_ms", 0), + language = o.optString("language", ""), + device = o.optString("device", ""), + files = o.optJSONArray("files")?.let { f -> + (0 until f.length()).map { j -> f.getString(j) } + } ?: emptyList(), + ) + } + } + + /** Downloads one of the recording's files (e.g. meeting.json). */ + fun fetchFile(baseUrl: String, id: String, name: String): ByteArray = + get(url(baseUrl, "/api/recordings/$id/files/$name")) + + /** Deletes a recording (204 expected). */ + fun delete(baseUrl: String, id: String) { + val conn = (URL(url(baseUrl, "/api/recordings/$id")) + .openConnection() as HttpURLConnection).apply { + requestMethod = "DELETE" + connectTimeout = 15_000 + readTimeout = 30_000 + } + try { + if (conn.responseCode !in 200..299) { + throw IOException("server returned ${conn.responseCode}") + } + } finally { + conn.disconnect() + } + } + + private fun get(url: String, timeoutMs: Int = 30_000): ByteArray { + val conn = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = timeoutMs + } + try { + val code = conn.responseCode + val stream = if (code in 200..299) conn.inputStream else conn.errorStream + val resp = stream?.use { it.readBytes() } ?: ByteArray(0) + if (code !in 200..299) { + throw IOException("server returned $code: " + + String(resp, 0, minOf(resp.size, 200))) + } + return resp + } finally { + conn.disconnect() + } + } + + private fun url(baseUrl: String, path: String): String = + baseUrl.trim().trimEnd('/') + path + /** Uploads a recording bundle; returns the new recording id. */ fun upload( baseUrl: String,