Preserve Android radio presence across member refreshes
This commit is contained in:
1 parent
658c248867
commit
47082c4e2e
3 files changed
+90
-31
No files matched your search
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
24 -> { /* MEMBER_UPDATED: [ch_id_len:1][ch_id:var][count:1][member:79B] */
|
||||
if (data == null || data.size < 3 + 79) return
|
||||
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
|
||||
val w = parseMemberWire(data, 1 + cl + 1) ?: return
|
||||
val m = w.toChatMember()
|
||||
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) {
|
||||
LogManager.addLog("ERROR", "VM", "MEMBER_UPDATED: missing header/member"); return
|
||||
}
|
||||
val cl = data[0].toInt() and 0xFF
|
||||
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()
|
||||
|
||||
Reference in new issue
Block a user