android: member list screen + link status dots, ipv6 autoconnect fixes
Android chatgui: - New MemberListScreen with member list, detail card, edit dialog - Link status dots (green/red/grey) per address in detail card (1s poll) - Self-node name coloured green - ChatScreen: people icon in TopAppBar IPv6 autoconnect fixes (3 bugs in conn_mgr/topo): - topo_node_sqlite_node_load: load v6 addresses from DB - topo_node_registry_store: merge v6_addrs into existing node - cm_sock_v6_classify: wildcard [::] treated as CM_V6_DIR - cm_add_v6_link: htons(port) was missing Memory fixes: - strdup→u_strdup in bridge (7 instances) - u_realloc cap update before success check (3 instances) - char sql[256]→[512] for truncated SELECT Chat member hash now includes addresses for change detection Linux chatgui: - Link status dots in AccountList detail panel (1s poll) - Self-node name in green in member list
This commit is contained in:
1 parent
6719a1e23c
commit
50156e12af
19 files changed
+1022
-23
No files matched your search
@@ -15,7 +15,7 @@ import com.utun.chat.viewmodel.ChatViewModel
|
||||
import android.content.Intent
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
enum class Screen { ChannelList, Chat, Settings, Logs, QrScan }
|
||||
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -32,7 +32,10 @@ class MainActivity : ComponentActivity() {
|
||||
var joinAutoLink by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
BackHandler(enabled = screen != Screen.ChannelList) {
|
||||
screen = Screen.ChannelList
|
||||
when (screen) {
|
||||
Screen.MemberList -> screen = Screen.Chat
|
||||
else -> screen = Screen.ChannelList
|
||||
}
|
||||
}
|
||||
|
||||
val connected = NativeLib.isReady()
|
||||
@@ -69,10 +72,25 @@ class MainActivity : ComponentActivity() {
|
||||
connected = connected,
|
||||
viewModel = vm,
|
||||
onSendMessage = { vm.sendMessage(it) },
|
||||
onMembersClick = { screen = Screen.MemberList },
|
||||
onBack = { screen = Screen.ChannelList }
|
||||
)
|
||||
}
|
||||
}
|
||||
Screen.MemberList -> {
|
||||
val members by vm.members.collectAsState()
|
||||
val selectedMember by vm.selectedMember.collectAsState()
|
||||
val channel = vm.currentChannel.collectAsState().value
|
||||
if (channel != null) {
|
||||
MemberListScreen(
|
||||
channel = channel,
|
||||
members = members,
|
||||
selectedMember = selectedMember,
|
||||
viewModel = vm,
|
||||
onBack = { screen = Screen.Chat }
|
||||
)
|
||||
}
|
||||
}
|
||||
Screen.Settings -> SettingsScreen(vm = vm, onBack = { screen = Screen.ChannelList }, onExit = { finish() })
|
||||
Screen.Logs -> LogScreen(onBack = { screen = Screen.ChannelList })
|
||||
Screen.QrScan -> {
|
||||
|
||||
@@ -12,6 +12,24 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L
|
||||
val voiceDurationMs: Int = 0, val fileSize: Long = 0,
|
||||
val downloadState: String = "" /* "", "downloading", "downloaded", "error" */)
|
||||
|
||||
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
|
||||
val isAdmin: Boolean = false, val isSuper: Boolean = false,
|
||||
val isStorage: Boolean = false, val nodeType: Int = 0,
|
||||
val rtt: Int = 0, val isSelf: Boolean = false)
|
||||
|
||||
data class MemberDetail(val nodeId: Long, val name: String = "",
|
||||
val online: Boolean = false, val lastSeen: Long = 0,
|
||||
val created: Long = 0, val addresses: List<MemberAddr> = emptyList())
|
||||
|
||||
data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "",
|
||||
val port: Int = 0, val socketId: Int = 0, val rtt: Int = 0)
|
||||
|
||||
data class LinkStatus(val family: Int = 0, val address: String = "", val port: Int = 0,
|
||||
val linkState: Int = 0, val linkStatus: Int = 0)
|
||||
|
||||
data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false,
|
||||
val linksUp: Int = 0, val links: List<LinkStatus> = emptyList())
|
||||
|
||||
class ChatRepository {
|
||||
private val myNodeId: Long = NativeLib.getMyNodeId()
|
||||
|
||||
@@ -138,5 +156,92 @@ class ChatRepository {
|
||||
fun connectChannel(channelId: String) = NativeLib.connectChannel(channelId)
|
||||
fun isReady() = NativeLib.isReady()
|
||||
|
||||
fun getMembers(channelId: String): List<ChatMember> {
|
||||
val list = mutableListOf<ChatMember>()
|
||||
try {
|
||||
val arr = JSONArray(NativeLib.getChannelMembers(channelId))
|
||||
for (i in 0 until arr.length()) {
|
||||
val obj = arr.getJSONObject(i)
|
||||
list.add(ChatMember(
|
||||
nodeId = obj.getLong("ni"),
|
||||
name = obj.optString("n", ""),
|
||||
online = obj.optInt("on", 0) != 0,
|
||||
isAdmin = obj.optInt("ad", 0) != 0,
|
||||
isSuper = obj.optInt("sp", 0) != 0,
|
||||
isStorage = obj.optInt("st", 0) != 0,
|
||||
nodeType = obj.optInt("tp", 0),
|
||||
rtt = obj.optInt("rt", 0),
|
||||
isSelf = obj.optInt("is", 0) != 0
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getMembers error", e) }
|
||||
return list
|
||||
}
|
||||
|
||||
fun getMemberDetail(channelId: String, nodeId: Long): MemberDetail? {
|
||||
try {
|
||||
val obj = JSONObject(NativeLib.getMemberDetail(channelId, nodeId))
|
||||
if (obj.length() == 0) return null
|
||||
val addrs = mutableListOf<MemberAddr>()
|
||||
val arr = obj.optJSONArray("adr")
|
||||
if (arr != null) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val a = arr.getJSONObject(i)
|
||||
addrs.add(MemberAddr(
|
||||
family = a.optInt("fm", 0),
|
||||
protocol = a.optString("pt", ""),
|
||||
address = a.optString("ad", ""),
|
||||
port = a.optInt("po", 0),
|
||||
socketId = a.optInt("si", 0),
|
||||
rtt = a.optInt("rt", 0)
|
||||
))
|
||||
}
|
||||
}
|
||||
return MemberDetail(
|
||||
nodeId = obj.getLong("ni"),
|
||||
name = obj.optString("n", ""),
|
||||
online = obj.optInt("on", 0) != 0,
|
||||
lastSeen = obj.optLong("ls", 0),
|
||||
created = obj.optLong("cr", 0),
|
||||
addresses = addrs
|
||||
)
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getMemberDetail error", e) }
|
||||
return null
|
||||
}
|
||||
|
||||
fun setMemberNick(channelId: String, nodeId: Long, nick: String) =
|
||||
NativeLib.setMemberNick(channelId, nodeId, nick)
|
||||
|
||||
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) =
|
||||
NativeLib.setMemberFlags(channelId, nodeId, admin, supernode, storage)
|
||||
|
||||
fun getMemberLinks(nodeId: Long): MemberLinks {
|
||||
try {
|
||||
val obj = JSONObject(NativeLib.getMemberLinks(nodeId))
|
||||
if (!obj.optBoolean("cn", false)) return MemberLinks()
|
||||
val links = mutableListOf<LinkStatus>()
|
||||
val arr = obj.optJSONArray("lk")
|
||||
if (arr != null) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val a = arr.getJSONObject(i)
|
||||
links.add(LinkStatus(
|
||||
family = a.optInt("fm", 0),
|
||||
address = a.optString("ad", ""),
|
||||
port = a.optInt("po", 0),
|
||||
linkState = a.optInt("st", 0),
|
||||
linkStatus = a.optInt("ls", 0)
|
||||
))
|
||||
}
|
||||
}
|
||||
return MemberLinks(
|
||||
connected = obj.optBoolean("cn", false),
|
||||
initialized = obj.optBoolean("in", false),
|
||||
linksUp = obj.optInt("lu", 0),
|
||||
links = links
|
||||
)
|
||||
} catch (e: Exception) { Log.w("utun-gui", "getMemberLinks error", e) }
|
||||
return MemberLinks()
|
||||
}
|
||||
|
||||
fun close() {}
|
||||
}
|
||||
@@ -81,6 +81,15 @@ object NativeLib {
|
||||
fun setUdpLogTarget(ip: String, port: Int) { nativeSetUdpLogTarget(ip, port) }
|
||||
fun regenerateKeys() { nativeRegenerateKeys() }
|
||||
|
||||
/* ── Channel members ── */
|
||||
fun getChannelMembers(channelId: String): String = nativeGetChannelMembers(channelId) ?: "[]"
|
||||
fun getMemberDetail(channelId: String, nodeId: Long): String = nativeGetMemberDetail(channelId, nodeId) ?: "{}"
|
||||
fun setMemberNick(channelId: String, nodeId: Long, nick: String) { nativeSetMemberNick(channelId, nodeId, nick) }
|
||||
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) {
|
||||
nativeSetMemberFlags(channelId, nodeId, admin, supernode, storage)
|
||||
}
|
||||
fun getMemberLinks(nodeId: Long): String = nativeGetMemberLinks(nodeId) ?: "{}"
|
||||
|
||||
/* ── Voice recording ── */
|
||||
fun voiceStart(channelId: String): Boolean = nativeVoiceStart(channelId)
|
||||
fun voiceFeed(samples: ShortArray): Boolean = nativeVoiceFeed(samples)
|
||||
@@ -130,6 +139,13 @@ object NativeLib {
|
||||
private external fun nativePing()
|
||||
private external fun nativeIsResponsive(): Boolean
|
||||
|
||||
/* ── Channel members JNI ── */
|
||||
private external fun nativeGetChannelMembers(channelId: String): String?
|
||||
private external fun nativeGetMemberDetail(channelId: String, nodeId: Long): String?
|
||||
private external fun nativeSetMemberNick(channelId: String, nodeId: Long, nick: String)
|
||||
private external fun nativeSetMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean)
|
||||
private external fun nativeGetMemberLinks(nodeId: Long): String?
|
||||
|
||||
/* ── Voice recording JNI ── */
|
||||
private external fun nativeVoiceStart(channelId: String): Boolean
|
||||
private external fun nativeVoiceFeed(samples: ShortArray): Boolean
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -26,6 +27,7 @@ fun ChatScreen(
|
||||
connected: Boolean,
|
||||
viewModel: ChatViewModel,
|
||||
onSendMessage: (String) -> Unit,
|
||||
onMembersClick: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var text by remember { mutableStateOf("") }
|
||||
@@ -58,6 +60,9 @@ fun ChatScreen(
|
||||
title = { Text(channel.name) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") }
|
||||
}
|
||||
)
|
||||
LazyColumn(
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.data.Channel
|
||||
import com.utun.chat.data.ChatMember
|
||||
import com.utun.chat.data.MemberDetail
|
||||
import com.utun.chat.data.MemberAddr
|
||||
import com.utun.chat.data.MemberLinks
|
||||
import com.utun.chat.data.LinkStatus
|
||||
import com.utun.chat.viewmodel.ChatViewModel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MemberListScreen(
|
||||
channel: Channel,
|
||||
members: List<ChatMember>,
|
||||
selectedMember: MemberDetail?,
|
||||
viewModel: ChatViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
var showEditDialog by remember { mutableStateOf(false) }
|
||||
var editMember by remember { mutableStateOf<ChatMember?>(null) }
|
||||
|
||||
LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) }
|
||||
|
||||
val memberLinks by viewModel.memberLinks.collectAsState()
|
||||
|
||||
LaunchedEffect(selectedMember?.nodeId) {
|
||||
val nodeId = selectedMember?.nodeId
|
||||
if (nodeId == null) { viewModel.refreshMemberLinks(0); return@LaunchedEffect }
|
||||
while (isActive) {
|
||||
viewModel.refreshMemberLinks(nodeId)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) {
|
||||
TopAppBar(
|
||||
title = { Text("Members (${members.size})") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
|
||||
}
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
items(members.size) { idx ->
|
||||
val m = members[idx]
|
||||
MemberListItem(
|
||||
member = m,
|
||||
isSelected = selectedMember?.nodeId == m.nodeId,
|
||||
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) },
|
||||
onEdit = {
|
||||
editMember = m
|
||||
showEditDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = selectedMember != null,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it })
|
||||
) {
|
||||
selectedMember?.let { detail ->
|
||||
MemberDetailCard(
|
||||
detail = detail,
|
||||
memberLinks = memberLinks,
|
||||
onDismiss = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditDialog && editMember != null) {
|
||||
MemberEditDialog(
|
||||
member = editMember!!,
|
||||
onDismiss = { showEditDialog = false },
|
||||
onSave = { nick, admin, supernode, storage ->
|
||||
if (nick != editMember!!.name) viewModel.setMemberNick(channel.id, editMember!!.nodeId, nick)
|
||||
if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || storage != editMember!!.isStorage)
|
||||
viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, storage)
|
||||
showEditDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberListItem(
|
||||
member: ChatMember,
|
||||
isSelected: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
) {
|
||||
val onlineColor = if (member.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
|
||||
val bgColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
color = bgColor,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(onlineColor)
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = member.name,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified
|
||||
)
|
||||
val roles = buildList {
|
||||
if (member.isAdmin) add("Admin")
|
||||
if (member.isSuper) add("Super")
|
||||
if (member.isStorage) add("Storage")
|
||||
}
|
||||
if (roles.isNotEmpty() || member.rtt > 0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (roles.isNotEmpty())
|
||||
Text(
|
||||
text = roles.joinToString(", "),
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (member.rtt > 0) {
|
||||
if (roles.isNotEmpty()) Text(" ", fontSize = 11.sp)
|
||||
Text(
|
||||
text = "${member.rtt / 10}ms",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onEdit) { Text("Edit") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberDetailCard(
|
||||
detail: MemberDetail,
|
||||
memberLinks: MemberLinks,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
fun linkDotColor(addr: MemberAddr): Color {
|
||||
val lk = memberLinks.links.find {
|
||||
it.family == addr.family && it.address == addr.address && it.port == addr.port
|
||||
} ?: return Color(0xFF9E9E9E)
|
||||
return if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF4CAF50) else Color(0xFFE53935)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val dotColor = if (detail.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dotColor)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "ID: 0x${detail.nodeId.toString(16).padStart(16, '0')}",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
|
||||
if (detail.lastSeen > 0) {
|
||||
val sec = System.currentTimeMillis() / 1000 - detail.lastSeen
|
||||
val ago = when {
|
||||
sec < 60 -> "${sec}s ago"
|
||||
sec < 3600 -> "${sec / 60}m ago"
|
||||
sec < 86400 -> "${sec / 3600}h ago"
|
||||
else -> "${sec / 86400}d ago"
|
||||
}
|
||||
Text("Last seen: $ago", fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
if (detail.created > 0) {
|
||||
val sec = System.currentTimeMillis() / 1000 - detail.created
|
||||
val ago = when {
|
||||
sec < 604800 -> "${sec / 86400}d ago"
|
||||
else -> "${sec / 2592000}mo ago"
|
||||
}
|
||||
Text("Created: $ago", fontSize = 12.sp, color = Color.Gray)
|
||||
}
|
||||
|
||||
if (detail.addresses.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
detail.addresses.forEach { a ->
|
||||
val dcol = linkDotColor(a)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dcol)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "${a.protocol} ${a.address}:${a.port}" +
|
||||
(if (a.rtt > 0) " (${a.rtt / 10}ms)" else ""),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
color = Color.DarkGray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberEditDialog(
|
||||
member: ChatMember,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (nick: String, admin: Boolean, supernode: Boolean, storage: Boolean) -> Unit
|
||||
) {
|
||||
var nick by remember { mutableStateOf(member.name) }
|
||||
var admin by remember { mutableStateOf(member.isAdmin) }
|
||||
var supernode by remember { mutableStateOf(member.isSuper) }
|
||||
var storage by remember { mutableStateOf(member.isStorage) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Edit ${member.name}") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = nick,
|
||||
onValueChange = { nick = it },
|
||||
label = { Text("Nickname") },
|
||||
enabled = !member.isSelf,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = admin, onCheckedChange = { admin = it })
|
||||
Text("Admin", modifier = Modifier.clickable { admin = !admin })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = supernode, onCheckedChange = { supernode = it })
|
||||
Text("Supernode", modifier = Modifier.clickable { supernode = !supernode })
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = storage, onCheckedChange = { storage = it })
|
||||
Text("Storage", modifier = Modifier.clickable { storage = !storage })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSave(nick, admin, supernode, storage) }) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,15 @@ class ChatViewModel : ViewModel() {
|
||||
private val _generatingKeys = MutableStateFlow(false)
|
||||
val generatingKeys: StateFlow<Boolean> = _generatingKeys
|
||||
|
||||
private val _members = MutableStateFlow<List<ChatMember>>(emptyList())
|
||||
val members: StateFlow<List<ChatMember>> = _members
|
||||
|
||||
private val _selectedMember = MutableStateFlow<MemberDetail?>(null)
|
||||
val selectedMember: StateFlow<MemberDetail?> = _selectedMember
|
||||
|
||||
private val _memberLinks = MutableStateFlow<MemberLinks>(MemberLinks())
|
||||
val memberLinks: StateFlow<MemberLinks> = _memberLinks
|
||||
|
||||
private val _isStuck = MutableStateFlow(false)
|
||||
val isStuck: StateFlow<Boolean> = _isStuck
|
||||
|
||||
@@ -143,6 +152,15 @@ class ChatViewModel : ViewModel() {
|
||||
refreshChannels()
|
||||
}
|
||||
}
|
||||
5 -> { /* MEMBERS_CHANGED: [ch_id_len:1][ch_id:var] */
|
||||
if (repo == null || data == null || data.size < 2) return
|
||||
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) refreshMembers(chId)
|
||||
}
|
||||
}
|
||||
8 -> if (repo != null) refreshChannels() /* CHANNEL_PEERS_ONLINE */
|
||||
14 -> { /* ATTACHMENT_DOWNLOADED: [ch_id_len:1][ch_id:var][msg_id:8] */
|
||||
if (repo == null || data == null || data.size < 2) return
|
||||
@@ -208,6 +226,40 @@ class ChatViewModel : ViewModel() {
|
||||
refreshMessages(channel.id)
|
||||
}
|
||||
|
||||
fun refreshMembers(chId: String) {
|
||||
val r = repo ?: return
|
||||
viewModelScope.launch {
|
||||
_members.value = withContext(Dispatchers.IO) { r.getMembers(chId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun showMemberDetail(channelId: String, nodeId: Long) {
|
||||
val r = repo ?: return
|
||||
viewModelScope.launch {
|
||||
_selectedMember.value = withContext(Dispatchers.IO) { r.getMemberDetail(channelId, nodeId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setMemberNick(channelId: String, nodeId: Long, nick: String) {
|
||||
val r = repo ?: return
|
||||
r.setMemberNick(channelId, nodeId, nick)
|
||||
refreshMembers(channelId)
|
||||
}
|
||||
|
||||
fun setMemberFlags(channelId: String, nodeId: Long, admin: Boolean, supernode: Boolean, storage: Boolean) {
|
||||
val r = repo ?: return
|
||||
r.setMemberFlags(channelId, nodeId, admin, supernode, storage)
|
||||
refreshMembers(channelId)
|
||||
}
|
||||
|
||||
fun refreshMemberLinks(nodeId: Long) {
|
||||
if (nodeId == 0L) { _memberLinks.value = MemberLinks(); return }
|
||||
val r = repo ?: return
|
||||
viewModelScope.launch {
|
||||
_memberLinks.value = withContext(Dispatchers.IO) { r.getMemberLinks(nodeId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
val r = repo ?: return
|
||||
val ch = _currentChannel.value ?: return
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "../../../src/chat/chat_setting.h"
|
||||
#include "../../../src/utun_instance.h"
|
||||
#include "../../../src/transport_layer/etcp.h"
|
||||
#include "../../../src/transport_layer/etcp_connections.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -278,16 +279,16 @@ char* utun_bridge_get_channels_json(void) {
|
||||
if (!chat_core_is_initialized() || !db) {
|
||||
bridge_log(BLEV_WARN, "get_channels: not initialized db=%p initialized=%d",
|
||||
(void*)db, chat_core_is_initialized());
|
||||
return strdup("[]");
|
||||
return u_strdup("[]");
|
||||
}
|
||||
size_t cap = 4096, pos = 1;
|
||||
char* json = u_malloc(cap);
|
||||
if (!json) return strdup("[]");
|
||||
if (!json) return u_strdup("[]");
|
||||
json[0] = '[';
|
||||
|
||||
sqlite3_stmt* st = NULL;
|
||||
int rc = sqlite3_prepare_v2(db, "SELECT channel_id, name, last_msg_at FROM channels ORDER BY last_msg_at DESC, name ASC", -1, &st, NULL);
|
||||
if (rc != SQLITE_OK) { u_free(json); return strdup("[]"); }
|
||||
if (rc != SQLITE_OK) { u_free(json); return u_strdup("[]"); }
|
||||
|
||||
int first = 1;
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
@@ -346,7 +347,7 @@ char* utun_bridge_get_channels_json(void) {
|
||||
|
||||
size_t needed = snprintf(NULL, 0, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}",
|
||||
sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online);
|
||||
while (pos + needed + 2 > cap) { cap *= 2; char* tmp = u_realloc(json, cap); if (!tmp) break; json = tmp; }
|
||||
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
|
||||
if (pos + needed + 2 <= cap)
|
||||
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":\"%s\",\"name\":\"%s\",\"lastMsgAt\":%lld,\"lastMsg\":\"%s\",\"peersOnline\":%d}",
|
||||
sep, ch_id, esc_name, (long long)last_msg_at, esc_msg, online);
|
||||
@@ -361,7 +362,7 @@ char* utun_bridge_get_channels_json(void) {
|
||||
|
||||
char* utun_bridge_get_messages_json(const char* channel_id, int limit) {
|
||||
sqlite3* db = chat_core_get_db();
|
||||
if (!chat_core_is_initialized() || !db || !channel_id || !channel_id[0]) return strdup("[]");
|
||||
if (!chat_core_is_initialized() || !db || !channel_id || !channel_id[0]) return u_strdup("[]");
|
||||
uint64_t my_id = g_cc.my_node_id;
|
||||
|
||||
char tbl_msg[80];
|
||||
@@ -371,11 +372,11 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit) {
|
||||
snprintf(sql, sizeof(sql), "SELECT id, node_id, data, timestamp, local_attrs FROM \"%s\" ORDER BY timestamp ASC LIMIT %d", tbl_msg, limit);
|
||||
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return strdup("[]");
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) return u_strdup("[]");
|
||||
|
||||
size_t cap = 4096, pos = 1;
|
||||
char* json = u_malloc(cap);
|
||||
if (!json) { sqlite3_finalize(st); return strdup("[]"); }
|
||||
if (!json) { sqlite3_finalize(st); return u_strdup("[]"); }
|
||||
json[0] = '[';
|
||||
|
||||
int first = 1;
|
||||
@@ -424,7 +425,7 @@ char* utun_bridge_get_messages_json(const char* channel_id, int limit) {
|
||||
sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct,
|
||||
esc_fp ? ",\"filePath\":\"" : "", esc_fp ? esc_fp : "", esc_fp ? "\"" : "",
|
||||
esc_la ? ",\"localAttrs\":\"" : "", esc_la ? esc_la : "", esc_la ? "\"" : "");
|
||||
while (pos + needed + 2 > cap) { cap *= 2; char* tmp = u_realloc(json, cap); if (!tmp) break; json = tmp; }
|
||||
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
|
||||
if (pos + needed + 2 <= cap)
|
||||
pos += (size_t)snprintf(json + pos, cap - pos, "%s{\"id\":%lld,\"author\":\"%s\",\"text\":\"%s\",\"ts\":%lld,\"isOutgoing\":%s,\"contentType\":\"%s\"%s%s%s%s%s%s}",
|
||||
sep, (long long)msg_id, author_name, esc_txt, (long long)ts, is_out ? "true" : "false", esc_ct,
|
||||
@@ -457,6 +458,313 @@ int utun_bridge_chat_setting_get_int(const char* name, int def) {
|
||||
return chat_setting_get_int(name, def);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
* Channel members (queried from shared DB)
|
||||
* ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
#include "../../../src/routing_layer/topo_node_sqlite.h"
|
||||
#include <openssl/evp.h>
|
||||
|
||||
char* utun_bridge_get_channel_members_json(const char* channel_id) {
|
||||
sqlite3* db = chat_core_get_db();
|
||||
if (!chat_core_is_initialized() || !db || !channel_id || !channel_id[0]) return u_strdup("[]");
|
||||
|
||||
char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl));
|
||||
|
||||
sqlite3_stmt* cnt_st = NULL;
|
||||
char sql[512];
|
||||
snprintf(sql, sizeof(sql), "SELECT COUNT(*) FROM \"%s\"", peers_tbl);
|
||||
/* if table doesn't exist, return empty */
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &cnt_st, NULL) != SQLITE_OK) return u_strdup("[]");
|
||||
int member_count = 0;
|
||||
if (sqlite3_step(cnt_st) == SQLITE_ROW) member_count = sqlite3_column_int(cnt_st, 0);
|
||||
sqlite3_finalize(cnt_st);
|
||||
if (member_count == 0) return u_strdup("[]");
|
||||
|
||||
snprintf(sql, sizeof(sql),
|
||||
"SELECT p.node_id, COALESCE(n.online,0), COALESCE(n.name,''),"
|
||||
" COALESCE(p.local_nick,''), COALESCE(p.adm_tags,''),"
|
||||
" COALESCE(p.node_type,0), COALESCE(p.node_RTT,0), COALESCE(p.storage,0)"
|
||||
" FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id"
|
||||
" ORDER BY n.online DESC, p.node_id ASC", peers_tbl);
|
||||
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
|
||||
bridge_log(BLEV_ERROR, "get_channel_members: main prepare failed ch=%s err=%s",
|
||||
channel_id, sqlite3_errmsg(db));
|
||||
return u_strdup("[]");
|
||||
}
|
||||
|
||||
size_t cap = 4096, pos = 1;
|
||||
char* json = u_malloc(cap);
|
||||
if (!json) { sqlite3_finalize(st); return u_strdup("[]"); }
|
||||
json[0] = '[';
|
||||
|
||||
uint64_t my_id = g_cc.my_node_id;
|
||||
int first = 1;
|
||||
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
uint64_t node_id = (uint64_t)sqlite3_column_int64(st, 0);
|
||||
int online = sqlite3_column_int(st, 1);
|
||||
const char* name = (const char*)sqlite3_column_text(st, 2);
|
||||
const char* local_nick = (const char*)sqlite3_column_text(st, 3);
|
||||
const char* adm_tags = (const char*)sqlite3_column_text(st, 4);
|
||||
int node_type = sqlite3_column_int(st, 5);
|
||||
int node_rtt = sqlite3_column_int(st, 6);
|
||||
int storage_flag = sqlite3_column_int(st, 7);
|
||||
|
||||
int is_admin = 0, is_super = 0;
|
||||
if (adm_tags) {
|
||||
if (strstr(adm_tags, "admin=yes")) is_admin = 1;
|
||||
if (strstr(adm_tags, "supernode=yes")) is_super = 1;
|
||||
}
|
||||
|
||||
char* esc_name = json_escape_alloc(local_nick && local_nick[0] ? local_nick :
|
||||
(name && name[0] ? name : ""));
|
||||
const char* sep = first ? "" : ",";
|
||||
first = 0;
|
||||
|
||||
size_t needed = snprintf(NULL, 0,
|
||||
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"st\":%d,"
|
||||
"\"tp\":%d,\"rt\":%d,\"is\":%d}",
|
||||
sep, esc_name, (unsigned long long)node_id, online,
|
||||
is_admin, is_super, storage_flag,
|
||||
node_type, node_rtt, (node_id == my_id) ? 1 : 0);
|
||||
|
||||
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
|
||||
if (pos + needed + 2 <= cap)
|
||||
pos += (size_t)snprintf(json + pos, cap - pos,
|
||||
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"st\":%d,"
|
||||
"\"tp\":%d,\"rt\":%d,\"is\":%d}",
|
||||
sep, esc_name, (unsigned long long)node_id, online,
|
||||
is_admin, is_super, storage_flag,
|
||||
node_type, node_rtt, (node_id == my_id) ? 1 : 0);
|
||||
u_free(esc_name);
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
|
||||
json[pos++] = ']'; json[pos] = '\0';
|
||||
return json;
|
||||
}
|
||||
|
||||
char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id) {
|
||||
sqlite3* db = chat_core_get_db();
|
||||
if (!chat_core_is_initialized() || !db || !channel_id || node_id == 0) return u_strdup("{}");
|
||||
|
||||
char buf[8192];
|
||||
size_t pos = 0;
|
||||
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, "{\"ni\":%llu", (unsigned long long)node_id);
|
||||
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT COALESCE(name,''), COALESCE(online,0),"
|
||||
" COALESCE(last_seen_at,0), COALESCE(created_at,0)"
|
||||
" FROM nodes WHERE node_id=?", -1, &st, NULL) == SQLITE_OK) {
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
|
||||
if (sqlite3_step(st) == SQLITE_ROW) {
|
||||
const char* name = (const char*)sqlite3_column_text(st, 0);
|
||||
int online = sqlite3_column_int(st, 1);
|
||||
int64_t last_seen = sqlite3_column_int64(st, 2);
|
||||
int64_t created = sqlite3_column_int64(st, 3);
|
||||
char* esc_name = json_escape_alloc(name && name[0] ? name : "");
|
||||
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos,
|
||||
",\"n\":\"%s\",\"on\":%d,\"ls\":%lld,\"cr\":%lld",
|
||||
esc_name, online, (long long)last_seen, (long long)created);
|
||||
u_free(esc_name);
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
|
||||
char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl));
|
||||
snprintf(buf + pos, sizeof(buf) - pos, ",\"adr\":["); pos += strlen(buf + pos);
|
||||
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT family, protocol, address, port, socket_id, COALESCE(rtt,0)"
|
||||
" FROM node_addresses WHERE node_id=? ORDER BY family, port",
|
||||
-1, &st, NULL) == SQLITE_OK) {
|
||||
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
|
||||
int af = 1;
|
||||
while (sqlite3_step(st) == SQLITE_ROW) {
|
||||
int family = sqlite3_column_int(st, 0);
|
||||
int proto = sqlite3_column_int(st, 1);
|
||||
const uint8_t* addr = (const uint8_t*)sqlite3_column_blob(st, 2);
|
||||
int addr_len = sqlite3_column_bytes(st, 2);
|
||||
int port = sqlite3_column_int(st, 3);
|
||||
int sid = sqlite3_column_int(st, 4);
|
||||
int rtt = sqlite3_column_int(st, 5);
|
||||
|
||||
char addr_str[64] = "";
|
||||
if (addr && addr_len >= (family == 4 ? 4 : 16)) {
|
||||
if (family == 4)
|
||||
snprintf(addr_str, sizeof(addr_str), "%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3]);
|
||||
else if (family == 6) {
|
||||
const uint16_t* a = (const uint16_t*)addr;
|
||||
snprintf(addr_str, sizeof(addr_str), "%04x:%04x:%04x:%04x:%04x:%04x:%04x:%04x",
|
||||
ntohs(a[0]), ntohs(a[1]), ntohs(a[2]), ntohs(a[3]),
|
||||
ntohs(a[4]), ntohs(a[5]), ntohs(a[6]), ntohs(a[7]));
|
||||
}
|
||||
}
|
||||
const char* pn;
|
||||
switch (proto) { case 1: pn = "UDP"; break; case 2: pn = "TCP"; break; default: pn = "UDP/TCP"; break; }
|
||||
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos,
|
||||
"%s{\"fm\":%d,\"pt\":\"%s\",\"ad\":\"%s\",\"po\":%d,\"si\":%d,\"rt\":%d}",
|
||||
af ? "" : ",", family, pn, addr_str[0] ? addr_str : "?", port, sid, rtt);
|
||||
af = 0;
|
||||
}
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
|
||||
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, "]}");
|
||||
return u_strdup(buf);
|
||||
}
|
||||
|
||||
void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick) {
|
||||
sqlite3* db = chat_core_get_db();
|
||||
if (!chat_core_is_initialized() || !db || !channel_id || node_id == 0) return;
|
||||
|
||||
char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl));
|
||||
char sql[256];
|
||||
snprintf(sql, sizeof(sql), "UPDATE \"%s\" SET local_nick=? WHERE node_id=?", peers_tbl);
|
||||
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) {
|
||||
if (nick && nick[0])
|
||||
sqlite3_bind_text(st, 1, nick, -1, SQLITE_STATIC);
|
||||
else
|
||||
sqlite3_bind_null(st, 1);
|
||||
sqlite3_bind_int64(st, 2, (sqlite3_int64)node_id);
|
||||
sqlite3_step(st);
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
bridge_log(BLEV_DEBUG, "set_member_nick ch=%s nid=%llu nick=%s",
|
||||
channel_id, (unsigned long long)node_id, nick ? nick : "(null)");
|
||||
}
|
||||
|
||||
void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id,
|
||||
int admin, int supernode, int storage_flag) {
|
||||
sqlite3* db = chat_core_get_db();
|
||||
if (!chat_core_is_initialized() || !db || !channel_id || node_id == 0) return;
|
||||
|
||||
char peers_tbl[80]; peers_table_name(channel_id, peers_tbl, sizeof(peers_tbl));
|
||||
|
||||
/* current version */
|
||||
int cur_ver = 0;
|
||||
sqlite3_stmt* vst = NULL;
|
||||
char sql[256]; snprintf(sql, sizeof(sql), "SELECT adm_tags FROM \"%s\" WHERE node_id=?", peers_tbl);
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &vst, NULL) == SQLITE_OK) {
|
||||
sqlite3_bind_int64(vst, 1, (sqlite3_int64)node_id);
|
||||
if (sqlite3_step(vst) == SQLITE_ROW) {
|
||||
const char* tags = (const char*)sqlite3_column_text(vst, 0);
|
||||
if (tags) { const char* vp = strstr(tags, "ver="); if (vp) cur_ver = atoi(vp + 4); }
|
||||
}
|
||||
sqlite3_finalize(vst);
|
||||
}
|
||||
|
||||
int new_ver = cur_ver + 1;
|
||||
char adm_tags[128] = "";
|
||||
int off = 0;
|
||||
off += snprintf(adm_tags + off, sizeof(adm_tags) - (size_t)off, "ver=%d", new_ver);
|
||||
if (admin) off += snprintf(adm_tags + off, sizeof(adm_tags) - (size_t)off, ",admin=yes");
|
||||
if (supernode) off += snprintf(adm_tags + off, sizeof(adm_tags) - (size_t)off, ",supernode=yes");
|
||||
if (storage_flag) off += snprintf(adm_tags + off, sizeof(adm_tags) - (size_t)off, ",storage=yes");
|
||||
|
||||
/* get channel ed25519 private key for signing */
|
||||
uint8_t ch_ed_priv[32] = {0};
|
||||
int have_key = 0;
|
||||
sqlite3_stmt* ks = NULL;
|
||||
if (sqlite3_prepare_v2(db,
|
||||
"SELECT ed25519_privkey FROM channels WHERE channel_id=?",
|
||||
-1, &ks, NULL) == SQLITE_OK) {
|
||||
sqlite3_bind_text(ks, 1, channel_id, -1, SQLITE_STATIC);
|
||||
if (sqlite3_step(ks) == SQLITE_ROW) {
|
||||
const uint8_t* kp = (const uint8_t*)sqlite3_column_blob(ks, 0);
|
||||
if (kp && sqlite3_column_bytes(ks, 0) >= 32) {
|
||||
memcpy(ch_ed_priv, kp, 32); have_key = 1;
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(ks);
|
||||
}
|
||||
|
||||
/* sign with Ed25519 */
|
||||
uint8_t sig[64] = {0};
|
||||
if (have_key) {
|
||||
uint8_t msg[384]; size_t mlen = 0;
|
||||
size_t tl = strlen(adm_tags);
|
||||
if (tl > 191) tl = 191;
|
||||
memcpy(msg + mlen, adm_tags, tl); mlen += tl;
|
||||
memcpy(msg + mlen, &node_id, 8); mlen += 8;
|
||||
EVP_PKEY* pkey = EVP_PKEY_new_raw_private_key(EVP_PKEY_ED25519, NULL, ch_ed_priv, 32);
|
||||
if (pkey) {
|
||||
EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
|
||||
if (mdctx) {
|
||||
size_t siglen = 64;
|
||||
if (EVP_DigestSignInit(mdctx, NULL, NULL, NULL, pkey) == 1)
|
||||
EVP_DigestSign(mdctx, sig, &siglen, msg, mlen);
|
||||
EVP_MD_CTX_free(mdctx);
|
||||
}
|
||||
EVP_PKEY_free(pkey);
|
||||
}
|
||||
}
|
||||
|
||||
int nt = supernode ? 4 : 1;
|
||||
snprintf(sql, sizeof(sql),
|
||||
"UPDATE \"%s\" SET adm_tags=?, adm_tags_sig=?, node_type=?, storage=? WHERE node_id=?",
|
||||
peers_tbl);
|
||||
sqlite3_stmt* st = NULL;
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) == SQLITE_OK) {
|
||||
sqlite3_bind_text(st, 1, adm_tags, -1, SQLITE_STATIC);
|
||||
if (have_key) sqlite3_bind_blob(st, 2, sig, 64, SQLITE_STATIC);
|
||||
else sqlite3_bind_null(st, 2);
|
||||
sqlite3_bind_int(st, 3, nt);
|
||||
sqlite3_bind_int(st, 4, storage_flag ? 1 : 0);
|
||||
sqlite3_bind_int64(st, 5, (sqlite3_int64)node_id);
|
||||
sqlite3_step(st);
|
||||
sqlite3_finalize(st);
|
||||
}
|
||||
bridge_log(BLEV_INFO, "set_member_flags ch=%s nid=%llu admin=%d super=%d storage=%d tags=%s have_key=%d",
|
||||
channel_id, (unsigned long long)node_id, admin, supernode, storage_flag, adm_tags, have_key);
|
||||
}
|
||||
|
||||
char* utun_bridge_get_member_links_json(uint64_t node_id) {
|
||||
struct UTUN_INSTANCE* inst = chat_core_get_inst();
|
||||
if (!inst || node_id == 0) return u_strdup("{}");
|
||||
|
||||
struct ETCP_CONN* conn = instance_find_conn(inst, node_id);
|
||||
if (!conn) return u_strdup("{}");
|
||||
|
||||
char buf[4096];
|
||||
int pos = snprintf(buf, sizeof(buf),
|
||||
"{\"cn\":true,\"in\":%s,\"lu\":%d,\"lk\":[",
|
||||
conn->initialized ? "true" : "false", conn->links_up);
|
||||
|
||||
int first = 1;
|
||||
for (struct ETCP_LINK* lk = conn->links; lk; lk = lk->next) {
|
||||
if (pos >= (int)sizeof(buf) - 100) break;
|
||||
const struct sockaddr_storage* sa = &lk->remote_addr;
|
||||
char ip[INET6_ADDRSTRLEN] = "";
|
||||
int fm = 0;
|
||||
uint16_t po = 0;
|
||||
|
||||
if (sa->ss_family == AF_INET) {
|
||||
struct sockaddr_in* sin = (struct sockaddr_in*)sa;
|
||||
inet_ntop(AF_INET, &sin->sin_addr, ip, sizeof(ip));
|
||||
fm = 4; po = ntohs(sin->sin_port);
|
||||
} else if (sa->ss_family == AF_INET6) {
|
||||
struct sockaddr_in6* sin6 = (struct sockaddr_in6*)sa;
|
||||
inet_ntop(AF_INET6, &sin6->sin6_addr, ip, sizeof(ip));
|
||||
fm = 6; po = ntohs(sin6->sin6_port);
|
||||
} else continue;
|
||||
|
||||
if (!ip[0]) continue;
|
||||
const char* sep = first ? "" : ","; first = 0;
|
||||
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos,
|
||||
"%s{\"fm\":%d,\"ad\":\"%s\",\"po\":%d,\"st\":%d,\"ls\":%d}",
|
||||
sep, fm, ip, (int)po, lk->link_state, lk->link_status);
|
||||
}
|
||||
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "]}");
|
||||
return u_strdup(buf);
|
||||
}
|
||||
|
||||
void utun_bridge_restart(const char* config_text) {
|
||||
bridge_log(BLEV_INFO, "bridge restart: posting to instance");
|
||||
instance_lite_restart(config_text);
|
||||
@@ -1139,4 +1447,56 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeVoiceDecodeClose(
|
||||
utun_bridge_voice_decode_close((void*)(intptr_t)handle);
|
||||
}
|
||||
|
||||
/* ── Channel members JNI ── */
|
||||
|
||||
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetChannelMembers(
|
||||
JNIEnv* env, jobject thiz, jstring channelId) {
|
||||
(void)thiz;
|
||||
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
|
||||
char* json = utun_bridge_get_channel_members_json(ch);
|
||||
jstring result = (*env)->NewStringUTF(env, json);
|
||||
u_free(json);
|
||||
(*env)->ReleaseStringUTFChars(env, channelId, ch);
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMemberDetail(
|
||||
JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId) {
|
||||
(void)thiz;
|
||||
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
|
||||
char* json = utun_bridge_get_member_detail_json(ch, (uint64_t)nodeId);
|
||||
jstring result = (*env)->NewStringUTF(env, json);
|
||||
u_free(json);
|
||||
(*env)->ReleaseStringUTFChars(env, channelId, ch);
|
||||
return result;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetMemberNick(
|
||||
JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId, jstring nick) {
|
||||
(void)thiz;
|
||||
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
|
||||
const char* nk = nick ? (*env)->GetStringUTFChars(env, nick, NULL) : NULL;
|
||||
utun_bridge_set_member_nick(ch, (uint64_t)nodeId, nk);
|
||||
if (nk) (*env)->ReleaseStringUTFChars(env, nick, nk);
|
||||
(*env)->ReleaseStringUTFChars(env, channelId, ch);
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetMemberFlags(
|
||||
JNIEnv* env, jobject thiz, jstring channelId, jlong nodeId,
|
||||
jboolean admin, jboolean supernode, jboolean storage) {
|
||||
(void)thiz;
|
||||
const char* ch = (*env)->GetStringUTFChars(env, channelId, NULL);
|
||||
utun_bridge_set_member_flags(ch, (uint64_t)nodeId, admin, supernode, storage);
|
||||
(*env)->ReleaseStringUTFChars(env, channelId, ch);
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL Java_com_utun_chat_data_NativeLib_nativeGetMemberLinks(
|
||||
JNIEnv* env, jobject thiz, jlong nodeId) {
|
||||
(void)thiz;
|
||||
char* json = utun_bridge_get_member_links_json((uint64_t)nodeId);
|
||||
jstring result = (*env)->NewStringUTF(env, json);
|
||||
u_free(json);
|
||||
return result;
|
||||
}
|
||||
|
||||
#endif /* __ANDROID__ */
|
||||
@@ -111,6 +111,16 @@ void utun_bridge_set_debug_level(const char* category, const char* level);
|
||||
void utun_bridge_chat_setting_set(const char* name, const char* value);
|
||||
int utun_bridge_chat_setting_get_int(const char* name, int def);
|
||||
|
||||
/* ── Channel members ── */
|
||||
|
||||
char* utun_bridge_get_channel_members_json(const char* channel_id);
|
||||
char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_id);
|
||||
void utun_bridge_set_member_nick(const char* channel_id, uint64_t node_id, const char* nick);
|
||||
void utun_bridge_set_member_flags(const char* channel_id, uint64_t node_id,
|
||||
int admin, int supernode, int storage);
|
||||
|
||||
char* utun_bridge_get_member_links_json(uint64_t node_id);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in new issue
Block a user