From 47082c4e2e988dca8b580a6b4c733c3493170d37 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 4 Oct 2026 16:02:57 +0200 Subject: [PATCH] Preserve Android radio presence across member refreshes --- .../chat/ui/components/PortableRadioIcon.kt | 36 ++++++++++++ .../com/utun/chat/ui/screens/ChatScreen.kt | 25 +------- .../com/utun/chat/viewmodel/ChatViewModel.kt | 58 +++++++++++++++++-- 3 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PortableRadioIcon.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PortableRadioIcon.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PortableRadioIcon.kt new file mode 100644 index 00000000..3af671c0 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PortableRadioIcon.kt @@ -0,0 +1,36 @@ +package com.utun.chat.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +/* Общая портативная рация для кнопки и списка участников; размер задаёт вызывающий. */ +@Composable +fun PortableRadioIcon(label: String, modifier: Modifier = Modifier.size(24.dp)) { + val color = LocalContentColor.current + Canvas(modifier.semantics { contentDescription = label }) { + val unit = size.minDimension / 24f + val stroke = 1.7f * unit + fun line(x1: Float, y1: Float, x2: Float, y2: Float) { + drawLine(color, Offset(x1 * unit, y1 * unit), Offset(x2 * unit, y2 * unit), + strokeWidth = stroke, cap = StrokeCap.Round) + } + line(8.5f, 1f, 8.5f, 9f) + line(14.5f, 7f, 14.5f, 9f) + drawRoundRect(color, Offset(7f * unit, 9f * unit), Size(10f * unit, 13f * unit), + CornerRadius(2f * unit), style = Stroke(stroke)) + drawRoundRect(color, Offset(9.5f * unit, 11.5f * unit), Size(5f * unit, 3f * unit), + CornerRadius(0.5f * unit), style = Stroke(stroke)) + for (y in listOf(16f, 18f, 20f)) line(9.5f, y, 14.5f, y) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 52bde110..2614fce1 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -27,9 +27,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity @@ -52,6 +49,7 @@ import com.utun.chat.data.RadioAudioEngine import com.utun.chat.data.RadioOverlayService import com.utun.chat.ui.components.ConversationMessages import com.utun.chat.ui.components.ConversationComposer +import com.utun.chat.ui.components.PortableRadioIcon import com.utun.chat.data.ConversationTarget import com.utun.chat.viewmodel.ChatViewModel import kotlinx.coroutines.Dispatchers @@ -407,24 +405,3 @@ private fun RadioFloatingIcon(label: String) { } } } - -/* Портативная рация: форма и цвет значка не зависят от включения. */ -@Composable -private fun PortableRadioIcon(label: String) { - val color = LocalContentColor.current - Canvas(Modifier.size(24.dp).semantics { contentDescription = label }) { - val unit = size.width / 24f - val stroke = 1.7f * unit - fun line(x1: Float, y1: Float, x2: Float, y2: Float) { - drawLine(color, Offset(x1 * unit, y1 * unit), Offset(x2 * unit, y2 * unit), - strokeWidth = stroke, cap = StrokeCap.Round) - } - line(8.5f, 1f, 8.5f, 9f) - line(14.5f, 7f, 14.5f, 9f) - drawRoundRect(color, Offset(7f * unit, 9f * unit), Size(10f * unit, 13f * unit), - CornerRadius(2f * unit), style = Stroke(stroke)) - drawRoundRect(color, Offset(9.5f * unit, 11.5f * unit), Size(5f * unit, 3f * unit), - CornerRadius(0.5f * unit), style = Stroke(stroke)) - for (y in listOf(16f, 18f, 20f)) line(9.5f, y, 14.5f, y) - } -} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index a6903be0..495b215a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -126,6 +126,8 @@ class ChatViewModel : ViewModel() { private val _members = MutableStateFlow>(emptyList()) val members: StateFlow> = _members + /* Live-подписки по каналам: JSON-refresh не должен стирать более свежие BGP-события. Только UI-поток. */ + private val memberRadios = mutableMapOf>() private val _channelOwner = MutableStateFlow(false) val channelOwner: StateFlow = _channelOwner @@ -351,6 +353,8 @@ class ChatViewModel : ViewModel() { 4 -> { if (repo != null) { LogManager.addLog("INFO", "VM", "CHANNEL_UPDATED — refreshChannels"); refreshChannels() } else { LogManager.addLog("WARN", "VM", "CHANNEL_UPDATED skipped — repo is NULL") } } 12 -> LogManager.addLog("INFO", "VM", "service started") 13 -> { + memberRadios.clear() + _members.value = _members.value.map { it.copy(radioActive = false) } _attachmentPreparations.value = emptyMap() _pmInvite.value?.let { state -> if (state.pending) _pmInvite.value = state.copy(pending = false, errorRes = R.string.invite_core_unavailable) @@ -449,16 +453,52 @@ class ChatViewModel : ViewModel() { if (cur != null) refreshMembers(cur.id) } 19 -> { if (repo != null) refreshLocalSockets() } /* LOCAL_SOCKETS */ + 23 -> { /* MEMBER_LIST: live-снимок дополняет расширенный JSON-список. */ + if (data == null || data.size < 3) { + LogManager.addLog("ERROR", "VM", "MEMBER_LIST: missing header"); return + } + val cl = data[0].toInt() and 0xFF + if (cl == 0 || data.size < 1 + cl + 2) { + LogManager.addLog("ERROR", "VM", "MEMBER_LIST: invalid channel length=$cl bytes=${data.size}"); return + } + val chId = String(data, 1, cl) + val count = (data[1 + cl].toInt() and 0xFF) or ((data[2 + cl].toInt() and 0xFF) shl 8) + val offset = cl + 3 + if (data.size != offset + count * 79) { + LogManager.addLog("ERROR", "VM", "MEMBER_LIST: invalid size ch=$chId count=$count bytes=${data.size}"); return + } + val radios = (0 until count).associate { i -> + val w = requireNotNull(parseMemberWire(data, offset + i * 79)) + w.nodeId to ((w.flags and 0x10) != 0) + } + memberRadios[chId] = radios + if (_currentChannel.value?.id == chId) + _members.value = _members.value.map { it.copy(radioActive = radios[it.nodeId] == true) } + LogManager.addLog("DEBUG", "VM", "MEMBER_LIST: ch=$chId members=$count radios=${radios.values.count { it }}") + } 24 -> { /* MEMBER_UPDATED: [ch_id_len:1][ch_id:var][count:1][member:79B] */ - if (data == null || data.size < 3 + 79) return + if (data == null || data.size < 3 + 79) { + LogManager.addLog("ERROR", "VM", "MEMBER_UPDATED: missing header/member"); return + } val cl = data[0].toInt() and 0xFF - val w = parseMemberWire(data, 1 + cl + 1) ?: return - val m = w.toChatMember() + if (cl == 0 || data.size != cl + 2 + 79 || data[cl + 1].toInt() != 1) { + LogManager.addLog("ERROR", "VM", "MEMBER_UPDATED: invalid payload channelLength=$cl bytes=${data.size}"); return + } + val chId = String(data, 1, cl) + val w = requireNotNull(parseMemberWire(data, cl + 2)) + val radio = (w.flags and 0x10) != 0 + val previousRadio = memberRadios[chId]?.get(w.nodeId) + memberRadios[chId] = memberRadios[chId].orEmpty() + (w.nodeId to radio) + if (previousRadio != radio) + LogManager.addLog("DEBUG", "VM", "Member radio: ch=$chId node=${w.nodeId} enabled=$radio self=${w.isSelf}") val cur = _currentChannel.value - if (cur != null && cur.id == String(data, 1, cl)) { + if (cur != null && cur.id == chId) { _members.value = _members.value.toMutableList().apply { val idx = indexOfFirst { it.nodeId == w.nodeId } - if (idx >= 0) set(idx, m) else add(m) + val m = if (idx >= 0) w.toChatMember(this[idx]) else w.toChatMember() + if (m.isDeleted && !_channelOwner.value) { + if (idx >= 0) removeAt(idx) + } else if (idx >= 0) set(idx, m) else add(m) } } val sel = _selectedMember.value @@ -470,6 +510,8 @@ class ChatViewModel : ViewModel() { val cl = data[0].toInt() and 0xFF val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN) val nodeId = buf.getLong() + val chId = String(data, 1, cl) + memberRadios[chId] = memberRadios[chId].orEmpty() - nodeId val cur = _currentChannel.value if (cur != null && cur.id == String(data, 1, cl)) _members.value = _members.value.filter { it.nodeId != nodeId } @@ -567,6 +609,7 @@ class ChatViewModel : ViewModel() { if (data.size < 1 + cl) return val chId = String(data, 1, cl) LogManager.addLog("INFO", "VM", "CHANNEL_DELETED ch=$chId") + memberRadios.remove(chId) if (radioChannelId == chId) turnRadioOff() if (_currentChannel.value?.id == chId) { _currentChannel.value = null @@ -1036,6 +1079,7 @@ class ChatViewModel : ViewModel() { val connecting = _connecting.value[chId] ?: emptySet() viewModelScope.launch { val (raw, owner) = withContext(Dispatchers.IO) { r.getMembers(chId) to r.isChannelOwner(chId) } + if (_currentChannel.value?.id != chId) return@launch _channelOwner.value = owner _members.value = raw.filter { owner || !it.isDeleted }.map { m -> val ns = statuses[m.nodeId] @@ -1046,8 +1090,9 @@ class ChatViewModel : ViewModel() { bgpPresence = ns.bgpPresence, bgpRtt = ns.bgpRtt, bgpNodes = ns.bgpNodes ) else m - base.copy(connecting = base.nodeId in connecting) + base.copy(connecting = base.nodeId in connecting, radioActive = memberRadios[chId]?.get(base.nodeId) == true) } + NativeLib.requestMemberList(chId) } } @@ -1395,6 +1440,7 @@ class ChatViewModel : ViewModel() { } fun clearState() { + memberRadios.clear() dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null _attachmentPreparations.value = emptyMap() repo?.close()