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:
evgeny committed 2026-07-29 14:15:10 +03:00
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
}
}
}
@@ -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
@@ -489,8 +489,8 @@ int utun_bridge_voice_get_compressor(void) {
}
void utun_bridge_voice_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms,
float rise_rate_per_500ms, float target_level_db) {
voice_recorder_set_compressor_config(max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_500ms, target_level_db);
float rise_rate_per_sec, float target_level_db) {
voice_recorder_set_compressor_config(max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_sec, target_level_db);
}
float utun_bridge_voice_get_peak_level(void) {
@@ -86,7 +86,7 @@ int utun_bridge_voice_get_preset(void);
void utun_bridge_voice_set_compressor(int enabled);
int utun_bridge_voice_get_compressor(void);
void utun_bridge_voice_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms,
float rise_rate_per_500ms, float target_level_db);
float rise_rate_per_sec, float target_level_db);
float utun_bridge_voice_get_peak_level(void);
/* ── Attachment ── */
@@ -12,18 +12,6 @@
#include <unistd.h>
#include <libgen.h>
#define MEDIA_BLOCK_MIN (10 * 1024 * 1024)
#define MEDIA_BLOCK_MAX (25 * 1024 * 1024)
#define MEDIA_BLOCK_TARGET 30
static uint32_t media_calc_block_size(uint64_t file_size) {
if (file_size == 0) return 0;
uint64_t target = (file_size + MEDIA_BLOCK_TARGET - 1) / MEDIA_BLOCK_TARGET;
if (target < MEDIA_BLOCK_MIN) target = MEDIA_BLOCK_MIN;
if (target > MEDIA_BLOCK_MAX) target = MEDIA_BLOCK_MAX;
return (uint32_t)target;
}
int attachment_send(const char* channel_id, const char* src_file_path, const char* db_path) {
if (!channel_id || !src_file_path || !db_path) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: invalid args");
@@ -33,27 +21,15 @@ int attachment_send(const char* channel_id, const char* src_file_path, const cha
struct UASYNC* ua = instance_lite_get_uasync();
if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: no uasync"); return -1; }
FILE* src = fopen(src_file_path, "rb");
if (!src) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: cannot open %s", src_file_path); return -1; }
fseek(src, 0, SEEK_END);
uint64_t file_size = (uint64_t)ftell(src);
fseek(src, 0, SEEK_SET);
if (file_size == 0) { DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "attachment_send: empty file %s", src_file_path); fclose(src); return -1; }
/* extract basename and ext from path */
/* extract basename and ext */
char path_copy[1024];
snprintf(path_copy, sizeof(path_copy), "%s", src_file_path);
char* fname = basename(path_copy);
char* dot = strrchr(fname, '.');
char ext[32] = "";
if (dot) {
snprintf(ext, sizeof(ext), "%s", dot + 1);
*dot = '\0';
}
if (dot) { snprintf(ext, sizeof(ext), "%s", dot + 1); *dot = '\0'; }
/* generate dt/suffix */
/* generate dt/suffix for unique filename */
time_t t = time(NULL);
struct tm tm_buf; localtime_r(&t, &tm_buf);
char dt[32]; snprintf(dt, sizeof(dt), "%04d%02d%02d-%02d%02d%02d",
@@ -62,10 +38,7 @@ int attachment_send(const char* channel_id, const char* src_file_path, const cha
int rnd = rand() & 0xFFFF;
char suffix[8]; snprintf(suffix, sizeof(suffix), "%04x", rnd);
uint32_t block_size = media_calc_block_size(file_size);
int num_blocks = (int)((file_size + block_size - 1) / block_size);
/* ensure media dir */
/* ensure media dir exists */
char media_dir[1024];
snprintf(media_dir, sizeof(media_dir), "%s/media/%s", db_path, channel_id);
{
@@ -77,46 +50,26 @@ int attachment_send(const char* channel_id, const char* src_file_path, const cha
mkdir(tmp, 0755);
}
/* write blocks */
for (int n = 0; n < num_blocks; n++) {
char block_path[1280];
snprintf(block_path, sizeof(block_path), "%s/%s_%d_%s_%s.%s",
media_dir, dt, n, fname, suffix, ext[0] ? ext : "bin");
FILE* dst = fopen(block_path, "wb");
if (!dst) {
DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "attachment_send: cannot create block %s", block_path);
continue;
}
uint8_t buf[65536];
uint64_t remaining = block_size;
while (remaining > 0) {
size_t to_read = remaining < sizeof(buf) ? (size_t)remaining : sizeof(buf);
size_t rd = fread(buf, 1, to_read, src);
if (rd == 0) break;
fwrite(buf, 1, rd, dst);
remaining -= rd;
}
fclose(dst);
}
fclose(src);
/* single file path — no splitting */
char media_file[1280];
snprintf(media_file, sizeof(media_file), "%s/%s_%s_%s.%s",
media_dir, dt, fname, suffix, ext[0] ? ext : "bin");
/* post chat_msg_submit */
/* post chat_msg_submit — media_copy=1 so media_index copies src to dest */
struct chat_msg_submit* req = u_calloc(1, sizeof(struct chat_msg_submit) + 1);
if (!req) return -1;
snprintf(req->channel_id, sizeof(req->channel_id), "%s", channel_id);
snprintf(req->content_type, sizeof(req->content_type), "application/octet-stream");
snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt);
snprintf(req->media_basename, sizeof(req->media_basename), "%s", fname);
snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix);
snprintf(req->media_ext, sizeof(req->media_ext), "%s", ext[0] ? ext : "bin");
req->media_num_blocks = (uint32_t)num_blocks;
snprintf(req->media_src, sizeof(req->media_src), "%s", src_file_path);
snprintf(req->media_dest, sizeof(req->media_dest), "%s", media_file);
req->media_copy = 1;
req->data = (uint8_t*)(req + 1);
req->data_len = 0;
req->timestamp = 0;
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "attachment_send: ch=%s file=%s blocks=%d size=%llu",
channel_id, src_file_path, num_blocks, (unsigned long long)file_size);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "attachment_send: ch=%s src=%s dst=%s",
channel_id, src_file_path, media_file);
uasync_post(ua, chat_core_submit_trampoline, req);
return 0;
@@ -58,6 +58,8 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
${SRC_DIR}/proxy
${SRC_DIR}/lwip_tcp
${SRC_DIR}/BBR
${SRC_DIR}/media_delivery
${SRC_DIR}/media_async
${TOOLS_DIR}
${CONFIG_DIR}
PARENT_SCOPE
@@ -17,9 +17,6 @@
#include <unistd.h>
#include <errno.h>
#define MEDIA_BLOCK_MIN (10 * 1024 * 1024)
#define MEDIA_BLOCK_MAX (25 * 1024 * 1024)
#define MEDIA_BLOCK_TARGET 30
#define OPUS_MAGIC 0x5355504F
#define FRAME_MS 20
#define MAX_PACKET 4000
@@ -85,9 +82,9 @@ int voice_recorder_init(const char* db_path) {
cfg.block_duration_ms = 20;
cfg.lookback_ms = 200;
cfg.lookahead_ms = 100;
cfg.max_gain_db = 30.0f;
cfg.rise_rate_per_500ms = 2.0f;
cfg.target_level = 0.25f;
cfg.max_gain_db = 25.0f;
cfg.rise_rate_per_sec = 10.0f;
cfg.target_level = 1.0f;
audio_compressor_configure(g_rec->compressor, &cfg);
}
@@ -199,14 +196,6 @@ int voice_recorder_feed(const int16_t* samples, int count) {
return 0;
}
static uint32_t media_calc_block_size(uint64_t file_size) {
if (file_size == 0) return 0;
uint64_t target = (file_size + MEDIA_BLOCK_TARGET - 1) / MEDIA_BLOCK_TARGET;
if (target < MEDIA_BLOCK_MIN) target = MEDIA_BLOCK_MIN;
if (target > MEDIA_BLOCK_MAX) target = MEDIA_BLOCK_MAX;
return (uint32_t)target;
}
static void voice_cleanup_locked(struct voice_recorder* rec) {
u_free(rec->pcm_buffer); rec->pcm_buffer = NULL;
rec->pcm_count = 0; rec->pcm_cap = 0;
@@ -364,37 +353,8 @@ int voice_recorder_stop(int* out_duration_ms) {
return -1;
}
/* get file size and calculate blocks */
FILE* fsize = fopen(temp_path, "rb"); uint64_t file_size = 0;
if (fsize) { fseek(fsize, 0, SEEK_END); file_size = (uint64_t)ftell(fsize); fclose(fsize); }
uint32_t block_size = media_calc_block_size(file_size);
int num_blocks = (int)((file_size + block_size - 1) / block_size);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: file_size=%llu block_size=%u num_blocks=%d",
(unsigned long long)file_size, block_size, num_blocks);
/* split file into blocks */
FILE* src = fopen(temp_path, "rb");
if (!src) { unlink(temp_path); voice_cleanup_locked(g_rec); pthread_mutex_unlock(&g_rec->mtx); pthread_mutex_unlock(&g_init_mtx); return -1; }
for (int n = 0; n < num_blocks; n++) {
char block_path[1280];
snprintf(block_path, sizeof(block_path), "%s/%s_%d_%s_%s.opus",
media_dir, dt, n, basename, suffix);
FILE* dst = fopen(block_path, "wb");
if (!dst) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: cannot create block %s", block_path); continue; }
uint8_t buf[65536];
uint64_t remaining = block_size;
while (remaining > 0) {
size_t rd = fread(buf, 1, remaining < sizeof(buf) ? (size_t)remaining : sizeof(buf), src);
if (rd == 0) break;
fwrite(buf, 1, rd, dst);
remaining -= rd;
}
fclose(dst);
}
fclose(src);
unlink(temp_path);
/* file is already at temp_path in media dir – no splitting needed */
/* media_index_register_async will handle block size calculation, signing, and DB */
/* build compact waveform: 100 bytes base64 (A-Za-z0-9+/), log scale */
static const char b64_table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@@ -421,18 +381,16 @@ int voice_recorder_stop(int* out_duration_ms) {
snprintf(req->channel_id, sizeof(req->channel_id), "%s", g_rec->channel_id);
snprintf(req->content_type, sizeof(req->content_type), "audio/opus");
snprintf(req->media_dt, sizeof(req->media_dt), "%s", dt);
snprintf(req->media_basename, sizeof(req->media_basename), "%s", basename);
snprintf(req->media_suffix, sizeof(req->media_suffix), "%s", suffix);
snprintf(req->media_ext, sizeof(req->media_ext), "opus");
req->media_num_blocks = (uint32_t)num_blocks;
snprintf(req->media_src, sizeof(req->media_src), "%s", temp_path);
snprintf(req->media_dest, sizeof(req->media_dest), "%s", temp_path);
req->media_copy = 0;
req->data = (uint8_t*)(req + 1);
req->data_len = (uint32_t)total_data_len;
memcpy(req->data, wf_data, total_data_len);
req->timestamp = 0;
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: posting media ch=%s dt=%s stem=%s blocks=%d",
req->channel_id, dt, basename, num_blocks);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder_stop: posting media ch=%s path=%s",
req->channel_id, temp_path);
uasync_post(ua, chat_core_submit_trampoline, req);
@@ -505,7 +463,7 @@ int voice_recorder_is_compressor_enabled(void) {
}
void voice_recorder_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms,
float rise_rate_per_500ms, float target_level_db) {
float rise_rate_per_sec, float target_level_db) {
pthread_mutex_lock(&g_init_mtx);
if (g_rec && g_rec->compressor) {
pthread_mutex_lock(&g_rec->mtx);
@@ -516,11 +474,11 @@ void voice_recorder_set_compressor_config(int max_gain_db, int lookback_ms, int
cfg.lookback_ms = lookback_ms;
cfg.lookahead_ms = lookahead_ms;
cfg.max_gain_db = (float)max_gain_db;
cfg.rise_rate_per_500ms = rise_rate_per_500ms;
cfg.rise_rate_per_sec = rise_rate_per_sec;
cfg.target_level = powf(10.0f, target_level_db / 20.0f);
audio_compressor_configure(g_rec->compressor, &cfg);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "voice_recorder compressor config: gain=%ddB lookback=%d lookahead=%d rise=%.1f target=%.0fdBFS",
max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_500ms, target_level_db);
max_gain_db, lookback_ms, lookahead_ms, rise_rate_per_sec, target_level_db);
pthread_mutex_unlock(&g_rec->mtx);
}
pthread_mutex_unlock(&g_init_mtx);
@@ -24,7 +24,7 @@ int voice_recorder_get_preset(void);
void voice_recorder_set_compressor_enabled(int enabled);
int voice_recorder_is_compressor_enabled(void);
void voice_recorder_set_compressor_config(int max_gain_db, int lookback_ms, int lookahead_ms,
float rise_rate_per_500ms, float target_level_db);
float rise_rate_per_sec, float target_level_db);
float voice_recorder_get_peak_level(void);
#ifdef __cplusplus