From d28b7062d26f657c8eb637b1330bb294e88f04e8 Mon Sep 17 00:00:00 2001 From: evgeny Date: Thu, 8 Oct 2026 10:29:30 +0200 Subject: [PATCH] android: track saved replies and navigate original messages --- .../java/com/utun/chat/ChatApplication.kt | 8 +- .../utun/chat/data/AudioRecorderManager.kt | 4 +- .../com/utun/chat/viewmodel/ChatViewModel.kt | 321 +++++++++++++----- 3 files changed, 254 insertions(+), 79 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 07ee4023..253aca9b 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -16,12 +16,17 @@ import com.utun.chat.data.CallController import com.utun.chat.data.AvatarRepository import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import java.nio.ByteBuffer import java.nio.ByteOrder object AppEventHandler : ChatEventCallback { private val mainHandler = Handler(Looper.getMainLooper()) private val _events = MutableSharedFlow>(replay = 16, extraBufferCapacity = 64) + private val eventScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) val events: SharedFlow> = _events override fun onEvent(type: Int, data: ByteArray?) { @@ -31,7 +36,8 @@ object AppEventHandler : ChatEventCallback { if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) { mainHandler.post { CallController.onEvent(type, data) } } - _events.tryEmit(type to data) + // Подтверждения отправки нельзя терять при заполнении буфера; native-поток не блокируем. + eventScope.launch { _events.emit(type to data) } } // Звук живёт в приложении: работает без Activity и не повторяется при replay событий в UI. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt index c9dbe786..b65766cb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt @@ -34,11 +34,11 @@ class AudioRecorderManager { PackageManager.PERMISSION_GRANTED } - fun startRecording(channelId: String): Boolean { + fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0): Boolean { if (isRecording) return false if (!hasPermission()) return false - val ok = NativeLib.voiceStart(channelId) + val ok = NativeLib.voiceStart(channelId, replyTo, requestId) if (!ok) { LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed") return false 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 48acc26b..e07b0d6b 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 @@ -14,6 +14,9 @@ import com.utun.chat.ChatApplication import com.utun.chat.AppEventHandler import com.utun.chat.data.* import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.isActive @@ -27,6 +30,97 @@ import org.json.JSONArray private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") class ChatViewModel : ViewModel() { + private val _replies = MutableStateFlow>(emptyMap()) + val replies: StateFlow> = _replies + private var replyRevision = 0L + private var nextSubmissionId = 0L + private val submissions = mutableMapOf>() + private val submissionChannels = mutableMapOf() + private val _submittingChannels = MutableStateFlow>(emptySet()) + val submittingChannels: StateFlow> = _submittingChannels + private val _messageSendFailed = MutableStateFlow(false) + val messageSendFailed: StateFlow = _messageSendFailed + private val _replyOriginals = MutableStateFlow>(emptyMap()) + val replyOriginals: StateFlow> = _replyOriginals + private val _historyAnchor = MutableStateFlow("") + val historyAnchor: StateFlow = _historyAnchor + data class MessageJump(val messageId: String, val serial: Long) + private val _messageJump = MutableStateFlow(null) + val messageJump: StateFlow = _messageJump + private var messageRefreshRevision = 0L + private var jumpRevision = 0L + private var pendingLatestJump: MessageJump? = null + private val _originalMissing = MutableStateFlow(false) + val originalMissing: StateFlow = _originalMissing + var cameraReply: ChannelReply? = null + private set + + fun replyFor(target: ConversationTarget): ChannelReply? = + if (target is ConversationTarget.Channel) _replies.value[target.id] else null + + fun selectReply(message: Message) { + val channel = _currentChannel.value?.id ?: return + if (!validMessageId(message.messageId)) { LogManager.addLog("ERROR", "Reply", "invalid original ID"); return } + _replies.value = _replies.value + (channel to ChannelReply(channel, message, ++replyRevision)) + LogManager.addLog("INFO", "Reply", "selected ch=$channel original=${message.messageId}") + } + + fun cancelReply(channel: String) { + _replies.value = _replies.value - channel + LogManager.addLog("INFO", "Reply", "cancelled ch=$channel") + } + + fun finishReply(reply: ChannelReply?) { + if (reply != null && reply.channelId !in _submittingChannels.value && _replies.value[reply.channelId] == reply) + cancelReply(reply.channelId) + } + + fun captureCameraReply(target: ConversationTarget) { cameraReply = replyFor(target) } + fun dismissMessageSendError() { _messageSendFailed.value = false } + fun dismissOriginalMissing() { _originalMissing.value = false } + + private fun submissionResult(id: Long, success: Boolean) { + val result = submissions.remove(id) + submissionChannels.remove(id) + _submittingChannels.value = submissionChannels.values.toSet() + if (result == null) { LogManager.addLog("WARN", "Reply", "unknown submission result request=$id success=$success"); return } + LogManager.addLog(if (success) "INFO" else "ERROR", "Reply", "submission request=$id saved=$success") + result.complete(success) + } + + private suspend fun submitChannel(channel: String, reply: ChannelReply?, queue: (Long) -> Boolean): Boolean { + if (reply != null && reply.channelId != channel) { + LogManager.addLog("ERROR", "Reply", "target mismatch ch=$channel reply=${reply.channelId}") + _messageSendFailed.value = true + return false + } + val id = ++nextSubmissionId + val result = CompletableDeferred() + submissions[id] = result + submissionChannels[id] = channel + _submittingChannels.value = submissionChannels.values.toSet() + LogManager.addLog("INFO", "Reply", "submit ch=$channel request=$id original=${reply?.message?.messageId.orEmpty()}") + try { + if (!queue(id)) submissionResult(id, false) + val success = result.await() + if (!success) _messageSendFailed.value = true + return success + } catch (e: kotlinx.coroutines.CancellationException) { throw e } + catch (e: Exception) { + LogManager.addLog("ERROR", "Reply", "submission failed ch=$channel request=$id: ${e.message}") + _messageSendFailed.value = true + return false + } finally { + submissions.remove(id) + submissionChannels.remove(id) + _submittingChannels.value = submissionChannels.values.toSet() + } + } + + fun sendChannelText(target: ConversationTarget.Channel, text: String, reply: ChannelReply?): Deferred = viewModelScope.async { + submitChannel(target.id, reply) { NativeLib.sendMessage(target.id, text, reply?.message?.messageId.orEmpty(), it) } + } + 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) @@ -297,6 +391,11 @@ class ChatViewModel : ViewModel() { _dmConversations.value = _dmConversations.value.map { it.copy(online = false) } } else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute() when (type) { + 56 -> { /* MSG_SUBMITTED: request_id:8LE, success. */ + if (data == null || data.size != 9) { LogManager.addLog("ERROR", "Reply", "invalid submission result"); return } + val id = java.nio.ByteBuffer.wrap(data).order(java.nio.ByteOrder.LITTLE_ENDIAN).long + submissionResult(id, data[8].toInt() != 0) + } 55 -> { /* RADIO_MUTE_RESULT: group_id:8LE, node_id:8LE, success. */ if (data == null || data.size != 17) { LogManager.addLog("ERROR", "Radio", "Invalid member mute result"); _radioMuteFailed.value = true; return @@ -404,6 +503,7 @@ 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 -> { + for (id in submissions.keys.toList()) submissionResult(id, false) _radioTalkState.value = RadioTalkState() _radioTalking.value = null _radioActive.value = false @@ -1004,16 +1104,56 @@ class ChatViewModel : ViewModel() { fun refreshMessages(chId: String) { val r = repo ?: return + if (_currentChannel.value?.id != chId) return + val anchor = _historyAnchor.value + val revision = ++messageRefreshRevision viewModelScope.launch { - val rows = withContext(Dispatchers.IO) { r.getMessages(chId) } - if (_currentChannel.value?.id != chId) return@launch + val (rows, originals) = withContext(Dispatchers.IO) { + val rows = r.getMessages(chId, 100, anchor) + val loaded = rows.associateBy { it.messageId }.toMutableMap() + for (id in rows.map { it.replyTo }.filter { it.isNotEmpty() }.distinct()) { + if (id !in loaded) r.getMessages(chId, 1, id, true).firstOrNull()?.let { loaded[id] = it } + } + rows to loaded + } + if (_currentChannel.value?.id != chId || revision != messageRefreshRevision) return@launch _messages.value = rows.map { it.copy(feedback = feedbackCache[it.messageId]) } + _replyOriginals.value = originals + if (anchor.isEmpty()) pendingLatestJump?.let { _messageJump.value = it; pendingLatestJump = null } if (_reactionCatalog.value.isEmpty()) sendInteraction(JSONObject().put("cmd", "reaction_catalog"), false) for (row in rows) if (row.messageId.length == 64) sendInteraction(JSONObject().put("cmd", "feedback").put("ch", chId).put("message_id", row.messageId), false) } } + fun showOriginal(id: String) { + val channel = _currentChannel.value?.id ?: return + val r = repo ?: return + val revision = ++jumpRevision + pendingLatestJump = null + viewModelScope.launch { + val rows = withContext(Dispatchers.IO) { r.getMessages(channel, 100, id) } + if (_currentChannel.value?.id != channel || jumpRevision != revision) return@launch + if (rows.none { it.messageId == id }) { + LogManager.addLog("WARN", "Reply", "original unavailable ch=$channel id=$id") + _originalMissing.value = true + return@launch + } + ++messageRefreshRevision + _historyAnchor.value = id + _messages.value = rows.map { it.copy(feedback = feedbackCache[it.messageId]) } + _messageJump.value = MessageJump(id, revision) + refreshMessages(channel) + LogManager.addLog("INFO", "Reply", "history jump ch=$channel id=$id rows=${rows.size}") + } + } + + fun showLatestMessages() { + _historyAnchor.value = "" + pendingLatestJump = MessageJump("", ++jumpRevision) + _currentChannel.value?.id?.let(::refreshMessages) + } + private fun sendInteraction(command: JSONObject, userAction: Boolean = true) { if (!command.has("ch")) command.put("ch", _currentChannel.value?.id ?: return) if (!NativeLib.feedbackCommand(command.toString(), if (userAction) ++nextInteractionId else 0)) { @@ -1043,6 +1183,11 @@ class ChatViewModel : ViewModel() { fun selectChannel(channel: Channel) { val r = repo ?: return if (_currentChannel.value?.id != channel.id) { + _historyAnchor.value = "" + _messageJump.value = null + pendingLatestJump = null + _replyOriginals.value = emptyMap() + ++jumpRevision feedbackCache.clear() memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null } @@ -1282,13 +1427,11 @@ class ChatViewModel : ViewModel() { } fun sendMessage(text: String) { - val r = repo ?: return val ch = _currentChannel.value ?: return - r.sendMessage(ch.id, text) + val target = ConversationTarget.Channel(ch.id) + val reply = replyFor(target) viewModelScope.launch { - _messages.value = withContext(Dispatchers.IO) { r.getMessages(ch.id) } - .map { it.copy(feedback = feedbackCache[it.messageId]) } - refreshChannels() + if (sendChannelText(target, text, reply).await()) finishReply(reply) } } @@ -1327,15 +1470,27 @@ class ChatViewModel : ViewModel() { private var recordingStopping = false private var recordingTarget: ConversationTarget? = null + private var recordingReply: ChannelReply? = null + private var recordingSubmission: Pair>? = null fun startVoiceRecording(target: ConversationTarget) { if (recordingStopping) { LogManager.addLog("WARN", "VM", "recording is still stopping"); return } - if (!audioRecorder.startRecording(target.nativeId)) { + if (_isRecording.value) { LogManager.addLog("WARN", "VM", "recording already active"); return } + if (target is ConversationTarget.Channel && target.id in _submittingChannels.value) { + LogManager.addLog("WARN", "Reply", "voice start blocked by pending submission ch=${target.id}"); return + } + val reply = replyFor(target) + val request = if (target is ConversationTarget.Channel) ++nextSubmissionId to CompletableDeferred() else null + if (request != null) submissions[request.first] = request.second + if (!audioRecorder.startRecording(target.nativeId, reply?.message?.messageId.orEmpty(), request?.first ?: 0)) { + request?.let { submissions.remove(it.first) } LogManager.addLog("ERROR", "VM", "voice recording failed to start") return } _isRecording.value = true recordingTarget = target + recordingReply = reply + recordingSubmission = request _isRecordingLocked.value = false recordingStartTimeMs = System.currentTimeMillis() _recordingDurationMs.value = 0 @@ -1353,13 +1508,37 @@ class ChatViewModel : ViewModel() { _isRecording.value = false _isRecordingLocked.value = false _recordingDurationMs.value = 0 + val request = recordingSubmission + val reply = recordingReply + if (request != null) { + submissionChannels[request.first] = recordingTarget!!.id + _submittingChannels.value = submissionChannels.values.toSet() + } viewModelScope.launch { + var discarded = false try { val duration = withContext(Dispatchers.IO) { audioRecorder.stopRecording() } LogManager.addLog("INFO", "VM", "voice detached target=${recordingTarget?.nativeId} duration=$duration") + if (request != null && duration < AudioRecorderManager.MIN_DURATION_MS) { + discarded = duration >= 0 + submissions.remove(request.first) + submissionChannels.remove(request.first) + _submittingChannels.value = submissionChannels.values.toSet() + request.second.complete(false) + if (duration < 0) _messageSendFailed.value = true + } } finally { recordingStopping = false recordingTarget = null + recordingSubmission = null + recordingReply = null + } + if (request != null) { + if (request.second.await()) finishReply(reply) + else if (!discarded) { + _messageSendFailed.value = true + LogManager.addLog("ERROR", "Reply", "voice submission failed request=${request.first}") + } } } } @@ -1370,6 +1549,9 @@ class ChatViewModel : ViewModel() { _isRecording.value = false _isRecordingLocked.value = false _recordingDurationMs.value = 0 + recordingSubmission?.let { submissions.remove(it.first); it.second.complete(false) } + recordingSubmission = null + recordingReply = null viewModelScope.launch { try { withContext(Dispatchers.IO) { audioRecorder.cancelRecording() } } finally { recordingStopping = false; recordingTarget = null } @@ -1381,89 +1563,76 @@ class ChatViewModel : ViewModel() { stopVoiceRecording() } - fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name) { - val r = repo ?: throw IllegalStateException(AppLanguage.text(R.string.chat_service_stopped)) + fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name, + reply: ChannelReply? = replyFor(target)): Deferred = viewModelScope.async { if (target is ConversationTarget.Pm) { - if (!NativeLib.dmSendFile(target.id, filePath, name)) throw IllegalStateException(AppLanguage.text(R.string.attachment_send_failed)) - return + val accepted = NativeLib.dmSendFile(target.id, filePath, name) + if (!accepted) { LogManager.addLog("ERROR", "VM", "PM attachment rejected"); _messageSendFailed.value = true } + return@async accepted } val chId = target.id val ext = filePath.substringAfterLast('.', "").lowercase() - if (ext in IMAGE_EXTENSIONS) { - viewModelScope.launch { - val dims = withContext(Dispatchers.IO) { - val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(filePath, opts) - opts.outWidth to opts.outHeight - } - val (w, h) = dims - if (w > 0 && h > 0) { - LogManager.addLog("INFO", "VM", "sendAttachment(image) ch=$chId ext=$ext ${w}x${h}") - r.sendPhoto(chId, filePath, w, h) - } else { - LogManager.addLog("WARN", "VM", "image decode failed, sent as file: $filePath") - r.sendAttachment(chId, filePath) - } - delay(500) - refreshMessages(chId) - refreshChannels() - } - } else { - r.sendAttachment(chId, filePath) - viewModelScope.launch { - delay(500) - refreshMessages(chId) - refreshChannels() - } + val dims = if (ext in IMAGE_EXTENSIONS) withContext(Dispatchers.IO) { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(filePath, opts) + opts.outWidth to opts.outHeight + } else 0 to 0 + submitChannel(chId, reply) { request -> + if (dims.first > 0 && dims.second > 0) + NativeLib.photoSend(chId, filePath, dims.first, dims.second, reply?.message?.messageId.orEmpty(), request) + else NativeLib.attachmentSend(chId, filePath, reply?.message?.messageId.orEmpty(), request) } } /** Cache-файл передаётся native только после успешного чтения метаданных. */ - fun sendVideoMessage(filePath: String, target: ConversationTarget) { + fun sendVideoMessage(filePath: String, target: ConversationTarget, reply: ChannelReply? = replyFor(target), + clearReply: Boolean = true): Deferred = viewModelScope.async { val job = java.util.UUID.randomUUID().toString() preparationState(target.nativeId, job, 0) - viewModelScope.launch { - var accepted = false - try { - val r = repo ?: throw IllegalStateException(AppLanguage.text(R.string.chat_service_stopped)) - val meta = withContext(Dispatchers.IO) { - val mmr = MediaMetadataRetriever() - try { - mmr.setDataSource(filePath) - if (mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) != "video/mp4") - throw IllegalStateException(AppLanguage.text(R.string.video_mp4_only)) - Triple( - mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0, - mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0, - mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0 - ) - } finally { mmr.release() } - } - accepted = r.sendVideo(target.nativeId, filePath, meta.first, meta.second, meta.third) - if (!accepted) throw IllegalStateException(AppLanguage.text(R.string.video_invalid_or_stopped)) - LogManager.addLog("INFO", "VM", "video queued target=${target.nativeId} dur=${meta.first} ${meta.second}x${meta.third}") - } catch (e: kotlinx.coroutines.CancellationException) { throw e } - catch (e: Exception) { LogManager.addLog("ERROR", "VM", "video failed target=${target.nativeId}: ${e.message}") } - finally { - preparationState(target.nativeId, job, if (accepted) 1 else 2) - if (!accepted) withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { - if (!File(filePath).delete() && File(filePath).exists()) - LogManager.addLog("ERROR", "VM", "video cache cleanup failed: $filePath") - } + var accepted = false + var saved = false + try { + val meta = withContext(Dispatchers.IO) { + val mmr = MediaMetadataRetriever() + try { + mmr.setDataSource(filePath) + if (mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) != "video/mp4") + throw IllegalStateException(AppLanguage.text(R.string.video_mp4_only)) + Triple( + mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0, + mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0, + mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0 + ) + } finally { mmr.release() } + } + val queue: (Long) -> Boolean = { request -> + NativeLib.videoSend(target.nativeId, filePath, meta.first, meta.second, meta.third, + reply?.message?.messageId.orEmpty(), request).also { accepted = it } + } + saved = if (target is ConversationTarget.Channel) submitChannel(target.id, reply, queue) else queue(0) + if (!saved) _messageSendFailed.value = true + if (saved && clearReply) finishReply(reply) + } catch (e: kotlinx.coroutines.CancellationException) { throw e } + catch (e: Exception) { + LogManager.addLog("ERROR", "VM", "video failed target=${target.nativeId}: ${e.message}") + _messageSendFailed.value = true + } finally { + preparationState(target.nativeId, job, if (saved) 1 else 2) + if (!accepted) withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { + if (!File(filePath).delete() && File(filePath).exists()) + LogManager.addLog("ERROR", "VM", "video cache cleanup failed: $filePath") } } + saved } - fun sendPhotoMessage(filePath: String, width: Int, height: Int) { - val r = repo ?: return - val ch = _currentChannel.value ?: return - LogManager.addLog("INFO", "VM", "sendPhotoMessage ch=${ch.id} ${width}x${height}") - r.sendPhoto(ch.id, filePath, width, height) - viewModelScope.launch { - delay(500) - refreshMessages(ch.id) - refreshChannels() + fun sendPhotoMessage(filePath: String, width: Int, height: Int, channel: String, + reply: ChannelReply?): Deferred = viewModelScope.async { + val saved = submitChannel(channel, reply) { + NativeLib.photoSend(channel, filePath, width, height, reply?.message?.messageId.orEmpty(), it) } + if (saved) finishReply(reply) + saved } fun downloadAttachment(msg: Message) {