Browse Source

Preserve Android radio presence across member refreshes

master
evgeny 1 week ago
parent
commit
47082c4e2e
  1. 36
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PortableRadioIcon.kt
  2. 25
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  3. 58
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

36
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)
}
}

25
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)
}
}

58
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -126,6 +126,8 @@ class ChatViewModel : ViewModel() {
private val _members = MutableStateFlow<List<ChatMember>>(emptyList())
val members: StateFlow<List<ChatMember>> = _members
/* Live-подписки по каналам: JSON-refresh не должен стирать более свежие BGP-события. Только UI-поток. */
private val memberRadios = mutableMapOf<String, Map<Long, Boolean>>()
private val _channelOwner = MutableStateFlow(false)
val channelOwner: StateFlow<Boolean> = _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()

Loading…
Cancel
Save