Browse Source

android: track saved replies and navigate original messages

master
evgeny 4 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 com.utun.chat.data.AvatarRepository
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow 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.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
object AppEventHandler : ChatEventCallback { object AppEventHandler : ChatEventCallback {
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val _events = MutableSharedFlow<Pair<Int, ByteArray?>>(replay = 16, extraBufferCapacity = 64) 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 val events: SharedFlow<Pair<Int, ByteArray?>> = _events
override fun onEvent(type: Int, data: ByteArray?) { 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) { if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) {
mainHandler.post { CallController.onEvent(type, data) } mainHandler.post { CallController.onEvent(type, data) }
} }
_events.tryEmit(type to data) // Подтверждения отправки нельзя терять при заполнении буфера; native-поток не блокируем.
eventScope.launch { _events.emit(type to data) }
} }
// Звук живёт в приложении: работает без Activity и не повторяется при replay событий в UI. // Звук живёт в приложении: работает без 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 PackageManager.PERMISSION_GRANTED
} }
fun startRecording(channelId: String): Boolean { fun startRecording(channelId: String, replyTo: String = "", requestId: Long = 0): Boolean {
if (isRecording) return false if (isRecording) return false
if (!hasPermission()) return false if (!hasPermission()) return false
val ok = NativeLib.voiceStart(channelId) val ok = NativeLib.voiceStart(channelId, replyTo, requestId)
if (!ok) { if (!ok) {
LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed") LogManager.addLog("ERROR", "AudioRecorder", "native voiceStart failed")
return false 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.AppEventHandler
import com.utun.chat.data.* import com.utun.chat.data.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -27,6 +30,97 @@ import org.json.JSONArray
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp") private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "bmp", "webp")
class ChatViewModel : ViewModel() { 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>, 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) val groupId: String = "", val requestId: Long = 0, val pending: Boolean = false, val errorRes: Int = 0)
private val _pmInvite = MutableStateFlow<PmInviteState?>(null) private val _pmInvite = MutableStateFlow<PmInviteState?>(null)
@ -297,6 +391,11 @@ class ChatViewModel : ViewModel() {
_dmConversations.value = _dmConversations.value.map { it.copy(online = false) } _dmConversations.value = _dmConversations.value.map { it.copy(online = false) }
} else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute() } else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute()
when (type) { 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. */ 55 -> { /* RADIO_MUTE_RESULT: group_id:8LE, node_id:8LE, success. */
if (data == null || data.size != 17) { if (data == null || data.size != 17) {
LogManager.addLog("ERROR", "Radio", "Invalid member mute result"); _radioMuteFailed.value = true; return 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") } } 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") 12 -> LogManager.addLog("INFO", "VM", "service started")
13 -> { 13 -> {
for (id in submissions.keys.toList()) submissionResult(id, false)
_radioTalkState.value = RadioTalkState() _radioTalkState.value = RadioTalkState()
_radioTalking.value = null _radioTalking.value = null
_radioActive.value = false _radioActive.value = false
@ -1004,16 +1104,56 @@ class ChatViewModel : ViewModel() {
fun refreshMessages(chId: String) { fun refreshMessages(chId: String) {
val r = repo ?: return val r = repo ?: return
if (_currentChannel.value?.id != chId) return
val anchor = _historyAnchor.value
val revision = ++messageRefreshRevision
viewModelScope.launch { viewModelScope.launch {
val rows = withContext(Dispatchers.IO) { r.getMessages(chId) } val (rows, originals) = withContext(Dispatchers.IO) {
if (_currentChannel.value?.id != chId) return@launch 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]) } _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) if (_reactionCatalog.value.isEmpty()) sendInteraction(JSONObject().put("cmd", "reaction_catalog"), false)
for (row in rows) if (row.messageId.length == 64) for (row in rows) if (row.messageId.length == 64)
sendInteraction(JSONObject().put("cmd", "feedback").put("ch", chId).put("message_id", row.messageId), false) 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) { private fun sendInteraction(command: JSONObject, userAction: Boolean = true) {
if (!command.has("ch")) command.put("ch", _currentChannel.value?.id ?: return) if (!command.has("ch")) command.put("ch", _currentChannel.value?.id ?: return)
if (!NativeLib.feedbackCommand(command.toString(), if (userAction) ++nextInteractionId else 0)) { if (!NativeLib.feedbackCommand(command.toString(), if (userAction) ++nextInteractionId else 0)) {
@ -1043,6 +1183,11 @@ class ChatViewModel : ViewModel() {
fun selectChannel(channel: Channel) { fun selectChannel(channel: Channel) {
val r = repo ?: return val r = repo ?: return
if (_currentChannel.value?.id != channel.id) { if (_currentChannel.value?.id != channel.id) {
_historyAnchor.value = ""
_messageJump.value = null
pendingLatestJump = null
_replyOriginals.value = emptyMap()
++jumpRevision
feedbackCache.clear() feedbackCache.clear()
memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null memberDetailJob?.cancel(); memberDetailTarget = null; _selectedMember.value = null
} }
@ -1282,13 +1427,11 @@ class ChatViewModel : ViewModel() {
} }
fun sendMessage(text: String) { fun sendMessage(text: String) {
val r = repo ?: return
val ch = _currentChannel.value ?: return val ch = _currentChannel.value ?: return
r.sendMessage(ch.id, text) val target = ConversationTarget.Channel(ch.id)
val reply = replyFor(target)
viewModelScope.launch { viewModelScope.launch {
_messages.value = withContext(Dispatchers.IO) { r.getMessages(ch.id) } if (sendChannelText(target, text, reply).await()) finishReply(reply)
.map { it.copy(feedback = feedbackCache[it.messageId]) }
refreshChannels()
} }
} }
@ -1327,15 +1470,27 @@ class ChatViewModel : ViewModel() {
private var recordingStopping = false private var recordingStopping = false
private var recordingTarget: ConversationTarget? = null private var recordingTarget: ConversationTarget? = null
private var recordingReply: ChannelReply? = null
private var recordingSubmission: Pair<Long, CompletableDeferred<Boolean>>? = null
fun startVoiceRecording(target: ConversationTarget) { fun startVoiceRecording(target: ConversationTarget) {
if (recordingStopping) { LogManager.addLog("WARN", "VM", "recording is still stopping"); return } 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") LogManager.addLog("ERROR", "VM", "voice recording failed to start")
return return
} }
_isRecording.value = true _isRecording.value = true
recordingTarget = target recordingTarget = target
recordingReply = reply
recordingSubmission = request
_isRecordingLocked.value = false _isRecordingLocked.value = false
recordingStartTimeMs = System.currentTimeMillis() recordingStartTimeMs = System.currentTimeMillis()
_recordingDurationMs.value = 0 _recordingDurationMs.value = 0
@ -1353,13 +1508,37 @@ class ChatViewModel : ViewModel() {
_isRecording.value = false _isRecording.value = false
_isRecordingLocked.value = false _isRecordingLocked.value = false
_recordingDurationMs.value = 0 _recordingDurationMs.value = 0
val request = recordingSubmission
val reply = recordingReply
if (request != null) {
submissionChannels[request.first] = recordingTarget!!.id
_submittingChannels.value = submissionChannels.values.toSet()
}
viewModelScope.launch { viewModelScope.launch {
var discarded = false
try { try {
val duration = withContext(Dispatchers.IO) { audioRecorder.stopRecording() } val duration = withContext(Dispatchers.IO) { audioRecorder.stopRecording() }
LogManager.addLog("INFO", "VM", "voice detached target=${recordingTarget?.nativeId} duration=$duration") 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 { } finally {
recordingStopping = false recordingStopping = false
recordingTarget = null 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 _isRecording.value = false
_isRecordingLocked.value = false _isRecordingLocked.value = false
_recordingDurationMs.value = 0 _recordingDurationMs.value = 0
recordingSubmission?.let { submissions.remove(it.first); it.second.complete(false) }
recordingSubmission = null
recordingReply = null
viewModelScope.launch { viewModelScope.launch {
try { withContext(Dispatchers.IO) { audioRecorder.cancelRecording() } } try { withContext(Dispatchers.IO) { audioRecorder.cancelRecording() } }
finally { recordingStopping = false; recordingTarget = null } finally { recordingStopping = false; recordingTarget = null }
@ -1381,89 +1563,76 @@ class ChatViewModel : ViewModel() {
stopVoiceRecording() stopVoiceRecording()
} }
fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name) { fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name,
val r = repo ?: throw IllegalStateException(AppLanguage.text(R.string.chat_service_stopped)) reply: ChannelReply? = replyFor(target)): Deferred<Boolean> = viewModelScope.async {
if (target is ConversationTarget.Pm) { if (target is ConversationTarget.Pm) {
if (!NativeLib.dmSendFile(target.id, filePath, name)) throw IllegalStateException(AppLanguage.text(R.string.attachment_send_failed)) val accepted = NativeLib.dmSendFile(target.id, filePath, name)
return if (!accepted) { LogManager.addLog("ERROR", "VM", "PM attachment rejected"); _messageSendFailed.value = true }
return@async accepted
} }
val chId = target.id val chId = target.id
val ext = filePath.substringAfterLast('.', "").lowercase() val ext = filePath.substringAfterLast('.', "").lowercase()
if (ext in IMAGE_EXTENSIONS) { val dims = if (ext in IMAGE_EXTENSIONS) withContext(Dispatchers.IO) {
viewModelScope.launch { val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
val dims = withContext(Dispatchers.IO) { BitmapFactory.decodeFile(filePath, opts)
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } opts.outWidth to opts.outHeight
BitmapFactory.decodeFile(filePath, opts) } else 0 to 0
opts.outWidth to opts.outHeight submitChannel(chId, reply) { request ->
} if (dims.first > 0 && dims.second > 0)
val (w, h) = dims NativeLib.photoSend(chId, filePath, dims.first, dims.second, reply?.message?.messageId.orEmpty(), request)
if (w > 0 && h > 0) { else NativeLib.attachmentSend(chId, filePath, reply?.message?.messageId.orEmpty(), request)
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()
}
} }
} }
/** Cache-файл передаётся native только после успешного чтения метаданных. */ /** 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() val job = java.util.UUID.randomUUID().toString()
preparationState(target.nativeId, job, 0) preparationState(target.nativeId, job, 0)
viewModelScope.launch { var accepted = false
var accepted = false var saved = false
try { try {
val r = repo ?: throw IllegalStateException(AppLanguage.text(R.string.chat_service_stopped)) val meta = withContext(Dispatchers.IO) {
val meta = withContext(Dispatchers.IO) { val mmr = MediaMetadataRetriever()
val mmr = MediaMetadataRetriever() try {
try { mmr.setDataSource(filePath)
mmr.setDataSource(filePath) if (mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) != "video/mp4")
if (mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) != "video/mp4") throw IllegalStateException(AppLanguage.text(R.string.video_mp4_only))
throw IllegalStateException(AppLanguage.text(R.string.video_mp4_only)) Triple(
Triple( mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0,
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0, mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0,
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0, mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() ?: 0 )
) } finally { mmr.release() }
} finally { mmr.release() } }
} val queue: (Long) -> Boolean = { request ->
accepted = r.sendVideo(target.nativeId, filePath, meta.first, meta.second, meta.third) NativeLib.videoSend(target.nativeId, filePath, meta.first, meta.second, meta.third,
if (!accepted) throw IllegalStateException(AppLanguage.text(R.string.video_invalid_or_stopped)) reply?.message?.messageId.orEmpty(), request).also { accepted = it }
LogManager.addLog("INFO", "VM", "video queued target=${target.nativeId} dur=${meta.first} ${meta.second}x${meta.third}") }
} catch (e: kotlinx.coroutines.CancellationException) { throw e } saved = if (target is ConversationTarget.Channel) submitChannel(target.id, reply, queue) else queue(0)
catch (e: Exception) { LogManager.addLog("ERROR", "VM", "video failed target=${target.nativeId}: ${e.message}") } if (!saved) _messageSendFailed.value = true
finally { if (saved && clearReply) finishReply(reply)
preparationState(target.nativeId, job, if (accepted) 1 else 2) } catch (e: kotlinx.coroutines.CancellationException) { throw e }
if (!accepted) withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { catch (e: Exception) {
if (!File(filePath).delete() && File(filePath).exists()) LogManager.addLog("ERROR", "VM", "video failed target=${target.nativeId}: ${e.message}")
LogManager.addLog("ERROR", "VM", "video cache cleanup failed: $filePath") _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) { fun sendPhotoMessage(filePath: String, width: Int, height: Int, channel: String,
val r = repo ?: return reply: ChannelReply?): Deferred<Boolean> = viewModelScope.async {
val ch = _currentChannel.value ?: return val saved = submitChannel(channel, reply) {
LogManager.addLog("INFO", "VM", "sendPhotoMessage ch=${ch.id} ${width}x${height}") NativeLib.photoSend(channel, filePath, width, height, reply?.message?.messageId.orEmpty(), it)
r.sendPhoto(ch.id, filePath, width, height)
viewModelScope.launch {
delay(500)
refreshMessages(ch.id)
refreshChannels()
} }
if (saved) finishReply(reply)
saved
} }
fun downloadAttachment(msg: Message) { fun downloadAttachment(msg: Message) {

Loading…
Cancel
Save