diff --git a/src/chat/chat_sync.c b/src/chat/chat_sync.c index 9cc7f830..6d5d0d95 100644 --- a/src/chat/chat_sync.c +++ b/src/chat/chat_sync.c @@ -641,7 +641,16 @@ static void cm_invite_trampoline(void* arg) { a4->next = ni->v4_addrs; ni->v4_addrs = a4; } - if (inst->topo_groups && ni->v4_addrs) { + struct ETCP_CONN* existing = instance_find_conn(inst, node_id); + if (existing) { + if (existing->links_up) { + DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: invite conn already UP node=0x%016llx, reusing", CS_ID, (unsigned long long)node_id); + if (g_cs && g_cs->pending_invite_ch_id != 0 && !g_cs->info_req_timer) + cs_on_conn_up(existing, NULL); + } else { + DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: invite conn in progress node=0x%016llx, waiting UP", CS_ID, (unsigned long long)node_id); + } + } else if (inst->topo_groups && ni->v4_addrs) { struct TOPO_GROUP* g = topo_groups_find(inst->topo_groups, channel_id); if (g && g->conn_mgr) { conn_mgr_connect_from_invite(g->conn_mgr, ni, channel_id, 0, NULL, NULL); @@ -652,14 +661,6 @@ static void cm_invite_trampoline(void* arg) { (void*)inst->topo_groups, (void*)ni->v4_addrs); } - if (g_cs && g_cs->pending_invite_ch_id != 0 && !g_cs->info_req_timer) { - struct ETCP_CONN* conn = instance_find_conn(inst, node_id); - if (conn && conn->links_up) { - DEBUG_INFO(DEBUG_CATEGORY_DB_SYNC, "%s: invite conn already UP node=0x%016llx, triggering cs_on_conn_up", CS_ID, (unsigned long long)node_id); - cs_on_conn_up(conn, NULL); - } - } - /* free caller-owned ni */ while (ni->v4_addrs) { struct TOPO_ADDR4* n = ni->v4_addrs->next; u_free(ni->v4_addrs); ni->v4_addrs = n; } u_free(ni); diff --git a/src/transport_layer/etcp_connections.c b/src/transport_layer/etcp_connections.c index 79408dfc..c31fbb63 100644 --- a/src/transport_layer/etcp_connections.c +++ b/src/transport_layer/etcp_connections.c @@ -1624,8 +1624,8 @@ void etcp_connections_read_callback_socket(socket_t sock, void* arg) { (unsigned long long)link->etcp->crypto_ctx.rx_counter, pkt_len); goto process_decrypted; } - DEBUG_INFO(DEBUG_CATEGORY_ETCP, "etcp: DECRYPT FAIL rc=%d link=%p log=%s sess=%d link_state=%d keepalive=%d enc_errs=%zu my_pub=%016llx peer_pub=%016llx seskey=%02x%02x%02x%02x", - dec_rc, link, link->etcp->log_name, link->etcp->crypto_ctx.session_ready, + DEBUG_DEBUG(DEBUG_CATEGORY_ETCP, "etcp: DECRYPT FAIL on existing link. rc=%d link=%p log=%s from=%s sess=%d link_state=%d keepalive=%d enc_errs=%zu my_pub=%016llx peer_pub=%016llx seskey=%02x%02x%02x%02x", + dec_rc, link, link->etcp->log_name, sockaddr_storage_to_str(&addr).str, link->etcp->crypto_ctx.session_ready, link->link_state, link->recv_keepalive, link->encrypt_errors, *(uint64_t*)link->etcp->crypto_ctx.pk->public_key, *(uint64_t*)link->etcp->crypto_ctx.peer_public_key, link->etcp->crypto_ctx.session_key[0], link->etcp->crypto_ctx.session_key[1], @@ -1634,7 +1634,7 @@ void etcp_connections_read_callback_socket(socket_t sock, void* arg) { DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "SKIP normal decrypt: link=%p session_ready=%d — trying init decrypt", link, link && link->etcp ? link->etcp->crypto_ctx.session_ready : -1); } - + // Try INIT decryption (for incoming connection requests) // This handles: no link found, or link without session, or normal decrypt failed if (recv_len <= SC_PUBKEY_ENC_SIZE + UDP_SC_HDR_SIZE) { @@ -1642,16 +1642,16 @@ void etcp_connections_read_callback_socket(socket_t sock, void* arg) { errorcode=1; goto ec_fr; } - + struct secure_channel sc; sc_init_ctx(&sc, &e_sock->instance->my_keys); - + const uint8_t* salt = data + recv_len - SC_PUBKEY_ENC_SIZE; const uint8_t* encrypted_pubkey = salt + SC_PUBKEY_ENC_SALT_SIZE; - + uint8_t decrypted_pubkey[SC_PUBKEY_SIZE]; sc_obfuscate_pubkey(salt, e_sock->instance->my_keys.public_key, encrypted_pubkey, decrypted_pubkey); - + if (sc_set_peer_public_key(&sc, decrypted_pubkey, SC_PEER_PUBKEY_BIN)!=SC_OK) { DEBUG_ERROR(DEBUG_CATEGORY_CRYPTO, "failed to set peer public key during init, from %s", sockaddr_storage_to_str(&addr).str); errorcode=2; @@ -1664,7 +1664,18 @@ void etcp_connections_read_callback_socket(socket_t sock, void* arg) { salt[0], salt[1], salt[2], salt[3], salt[4], salt[5], salt[6], salt[7], encrypted_pubkey[0], encrypted_pubkey[1], encrypted_pubkey[2], encrypted_pubkey[3]); if (sc_decrypt(&sc, data, recv_len - SC_PUBKEY_ENC_SIZE, (uint8_t*)&pkt->timestamp, &pkt_len)) { - DEBUG_ERROR(DEBUG_CATEGORY_CRYPTO, "failed to decrypt init packet, from %s", sockaddr_storage_to_str(&addr).str); + if (link) { + DEBUG_ERROR(DEBUG_CATEGORY_CRYPTO, + "packet undecryptable (normal+init fail) from=%s — existing link log=%s state=%d sess=%d", + sockaddr_storage_to_str(&addr).str, + link->etcp ? link->etcp->log_name : "null", + link->link_state, + link->etcp ? link->etcp->crypto_ctx.session_ready : -1); + } else { + DEBUG_ERROR(DEBUG_CATEGORY_CRYPTO, + "packet undecryptable (normal+init fail) from=%s — no link for this address", + sockaddr_storage_to_str(&addr).str); + } errorcode=3; goto ec_fr; } @@ -1716,6 +1727,11 @@ void etcp_connections_read_callback_socket(socket_t sock, void* arg) { goto ec_fr; } + if (link) + DEBUG_INFO(DEBUG_CATEGORY_CONNECTION, "INIT packet on existing link from=%s log=%s link_state=%d", + sockaddr_storage_to_str(&addr).str, + link->etcp ? link->etcp->log_name : "null", link->link_state); + // Check allowed keys for incoming connections struct global_config *global = &e_sock->instance->config->global; if (!global->allowed_keys_allow_all) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 0f8cb1ce..937aba5f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -1,8 +1,23 @@ package com.utun.chat import android.app.Application +import android.util.Log import com.utun.chat.data.LogManager import com.utun.chat.data.ConfigProvider +import com.utun.chat.data.NativeLib +import com.utun.chat.data.ChatEventCallback +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +object AppEventHandler : ChatEventCallback { + private val _events = MutableSharedFlow>(replay = 1, extraBufferCapacity = 64) + val events: SharedFlow> = _events + + override fun onEvent(type: Int, data: ByteArray?) { + Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}") + _events.tryEmit(type to data) + } +} class ChatApplication : Application() { lateinit var logManager: LogManager @@ -14,6 +29,9 @@ class ChatApplication : Application() { logManager = LogManager configProvider = ConfigProvider(this) + /* One-time event callback registration — survives ViewModel recreation */ + NativeLib.setEventCallback(AppEventHandler) + /* First launch config */ if (configProvider.isFirstLaunch()) { kotlinx.coroutines.runBlocking { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 6ddb977b..30798e21 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -46,14 +46,17 @@ class MainActivity : ComponentActivity() { when (screen) { Screen.ChannelList -> { val channels by vm.channels.collectAsState() + val stuck by vm.isStuck.collectAsState() ChannelListScreen( channels = channels, connected = connected, + isStuck = stuck, onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, onCreateChannel = { vm.createChannel("general", "channel_1") }, onJoinChannel = { showJoinDialog = true; joinAutoLink = null }, onSettings = { screen = Screen.Settings }, - onLogs = { screen = Screen.Logs } + onLogs = { screen = Screen.Logs }, + onRefresh = { vm.requestRefresh() } ) } Screen.Chat -> { @@ -69,7 +72,7 @@ class MainActivity : ComponentActivity() { ) } } - Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }) + Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() }) Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList }) Screen.QrScan -> { QrScanScreen( diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 26f2140e..052a7a91 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -1,93 +1,63 @@ package com.utun.chat.data -import android.database.sqlite.SQLiteDatabase +import android.util.Log +import org.json.JSONArray import org.json.JSONObject data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "", val peersOnline: Int = 0) data class Message(val id: Long, val author: String, val text: String, val ts: Long, val isOutgoing: Boolean = false) -class ChatRepository(dbPath: String) { - private val db: SQLiteDatabase - private var myNodeId: Long? = null - - init { - db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY) - try { - db.rawQuery("SELECT node_id FROM local_identity WHERE id=1", null).use { cur -> - if (cur.moveToFirst()) myNodeId = cur.getLong(0) - } - } catch (_: Exception) {} - } +class ChatRepository { + private val myNodeId: Long = NativeLib.getMyNodeId() fun getChannels(): List { val list = mutableListOf() try { - db.rawQuery( - "SELECT channel_id, name, last_msg_at FROM channels ORDER BY last_msg_at DESC, name ASC", - null - ).use { cur -> - while (cur.moveToNext()) { - val chId = cur.getString(0) ?: continue - val name = cur.getString(1) ?: chId - val lastAt = cur.getLong(2) - val last = getLastMsgText(chId) - val online = getPeersOnline(chId) - list.add(Channel(id = chId, name = name, lastMsgAt = lastAt, lastMsg = last, peersOnline = online)) - } + val arr = JSONArray(NativeLib.getChannels()) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + list.add(Channel( + id = obj.getString("id"), + name = obj.optString("name", obj.getString("id")), + lastMsgAt = obj.optLong("lastMsgAt", 0), + lastMsg = obj.optString("lastMsg", ""), + peersOnline = obj.optInt("peersOnline", 0) + )) } - } catch (_: Exception) {} + } catch (e: Exception) { Log.w("utun-gui", "getChannels error", e) } return list } fun getPeersOnline(channelId: String): Int { - val tbl = peersTableName(channelId) try { - db.rawQuery("SELECT COUNT(*) FROM \"$tbl\"", null).use { cur -> - if (cur.moveToFirst()) return cur.getInt(0) + val arr = JSONArray(NativeLib.getChannels()) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + if (obj.getString("id") == channelId) return obj.optInt("peersOnline", 0) } - } catch (_: Exception) {} + } catch (e: Exception) { Log.w("utun-gui", "getPeersOnline error", e) } return 0 } fun getMessages(channelId: String, limit: Int = 50): List { - val tbl = msgTableName(channelId) val list = mutableListOf() - val mid = myNodeId try { - db.rawQuery( - "SELECT id, node_id, data, timestamp FROM \"$tbl\" ORDER BY timestamp ASC LIMIT ?", - arrayOf(limit.toString()) - ).use { cur -> - while (cur.moveToNext()) { - val id = cur.getLong(0) - val nodeId = cur.getLong(1) - val jdata = cur.getBlob(2) - val ts = cur.getLong(3) - val text = parseMsgText(jdata) - val author = nodeId.toHex() - list.add(Message(id = id, author = author, text = text, ts = ts, - isOutgoing = mid != null && nodeId == mid)) - } + val arr = JSONArray(NativeLib.getMessages(channelId, limit)) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + list.add(Message( + id = obj.getLong("id"), + author = obj.getString("author"), + text = obj.getString("text"), + ts = obj.getLong("ts"), + isOutgoing = obj.optBoolean("isOutgoing", false) + )) } - } catch (_: Exception) {} + } catch (e: Exception) { Log.w("utun-gui", "getMessages error", e) } return list } - private fun getLastMsgText(channelId: String): String { - val tbl = msgTableName(channelId) - try { - db.rawQuery("SELECT node_id, data FROM \"$tbl\" ORDER BY id DESC LIMIT 1", null).use { cur -> - if (cur.moveToFirst()) { - val author = cur.getLong(0).toHex() - val text = parseMsgText(cur.getBlob(1)) - return "$author: ${text.take(60)}${if (text.length > 60) "..." else ""}" - } - } - } catch (_: Exception) {} - return "" - } - fun sendMessage(channelId: String, text: String) = NativeLib.sendMessage(channelId, text) fun createChannel(name: String, channelId: String) = NativeLib.createChannel(name, channelId) fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex) @@ -96,26 +66,5 @@ class ChatRepository(dbPath: String) { fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId) fun isReady() = NativeLib.isReady() - fun close() { db.close() } - - companion object { - fun msgTableName(chId: String): String = "msg_" + sanitize(chId) - fun peersTableName(chId: String): String = "peers_" + sanitize(chId) - - private fun sanitize(chId: String): String { - val sb = StringBuilder() - for (c in chId) { - if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '_') sb.append(c) - else sb.append('_') - } - return sb.toString() - } - - fun parseMsgText(data: ByteArray?): String { - if (data == null) return "" - return try { JSONObject(String(data)).optString("d", "") } catch (_: Exception) { "" } - } - - fun Long.toHex(): String = "0x%016x".format(this) - } + fun close() {} } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 6039997e..95c925fc 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -48,13 +48,16 @@ object NativeLib { } fun restart(dbPath: String, controlPort: Int, configText: String): Boolean { - stop() - init(dbPath, controlPort) - return start(configText) + return nativeRestart(configText) } fun isReady(): Boolean = running + fun getMyNodeId(): Long = nativeGetMyNodeId() + + fun ping() { nativePing() } + fun isResponsive(): Boolean = nativeIsResponsive() + fun sendMessage(channelId: String, text: String) { nativeSendMessage(channelId, text) } fun createChannel(name: String, channelId: String) { nativeCreateChannel(name, channelId) } fun connectNode(address: String, port: Int, pubkeyHex: String) { nativeConnectNode(address, port, pubkeyHex) } @@ -91,4 +94,8 @@ object NativeLib { private external fun nativeSetUdpLogTarget(ip: String, port: Int) private external fun nativeRegenerateKeys() private external fun nativeClearDatabase(): Boolean + private external fun nativeGetMyNodeId(): Long + private external fun nativeRestart(configText: String): Boolean + private external fun nativePing() + private external fun nativeIsResponsive(): Boolean } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt index 8c2b9bfc..49d787e9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -1,9 +1,11 @@ package com.utun.chat.ui.screens +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.QrCode @@ -11,7 +13,9 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Build import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -22,24 +26,28 @@ import com.utun.chat.data.Channel fun ChannelListScreen( channels: List, connected: Boolean, + isStuck: Boolean = false, onSelectChannel: (Channel) -> Unit, onCreateChannel: () -> Unit, onJoinChannel: () -> Unit, onSettings: () -> Unit, - onLogs: () -> Unit + onLogs: () -> Unit, + onRefresh: () -> Unit = {} ) { var showCreateDialog by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { onRefresh() } + Scaffold( topBar = { TopAppBar( title = { Text("uTun Chat") }, actions = { - Text( - "ON", - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(end = 8.dp) + Box( + Modifier.size(10.dp) + .background(if (isStuck) Color.Red else Color(0xFF4CAF50), CircleShape) ) + Spacer(Modifier.width(4.dp)) IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, "Join") } IconButton(onClick = onLogs) { Icon(Icons.Default.Build, "Logs") } IconButton(onClick = onSettings) { Icon(Icons.Default.Settings, "Settings") } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index f6654996..5e38581a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier @@ -36,7 +38,7 @@ private val PROTOCOLS = listOf("tcp", "udp") @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit) { +fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {}) { val provider = ChatApplication.instance.configProvider val scope = rememberCoroutineScope() var dirty by remember { mutableStateOf(false) } @@ -339,6 +341,40 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit) { } } Spacer(Modifier.height(16.dp)) + + Button( + onClick = { + scope.launch(Dispatchers.IO) { + val path = provider.getString("db.path") ?: "" + val port = provider.getInt("control.port") + val configText = provider.buildConfigText() + NativeLib.restart(path, port, configText) + } + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary) + ) { + Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Restart instance") + } + + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { + NativeLib.stop() + onExit() + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Stop All & Exit") + } + + Spacer(Modifier.height(16.dp)) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 85a61c48..ea7cfee4 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -4,16 +4,19 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.utun.chat.ChatApplication +import com.utun.chat.AppEventHandler import com.utun.chat.data.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class ChatViewModel : ViewModel() { private var repo: ChatRepository? = null + private var dbReady = false private val _channels = MutableStateFlow>(emptyList()) val channels: StateFlow> = _channels @@ -30,23 +33,44 @@ class ChatViewModel : ViewModel() { private val _generatingKeys = MutableStateFlow(false) val generatingKeys: StateFlow = _generatingKeys + private val _isStuck = MutableStateFlow(false) + val isStuck: StateFlow = _isStuck + init { - NativeLib.setEventCallback(object : ChatEventCallback { - override fun onEvent(type: Int, data: ByteArray?) = handleEvent(type, data) - }) + viewModelScope.launch { + AppEventHandler.events.collect { (type, data) -> handleEvent(type, data) } + } viewModelScope.launch(Dispatchers.IO) { val key = ChatApplication.instance.configProvider.getString("node.public_key") if (key != null && key.length == 64) _pubKey.value = key } + viewModelScope.launch { + delay(500) + if (!dbReady && NativeLib.isReady()) { + repo = ChatRepository() + dbReady = true + LogManager.addLog("INFO", "VM", "DB ready (proactive)") + refreshChannels() + } + } + viewModelScope.launch { + while (isActive) { + delay(3000) + if (!dbReady || !NativeLib.isReady()) continue + NativeLib.ping() + delay(100) + _isStuck.value = !NativeLib.isResponsive() + } + } } private fun handleEvent(type: Int, data: ByteArray?) { when (type) { 9 -> { /* DB_READY */ - if (repo != null) return - val dbDir = ChatApplication.instance.configProvider.getString("db.path") ?: "" - if (dbDir.isEmpty()) return - repo = ChatRepository("$dbDir/chats.db") + if (dbReady) return + repo = ChatRepository() + dbReady = true + LogManager.addLog("INFO", "VM", "DB ready") refreshChannels() } 11 -> { /* KEYS_GENERATED: [pub_hex:64] */ @@ -78,6 +102,8 @@ class ChatViewModel : ViewModel() { if (result == 0) refreshChannels() } 4 -> if (repo != null) refreshChannels() /* CHANNEL_UPDATED */ + 12 -> LogManager.addLog("INFO", "VM", "service started") + 13 -> LogManager.addLog("INFO", "VM", "service stopped") } } @@ -93,6 +119,10 @@ class ChatViewModel : ViewModel() { } } + fun requestRefresh() { + if (dbReady && repo != null) refreshChannels() + } + fun refreshChannels() { val r = repo ?: return viewModelScope.launch { diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 4ad2c74a..f06c9b61 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -152,14 +152,15 @@ void utun_bridge_destroy(void) { bridge_log(BLEV_INFO, "bridge destroy"); } -void utun_bridge_start(const char* config_text) { +int utun_bridge_start(const char* config_text) { bridge_log(BLEV_INFO, "bridge start: launching utun instance"); int rc = instance_lite_start(config_text); if (rc != 0) { bridge_log(BLEV_ERROR, "bridge start: instance_lite_start failed rc=%d", rc); } else { - bridge_log(BLEV_INFO, "bridge start: instance running, node_id ready"); + bridge_log(BLEV_INFO, "bridge start: instance thread created"); } + return rc; } void utun_bridge_poll(void) { @@ -210,21 +211,174 @@ void utun_bridge_connect_channel(const char* channel_id) { uasync_post(ua, chat_core_connect_channel_trampoline, cid); } +#include "chat/chat_core_priv.h" + +static char* json_escape_alloc(const char* str) { + if (!str) return u_strdup(""); + size_t len = strlen(str), cap = len * 2 + 8; + char* out = u_malloc(cap); + if (!out) return u_strdup(""); + size_t pos = 0; + for (size_t i = 0; i < len && pos < cap - 2; i++) { + char c = str[i]; + if (c == '"' || c == '\\') { out[pos++] = '\\'; out[pos++] = c; } + else if (c == '\n') { out[pos++] = '\\'; out[pos++] = 'n'; } + else if (c == '\r') { out[pos++] = '\\'; out[pos++] = 'r'; } + else if (c == '\t') { out[pos++] = '\\'; out[pos++] = 't'; } + else if ((unsigned char)c < 32) { pos += snprintf(out + pos, cap - pos, "\\u%04x", (unsigned char)c); } + else out[pos++] = c; + } + out[pos] = '\0'; + return out; +} + char* utun_bridge_get_channels_json(void) { - return strdup("[]"); + sqlite3* db = chat_core_get_db(); + if (!chat_core_is_initialized() || !db) { + bridge_log(BLEV_WARN, "get_channels: not initialized db=%p initialized=%d", + (void*)db, chat_core_is_initialized()); + return strdup("[]"); + } + size_t cap = 4096, pos = 1; + char* json = u_malloc(cap); + if (!json) return strdup("[]"); + json[0] = '['; + + sqlite3_stmt* st = NULL; + int rc = sqlite3_prepare_v2(db, "SELECT channel_id, name, last_msg_at FROM channels ORDER BY last_msg_at DESC, name ASC", -1, &st, NULL); + if (rc != SQLITE_OK) { u_free(json); return strdup("[]"); } + + int first = 1; + while (sqlite3_step(st) == SQLITE_ROW) { + const char* ch_id = (const char*)sqlite3_column_text(st, 0); + const char* name = (const char*)sqlite3_column_text(st, 1); + int64_t last_msg_at = sqlite3_column_int64(st, 2); + if (!ch_id) continue; + + char tbl_msg[80], tbl_peers[80], last_msg[128]; + msg_table_name(ch_id, tbl_msg, sizeof(tbl_msg)); + peers_table_name(ch_id, tbl_peers, sizeof(tbl_peers)); + last_msg[0] = '\0'; + + sqlite3_stmt* lm = NULL; + char sql[256]; + snprintf(sql, sizeof(sql), "SELECT node_id, data FROM \"%s\" ORDER BY id DESC LIMIT 1", tbl_msg); + if (sqlite3_prepare_v2(db, sql, -1, &lm, NULL) == SQLITE_OK) { + if (sqlite3_step(lm) == SQLITE_ROW) { + int64_t author = sqlite3_column_int64(lm, 0); + const uint8_t* dptr = sqlite3_column_blob(lm, 1); + int dlen = sqlite3_column_bytes(lm, 1); + char txt[128] = ""; + if (dptr && dlen > 0) { + const char* dp = (const char*)dptr; + const char* ds = strstr(dp, "\"d\":\""); + if (ds) { ds += 5; char* de = strchr((char*)ds, '"'); if (de) { size_t tl = (size_t)(de - ds); if (tl > 120) tl = 120; memcpy(txt, ds, tl); txt[tl] = '\0'; } } + } + snprintf(last_msg, sizeof(last_msg), "0x%016llx: %s", (unsigned long long)author, txt); + } + sqlite3_finalize(lm); + } + + int online = 0; + sqlite3_stmt* pc = NULL; + snprintf(sql, sizeof(sql), "SELECT COUNT(*) FROM \"%s\"", tbl_peers); + if (sqlite3_prepare_v2(db, sql, -1, &pc, NULL) == SQLITE_OK) { + if (sqlite3_step(pc) == SQLITE_ROW) online = sqlite3_column_int(pc, 0); + sqlite3_finalize(pc); + } + + char* esc_name = json_escape_alloc(name ? name : ch_id); + char* esc_msg = json_escape_alloc(last_msg); + const char* sep = first ? "" : ","; + first = 0; + + size_t needed = snprintf(NULL, 0, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}", + sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online); + while (pos + needed + 2 > cap) { cap *= 2; char* tmp = u_realloc(json, cap); if (!tmp) break; json = tmp; } + if (pos + needed + 2 <= cap) + pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}", + sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online); + u_free(esc_msg); + u_free(esc_name); + } + sqlite3_finalize(st); + + json[pos++] = ']'; json[pos] = '\0'; + return json; } char* utun_bridge_get_messages_json(const char* channel_id, int limit) { - (void)limit; - char buf[128]; - snprintf(buf, sizeof(buf), "{\"channel\":\"%s\",\"messages\":[]}", channel_id ? channel_id : ""); - return strdup(buf); + sqlite3* db = chat_core_get_db(); + if (!chat_core_is_initialized() || !db || !channel_id || !channel_id[0]) return strdup("[]"); + uint64_t my_id = g_cc.my_node_id; + + char tbl_msg[80]; + msg_table_name(channel_id, tbl_msg, sizeof(tbl_msg)); + + char sql[256]; + snprintf(sql, sizeof(sql), "SELECT id, node_id, data, timestamp FROM \"%s\" ORDER BY timestamp ASC LIMIT %d", tbl_msg, limit); + + sqlite3_stmt* st = NULL; + if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return strdup("[]"); + + size_t cap = 4096, pos = 1; + char* json = u_malloc(cap); + if (!json) { sqlite3_finalize(st); return strdup("[]"); } + json[0] = '['; + + int first = 1; + while (sqlite3_step(st) == SQLITE_ROW) { + int64_t msg_id = sqlite3_column_int64(st, 0); + int64_t node_id = sqlite3_column_int64(st, 1); + int64_t ts = sqlite3_column_int64(st, 3); + const uint8_t* dptr = sqlite3_column_blob(st, 2); + int dlen = sqlite3_column_bytes(st, 2); + + char txt[256] = ""; + if (dptr && dlen > 0) { + const char* ds = strstr((const char*)dptr, "\"d\":\""); + if (ds) { ds += 5; char* de = strchr((char*)ds, '"'); if (de) { size_t tl = (size_t)(de - ds); if (tl > 250) tl = 250; memcpy(txt, ds, tl); txt[tl] = '\0'; } } + } + char* esc_txt = json_escape_alloc(txt); + int is_out = (node_id == (int64_t)my_id) ? 1 : 0; + const char* sep = first ? "" : ","; + first = 0; + + size_t needed = snprintf(NULL, 0, "%s{\"id\":%lld,\"author\":\"0x%016llx\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s}", + sep, (long long)msg_id, (unsigned long long)node_id, esc_txt, (long long)ts, is_out ? "true" : "false"); + while (pos + needed + 2 > cap) { cap *= 2; char* tmp = u_realloc(json, cap); if (!tmp) break; json = tmp; } + if (pos + needed + 2 <= cap) + pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"0x%016llx\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s}", + sep, (long long)msg_id, (unsigned long long)node_id, esc_txt, (long long)ts, is_out ? "true" : "false"); + u_free(esc_txt); + } + sqlite3_finalize(st); + + json[pos++] = ']'; json[pos] = '\0'; + return json; +} + +uint64_t utun_bridge_get_my_node_id(void) { + return chat_core_is_initialized() ? g_cc.my_node_id : 0; } 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_restart(const char* config_text) { + bridge_log(BLEV_INFO, "bridge restart: posting to instance"); + instance_lite_restart(config_text); +} + +void utun_bridge_ping(void) { + instance_lite_ping(); +} + +int utun_bridge_is_responsive(void) { + return instance_lite_is_responsive(); +} + /* ────────────────────────────────────────────────────────────────── * JNI functions (compiled only for Android via NDK) * ────────────────────────────────────────────────────────────────── */ @@ -346,10 +500,9 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStart( /* Set up event forwarder (bridge config/log are set by nativeInit) */ instance_lite_set_event_handler(instance_event_handler); - utun_bridge_start(cfg); + int rc = utun_bridge_start(cfg); (*env)->ReleaseStringUTFChars(env, configText, cfg); - - return instance_lite_is_running() ? JNI_TRUE : JNI_FALSE; + return (rc >= 0) ? JNI_TRUE : JNI_FALSE; } /* ── nativeStop() ── */ @@ -485,7 +638,7 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetChannels( (void)thiz; char* json = utun_bridge_get_channels_json(); jstring result = (*env)->NewStringUTF(env, json); - free(json); + u_free(json); return result; } @@ -495,11 +648,40 @@ JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMessages( const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL); char* json = utun_bridge_get_messages_json(ch, (int)limit); jstring result = (*env)->NewStringUTF(env, json); - free(json); + u_free(json); (*env)->ReleaseStringUTFChars(env, channelId, ch); return result; } +JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMyNodeId( + JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + return (jlong)utun_bridge_get_my_node_id(); +} + +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRestart( + JNIEnv* env, jobject thiz, jstring configText) { + (void)thiz; + if (!configText) return JNI_FALSE; + const char* cfg = (*env)->GetStringUTFChars(env, configText, NULL); + if (!cfg) return JNI_FALSE; + utun_bridge_restart(cfg); + (*env)->ReleaseStringUTFChars(env, configText, cfg); + return JNI_TRUE; +} + +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeIsResponsive( + JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + return utun_bridge_is_responsive() ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativePing( + JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + utun_bridge_ping(); +} + JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeConnectChannel( JNIEnv* env, jobject thiz, jstring channelId) { (void)thiz; diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index fa54b51e..90314012 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -39,7 +39,7 @@ bool utun_bridge_init(const char* db_path, int control_port, const struct utun_bridge_config* config); /* ── Start full uTun stack from INI config text ── */ -void utun_bridge_start(const char* config_text); +int utun_bridge_start(const char* config_text); /* ── Shutdown uTun core ── */ void utun_bridge_destroy(void); @@ -63,6 +63,16 @@ void utun_bridge_connect_channel(const char* channel_id); char* utun_bridge_get_channels_json(void); char* utun_bridge_get_messages_json(const char* channel_id, int limit); +uint64_t utun_bridge_get_my_node_id(void); + +/* ── Restart (config update, key regen, DB clear) ── */ + +void utun_bridge_restart(const char* config_text); + +/* ── Health check ── */ + +void utun_bridge_ping(void); +int utun_bridge_is_responsive(void); /* ── Debug ── */ diff --git a/tools/chatgui-android/libutun_lite/instance_lite.c b/tools/chatgui-android/libutun_lite/instance_lite.c index c8c3a217..25113dce 100644 --- a/tools/chatgui-android/libutun_lite/instance_lite.c +++ b/tools/chatgui-android/libutun_lite/instance_lite.c @@ -139,44 +139,27 @@ static void chat_event_forward(int type, const uint8_t* data, int len) { static void* instance_thread(void* arg) { char* config_text = (char*)arg; - /* Parse config from text */ struct utun_config* config = parse_config_from_buf(config_text, strlen(config_text), "android"); u_free(config_text); - if (!config) { - IL_LOGE("parse_config_from_buf failed"); - g_running = 0; - return NULL; - } + if (!config) { IL_LOGE("parse_config_from_buf failed"); return NULL; } - /* Create uasync */ g_ua = uasync_create(); - if (!g_ua) { - IL_LOGE("uasync_create failed"); - free_config(config); - g_running = 0; - return NULL; - } + if (!g_ua) { IL_LOGE("uasync_create failed"); free_config(config); return NULL; } - /* Start UDP log early before any DEBUG messages */ if (config->global.log_udp_ip[0] && config->global.log_udp_port > 0) { udp_log_set_target(config->global.log_udp_ip, config->global.log_udp_port); uasync_set_timeout(g_ua, 2000, NULL, udp_trampoline, "udp_log_flush"); } - /* Set event handler before ensure_keys so KEYS_GENERATED event is delivered */ chat_event_set_handler(chat_event_forward); - - /* Generate keys if missing (before creating instance) */ ensure_keys(config); - /* Create UTUN_INSTANCE from parsed config */ g_inst = utun_instance_create_from_config(g_ua, config); if (!g_inst) { IL_LOGE("utun_instance_create_from_config failed"); uasync_destroy(g_ua, 0); g_ua = NULL; u_report_unfreed_blocks(); - g_running = 0; return NULL; } @@ -209,7 +192,6 @@ static void* instance_thread(void* arg) { uasync_destroy(g_ua, 0); g_ua = NULL; u_report_unfreed_blocks(); - g_running = 0; return NULL; } @@ -242,7 +224,6 @@ static void* instance_thread(void* arg) { g_ua = NULL; } u_report_unfreed_blocks(); - g_running = 0; chat_event_post(CHAT_EVT_SERVICE_STOPPED, NULL, 0); g_event_handler = NULL; pthread_detach(pthread_self()); @@ -253,16 +234,15 @@ static void* instance_thread(void* arg) { /* ── Public API ── */ int instance_lite_start(const char* config_text) { - if (!config_text || g_running) return -1; + if (!config_text) return -1; + if (g_inst) return 0; debug_config_init(); debug_set_level(DEBUG_LEVEL_INFO); debug_enable_function_name(0); - /* Extract db_path for later SQLite open */ cfg_get_val(config_text, "db_path", g_db_path, sizeof(g_db_path)); - /* Copy config text — the thread takes ownership */ char* config_copy = u_strdup(config_text); if (!config_copy) return -1; @@ -274,30 +254,116 @@ int instance_lite_start(const char* config_text) { return -1; } - /* Wait for init to complete (or fail) */ - for (int i = 0; i < 300 && g_running == 0 && g_inst == NULL; i++) - usleep(10000); - - if (!g_inst) { - IL_LOGE("init timed out or failed"); - g_stop = 1; - pthread_join(g_thread, NULL); - return -1; - } - IL_LOGI("started OK"); return 0; } void instance_lite_stop(void) { - if (!g_inst && !g_running) return; + if (!g_inst) return; IL_LOGI("stopping..."); g_stop = 1; if (g_ua) uasync_wakeup(g_ua); + for (int i = 0; i < 500 && g_inst != NULL; i++) usleep(10000); + if (g_inst) { + IL_LOGE("stop timeout, force cleanup"); + g_inst = NULL; + g_ua = NULL; + } + g_running = 0; +} + +static void restart_trampoline(void* arg) { + char* config_text = (char*)arg; + IL_LOGI("restart: begin"); + + chat_sync_destroy(g_inst); + chat_core_destroy(g_inst); + + if (g_inst->topo_sqlite_db) { + sqlite3_close(g_inst->topo_sqlite_db); + g_inst->topo_sqlite_db = NULL; + } + + struct utun_config* new_config = parse_config_from_buf(config_text, strlen(config_text), "android"); + u_free(config_text); + if (!new_config) { + IL_LOGE("restart: parse_config failed"); + g_running = 0; + return; + } + + free_config(g_inst->config); + g_inst->config = new_config; + + ensure_keys(new_config); + + uint8_t pk_bin[32]; + for (int i = 0; i < 32; i++) { unsigned int b; sscanf(new_config->global.my_public_key_hex + i * 2, "%2x", &b); pk_bin[i] = (uint8_t)b; } + g_inst->node_id = sc_derive_node_id_from_pubkey(pk_bin); + sc_init_local_keys(&g_inst->my_keys, new_config->global.my_public_key_hex, new_config->global.my_private_key_hex); + + if (new_config->global.log_udp_ip[0] && new_config->global.log_udp_port > 0) { + udp_log_set_target(new_config->global.log_udp_ip, new_config->global.log_udp_port); + uasync_set_timeout(g_ua, 2000, NULL, udp_trampoline, "udp_log_flush"); + } else { + udp_log_stop(); + } + + char db_file[512]; + snprintf(db_file, sizeof(db_file), "%s/chats.db", g_db_path); + int rc = sqlite3_open_v2(db_file, &g_inst->topo_sqlite_db, + SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, NULL); + if (rc == SQLITE_OK && g_inst->topo_sqlite_db) { + sqlite3_exec(g_inst->topo_sqlite_db, "PRAGMA journal_mode=WAL", NULL, NULL, NULL); + sqlite3_exec(g_inst->topo_sqlite_db, "PRAGMA foreign_keys=ON", NULL, NULL, NULL); + sqlite3_exec(g_inst->topo_sqlite_db, "PRAGMA synchronous=NORMAL", NULL, NULL, NULL); + topo_node_sqlite_init(g_inst->topo_sqlite_db); + IL_LOGI("restart: SQLite reopened %s", db_file); + } else { + IL_LOGE("restart: SQLite reopen failed rc=%d", rc); + g_running = 0; + return; + } + + if (utun_instance_init(g_inst) != 0) { + IL_LOGE("restart: utun_instance_init failed"); + g_running = 0; + return; + } + + IL_LOGI("restart: done"); +} + +void instance_lite_restart(const char* new_config_text) { + if (!g_ua || !g_inst || !new_config_text) return; + IL_LOGI("restart: posting to uasync"); + char* copy = u_strdup(new_config_text); + if (!copy) return; + uasync_post(g_ua, restart_trampoline, copy); +} + +/* ── Health check ping ── */ + +static volatile int g_ping_id = 0; +static volatile int g_pong_id = 0; + +static void ping_trampoline(void* arg) { + (void)arg; + g_pong_id = g_ping_id; +} + +void instance_lite_ping(void) { + if (!g_ua || !g_inst) return; + g_ping_id++; + uasync_post(g_ua, ping_trampoline, NULL); +} + +int instance_lite_is_responsive(void) { + return (g_pong_id == g_ping_id) ? 1 : 0; } int instance_lite_is_running(void) { - return g_running ? 1 : 0; + return g_inst != NULL ? 1 : 0; } struct UASYNC* instance_lite_get_uasync(void) { diff --git a/tools/chatgui-android/libutun_lite/instance_lite.h b/tools/chatgui-android/libutun_lite/instance_lite.h index 4e32114a..30e915aa 100644 --- a/tools/chatgui-android/libutun_lite/instance_lite.h +++ b/tools/chatgui-android/libutun_lite/instance_lite.h @@ -40,6 +40,15 @@ void instance_lite_set_event_handler(instance_lite_event_fn handler); * Call from any thread. Keys are regenerated asynchronously. */ void instance_lite_regenerate_keys(void); +/* Restart the instance internally (no thread kill). Stops chat_sync/chat_core, + * re-parses config, regenerates keys if needed, reopens DB, re-inits. + * Posts to uasync thread — restart happens asynchronously. */ +void instance_lite_restart(const char* new_config_text); + +/* Health check: posts a ping to uasync, returns responsiveness after delay */ +void instance_lite_ping(void); +int instance_lite_is_responsive(void); + #ifdef __cplusplus } #endif