diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileMetadata.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileMetadata.kt index eb113c3d..d7b78209 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileMetadata.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/VideoFileMetadata.kt @@ -14,6 +14,7 @@ internal data class VideoInfo(val width: Int, val height: Int, val rotation: Int val aspect: Float get() = if (displayHeight > 0) displayWidth.toFloat() / displayHeight else 0f } internal data class VideoFilePreview(val bitmap: Bitmap?, val durationMs: Int) +internal data class VideoFileSubmission(val durationMs: Int, val width: Int, val height: Int) /** Один IO caller читает metadata внутри use; после завершения чтения допускается только повтор close. * Неудавшийся release удерживает retriever до SDK ACK, retry не обращается к UI/native core. */ @@ -38,6 +39,15 @@ internal class VideoFileMetadata : AutoCloseable { require(width > 0 && height > 0 && rotation in listOf(0, 90, 180, 270)) { "Invalid video geometry ${width}x$height rotation=$rotation" } return VideoInfo(width, height, rotation) } + /** IO: готовый MP4, длительность 1..86400000 мс и coded размеры; caller передаёт исходник без повторного транскода. */ + @Synchronized fun submission(path: String): VideoFileSubmission { + val info = read(path) + val current = checkNotNull(retriever) + require(current.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) == "video/mp4") { "Expected MP4 video" } + val duration = current.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0 + require(duration in 1..86_400_000 && info.width <= 16384 && info.height <= 16384) { "Invalid MP4 metadata durationMs=$duration" } + return VideoFileSubmission(duration, info.width, info.height) + } /** IO: thumbnail не больше 640 по каждой стороне; Bitmap принадлежит caller, duration в мс. * API 26 сначала извлекает полный кадр и уменьшает до передачи UI; null bitmap не запрещает воспроизведение. */ @Synchronized fun preview(path: String): VideoFilePreview { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 5b9d5870..13881390 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -3,9 +3,7 @@ package com.utun.chat.viewmodel import com.utun.chat.R import com.utun.chat.data.AppLanguage import android.content.Context -import android.media.MediaMetadataRetriever import android.net.Uri -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -1555,11 +1553,11 @@ class ChatViewModel : ViewModel() { LogManager.addLog("WARN", "Reply", "voice start blocked by pending submission ch=${target.id}"); return } val reply = replyFor(target) - val request = if (target is ConversationTarget.Channel) ++nextSubmissionId to CompletableDeferred() else null - if (request != null) submissions[request.first] = request.second + val request = ++nextSubmissionId to CompletableDeferred() + submissions[request.first] = request.second if (!audioRecorder.startRecording(target.nativeId, reply?.message?.messageId.orEmpty(), request?.first ?: 0, (target as? ConversationTarget.Pm)?.generation ?: 0)) { - request?.let { submissions.remove(it.first) } + submissions.remove(request.first) LogManager.addLog("ERROR", "VM", "voice recording failed to start") return } @@ -1589,7 +1587,7 @@ class ChatViewModel : ViewModel() { val request = recordingSubmission val reply = recordingReply if (request != null) { - submissionChannels[request.first] = recordingTarget!!.id + submissionChannels[request.first] = recordingTarget!!.nativeId _submittingChannels.value = submissionChannels.values.toSet() } viewModelScope.launch { @@ -1605,6 +1603,11 @@ class ChatViewModel : ViewModel() { request.second.complete(false) if (duration < 0) _messageSendFailed.value = true } + } catch (e: kotlinx.coroutines.CancellationException) { throw e } + catch (e: Exception) { + LogManager.addLog("ERROR", "Voice", "Recording stop failed target=${target?.nativeId}: $e") + request?.let { submissionResult(it.first, false) } + _messageSendFailed.value = true } finally { recordingStopping = false recordingTarget = null @@ -1632,6 +1635,8 @@ class ChatViewModel : ViewModel() { recordingReply = null viewModelScope.launch { try { withContext(Dispatchers.IO) { audioRecorder.cancelRecording() } } + catch (e: kotlinx.coroutines.CancellationException) { throw e } + catch (e: Exception) { LogManager.addLog("ERROR", "Voice", "Recording cancel failed: $e"); _messageSendFailed.value = true } finally { recordingStopping = false; recordingTarget = null } } LogManager.addLog("INFO", "VM", "voice recording cancelled target=${recordingTarget?.nativeId}") @@ -1646,20 +1651,14 @@ class ChatViewModel : ViewModel() { val ext = filePath.substringAfterLast('.', "").lowercase() if (ext in IMAGE_EXTENSIONS && (target is ConversationTarget.Channel || ext in setOf("jpg", "jpeg", "png"))) return@async sendPhotoMessage(filePath, target, reply, clearReply = false).await() - if (target is ConversationTarget.Pm) { - val current = dmTargetCurrent(target) - val accepted = current && NativeLib.dmSendFile(target.id, filePath, name, target.generation) - if (!accepted) { - withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { - if (!File(filePath).delete() && File(filePath).exists()) - LogManager.addLog("ERROR", "PM", "Rejected attachment cache cleanup failed: $filePath") - } - if (current) { LogManager.addLog("ERROR", "VM", "PM attachment rejected"); _messageSendFailed.value = true } + var accepted = false + try { + submitMessage(target.nativeId, reply) { request -> + (if (target is ConversationTarget.Pm) dmTargetCurrent(target) && NativeLib.dmSendFile(target.id, filePath, name, target.generation, request) + else NativeLib.attachmentSend(target.id, filePath, reply?.message?.messageId.orEmpty(), request)).also { accepted = it } } - return@async accepted - } - submitMessage(target.id, reply) { request -> - NativeLib.attachmentSend(target.id, filePath, reply?.message?.messageId.orEmpty(), request) + } finally { + withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { cleanupAttachmentCache(filePath, !accepted) } } } @@ -1671,26 +1670,14 @@ class ChatViewModel : ViewModel() { 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 meta = withContext(Dispatchers.IO) { VideoFileMetadata().use { it.submission(filePath) } } val queue: (Long) -> Boolean = { request -> if (target is ConversationTarget.Pm && !dmTargetCurrent(target)) { discardedPreparationJobs.add(job); false - } else NativeLib.videoSend(target.nativeId, filePath, meta.first, meta.second, meta.third, + } else NativeLib.videoSend(target.nativeId, filePath, meta.durationMs, meta.width, meta.height, reply?.message?.messageId.orEmpty(), request, (target as? ConversationTarget.Pm)?.generation ?: 0).also { accepted = it } } - saved = if (target is ConversationTarget.Channel) submitMessage(target.id, reply, queue = queue) else queue(0) + saved = submitMessage(target.nativeId, reply, queue = queue) if (!saved && job !in discardedPreparationJobs) _messageSendFailed.value = true if (saved && clearReply) finishReply(reply) } catch (e: kotlinx.coroutines.CancellationException) { throw e } @@ -1699,10 +1686,7 @@ class ChatViewModel : ViewModel() { _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") - } + withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { cleanupAttachmentCache(filePath, !accepted) } } saved } @@ -1723,7 +1707,7 @@ class ChatViewModel : ViewModel() { } else NativeLib.photoSend(target.nativeId, filePath, meta.width, meta.height, meta.mime, reply?.message?.messageId.orEmpty(), request, (target as? ConversationTarget.Pm)?.generation ?: 0).also { accepted = it } } - saved = if (target is ConversationTarget.Channel) submitMessage(target.id, reply, queue = queue) else queue(0) + saved = submitMessage(target.nativeId, reply, queue = queue) completed = true if (!saved) _messageSendFailed.value = true if (saved && clearReply) finishReply(reply) @@ -1734,9 +1718,7 @@ class ChatViewModel : ViewModel() { LogManager.addLog("ERROR", "Image", "Image submission failed target=${target.nativeId}: ${e.message}") _messageSendFailed.value = true } finally { - if (!accepted || (legacy && completed)) withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { - if (!File(filePath).delete() && File(filePath).exists()) LogManager.addLog("ERROR", "Image", "Cache cleanup failed: $filePath") - } + withContext(kotlinx.coroutines.NonCancellable + Dispatchers.IO) { cleanupAttachmentCache(filePath, !accepted || (legacy && completed)) } } saved } @@ -1799,7 +1781,19 @@ class ChatViewModel : ViewModel() { }) } - fun copyUriToCache(context: Context, uri: Uri, fileName: String): String? { + /** IO: удалять только собственный cache; принятый native source остаётся его worker до завершения. */ + private fun cleanupAttachmentCache(filePath: String, deleteSource: Boolean) { + try { + val file = File(filePath) + if (deleteSource && !file.delete() && file.exists()) LogManager.addLog("ERROR", "Attachment", "Cache removal failed path=$filePath") + val parent = file.parentFile + if (parent?.parentFile?.name == "attachments" && parent.list()?.isEmpty() == true && !parent.delete() && parent.exists()) { + LogManager.addLog("ERROR", "Attachment", "Empty cache directory removal failed path=$parent") + } + } catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "Cache cleanup failed path=$filePath: $e") } + } + + suspend fun copyUriToCache(context: Context, uri: Uri, fileName: String): String? { var cacheFile: File? = null try { val name = fileName.substringAfterLast('/').substringAfterLast('\\') @@ -1812,6 +1806,7 @@ class ChatViewModel : ViewModel() { FileOutputStream(outputFile).use { output -> val buffer = ByteArray(65536); var size = 0L while (true) { + kotlinx.coroutines.currentCoroutineContext().ensureActive() val n = input.read(buffer) if (n < 0) break size += n; check(size <= 1024L * 1024 * 1024) { AppLanguage.text(R.string.file_too_large) } @@ -1822,7 +1817,8 @@ class ChatViewModel : ViewModel() { LogManager.addLog("INFO", "VM", "copied attachment to cache: ${outputFile.absolutePath}") return outputFile.absolutePath } catch (e: Exception) { - cacheFile?.delete(); cacheFile?.parentFile?.delete() + cacheFile?.let { cleanupAttachmentCache(it.path, true) } + if (e is kotlinx.coroutines.CancellationException) throw e LogManager.addLog("ERROR", "VM", "copyUriToCache failed: ${e.message}") return null }