|
|
|
|
@ -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) { |
|
|
|
|
|