Browse Source

Android: unify DB access via JNI/JSON, fix instance lifecycle, add restart + health check

- ChatRepository: remove SQLiteDatabase (READONLY 2nd handle), use JNI→shared g_cc.db (FULLMUTEX, like desktop)
- instance_lite: simplify start/stop lifecycle — start no longer waits for init, stop waits for thread exit with timeout, add internal restart (no thread kill)
- Restart trampoline: reinit UDP log target on config change
- Health check: ping/uasync_post round-trip every 3s, red dot on stall
- Settings: Restart instance button
- Fix: g_inst check instead of g_running for is_running()
- Fix: SharedFlow replay=1 + proactive DB_READY check in ViewModel
topo_upd
evgeny 2 months ago
parent
commit
f8d7c21a79
  1. 19
      src/chat/chat_sync.c
  2. 32
      src/transport_layer/etcp_connections.c
  3. 18
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  4. 7
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  5. 115
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  6. 13
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  7. 18
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  8. 38
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  9. 44
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  10. 206
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  11. 12
      tools/chatgui-android/jni_bridge/android_jni_bridge.h
  12. 140
      tools/chatgui-android/libutun_lite/instance_lite.c
  13. 9
      tools/chatgui-android/libutun_lite/instance_lite.h

19
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);

32
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) {

18
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<Pair<Int, ByteArray?>>(replay = 1, extraBufferCapacity = 64)
val events: SharedFlow<Pair<Int, ByteArray?>> = _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 {

7
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(

115
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<Channel> {
val list = mutableListOf<Channel>()
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<Message> {
val tbl = msgTableName(channelId)
val list = mutableListOf<Message>()
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() {}
}

13
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
}

18
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<Channel>,
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") }

38
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))
}
}

44
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<List<Channel>>(emptyList())
val channels: StateFlow<List<Channel>> = _channels
@ -30,23 +33,44 @@ class ChatViewModel : ViewModel() {
private val _generatingKeys = MutableStateFlow(false)
val generatingKeys: StateFlow<Boolean> = _generatingKeys
private val _isStuck = MutableStateFlow(false)
val isStuck: StateFlow<Boolean> = _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 {

206
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;

12
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 ── */

140
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) {

9
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

Loading…
Cancel
Save