Wait for committed PM media and retain native ownership of accepted cache files

This commit is contained in:
evgeny committed 2026-10-29 13:34:26 +01:00
1 parent 2dbc45e63d
commit dad127d770
2 files changed
+50 -44

No files matched your search

@@ -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 {
@@ -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<Boolean>() else null
if (request != null) submissions[request.first] = request.second
val request = ++nextSubmissionId to CompletableDeferred<Boolean>()
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
}