Android M3b: Library tab — browse, play, share, delete recordings

- new bottom navigation: Record | Library; existing screen moved to
  RecordScreen.kt, new MainActivity holds the tab scaffold
- LibraryScreen lists recordings 'on this phone' (local files, showing
  which have transcripts) and 'on the server' (meetrec-server index with
  date, duration, language, device), with a refresh button
- detail view: timestamped transcript from meeting.json (txt fallback),
  streaming audio playback via MediaPlayer (server WAV or local file),
  share as timestamped text, delete with confirmation (server API or
  local files)
- StorageClient gains list/fetchFile/delete; verified against the live
  server on-device (list, detail, playback of the recovered 68-min
  meeting)
This commit is contained in:
2026-09-08 12:26:33 +02:00
parent 29bc4ef40b
commit 4a2aeb60d8
5 changed files with 1062 additions and 498 deletions
@@ -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<List<LibraryItem.Local>>(emptyList()) }
var serverItems by remember { mutableStateOf<List<LibraryItem.Server>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var selected by remember { mutableStateOf<LibraryItem?>(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<Pair<Long, String>>,
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<Detail?>(null) }
var detailError by remember { mutableStateOf<String?>(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<Pair<Long, String>>()
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<LibraryItem.Local> {
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) {
}
}
@@ -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<WhisperEngine?>(null) }
var wavName by remember { mutableStateOf<String?>(null) }
var wavSamples by remember { mutableStateOf<FloatArray?>(null) }
var segments by remember { mutableStateOf<List<WhisperEngine.Segment>>(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 <T> 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<Pair<Long, String>> =
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)
}
@@ -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<WhisperEngine?>(null) }
var wavName by remember { mutableStateOf<String?>(null) }
var wavSamples by remember { mutableStateOf<FloatArray?>(null) }
var segments by remember { mutableStateOf<List<WhisperEngine.Segment>>(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 <T> 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<Pair<Long, String>> =
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)
}
@@ -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<String>,
)
/** Lists recordings, newest first. */
fun list(baseUrl: String): List<RecordingMeta> {
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,