invite: add proto field (UDP/TCP/BOTH) to invite link binary format

Format per v4 addr: family(1) + socketId(1) + proto(1) + addr(4) + port(2) = 9 bytes (was 8)
Proto values: 0x01=UDP, 0x02=TCP, 0x03=BOTH
Affects: invite_link_c.h/c (Android), invite_link.h/cpp (desktop), chat_sync.c, conn_mgr.c, joindialog.cpp, utun_node.cpp
This commit is contained in:
evgeny committed 2026-07-28 00:34:07 +03:00
1 parent 5276c1288c
commit d0cca27edd
23 files changed
+329 -61

No files matched your search

@@ -1,13 +1,8 @@
package com.utun.chat
import android.app.Application
import android.content.Intent
import com.utun.chat.data.LogManager
import com.utun.chat.data.ConfigProvider
import com.utun.chat.headless.HeadlessService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class ChatApplication : Application() {
lateinit var logManager: LogManager
@@ -19,19 +14,15 @@ class ChatApplication : Application() {
logManager = LogManager
configProvider = ConfigProvider(this)
// First launch: generate keys + detect interfaces
val scope = CoroutineScope(Dispatchers.IO)
scope.launch {
if (configProvider.isFirstLaunch()) {
/* First launch config */
if (configProvider.isFirstLaunch()) {
kotlinx.coroutines.runBlocking {
LogManager.addLog("INFO", "APP", "First launch — configuring")
configProvider.generateKeys()
configProvider.detectInterfaces()
configProvider.markLaunched()
LogManager.addLog("INFO", "APP", "First launch done, ${configProvider.loadServers().size} interfaces detected")
}
// Start uTun service
val intent = Intent(this@ChatApplication, HeadlessService::class.java)
startService(intent)
}
}
@@ -5,12 +5,15 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.runtime.*
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.utun.chat.data.ChatRepository
import com.utun.chat.data.InviteData
import com.utun.chat.data.NativeLib
import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.screens.*
import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent
import java.io.File
class MainActivity : ComponentActivity() {
@@ -19,6 +22,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
/* Start uTun headless service (must be from foreground Activity on API 34+) */
ContextCompat.startForegroundService(this, Intent(this, HeadlessService::class.java))
val dbFile = File(filesDir, "chats.db")
val repo = ChatRepository(dbFile.absolutePath)
@@ -3,7 +3,8 @@ package com.utun.chat.data
import android.database.sqlite.SQLiteDatabase
import org.json.JSONObject
data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, val lastMsg: String = "")
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) {
@@ -11,12 +12,10 @@ class ChatRepository(dbPath: String) {
private var myNodeId: Long? = null
init {
/* Ensure DB file exists — wait up to 5 seconds for service to create it */
val f = java.io.File(dbPath)
var tries = 0
while (tries < 50 && !f.exists()) { Thread.sleep(100); tries++ }
db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY)
/* Don't crash if tables aren't created yet */
try {
db.rawQuery("SELECT node_id FROM local_identity WHERE id=1", null).use { cur ->
if (cur.moveToFirst()) myNodeId = cur.getLong(0)
@@ -31,18 +30,29 @@ class ChatRepository(dbPath: String) {
"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)
list.add(Channel(id = chId, name = name, lastMsgAt = lastAt, lastMsg = last))
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))
}
}
}
} catch (_: Exception) {}
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)
}
} catch (_: Exception) {}
return 0
}
fun getMessages(channelId: String, limit: Int = 50): List<Message> {
val tbl = msgTableName(channelId)
val list = mutableListOf<Message>()
@@ -63,17 +73,14 @@ class ChatRepository(dbPath: String) {
isOutgoing = mid != null && nodeId == mid))
}
}
} catch (_: Exception) { /* table may not exist yet */ }
} catch (_: Exception) {}
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 ->
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))
@@ -89,16 +96,20 @@ class ChatRepository(dbPath: String) {
fun connectNode(address: String, port: Int, pubkeyHex: String) = NativeLib.connectNode(address, port, pubkeyHex)
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) =
NativeLib.joinChannel(channelId, nodeId, pubkey, addrs, addrCount)
fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId)
fun isReady() = NativeLib.isReady()
companion object {
fun msgTableName(chId: String): String {
val san = StringBuilder()
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 == '_') san.append(c)
else san.append('_')
if (c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '_') sb.append(c)
else sb.append('_')
}
return "msg_$san"
return sb.toString()
}
fun parseMsgText(data: ByteArray?): String {
@@ -65,6 +65,7 @@ object NativeLib {
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) {
nativeJoinChannel(channelId, nodeId, pubkey, addrs, addrCount)
}
fun connectChannel(channelId: String) { nativeConnectChannel(channelId) }
fun getChannels(): String = nativeGetChannels() ?: "[]"
fun getMessages(channelId: String, limit: Int = 50): String = nativeGetMessages(channelId, limit) ?: "[]"
fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) }
@@ -81,6 +82,7 @@ object NativeLib {
private external fun nativeCreateChannel(name: String, channelId: String)
private external fun nativeConnectNode(address: String, port: Int, pubkeyHex: String)
private external fun nativeJoinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int)
private external fun nativeConnectChannel(channelId: String)
private external fun nativeGetChannels(): String?
private external fun nativeGetMessages(channelId: String, limit: Int): String?
private external fun nativeSetDebugLevel(category: String, level: String)
@@ -40,6 +40,7 @@ class HeadlessService : Service() {
val listenPort = provider.getInt("node.listen_port")
val configText = buildString {
appendLine("[global]")
if (nodeName.isNotBlank()) appendLine("my_node_name=$nodeName")
if (pubKey.length == 64) appendLine("my_public_key=$pubKey")
if (privKey.length == 64) appendLine("my_private_key=$privKey")
@@ -64,12 +65,6 @@ class HeadlessService : Service() {
/* Init callbacks first (log + config) — required before start */
NativeLib.init(dbDir, 9999)
NativeLib.setEventCallback(object : com.utun.chat.data.ChatEventCallback {
override fun onEvent(type: Int, data: ByteArray?) {
LogManager.addLog("DEBUG", "EVENT", "type=$type len=${data?.size ?: 0}")
}
})
val ok = NativeLib.start(configText)
if (ok) {
LogManager.addLog("INFO", "SRV", "uTun core started")
@@ -26,18 +26,18 @@ fun MessageBubble(message: Message) {
) {
Column(
modifier = Modifier
.widthIn(max = 280.dp)
.widthIn(max = 300.dp)
.clip(RoundedCornerShape(12.dp))
.background(bgColor)
.padding(10.dp)
.padding(12.dp)
) {
Text(message.author, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
Spacer(Modifier.height(2.dp))
Text(message.text, fontSize = 15.sp, color = textColor)
Text(message.text, fontSize = 17.sp, color = textColor)
Spacer(Modifier.height(2.dp))
Text(
formatTime(message.ts),
fontSize = 10.sp,
fontSize = 11.sp,
color = textColor.copy(alpha = 0.5f),
modifier = Modifier.align(Alignment.End)
)
@@ -50,10 +50,11 @@ fun InputBar(
text: String,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
enabled: Boolean = true
enabled: Boolean = true,
modifier: Modifier = Modifier
) {
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
modifier = modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
@@ -36,8 +36,8 @@ fun ChannelListScreen(
title = { Text("uTun Chat") },
actions = {
Text(
if (connected) "ON" else "OFF",
color = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
"ON",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
)
IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, "Join") }
@@ -73,7 +73,15 @@ fun ChannelListScreen(
}
Spacer(Modifier.width(12.dp))
Column {
Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Row {
Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
if (ch.peersOnline > 0) {
Text(" (${ch.peersOnline})",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp))
}
}
if (ch.lastMsg.isNotEmpty())
Text(ch.lastMsg, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
@@ -3,6 +3,7 @@ package com.utun.chat.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
@@ -24,6 +25,11 @@ fun ChatScreen(
onBack: () -> Unit
) {
var text by remember { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
}
Scaffold(
topBar = {
@@ -42,15 +48,18 @@ fun ChatScreen(
onSendMessage(text)
text = ""
},
enabled = connected
enabled = true,
modifier = Modifier.navigationBarsPadding()
)
}
) { padding ->
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding),
reverseLayout = false
modifier = Modifier.fillMaxSize().padding(padding).imePadding(),
state = listState
) {
items(messages) { msg -> MessageBubble(msg) }
items(messages.size) { idx ->
MessageBubble(messages[idx])
}
item { Spacer(Modifier.height(8.dp)) }
}
}
@@ -35,6 +35,7 @@ class ChatViewModel(private val repo: ChatRepository) : ViewModel() {
}
}
}
8 -> refreshChannels() /* CHANNEL_PEERS_ONLINE */
2 -> { /* CONNECT_RESULT: [node_id:8][result:4][channel_id:8] */
if (data != null && data.size >= 20) {
val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN)
@@ -68,6 +69,7 @@ class ChatViewModel(private val repo: ChatRepository) : ViewModel() {
fun selectChannel(channel: Channel) {
_currentChannel.value = channel
repo.connectChannel(channel.id)
refreshMessages(channel.id)
}