Guard Android PM history and pending deletion across asynchronous sends

This commit is contained in:
evgeny committed 2026-10-26 22:11:39 +01:00
1 parent 5778c8aa0a
commit f83033d3a1
5 files changed
+61 -25

No files matched your search

@@ -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
}
@@ -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)) } })
@@ -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<Long, CompletableDeferred<Boolean>>? = 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<Boolean> = 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) {
@@ -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);
}
@@ -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