feat: media_async + media_index modules, media_files table, tests
- media_async: thread-per-task async engine with primitives (sha256, sign, copy, uuid, file_size) - media_index: media_files DB table with register_async for indexing chunks - chat_msg: async media submission via register_async, simplified chat_msg_submit fields - voice_recorder/attachment_sender: write single file (no chunk splitting on caller side) - inputbar/messagelist: updated to new simplified submit interface - test_media_async: 4-thread stress test (200K ops / 2 sec, 0 errors) - test_media_index: 9 cases covering commit edge cases + async flow - build: Makefile.am, CMakeLists.txt, utun_sources.cmake updated
This commit is contained in:
1 parent
ce5a84da24
commit
65dbfa6813
43 files changed
+1983
-391
No files matched your search
@@ -9,7 +9,7 @@ data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, va
|
||||
data class Message(val id: Long, val author: String, val text: String, val ts: Long,
|
||||
val isOutgoing: Boolean = false, val contentType: String = "text/plain",
|
||||
val filePath: String = "", val waveform: List<Float> = emptyList(),
|
||||
val voiceDurationMs: Int = 0)
|
||||
val voiceDurationMs: Int = 0, val fileSize: Long = 0)
|
||||
|
||||
class ChatRepository {
|
||||
private val myNodeId: Long = NativeLib.getMyNodeId()
|
||||
@@ -53,6 +53,9 @@ class ChatRepository {
|
||||
val contentType = obj.optString("contentType", "text/plain")
|
||||
val filePath = obj.optString("filePath", "")
|
||||
|
||||
var displayText = text
|
||||
var fileSize = 0L
|
||||
|
||||
/* Parse base64 waveform from voice message text (format: "wf=<100 chars A-Za-z0-9+/>;dur=1234;") */
|
||||
var waveform = emptyList<Float>()
|
||||
var voiceDurationMs = 0
|
||||
@@ -88,16 +91,30 @@ class ChatRepository {
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/* Parse file attachment: "filename.ext|file_size|block_size|num_blocks|sigs" */
|
||||
if (contentType == "application/octet-stream") {
|
||||
try {
|
||||
val pipeIdx = text.indexOf('|')
|
||||
if (pipeIdx > 0) {
|
||||
displayText = text.substring(0, pipeIdx)
|
||||
val metaParts = text.substring(pipeIdx + 1).split("|")
|
||||
if (metaParts.isNotEmpty())
|
||||
fileSize = metaParts[0].toLongOrNull() ?: 0L
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
list.add(Message(
|
||||
id = obj.getLong("id"),
|
||||
author = obj.getString("author"),
|
||||
text = text,
|
||||
text = displayText,
|
||||
ts = obj.getLong("ts"),
|
||||
isOutgoing = obj.optBoolean("isOutgoing", false),
|
||||
contentType = contentType,
|
||||
filePath = filePath,
|
||||
waveform = waveform,
|
||||
voiceDurationMs = voiceDurationMs
|
||||
voiceDurationMs = voiceDurationMs,
|
||||
fileSize = fileSize
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) }
|
||||
|
||||
@@ -36,6 +36,9 @@ class ConfigProvider(private val context: Context) {
|
||||
private val logUdpIp = stringPreferencesKey("log_udp_ip")
|
||||
private val logUdpPort = intPreferencesKey("log_udp_port")
|
||||
private val serversJson = stringPreferencesKey("servers")
|
||||
private val storageAutoload = intPreferencesKey("storage_autoload")
|
||||
private val storageAutoloadMaxsizeMb = intPreferencesKey("storage_autoload_maxsize_mb")
|
||||
private val storageMaxsizeGb = intPreferencesKey("storage_maxsize_gb")
|
||||
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
|
||||
|
||||
private val gson = Gson()
|
||||
@@ -130,6 +133,9 @@ class ConfigProvider(private val context: Context) {
|
||||
key == "node.listen_port" -> context.dataStore.data.first()[nodeListenPort] ?: 12345
|
||||
key == "control.port" -> context.dataStore.data.first()[controlPort] ?: 9999
|
||||
key == "log_udp.port" -> context.dataStore.data.first()[logUdpPort] ?: 9999
|
||||
key == "storage_autoload" -> context.dataStore.data.first()[storageAutoload] ?: 1
|
||||
key == "storage_autoload_maxsize_mb" -> context.dataStore.data.first()[storageAutoloadMaxsizeMb] ?: 10
|
||||
key == "storage_maxsize_gb" -> context.dataStore.data.first()[storageMaxsizeGb] ?: 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
@@ -149,6 +155,9 @@ class ConfigProvider(private val context: Context) {
|
||||
"debug.categories" -> prefs[debugCategories] = value
|
||||
"log_udp.ip" -> prefs[logUdpIp] = value
|
||||
"log_udp.port" -> prefs[logUdpPort] = value.toIntOrNull() ?: 0
|
||||
"storage_autoload" -> prefs[storageAutoload] = value.toIntOrNull() ?: 1
|
||||
"storage_autoload_maxsize_mb" -> prefs[storageAutoloadMaxsizeMb] = value.toIntOrNull() ?: 10
|
||||
"storage_maxsize_gb" -> prefs[storageMaxsizeGb] = value.toIntOrNull() ?: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
-1
@@ -62,7 +62,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
|
||||
|
||||
when {
|
||||
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice)
|
||||
isMedia -> Text("\uD83D\uDCC4 ${message.text}", fontSize = 15.sp, color = textColor)
|
||||
isMedia -> FileBubble(message, textColor)
|
||||
else -> {
|
||||
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
@@ -167,6 +167,26 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileBubble(message: Message, textColor: Color) {
|
||||
Column {
|
||||
Text(message.text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(formatFileSize(message.fileSize), fontSize = 12.sp, color = textColor.copy(alpha = 0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
if (bytes <= 0) return ""
|
||||
if (bytes < 1024) return "${bytes}B"
|
||||
val kb = bytes / 1024.0
|
||||
if (kb < 1024.0) return if (kb < 10.0) "%.1fKB".format(kb) else "%.0fKB".format(kb)
|
||||
val mb = kb / 1024.0
|
||||
if (mb < 1024.0) return if (mb < 10.0) "%.1fMB".format(mb) else "%.0fMB".format(mb)
|
||||
val gb = mb / 1024.0
|
||||
return "%.1fGB".format(gb)
|
||||
}
|
||||
|
||||
private fun formatDurationMs(ms: Int): String {
|
||||
val secs = ms / 1000
|
||||
return "%d:%02d".format(secs / 60, secs % 60)
|
||||
|
||||
@@ -42,7 +42,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
|
||||
var dirty by remember { mutableStateOf(false) }
|
||||
|
||||
var tabIndex by remember { mutableStateOf(0) }
|
||||
val tabs = listOf("Settings", "Audio")
|
||||
val tabs = listOf("Settings", "Audio", "Storage")
|
||||
|
||||
val handleBack = {
|
||||
if (dirty) {
|
||||
@@ -73,6 +73,7 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
|
||||
when (tabIndex) {
|
||||
0 -> GeneralSettingsTab(vm, dirty, onDirty = { dirty = it }, onExit)
|
||||
1 -> AudioSettingsTab(vm)
|
||||
2 -> StorageSettingsTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,6 +415,52 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Storage Settings Tab ── */
|
||||
|
||||
@Composable
|
||||
private fun StorageSettingsTab() {
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var autoload by remember { mutableStateOf(true) }
|
||||
var autoloadMaxSizeMb by remember { mutableStateOf("10") }
|
||||
var maxStorageGb by remember { mutableStateOf("1") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
autoload = provider.getInt("storage_autoload") != 0
|
||||
autoloadMaxSizeMb = provider.getInt("storage_autoload_maxsize_mb").toString()
|
||||
maxStorageGb = provider.getInt("storage_maxsize_gb").toString()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Media Storage", style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Autoload media", modifier = Modifier.weight(1f))
|
||||
Switch(checked = autoload, onCheckedChange = {
|
||||
autoload = it
|
||||
scope.launch { provider.setValue("storage_autoload", if (it) "1" else "0") }
|
||||
})
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
OutlinedTextField(autoloadMaxSizeMb,
|
||||
{ v -> autoloadMaxSizeMb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..500) scope.launch { provider.setValue("storage_autoload_maxsize_mb", v) } },
|
||||
label = { Text("Autoload max size (MB)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true, modifier = Modifier.padding(top = 4.dp))
|
||||
|
||||
OutlinedTextField(maxStorageGb,
|
||||
{ v -> maxStorageGb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..100) scope.launch { provider.setValue("storage_maxsize_gb", v) } },
|
||||
label = { Text("Max storage size (GB)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Labeled Slider ── */
|
||||
|
||||
@Composable
|
||||
|
||||
Reference in new issue
Block a user