android: real utun instance + SQLite channels + reactive logs + QR join
- instance_lite.h/c: full uTun stack from INI config text (keygen, uasync, UTUN_INSTANCE, SQLite, chat_core/sync, BGP, etcp_router) - jni_bridge: nativeStart/Stop, nativeJoinChannel → chat_sync_connect_from_invite, nativeSubscribeLogs, C logs → logcat + Kotlin LogManager - Kotlin: direct read-only SQLite queries (channels, messages), ViewModel with live event callbacks, reactive LogScreen (tail -f), CameraX QR scanner with proper lifecycle, JoinChannelDialog - HeadlessService: generates INI config from Kotlin settings → C instance
This commit is contained in:
1 parent
d1f6f6fca9
commit
5276c1288c
25 files changed
+2444
-63
No files matched your search
@@ -0,0 +1,41 @@
|
||||
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
|
||||
lateinit var configProvider: ConfigProvider
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
logManager = LogManager
|
||||
configProvider = ConfigProvider(this)
|
||||
|
||||
// First launch: generate keys + detect interfaces
|
||||
val scope = CoroutineScope(Dispatchers.IO)
|
||||
scope.launch {
|
||||
if (configProvider.isFirstLaunch()) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var instance: ChatApplication
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.utun.chat
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.*
|
||||
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.ui.screens.*
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
enum class Screen { ChannelList, Chat, Settings, Logs, QrScan }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val dbFile = File(filesDir, "chats.db")
|
||||
val repo = ChatRepository(dbFile.absolutePath)
|
||||
|
||||
setContent {
|
||||
val vm: ChatViewModel = viewModel(factory = ChatViewModel.Factory(repo))
|
||||
var screen by remember { mutableStateOf(Screen.ChannelList) }
|
||||
var qrResult by remember { mutableStateOf<String?>(null) }
|
||||
var showJoinDialog by remember { mutableStateOf(false) }
|
||||
var joinAutoLink by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
BackHandler(enabled = screen != Screen.ChannelList) {
|
||||
screen = Screen.ChannelList
|
||||
}
|
||||
|
||||
val connected = NativeLib.isReady()
|
||||
|
||||
/* Subscribe native logs only when LogScreen is open */
|
||||
DisposableEffect(screen) {
|
||||
if (screen == Screen.Logs) NativeLib.subscribeNativeLogs(true)
|
||||
onDispose { NativeLib.subscribeNativeLogs(false) }
|
||||
}
|
||||
|
||||
when (screen) {
|
||||
Screen.ChannelList -> {
|
||||
val channels by vm.channels.collectAsState()
|
||||
ChannelListScreen(
|
||||
channels = channels,
|
||||
connected = connected,
|
||||
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 }
|
||||
)
|
||||
}
|
||||
Screen.Chat -> {
|
||||
val messages by vm.messages.collectAsState()
|
||||
val channel = vm.currentChannel.collectAsState().value
|
||||
if (channel != null) {
|
||||
ChatScreen(
|
||||
channel = channel,
|
||||
messages = messages,
|
||||
connected = connected,
|
||||
onSendMessage = { vm.sendMessage(it) },
|
||||
onBack = { screen = Screen.ChannelList }
|
||||
)
|
||||
}
|
||||
}
|
||||
Screen.Settings -> SettingsScreen(onBack = { screen = Screen.ChannelList })
|
||||
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
|
||||
Screen.QrScan -> {
|
||||
QrScanScreen(
|
||||
onResult = { link ->
|
||||
qrResult = link
|
||||
screen = Screen.ChannelList
|
||||
},
|
||||
onBack = { screen = Screen.ChannelList }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* Show join dialog after QR scan returns */
|
||||
LaunchedEffect(qrResult) {
|
||||
qrResult?.let { link ->
|
||||
joinAutoLink = link
|
||||
showJoinDialog = true
|
||||
qrResult = null
|
||||
}
|
||||
}
|
||||
|
||||
if (showJoinDialog) {
|
||||
JoinChannelDialog(
|
||||
autoLink = joinAutoLink,
|
||||
onScanQr = {
|
||||
screen = Screen.QrScan
|
||||
showJoinDialog = false
|
||||
},
|
||||
onConnect = { data: InviteData ->
|
||||
val addrs = data.serializeAddrs()
|
||||
vm.joinChannel(data.channelId, data.nodeId, data.pubkey, addrs, data.addrs.size)
|
||||
showJoinDialog = false
|
||||
},
|
||||
onDismiss = { showJoinDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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 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 {
|
||||
/* 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)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
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)
|
||||
list.add(Channel(id = chId, name = name, lastMsgAt = lastAt, lastMsg = last))
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return list
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { /* table may not exist yet */ }
|
||||
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)
|
||||
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) =
|
||||
NativeLib.joinChannel(channelId, nodeId, pubkey, addrs, addrCount)
|
||||
fun isReady() = NativeLib.isReady()
|
||||
|
||||
companion object {
|
||||
fun msgTableName(chId: String): String {
|
||||
val san = 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('_')
|
||||
}
|
||||
return "msg_$san"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
interface ConfigCallback {
|
||||
fun getString(key: String): String?
|
||||
fun getInt(key: String): Int
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.util.UUID
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("utun_config")
|
||||
|
||||
class ConfigProvider(private val context: Context) {
|
||||
private val nodePrivateKey = stringPreferencesKey("node_private_key")
|
||||
private val nodePublicKey = stringPreferencesKey("node_public_key")
|
||||
private val nodeName = stringPreferencesKey("node_name")
|
||||
private val nodeListenPort = intPreferencesKey("node_listen_port")
|
||||
private val controlPort = intPreferencesKey("control_port")
|
||||
private val controlBindIp = stringPreferencesKey("control_bind_ip")
|
||||
private val dbPath = stringPreferencesKey("db_path")
|
||||
private val debugConsoleLevel = stringPreferencesKey("debug_console_level")
|
||||
private val debugLogFile = stringPreferencesKey("debug_log_file")
|
||||
private val debugCategories = stringPreferencesKey("debug_categories")
|
||||
private val serversJson = stringPreferencesKey("servers")
|
||||
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
|
||||
|
||||
private val gson = Gson()
|
||||
private val random = java.util.concurrent.ThreadLocalRandom.current()
|
||||
|
||||
fun getDeviceName(): String = Build.MODEL.ifEmpty { "Android" }
|
||||
|
||||
val allValues: Flow<Map<String, String>> = context.dataStore.data.map { prefs ->
|
||||
mapOf(
|
||||
"node.private_key" to (prefs[nodePrivateKey] ?: ""),
|
||||
"node.public_key" to (prefs[nodePublicKey] ?: ""),
|
||||
"node.name" to (prefs[nodeName] ?: getDeviceName()),
|
||||
"node.listen_port" to (prefs[nodeListenPort]?.toString() ?: "12345"),
|
||||
"control.port" to (prefs[controlPort]?.toString() ?: "9999"),
|
||||
"db.path" to (prefs[dbPath] ?: "utun_chat.db"),
|
||||
"debug.console_level" to (prefs[debugConsoleLevel] ?: "info"),
|
||||
"debug.log_file" to (prefs[debugLogFile] ?: "utun.log"),
|
||||
"debug.categories" to (prefs[debugCategories] ?: "")
|
||||
)
|
||||
}
|
||||
|
||||
fun getString(key: String): String? = runBlocking {
|
||||
when {
|
||||
key.startsWith("servers.") -> {
|
||||
val parts = key.removePrefix("servers.").split(".", limit = 2)
|
||||
val idx = parts[0].toIntOrNull() ?: return@runBlocking null
|
||||
val field = parts.getOrNull(1) ?: return@runBlocking null
|
||||
val list = loadServers()
|
||||
val srv = list.getOrNull(idx) ?: return@runBlocking null
|
||||
when (field) {
|
||||
"bindIp" -> srv.bindIp
|
||||
"protocol" -> srv.protocol
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
key == "servers.count" -> loadServers().size.toString()
|
||||
key == "node.private_key" -> context.dataStore.data.first()[nodePrivateKey]
|
||||
key == "node.public_key" -> context.dataStore.data.first()[nodePublicKey]
|
||||
key == "node.name" -> context.dataStore.data.first()[nodeName] ?: getDeviceName()
|
||||
key == "db.path" -> context.dataStore.data.first()[dbPath] ?: "utun_chat.db"
|
||||
key == "control.bind_ip" -> context.dataStore.data.first()[controlBindIp] ?: "127.0.0.1"
|
||||
key == "debug.console_level" -> context.dataStore.data.first()[debugConsoleLevel] ?: "info"
|
||||
key == "debug.log_file" -> context.dataStore.data.first()[debugLogFile] ?: "utun.log"
|
||||
key == "debug.categories" -> context.dataStore.data.first()[debugCategories] ?: ""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun getInt(key: String): Int = runBlocking {
|
||||
when {
|
||||
key.startsWith("servers.") -> {
|
||||
val parts = key.removePrefix("servers.").split(".", limit = 2)
|
||||
val idx = parts[0].toIntOrNull() ?: return@runBlocking 0
|
||||
val field = parts.getOrNull(1) ?: return@runBlocking 0
|
||||
val list = loadServers()
|
||||
val srv = list.getOrNull(idx) ?: return@runBlocking 0
|
||||
when (field) {
|
||||
"port" -> srv.port
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
key == "servers.count" -> loadServers().size
|
||||
key == "node.listen_port" -> context.dataStore.data.first()[nodeListenPort] ?: 12345
|
||||
key == "control.port" -> context.dataStore.data.first()[controlPort] ?: 9999
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setValue(key: String, value: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
when (key) {
|
||||
"node.private_key" -> prefs[nodePrivateKey] = value
|
||||
"node.public_key" -> prefs[nodePublicKey] = value
|
||||
"node.name" -> prefs[nodeName] = value
|
||||
"node.listen_port" -> prefs[nodeListenPort] = value.toIntOrNull() ?: 12345
|
||||
"control.port" -> prefs[controlPort] = value.toIntOrNull() ?: 9999
|
||||
"control.bind_ip" -> prefs[controlBindIp] = value
|
||||
"db.path" -> prefs[dbPath] = value
|
||||
"debug.console_level" -> prefs[debugConsoleLevel] = value
|
||||
"debug.log_file" -> prefs[debugLogFile] = value
|
||||
"debug.categories" -> prefs[debugCategories] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun generateKeys() {
|
||||
val priv = "priv_" + (1..64).map { "0123456789abcdef".random() }.joinToString("")
|
||||
val pub = "pub_" + (1..64).map { "0123456789abcdef".random() }.joinToString("")
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[nodePrivateKey] = priv
|
||||
prefs[nodePublicKey] = pub
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Accept only global unicast (2000::/3) and unique local (fc00::/7) IPv6. */
|
||||
fun isUsableV6(addr: Inet6Address): Boolean {
|
||||
if (addr.isLoopbackAddress || addr.isLinkLocalAddress || addr.isMulticastAddress) return false
|
||||
val b = addr.address // 16 bytes
|
||||
val hi = ((b[0].toInt() and 0xFF) shl 8) or (b[1].toInt() and 0xFF)
|
||||
// fc00::/7 → fc00-fdff, 2000::/3 → 2000-3fff
|
||||
return hi in 0xFC00..0xFDFF || hi in 0x2000..0x3FFF
|
||||
}
|
||||
}
|
||||
|
||||
/* ── First launch ── */
|
||||
|
||||
fun isFirstLaunch(): Boolean = runBlocking {
|
||||
!(context.dataStore.data.first()[firstLaunchDone] ?: false)
|
||||
}
|
||||
|
||||
suspend fun markLaunched() {
|
||||
context.dataStore.edit { prefs -> prefs[firstLaunchDone] = true }
|
||||
}
|
||||
|
||||
/* ── Server entries ── */
|
||||
|
||||
suspend fun detectInterfaces(): List<ServerEntry> {
|
||||
val servers = mutableListOf<ServerEntry>()
|
||||
try {
|
||||
val nis = NetworkInterface.getNetworkInterfaces()
|
||||
while (nis.hasMoreElements()) {
|
||||
val ni = nis.nextElement()
|
||||
if (!ni.isUp || ni.isLoopback) continue
|
||||
for (a in ni.interfaceAddresses) {
|
||||
val addr = a.address
|
||||
val ip = when {
|
||||
addr is Inet4Address -> addr.hostAddress
|
||||
addr is Inet6Address && isUsableV6(addr) -> addr.hostAddress?.let { it.split("%")[0] }
|
||||
else -> null
|
||||
} ?: continue
|
||||
servers.add(ServerEntry(
|
||||
id = UUID.randomUUID().toString(),
|
||||
ifName = ni.name,
|
||||
bindIp = ip,
|
||||
protocol = "tcp",
|
||||
port = random.nextInt(2000, 10000) // 2000-9999
|
||||
))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "CFG", "detectInterfaces failed: ${e.message}")
|
||||
}
|
||||
saveServers(servers)
|
||||
return servers
|
||||
}
|
||||
|
||||
suspend fun saveServers(servers: List<ServerEntry>) {
|
||||
val json = gson.toJson(servers)
|
||||
context.dataStore.edit { prefs -> prefs[serversJson] = json }
|
||||
}
|
||||
|
||||
fun loadServers(): List<ServerEntry> = runBlocking {
|
||||
val json = context.dataStore.data.first()[serversJson] ?: "[]"
|
||||
try {
|
||||
gson.fromJson(json, object : TypeToken<List<ServerEntry>>() {}.type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Return all currently available network interface names (for dropdown) */
|
||||
fun getAvailableInterfaces(): List<Pair<String, String>> = runBlocking {
|
||||
val list = mutableListOf<Pair<String, String>>()
|
||||
try {
|
||||
val nis = NetworkInterface.getNetworkInterfaces()
|
||||
while (nis.hasMoreElements()) {
|
||||
val ni = nis.nextElement()
|
||||
if (!ni.isUp || ni.isLoopback) continue
|
||||
for (a in ni.interfaceAddresses) {
|
||||
val addr = a.address
|
||||
val ip = when {
|
||||
addr is Inet4Address -> addr.hostAddress
|
||||
addr is Inet6Address && isUsableV6(addr) -> addr.hostAddress?.let { it.split("%")[0] }
|
||||
else -> null
|
||||
} ?: continue
|
||||
list.add(ni.name to ip)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
if (list.isEmpty()) list.add("lo" to "127.0.0.1")
|
||||
list
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.util.Base64
|
||||
import java.security.MessageDigest
|
||||
|
||||
const val INVITE_LINK_VERSION: Int = 0x01
|
||||
const val INVITE_PUBKEY_SIZE: Int = 32
|
||||
|
||||
data class InviteAddr(
|
||||
val family: Int, // 4 or 6
|
||||
val address: ByteArray,
|
||||
val port: Int,
|
||||
val socketId: Int
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is InviteAddr) return false
|
||||
return family == other.family && address.contentEquals(other.address) &&
|
||||
port == other.port && socketId == other.socketId
|
||||
}
|
||||
override fun hashCode(): Int = family * 31 + address.contentHashCode() + port * 31 + socketId
|
||||
}
|
||||
|
||||
data class InviteData(
|
||||
val channelId: Long,
|
||||
val pubkey: ByteArray,
|
||||
val nodeId: Long,
|
||||
val addrs: List<InviteAddr>
|
||||
) {
|
||||
/** Serialize addresses for C bridge: family(1) + socketId(1) + address(4|16) + port(2 BE) */
|
||||
fun serializeAddrs(): ByteArray {
|
||||
val size = addrs.fold(0) { acc, a -> acc + if (a.family == 4) 8 else 20 }
|
||||
val buf = ByteArray(size)
|
||||
var pos = 0
|
||||
for (a in addrs) {
|
||||
buf[pos] = a.family.toByte(); pos++
|
||||
buf[pos] = a.socketId.toByte(); pos++
|
||||
val ipLen = if (a.family == 4) 4 else 16
|
||||
System.arraycopy(a.address, 0, buf, pos, ipLen); pos += ipLen
|
||||
buf[pos] = ((a.port shr 8) and 0xFF).toByte(); pos++
|
||||
buf[pos] = (a.port and 0xFF).toByte(); pos++
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is InviteData) return false
|
||||
return channelId == other.channelId && pubkey.contentEquals(other.pubkey) &&
|
||||
nodeId == other.nodeId && addrs == other.addrs
|
||||
}
|
||||
override fun hashCode(): Int = channelId.hashCode() * 31 + pubkey.contentHashCode()
|
||||
}
|
||||
|
||||
object InviteLink {
|
||||
private const val PREFIX = "utun://"
|
||||
|
||||
fun deriveNodeId(pubkey: ByteArray): Long {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val hash = md.digest(pubkey)
|
||||
var id = 0L
|
||||
for (i in 0 until 8)
|
||||
id = (id shl 8) or (hash[i].toLong() and 0xFF)
|
||||
return id and 0x7FFFFFFFFFFFFFFFL
|
||||
}
|
||||
|
||||
fun decode(link: String): InviteData? {
|
||||
if (!link.startsWith(PREFIX)) return null
|
||||
val raw: ByteArray
|
||||
try {
|
||||
raw = Base64.decode(link.substring(PREFIX.length), Base64.DEFAULT)
|
||||
} catch (e: Exception) {
|
||||
return null
|
||||
}
|
||||
if (raw.size < 11) return null
|
||||
|
||||
var off = 0
|
||||
if ((raw[off].toInt() and 0xFF) != INVITE_LINK_VERSION) return null
|
||||
off++
|
||||
|
||||
var chId = 0L
|
||||
for (i in 0 until 8) {
|
||||
if (off + i >= raw.size) return null
|
||||
chId = (chId shl 8) or (raw[off + i].toLong() and 0xFF)
|
||||
}
|
||||
off += 8
|
||||
|
||||
var pubkey: ByteArray? = null
|
||||
val addrs = mutableListOf<InviteAddr>()
|
||||
var firstBlock = true
|
||||
|
||||
while (off + 1 <= raw.size) {
|
||||
val header = raw[off].toInt() and 0xFF; off++
|
||||
val cnt = (header and 0x03) + 1
|
||||
if (cnt < 1 || cnt > 4) return null
|
||||
if (off + 32 > raw.size) return null
|
||||
|
||||
if (firstBlock) {
|
||||
pubkey = raw.copyOfRange(off, off + 32)
|
||||
firstBlock = false
|
||||
}
|
||||
off += 32
|
||||
|
||||
for (j in 0 until cnt) {
|
||||
val isV6 = ((header shr (2 + j)) and 1) != 0
|
||||
val ipLen = if (isV6) 16 else 4
|
||||
if (off + 1 + ipLen + 2 > raw.size) return null
|
||||
val sockId = raw[off].toInt() and 0xFF; off++
|
||||
val ip = raw.copyOfRange(off, off + ipLen); off += ipLen
|
||||
val port = ((raw[off].toInt() and 0xFF) shl 8) or (raw[off + 1].toInt() and 0xFF); off += 2
|
||||
addrs.add(InviteAddr(family = if (isV6) 6 else 4, address = ip, port = port, socketId = sockId))
|
||||
}
|
||||
}
|
||||
|
||||
if (pubkey == null || pubkey.size != INVITE_PUBKEY_SIZE) return null
|
||||
val nodeId = deriveNodeId(pubkey)
|
||||
return InviteData(channelId = chId, pubkey = pubkey, nodeId = nodeId, addrs = addrs)
|
||||
}
|
||||
|
||||
fun addrToString(addr: InviteAddr): String {
|
||||
if (addr.family == 4 && addr.address.size == 4) {
|
||||
val b = addr.address
|
||||
return "${b[0].toInt() and 0xFF}.${b[1].toInt() and 0xFF}.${b[2].toInt() and 0xFF}.${b[3].toInt() and 0xFF}:${addr.port}"
|
||||
}
|
||||
return "ipv6:${addr.address.joinToString("") { "%02x".format(it) }}:${addr.port}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
|
||||
data class LogEntry(
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val level: String,
|
||||
val category: String,
|
||||
val message: String
|
||||
)
|
||||
|
||||
object LogManager {
|
||||
private const val MAX_BUFFER = 100000
|
||||
private val _buffer = ArrayDeque<LogEntry>(MAX_BUFFER)
|
||||
private val _events = MutableSharedFlow<LogEntry>(replay = 0, extraBufferCapacity = 512)
|
||||
val events: SharedFlow<LogEntry> = _events.asSharedFlow()
|
||||
|
||||
@Synchronized
|
||||
fun addLog(level: String, category: String, message: String) {
|
||||
val entry = LogEntry(level = level, category = category, message = message)
|
||||
if (_buffer.size >= MAX_BUFFER) _buffer.removeFirst()
|
||||
_buffer.add(entry)
|
||||
_events.tryEmit(entry)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getRecent(count: Int = 100): List<LogEntry> {
|
||||
return _buffer.takeLast(count)
|
||||
}
|
||||
|
||||
/** Called from JNI (via JNI config callback interface) */
|
||||
fun onNativeLog(level: Int, category: String?, message: String?) {
|
||||
val lvl = when (level) {
|
||||
0 -> "TRACE"
|
||||
1 -> "DEBUG"
|
||||
2 -> "INFO"
|
||||
3 -> "WARN"
|
||||
4 -> "ERROR"
|
||||
else -> "NONE"
|
||||
}
|
||||
addLog(lvl, category ?: "", message ?: "")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import com.utun.chat.ChatApplication
|
||||
|
||||
interface ChatEventCallback {
|
||||
fun onEvent(type: Int, data: ByteArray?)
|
||||
}
|
||||
|
||||
object NativeLib {
|
||||
private var callbacksSet = false
|
||||
private var running = false
|
||||
|
||||
init {
|
||||
System.loadLibrary("utun")
|
||||
}
|
||||
|
||||
/** Save Kotlin callbacks to C globals (log, config). Must be called before start(). */
|
||||
fun init(dbPath: String, controlPort: Int): Boolean {
|
||||
if (callbacksSet) return true
|
||||
val ok = nativeInit(
|
||||
dbPath, controlPort,
|
||||
ChatApplication.instance.configProvider,
|
||||
LogManager
|
||||
)
|
||||
if (ok) callbacksSet = true
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Start full uTun instance from INI config text */
|
||||
fun start(configText: String): Boolean {
|
||||
if (running) return true
|
||||
val ok = nativeStart(configText)
|
||||
if (ok) running = true
|
||||
return ok
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!running) return
|
||||
nativeStop()
|
||||
running = false
|
||||
}
|
||||
|
||||
fun setEventCallback(cb: ChatEventCallback) {
|
||||
nativeSetEventCallback(object : ChatEventCallback {
|
||||
override fun onEvent(type: Int, data: ByteArray?) { cb.onEvent(type, data) }
|
||||
})
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
nativeDestroy()
|
||||
callbacksSet = false
|
||||
running = false
|
||||
}
|
||||
|
||||
fun restart(dbPath: String, controlPort: Int): Boolean {
|
||||
destroy()
|
||||
return init(dbPath, controlPort)
|
||||
}
|
||||
|
||||
fun isReady(): Boolean = running
|
||||
|
||||
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) }
|
||||
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) {
|
||||
nativeJoinChannel(channelId, nodeId, pubkey, addrs, addrCount)
|
||||
}
|
||||
fun getChannels(): String = nativeGetChannels() ?: "[]"
|
||||
fun getMessages(channelId: String, limit: Int = 50): String = nativeGetMessages(channelId, limit) ?: "[]"
|
||||
fun setDebugLevel(category: String, level: String) { nativeSetDebugLevel(category, level) }
|
||||
|
||||
/** Enable/disable forwarding native C logs to LogManager (expensive, off by default) */
|
||||
fun subscribeNativeLogs(enabled: Boolean) { nativeSubscribeLogs(enabled) }
|
||||
|
||||
private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean
|
||||
private external fun nativeStart(configText: String): Boolean
|
||||
private external fun nativeStop()
|
||||
private external fun nativeSetEventCallback(cb: Any)
|
||||
private external fun nativeDestroy()
|
||||
private external fun nativeSendMessage(channelId: String, text: String)
|
||||
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 nativeGetChannels(): String?
|
||||
private external fun nativeGetMessages(channelId: String, limit: Int): String?
|
||||
private external fun nativeSetDebugLevel(category: String, level: String)
|
||||
private external fun nativeSubscribeLogs(enabled: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
data class ServerEntry(
|
||||
val id: String, // UUID
|
||||
val ifName: String, // wlan0, rmnet0, ...
|
||||
val bindIp: String, // IPv4 address
|
||||
val protocol: String, // "tcp" or "udp"
|
||||
val port: Int
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.utun.chat.headless
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.MainActivity
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.NativeLib
|
||||
|
||||
class HeadlessService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
|
||||
val notification = NotificationCompat.Builder(this, "utun_channel")
|
||||
.setContentTitle("uTun Chat")
|
||||
.setContentText("Service running...")
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
startForeground(1, notification)
|
||||
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val dbDir = filesDir.absolutePath
|
||||
val pubKey = provider.getString("node.public_key") ?: ""
|
||||
val privKey = provider.getString("node.private_key") ?: ""
|
||||
val nodeName = provider.getString("node.name") ?: provider.getDeviceName()
|
||||
val listenPort = provider.getInt("node.listen_port")
|
||||
|
||||
val configText = buildString {
|
||||
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")
|
||||
appendLine("db_path=$dbDir")
|
||||
appendLine("db_sync_enabled=1")
|
||||
appendLine()
|
||||
appendLine("[server:main]")
|
||||
appendLine("addr=0.0.0.0:$listenPort")
|
||||
appendLine()
|
||||
appendLine("[allowed_keys]")
|
||||
appendLine("allow_all=yes")
|
||||
appendLine()
|
||||
appendLine("[ntp]")
|
||||
appendLine("enabled=no")
|
||||
appendLine()
|
||||
appendLine("[debug]")
|
||||
appendLine("console_level=info")
|
||||
}
|
||||
|
||||
LogManager.addLog("INFO", "SRV", "uTun service starting, port=$listenPort db=$dbDir")
|
||||
|
||||
/* 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")
|
||||
} else {
|
||||
LogManager.addLog("ERROR", "SRV", "uTun start failed")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
LogManager.addLog("INFO", "SRV", "uTun service stopping")
|
||||
NativeLib.stop()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
"utun_channel", "uTun Chat",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply { description = "uTun Chat background service" }
|
||||
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.utun.chat.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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
|
||||
import com.utun.chat.data.Message
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(message: Message) {
|
||||
val bgColor = if (message.isOutgoing) Color(0xFF4A90D9) else Color(0xFFE8E8E8)
|
||||
val textColor = if (message.isOutgoing) Color.White else Color.Black
|
||||
val alignment = if (message.isOutgoing) Arrangement.End else Arrangement.Start
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = alignment
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 280.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(bgColor)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Text(message.author, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.7f))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(message.text, fontSize = 15.sp, color = textColor)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
formatTime(message.ts),
|
||||
fontSize = 10.sp,
|
||||
color = textColor.copy(alpha = 0.5f),
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InputBar(
|
||||
text: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text("Message...") },
|
||||
maxLines = 4
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = onSend, enabled = enabled && text.isNotBlank()) {
|
||||
Text("Send")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatTime(ts: Long): String {
|
||||
if (ts == 0L) return ""
|
||||
val s = ts / 1_000_000_000 // nanoseconds -> seconds
|
||||
val h = (s / 3600) % 24
|
||||
val m = (s / 60) % 60
|
||||
return "%02d:%02d".format(h, m)
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.QrCode
|
||||
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.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.data.Channel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChannelListScreen(
|
||||
channels: List<Channel>,
|
||||
connected: Boolean,
|
||||
onSelectChannel: (Channel) -> Unit,
|
||||
onCreateChannel: () -> Unit,
|
||||
onJoinChannel: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onLogs: () -> Unit
|
||||
) {
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("uTun Chat") },
|
||||
actions = {
|
||||
Text(
|
||||
if (connected) "ON" else "OFF",
|
||||
color = if (connected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(end = 8.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") }
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showCreateDialog = true }) {
|
||||
Icon(Icons.Default.Add, "New channel")
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
if (channels.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().weight(1f)) {
|
||||
Text(
|
||||
"No channels yet.\nTap + to create one.",
|
||||
modifier = Modifier.padding(32.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(channels) { ch ->
|
||||
Surface(modifier = Modifier.clickable { onSelectChannel(ch) }) {
|
||||
Row(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||
Box(Modifier.size(48.dp), contentAlignment = androidx.compose.ui.Alignment.Center) {
|
||||
Surface(shape = MaterialTheme.shapes.small) {
|
||||
Text("#", fontSize = 24.sp, modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(ch.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
if (ch.lastMsg.isNotEmpty())
|
||||
Text(ch.lastMsg, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
CreateChannelDialog(
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onCreate = { name ->
|
||||
showCreateDialog = false
|
||||
onCreateChannel()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateChannelDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("New Channel") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Channel name") },
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }, enabled = name.isNotBlank()) {
|
||||
Text("Create")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.utun.chat.data.Channel
|
||||
import com.utun.chat.data.Message
|
||||
import com.utun.chat.ui.components.InputBar
|
||||
import com.utun.chat.ui.components.MessageBubble
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
channel: Channel,
|
||||
messages: List<Message>,
|
||||
connected: Boolean,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(channel.name) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") }
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
InputBar(
|
||||
text = text,
|
||||
onTextChange = { text = it },
|
||||
onSend = {
|
||||
onSendMessage(text)
|
||||
text = ""
|
||||
},
|
||||
enabled = connected
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
reverseLayout = false
|
||||
) {
|
||||
items(messages) { msg -> MessageBubble(msg) }
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.data.InviteData
|
||||
import com.utun.chat.data.InviteLink
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun JoinChannelDialog(
|
||||
autoLink: String? = null,
|
||||
onScanQr: () -> Unit,
|
||||
onConnect: (InviteData) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var link by remember { mutableStateOf(autoLink ?: "") }
|
||||
var decoded by remember { mutableStateOf<InviteData?>(null) }
|
||||
var status by remember { mutableStateOf("") }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
var connecting by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (!autoLink.isNullOrBlank()) {
|
||||
LaunchedEffect(autoLink) {
|
||||
connecting = true
|
||||
isError = false
|
||||
|
||||
status = "Decoding..."
|
||||
delay(300)
|
||||
|
||||
val d = InviteLink.decode(autoLink.trim())
|
||||
if (d != null) {
|
||||
decoded = d
|
||||
status = "Connecting to peer..."
|
||||
delay(800)
|
||||
|
||||
onConnect(d)
|
||||
} else {
|
||||
connecting = false
|
||||
status = "Invalid invite link"
|
||||
isError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!connecting) onDismiss() },
|
||||
title = { Text("Join Channel") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (autoLink.isNullOrBlank()) {
|
||||
OutlinedTextField(
|
||||
value = link,
|
||||
onValueChange = { newVal ->
|
||||
link = newVal
|
||||
status = ""
|
||||
isError = false
|
||||
scope.launch {
|
||||
delay(400)
|
||||
if (link == newVal) {
|
||||
if (newVal.isBlank()) {
|
||||
decoded = null
|
||||
} else {
|
||||
decoded = InviteLink.decode(newVal.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
label = { Text("Invite link") },
|
||||
placeholder = { Text("utun://...") },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onScanQr) {
|
||||
Icon(Icons.Default.QrCodeScanner, "Scan QR")
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
enabled = !connecting,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
decoded?.let { d ->
|
||||
val addrStr = d.addrs.joinToString(", ") { InviteLink.addrToString(it) }
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text("Node: 0x%016x".format(d.nodeId), fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("Channel: ${d.channelId}", fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (addrStr.isNotBlank())
|
||||
Text(addrStr, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
if (status.isNotBlank()) {
|
||||
Text(
|
||||
status,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
fontSize = 14.sp, fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
connecting = true
|
||||
isError = false
|
||||
status = "Connecting to peer..."
|
||||
val d = InviteLink.decode(link.trim())
|
||||
if (d != null) {
|
||||
onConnect(d)
|
||||
} else {
|
||||
connecting = false
|
||||
status = "Invalid invite link"
|
||||
isError = true
|
||||
}
|
||||
},
|
||||
enabled = !connecting && decoded != null && (autoLink.isNullOrBlank())
|
||||
) {
|
||||
Text(if (connecting) "..." else "Connect")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
if (autoLink.isNullOrBlank()) {
|
||||
TextButton(onClick = onDismiss, enabled = !connecting) { Text("Cancel") }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.data.LogEntry
|
||||
import com.utun.chat.data.LogManager
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LogScreen(onBack: () -> Unit) {
|
||||
val entries = remember { mutableStateListOf<LogEntry>() }
|
||||
val listState = rememberLazyListState()
|
||||
var filterLevel by remember { mutableStateOf("") }
|
||||
var autoScroll by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
/* Load history from in-memory buffer */
|
||||
val recent = LogManager.getRecent(500)
|
||||
entries.addAll(recent)
|
||||
|
||||
/* Live tail via SharedFlow */
|
||||
LogManager.events.collect { entry ->
|
||||
entries.add(entry)
|
||||
if (entries.size > 10000) entries.removeAt(0)
|
||||
if (autoScroll) {
|
||||
listState.animateScrollToItem(entries.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Logs") },
|
||||
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
|
||||
actions = {
|
||||
for ((lvl, label) in arrayOf("" to "ALL", "ERROR" to "ERR", "WARN" to "WRN", "INFO" to "INF", "DEBUG" to "DBG", "TRACE" to "TRC")) {
|
||||
val selected = filterLevel == lvl
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) Color.White else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier
|
||||
.clickable { filterLevel = lvl }
|
||||
.background(
|
||||
if (selected) MaterialTheme.colorScheme.primary else Color.Transparent,
|
||||
MaterialTheme.shapes.extraSmall
|
||||
)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { autoScroll = !autoScroll }) {
|
||||
Text(if (autoScroll) "|>" else "||", fontSize = 11.sp)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
val filtered = if (filterLevel.isEmpty()) entries.toList()
|
||||
else entries.filter { it.level == filterLevel }
|
||||
|
||||
LazyColumn(modifier = Modifier.padding(padding), state = listState) {
|
||||
items(filtered.size) { idx -> // index-based to avoid recomposition on each new entry
|
||||
val entry = filtered[idx]
|
||||
val color = when (entry.level) {
|
||||
"ERROR" -> Color(0xFFFF4444)
|
||||
"WARN" -> Color(0xFFFFAA00)
|
||||
"INFO" -> Color(0xFF4CAF50)
|
||||
"DEBUG" -> Color(0xFF2196F3)
|
||||
"TRACE" -> Color(0xFF9E9E9E)
|
||||
else -> Color.Gray
|
||||
}
|
||||
Text(
|
||||
text = "${entry.level} [${entry.category}] ${entry.message}",
|
||||
color = color,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
|
||||
import com.google.mlkit.vision.barcode.BarcodeScanning
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun QrScanScreen(
|
||||
onResult: (String) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
var hasPermission by remember { mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
) }
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (!hasPermission) launcher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
|
||||
/* Camera lifecycle — unbind when leaving this screen */
|
||||
val cameraProviderRef = remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
|
||||
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
Log.d("QrScan", "disposing camera")
|
||||
cameraProviderRef.value?.unbindAll()
|
||||
cameraProviderRef.value = null
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Scan QR Code") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") }
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (!hasPermission) {
|
||||
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("Camera permission required")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) {
|
||||
Text("Grant")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var scanned by remember { mutableStateOf(false) }
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
factory = { ctx ->
|
||||
val previewView = PreviewView(ctx)
|
||||
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
cameraProviderRef.value = cameraProvider
|
||||
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.surfaceProvider = previewView.surfaceProvider
|
||||
}
|
||||
|
||||
val options = BarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
val scanner = BarcodeScanning.getClient(options)
|
||||
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
|
||||
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
|
||||
if (scanned) {
|
||||
imageProxy.close()
|
||||
return@setAnalyzer
|
||||
}
|
||||
val mediaImage = imageProxy.image
|
||||
if (mediaImage != null) {
|
||||
try {
|
||||
val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
|
||||
scanner.process(image)
|
||||
.addOnSuccessListener { barcodes ->
|
||||
for (barcode in barcodes) {
|
||||
val text = barcode.rawValue
|
||||
if (text != null && text.startsWith("utun://")) {
|
||||
scanned = true
|
||||
val link = text
|
||||
Log.d("QrScan", "detected: $link")
|
||||
mainHandler.post { onResult(link) }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnCompleteListener { imageProxy.close() }
|
||||
} catch (e: Exception) {
|
||||
Log.e("QrScan", "scan error", e)
|
||||
imageProxy.close()
|
||||
}
|
||||
} else {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview,
|
||||
analysis
|
||||
)
|
||||
Log.d("QrScan", "camera bound ok")
|
||||
} catch (e: Exception) {
|
||||
Log.e("QrScan", "Camera bind failed", e)
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(ctx))
|
||||
|
||||
previewView
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.data.NativeLib
|
||||
import com.utun.chat.data.ServerEntry
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
private val LEVELS = listOf("NONE", "ERROR", "WARN", "INFO", "DEBUG", "TRACE")
|
||||
private val CATEGORIES = listOf(
|
||||
"UASYNC", "LL_QUEUE", "CONNECTION", "ETCP", "CRYPTO", "MEMORY",
|
||||
"TIMING", "CONFIG", "TUN", "ROUTING", "TIMERS", "NORMALIZER", "BGP",
|
||||
"SOCKET", "CONTROL", "DUMP", "TRAFFIC", "DEBUG", "GENERAL", "NAT",
|
||||
"KEEPALIVE", "ETCPROUTE", "BBR", "ETCP_DUMP", "CONNECTIVITY", "DB_SYNC"
|
||||
)
|
||||
private val PROTOCOLS = listOf("tcp", "udp")
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(onBack: () -> Unit) {
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val scope = rememberCoroutineScope()
|
||||
var dirty by remember { mutableStateOf(false) }
|
||||
|
||||
var nodeName by remember { mutableStateOf("") }
|
||||
var listenPort by remember { mutableStateOf("12345") }
|
||||
var controlPort by remember { mutableStateOf("9999") }
|
||||
var controlBindIp by remember { mutableStateOf("127.0.0.1") }
|
||||
var ctrlBindLabel by remember { mutableStateOf("lo (127.0.0.1)") }
|
||||
var dbPath by remember { mutableStateOf("") }
|
||||
var debugLevel by remember { mutableStateOf("info") }
|
||||
var logFile by remember { mutableStateOf("") }
|
||||
var pubKey by remember { mutableStateOf("") }
|
||||
|
||||
var showDebugCategories by remember { mutableStateOf(false) }
|
||||
var showOverwriteKeys by remember { mutableStateOf(false) }
|
||||
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()) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
nodeName = provider.getString("node.name") ?: provider.getDeviceName()
|
||||
listenPort = provider.getInt("node.listen_port").toString()
|
||||
controlPort = provider.getInt("control.port").toString()
|
||||
controlBindIp = provider.getString("control.bind_ip") ?: "127.0.0.1"
|
||||
val allIface = listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces()
|
||||
ctrlBindLabel = allIface.firstOrNull { it.second == controlBindIp }?.let { "${it.first} (${it.second})" } ?: "lo (127.0.0.1)"
|
||||
dbPath = provider.getString("db.path") ?: "utun_chat.db"
|
||||
debugLevel = provider.getString("debug.console_level") ?: "info"
|
||||
logFile = provider.getString("debug.log_file") ?: "utun.log"
|
||||
pubKey = provider.getString("node.public_key") ?: "Not generated"
|
||||
val stored = provider.getString("debug.categories") ?: ""
|
||||
catLevels = if (stored.isNotEmpty()) {
|
||||
stored.split(",").associate { pair ->
|
||||
val (k, v) = pair.split("=", limit = 2)
|
||||
k to v
|
||||
}
|
||||
} else emptyMap()
|
||||
servers = provider.loadServers()
|
||||
availableIfaces = provider.getAvailableInterfaces()
|
||||
}
|
||||
|
||||
val save = { s: List<ServerEntry> ->
|
||||
dirty = true
|
||||
servers = s
|
||||
scope.launch { provider.saveServers(s) }
|
||||
}
|
||||
|
||||
val setDebug = { level: String ->
|
||||
dirty = true
|
||||
NativeLib.setDebugLevel("", level)
|
||||
scope.launch { provider.setValue("debug.console_level", level) }
|
||||
debugLevel = level
|
||||
}
|
||||
|
||||
val setCatLevel = { cat: String, lvl: String ->
|
||||
dirty = true
|
||||
val updated = catLevels.toMutableMap()
|
||||
if (lvl == "") updated.remove(cat) else updated[cat] = lvl
|
||||
catLevels = updated
|
||||
val csv = updated.entries.joinToString(",") { "${it.key}=${it.value}" }
|
||||
scope.launch { provider.setValue("debug.categories", csv) }
|
||||
if (lvl.isEmpty()) NativeLib.setDebugLevel(cat, "NONE")
|
||||
else NativeLib.setDebugLevel(cat, lvl)
|
||||
}
|
||||
|
||||
val handleBack = {
|
||||
if (dirty) {
|
||||
val path = provider.getString("db.path") ?: "utun_chat.db"
|
||||
val port = provider.getInt("control.port")
|
||||
NativeLib.restart(path, port)
|
||||
}
|
||||
onBack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.Default.ArrowBack, "Back") } }
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(padding).padding(16.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("Node", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(nodeName, { v -> dirty = true; nodeName = v; scope.launch { provider.setValue("node.name", v) } },
|
||||
label = { Text("Node Name") }, singleLine = true,
|
||||
modifier = Modifier.heightIn(min = 40.dp))
|
||||
OutlinedTextField(listenPort, { v -> dirty = true; listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } },
|
||||
label = { Text("Listen Port") },
|
||||
modifier = Modifier.heightIn(min = 40.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
|
||||
// Control socket interface (including lo0)
|
||||
var ctrlExpanded by remember { mutableStateOf(false) }
|
||||
var ctrlBindLabel by remember { mutableStateOf("lo (127.0.0.1)") }
|
||||
val ctrlIfaces = remember { listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() }
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it },
|
||||
modifier = Modifier.weight(1f)) {
|
||||
OutlinedTextField(
|
||||
value = ctrlBindLabel, onValueChange = {},
|
||||
readOnly = true, singleLine = true, textStyle = MaterialTheme.typography.bodyLarge,
|
||||
label = { Text("Control Bind IP") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) },
|
||||
modifier = Modifier.menuAnchor().heightIn(min = 40.dp)
|
||||
)
|
||||
ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) {
|
||||
ctrlIfaces.forEach { (name, ip) ->
|
||||
val label = "$name ($ip)"
|
||||
DropdownMenuItem(text = { Text(label) }, onClick = {
|
||||
controlBindIp = ip
|
||||
ctrlBindLabel = label
|
||||
dirty = true
|
||||
scope.launch { provider.setValue("control.bind_ip", ip) }
|
||||
ctrlExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedTextField(controlPort, { v -> dirty = true; controlPort = v; scope.launch { provider.setValue("control.port", v) } },
|
||||
label = { Text("Port") },
|
||||
modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
|
||||
}
|
||||
|
||||
Text("Keys", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(pubKey, {}, label = { Text("Public Key") }, readOnly = true, singleLine = true)
|
||||
Button(onClick = {
|
||||
val hasKey = pubKey.isNotEmpty() && pubKey != "Not generated" && pubKey != ""
|
||||
if (hasKey) showOverwriteKeys = true
|
||||
else { dirty = true; scope.launch { provider.generateKeys(); pubKey = provider.getString("node.public_key") ?: "" } }
|
||||
}) {
|
||||
Text("Generate Keys")
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
// ── Network Interfaces ──
|
||||
Text("Network Interfaces", style = MaterialTheme.typography.titleMedium)
|
||||
servers.forEachIndexed { idx, srv ->
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
// Line 1: [ifName ▾] IP [✕]
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
var ifExpanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(expanded = ifExpanded, onExpandedChange = { ifExpanded = it },
|
||||
modifier = Modifier.weight(1f)) {
|
||||
OutlinedTextField(
|
||||
value = srv.ifName, onValueChange = {},
|
||||
readOnly = true, singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ifExpanded) },
|
||||
modifier = Modifier.menuAnchor().heightIn(min = 40.dp)
|
||||
)
|
||||
ExposedDropdownMenu(ifExpanded, onDismissRequest = { ifExpanded = false }) {
|
||||
availableIfaces.forEach { (name, ip) ->
|
||||
DropdownMenuItem(text = { Text("$name ($ip)") }, onClick = {
|
||||
save(servers.toMutableList().also { it[idx] = it[idx].copy(ifName = name, 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)
|
||||
}
|
||||
}
|
||||
// Line 2: [TCP ▾] port
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
var protoExpanded by remember { mutableStateOf(false) }
|
||||
ExposedDropdownMenuBox(expanded = protoExpanded, onExpandedChange = { protoExpanded = it },
|
||||
modifier = Modifier.width(100.dp)) {
|
||||
OutlinedTextField(
|
||||
value = srv.protocol.uppercase(), onValueChange = {},
|
||||
readOnly = true, singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
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(
|
||||
value = portText, singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
onValueChange = { v ->
|
||||
portText = v
|
||||
val p = v.toIntOrNull()
|
||||
if (p != null && p in 1..65535)
|
||||
save(servers.toMutableList().also { it[idx] = srv.copy(port = p) })
|
||||
},
|
||||
modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
}
|
||||
// Add button
|
||||
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))
|
||||
|
||||
// ── Debug ──
|
||||
Text("Debug", style = MaterialTheme.typography.titleMedium)
|
||||
DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug)
|
||||
OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } },
|
||||
label = { Text("Log File") }, singleLine = true)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories },
|
||||
horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Categories", style = MaterialTheme.typography.titleMedium)
|
||||
Icon(Icons.Default.ArrowDropDown,
|
||||
contentDescription = if (showDebugCategories) "Collapse" else "Expand",
|
||||
modifier = Modifier.padding(end = 4.dp))
|
||||
}
|
||||
if (showDebugCategories) {
|
||||
CATEGORIES.forEach { cat ->
|
||||
val current = catLevels[cat] ?: ""
|
||||
DebugLevelDropdown(label = cat, current = current.ifEmpty { "(global)" },
|
||||
onSelect = { setCatLevel(cat, if (it == "NONE") "" else it) }, compact = true)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showOverwriteKeys) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showOverwriteKeys = false },
|
||||
title = { Text("Overwrite Keys?") },
|
||||
text = { Text("Keys already exist. Generating new keys will invalidate all existing connections and channels.") },
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
showOverwriteKeys = false
|
||||
dirty = true
|
||||
scope.launch { provider.generateKeys(); pubKey = provider.getString("node.public_key") ?: "" }
|
||||
}) { Text("Overwrite") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showOverwriteKeys = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DebugLevelDropdown(
|
||||
label: String, current: String, onSelect: (String) -> Unit, compact: Boolean = false
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
if (compact) {
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Text(label, modifier = Modifier.width(120.dp), style = MaterialTheme.typography.labelMedium)
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(value = current, onValueChange = {}, readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor().height(48.dp).weight(1f), singleLine = true)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
LEVELS.forEach { lvl ->
|
||||
DropdownMenuItem(text = { Text(lvl) }, onClick = { onSelect(lvl); expanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
|
||||
OutlinedTextField(value = current, onValueChange = {}, readOnly = true,
|
||||
label = { Text("$label level") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor(), singleLine = true)
|
||||
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
LEVELS.forEach { lvl ->
|
||||
DropdownMenuItem(text = { Text(lvl) }, onClick = { onSelect(lvl); expanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.utun.chat.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.utun.chat.data.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ChatViewModel(private val repo: ChatRepository) : ViewModel() {
|
||||
|
||||
private val _channels = MutableStateFlow<List<Channel>>(emptyList())
|
||||
val channels: StateFlow<List<Channel>> = _channels
|
||||
|
||||
private val _messages = MutableStateFlow<List<Message>>(emptyList())
|
||||
val messages: StateFlow<List<Message>> = _messages
|
||||
|
||||
private val _currentChannel = MutableStateFlow<Channel?>(null)
|
||||
val currentChannel: StateFlow<Channel?> = _currentChannel
|
||||
|
||||
init {
|
||||
NativeLib.setEventCallback(object : ChatEventCallback {
|
||||
override fun onEvent(type: Int, data: ByteArray?) {
|
||||
when (type) {
|
||||
1 -> { /* MSG_RECEIVED: [ch_id_len:1][ch_id:var][author:8] */
|
||||
if (data != null && data.size >= 2) {
|
||||
val chLen = data[0].toInt() and 0xFF
|
||||
if (data.size >= 1 + chLen) {
|
||||
val chId = String(data, 1, chLen)
|
||||
val cur = _currentChannel.value
|
||||
if (cur != null && cur.id == chId) refreshMessages(chId)
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
val nodeId = buf.getLong()
|
||||
val result = buf.getInt()
|
||||
val chId = buf.getLong()
|
||||
LogManager.addLog(
|
||||
if (result == 0) "INFO" else "ERROR",
|
||||
"JOIN", "connect to 0x%016x ch=$chId result=$result".format(nodeId))
|
||||
if (result == 0) refreshChannels()
|
||||
}
|
||||
}
|
||||
4 -> refreshChannels() /* CHANNEL_UPDATED */
|
||||
}
|
||||
}
|
||||
})
|
||||
refreshChannels()
|
||||
}
|
||||
|
||||
fun refreshChannels() {
|
||||
viewModelScope.launch {
|
||||
_channels.value = withContext(Dispatchers.IO) { repo.getChannels() }
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshMessages(chId: String) {
|
||||
viewModelScope.launch {
|
||||
_messages.value = withContext(Dispatchers.IO) { repo.getMessages(chId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun selectChannel(channel: Channel) {
|
||||
_currentChannel.value = channel
|
||||
refreshMessages(channel.id)
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
val ch = _currentChannel.value ?: return
|
||||
repo.sendMessage(ch.id, text)
|
||||
viewModelScope.launch {
|
||||
_messages.value = withContext(Dispatchers.IO) { repo.getMessages(ch.id) }
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
fun createChannel(name: String, channelId: String) {
|
||||
repo.createChannel(name, channelId)
|
||||
refreshChannels()
|
||||
}
|
||||
|
||||
fun connectNode(address: String, port: Int, pubkeyHex: String) {
|
||||
repo.connectNode(address, port, pubkeyHex)
|
||||
}
|
||||
|
||||
fun joinChannel(channelId: Long, nodeId: Long, pubkey: ByteArray, addrs: ByteArray, addrCount: Int) {
|
||||
repo.joinChannel(channelId, nodeId, pubkey, addrs, addrCount)
|
||||
}
|
||||
|
||||
class Factory(private val repo: ChatRepository) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = ChatViewModel(repo) as T
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
/*
|
||||
* android_jni_bridge.c — JNI bridge implementation
|
||||
*
|
||||
* C side of the bridge. For now:
|
||||
* - Minimal stubs for core functions (will connect to utun instance later)
|
||||
* - JNI function stubs (compile only on Android with NDK)
|
||||
* C side of the bridge. Wires Kotlin ↔ uTun stack:
|
||||
* - nativeStart(configText) → instance_lite_start()
|
||||
* - nativeJoinChannel(chId, nodeId, pubkey, addrs) → chat_sync_connect_from_invite()
|
||||
* - Chat events → JNI callback → Kotlin
|
||||
*/
|
||||
#include "android_jni_bridge.h"
|
||||
#include "../libutun_lite/utun_config_api.h"
|
||||
#include "../libutun_lite/invite_link_c.h"
|
||||
#include "../libutun_lite/instance_lite.h"
|
||||
#include "../../../lib/debug_config.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -19,7 +21,6 @@
|
||||
#include <android/log.h>
|
||||
#define LOG_TAG "utun-bridge"
|
||||
#else
|
||||
/* no android/log.h — fallback to stderr */
|
||||
#define ANDROID_LOG_VERBOSE 2
|
||||
#define ANDROID_LOG_DEBUG 3
|
||||
#define ANDROID_LOG_INFO 4
|
||||
@@ -27,10 +28,8 @@
|
||||
#define ANDROID_LOG_ERROR 6
|
||||
#endif
|
||||
|
||||
/* bridge log levels (match debug_config levels: 0=TRACE..4=ERROR) */
|
||||
enum { BLEV_TRACE = 0, BLEV_DEBUG = 1, BLEV_INFO = 2, BLEV_WARN = 3, BLEV_ERROR = 4 };
|
||||
|
||||
/* map bridge level → android log level */
|
||||
static int blevel_to_android(int blevel) {
|
||||
switch (blevel) {
|
||||
case BLEV_TRACE: return ANDROID_LOG_VERBOSE;
|
||||
@@ -42,13 +41,9 @@ static int blevel_to_android(int blevel) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Callback storage (declared before any use) ── */
|
||||
|
||||
static utun_log_fn g_on_log = NULL;
|
||||
static utun_event_fn g_on_event = NULL;
|
||||
|
||||
/* ── Log to logcat (Android) or stderr (headless) + forward to Kotlin ── */
|
||||
|
||||
static void bridge_log(int level, const char* fmt, ...) {
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
@@ -78,8 +73,6 @@ static void bridge_log(int level, const char* fmt, ...) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Log hook for debug_config → logcat + Kotlin LogManager ── */
|
||||
|
||||
static void bridge_debug_hook(int level, const char* category, const char* message) {
|
||||
#ifdef __ANDROID__
|
||||
__android_log_print(blevel_to_android(level), LOG_TAG, "[%s] %s",
|
||||
@@ -94,8 +87,6 @@ static utun_config_string_fn g_cfg_string = NULL;
|
||||
static utun_config_int64_fn g_cfg_int64 = NULL;
|
||||
static utun_config_int_fn g_cfg_int = NULL;
|
||||
|
||||
/* ── Config wrappers (bridge callbacks → config_api callbacks with defaults) ── */
|
||||
|
||||
static const char* cfg_wrapper_string(const char* key, const char* def) {
|
||||
if (g_cfg_string) { const char* val = g_cfg_string(key); return val ? val : def; }
|
||||
return def;
|
||||
@@ -125,7 +116,6 @@ bool utun_bridge_init(const char* db_path, int control_port,
|
||||
const struct utun_bridge_config* config) {
|
||||
g_on_log = on_log;
|
||||
g_on_event = on_event;
|
||||
|
||||
debug_set_log_hook(bridge_debug_hook);
|
||||
|
||||
if (config) {
|
||||
@@ -141,52 +131,60 @@ bool utun_bridge_init(const char* db_path, int control_port,
|
||||
utun_config_set_provider(&provider);
|
||||
}
|
||||
|
||||
bridge_log(BLEV_INFO, "uTun bridge init: db=%s port=%d",
|
||||
bridge_log(BLEV_INFO, "bridge init: db=%s port=%d (stub, use nativeStart for real init)",
|
||||
db_path ? db_path : "(null)", control_port);
|
||||
return true;
|
||||
}
|
||||
|
||||
void utun_bridge_destroy(void) {
|
||||
bridge_log(BLEV_INFO, "uTun bridge destroy");
|
||||
instance_lite_stop();
|
||||
g_on_log = NULL;
|
||||
g_on_event = NULL;
|
||||
g_cfg_string = NULL;
|
||||
g_cfg_int64 = NULL;
|
||||
g_cfg_int = NULL;
|
||||
bridge_log(BLEV_INFO, "bridge destroy");
|
||||
}
|
||||
|
||||
void 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");
|
||||
}
|
||||
}
|
||||
|
||||
void utun_bridge_poll(void) {
|
||||
}
|
||||
|
||||
void utun_bridge_send_message(const char* channel_id, const char* text) {
|
||||
bridge_log(BLEV_DEBUG, "send ch=%s text=%s",
|
||||
channel_id ? channel_id : "", text ? text : "");
|
||||
bridge_log(BLEV_DEBUG, "send ch=%s text=%s", channel_id ? channel_id : "", text ? text : "");
|
||||
}
|
||||
|
||||
void utun_bridge_create_channel(const char* name, const char* channel_id) {
|
||||
bridge_log(BLEV_INFO, "create_channel name=%s id=%s",
|
||||
name ? name : "", channel_id ? channel_id : "");
|
||||
bridge_log(BLEV_INFO, "create_channel name=%s id=%s", name ? name : "", channel_id ? channel_id : "");
|
||||
}
|
||||
|
||||
void utun_bridge_connect_node(const char* address, int port, const char* pubkey_hex) {
|
||||
bridge_log(BLEV_INFO, "connect %s:%d pubkey=%s",
|
||||
address ? address : "", port, pubkey_hex ? pubkey_hex : "");
|
||||
bridge_log(BLEV_INFO, "connect %s:%d pubkey=%s", address ? address : "", port, pubkey_hex ? pubkey_hex : "");
|
||||
}
|
||||
|
||||
/* Real implementation: calls chat_sync_connect_from_invite() */
|
||||
#include "chat/chat_sync.h"
|
||||
|
||||
void utun_bridge_join_channel(uint64_t channel_id, uint64_t node_id,
|
||||
const uint8_t* pubkey_bin,
|
||||
const uint8_t* addrs_data, int addr_count) {
|
||||
bridge_log(BLEV_INFO, "join ch=%llu node=0x%016llx addrs=%d",
|
||||
(unsigned long long)channel_id, (unsigned long long)node_id, addr_count);
|
||||
if (!pubkey_bin || !addrs_data || addr_count <= 0) {
|
||||
bridge_log(BLEV_ERROR, "join invalid args: pubkey=%p addrs=%p count=%d",
|
||||
(const void*)pubkey_bin, (const void*)addrs_data, addr_count);
|
||||
utun_bridge_event(6 /* CHAT_EVT_CONNECT_RESULT */, "{\"node_id\":0,\"result\":-7}");
|
||||
bridge_log(BLEV_ERROR, "join invalid args");
|
||||
utun_bridge_event(6, "{\"node_id\":0,\"result\":-7}"); /* CHAT_EVT_CONNECT_RESULT */
|
||||
return;
|
||||
}
|
||||
/* TODO: integrate with actual chat_sync_connect_from_invite() via uasync_post */
|
||||
bridge_log(BLEV_INFO, "join stub: returning OK (real chat_sync not connected yet)");
|
||||
utun_bridge_event(6, "{\"node_id\":0,\"result\":0}");
|
||||
chat_sync_connect_from_invite(channel_id, node_id, pubkey_bin, addrs_data, addr_count);
|
||||
}
|
||||
|
||||
char* utun_bridge_get_channels_json(void) {
|
||||
@@ -212,16 +210,17 @@ void utun_bridge_set_debug_level(const char* category, const char* level) {
|
||||
|
||||
static JavaVM* g_jvm = NULL;
|
||||
static jobject g_config_callback = NULL;
|
||||
static jobject g_log_callback = NULL; /* LogManager instance */
|
||||
static jobject g_log_callback = NULL;
|
||||
static jobject g_event_callback = NULL;
|
||||
static jmethodID g_event_mid = NULL;
|
||||
|
||||
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||
(void)reserved;
|
||||
g_jvm = vm;
|
||||
bridge_log(BLEV_INFO, "JNI_OnLoad OK");
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
|
||||
/* ── JNI log callback: calls LogManager.onNativeLog(level, cat, msg) ── */
|
||||
/* ── JNI log callback → LogManager.onNativeLog() ── */
|
||||
|
||||
static void jni_on_log(int level, const char* category, const char* message) {
|
||||
if (!g_jvm || !g_log_callback || !category || !message) return;
|
||||
@@ -237,6 +236,26 @@ static void jni_on_log(int level, const char* category, const char* message) {
|
||||
(*env)->DeleteLocalRef(env, jcat);
|
||||
}
|
||||
|
||||
/* ── JNI event callback (chat_event → Kotlin) ── */
|
||||
|
||||
static void jni_on_event(int type, const uint8_t* data, int len) {
|
||||
if (!g_jvm || !g_event_callback || !g_event_mid) return;
|
||||
JNIEnv* env = NULL;
|
||||
if ((*g_jvm)->GetEnv(g_jvm, (void**)&env, JNI_VERSION_1_6) != JNI_OK) return;
|
||||
jbyteArray jdata = NULL;
|
||||
if (data && len > 0) {
|
||||
jdata = (*env)->NewByteArray(env, len);
|
||||
if (jdata) (*env)->SetByteArrayRegion(env, jdata, 0, len, (const jbyte*)data);
|
||||
}
|
||||
(*env)->CallVoidMethod(env, g_event_callback, g_event_mid, (jint)type, jdata);
|
||||
if (jdata) (*env)->DeleteLocalRef(env, jdata);
|
||||
}
|
||||
|
||||
static void instance_event_handler(int type, const uint8_t* data, int len) {
|
||||
bridge_log(BLEV_DEBUG, "event type=%d len=%d", type, len);
|
||||
jni_on_event(type, data, len);
|
||||
}
|
||||
|
||||
static jstring jni_get_string_callback(JNIEnv* env, const char* key) {
|
||||
if (!g_config_callback) return NULL;
|
||||
jclass cls = (*env)->GetObjectClass(env, g_config_callback);
|
||||
@@ -272,6 +291,40 @@ static int jni_cfg_get_int(const char* key) {
|
||||
return s ? atoi(s) : 0;
|
||||
}
|
||||
|
||||
/* ── nativeStart(configText) — real uTun instance launch ── */
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStart(
|
||||
JNIEnv* env, jobject thiz, jstring configText) {
|
||||
(void)thiz;
|
||||
if (!configText) {
|
||||
bridge_log(BLEV_ERROR, "nativeStart: null config");
|
||||
return JNI_FALSE;
|
||||
}
|
||||
const char* cfg = (*env)->GetStringUTFChars(env, configText, NULL);
|
||||
if (!cfg) return JNI_FALSE;
|
||||
|
||||
/* Wire C logs → logcat + Kotlin LogManager */
|
||||
utun_log_fn log_cb = g_log_callback ? jni_on_log : NULL;
|
||||
utun_bridge_init(NULL, 0, log_cb, NULL, NULL);
|
||||
|
||||
/* Set up event forwarder */
|
||||
instance_lite_set_event_handler(instance_event_handler);
|
||||
|
||||
utun_bridge_start(cfg);
|
||||
(*env)->ReleaseStringUTFChars(env, configText, cfg);
|
||||
return instance_lite_is_running() ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
/* ── nativeStop() ── */
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeStop(
|
||||
JNIEnv* env, jobject thiz) {
|
||||
(void)env; (void)thiz;
|
||||
utun_bridge_destroy();
|
||||
}
|
||||
|
||||
/* ── nativeInit (legacy, just saves callbacks) ── */
|
||||
|
||||
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeInit(
|
||||
JNIEnv* env, jobject thiz, jstring dbPath, jint controlPort,
|
||||
jobject configCallback, jobject logCb) {
|
||||
@@ -284,8 +337,6 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeInit(
|
||||
g_log_callback = logCb ? (*env)->NewGlobalRef(env, logCb) : NULL;
|
||||
|
||||
const char* db = dbPath ? (*env)->GetStringUTFChars(env, dbPath, NULL) : NULL;
|
||||
|
||||
/* Set log hook so all C logs go to logcat + Kotlin LogManager */
|
||||
utun_log_fn log_cb = g_log_callback ? jni_on_log : NULL;
|
||||
|
||||
struct utun_bridge_config cfg = {
|
||||
@@ -294,17 +345,60 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeInit(
|
||||
};
|
||||
|
||||
bool ok = utun_bridge_init(db, (int)controlPort, log_cb, NULL, &cfg);
|
||||
|
||||
if (db) (*env)->ReleaseStringUTFChars(env, dbPath, db);
|
||||
return ok ? JNI_TRUE : JNI_FALSE;
|
||||
}
|
||||
|
||||
/* ── nativeJoinChannel ── */
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeJoinChannel(
|
||||
JNIEnv* env, jobject thiz, jlong channelId, jlong nodeId,
|
||||
jbyteArray pubkey, jbyteArray addrsData, jint addrCount) {
|
||||
(void)thiz;
|
||||
if (!pubkey || !addrsData) {
|
||||
bridge_log(BLEV_ERROR, "nativeJoinChannel null args");
|
||||
return;
|
||||
}
|
||||
jsize pk_len = (*env)->GetArrayLength(env, pubkey);
|
||||
jsize ad_len = (*env)->GetArrayLength(env, addrsData);
|
||||
if (pk_len < 32 || ad_len <= 0) {
|
||||
bridge_log(BLEV_ERROR, "nativeJoinChannel bad sizes pk=%d ad=%d", (int)pk_len, (int)ad_len);
|
||||
return;
|
||||
}
|
||||
uint8_t* pk = (uint8_t*)(*env)->GetByteArrayElements(env, pubkey, NULL);
|
||||
uint8_t* ad = (uint8_t*)(*env)->GetByteArrayElements(env, addrsData, NULL);
|
||||
bridge_log(BLEV_DEBUG, "nativeJoinChannel ch=%lld node=0x%llx pk=%02x%02x... addrs_sz=%d",
|
||||
(long long)channelId, (unsigned long long)nodeId, pk[0], pk[1], (int)ad_len);
|
||||
utun_bridge_join_channel((uint64_t)channelId, (uint64_t)nodeId, pk, ad, (int)addrCount);
|
||||
(*env)->ReleaseByteArrayElements(env, addrsData, (jbyte*)ad, JNI_ABORT);
|
||||
(*env)->ReleaseByteArrayElements(env, pubkey, (jbyte*)pk, JNI_ABORT);
|
||||
}
|
||||
|
||||
/* ── nativeSetEventCallback(eventCb) ── */
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetEventCallback(
|
||||
JNIEnv* env, jobject thiz, jobject callback) {
|
||||
(void)thiz;
|
||||
if (g_event_callback) (*env)->DeleteGlobalRef(env, g_event_callback);
|
||||
g_event_callback = callback ? (*env)->NewGlobalRef(env, callback) : NULL;
|
||||
if (g_event_callback) {
|
||||
jclass cls = (*env)->GetObjectClass(env, g_event_callback);
|
||||
g_event_mid = (*env)->GetMethodID(env, cls, "onEvent", "(I[B)V");
|
||||
if (!g_event_mid) bridge_log(BLEV_ERROR, "onEvent method not found on callback");
|
||||
} else {
|
||||
g_event_mid = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pass-through JNI wrappers (unchanged from before) ── */
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDestroy(
|
||||
JNIEnv* env, jobject thiz) {
|
||||
(void)thiz;
|
||||
utun_bridge_destroy();
|
||||
if (g_config_callback) { (*env)->DeleteGlobalRef(env, g_config_callback); g_config_callback = NULL; }
|
||||
if (g_log_callback) { (*env)->DeleteGlobalRef(env, g_log_callback); g_log_callback = NULL; }
|
||||
if (g_event_callback) { (*env)->DeleteGlobalRef(env, g_event_callback); g_event_callback = NULL; }
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSendMessage(
|
||||
@@ -337,29 +431,6 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeConnectNode(
|
||||
if (a) (*env)->ReleaseStringUTFChars(env, address, a);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeJoinChannel(
|
||||
JNIEnv* env, jobject thiz, jlong channelId, jlong nodeId,
|
||||
jbyteArray pubkey, jbyteArray addrsData, jint addrCount) {
|
||||
(void)thiz;
|
||||
if (!pubkey || !addrsData) {
|
||||
bridge_log(BLEV_ERROR, "nativeJoinChannel null args");
|
||||
return;
|
||||
}
|
||||
jsize pk_len = (*env)->GetArrayLength(env, pubkey);
|
||||
jsize ad_len = (*env)->GetArrayLength(env, addrsData);
|
||||
if (pk_len < 32 || ad_len <= 0) {
|
||||
bridge_log(BLEV_ERROR, "nativeJoinChannel bad sizes pk=%d ad=%d", (int)pk_len, (int)ad_len);
|
||||
return;
|
||||
}
|
||||
uint8_t* pk = (uint8_t*)(*env)->GetByteArrayElements(env, pubkey, NULL);
|
||||
uint8_t* ad = (uint8_t*)(*env)->GetByteArrayElements(env, addrsData, NULL);
|
||||
bridge_log(BLEV_DEBUG, "nativeJoinChannel ch=%lld node=0x%llx pk=%02x%02x... addrs_sz=%d",
|
||||
(long long)channelId, (unsigned long long)nodeId, pk[0], pk[1], (int)ad_len);
|
||||
utun_bridge_join_channel((uint64_t)channelId, (uint64_t)nodeId, pk, ad, (int)addrCount);
|
||||
(*env)->ReleaseByteArrayElements(env, addrsData, (jbyte*)ad, JNI_ABORT);
|
||||
(*env)->ReleaseByteArrayElements(env, pubkey, (jbyte*)pk, JNI_ABORT);
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetChannels(
|
||||
JNIEnv* env, jobject thiz) {
|
||||
(void)thiz;
|
||||
@@ -390,4 +461,11 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetDebugLevel(
|
||||
if (c) (*env)->ReleaseStringUTFChars(env, category, c);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSubscribeLogs(
|
||||
JNIEnv* env, jobject thiz, jboolean enabled) {
|
||||
(void)thiz; (void)env;
|
||||
g_on_log = (enabled && g_log_callback) ? jni_on_log : NULL;
|
||||
bridge_log(BLEV_INFO, "native log subscription %s", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
#endif /* __ANDROID__ */
|
||||
@@ -31,20 +31,19 @@ struct utun_bridge_config {
|
||||
utun_config_int_fn get_int;
|
||||
};
|
||||
|
||||
/* ── Initialize uTun core ── */
|
||||
/* ── Initialize uTun core (legacy stub — use start for real init) ── */
|
||||
|
||||
bool utun_bridge_init(const char* db_path, int control_port,
|
||||
utun_log_fn on_log,
|
||||
utun_event_fn on_event,
|
||||
const struct utun_bridge_config* config);
|
||||
|
||||
/* ── Start full uTun stack from INI config text ── */
|
||||
void utun_bridge_start(const char* config_text);
|
||||
|
||||
/* ── Shutdown uTun core ── */
|
||||
void utun_bridge_destroy(void);
|
||||
|
||||
/* ── Background poll (alternative to running uasync loop) ── */
|
||||
/* Called from main/uasync thread periodically to process */
|
||||
void utun_bridge_poll(void);
|
||||
|
||||
/* ── Actions (Kotlin → C) ── */
|
||||
|
||||
void utun_bridge_send_message(const char* channel_id, const char* text);
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* instance_lite.c — lightweight uTun instance lifecycle for Android
|
||||
*
|
||||
* Kotlin sends INI config text → parse_config_from_buf →
|
||||
* utun_instance_create_from_config → sqlite3_open → utun_instance_init →
|
||||
* pthread with uasync_poll loop.
|
||||
*
|
||||
* Mirrors UtunNode::runLoop() from tools/chatgui/transport/utun_node.cpp:221-372.
|
||||
*/
|
||||
#include "instance_lite.h"
|
||||
#include "utun_instance.h"
|
||||
#include "config_parser.h"
|
||||
#include "config_updater.h"
|
||||
#include "secure_channel.h"
|
||||
#include "etcp_router.h"
|
||||
#include "topo_node_sqlite.h"
|
||||
#include "chat/chat_core.h"
|
||||
#include "chat/chat_sync.h"
|
||||
#include "chat/chat_event.h"
|
||||
#include "u_async.h"
|
||||
#include "sqlite3.h"
|
||||
#include "mem.h"
|
||||
#include "debug_config.h"
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#ifdef __ANDROID__
|
||||
#include <android/log.h>
|
||||
#define IL_LOGV(fmt, ...) __android_log_print(ANDROID_LOG_VERBOSE, "utun-inst", fmt, ##__VA_ARGS__)
|
||||
#define IL_LOGI(fmt, ...) __android_log_print(ANDROID_LOG_INFO, "utun-inst", fmt, ##__VA_ARGS__)
|
||||
#define IL_LOGE(fmt, ...) __android_log_print(ANDROID_LOG_ERROR, "utun-inst", fmt, ##__VA_ARGS__)
|
||||
#else
|
||||
#define IL_LOGV(fmt, ...) fprintf(stderr, fmt "\n", ##__VA_ARGS__)
|
||||
#define IL_LOGI(fmt, ...) fprintf(stderr, fmt "\n", ##__VA_ARGS__)
|
||||
#define IL_LOGE(fmt, ...) fprintf(stderr, fmt "\n", ##__VA_ARGS__)
|
||||
#endif
|
||||
|
||||
/* ── Static state ── */
|
||||
|
||||
static struct UASYNC* g_ua = NULL;
|
||||
static struct UTUN_INSTANCE* g_inst = NULL;
|
||||
static pthread_t g_thread;
|
||||
static volatile int g_running = 0;
|
||||
static volatile int g_stop = 0;
|
||||
static char g_db_path[512];
|
||||
static instance_lite_event_fn g_event_handler = NULL;
|
||||
static char g_generated_pub[65];
|
||||
static char g_generated_priv[65];
|
||||
|
||||
/* ── Generate X25519 key pair, hex-encode to out buffers ── */
|
||||
|
||||
static int generate_x25519_keys(char pub_hex[65], char priv_hex[65]) {
|
||||
EVP_PKEY* pkey = NULL;
|
||||
EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL);
|
||||
if (!ctx) return -1;
|
||||
if (EVP_PKEY_keygen_init(ctx) <= 0 || EVP_PKEY_generate(ctx, &pkey) <= 0) {
|
||||
EVP_PKEY_CTX_free(ctx);
|
||||
return -1;
|
||||
}
|
||||
EVP_PKEY_CTX_free(ctx);
|
||||
size_t pkLen = 32, skLen = 32;
|
||||
uint8_t pk[32], sk[32];
|
||||
EVP_PKEY_get_raw_public_key(pkey, pk, &pkLen);
|
||||
EVP_PKEY_get_raw_private_key(pkey, sk, &skLen);
|
||||
EVP_PKEY_free(pkey);
|
||||
for (int i = 0; i < 32; i++) snprintf(pub_hex + i * 2, 3, "%02x", pk[i]);
|
||||
for (int i = 0; i < 32; i++) snprintf(priv_hex + i * 2, 3, "%02x", sk[i]);
|
||||
pub_hex[64] = '\0';
|
||||
priv_hex[64] = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Ensure valid keys in config, generate if missing ── */
|
||||
|
||||
static void ensure_keys(struct utun_config* config) {
|
||||
if (strlen(config->global.my_private_key_hex) != 64 ||
|
||||
strlen(config->global.my_public_key_hex) != 64) {
|
||||
IL_LOGI("config keys missing or invalid, generating new X25519 keypair");
|
||||
if (generate_x25519_keys(g_generated_pub, g_generated_priv) == 0) {
|
||||
memcpy(config->global.my_public_key_hex, g_generated_pub, 65);
|
||||
memcpy(config->global.my_private_key_hex, g_generated_priv, 65);
|
||||
IL_LOGI("new keys generated pub=%s", g_generated_pub);
|
||||
} else {
|
||||
IL_LOGE("key generation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Helper: extract single config value by key ── */
|
||||
|
||||
static const char* cfg_get(const char* text, const char* key) {
|
||||
size_t klen = strlen(key);
|
||||
const char* p = text;
|
||||
while (*p) {
|
||||
if (strncmp(p, key, klen) == 0 && p[klen] == '=') {
|
||||
return p + klen + 1;
|
||||
}
|
||||
while (*p && *p != '\n') p++;
|
||||
if (*p == '\n') p++;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void cfg_get_val(const char* text, const char* key, char* out, size_t out_sz) {
|
||||
const char* v = cfg_get(text, key);
|
||||
if (!v) { out[0] = '\0'; return; }
|
||||
size_t i = 0;
|
||||
while (*v && *v != '\n' && *v != '\r' && i < out_sz - 1)
|
||||
out[i++] = *v++;
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
/* ── Chat event forward → Kotlin callback ── */
|
||||
|
||||
static void chat_event_forward(int type, const uint8_t* data, int len) {
|
||||
if (g_event_handler) g_event_handler(type, data, len);
|
||||
}
|
||||
|
||||
/* ── Thread function ── */
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Create uasync */
|
||||
g_ua = uasync_create();
|
||||
if (!g_ua) {
|
||||
IL_LOGE("uasync_create failed");
|
||||
free_config(config);
|
||||
g_running = 0;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
g_running = 0;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
IL_LOGI("instance created, node_id=0x%016llx", (unsigned long long)g_inst->node_id);
|
||||
|
||||
/* Open shared SQLite DB */
|
||||
if (g_db_path[0] && !g_inst->topo_sqlite_db) {
|
||||
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);
|
||||
sqlite3_exec(g_inst->topo_sqlite_db, "PRAGMA wal_autocheckpoint=10000", NULL, NULL, NULL);
|
||||
topo_node_sqlite_init(g_inst->topo_sqlite_db);
|
||||
IL_LOGI("SQLite opened %s", db_file);
|
||||
} else {
|
||||
IL_LOGE("SQLite open failed rc=%d", rc);
|
||||
if (g_inst->topo_sqlite_db) { sqlite3_close(g_inst->topo_sqlite_db); g_inst->topo_sqlite_db = NULL; }
|
||||
}
|
||||
}
|
||||
|
||||
/* Full init: db_sync, chat_core, chat_sync, init_connections */
|
||||
if (utun_instance_init(g_inst) != 0) {
|
||||
IL_LOGE("utun_instance_init failed");
|
||||
utun_instance_destroy(g_inst);
|
||||
g_inst = NULL;
|
||||
uasync_destroy(g_ua, 0);
|
||||
g_ua = NULL;
|
||||
g_running = 0;
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Bind chat sync via etcp_router */
|
||||
etcp_router_bind(g_inst, ETCP_RT_ID_CHAT_SYNC, NULL);
|
||||
|
||||
/* Forward chat events → Kotlin */
|
||||
chat_event_set_handler(chat_event_forward);
|
||||
|
||||
IL_LOGI("all init done, entering poll loop");
|
||||
|
||||
g_running = 1;
|
||||
|
||||
while (!g_stop) {
|
||||
uasync_poll(g_ua, 100);
|
||||
}
|
||||
|
||||
IL_LOGI("poll loop exit");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* ── Public API ── */
|
||||
|
||||
int instance_lite_start(const char* config_text) {
|
||||
if (!config_text || g_running) return -1;
|
||||
|
||||
debug_config_init();
|
||||
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;
|
||||
|
||||
g_stop = 0;
|
||||
int rc = pthread_create(&g_thread, NULL, instance_thread, config_copy);
|
||||
if (rc != 0) {
|
||||
u_free(config_copy);
|
||||
IL_LOGE("pthread_create failed rc=%d", rc);
|
||||
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_running && !g_inst) return;
|
||||
IL_LOGI("stopping...");
|
||||
|
||||
g_stop = 1;
|
||||
if (g_ua) uasync_wakeup(g_ua);
|
||||
pthread_join(g_thread, NULL);
|
||||
|
||||
if (g_inst) {
|
||||
etcp_router_bind(g_inst, ETCP_RT_ID_CHAT_SYNC, NULL);
|
||||
chat_sync_destroy(g_inst);
|
||||
chat_core_destroy(g_inst);
|
||||
utun_instance_destroy(g_inst);
|
||||
g_inst = NULL;
|
||||
}
|
||||
if (g_ua) {
|
||||
uasync_destroy(g_ua, 0);
|
||||
g_ua = NULL;
|
||||
}
|
||||
g_running = 0;
|
||||
g_event_handler = NULL;
|
||||
IL_LOGI("stopped");
|
||||
}
|
||||
|
||||
int instance_lite_is_running(void) {
|
||||
return g_running ? 1 : 0;
|
||||
}
|
||||
|
||||
struct UASYNC* instance_lite_get_uasync(void) {
|
||||
return g_ua;
|
||||
}
|
||||
|
||||
void instance_lite_set_event_handler(instance_lite_event_fn handler) {
|
||||
g_event_handler = handler;
|
||||
if (g_running) chat_event_set_handler(chat_event_forward);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* instance_lite.h — lightweight uTun instance lifecycle for Android
|
||||
*
|
||||
* Kotlin generates INI config text, passes to instance_lite_start().
|
||||
* C side parses it, creates uasync+ETCP+bgp+chat stack, runs in a pthread.
|
||||
* Mirrors UtunNode::runLoop() from tools/chatgui/transport/utun_node.cpp.
|
||||
*/
|
||||
#ifndef INSTANCE_LITE_H
|
||||
#define INSTANCE_LITE_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
struct UASYNC;
|
||||
|
||||
/* Start full uTun stack from INI config text.
|
||||
* Creates a pthread for uasync event loop.
|
||||
* Returns 0 on success, -1 on error.
|
||||
* Config format: same as utun INI (see node_config.cpp saveFull). */
|
||||
int instance_lite_start(const char* config_text);
|
||||
|
||||
/* Signal stop and wait for thread to exit.
|
||||
* Calls chat_sync_destroy, chat_core_destroy, utun_instance_destroy, uasync_destroy. */
|
||||
void instance_lite_stop(void);
|
||||
|
||||
/* Returns non-zero if instance is running. */
|
||||
int instance_lite_is_running(void);
|
||||
|
||||
/* Get current uasync handle (NULL if not running). */
|
||||
struct UASYNC* instance_lite_get_uasync(void);
|
||||
|
||||
/* Set Kotlin callback for chat events (CONNECT_RESULT, MSG_RECEIVED, ...) */
|
||||
typedef void (*instance_lite_event_fn)(int type, const uint8_t* data, int len);
|
||||
void instance_lite_set_event_handler(instance_lite_event_fn handler);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif /* INSTANCE_LITE_H */
|
||||
@@ -35,6 +35,7 @@ function(utun_setup_sources LIB_DIR SRC_DIR CONFIG_DIR TOOLS_DIR)
|
||||
set(_cfg_src
|
||||
"${CONFIG_DIR}/utun_config_api.c"
|
||||
"${CONFIG_DIR}/invite_link_c.c"
|
||||
"${CONFIG_DIR}/instance_lite.c"
|
||||
)
|
||||
|
||||
# ── Export sources ──
|
||||
|
||||
Reference in new issue
Block a user