Browse Source

android: track saved replies and navigate original messages

master
evgeny 3 days ago
parent
commit
d28b7062d2
  1. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  2. 4
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AudioRecorderManager.kt
  3. 321
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

8
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<Pair<Int, ByteArray?>>(replay = 16, extraBufferCapacity = 64)
private val eventScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
val events: SharedFlow<Pair<Int, ByteArray?>> = _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.

4
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

321
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<Map<String, ChannelReply>>(emptyMap())
val replies: StateFlow<Map<String, ChannelReply>> = _replies
private var replyRevision = 0L
private var nextSubmissionId = 0L
private val submissions = mutableMapOf<Long, CompletableDeferred<Boolean>>()
private val submissionChannels = mutableMapOf<Long, String>()
private val _submittingChannels = MutableStateFlow<Set<String>>(emptySet())
val submittingChannels: StateFlow<Set<String>> = _submittingChannels
private val _messageSendFailed = MutableStateFlow(false)
val messageSendFailed: StateFlow<Boolean> = _messageSendFailed
private val _replyOriginals = MutableStateFlow<Map<String, Message>>(emptyMap())
val replyOriginals: StateFlow<Map<String, Message>> = _replyOriginals
private val _historyAnchor = MutableStateFlow("")
val historyAnchor: StateFlow<String> = _historyAnchor
data class MessageJump(val messageId: String, val serial: Long)
private val _messageJump = MutableStateFlow<MessageJump?>(null)
val messageJump: StateFlow<MessageJump?> = _messageJump
private var messageRefreshRevision = 0L
private var jumpRevision = 0L
private var pendingLatestJump: MessageJump? = null
private val _originalMissing = MutableStateFlow(false)
val originalMissing: StateFlow<Boolean> = _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<Boolean>()
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<Boolean> = 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<Channel>,
val groupId: String = "", val requestId: Long = 0, val pending: Boolean = false, val errorRes: Int = 0)
private val _pmInvite = MutableStateFlow<PmInviteState?>(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<Long, CompletableDeferred<Boolean>>? = 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<Boolean>() 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<Boolean> = 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<Boolean> = 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<Boolean> = 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) {

Loading…
Cancel
Save