Browse Source

chat_settings: [chat] config section + live sync + fix test segfault

- New chat_setting.c/h: unified chat settings API (int/str getters)
- Config parser supports [chat] section, calls chat_setting_set per key
- chat_core exposes chat_core_set_setting / trampoline for GUI bridge
- chatgui: AudioDevice + Storage pages persist to [chat] config + live apply
- chatgui-android: ConfigProvider, NativeLib, headless handle chat settings
- MessageList: 200 msg limit, fixed saveChannelState via trampoline
- test_media_delivery_full: fix segfault at shutdown (mon() timer vs freed conns)
topo_upd
evgeny 2 months ago
parent
commit
b8920cf483
  1. 12
      AGENTS.md
  2. 2
      src/Makefile.am
  3. 16
      src/chat/chat_core.c
  4. 4
      src/chat/chat_core.h
  5. 127
      src/chat/chat_setting.c
  6. 28
      src/chat/chat_setting.h
  7. 9
      src/config_parser.c
  8. 2
      tests/test_media_delivery_full.c
  9. 10
      tools/chatgui-android/AGENTS.md
  10. 51
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  11. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  12. 25
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  13. 19
      tools/chatgui-android/headless/headless_control.c
  14. 28
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  15. 5
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  16. 19
      tools/chatgui/src/audiodevicesettingspage.cpp
  17. 2
      tools/chatgui/src/audiodevicesettingspage.h
  18. 2
      tools/chatgui/src/mainwindow.cpp
  19. 60
      tools/chatgui/src/messagelist.cpp
  20. 2
      tools/chatgui/src/settingsdialog.cpp
  21. 30
      tools/chatgui/src/storagesettingspage.cpp
  22. 2
      tools/chatgui/src/storagesettingspage.h

12
AGENTS.md

@ -380,6 +380,18 @@ KEEPALIVE=21, ETCPROUTE=22, BBR=23, ETCP_DUMP=24
## Config Rules
- В серверном конфиге только собственные ключи и нет секций `[client]`
- В клиентском конфиге есть собственные ключи и pubkey каждого сервера в секции `[client]`
- Chat-настройки хранятся в секции `[chat]`. Парсер вызывает `chat_setting_set(key, value)` для каждого ключа — неизвестные ключи вызывают ошибку. Пример:
```ini
[chat]
storage_autoload=1
storage_autoload_maxsize_mb=10
storage_maxsize_gb=1
opus_codec_preset=1
compressor_enabled=0
compressor_max_gain_db=25
compressor_rise_rate=10
media_download_max_peers=3
```
## Bugs Debugging Protocol
Всегда когда начинаешь диагностику ознакомься со скиллом (используй skills)

2
src/Makefile.am

@ -63,6 +63,7 @@ utun_CORE_SOURCES = \
lwip_tcp/lwip_tcp_in.c \
lwip_tcp/lwip_tcp_out.c \
chat/chat_event.c \
chat/chat_setting.c \
chat/chat_core.c \
chat/chat_msg.c \
chat/chat_channel.c \
@ -133,6 +134,7 @@ libutun_a_SOURCES = \
lwip_tcp/lwip_tcp_in.c \
lwip_tcp/lwip_tcp_out.c \
chat/chat_event.c \
chat/chat_setting.c \
chat/chat_core.c \
chat/chat_msg.c \
chat/chat_channel.c \

16
src/chat/chat_core.c

@ -12,6 +12,7 @@
#include "chat_core_priv.h"
#include "chat_event.h"
#include "chat_setting.h"
#include "../utun_instance.h"
#include "../ntp_time.h"
@ -239,3 +240,18 @@ void chat_core_destroy(struct UTUN_INSTANCE* inst) {
sqlite3* chat_core_get_db(void) { return g_cc.db; }
struct UTUN_INSTANCE* chat_core_get_inst(void) { return g_cc.inst; }
int chat_core_is_initialized(void) { return g_cc.initialized; }
/* ─── chat settings (GUI → uasync) ─── */
void chat_core_set_setting(const char* name, const char* value) {
chat_setting_set(name, value);
}
struct chat_setting_arg { char data[256]; };
void chat_core_set_setting_trampoline(void* arg) {
struct chat_setting_arg* a = (struct chat_setting_arg*)arg;
const char* name = a->data;
const char* value = name + strlen(name) + 1;
chat_core_set_setting(name, value);
u_free(arg);
}

4
src/chat/chat_core.h

@ -76,6 +76,10 @@ void chat_core_sync_my_addresses(void);
void chat_core_save_ui_state(const char* key, const char* value);
void chat_core_save_ui_state_trampoline(void* arg);
/* Установка chat-настройки (name=value) через chat_setting API (GUI → uasync) */
void chat_core_set_setting(const char* name, const char* value);
void chat_core_set_setting_trampoline(void* arg);
/* Запуск подключения к узлам выбранного канала (GUI → uasync) */
void chat_core_connect_channel(const char* ch_id);
void chat_core_connect_channel_trampoline(void* arg);

127
src/chat/chat_setting.c

@ -0,0 +1,127 @@
/*
* chat_setting.c — chat-related settings: validation, storage, on-the-fly apply
*
* Используется и из парсера конфига (headless), и из GUI (через bridge).
* Никаких зависимостей от g_cc или instance — работает автономно.
*/
#include "chat_setting.h"
#include "../../lib/debug_config.h"
#ifdef DEBUG_CATEGORY_DB_SYNC
#define CS_ID DEBUG_CATEGORY_DB_SYNC
#else
#define CS_ID DEBUG_CATEGORY_GENERAL
#endif
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
/* ─── типы настроек ─── */
#define CHAT_SETTING_INT 0
#define CHAT_SETTING_BOOL 1
struct chat_setting_def {
const char* name;
int type;
int default_val;
int min_val;
int max_val;
};
/* ─── реестр всех известных настроек ─── */
static const struct chat_setting_def g_setting_defs[] = {
{"storage_autoload", CHAT_SETTING_BOOL, 1, 0, 1},
{"storage_autoload_maxsize_mb", CHAT_SETTING_INT, 10, 1, 500},
{"storage_maxsize_gb", CHAT_SETTING_INT, 1, 1, 100},
{"opus_codec_preset", CHAT_SETTING_INT, 1, 0, 4},
{"compressor_enabled", CHAT_SETTING_BOOL, 0, 0, 1},
{"compressor_max_gain_db", CHAT_SETTING_INT, 25, 1, 100},
{"compressor_rise_rate", CHAT_SETTING_INT, 10, 1, 100},
{"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16},
};
#define CHAT_SETTING_COUNT (sizeof(g_setting_defs) / sizeof(g_setting_defs[0]))
/* ─── состояние (текущие значения) ─── */
static int g_chat_values[CHAT_SETTING_COUNT];
static int g_chat_initialized = 0;
static void chat_setting_reset_defaults(void) {
for (size_t i = 0; i < CHAT_SETTING_COUNT; i++)
g_chat_values[i] = g_setting_defs[i].default_val;
g_chat_initialized = 1;
}
/* ─── поиск ─── */
static int chat_setting_find(const char* name) {
for (size_t i = 0; i < CHAT_SETTING_COUNT; i++)
if (strcmp(g_setting_defs[i].name, name) == 0) return (int)i;
return -1;
}
/* ─── apply: вызов специфичного для настройки кода ─── */
static void chat_setting_apply(void) {
/* будет заполняться по мере появления runtime-setter'ов в модулях:
if (idx == ...) some_module_set_xxx(value); */
}
/* ─── public API ─── */
int chat_setting_set(const char* name, const char* value) {
if (!name || !value || !*name || !*value) return -2;
if (!g_chat_initialized) chat_setting_reset_defaults();
int idx = chat_setting_find(name);
if (idx < 0) {
DEBUG_ERROR(CS_ID, "chat_setting: unknown setting '%s'", name);
return -1;
}
const struct chat_setting_def* def = &g_setting_defs[idx];
int v;
if (def->type == CHAT_SETTING_BOOL) {
if (strcmp(value, "0") == 0 || strcmp(value, "no") == 0 || strcmp(value, "false") == 0) v = 0;
else if (strcmp(value, "1") == 0 || strcmp(value, "yes") == 0 || strcmp(value, "true") == 0) v = 1;
else { DEBUG_ERROR(CS_ID, "chat_setting: '%s' invalid bool '%s' (expect 0/1)", name, value); return -2; }
} else {
char* end = NULL;
long lv = strtol(value, &end, 10);
if (!end || *end != '\0' || lv < def->min_val || lv > def->max_val) {
DEBUG_ERROR(CS_ID, "chat_setting: '%s' invalid int '%s' (range %d..%d)", name, value, def->min_val, def->max_val);
return -2;
}
v = (int)lv;
}
if (g_chat_values[idx] == v) return 0;
g_chat_values[idx] = v;
DEBUG_INFO(CS_ID, "chat_setting: %s = %s", name, value);
chat_setting_apply();
return 0;
}
const char* chat_setting_get(const char* name) {
if (!g_chat_initialized) chat_setting_reset_defaults();
int idx = chat_setting_find(name);
if (idx < 0) return NULL;
static char buf[16];
snprintf(buf, sizeof(buf), "%d", g_chat_values[idx]);
return buf;
}
int chat_setting_get_int(const char* name, int def) {
if (!g_chat_initialized) chat_setting_reset_defaults();
int idx = chat_setting_find(name);
return idx >= 0 ? g_chat_values[idx] : def;
}

28
src/chat/chat_setting.h

@ -0,0 +1,28 @@
/*
* chat_setting.h — chat-related settings (key-value API)
*
* Один API для всех потребителей: парсер конфига (headless) и GUI (через bridge).
* chat_setting_set(name, value) — валидация + хранение + apply к модулям.
* Возвращает 0=OK, -1=неизвестная настройка, -2=невалидное значение.
*/
#ifndef CHAT_SETTING_H
#define CHAT_SETTING_H
#ifdef __cplusplus
extern "C" {
#endif
/* Установить настройку. Применяет на лету если модуль уже инициализирован. */
int chat_setting_set(const char* name, const char* value);
/* Прочитать сохранённое значение. NULL если такой настройки нет. */
const char* chat_setting_get(const char* name);
/* Прочитать как int. Возвращает def если настройка не найдена. */
int chat_setting_get_int(const char* name, int def);
#ifdef __cplusplus
}
#endif
#endif /* CHAT_SETTING_H */

9
src/config_parser.c

@ -16,6 +16,7 @@
#include <netinet/in.h>
#endif
#include "../lib/mem.h"
#include "chat/chat_setting.h"
#define MAX_LINE_LEN 1024
#define INITIAL_ARRAY_CAPACITY 8
@ -49,6 +50,7 @@ typedef enum {
SECTION_TCP_PROXY_SERVER,
SECTION_NETWORK,
SECTION_GUI,
SECTION_CHAT,
SECTION_NTP,
SECTION_LOG_UDP
} section_type_t;
@ -755,6 +757,7 @@ static section_type_t parse_section_header(const char *line, char *name, size_t
if (strcasecmp(section, "tcp_proxy_server") == 0) return SECTION_TCP_PROXY_SERVER;
if (strcasecmp(section, "ntp") == 0) return SECTION_NTP;
if (strcasecmp(section, "gui") == 0) return SECTION_GUI;
if (strcasecmp(section, "chat") == 0) return SECTION_CHAT;
if (strcasecmp(section, "log_udp") == 0) return SECTION_LOG_UDP;
char *colon = strchr(section, ':');
@ -985,6 +988,11 @@ static struct utun_config* parse_config_internal(FILE *fp, const char *filename)
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'. Valid: enabled, server, interval", filename, line_num, key);
}
break;
case SECTION_CHAT:
if (chat_setting_set(key, value) < 0) {
/* chat_setting_set уже напечатал ошибку */
}
break;
case SECTION_GUI:
break;
default:
@ -1108,6 +1116,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha
case SECTION_TCP_PROXY_SERVER: cfg->global.tcp_proxy_server_enabled = 1; if (strcmp(key, "tcp_recv_buf") == 0) cfg->global.tcp_recv_buf = atoi(value); break;
case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break;
case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break;
case SECTION_CHAT: chat_setting_set(key, value); break;
case SECTION_GUI: break;
case SECTION_LOG_UDP:
if (strcmp(key, "ip") == 0) strncpy(cfg->global.log_udp_ip, value, sizeof(cfg->global.log_udp_ip) - 1);

2
tests/test_media_delivery_full.c

@ -71,6 +71,7 @@ static int db_count(sqlite3* db, const char* tbl, const char* wh, uint64_t val)
static void to_cb(void* arg) { (void)arg; fprintf(stderr, " TIMEOUT\n"); g_result = 2; }
static void mon(void* arg) {
(void)arg;
if (g_result) return;
int ok = 0;
for (int i = 0; i < N_NODES; i++) {
struct ll_entry* e = g_inst[i]->connections->head;
@ -303,6 +304,7 @@ int main(void) {
printf("\n%d/%d passed, %d failed\n", G_PASSED, G_TOTAL, G_FAILED); fflush(stdout);
done:
g_result = 1;
for (int i = 0; i < N_NODES; i++) { if (g_inst[i]) { g_inst[i]->running = 0; utun_instance_destroy(g_inst[i]); g_inst[i] = NULL; } }
if (g_ua) { uasync_destroy(g_ua, 0); g_ua = NULL; }
t_rmdir(g_tdir);

10
tools/chatgui-android/AGENTS.md

@ -189,6 +189,16 @@ allow_all=yes
[ntp]
enabled=no
[chat]
storage_autoload=1
storage_autoload_maxsize_mb=10
storage_maxsize_gb=1
opus_codec_preset=1
compressor_enabled=0
compressor_max_gain_db=25
compressor_rise_rate=10
media_download_max_peers=3
[debug]
console_level=info

51
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt

@ -36,9 +36,14 @@ 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 chatStorageAutoload = intPreferencesKey("chat_storage_autoload")
private val chatStorageAutoloadMaxsizeMb = intPreferencesKey("chat_storage_autoload_maxsize_mb")
private val chatStorageMaxsizeGb = intPreferencesKey("chat_storage_maxsize_gb")
private val chatOpusCodecPreset = intPreferencesKey("chat_opus_codec_preset")
private val chatCompressorEnabled = intPreferencesKey("chat_compressor_enabled")
private val chatCompressorMaxGainDb = intPreferencesKey("chat_compressor_max_gain_db")
private val chatCompressorRiseRate = intPreferencesKey("chat_compressor_rise_rate")
private val chatMediaDownloadMaxPeers = intPreferencesKey("chat_media_download_max_peers")
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
private val gson = Gson()
@ -133,9 +138,14 @@ 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
key == "chat.storage_autoload" -> context.dataStore.data.first()[chatStorageAutoload] ?: 1
key == "chat.storage_autoload_maxsize_mb" -> context.dataStore.data.first()[chatStorageAutoloadMaxsizeMb] ?: 10
key == "chat.storage_maxsize_gb" -> context.dataStore.data.first()[chatStorageMaxsizeGb] ?: 1
key == "chat.opus_codec_preset" -> context.dataStore.data.first()[chatOpusCodecPreset] ?: 1
key == "chat.compressor_enabled" -> context.dataStore.data.first()[chatCompressorEnabled] ?: 0
key == "chat.compressor_max_gain_db" -> context.dataStore.data.first()[chatCompressorMaxGainDb] ?: 25
key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 10
key == "chat.media_download_max_peers" -> context.dataStore.data.first()[chatMediaDownloadMaxPeers] ?: 3
else -> 0
}
}
@ -155,9 +165,14 @@ 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
"chat.storage_autoload" -> prefs[chatStorageAutoload] = value.toIntOrNull() ?: 1
"chat.storage_autoload_maxsize_mb" -> prefs[chatStorageAutoloadMaxsizeMb] = value.toIntOrNull() ?: 10
"chat.storage_maxsize_gb" -> prefs[chatStorageMaxsizeGb] = value.toIntOrNull() ?: 1
"chat.opus_codec_preset" -> prefs[chatOpusCodecPreset] = value.toIntOrNull() ?: 1
"chat.compressor_enabled" -> prefs[chatCompressorEnabled] = value.toIntOrNull() ?: 0
"chat.compressor_max_gain_db" -> prefs[chatCompressorMaxGainDb] = value.toIntOrNull() ?: 25
"chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 10
"chat.media_download_max_peers" -> prefs[chatMediaDownloadMaxPeers] = value.toIntOrNull() ?: 3
}
}
}
@ -281,6 +296,24 @@ class ConfigProvider(private val context: Context) {
appendLine("[ntp]")
appendLine("enabled=no")
appendLine()
appendLine("[chat]")
val chatAutoload = getInt("chat.storage_autoload")
val chatAutoloadMaxMb = getInt("chat.storage_autoload_maxsize_mb")
val chatMaxGb = getInt("chat.storage_maxsize_gb")
val chatOpusPreset = getInt("chat.opus_codec_preset")
val chatCompEnabled = getInt("chat.compressor_enabled")
val chatCompMaxGain = getInt("chat.compressor_max_gain_db")
val chatCompRiseRate = getInt("chat.compressor_rise_rate")
val chatDlMaxPeers = getInt("chat.media_download_max_peers")
appendLine("storage_autoload=$chatAutoload")
appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb")
appendLine("storage_maxsize_gb=$chatMaxGb")
appendLine("opus_codec_preset=$chatOpusPreset")
appendLine("compressor_enabled=$chatCompEnabled")
appendLine("compressor_max_gain_db=$chatCompMaxGain")
appendLine("compressor_rise_rate=$chatCompRiseRate")
appendLine("media_download_max_peers=$chatDlMaxPeers")
appendLine()
appendLine("[debug]")
appendLine("console_level=info")
val udpIp = getString("log_udp.ip") ?: ""

6
tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt

@ -72,6 +72,10 @@ object NativeLib {
fun clearDatabase(): Boolean = nativeClearDatabase()
fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) }
/* ── Chat settings (live update via C side) ── */
fun setChatSetting(name: String, value: String) { nativeSetChatSetting(name, value) }
fun getChatSettingInt(name: String, def: Int = 0): Int = nativeGetChatSettingInt(name, def)
/** Enable/disable forwarding native C logs to LogManager (expensive, off by default) */
fun subscribeNativeLogs(enabled: Boolean) { nativeSubscribeLogs(enabled) }
fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
@ -114,6 +118,8 @@ object NativeLib {
private external fun nativeGetChannels(): String?
private external fun nativeGetMessages(channelId: String, limit: Int): String?
private external fun nativeSetDebugLevel(category: String, level: String)
private external fun nativeSetChatSetting(name: String, value: String)
private external fun nativeGetChatSettingInt(name: String, def: Int): Int
private external fun nativeSubscribeLogs(enabled: Boolean)
private external fun nativeSetUdpLogTarget(ip: String, port: Int)
private external fun nativeRegenerateKeys()

25
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

@ -324,6 +324,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
@Composable
private fun AudioSettingsTab(vm: ChatViewModel) {
val scope = rememberCoroutineScope()
val provider = ChatApplication.instance.configProvider
/* Mic test state */
var micTesting by remember { mutableStateOf(false) }
@ -352,6 +353,16 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
vm.setVoicePreset(preset)
vm.setVoiceCompressor(compressorOn)
vm.setCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRateTenths / 10f, targetLevelDb.toFloat())
scope.launch {
provider.setValue("chat.opus_codec_preset", preset.toString())
provider.setValue("chat.compressor_enabled", if (compressorOn) "1" else "0")
provider.setValue("chat.compressor_max_gain_db", maxGainDb.toString())
provider.setValue("chat.compressor_rise_rate", riseRateTenths.toString())
}
NativeLib.setChatSetting("opus_codec_preset", preset.toString())
NativeLib.setChatSetting("compressor_enabled", if (compressorOn) "1" else "0")
NativeLib.setChatSetting("compressor_max_gain_db", maxGainDb.toString())
NativeLib.setChatSetting("compressor_rise_rate", riseRateTenths.toString())
}
Column(
@ -427,9 +438,9 @@ private fun StorageSettingsTab() {
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()
autoload = provider.getInt("chat.storage_autoload") != 0
autoloadMaxSizeMb = provider.getInt("chat.storage_autoload_maxsize_mb").toString()
maxStorageGb = provider.getInt("chat.storage_maxsize_gb").toString()
}
Column(
@ -441,20 +452,22 @@ private fun StorageSettingsTab() {
Text("Autoload media", modifier = Modifier.weight(1f))
Switch(checked = autoload, onCheckedChange = {
autoload = it
scope.launch { provider.setValue("storage_autoload", if (it) "1" else "0") }
val s = if (it) "1" else "0"
scope.launch { provider.setValue("chat.storage_autoload", s) }
NativeLib.setChatSetting("storage_autoload", s)
})
}
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) } },
{ v -> autoloadMaxSizeMb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..500) { scope.launch { provider.setValue("chat.storage_autoload_maxsize_mb", v) }; NativeLib.setChatSetting("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) } },
{ v -> maxStorageGb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..100) { scope.launch { provider.setValue("chat.storage_maxsize_gb", v) }; NativeLib.setChatSetting("storage_maxsize_gb", v) } },
label = { Text("Max storage size (GB)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true)

19
tools/chatgui-android/headless/headless_control.c

@ -4,6 +4,7 @@
#include "headless_control.h"
#include "../libutun_lite/utun_config_api.h"
#include "../libutun_lite/invite_link_c.h"
#include "../../../src/chat/chat_setting.h"
#include <stdint.h>
#include <stdio.h>
@ -303,6 +304,20 @@ static int handle_join(int fd, int id, const char* json) {
return 0;
}
static int handle_chat_setting(int fd, int id, const char* json) {
char name[128], value[128];
if (json_get_str(json, "name", name, sizeof(name)) < 0 || json_get_str(json, "value", value, sizeof(value)) < 0) {
send_response(fd, id, NULL, "missing name or value");
return 0;
}
int rc = chat_setting_set(name, value);
if (rc == -1) { send_response(fd, id, NULL, "unknown setting"); return 0; }
if (rc == -2) { send_response(fd, id, NULL, "invalid value"); return 0; }
printf("control: chat_setting %s=%s\n", name, value);
send_response(fd, id, "{\"set\":true}", NULL);
return 0;
}
/* ── Command dispatch ── */
typedef int (*cmd_handler_fn)(int fd, int id, const char* json);
@ -310,13 +325,13 @@ typedef int (*cmd_handler_fn)(int fd, int id, const char* json);
static const char* const g_cmd_names[] = {
"ping", "status", "connect", "disconnect", "connections",
"create_channel", "channels", "send", "messages",
"debug_level", "subscribe", "join", "quit", NULL
"debug_level", "subscribe", "join", "chat_setting", "quit", NULL
};
static cmd_handler_fn g_cmd_handlers[] = {
handle_ping, handle_status, NULL, NULL, handle_connections,
NULL, NULL, NULL, NULL,
handle_debug_level, handle_subscribe, handle_join, handle_quit
handle_debug_level, handle_subscribe, handle_join, handle_chat_setting, handle_quit
};
static void handle_command(struct control_client* cli, const char* json) {

28
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -18,6 +18,7 @@
#include "../../../lib/mem.h"
#include "../../../lib/sqlite3.h"
#include "../../../src/chat/chat_core.h"
#include "../../../src/chat/chat_setting.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@ -435,6 +436,14 @@ void utun_bridge_set_debug_level(const char* category, const char* level) {
bridge_log(BLEV_INFO, "debug %s=%s", category ? category : "all", level ? level : "info");
}
void utun_bridge_chat_setting_set(const char* name, const char* value) {
chat_setting_set(name, value);
}
int utun_bridge_chat_setting_get_int(const char* name, int def) {
return chat_setting_get_int(name, def);
}
void utun_bridge_restart(const char* config_text) {
bridge_log(BLEV_INFO, "bridge restart: posting to instance");
instance_lite_restart(config_text);
@ -913,6 +922,25 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetDebugLevel(
if (c) (*env)->ReleaseStringUTFChars(env, category, c);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetChatSetting(
JNIEnv* env, jobject thiz, jstring name, jstring value) {
(void)thiz;
const char* n = (*env)->GetStringUTFChars(env, name, NULL);
const char* v = (*env)->GetStringUTFChars(env, value, NULL);
chat_setting_set(n, v);
if (v) (*env)->ReleaseStringUTFChars(env, value, v);
if (n) (*env)->ReleaseStringUTFChars(env, name, n);
}
JNIEXPORT jint JNICALL Java_com_utun_chat_data_NativeLib_nativeGetChatSettingInt(
JNIEnv* env, jobject thiz, jstring name, jint def) {
(void)thiz;
const char* n = (*env)->GetStringUTFChars(env, name, NULL);
int val = chat_setting_get_int(n, (int)def);
if (n) (*env)->ReleaseStringUTFChars(env, name, n);
return (jint)val;
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSubscribeLogs(
JNIEnv* env, jobject thiz, jboolean enabled) {
(void)thiz; (void)env;

5
tools/chatgui-android/jni_bridge/android_jni_bridge.h

@ -105,6 +105,11 @@ void utun_bridge_voice_decode_close(void* handle);
void utun_bridge_set_debug_level(const char* category, const char* level);
/* ── Chat settings ── */
void utun_bridge_chat_setting_set(const char* name, const char* value);
int utun_bridge_chat_setting_get_int(const char* name, int def);
#ifdef __cplusplus
}
#endif

19
tools/chatgui/src/audiodevicesettingspage.cpp

@ -9,6 +9,7 @@ extern "C" {
#include "../../lib/debug_config.h"
#include "../db/db_manager.h"
#include "../transport/gui_bridge.h"
#include "../transport/config_updater.h"
#include "../../lib/mem.h"
extern "C" {
#include "chat/chat_core.h"
@ -211,6 +212,24 @@ void AudioDeviceSettingsPage::applyAndSave() {
saveKey("compressor_max_gain_db", compMaxGain);
saveKey("compressor_rise_rate", compRiseRate);
/* also update [chat] config section + live apply for headless-compat settings */
if (!m_configPath.isEmpty()) {
auto postChat = [](const QString& path, const char* name, int val) {
config_update_option(path, "chat", name, QString::number(val), CONFIG_UPDATE_REPLACE);
QByteArray v = QByteArray::number(val);
size_t nlen = strlen(name), vlen = (size_t)v.size(), total = nlen + 1 + vlen + 1;
if (total > 256) return;
void* arg = u_malloc(total);
memcpy(arg, name, nlen + 1);
memcpy((char*)arg + nlen + 1, v.constData(), vlen + 1);
gui_bridge_post_uasync_fn(chat_core_set_setting_trampoline, arg);
};
postChat(m_configPath, "opus_codec_preset", codecPreset);
postChat(m_configPath, "compressor_enabled", compEnabled);
postChat(m_configPath, "compressor_max_gain_db", compMaxGain);
postChat(m_configPath, "compressor_rise_rate", compRiseRate);
}
m_savedOutputIdx = outIdx;
m_savedInputIdx = inIdx;
m_savedCodecPreset = codecPreset;

2
tools/chatgui/src/audiodevicesettingspage.h

@ -15,6 +15,7 @@ class AudioDeviceSettingsPage : public QWidget {
public:
explicit AudioDeviceSettingsPage(DbManager* db, AudioRecorder* recorder, QWidget* parent = nullptr);
~AudioDeviceSettingsPage() override;
void setConfigPath(const QString& path) { m_configPath = path; }
void loadFromDb();
void applyAndSave();
@ -31,6 +32,7 @@ private:
DbManager* m_db;
AudioRecorder* m_recorder = nullptr;
AudioRecorder* m_testRecorder = nullptr;
QString m_configPath;
QComboBox* m_outputCombo;
QComboBox* m_inputCombo;
QComboBox* m_codecPresetCombo;

2
tools/chatgui/src/mainwindow.cpp

@ -252,6 +252,8 @@ void MainWindow::setupBridgeCallbacks() {
if (!lastCid.isEmpty()) {
s_mainWindow->m_channelList->selectChannel(lastCid);
s_mainWindow->m_accountList->setChannel(lastCid);
s_mainWindow->m_currentChannelId = lastCid;
s_mainWindow->m_messageList->loadChannel(lastCid);
}
});
}

60
tools/chatgui/src/messagelist.cpp

@ -32,6 +32,8 @@ extern "C" {
#include "../../lib/u_async.h"
#include "../../lib/mem.h"
#include "../../lib/debug_config.h"
struct save_ui_state_arg { char data[256]; };
void chat_core_save_ui_state_trampoline(void* arg);
}
static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 36) {
@ -360,7 +362,7 @@ void MessageList::loadChannel(const QString& channelId) {
m_localNicks = m_db->getLocalNicknames(channelId);
auto msgs = m_db->getMessages(channelId, 100);
auto msgs = m_db->getMessages(channelId, 200);
for (const auto& m : msgs) {
AccountRow acc = m_db->getAccount(m.authorNodeId);
@ -450,7 +452,7 @@ void MessageList::refresh() {
setVoiceMessageRoles(item, m.data, m.contentType,
m_db ? m_db->dbPath() + "/media" : QString(), m_currentChannelId, m.localAttrs);
m_model->appendRow(item);
while (m_model->rowCount() > 100)
while (m_model->rowCount() > 200)
m_model->removeRow(0);
}
@ -495,29 +497,37 @@ void MessageList::addMessage(const QString& channelId, quint64 authorNodeId,
void MessageList::saveChannelState() {
if (m_currentChannelId.isEmpty() || m_model->rowCount() == 0) return;
int vpHeight = m_view->viewport()->height();
QModelIndex topIdx = m_view->indexAt(QPoint(0, 0));
QScrollBar* sb = m_view->verticalScrollBar();
bool atBottom = sb && (sb->value() >= sb->maximum());
qint64 lastPosMsgId = 0;
if (!atBottom && topIdx.isValid())
if (!atBottom) {
QModelIndex topIdx = m_view->indexAt(QPoint(0, 0));
if (topIdx.isValid())
lastPosMsgId = topIdx.data(MsgIdRole).toLongLong();
}
qint64 lastReadMsgId = m_lastReadMsgId;
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MessageList::saveChannelState ch=%s read=%lld pos=%lld",
qPrintable(m_currentChannelId), (long long)lastReadMsgId, (long long)lastPosMsgId);
if (lastReadMsgId == 0 && lastPosMsgId == 0) return;
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "MessageList::saveChannelState ch=%s bottom=%d read=%lld pos=%lld",
qPrintable(m_currentChannelId), (int)atBottom, (long long)lastReadMsgId, (long long)lastPosMsgId);
QByteArray cid = m_currentChannelId.toUtf8();
auto saveKey = [&cid](const char* suffix, qint64 val) {
auto postSaveKey = [&cid](const char* suffix, qint64 val) {
QByteArray key = "ch_" + cid + suffix;
QByteArray valStr = QByteArray::number(val);
chat_core_save_ui_state(key.constData(), valStr.constData());
size_t keyLen = (size_t)key.size(), valLen = (size_t)valStr.size();
size_t total = keyLen + 1 + valLen + 1;
if (total > 256) return;
struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg));
if (!arg) return;
memcpy(arg->data, key.constData(), keyLen + 1);
memcpy(arg->data + keyLen + 1, valStr.constData(), valLen + 1);
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
};
if (lastReadMsgId > 0) saveKey("_last_read", lastReadMsgId);
if (lastPosMsgId > 0) saveKey("_last_pos", lastPosMsgId);
if (lastReadMsgId > 0)
postSaveKey("_last_read", lastReadMsgId);
postSaveKey("_last_pos", lastPosMsgId);
}
QString MessageList::authorName(quint64 nodeId) const {
@ -589,26 +599,32 @@ void MessageList::updateReadPosition() {
qint64 lastPosMsgId = (!atBottom && topIdx.isValid()) ? topIdx.data(MsgIdRole).toLongLong() : 0;
DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG,
"MessageList::updateReadPosition ch=%s oldRead=%lld newRead=%lld pos=%lld lastVisRow=%d/%d",
"MessageList::updateReadPosition ch=%s oldRead=%lld newRead=%lld pos=%lld bottom=%d lastVisRow=%d/%d",
qPrintable(m_currentChannelId), (long long)m_lastReadMsgId,
(long long)newReadMsgId, (long long)lastPosMsgId,
(long long)newReadMsgId, (long long)lastPosMsgId, (int)atBottom,
lastFullyVisibleRow, m_model->rowCount());
QByteArray cid = m_currentChannelId.toUtf8();
auto postSaveKey = [&cid](const char* suffix, qint64 val) {
QByteArray key = "ch_" + cid + suffix;
QByteArray valStr = QByteArray::number(val);
size_t keyLen = (size_t)key.size(), valLen = (size_t)valStr.size();
size_t total = keyLen + 1 + valLen + 1;
if (total > 256) return;
struct save_ui_state_arg* arg = (struct save_ui_state_arg*)u_malloc(sizeof(struct save_ui_state_arg));
if (!arg) return;
memcpy(arg->data, key.constData(), keyLen + 1);
memcpy(arg->data + keyLen + 1, valStr.constData(), valLen + 1);
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
};
if (newReadMsgId > m_lastReadMsgId) {
m_lastReadMsgId = newReadMsgId;
QByteArray key = "ch_" + cid + "_last_read";
QByteArray valStr = QByteArray::number(m_lastReadMsgId);
chat_core_save_ui_state(key.constData(), valStr.constData());
postSaveKey("_last_read", m_lastReadMsgId);
emit readPositionChanged(m_currentChannelId, m_lastReadMsgId);
}
if (lastPosMsgId > 0) {
QByteArray posKey = "ch_" + cid + "_last_pos";
QByteArray posVal = QByteArray::number(lastPosMsgId);
chat_core_save_ui_state(posKey.constData(), posVal.constData());
}
postSaveKey("_last_pos", lastPosMsgId);
}
void MessageList::cleanupAnimations() {

2
tools/chatgui/src/settingsdialog.cpp

@ -88,6 +88,7 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod
// Storage page
m_storagePage = new StorageSettingsPage(db, m_pages);
m_storagePage->setConfigPath(m_configPath);
m_pages->addWidget(m_storagePage);
// Network page
@ -110,6 +111,7 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod
m_pages->addWidget(m_soundPage);
m_audioDevicePage = new AudioDeviceSettingsPage(db, recorder, m_pages);
m_audioDevicePage->setConfigPath(m_configPath);
m_pages->addWidget(m_audioDevicePage);
rightLayout->addWidget(m_pages, 1);

30
tools/chatgui/src/storagesettingspage.cpp

@ -1,6 +1,7 @@
#include "storagesettingspage.h"
#include "../db/db_manager.h"
#include "../transport/gui_bridge.h"
#include "../transport/config_updater.h"
#include "../../lib/mem.h"
extern "C" {
#include "chat/chat_core.h"
@ -22,6 +23,19 @@ static void saveUiStateInt(const char* key, int val) {
gui_bridge_post_uasync_fn(chat_core_save_ui_state_trampoline, arg);
}
static void postChatSetting(const QString& configPath, const char* name, int val) {
/* persist to config file */
config_update_option(configPath, "chat", name, QString::number(val), CONFIG_UPDATE_REPLACE);
/* apply on-the-fly via bridge */
QByteArray v = QByteArray::number(val);
size_t nlen = strlen(name), vlen = (size_t)v.size(), total = nlen + 1 + vlen + 1;
if (total > 256) return;
void* arg = u_malloc(total);
memcpy(arg, name, nlen + 1);
memcpy((char*)arg + nlen + 1, v.constData(), vlen + 1);
gui_bridge_post_uasync_fn(chat_core_set_setting_trampoline, arg);
}
StorageSettingsPage::StorageSettingsPage(DbManager* db, QWidget* parent)
: QWidget(parent)
, m_db(db)
@ -84,7 +98,17 @@ void StorageSettingsPage::loadFromDb() {
}
void StorageSettingsPage::applyAndSave() {
saveUiStateInt("storage_autoload", m_autoloadCheck->isChecked() ? 1 : 0);
saveUiStateInt("storage_autoload_maxsize_mb", m_autoloadMaxSize->value());
saveUiStateInt("storage_maxsize_gb", m_maxStorageSize->value());
int autoload = m_autoloadCheck->isChecked() ? 1 : 0;
int autoloadMaxSize = m_autoloadMaxSize->value();
int maxGb = m_maxStorageSize->value();
saveUiStateInt("storage_autoload", autoload);
saveUiStateInt("storage_autoload_maxsize_mb", autoloadMaxSize);
saveUiStateInt("storage_maxsize_gb", maxGb);
if (!m_configPath.isEmpty()) {
postChatSetting(m_configPath, "storage_autoload", autoload);
postChatSetting(m_configPath, "storage_autoload_maxsize_mb", autoloadMaxSize);
postChatSetting(m_configPath, "storage_maxsize_gb", maxGb);
}
}

2
tools/chatgui/src/storagesettingspage.h

@ -11,11 +11,13 @@ class StorageSettingsPage : public QWidget {
public:
explicit StorageSettingsPage(DbManager* db, QWidget* parent = nullptr);
void setConfigPath(const QString& path) { m_configPath = path; }
void loadFromDb();
void applyAndSave();
private:
DbManager* m_db = nullptr;
QString m_configPath;
QCheckBox* m_autoloadCheck;
QSpinBox* m_autoloadMaxSize;
QSpinBox* m_maxStorageSize;

Loading…
Cancel
Save