From e5a87bd043dc2114764401147d2faa4b12017a92 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 4 Oct 2026 15:02:27 +0200 Subject: [PATCH] Track Android PM invitations and joining from message links in ViewModel --- .../com/utun/chat/viewmodel/ChatViewModel.kt | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) 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 afbda3e0..d89d631b 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 @@ -25,6 +25,19 @@ import java.io.FileOutputStream private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") class ChatViewModel : ViewModel() { + data class PmInviteState(val peer: Long, val name: String, val source: String, val groups: List, + val groupId: String = "", val requestId: Long = 0, val pending: Boolean = false, val errorRes: Int = 0) + private val _pmInvite = MutableStateFlow(null) + val pmInvite: StateFlow = _pmInvite + private var nextPmInviteId = 0L + + data class LinkJoinState(val link: String, val channelId: Long, val nodeId: Long, + val pending: Boolean = true, val result: Int? = null) + private val _linkJoin = MutableStateFlow(null) + val linkJoin: StateFlow = _linkJoin + private val _joinedChannel = MutableStateFlow(null) + val joinedChannel: StateFlow = _joinedChannel + data class AttachmentPreparation(val jobs: Set = emptySet(), val failed: Boolean = false) private val _attachmentPreparations = MutableStateFlow>(emptyMap()) val attachmentPreparations: StateFlow> = _attachmentPreparations @@ -317,6 +330,18 @@ class ChatViewModel : ViewModel() { val nodeId = buf.getLong() val result = buf.getInt() val chId = buf.getLong() + val joining = _linkJoin.value + if (joining != null && joining.pending && joining.channelId == chId && joining.nodeId == nodeId) { + _linkJoin.value = joining.copy(pending = false, result = result) + if (result == 0) viewModelScope.launch { + val channels = withContext(Dispatchers.IO) { repo?.getChannels().orEmpty() } + _channels.value = channels + val channel = channels.find { it.id.toULongOrNull()?.toLong() == chId } + if (_linkJoin.value?.link == joining.link && channel != null) { + _linkJoin.value = null; _joinedChannel.value = channel + } else if (channel == null) LogManager.addLog("ERROR", "JOIN", "joined channel missing ch=$chId") + } + } LogManager.addLog( if (result == 0) "INFO" else "ERROR", "JOIN", "connect to 0x%016x ch=$chId result=$result".format(nodeId)) @@ -326,6 +351,26 @@ 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 -> { _attachmentPreparations.value = emptyMap(); LogManager.addLog("INFO", "VM", "service stopped") } + 48 -> { /* [request_id:8][result:4][peer:8][conv:8] */ + if (data == null || data.size != 28) { LogManager.addLog("ERROR", "INVITE", "invalid PM invite result"); return } + val buf = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN) + val request = buf.long; val result = buf.int; val peer = buf.long; val conv = buf.long.toULong().toString() + val state = _pmInvite.value ?: return + if (!state.pending || state.requestId != request || state.peer != peer) return + LogManager.addLog(if (result == 0) "INFO" else "ERROR", "INVITE", "PM invite result request=$request peer=$peer result=$result") + if (result == 0) { + _pmInvite.value = null + openDm(conv, peer, state.name); _dmOpen.value = conv; refreshDmConversations() + } else { + val error = when (result) { + -1 -> R.string.invite_pm_start_failed + -3 -> R.string.invite_pm_send_failed + -5 -> R.string.invite_pm_busy + else -> R.string.invite_build_failed + } + _pmInvite.value = state.copy(pending = false, errorRes = error) + } + } 47 -> { if (data == null || data.size < 17 || data.size != 17 + (data[0].toInt() and 0xff)) { LogManager.addLog("WARN", "Call", "invalid PM route snapshot size=${data?.size}"); return @@ -919,6 +964,59 @@ class ChatViewModel : ViewModel() { r.dmSend(dm.convId, text) } + fun showPmInvite(peer: Long, name: String, source: String = "") { + if (peer == 0L || peer == NativeLib.getMyNodeId()) { LogManager.addLog("ERROR", "INVITE", "invalid PM recipient"); return } + if (_pmInvite.value != null) return + val groups = _channels.value.filter { NativeLib.isChannelMember(it.id, NativeLib.getMyNodeId()) } + _pmInvite.value = PmInviteState(peer, name, source, groups) + LogManager.addLog("INFO", "INVITE", "PM group picker peer=$peer source=$source groups=${groups.size}") + } + + fun selectPmInviteGroup(groupId: String) { + _pmInvite.value?.let { if (!it.pending) _pmInvite.value = it.copy(groupId = groupId, errorRes = 0) } + } + + fun sendPmInvite() { + val state = _pmInvite.value ?: return + if (state.pending) return + val channel = state.groupId.toULongOrNull()?.toLong() ?: return + val request = ++nextPmInviteId + _pmInvite.value = state.copy(requestId = request, pending = true, errorRes = 0) + if (!NativeLib.dmInvite(request, state.peer, state.source, channel, state.name)) { + _pmInvite.value = state.copy(errorRes = R.string.invite_core_unavailable) + } + } + + fun dismissPmInvite() { + val state = _pmInvite.value ?: return + _pmInvite.value = null + if (state.pending) NativeLib.dmInvite(state.requestId, state.peer, state.source, 0, state.name, cancel = true) + LogManager.addLog("INFO", "INVITE", "PM group picker closed request=${state.requestId} pending=${state.pending}") + } + + fun openInviteLink(link: String) { + if (_linkJoin.value?.pending == true) return + val invite = InviteLink.decode(link) + if (invite == null) { + LogManager.addLog("ERROR", "JOIN", "invalid message invite link") + _linkJoin.value = LinkJoinState(link, 0, 0, pending = false, result = -1); return + } + val existing = _channels.value.find { it.id.toULongOrNull()?.toLong() == invite.channelId } + if (existing != null && NativeLib.isChannelMember(existing.id, NativeLib.getMyNodeId())) { + _linkJoin.value = null; _joinedChannel.value = existing; return + } + if (repo == null || !NativeLib.isReady()) { + LogManager.addLog("ERROR", "JOIN", "core unavailable for message invite") + _linkJoin.value = LinkJoinState(link, invite.channelId, invite.nodeId, pending = false, result = -7); return + } + _linkJoin.value = LinkJoinState(link, invite.channelId, invite.nodeId) + val addresses = invite.serializeAddrs() + joinChannel(invite.channelId, invite.nodeId, invite.joinKey, invite.pubkey, addresses, invite.addrs.size) + } + + fun dismissLinkJoin() { _linkJoin.value = null } + fun consumeJoinedChannel() { _joinedChannel.value = null } + fun sendDmFile(convId: String, path: String, name: String) { if (repo != null) NativeLib.dmSendFile(convId, path, name) } @@ -1288,6 +1386,7 @@ class ChatViewModel : ViewModel() { } fun clearState() { + dismissPmInvite(); _linkJoin.value = null; _joinedChannel.value = null _attachmentPreparations.value = emptyMap() repo?.close() repo = null