wip: STCP TCP chat integration — diagnostics, invite TCP fallback, test infra

This commit is contained in:
evgeny committed 2026-08-06 18:41:41 +03:00
1 parent e2d8f68c9a
commit dfe9d36783
41 files changed
+2432 -294

No files matched your search

@@ -8,6 +8,7 @@ import java.nio.ByteOrder
const val EVT_NODEINFO_UPDATED = 16
const val EVT_LINK_STATUS_CHANGED = 17
const val EVT_LOCAL_SOCKETS = 19
data class NodeStatus(
val nodeId: Long = 0,
@@ -79,6 +80,14 @@ data class LinkStatus(val family: Int = 0, val address: String = "", val port: I
data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false,
val linksUp: Int = 0, val links: List<LinkStatus> = emptyList())
data class LocalSocketState(
val sockId: Int = 0, val ifName: String = "", val bindIp: String = "",
val port: Int = 0, val protocol: String = "", val state: Int = 0,
val linksCount: Int = 0
) {
val stateLabel: String get() = when (state) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" }
}
class ChatRepository {
private val myNodeId: Long = NativeLib.getMyNodeId()
@@ -292,5 +301,26 @@ class ChatRepository {
return MemberLinks()
}
fun getLocalSockets(): List<LocalSocketState> {
try {
val arr = JSONArray(NativeLib.getLocalSockets())
val list = mutableListOf<LocalSocketState>()
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
list.add(LocalSocketState(
sockId = obj.optInt("si", 0),
ifName = obj.optString("nm", ""),
bindIp = obj.optString("ad", ""),
port = obj.optInt("po", 0),
protocol = obj.optString("pt", ""),
state = obj.optInt("st", 0),
linksCount = obj.optInt("lk", 0)
))
}
return list
} catch (e: Exception) { Log.w("utun-gui", "getLocalSockets error", e) }
return emptyList()
}
fun close() {}
}
@@ -43,6 +43,7 @@ class ConfigProvider(private val context: Context) {
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 connectionModeKey = stringPreferencesKey("connection_mode")
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@@ -187,6 +188,16 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[firstLaunchDone] = true }
}
/* ── Connection mode ── */
fun getConnectionMode(): String = runBlocking {
context.dataStore.data.first()[connectionModeKey] ?: "manual"
}
suspend fun setConnectionMode(mode: String) {
context.dataStore.edit { prefs -> prefs[connectionModeKey] = mode }
}
/* ── Server entries ── */
suspend fun detectInterfaces(): List<ServerEntry> {
@@ -364,6 +375,13 @@ class ConfigProvider(private val context: Context) {
/** Build full INI config text for NativeLib.start() */
fun buildConfigText(): String {
if (getConnectionMode() == "auto") {
val detected = runBlocking { detectInterfaces() }
LogManager.addLog("INFO", "CFG", "buildConfigText: auto mode, detected ${detected.size} interfaces" +
detected.joinToString(", ") { "${it.ifName}=${it.protocol}" })
saveServersToConfig(detected)
return buildConfigFromServers(detected)
}
val cfgFile = File(configFilePath())
val existing = if (cfgFile.exists()) loadServersFromConfig() else emptyList()
LogManager.addLog("INFO", "CFG", "buildConfigText: exists=${cfgFile.exists()} srv=${existing.size}" +
@@ -86,6 +86,9 @@ object NativeLib {
}
fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}"
/* ── Local sockets (for auto-mode display) ── */
fun getLocalSockets(): String = nativeGetLocalSockets() ?: "[]"
/* ── Voice recording ── */
fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId)
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
@@ -145,6 +148,9 @@ object NativeLib {
private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean)
private external fun nativeGetMemberLinks(nodeId: Long): String?
/* ── Local sockets JNI ── */
private external fun nativeGetLocalSockets(): String?
/* ── Voice recording JNI ── */
private external fun nativeVoiceStart(channelId: String): Boolean
private external fun nativeVoiceFeed(samples: ShortArray): Boolean
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
import com.utun.chat.ChatApplication
import com.utun.chat.data.NativeLib
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -107,6 +108,9 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
var catLevels by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
var servers by remember { mutableStateOf<List<ServerEntry>>(emptyList()) }
var availableIfaces by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var connectionMode by remember { mutableStateOf("manual") }
val localSocketStates by vm.localSockets.collectAsState()
LaunchedEffect(Unit) {
nodeName = provider.getString("node.name") ?: provider.getDeviceName()
@@ -125,9 +129,18 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
catLevels = if (stored.isNotEmpty()) stored.split(",").associate { it.split("=", limit = 2).let { (k, v) -> k to v } } else emptyMap()
servers = provider.loadServersFromConfig()
availableIfaces = provider.getAvailableInterfaces()
connectionMode = provider.getConnectionMode()
}
val save = { s: List<ServerEntry> -> onDirty(true); servers = s; provider.saveServersToConfig(s) }
val switchMode = { mode: String ->
connectionMode = mode; onDirty(true)
scope.launch(Dispatchers.IO) {
provider.setConnectionMode(mode)
NativeLib.restart(provider.getString("db.path") ?: "",
provider.getInt("control.port"), provider.buildConfigText())
}
}
val setDebug = { level: String ->
onDirty(true); NativeLib.setDebugLevel("", level)
scope.launch { provider.setValue("debug.console_level", level) }; debugLevel = level
@@ -192,60 +205,97 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
HorizontalDivider(Modifier.padding(vertical = 8.dp))
/* Network Interfaces */
/* ── Connection Mode ── */
Text("Network Interfaces", style = MaterialTheme.typography.titleMedium)
servers.forEachIndexed { idx, srv ->
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
var ifExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = ifExpanded, onExpandedChange = { ifExpanded = it }, modifier = Modifier.weight(1f)) {
OutlinedTextField(srv.ifName, {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ifExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(ifExpanded, onDismissRequest = { ifExpanded = false }) {
availableIfaces.forEach { (n, ip) ->
DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = {
save(servers.toMutableList().also { it[idx] = it[idx].copy(ifName = n, bindIp = ip) }); ifExpanded = false
})
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = connectionMode == "manual", onClick = { if (connectionMode != "manual") switchMode("manual") },
label = { Text("Manual") })
FilterChip(selected = connectionMode == "auto", onClick = { if (connectionMode != "auto") switchMode("auto") },
label = { Text("Auto") })
}
if (connectionMode == "auto") {
/* Auto mode: read-only live socket display */
if (localSocketStates.isEmpty()) {
Text("No sockets detected", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
localSocketStates.forEach { s ->
val stateColor = when (s.state) { 2 -> Color(0xFF27AE60); 1 -> Color(0xFFF1C40F); else -> Color(0xFFE53935) }
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(s.ifName, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Spacer(Modifier.width(4.dp))
Text("${s.bindIp}:${s.port}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.width(6.dp))
Surface(shape = MaterialTheme.shapes.extraSmall, color = stateColor,
modifier = Modifier.size(10.dp)) {}
Spacer(Modifier.width(4.dp))
Text(s.stateLabel, style = MaterialTheme.typography.labelSmall, color = stateColor)
Spacer(Modifier.width(6.dp))
Text(s.protocol, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (s.linksCount > 0) {
Spacer(Modifier.width(4.dp))
Text("${s.linksCount} links", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
Spacer(Modifier.width(4.dp))
Text(srv.bindIp, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall,
maxLines = 1, modifier = Modifier.width(105.dp))
IconButton(onClick = { save(servers.toMutableList().also { it.removeAt(idx) }) }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Close, "Remove", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
var protoExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = protoExpanded, onExpandedChange = { protoExpanded = it }, modifier = Modifier.width(100.dp)) {
OutlinedTextField(srv.protocol.uppercase(), {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(protoExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(protoExpanded, onDismissRequest = { protoExpanded = false }) {
PROTOCOLS.forEach { p ->
DropdownMenuItem(text = { Text(p.uppercase()) }, onClick = {
save(servers.toMutableList().also { it[idx] = srv.copy(protocol = p) }); protoExpanded = false
})
}
}
}
Spacer(Modifier.width(4.dp))
var portText by remember(srv.id) { mutableStateOf(srv.port.toString()) }
OutlinedTextField(portText, { v -> portText = v; val p = v.toIntOrNull(); if (p != null && p in 1..65535)
save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) },
singleLine = true, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
}
}
Spacer(Modifier.height(2.dp))
} else {
/* Manual mode: existing editable server config */
servers.forEachIndexed { idx, srv ->
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
var ifExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = ifExpanded, onExpandedChange = { ifExpanded = it }, modifier = Modifier.weight(1f)) {
OutlinedTextField(srv.ifName, {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ifExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(ifExpanded, onDismissRequest = { ifExpanded = false }) {
availableIfaces.forEach { (n, ip) ->
DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = {
save(servers.toMutableList().also { it[idx] = it[idx].copy(ifName = n, bindIp = ip) }); ifExpanded = false
})
}
}
}
Spacer(Modifier.width(4.dp))
Text(srv.bindIp, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall,
maxLines = 1, modifier = Modifier.width(105.dp))
IconButton(onClick = { save(servers.toMutableList().also { it.removeAt(idx) }) }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Close, "Remove", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
var protoExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = protoExpanded, onExpandedChange = { protoExpanded = it }, modifier = Modifier.width(100.dp)) {
OutlinedTextField(srv.protocol.uppercase(), {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(protoExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(protoExpanded, onDismissRequest = { protoExpanded = false }) {
PROTOCOLS.forEach { p ->
DropdownMenuItem(text = { Text(p.uppercase()) }, onClick = {
save(servers.toMutableList().also { it[idx] = srv.copy(protocol = p) }); protoExpanded = false
})
}
}
}
Spacer(Modifier.width(4.dp))
var portText by remember(srv.id) { mutableStateOf(srv.port.toString()) }
OutlinedTextField(portText, { v -> portText = v; val p = v.toIntOrNull(); if (p != null && p in 1..65535)
save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) },
singleLine = true, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
}
}
Spacer(Modifier.height(2.dp))
}
TextButton(onClick = {
val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1")
val rndPort = (2000..9999).random()
save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort))
}, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Add interface") }
}
TextButton(onClick = {
val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1")
val rndPort = (2000..9999).random()
save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort))
}, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text("Add interface") }
HorizontalDivider(Modifier.padding(vertical = 8.dp))
@@ -65,6 +65,9 @@ class ChatViewModel : ViewModel() {
private val _nodeStatuses = MutableStateFlow<Map<Long, NodeStatus>>(emptyMap())
val nodeStatuses: StateFlow<Map<Long, NodeStatus>> = _nodeStatuses
private val _localSockets = MutableStateFlow<List<LocalSocketState>>(emptyList())
val localSockets: StateFlow<List<LocalSocketState>> = _localSockets
val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false)
@@ -143,6 +146,7 @@ class ChatViewModel : ViewModel() {
dbReady = true
LogManager.addLog("INFO", "VM", "DB ready")
refreshChannels()
refreshLocalSockets()
}
11 -> { /* KEYS_GENERATED: [pub_hex:64] */
if (data != null && data.size >= 64) {
@@ -209,6 +213,7 @@ class ChatViewModel : ViewModel() {
val detail = _selectedMember.value
if (detail != null && detail.nodeId == nodeId) refreshMemberLinks(nodeId)
}
19 -> { if (repo != null) refreshLocalSockets() } /* LOCAL_SOCKETS */
}
}
@@ -301,6 +306,13 @@ class ChatViewModel : ViewModel() {
}
}
fun refreshLocalSockets() {
val r = repo ?: return
viewModelScope.launch {
_localSockets.value = withContext(Dispatchers.IO) { r.getLocalSockets() }
}
}
fun sendMessage(text: String) {
val r = repo ?: return
val ch = _currentChannel.value ?: return
@@ -490,6 +502,7 @@ class ChatViewModel : ViewModel() {
_messages.value = emptyList()
_currentChannel.value = null
_generatingKeys.value = false
_localSockets.value = emptyList()
}
class Factory : ViewModelProvider.Factory {
@@ -1018,6 +1018,100 @@ char* utun_bridge_get_member_links_json(uint64_t node_id) {
return u_strdup(buf);
}
/* ──────────────────────────────────────────────────────────────────
* Local sockets state (for auto-mode UI display)
* ────────────────────────────────────────────────────────────────── */
#include "../../../lib/socket_compat.h"
char* utun_bridge_get_local_sockets_json(void) {
if (!chat_core_is_initialized() || !g_cc.inst) return u_strdup("[]");
struct UTUN_INSTANCE* inst = g_cc.inst;
/* count active TCP connections */
int active_conns = 0, active_links = 0;
if (inst->connections) {
for (struct ll_entry* entry = inst->connections->head; entry; entry = entry->next) {
struct conn_queue_entry* ce = (struct conn_queue_entry*)entry->data;
if (ce && ce->conn && ce->conn->transport_link && ce->conn->initialized) {
active_conns++;
active_links += ce->conn->links_up;
}
}
}
if (inst->tcp_connections) {
for (struct ll_entry* entry = inst->tcp_connections->head; entry; entry = entry->next) {
struct tcp_conn_entry* te = (struct tcp_conn_entry*)entry->data;
if (te && te->etcp_conn && te->etcp_conn->initialized) {
active_conns++;
active_links += te->etcp_conn->links_up;
}
}
}
size_t cap = 4096, pos = 1;
char* json = u_malloc(cap);
if (!json) return u_strdup("[]");
json[0] = '[';
int first = 1;
/* TCP sockets */
{ struct TCP_SOCKET* ts = inst->tcp_sockets;
while (ts) {
char ip_str[INET6_ADDRSTRLEN] = ""; int port = 0;
const struct sockaddr_storage* addr = ts->interface_addr.ss_family ? &ts->interface_addr : &ts->local_addr;
if (addr->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)addr;
port = ntohs(sin->sin_port);
inet_ntop(AF_INET, &sin->sin_addr, ip_str, sizeof(ip_str));
} else if (addr->ss_family == AF_INET6) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)addr;
port = ntohs(sin6->sin6_port);
inet_ntop(AF_INET6, &sin6->sin6_addr, ip_str, sizeof(ip_str));
}
int state = active_conns > 0 ? 2 : 0;
const char* sep = first ? "" : ","; first = 0;
size_t needed = snprintf(NULL, 0, "%s{\"si\":%u,\"nm\":\"%s\",\"ad\":\"%s\",\"po\":%d,\"pt\":\"TCP\",\"st\":%d,\"lk\":%d}",
sep, ts->sock_id, ts->name, ip_str, port, state, active_links);
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"si\":%u,\"nm\":\"%s\",\"ad\":\"%s\",\"po\":%d,\"pt\":\"TCP\",\"st\":%d,\"lk\":%d}",
sep, ts->sock_id, ts->name, ip_str, port, state, active_links);
ts = ts->next;
}
}
/* UDP sockets */
{ struct ETCP_SOCKET* es = inst->etcp_sockets;
while (es) {
char ip_str[INET6_ADDRSTRLEN] = ""; int port = 0;
const struct sockaddr_storage* addr = es->interface_addr.ss_family ? &es->interface_addr : &es->local_addr;
if (addr->ss_family == AF_INET) {
struct sockaddr_in* sin = (struct sockaddr_in*)addr;
port = ntohs(sin->sin_port);
inet_ntop(AF_INET, &sin->sin_addr, ip_str, sizeof(ip_str));
} else if (addr->ss_family == AF_INET6) {
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)addr;
port = ntohs(sin6->sin6_port);
inet_ntop(AF_INET6, &sin6->sin6_addr, ip_str, sizeof(ip_str));
}
int lk = es->links_queue ? queue_entry_count(es->links_queue) : 0;
int state = lk > 0 ? 2 : 0;
const char* sep = first ? "" : ","; first = 0;
size_t needed = snprintf(NULL, 0, "%s{\"si\":%u,\"nm\":\"%s\",\"ad\":\"%s\",\"po\":%d,\"pt\":\"UDP\",\"st\":%d,\"lk\":%d}",
sep, es->sock_id, es->name, ip_str, port, state, lk);
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"si\":%u,\"nm\":\"%s\",\"ad\":\"%s\",\"po\":%d,\"pt\":\"UDP\",\"st\":%d,\"lk\":%d}",
sep, es->sock_id, es->name, ip_str, port, state, lk);
es = es->next;
}
}
json[pos++] = ']'; json[pos] = '\0';
return json;
}
void utun_bridge_restart(const char* config_text) {
bridge_log(BLEV_INFO, "bridge restart: posting to instance");
#ifdef __ANDROID__
@@ -1804,4 +1898,15 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMemberLinks
return result;
}
/* ── Local sockets JSON ── */
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetLocalSockets(
JNIEnv* env, jobject thiz) {
(void)thiz;
char* json = utun_bridge_get_local_sockets_json();
jstring result = (*env)->NewStringUTF(env, json ? json : "[]");
u_free(json);
return result;
}
#endif /* __ANDROID__ */
@@ -125,6 +125,10 @@ void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id,
char* utun_bridge_get_member_links_json(uint64_t node_id);
/* ── Local sockets state (for auto-mode display) ── */
char* utun_bridge_get_local_sockets_json(void);
#ifdef __cplusplus
}
#endif
@@ -25,6 +25,7 @@
#include "transport_layer/etcp_connections.h"
#include "transport_layer/etcp.h"
#include "../jni_bridge/android_udp_log.h"
#include "../jni_bridge/android_jni_bridge.h"
#include <pthread.h>
#include <stdio.h>
#include <string.h>
@@ -169,6 +170,14 @@ static void nodeinfo_event_cb(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE*
g_event_handler(CHAT_EVT_NODEINFO_UPDATED, data, sizeof(data));
}
static void fire_local_sockets_event(void) {
if (!g_event_handler) return;
char* json = utun_bridge_get_local_sockets_json();
if (!json) return;
g_event_handler(CHAT_EVT_LOCAL_SOCKETS, (const uint8_t*)json, (int)strlen(json));
u_free(json);
}
static void on_link_status_changed(struct ETCP_CONN* conn, struct ETCP_LINK* link,
int old_state, int old_status, void* arg) {
(void)old_state; (void)old_status; (void)arg;
@@ -181,6 +190,7 @@ static void on_link_status_changed(struct ETCP_CONN* conn, struct ETCP_LINK* lin
data[11] = conn->links_up;
data[12] = conn->initialized ? 1 : 0;
g_event_handler(CHAT_EVT_LINK_STATUS_CHANGED, data, sizeof(data));
fire_local_sockets_event();
}
/* ── Crash handler ── */
@@ -283,6 +293,7 @@ static void* instance_thread(void* arg) {
/* sockets are now created — sync my addresses to DB */
chat_core_sync_my_addresses();
fire_local_sockets_event();
/* Bind chat sync via etcp_router */
etcp_router_bind(g_inst, ETCP_RT_ID_CHAT_SYNC, NULL);
@@ -358,6 +369,7 @@ static void* instance_thread(void* arg) {
if (utun_instance_init(g_inst) != 0) { IL_LOGE("poll exit: utun_instance_init failed"); break; }
chat_core_sync_my_addresses();
fire_local_sockets_event();
if (g_inst->config->global.name[0]) chat_core_update_my_name(g_inst->config->global.name);
etcp_router_bind(g_inst, ETCP_RT_ID_CHAT_SYNC, NULL);
utun_add_nodeinfo_cbk(g_inst, nodeinfo_event_cb, NULL);
@@ -34,7 +34,6 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
# ── Our added files ──
set(_cfg_src
"${CONFIG_DIR}/utun_config_api.c"
"${CONFIG_DIR}/invite_link_c.c"
"${CONFIG_DIR}/instance_lite.c"
"${CONFIG_DIR}/voice_recorder.c"
"${CONFIG_DIR}/attachment_sender.c"