diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt index 92335246..fc36b664 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/AvatarEditors.kt @@ -98,6 +98,7 @@ fun ProfileAvatarControls() { var pickedAccount by rememberSaveable { mutableStateOf(0L) } var pickedServer by rememberSaveable { mutableStateOf(0L) } var cropUri by rememberSaveable { mutableStateOf(null) } + var pickerFailed by remember { mutableStateOf(false) } val state = edits["profile"] ?: AvatarEditState.IDLE LaunchedEffect(servers) { if (servers.none { it.nodeId == selectedServer }) selectedServer = servers.firstOrNull()?.nodeId ?: 0L @@ -128,12 +129,15 @@ fun ProfileAvatarControls() { } Row { TextButton(enabled = selectedServer != 0L && state != AvatarEditState.WORKING, onClick = { - pickedAccount = repository.accountId; pickedServer = selectedServer; picker.launch("image/*") + pickedAccount = repository.accountId; pickedServer = selectedServer + try { pickerFailed = false; picker.launch("image/*") } + catch (e: Exception) { pickerFailed = true; LogManager.addLog("ERROR", "Avatar", "Profile image picker failed: $e") } }) { Text(stringResource(R.string.avatar_upload)) } TextButton(enabled = repository.accountId != 0L && state != AvatarEditState.WORKING, onClick = { repository.editProfile(0, null, repository.accountId) }) { Text(stringResource(R.string.avatar_remove)) } } + if (pickerFailed) Text(stringResource(R.string.avatar_edit_failed), color = MaterialTheme.colorScheme.error) when (state) { AvatarEditState.WORKING -> LinearProgressIndicator(Modifier.fillMaxWidth()) AvatarEditState.ERROR -> Text(stringResource(R.string.avatar_edit_failed), color = MaterialTheme.colorScheme.error) 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 27efda55..2db39bb1 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 @@ -804,18 +804,8 @@ class ChatViewModel : ViewModel() { } else refreshSelectedMemberDetail(channel, node) } 27 -> { /* CONNECTING_NODES: [ch_id_len:1][ch_id:var][count:2][node_id:8]* */ - if (data == null || data.size < 3) return - val cl = data[0].toInt() and 0xFF - if (data.size < 1 + cl + 2) return - val chId = String(data, 1, cl) - val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, data.size - 1 - cl).order(java.nio.ByteOrder.LITTLE_ENDIAN) - val count = buf.getShort().toInt() and 0xFFFF - val set = HashSet() - for (i in 0 until count) { - if (buf.position() + 8 > data.size) break - set.add(buf.getLong()) - } - _connecting.value = _connecting.value + (chId to set) + val snapshot = ChannelEventFrames.nodes(data) + _connecting.value = _connecting.value + (snapshot.channel to snapshot.ids.toSet()) } 28 -> { /* ADMIN_KEY_RECEIVED: [ch_id_len:1][ch_id:var][from_node_id:8] */ if (data == null || data.size < 10) return @@ -828,50 +818,39 @@ class ChatViewModel : ViewModel() { if (_currentChannel.value?.id == chId) refreshMembers(chId) } 30 -> { /* INVITE_CANDIDATES: [ch_id_len:1][ch_id][auto_node_id:8][count:2][node_id:8]* */ - if (data == null || data.size < 11) { LogManager.addLog("WARN", "VM", "INVITE_CANDIDATES too short"); return } - val cl = data[0].toInt() and 0xFF - if (data.size < 1 + cl + 10) return - val chId = String(data, 1, cl) + val snapshot = ChannelEventFrames.nodes(data, candidates = true) + val chId = snapshot.channel if (inviteChannelId != chId) return - val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, data.size - 1 - cl).order(java.nio.ByteOrder.LITTLE_ENDIAN) - val autoId = buf.long - val count = buf.short.toInt() and 0xFFFF - val ids = mutableListOf() - for (i in 0 until count) { - if (buf.remaining() < 8) break - ids.add(buf.long) - } - LogManager.addLog("INFO", "VM", "INVITE_CANDIDATES ch=$chId auto=0x%016x count=%d".format(autoId, count)) + val autoId = snapshot.automatic + val ids = snapshot.ids + LogManager.addLog("INFO", "VM", "INVITE_CANDIDATES ch=$chId auto=0x%016x count=%d".format(autoId, ids.size)) viewModelScope.launch { - val myId = NativeLib.getMyNodeId() - val nameOf = withContext(Dispatchers.IO) { - (repo?.getMembers(chId) ?: emptyList()).associate { it.nodeId to it.name } + try { + val myId = NativeLib.getMyNodeId() + val nameOf = withContext(Dispatchers.IO) { + (repo?.getMembers(chId) ?: emptyList()).associate { it.nodeId to it.name } + } + fun label(id: Long): String = nameOf[id]?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(id) + val list = mutableListOf() + list.add(InviteCandidate(autoId, label(autoId), isAuto = true, isSelf = autoId == myId)) + for (id in ids) { + if (id == autoId) continue + list.add(InviteCandidate(id, label(id), isSelf = id == myId)) + } + if (inviteChannelId != chId) return@launch + _inviteCandidates.value = list + requestInviteLink(chId, autoId) + } catch (e: kotlinx.coroutines.CancellationException) { throw e + } catch (e: Exception) { + LogManager.addLog("ERROR", "Invite", "Candidate names unavailable channel=$chId: $e") } - fun label(id: Long): String = nameOf[id]?.takeIf { it.isNotEmpty() } ?: "0x%016x".format(id) - val list = mutableListOf() - list.add(InviteCandidate(autoId, label(autoId), isAuto = true, isSelf = autoId == myId)) - for (id in ids) { - if (id == autoId) continue - list.add(InviteCandidate(id, label(id), isSelf = id == myId)) - } - if (inviteChannelId != chId) return@launch - _inviteCandidates.value = list - if (inviteChannelId == chId) requestInviteLink(chId, autoId) } } 29 -> { /* [ch_id_len:1][ch_id][request_id:8][link_len:2][link] */ - if (data == null || data.size < 11) return - val cl = data[0].toInt() and 0xFF - if (data.size < 1 + cl + 10) return - val chId = String(data, 1, cl) - val buf = java.nio.ByteBuffer.wrap(data, 1 + cl, data.size - 1 - cl).order(java.nio.ByteOrder.LITTLE_ENDIAN) - val requestId = buf.long - if (chId != inviteChannelId || requestId != inviteRequestId) return - val linkLen = buf.short.toInt() and 0xFFFF - val link = if (linkLen > 0 && buf.remaining() >= linkLen) - String(data, 1 + cl + 10, linkLen) else null - LogManager.addLog(if (link != null) "INFO" else "ERROR", "VM", "INVITE_LINK_READY ch=$chId len=$linkLen") - _inviteLink.value = link + val result = ChannelEventFrames.invite(data) + if (result.channel != inviteChannelId || result.request != inviteRequestId) return + LogManager.addLog(if (result.link != null) "INFO" else "ERROR", "VM", "INVITE_LINK_READY ch=${result.channel} bytes=${data?.size}") + _inviteLink.value = result.link _inviteLinkPending.value = false } 31 -> { /* CHANNEL_DELETED: [ch_id_len:1][ch_id:var] */ @@ -1921,12 +1900,23 @@ class ChatViewModel : ViewModel() { } } + /** Main, после подтверждённого join worker: убрать все ссылки на удаляемую БД и завершить её ожидающие отправки. */ fun clearState() { cancelDmStart(); _dmStartFailed.value = false; _dmOpen.value = null ++channelRefreshRevision; ++messageRefreshRevision; ++jumpRevision + ++editRevision; ++replyRevision + for (id in submissions.keys.toList()) submissionResult(id, false) + _editing.value = null; _replies.value = emptyMap(); _replyOriginals.value = emptyMap(); cameraReply = null + _historyAnchor.value = ""; _messageJump.value = null; pendingLatestJump = null; _originalMissing.value = false + feedbackCache.clear(); _reactionCatalog.value = emptyList() + memberOwnerJob?.cancel(); _members.value = emptyList(); _memberLinks.value = MemberLinks() memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null - dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null - _attachmentPreparations.value = emptyMap() + dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null; _serverLink.value = null + inviteChannelId = null; _inviteCandidates.value = emptyList(); _inviteLink.value = null; _inviteLinkPending.value = false + _connecting.value = emptyMap(); dmPresenceRefreshJob?.cancel(); _dmCallAvailable.value = false + _currentDm.value = null; _dmConversations.value = emptyList(); _dmMessages.value = emptyList() + dmDeleteRequests.clear(); _dmDeleting.value = emptySet(); _dmDeleteFailed.value = false + _attachmentPreparations.value = emptyMap(); discardedPreparationJobs.clear() repo?.close() repo = null dbReady = false @@ -1937,6 +1927,9 @@ class ChatViewModel : ViewModel() { _generatingKeys.value = false _localSockets.value = emptyList() _channelOwner.value = false + _playbackState.value = PlaybackState(); realPlaybackElapsedMs = 0 + _radioActive.value = false; _radioTalking.value = null; _radioTalkState.value = RadioTalkState() + LogManager.addLog("INFO", "VM", "Database state cleared: conversations, drafts, members, history and pending submissions") } override fun onCleared() {