From 321a2c3ed7660688fde11d708a2d2403ce11764e Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 29 Oct 2026 12:31:28 +0100 Subject: [PATCH] Match private conversation confirmations before opening the requested peer --- .../main/java/com/utun/chat/MainActivity.kt | 14 ++++- .../java/com/utun/chat/data/PendingDmOpen.kt | 25 +++++++++ .../com/utun/chat/viewmodel/ChatViewModel.kt | 56 +++++++++++++------ 3 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/PendingDmOpen.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 25c5b930..4bcb167b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -330,6 +330,7 @@ class MainActivity : AppCompatActivity() { // После смерти процесса ViewModel уже новый; сохранённый экран может потерять свою беседу или звонок. LaunchedEffect(screen) { + if (screen != Screen.MemberList && screen != Screen.Dm) vm.cancelDmStart() inputScreen = screen.name LogManager.addLog("INFO", "MAIN", "UI screen=$screen") val unavailable = when (screen) { @@ -382,7 +383,8 @@ class MainActivity : AppCompatActivity() { vm.dmOpen.collect { convId -> if (convId != null) { vm.consumeDmOpen() - screen = Screen.Dm + if (screen == Screen.MemberList && vm.currentDm.value?.convId == convId) screen = Screen.Dm + else LogManager.addLog("DEBUG", "MAIN", "Ignoring stale DM navigation conv=$convId screen=$screen") } } } @@ -462,8 +464,14 @@ class MainActivity : AppCompatActivity() { isStuck = stuck, cpuLoad = cpuLoad, onCpuLoadVisible = vm::setCpuLoadVisible, - onSelectChannel = { ch -> vm.selectChannel(ch); screen = Screen.Chat }, - onSelectDm = { dm -> vm.openDm(dm.convId, dm.peerNodeId, dm.peerName, dm.generation); screen = Screen.Dm }, + onSelectChannel = { ch -> + vm.selectChannel(ch) + if (vm.currentChannel.value?.id == ch.id) screen = Screen.Chat + }, + onSelectDm = { dm -> + vm.openDm(dm.convId, dm.peerNodeId, dm.peerName, dm.generation) + if (vm.currentDm.value?.convId == dm.convId) screen = Screen.Dm + }, onCreateChannel = { name -> vm.createChannel(name, "channel_1") }, onJoinChannel = { showJoinDialog = true }, onInviteClick = showInvite, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PendingDmOpen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PendingDmOpen.kt new file mode 100644 index 00000000..a0d3db17 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PendingDmOpen.kt @@ -0,0 +1,25 @@ +package com.utun.chat.data + +/** Одна ожидаемая навигация: ID принятой worker команды фиксируется до DM_CONV_UPDATED. + * Чужое событие сохраняет ожидание. Новый запрос/отмена лишает старый timeout права менять состояние. */ +class PendingDmOpen { + private data class Request(val token: Long, val conversation: String, val peer: Long, val name: String) + private var sequence = 0L + private var request: Request? = null + val pending: Boolean get() = request != null + + fun begin(conversation: String, peer: Long, name: String): Long { + require(conversation.toULongOrNull()?.let { it != 0UL } == true && peer != 0L) + val token = ++sequence + request = Request(token, conversation, peer, name) + return token + } + fun isPending(token: Long): Boolean = request?.token == token + fun cancel() { request = null } + fun complete(conversation: String, generation: Long): DmConversation? { + val current = request ?: return null + if (current.conversation != conversation || generation <= 0) return null + request = null + return DmConversation(convId = conversation, peerNodeId = current.peer, peerName = current.name, generation = generation) + } +} 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 337bae16..1b1d25ef 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 @@ -349,8 +349,15 @@ class ChatViewModel : ViewModel() { private val _dmOpen = MutableStateFlow(null) val dmOpen: StateFlow = _dmOpen - /* peer-данные начатой беседы — для автооткрытия по событию DM_CONV_UPDATED. */ - private var pendingStartPeer: Pair? = null + private val pendingDmOpen = PendingDmOpen() + private var dmStartTimeout: kotlinx.coroutines.Job? = null + private val _dmStartFailed = MutableStateFlow(false) + val dmStartFailed: StateFlow = _dmStartFailed + fun dismissDmStartError() { _dmStartFailed.value = false } + + fun cancelDmStart() { + pendingDmOpen.cancel(); dmStartTimeout?.cancel(); dmStartTimeout = null + } val audioRecorder = AudioRecorderManager() @@ -505,6 +512,8 @@ class ChatViewModel : ViewModel() { private fun handleEvent(type: Int, data: ByteArray?) { if (type == 13) { + if (pendingDmOpen.pending) { _dmStartFailed.value = true; LogManager.addLog("WARN", "PM", "Conversation start interrupted by core stop") } + cancelDmStart() cpuLoadPending = false; _cpuLoad.value = 0 dmPresenceRefreshJob?.cancel() _dmCallAvailable.value = false @@ -895,16 +904,11 @@ class ChatViewModel : ViewModel() { refreshDmMessages() } } - val pend = pendingStartPeer - if (pend != null) { - pendingStartPeer = null - _currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second, - generation = generation) - _dmCallAvailable.value = false - refreshDmCallRoute() - refreshDmMessages() - _dmOpen.value = convId - LogManager.addLog("INFO", "VM", "DM auto-open conv=$convId") + pendingDmOpen.complete(convId, generation)?.let { started -> + dmStartTimeout?.cancel(); dmStartTimeout = null + openDm(started.convId, started.peerNodeId, started.peerName, started.generation) + if (_currentDm.value?.convId == convId) _dmOpen.value = convId + LogManager.addLog("INFO", "VM", "DM start confirmed conv=$convId peer=${started.peerNodeId}") } } 32 -> { /* DM_MSG_RECEIVED: [conv_id_len:1][conv_id:var][author_node_id:8] */ @@ -1149,7 +1153,10 @@ class ChatViewModel : ViewModel() { _inviteCandidates.value = emptyList() _inviteLink.value = null _inviteLinkPending.value = true - NativeLib.requestInviteCandidates(channelId) + if (!NativeLib.requestInviteCandidates(channelId)) { + _inviteLinkPending.value = false + LogManager.addLog("ERROR", "INVITE", "Candidate request rejected ch=$channelId") + } LogManager.addLog("INFO", "VM", "requestInvite ch=$channelId") } @@ -1157,7 +1164,10 @@ class ChatViewModel : ViewModel() { _inviteLink.value = null _inviteLinkPending.value = true inviteChannelId = channelId - NativeLib.requestInviteLink(channelId, targetNodeId, ++inviteRequestId) + if (!NativeLib.requestInviteLink(channelId, targetNodeId, ++inviteRequestId)) { + _inviteLinkPending.value = false + LogManager.addLog("ERROR", "INVITE", "Link request rejected ch=$channelId target=$targetNodeId") + } LogManager.addLog("INFO", "VM", "requestInviteLink ch=$channelId node=0x%016x".format(targetNodeId)) } @@ -1331,14 +1341,25 @@ class ChatViewModel : ViewModel() { fun startDm(channelId: String, nodeId: Long, name: String) { val r = repo ?: return if (nodeId == 0L || nodeId == NativeLib.getMyNodeId()) return - LogManager.addLog("INFO", "VM", "startDm ch=$channelId peer=0x%016x name=$name".format(nodeId)) - pendingStartPeer = nodeId to name - r.dmStart(nodeId, channelId, name) + cancelDmStart(); _dmStartFailed.value = false + try { + val conversation = checkNotNull(r.dmStart(nodeId, channelId, name)) { "Conversation start rejected" } + val token = pendingDmOpen.begin(conversation, nodeId, name) + LogManager.addLog("INFO", "PM", "Conversation start accepted channel=$channelId peer=$nodeId conv=$conversation") + dmStartTimeout = viewModelScope.launch { + delay(10_000) + if (pendingDmOpen.isPending(token)) { + pendingDmOpen.cancel(); _dmStartFailed.value = true + LogManager.addLog("ERROR", "PM", "Conversation start confirmation timed out conv=$conversation peer=$nodeId") + } + } + } catch (e: Exception) { _dmStartFailed.value = true; LogManager.addLog("ERROR", "PM", "Conversation start failed peer=$nodeId: $e") } } fun consumeDmOpen() { _dmOpen.value = null } fun openDm(convId: String, peerNodeId: Long, peerName: String, generation: Long) { + cancelDmStart() if (repo == null) return _editing.value = null val dm = DmConversation(convId = convId, peerNodeId = peerNodeId, peerName = peerName, generation = generation) @@ -1902,6 +1923,7 @@ class ChatViewModel : ViewModel() { } fun clearState() { + cancelDmStart(); _dmStartFailed.value = false; _dmOpen.value = null ++channelRefreshRevision; ++messageRefreshRevision; ++jumpRevision memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null