Browse Source

android: кнопка invite в чате + фикс тап-зоны #, вывод протокола линков участника

topo_upd
evgeny 2 months ago
parent
commit
b0753bc19d
  1. 42
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  2. 14
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  3. 5
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  4. 3
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  5. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  6. 4
      tools/chatgui-android/jni_bridge/android_jni_bridge.c

42
tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt

@ -1,19 +1,27 @@
package com.utun.chat package com.utun.chat
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.utun.chat.data.Channel
import com.utun.chat.data.InviteData import com.utun.chat.data.InviteData
import com.utun.chat.data.LogManager import com.utun.chat.data.LogManager
import com.utun.chat.data.NativeLib import com.utun.chat.data.NativeLib
import com.utun.chat.headless.HeadlessService import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.screens.* import com.utun.chat.ui.screens.*
import com.utun.chat.viewmodel.ChatViewModel import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan } enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, QrScan }
@ -32,6 +40,23 @@ class MainActivity : ComponentActivity() {
var showJoinDialog by remember { mutableStateOf(false) } var showJoinDialog by remember { mutableStateOf(false) }
var joinAutoLink by remember { mutableStateOf<String?>(null) } var joinAutoLink by remember { mutableStateOf<String?>(null) }
var inviteLinkText by remember { mutableStateOf<String?>(null) } var inviteLinkText by remember { mutableStateOf<String?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val showInvite: (Channel) -> Unit = { ch ->
val chId = ch.id.toLongOrNull()
if (chId == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
scope.launch { snackbarHostState.showSnackbar("Invalid channel id") }
} else {
val link = NativeLib.encodeInviteLink(chId)
if (link != null) inviteLinkText = link
else {
LogManager.addLog("ERROR", "INVITE", "encodeInviteLink returned null for ch=$chId")
scope.launch { snackbarHostState.showSnackbar("Could not generate invite link") }
}
}
}
BackHandler(enabled = screen != Screen.ChannelList) { BackHandler(enabled = screen != Screen.ChannelList) {
when (screen) { when (screen) {
@ -48,6 +73,7 @@ class MainActivity : ComponentActivity() {
onDispose { NativeLib.subscribeNativeLogs(false) } onDispose { NativeLib.subscribeNativeLogs(false) }
} }
Box(modifier = Modifier.fillMaxSize()) {
when (screen) { when (screen) {
Screen.ChannelList -> { Screen.ChannelList -> {
val channels by vm.channels.collectAsState() val channels by vm.channels.collectAsState()
@ -59,16 +85,7 @@ class MainActivity : ComponentActivity() {
onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat },
onCreateChannel = { vm.createChannel("general", "channel_1") }, onCreateChannel = { vm.createChannel("general", "channel_1") },
onJoinChannel = { showJoinDialog = true; joinAutoLink = null }, onJoinChannel = { showJoinDialog = true; joinAutoLink = null },
onInviteClick = { ch -> onInviteClick = showInvite,
val chId = ch.id.toLongOrNull()
if (chId == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
} else {
val link = NativeLib.encodeInviteLink(chId)
if (link != null) inviteLinkText = link
else LogManager.addLog("ERROR", "INVITE", "encodeInviteLink returned null for ch=$chId")
}
},
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
onLogs = { screen = Screen.Logs }, onLogs = { screen = Screen.Logs },
onRefresh = { vm.requestRefresh() } onRefresh = { vm.requestRefresh() }
@ -85,6 +102,7 @@ class MainActivity : ComponentActivity() {
viewModel = vm, viewModel = vm,
onSendMessage = { vm.sendMessage(it) }, onSendMessage = { vm.sendMessage(it) },
onMembersClick = { screen = Screen.MemberList }, onMembersClick = { screen = Screen.MemberList },
onInvite = { showInvite(channel) },
onBack = { screen = Screen.ChannelList } onBack = { screen = Screen.ChannelList }
) )
} }
@ -115,6 +133,8 @@ class MainActivity : ComponentActivity() {
) )
} }
} }
SnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
}
/* Show join dialog after QR scan returns */ /* Show join dialog after QR scan returns */
LaunchedEffect(qrResult) { LaunchedEffect(qrResult) {

14
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt

@ -87,7 +87,7 @@ data class MemberAddr(val family: Int = 0, val protocol: String = "", val addres
data class LinkStatus(val linkId: Int = 0, val localFamily: Int = 0, val localAddr: String = "", val localPort: Int = 0, data class LinkStatus(val linkId: Int = 0, val localFamily: Int = 0, val localAddr: String = "", val localPort: Int = 0,
val remoteFamily: Int = 0, val remoteAddr: String = "", val remotePort: Int = 0, val remoteFamily: Int = 0, val remoteAddr: String = "", val remotePort: Int = 0,
val linkState: Int = 0, val linkStatus: Int = 0, val linkState: Int = 0, val linkStatus: Int = 0,
val isServer: Boolean = false, val socketName: String = "") { val isServer: Boolean = false, val socketName: String = "", val protocol: String = "") {
val stateLabel: String get() = when (linkState) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" } val stateLabel: String get() = when (linkState) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" }
} }
@ -170,8 +170,8 @@ fun MemberWire.toChatMember(): ChatMember = ChatMember(
fun cleanSocketName(raw: String): String = fun cleanSocketName(raw: String): String =
raw.removePrefix("as_").replace(Regex("_v[46]_(tcp|udp)$"), " $1") raw.removePrefix("as_").replace(Regex("_v[46]_(tcp|udp)$"), " $1")
/* ── MEMBER_DETAIL (event 22) link format: 43 bytes per link ── */ /* ── MEMBER_DETAIL (event 22) link format: 63 bytes per link ── */
private const val MEMBER_DETAIL_LINK_SIZE = 43 private const val MEMBER_DETAIL_LINK_SIZE = 63
private fun addrFromBytes(family: Int, bytes: ByteArray): String { private fun addrFromBytes(family: Int, bytes: ByteArray): String {
if (family == 4 && bytes.size >= 4) if (family == 4 && bytes.size >= 4)
@ -203,12 +203,15 @@ fun parseMemberDetail(data: ByteArray): MemberLinks? {
val lp = lbuf.getShort().toInt() and 0xFFFF val lp = lbuf.getShort().toInt() and 0xFFFF
val rf = lbuf.get().toInt() and 0xFF; val ra = ByteArray(16); lbuf.get(ra) val rf = lbuf.get().toInt() and 0xFF; val ra = ByteArray(16); lbuf.get(ra)
val rp = lbuf.getShort().toInt() and 0xFFFF val rp = lbuf.getShort().toInt() and 0xFFFF
val snBytes = ByteArray(20); lbuf.get(snBytes)
val sn = String(snBytes).trimEnd('\u0000').trimEnd()
val effState = if (isTcp != 0 && tcpReady != 0) 3 else state val effState = if (isTcp != 0 && tcpReady != 0) 3 else state
val effStatus = if (isTcp != 0 && tcpReady != 0) 1 else status val effStatus = if (isTcp != 0 && tcpReady != 0) 1 else status
links.add(LinkStatus( links.add(LinkStatus(
localFamily = lf, localAddr = addrFromBytes(lf, la), localPort = lp, localFamily = lf, localAddr = addrFromBytes(lf, la), localPort = lp,
remoteFamily = rf, remoteAddr = addrFromBytes(rf, ra), remotePort = rp, remoteFamily = rf, remoteAddr = addrFromBytes(rf, ra), remotePort = rp,
linkState = effState, linkStatus = effStatus, isServer = isServer != 0 linkState = effState, linkStatus = effStatus, isServer = isServer != 0,
socketName = sn, protocol = if (isTcp != 0) "TCP" else "UDP"
)) ))
offset += MEMBER_DETAIL_LINK_SIZE offset += MEMBER_DETAIL_LINK_SIZE
} }
@ -421,7 +424,8 @@ class ChatRepository {
linkState = a.optInt("st", 0), linkState = a.optInt("st", 0),
linkStatus = a.optInt("ls", 0), linkStatus = a.optInt("ls", 0),
isServer = a.optInt("is", 0) != 0, isServer = a.optInt("is", 0) != 0,
socketName = a.optString("sn", "") socketName = a.optString("sn", ""),
protocol = a.optString("pt", "")
)) ))
} }
} }

5
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt

@ -88,9 +88,8 @@ fun ChannelListScreen(
Box(Modifier.width(4.dp).fillMaxHeight().background(barColor)) Box(Modifier.width(4.dp).fillMaxHeight().background(barColor))
Row(Modifier.fillMaxWidth().padding(12.dp)) { Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) { Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
Surface(shape = MaterialTheme.shapes.small, IconButton(onClick = { onInviteClick(ch) }, modifier = Modifier.size(44.dp)) {
modifier = Modifier.clickable { onInviteClick(ch) }) { Text("#", fontSize = 22.sp)
Text("#", fontSize = 22.sp, modifier = Modifier.padding(8.dp))
} }
if (badgeText.isNotEmpty()) { if (badgeText.isNotEmpty()) {
val badgeSize = 18.dp val badgeSize = 18.dp

3
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -37,6 +38,7 @@ fun ChatScreen(
viewModel: ChatViewModel, viewModel: ChatViewModel,
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onMembersClick: () -> Unit, onMembersClick: () -> Unit,
onInvite: () -> Unit,
onBack: () -> Unit onBack: () -> Unit
) { ) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
@ -112,6 +114,7 @@ fun ChatScreen(
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
}, },
actions = { actions = {
IconButton(onClick = onInvite) { Icon(Icons.Filled.Link, "Invite") }
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") } IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") }
} }
) )

2
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt

@ -305,6 +305,8 @@ private fun MemberDetailCard(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(dcol)) Box(Modifier.size(8.dp).clip(CircleShape).background(dcol))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text(lk.protocol, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = Color.Gray)
Spacer(Modifier.width(6.dp))
Text("${formatAddr(lk.localAddr)}:${lk.localPort}", Text("${formatAddr(lk.localAddr)}:${lk.localPort}",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Start, textAlign = TextAlign.Start,

4
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -1052,8 +1052,8 @@ char* utun_bridge_get_member_links_json(uint64_t node_id) {
const char* sep = first ? "" : ","; first = 0; const char* sep = first ? "" : ","; first = 0;
const char* sn = lk->conn ? lk->conn->name : ""; const char* sn = lk->conn ? lk->conn->name : "";
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos,
"%s{\"id\":%d,\"lf\":%d,\"la\":\"%s\",\"lp\":%d,\"rf\":%d,\"ra\":\"%s\",\"rp\":%d,\"st\":%d,\"ls\":%d,\"is\":%d,\"sn\":\"%s\"}", "%s{\"id\":%d,\"pt\":\"%s\",\"lf\":%d,\"la\":\"%s\",\"lp\":%d,\"rf\":%d,\"ra\":\"%s\",\"rp\":%d,\"st\":%d,\"ls\":%d,\"is\":%d,\"sn\":\"%s\"}",
sep, lk->local_link_id, lf, la, (int)lp, rf, ra, (int)rp, st, ls, lk->is_server, sn); sep, lk->local_link_id, lk->is_tcp ? "TCP" : "UDP", lf, la, (int)lp, rf, ra, (int)rp, st, ls, lk->is_server, sn);
} }
if (pos < (int)sizeof(buf)) if (pos < (int)sizeof(buf))
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "]}"); pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "]}");

Loading…
Cancel
Save