Wait for committed PM media and retain native ownership of accepted cache files
This commit is contained in:
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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user