diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index dea09e42..a4f3f5d9 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -336,7 +336,7 @@ object NativeLib { fun dmDelete(convId: String, generation: Long, request: Long): Boolean = nativeDmDelete(convId, generation, request) fun dmRequestConversations() { nativeDmRequestConversations() } fun dmRequestCallRoute(convId: String) { nativeDmRequestCallRoute(convId) } - fun dmRequestMessages(convId: String) { nativeDmRequestMessages(convId) } + fun dmRequestMessages(convId: String, generation: Long) { nativeDmRequestMessages(convId, generation) } fun dmSendFile(convId: String, path: String, name: String, generation: Long): Boolean = nativeDmSendFile(convId, path, name, generation) private external fun nativeInit(dbPath: String, controlPort: Int, config: Any, logCb: Any): Boolean @@ -472,6 +472,6 @@ object NativeLib { private external fun nativeIsChannelMember(channelId: String, nodeId: Long): Boolean private external fun nativeDmRequestConversations() private external fun nativeDmRequestCallRoute(convId: String) - private external fun nativeDmRequestMessages(convId: String) + private external fun nativeDmRequestMessages(convId: String, generation: Long) private external fun nativeDmSendFile(convId: String, path: String, name: String, generation: Long): Boolean } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt index ce193bad..185cd5f2 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt @@ -42,7 +42,8 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, val reply = if (target is ConversationTarget.Channel) replies[target.id] else null val sendFailed by viewModel.messageSendFailed.collectAsState() val submitting by viewModel.submittingChannels.collectAsState() - val pending = target is ConversationTarget.Channel && target.id in submitting + val deletingDm by viewModel.dmDeleting.collectAsState() + val pending = if (target is ConversationTarget.Channel) target.id in submitting else target.id in deletingDm if (sendFailed) AlertDialog(onDismissRequest = viewModel::dismissMessageSendError, text = { Text(stringResource(R.string.message_send_failed)) }, confirmButton = { TextButton(onClick = viewModel::dismissMessageSendError) { Text(stringResource(R.string.close)) } }) 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 0d63548c..9822027f 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 @@ -807,13 +807,19 @@ class ChatViewModel : ViewModel() { val cl = data[0].toInt() and 0xFF if (data.size != cl + 9) { LogManager.addLog("ERROR", "PM", "Invalid conversation update bytes=${data.size}"); return } val convId = String(data, 1, cl) + val generation = java.nio.ByteBuffer.wrap(data, 1 + cl, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long LogManager.addLog("INFO", "VM", "DM_CONV_UPDATED conv=$convId") refreshDmConversations() - _currentDm.value?.let { if (it.convId == convId) refreshDmMessages() } + _currentDm.value?.let { + if (it.convId == convId && generation >= it.generation) { + if (generation != it.generation) _dmMessages.value = emptyList() + _currentDm.value = it.copy(generation = generation) + refreshDmMessages() + } + } val pend = pendingStartPeer if (pend != null) { pendingStartPeer = null - val generation = java.nio.ByteBuffer.wrap(data, 1 + cl, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long _currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second, generation = generation) _dmCallAvailable.value = false @@ -835,14 +841,19 @@ class ChatViewModel : ViewModel() { if (cur != null && cur.convId == convId) refreshDmMessages() refreshDmConversations() } - 41 -> { /* DM_MESSAGES: [conv_len:1][conv:utf8][JSON:utf8] */ + 41 -> { /* DM_MESSAGES: [conv_len:1][conv:utf8][generation:8][JSON:utf8] */ if (data == null || data.isEmpty()) return val length = data[0].toInt() and 0xFF - if (data.size <= 1 + length) return + if (length == 0 || data.size <= 9 + length) { + LogManager.addLog("ERROR", "PM", "Invalid history snapshot bytes=${data.size}"); return + } val dm = _currentDm.value ?: return val conv = String(data, 1, length, Charsets.UTF_8) - if (conv != dm.convId) return - val json = String(data, 1 + length, data.size - 1 - length, Charsets.UTF_8) + val generation = java.nio.ByteBuffer.wrap(data, 1 + length, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).long + if (conv != dm.convId || generation != dm.generation || conv in _dmDeleting.value) { + LogManager.addLog("DEBUG", "PM", "Discarded stale history conv=$conv generation=$generation"); return + } + val json = String(data, 9 + length, data.size - 9 - length, Charsets.UTF_8) _dmMessages.value = repo?.parseDmMessages(json, dm.peerName) ?: emptyList() } 45 -> { @@ -852,7 +863,10 @@ class ChatViewModel : ViewModel() { it.copy(online = previous[it.convId]?.online == true) } _currentDm.value?.let { current -> - _dmConversations.value.find { it.convId == current.convId }?.let { _currentDm.value = it } + _dmConversations.value.find { it.convId == current.convId }?.let { + _currentDm.value = it + if (it.generation != current.generation) { _dmMessages.value = emptyList(); refreshDmMessages() } + } } refreshDmCallRoute() } @@ -1212,7 +1226,7 @@ class ChatViewModel : ViewModel() { fun refreshDmMessages() { val dm = _currentDm.value ?: return - if (repo != null) NativeLib.dmRequestMessages(dm.convId) + if (repo != null && dm.convId !in _dmDeleting.value) NativeLib.dmRequestMessages(dm.convId, dm.generation) } fun startDm(channelId: String, nodeId: Long, name: String) { @@ -1241,13 +1255,18 @@ class ChatViewModel : ViewModel() { _dmMessages.value = emptyList() } - fun sendDmText(target: ConversationTarget.Pm, text: String): Boolean { - val r = repo ?: return false - if (text.isBlank()) return false + /** Единая проверка PM до записи, копирования и native-публикации; ядро повторяет проверку поколения. */ + private fun dmTargetCurrent(target: ConversationTarget.Pm): Boolean { val dm = _dmConversations.value.find { it.convId == target.id } ?: _currentDm.value?.takeIf { it.convId == target.id } if (dm?.generation != target.generation || target.id in _dmDeleting.value) { - LogManager.addLog("INFO", "PM", "Discarded stale text conv=${target.id} generation=${target.generation}"); return false + LogManager.addLog("INFO", "PM", "Discarded stale operation conv=${target.id} generation=${target.generation}"); return false } + return true + } + + fun sendDmText(target: ConversationTarget.Pm, text: String): Boolean { + val r = repo ?: return false + if (text.isBlank() || !dmTargetCurrent(target)) return false r.dmSend(target.id, text, target.generation) return true } @@ -1437,6 +1456,7 @@ class ChatViewModel : ViewModel() { private var recordingSubmission: Pair>? = null fun startVoiceRecording(target: ConversationTarget) { + if (target is ConversationTarget.Pm && !dmTargetCurrent(target)) return if (recordingStopping) { LogManager.addLog("WARN", "VM", "recording is still stopping"); return } if (_isRecording.value) { LogManager.addLog("WARN", "VM", "recording already active"); return } if (target is ConversationTarget.Channel && target.id in _submittingChannels.value) { @@ -1468,6 +1488,8 @@ class ChatViewModel : ViewModel() { fun stopVoiceRecording() { if (!_isRecording.value || recordingStopping) return + val target = recordingTarget + if (target is ConversationTarget.Pm && !dmTargetCurrent(target)) { cancelVoiceRecording(target); return } recordingStopping = true _isRecording.value = false _isRecordingLocked.value = false @@ -1530,8 +1552,15 @@ class ChatViewModel : ViewModel() { fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name, reply: ChannelReply? = replyFor(target)): Deferred = viewModelScope.async { if (target is ConversationTarget.Pm) { - val accepted = NativeLib.dmSendFile(target.id, filePath, name, target.generation) - if (!accepted) { LogManager.addLog("ERROR", "VM", "PM attachment rejected"); _messageSendFailed.value = true } + val current = dmTargetCurrent(target) + val accepted = current && NativeLib.dmSendFile(target.id, filePath, name, target.generation) + if (!accepted) { + withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { + if (!File(filePath).delete() && File(filePath).exists()) + LogManager.addLog("ERROR", "PM", "Rejected attachment cache cleanup failed: $filePath") + } + if (current) { LogManager.addLog("ERROR", "VM", "PM attachment rejected"); _messageSendFailed.value = true } + } return@async accepted } val chId = target.id @@ -1570,11 +1599,13 @@ class ChatViewModel : ViewModel() { } finally { mmr.release() } } val queue: (Long) -> Boolean = { request -> - NativeLib.videoSend(target.nativeId, filePath, meta.first, meta.second, meta.third, + if (target is ConversationTarget.Pm && !dmTargetCurrent(target)) { + discardedPreparationJobs.add(job); false + } else NativeLib.videoSend(target.nativeId, filePath, meta.first, meta.second, meta.third, reply?.message?.messageId.orEmpty(), request, (target as? ConversationTarget.Pm)?.generation ?: 0).also { accepted = it } } saved = if (target is ConversationTarget.Channel) submitChannel(target.id, reply, queue) else queue(0) - if (!saved) _messageSendFailed.value = true + if (!saved && job !in discardedPreparationJobs) _messageSendFailed.value = true if (saved && clearReply) finishReply(reply) } catch (e: kotlinx.coroutines.CancellationException) { throw e } catch (e: Exception) { diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index f706ea60..c6de1cdc 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -677,12 +677,15 @@ void utun_bridge_dm_request_conversations(void) { uasync_post(ua, dm_conversations_trampoline, req); } -void utun_bridge_dm_request_messages(const char* conv_id) { +void utun_bridge_dm_request_messages(const char* conv_id, uint64_t generation) { struct UASYNC* ua = instance_lite_get_uasync(); - if (!conv_id || !conv_id[0] || !uasync_is_running(ua)) { bridge_log(BLEV_WARN, "DM history: invalid request/core stopped"); return; } + if (!conv_id || !conv_id[0] || !generation || !uasync_is_running(ua)) { + bridge_log(BLEV_WARN, "DM history: invalid request/core stopped"); return; + } struct dm_messages_req* req = u_calloc(1, sizeof(*req)); if (!req) { bridge_log(BLEV_ERROR, "DM history: OOM"); return; } req->inst = instance_lite_get_instance(); + req->generation = generation; snprintf(req->conv_id, sizeof(req->conv_id), "%s", conv_id); uasync_post(ua, dm_messages_trampoline, req); } @@ -2149,7 +2152,7 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmSend( if (c) (*env)->ReleaseStringUTFChars(env, convId, c); } -/* UI получает подтверждение commit через CHAT_EVT_DM_INVITE_RESULT, а не через return JNI. */ +/* UI получает подтверждение commit через CHAT_EVT_DM_DELETE_RESULT, а не через return JNI. */ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeDmDelete( JNIEnv* env, jobject thiz, jstring conversation, jlong generation, jlong request) { (void)thiz; @@ -2230,10 +2233,10 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmRequestCallRout } JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeDmRequestMessages( - JNIEnv* env, jobject thiz, jstring convId) { + JNIEnv* env, jobject thiz, jstring convId, jlong generation) { (void)thiz; const char* c = (*env)->GetStringUTFChars(env, convId, NULL); - utun_bridge_dm_request_messages(c); + utun_bridge_dm_request_messages(c, (uint64_t)generation); if (c) (*env)->ReleaseStringUTFChars(env, convId, c); } diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.h b/tools/chatgui-android/jni_bridge/android_jni_bridge.h index e44971e2..594cfc06 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.h +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.h @@ -207,7 +207,8 @@ void utun_bridge_collect_conn_metrics(uint64_t peer_node_id); void utun_bridge_dm_start(uint64_t node_id, const char* source_ch_id, const char* peer_name); void utun_bridge_dm_send(const char* conv_id, const char* text, uint64_t generation); void utun_bridge_dm_request_conversations(void); -void utun_bridge_dm_request_messages(const char* conv_id); +/* Снимок видимой истории указанного поколения; запоздалый запрос отбрасывается в uasync. */ +void utun_bridge_dm_request_messages(const char* conv_id, uint64_t generation); int utun_bridge_dm_send_file(const char* conv_id, const char* path, const char* name, uint64_t generation); #ifdef __cplusplus