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:
evgeny committed 2026-07-28 00:01:57 +03:00
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)
}
@@ -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)) }
}
}
}
@@ -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
}
}