android: track saved replies and navigate original messages
This commit is contained in:
1 parent
22e3c1b0a5
commit
d28b7062d2
3 files changed
+254
-79
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user