wip: STCP TCP chat integration — diagnostics, invite TCP fallback, test infra

This commit is contained in:
evgeny committed 2026-08-06 18:41:41 +03:00
1 parent e2d8f68c9a
commit dfe9d36783
41 files changed
+2432 -294

No files matched your search

@@ -8,6 +8,7 @@ import java.nio.ByteOrder
const val EVT_NODEINFO_UPDATED = 16
const val EVT_LINK_STATUS_CHANGED = 17
const val EVT_LOCAL_SOCKETS = 19
data class NodeStatus(
val nodeId: Long = 0,
@@ -79,6 +80,14 @@ data class LinkStatus(val family: Int = 0, val address: String = "", val port: I
data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false,
val linksUp: Int = 0, val links: List<LinkStatus> = emptyList())
data class LocalSocketState(
val sockId: Int = 0, val ifName: String = "", val bindIp: String = "",
val port: Int = 0, val protocol: String = "", val state: Int = 0,
val linksCount: Int = 0
) {
val stateLabel: String get() = when (state) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" }
}
class ChatRepository {
private val myNodeId: Long = NativeLib.getMyNodeId()
@@ -292,5 +301,26 @@ class ChatRepository {
return MemberLinks()
}
fun getLocalSockets(): List<LocalSocketState> {
try {
val arr = JSONArray(NativeLib.getLocalSockets())
val list = mutableListOf<LocalSocketState>()
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
list.add(LocalSocketState(
sockId = obj.optInt("si", 0),
ifName = obj.optString("nm", ""),
bindIp = obj.optString("ad", ""),
port = obj.optInt("po", 0),
protocol = obj.optString("pt", ""),
state = obj.optInt("st", 0),
linksCount = obj.optInt("lk", 0)
))
}
return list
} catch (e: Exception) { Log.w("utun-gui", "getLocalSockets error", e) }
return emptyList()
}
fun close() {}
}
@@ -43,6 +43,7 @@ class ConfigProvider(private val context: Context) {
private val chatCompressorRiseRate = intPreferencesKey("chat_compressor_rise_rate")
private val chatMediaDownloadMaxPeers = intPreferencesKey("chat_media_download_max_peers")
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
private val connectionModeKey = stringPreferencesKey("connection_mode")
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@@ -187,6 +188,16 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[firstLaunchDone] = true }
}
/* ── Connection mode ── */
fun getConnectionMode(): String = runBlocking {
context.dataStore.data.first()[connectionModeKey] ?: "manual"
}
suspend fun setConnectionMode(mode: String) {
context.dataStore.edit { prefs -> prefs[connectionModeKey] = mode }
}
/* ── Server entries ── */
suspend fun detectInterfaces(): List<ServerEntry> {
@@ -364,6 +375,13 @@ class ConfigProvider(private val context: Context) {
/** Build full INI config text for NativeLib.start() */
fun buildConfigText(): String {
if (getConnectionMode() == "auto") {
val detected = runBlocking { detectInterfaces() }
LogManager.addLog("INFO", "CFG", "buildConfigText: auto mode, detected ${detected.size} interfaces" +
detected.joinToString(", ") { "${it.ifName}=${it.protocol}" })
saveServersToConfig(detected)
return buildConfigFromServers(detected)
}
val cfgFile = File(configFilePath())
val existing = if (cfgFile.exists()) loadServersFromConfig() else emptyList()
LogManager.addLog("INFO", "CFG", "buildConfigText: exists=${cfgFile.exists()} srv=${existing.size}" +
@@ -86,6 +86,9 @@ object NativeLib {
}
fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}"
/* ── Local sockets (for auto-mode display) ── */
fun getLocalSockets(): String = nativeGetLocalSockets() ?: "[]"
/* ── Voice recording ── */
fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId)
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
@@ -145,6 +148,9 @@ object NativeLib {
private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean)
private external fun nativeGetMemberLinks(nodeId: Long): String?
/* ── Local sockets JNI ── */
private external fun nativeGetLocalSockets(): String?
/* ── Voice recording JNI ── */
private external fun nativeVoiceStart(channelId: String): Boolean
private external fun nativeVoiceFeed(samples: ShortArray): Boolean
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
import com.utun.chat.ChatApplication
import com.utun.chat.data.NativeLib
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -107,6 +108,9 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
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()) }
var connectionMode by remember { mutableStateOf("manual") }
val localSocketStates by vm.localSockets.collectAsState()
LaunchedEffect(Unit) {
nodeName = provider.getString("node.name") ?: provider.getDeviceName()
@@ -125,9 +129,18 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
catLevels = if (stored.isNotEmpty()) stored.split(",").associate { it.split("=", limit = 2).let { (k, v) -> k to v } } else emptyMap()
servers = provider.loadServersFromConfig()
availableIfaces = provider.getAvailableInterfaces()
connectionMode = provider.getConnectionMode()
}
val save = { s: List<ServerEntry> -> onDirty(true); servers = s; provider.saveServersToConfig(s) }
val switchMode = { mode: String ->
connectionMode = mode; onDirty(true)
scope.launch(Dispatchers.IO) {
provider.setConnectionMode(mode)
NativeLib.restart(provider.getString("db.path") ?: "",
provider.getInt("control.port"), provider.buildConfigText())
}
}
val setDebug = { level: String ->
onDirty(true); NativeLib.setDebugLevel("", level)
scope.launch { provider.setValue("debug.console_level", level) }; debugLevel = level
@@ -192,60 +205,97 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
HorizontalDivider(Modifier.padding(vertical = 8.dp))
/* Network Interfaces */
/* ── Connection Mode ── */
Text("Network Interfaces", style = MaterialTheme.typography.titleMedium)
servers.forEachIndexed { idx, srv ->
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
var ifExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = ifExpanded, onExpandedChange = { ifExpanded = it }, modifier = Modifier.weight(1f)) {
OutlinedTextField(srv.ifName, {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ifExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(ifExpanded, onDismissRequest = { ifExpanded = false }) {
availableIfaces.forEach { (n, ip) ->
DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = {
save(servers.toMutableList().also { it[idx] = it[idx].copy(ifName = n, bindIp = ip) }); ifExpanded = false
})
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = connectionMode == "manual", onClick = { if (connectionMode != "manual") switchMode("manual") },
label = { Text("Manual") })
FilterChip(selected = connectionMode == "auto", onClick = { if (connectionMode != "auto") switchMode("auto") },
label = { Text("Auto") })
}
if (connectionMode == "auto") {
/* Auto mode: read-only live socket display */
if (localSocketStates.isEmpty()) {
Text("No sockets detected", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
localSocketStates.forEach { s ->
val stateColor = when (s.state) { 2 -> Color(0xFF27AE60); 1 -> Color(0xFFF1C40F); else -> Color(0xFFE53935) }
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(s.ifName, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
Spacer(Modifier.width(4.dp))
Text("${s.bindIp}:${s.port}", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.width(6.dp))
Surface(shape = MaterialTheme.shapes.extraSmall, color = stateColor,
modifier = Modifier.size(10.dp)) {}
Spacer(Modifier.width(4.dp))
Text(s.stateLabel, style = MaterialTheme.typography.labelSmall, color = stateColor)
Spacer(Modifier.width(6.dp))
Text(s.protocol, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (s.linksCount > 0) {
Spacer(Modifier.width(4.dp))
Text("${s.linksCount} links", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
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)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
var protoExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = protoExpanded, onExpandedChange = { protoExpanded = it }, modifier = Modifier.width(100.dp)) {
OutlinedTextField(srv.protocol.uppercase(), {}, readOnly = true, singleLine = true,
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(portText, { v -> portText = v; val p = v.toIntOrNull(); if (p != null && p in 1..65535)
save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) },
singleLine = true, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
}
}
Spacer(Modifier.height(2.dp))
} else {
/* Manual mode: existing editable server config */
servers.forEachIndexed { idx, srv ->
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
var ifExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = ifExpanded, onExpandedChange = { ifExpanded = it }, modifier = Modifier.weight(1f)) {
OutlinedTextField(srv.ifName, {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ifExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(ifExpanded, onDismissRequest = { ifExpanded = false }) {
availableIfaces.forEach { (n, ip) ->
DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = {
save(servers.toMutableList().also { it[idx] = it[idx].copy(ifName = n, 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)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
var protoExpanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = protoExpanded, onExpandedChange = { protoExpanded = it }, modifier = Modifier.width(100.dp)) {
OutlinedTextField(srv.protocol.uppercase(), {}, readOnly = true, singleLine = true,
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(portText, { v -> portText = v; val p = v.toIntOrNull(); if (p != null && p in 1..65535)
save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) },
singleLine = true, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
}
}
Spacer(Modifier.height(2.dp))
}
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") }
}
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))
@@ -65,6 +65,9 @@ class ChatViewModel : ViewModel() {
private val _nodeStatuses = MutableStateFlow<Map<Long, NodeStatus>>(emptyMap())
val nodeStatuses: StateFlow<Map<Long, NodeStatus>> = _nodeStatuses
private val _localSockets = MutableStateFlow<List<LocalSocketState>>(emptyList())
val localSockets: StateFlow<List<LocalSocketState>> = _localSockets
val audioRecorder = AudioRecorderManager()
private val _isRecording = MutableStateFlow(false)
@@ -143,6 +146,7 @@ class ChatViewModel : ViewModel() {
dbReady = true
LogManager.addLog("INFO", "VM", "DB ready")
refreshChannels()
refreshLocalSockets()
}
11 -> { /* KEYS_GENERATED: [pub_hex:64] */
if (data != null && data.size >= 64) {
@@ -209,6 +213,7 @@ class ChatViewModel : ViewModel() {
val detail = _selectedMember.value
if (detail != null && detail.nodeId == nodeId) refreshMemberLinks(nodeId)
}
19 -> { if (repo != null) refreshLocalSockets() } /* LOCAL_SOCKETS */
}
}
@@ -301,6 +306,13 @@ class ChatViewModel : ViewModel() {
}
}
fun refreshLocalSockets() {
val r = repo ?: return
viewModelScope.launch {
_localSockets.value = withContext(Dispatchers.IO) { r.getLocalSockets() }
}
}
fun sendMessage(text: String) {
val r = repo ?: return
val ch = _currentChannel.value ?: return
@@ -490,6 +502,7 @@ class ChatViewModel : ViewModel() {
_messages.value = emptyList()
_currentChannel.value = null
_generatingKeys.value = false
_localSockets.value = emptyList()
}
class Factory : ViewModelProvider.Factory {