diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt index 25e7612e..68885c07 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt @@ -100,9 +100,10 @@ data class MemberDetail(val nodeId: Long, val name: String = "", data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "", val port: Int = 0, val socketId: Int = 0, val addrType: Int = 0, val rtt: Int = 0) { - val roleLabel: String get() = when (addrType) { - 0 -> AppLanguage.text(R.string.address_local); 1 -> AppLanguage.text(R.string.address_public) - 2 -> "nat_eim"; 3 -> "nat_strict"; 4 -> "nat"; else -> "?$addrType" + val roleLabelRes: Int get() = when (addrType) { + 0 -> R.string.address_local; 1 -> R.string.address_public + 2 -> R.string.address_nat_eim; 3 -> R.string.address_nat_dependent; 4 -> R.string.address_nat + 5 -> R.string.address_contact; else -> R.string.address_unknown } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index eab1fadd..ab8d6e4b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -44,7 +44,6 @@ 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.data.LocalSocketState @@ -281,9 +280,7 @@ private fun MemberListItem( fun formatAddr(addr: String): String { if (addr.isEmpty() || addr == "?") return addr val trimmed = addr.trim('[', ']') - if (trimmed.all { it == '0' || it == ':' }) return "[::]" - if (trimmed == "::") return "[::]" - return trimmed + return if (':' in trimmed) "[$trimmed]" else trimmed } @Composable @@ -297,6 +294,14 @@ private fun MemberDetailCard( onConnect: () -> Unit, onEdit: () -> Unit ) { + LaunchedEffect(detail.channelId, detail.nodeId, detail.addresses, isSelf) { + Log.d("utun-gui", "Member detail: channel=${detail.channelId} node=${detail.nodeId} self=$isSelf " + + "addresses=${detail.addresses.size} endpoints=" + detail.addresses.joinToString { a -> + "sock${a.socketId} ${a.protocol} ${formatAddr(a.address)}:${a.port} type=${a.addrType}" + }) + } + // У каждого участника своя прокрутка: прежний offset не должен прятать заголовок нового. + val scrollState = key(detail.channelId, detail.nodeId) { rememberScrollState() } Card( modifier = Modifier .fillMaxWidth() @@ -309,7 +314,7 @@ private fun MemberDetailCard( ), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { - Column(modifier = Modifier.padding(12.dp).verticalScroll(rememberScrollState())) { + Column(modifier = Modifier.verticalScroll(scrollState).padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { UserAvatar(detail.channelId, detail.nodeId, detail.name) Spacer(Modifier.width(8.dp)) @@ -364,9 +369,19 @@ private fun MemberDetailCard( Text(stringResource(R.string.member_inviter, inviter), fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface) } - if (!isSelf) TextButton(onClick = onConnect, enabled = !detail.directUp) { Text(stringResource(R.string.connect)) } - TextButton(onClick = onEdit, enabled = canEdit) { Text(stringResource(R.string.edit)) } - TextButton(onClick = onFiles) { Text(stringResource(R.string.files_member)) } + } + + // Кнопки не отнимают ширину у имени и инвайтера, особенно на узком экране. + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (!isSelf) TextButton(onClick = onConnect, enabled = !detail.directUp, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.connect)) + } + TextButton(onClick = onEdit, enabled = canEdit, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.edit)) + } + TextButton(onClick = onFiles, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.files_member)) + } } Spacer(Modifier.height(4.dp)) @@ -406,7 +421,7 @@ private fun MemberDetailCard( Box(Modifier.size(8.dp).clip(CircleShape).background(dot)) Spacer(Modifier.width(6.dp)) Text( - text = "[${sk.linksUp}/${sk.linksDown}] ${sk.protocol} ${sk.ifName} ${sk.bindIp}:${sk.port}", + text = "[${sk.linksUp}/${sk.linksDown}] ${sk.protocol} ${sk.ifName} ${formatAddr(sk.bindIp)}:${sk.port}", fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurface @@ -420,12 +435,14 @@ private fun MemberDetailCard( Text(stringResource(R.string.addresses), fontSize = 13.sp, fontWeight = FontWeight.Medium) detail.addresses.forEachIndexed { idx, a -> Text( - text = "[sock${a.socketId}] ${a.protocol} ${a.address}:${a.port} ${a.roleLabel}", + text = "${formatAddr(a.address)}:${a.port}", fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 15.sp, color = MaterialTheme.colorScheme.onSurface ) + Text("[sock${a.socketId}] ${a.protocol} · ${stringResource(a.roleLabelRes, a.addrType)}", + fontSize = 12.sp, lineHeight = 15.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) if (idx < detail.addresses.lastIndex) Spacer(Modifier.height(8.dp)) } } diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index e5d56f61..63b76696 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -340,6 +340,11 @@ "Подключение" "локальный" "публичный" + "NAT EIM" + "зависимый NAT" + "NAT" + "требуется предварительный контакт" + "неизвестный (%1$d)" "Не удалось сохранить файл" "Передать приватный ключ канала участнику «%1$s»?\n\nКлюч передаётся зашифрованным и подписанным. Получатель станет совладельцем и сможет управлять участниками." "Буфер: %1$s мс · Запас: %2$s мс\nЗа 10 с: минимум %3$s · максимум %4$s мс\nСкорость: %5$s× · RTT: %6$s мс\nПотери буфера: %7$s · Недостачи: %8$s" diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 1324969d..e09b866d 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -341,6 +341,11 @@ "CONNECTING" "local" "public" + "NAT EIM" + "dependent NAT" + "NAT" + "contact required" + "unknown (%1$d)" "Could not save file" "Send the channel private key to “%1$s”?\n\nThe key is transferred encrypted and signed. The recipient will become a co-owner and will be able to manage members." "Buffer: %1$s ms · Reserve: %2$s ms\nOver 10 s: min %3$s · max %4$s ms\nSpeed: %5$s× · RTT: %6$s ms\nBuffer drops: %7$s · Underruns: %8$s" diff --git a/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/MemberAddressFormatTest.kt b/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/MemberAddressFormatTest.kt new file mode 100644 index 00000000..dad0aef0 --- /dev/null +++ b/tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/MemberAddressFormatTest.kt @@ -0,0 +1,22 @@ +package com.utun.chat.ui.screens + +import com.utun.chat.R +import com.utun.chat.data.MemberAddr +import org.junit.Assert.assertEquals +import org.junit.Test + +class MemberAddressFormatTest { + @Test fun ipv6EndpointsKeepPortSeparateFromAddress() { + assertEquals("[2001:db8::1]:443", "${formatAddr("2001:db8::1")}:443") + assertEquals("[fe80::1%wlan0]:9000", "${formatAddr("fe80::1%wlan0")}:9000") + assertEquals("[::]:9000", "${formatAddr("[::]")}:9000") + assertEquals("192.168.1.10:9000", "${formatAddr("192.168.1.10")}:9000") + } + + @Test fun contactRequiredIsDistinctFromPublicAndNat() { + assertEquals(R.string.address_public, MemberAddr(addrType = 1).roleLabelRes) + assertEquals(R.string.address_nat_eim, MemberAddr(addrType = 2).roleLabelRes) + assertEquals(R.string.address_contact, MemberAddr(addrType = 5).roleLabelRes) + assertEquals(R.string.address_unknown, MemberAddr(addrType = 6).roleLabelRes) + } +}