Browse Source

Fix Android member details layout and address formatting

stream-multiview
evgeny 1 week ago
parent
commit
d2e62874bb
  1. 7
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
  2. 37
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
  3. 5
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  4. 5
      tools/chatgui-android/app/src/main/res/values/strings.xml
  5. 22
      tools/chatgui-android/app/src/test/java/com/utun/chat/ui/screens/MemberAddressFormatTest.kt

7
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 = "", 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 port: Int = 0, val socketId: Int = 0, val addrType: Int = 0, val rtt: Int = 0) {
val roleLabel: String get() = when (addrType) { val roleLabelRes: Int get() = when (addrType) {
0 -> AppLanguage.text(R.string.address_local); 1 -> AppLanguage.text(R.string.address_public) 0 -> R.string.address_local; 1 -> R.string.address_public
2 -> "nat_eim"; 3 -> "nat_strict"; 4 -> "nat"; else -> "?$addrType" 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
} }
} }

37
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.Channel
import com.utun.chat.data.ChatMember import com.utun.chat.data.ChatMember
import com.utun.chat.data.MemberDetail import com.utun.chat.data.MemberDetail
import com.utun.chat.data.MemberAddr
import com.utun.chat.data.MemberLinks import com.utun.chat.data.MemberLinks
import com.utun.chat.data.LinkStatus import com.utun.chat.data.LinkStatus
import com.utun.chat.data.LocalSocketState import com.utun.chat.data.LocalSocketState
@ -281,9 +280,7 @@ private fun MemberListItem(
fun formatAddr(addr: String): String { fun formatAddr(addr: String): String {
if (addr.isEmpty() || addr == "?") return addr if (addr.isEmpty() || addr == "?") return addr
val trimmed = addr.trim('[', ']') val trimmed = addr.trim('[', ']')
if (trimmed.all { it == '0' || it == ':' }) return "[::]" return if (':' in trimmed) "[$trimmed]" else trimmed
if (trimmed == "::") return "[::]"
return trimmed
} }
@Composable @Composable
@ -297,6 +294,14 @@ private fun MemberDetailCard(
onConnect: () -> Unit, onConnect: () -> Unit,
onEdit: () -> 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( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -309,7 +314,7 @@ private fun MemberDetailCard(
), ),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) 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) { Row(verticalAlignment = Alignment.CenterVertically) {
UserAvatar(detail.channelId, detail.nodeId, detail.name) UserAvatar(detail.channelId, detail.nodeId, detail.name)
Spacer(Modifier.width(8.dp)) 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, Text(stringResource(R.string.member_inviter, inviter), fontSize = 13.sp, lineHeight = 16.sp,
color = MaterialTheme.colorScheme.onSurface) 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)) Spacer(Modifier.height(4.dp))
@ -406,7 +421,7 @@ private fun MemberDetailCard(
Box(Modifier.size(8.dp).clip(CircleShape).background(dot)) Box(Modifier.size(8.dp).clip(CircleShape).background(dot))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text( 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, fontFamily = FontFamily.Monospace,
fontSize = 13.sp, fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
@ -420,12 +435,14 @@ private fun MemberDetailCard(
Text(stringResource(R.string.addresses), fontSize = 13.sp, fontWeight = FontWeight.Medium) Text(stringResource(R.string.addresses), fontSize = 13.sp, fontWeight = FontWeight.Medium)
detail.addresses.forEachIndexed { idx, a -> detail.addresses.forEachIndexed { idx, a ->
Text( Text(
text = "[sock${a.socketId}] ${a.protocol} ${a.address}:${a.port} ${a.roleLabel}", text = "${formatAddr(a.address)}:${a.port}",
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 13.sp, fontSize = 13.sp,
lineHeight = 15.sp, lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurface 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)) if (idx < detail.addresses.lastIndex) Spacer(Modifier.height(8.dp))
} }
} }

5
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -340,6 +340,11 @@
<string name="connection_connecting">"Подключение"</string> <string name="connection_connecting">"Подключение"</string>
<string name="address_local">"локальный"</string> <string name="address_local">"локальный"</string>
<string name="address_public">"публичный"</string> <string name="address_public">"публичный"</string>
<string name="address_nat_eim">"NAT EIM"</string>
<string name="address_nat_dependent">"зависимый NAT"</string>
<string name="address_nat">"NAT"</string>
<string name="address_contact">"требуется предварительный контакт"</string>
<string name="address_unknown">"неизвестный (%1$d)"</string>
<string name="file_save_failed">"Не удалось сохранить файл"</string> <string name="file_save_failed">"Не удалось сохранить файл"</string>
<string name="transfer_key_confirm">"Передать приватный ключ канала участнику «%1$s»?\n\nКлюч передаётся зашифрованным и подписанным. Получатель станет совладельцем и сможет управлять участниками."</string> <string name="transfer_key_confirm">"Передать приватный ключ канала участнику «%1$s»?\n\nКлюч передаётся зашифрованным и подписанным. Получатель станет совладельцем и сможет управлять участниками."</string>
<string name="call_stats">"Буфер: %1$s мс · Запас: %2$s мс\nЗа 10 с: минимум %3$s · максимум %4$s мс\nСкорость: %5$s× · RTT: %6$s мс\nПотери буфера: %7$s · Недостачи: %8$s"</string> <string name="call_stats">"Буфер: %1$s мс · Запас: %2$s мс\nЗа 10 с: минимум %3$s · максимум %4$s мс\nСкорость: %5$s× · RTT: %6$s мс\nПотери буфера: %7$s · Недостачи: %8$s"</string>

5
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -341,6 +341,11 @@
<string name="connection_connecting">"CONNECTING"</string> <string name="connection_connecting">"CONNECTING"</string>
<string name="address_local">"local"</string> <string name="address_local">"local"</string>
<string name="address_public">"public"</string> <string name="address_public">"public"</string>
<string name="address_nat_eim">"NAT EIM"</string>
<string name="address_nat_dependent">"dependent NAT"</string>
<string name="address_nat">"NAT"</string>
<string name="address_contact">"contact required"</string>
<string name="address_unknown">"unknown (%1$d)"</string>
<string name="file_save_failed">"Could not save file"</string> <string name="file_save_failed">"Could not save file"</string>
<string name="transfer_key_confirm">"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."</string> <string name="transfer_key_confirm">"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."</string>
<string name="call_stats">"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"</string> <string name="call_stats">"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"</string>

22
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)
}
}
Loading…
Cancel
Save