mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Fixes Applied
Code reuse - Extracted remuxTracks() helper to deduplicate video/audio remux loop (~40 lines) - Extracted stripAfterCompression() to deduplicate identical blocks in upload()/uploadEncrypted() - Replaced 8 identical showStrippingFailureDialog() copy-pastes with shared SuspendableConfirmation utility Privacy - 4 metadata ViewModels now error on strip failure instead of silently uploading unstripped media Efficiency - Eliminated temp input file copy for video/audio — MediaExtractor reads URIs directly Bug fixes - Fixed Long.toInt() overflow for MP3 files >2GB - Fixed MP3 temp file leak on exception and null InputStream paths Cleanup - Mutex field and wrapping the suspendCancellableCoroutine in mutex.withLock. Concurrent callers now queued - Converted MetadataStripper from class to object (stateless) - Moved StrippingFailureState from service layer to UI as generic ConfirmationCallbacks
This commit is contained in:
+167
-193
@@ -32,68 +32,67 @@ import androidx.exifinterface.media.ExifInterface
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
data class StrippingResult(
|
||||
val uri: Uri,
|
||||
val stripped: Boolean,
|
||||
)
|
||||
|
||||
class MetadataStripper {
|
||||
companion object {
|
||||
private const val REMUX_BUFFER_SIZE = 8 * 1024 * 1024
|
||||
object MetadataStripper {
|
||||
private const val DEFAULT_REMUX_BUFFER_SIZE = 8 * 1024 * 1024
|
||||
|
||||
private val SENSITIVE_EXIF_TAGS =
|
||||
arrayOf(
|
||||
ExifInterface.TAG_GPS_LATITUDE,
|
||||
ExifInterface.TAG_GPS_LATITUDE_REF,
|
||||
ExifInterface.TAG_GPS_LONGITUDE,
|
||||
ExifInterface.TAG_GPS_LONGITUDE_REF,
|
||||
ExifInterface.TAG_GPS_ALTITUDE,
|
||||
ExifInterface.TAG_GPS_ALTITUDE_REF,
|
||||
ExifInterface.TAG_GPS_TIMESTAMP,
|
||||
ExifInterface.TAG_GPS_DATESTAMP,
|
||||
ExifInterface.TAG_GPS_PROCESSING_METHOD,
|
||||
ExifInterface.TAG_GPS_AREA_INFORMATION,
|
||||
ExifInterface.TAG_GPS_SPEED,
|
||||
ExifInterface.TAG_GPS_SPEED_REF,
|
||||
ExifInterface.TAG_GPS_TRACK,
|
||||
ExifInterface.TAG_GPS_TRACK_REF,
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION,
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION_REF,
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE,
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE_REF,
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE,
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF,
|
||||
ExifInterface.TAG_GPS_DEST_BEARING,
|
||||
ExifInterface.TAG_GPS_DEST_BEARING_REF,
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE,
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE_REF,
|
||||
ExifInterface.TAG_GPS_MAP_DATUM,
|
||||
ExifInterface.TAG_GPS_DOP,
|
||||
ExifInterface.TAG_GPS_MEASURE_MODE,
|
||||
ExifInterface.TAG_GPS_SATELLITES,
|
||||
ExifInterface.TAG_GPS_STATUS,
|
||||
ExifInterface.TAG_GPS_VERSION_ID,
|
||||
ExifInterface.TAG_MAKE,
|
||||
ExifInterface.TAG_MODEL,
|
||||
ExifInterface.TAG_SOFTWARE,
|
||||
ExifInterface.TAG_ARTIST,
|
||||
ExifInterface.TAG_COPYRIGHT,
|
||||
ExifInterface.TAG_CAMERA_OWNER_NAME,
|
||||
ExifInterface.TAG_BODY_SERIAL_NUMBER,
|
||||
ExifInterface.TAG_LENS_SERIAL_NUMBER,
|
||||
ExifInterface.TAG_LENS_MAKE,
|
||||
ExifInterface.TAG_LENS_MODEL,
|
||||
ExifInterface.TAG_DATETIME,
|
||||
ExifInterface.TAG_DATETIME_ORIGINAL,
|
||||
ExifInterface.TAG_DATETIME_DIGITIZED,
|
||||
ExifInterface.TAG_OFFSET_TIME,
|
||||
ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
|
||||
ExifInterface.TAG_OFFSET_TIME_DIGITIZED,
|
||||
ExifInterface.TAG_IMAGE_UNIQUE_ID,
|
||||
ExifInterface.TAG_USER_COMMENT,
|
||||
)
|
||||
}
|
||||
private val SENSITIVE_EXIF_TAGS =
|
||||
arrayOf(
|
||||
ExifInterface.TAG_GPS_LATITUDE,
|
||||
ExifInterface.TAG_GPS_LATITUDE_REF,
|
||||
ExifInterface.TAG_GPS_LONGITUDE,
|
||||
ExifInterface.TAG_GPS_LONGITUDE_REF,
|
||||
ExifInterface.TAG_GPS_ALTITUDE,
|
||||
ExifInterface.TAG_GPS_ALTITUDE_REF,
|
||||
ExifInterface.TAG_GPS_TIMESTAMP,
|
||||
ExifInterface.TAG_GPS_DATESTAMP,
|
||||
ExifInterface.TAG_GPS_PROCESSING_METHOD,
|
||||
ExifInterface.TAG_GPS_AREA_INFORMATION,
|
||||
ExifInterface.TAG_GPS_SPEED,
|
||||
ExifInterface.TAG_GPS_SPEED_REF,
|
||||
ExifInterface.TAG_GPS_TRACK,
|
||||
ExifInterface.TAG_GPS_TRACK_REF,
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION,
|
||||
ExifInterface.TAG_GPS_IMG_DIRECTION_REF,
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE,
|
||||
ExifInterface.TAG_GPS_DEST_LATITUDE_REF,
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE,
|
||||
ExifInterface.TAG_GPS_DEST_LONGITUDE_REF,
|
||||
ExifInterface.TAG_GPS_DEST_BEARING,
|
||||
ExifInterface.TAG_GPS_DEST_BEARING_REF,
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE,
|
||||
ExifInterface.TAG_GPS_DEST_DISTANCE_REF,
|
||||
ExifInterface.TAG_GPS_MAP_DATUM,
|
||||
ExifInterface.TAG_GPS_DOP,
|
||||
ExifInterface.TAG_GPS_MEASURE_MODE,
|
||||
ExifInterface.TAG_GPS_SATELLITES,
|
||||
ExifInterface.TAG_GPS_STATUS,
|
||||
ExifInterface.TAG_GPS_VERSION_ID,
|
||||
ExifInterface.TAG_MAKE,
|
||||
ExifInterface.TAG_MODEL,
|
||||
ExifInterface.TAG_SOFTWARE,
|
||||
ExifInterface.TAG_ARTIST,
|
||||
ExifInterface.TAG_COPYRIGHT,
|
||||
ExifInterface.TAG_CAMERA_OWNER_NAME,
|
||||
ExifInterface.TAG_BODY_SERIAL_NUMBER,
|
||||
ExifInterface.TAG_LENS_SERIAL_NUMBER,
|
||||
ExifInterface.TAG_LENS_MAKE,
|
||||
ExifInterface.TAG_LENS_MODEL,
|
||||
ExifInterface.TAG_DATETIME,
|
||||
ExifInterface.TAG_DATETIME_ORIGINAL,
|
||||
ExifInterface.TAG_DATETIME_DIGITIZED,
|
||||
ExifInterface.TAG_OFFSET_TIME,
|
||||
ExifInterface.TAG_OFFSET_TIME_ORIGINAL,
|
||||
ExifInterface.TAG_OFFSET_TIME_DIGITIZED,
|
||||
ExifInterface.TAG_IMAGE_UNIQUE_ID,
|
||||
ExifInterface.TAG_USER_COMMENT,
|
||||
)
|
||||
|
||||
private fun extractorToCodecFlags(sampleFlags: Int): Int {
|
||||
var flags = 0
|
||||
@@ -106,6 +105,77 @@ class MetadataStripper {
|
||||
return flags
|
||||
}
|
||||
|
||||
private fun remuxTracks(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
outputFile: File,
|
||||
preStart: (MediaMuxer, MediaExtractor, Context, Uri) -> Unit = { _, _, _, _ -> },
|
||||
): Boolean {
|
||||
val extractor = MediaExtractor()
|
||||
var muxer: MediaMuxer? = null
|
||||
var muxerStarted = false
|
||||
var succeeded = false
|
||||
try {
|
||||
extractor.setDataSource(context, uri, null)
|
||||
|
||||
if (extractor.trackCount == 0) return false
|
||||
|
||||
// Note: MediaMuxer may still write a creation timestamp and encoder info into
|
||||
// the new container. This is not controllable via the Android API and is a
|
||||
// known residual privacy limitation of the remux approach.
|
||||
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
val trackIndexMap = mutableMapOf<Int, Int>()
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
trackIndexMap[i] = muxer.addTrack(format)
|
||||
extractor.selectTrack(i)
|
||||
}
|
||||
|
||||
preStart(muxer, extractor, context, uri)
|
||||
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
|
||||
// Size buffer to the largest track's KEY_MAX_INPUT_SIZE (covers 4K keyframes),
|
||||
// falling back to 8MB if the format doesn't report it.
|
||||
var maxInputSize = DEFAULT_REMUX_BUFFER_SIZE
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val fmt = extractor.getTrackFormat(i)
|
||||
if (fmt.containsKey(MediaFormat.KEY_MAX_INPUT_SIZE)) {
|
||||
maxInputSize = maxOf(maxInputSize, fmt.getInteger(MediaFormat.KEY_MAX_INPUT_SIZE))
|
||||
}
|
||||
}
|
||||
val buffer = ByteBuffer.allocateDirect(maxInputSize)
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
|
||||
while (true) {
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) break
|
||||
|
||||
val outputTrack = trackIndexMap[extractor.sampleTrackIndex] ?: break
|
||||
|
||||
bufferInfo.offset = 0
|
||||
bufferInfo.size = sampleSize
|
||||
bufferInfo.presentationTimeUs = extractor.sampleTime
|
||||
bufferInfo.flags = extractorToCodecFlags(extractor.sampleFlags)
|
||||
|
||||
muxer.writeSampleData(outputTrack, buffer, bufferInfo)
|
||||
extractor.advance()
|
||||
}
|
||||
|
||||
muxer.stop()
|
||||
muxerStarted = false
|
||||
succeeded = true
|
||||
} finally {
|
||||
if (muxerStarted) runCatching { muxer?.stop() }
|
||||
muxer?.release()
|
||||
extractor.release()
|
||||
if (!succeeded) outputFile.delete()
|
||||
}
|
||||
return succeeded
|
||||
}
|
||||
|
||||
fun stripImageMetadata(
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
@@ -159,81 +229,26 @@ class MetadataStripper {
|
||||
context: Context,
|
||||
): StrippingResult {
|
||||
return try {
|
||||
val tempInputFile = File.createTempFile("video_input_", ".mp4", context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tempInputFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return StrippingResult(uri, false)
|
||||
|
||||
val tempOutputFile = File.createTempFile("stripped_video_", ".mp4", context.cacheDir)
|
||||
|
||||
val extractor = MediaExtractor()
|
||||
var succeeded = false
|
||||
var muxer: MediaMuxer? = null
|
||||
var muxerStarted = false
|
||||
try {
|
||||
extractor.setDataSource(tempInputFile.absolutePath)
|
||||
|
||||
if (extractor.trackCount == 0) return StrippingResult(uri, false)
|
||||
|
||||
// Note: MediaMuxer may still write a creation timestamp and encoder info into
|
||||
// the new container. This is not controllable via the Android API and is a
|
||||
// known residual privacy limitation of the remux approach.
|
||||
muxer = MediaMuxer(tempOutputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
val trackIndexMap = mutableMapOf<Int, Int>()
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val format = extractor.getTrackFormat(i)
|
||||
trackIndexMap[i] = muxer.addTrack(format)
|
||||
extractor.selectTrack(i)
|
||||
val succeeded =
|
||||
remuxTracks(uri, context, tempOutputFile) { muxer, _, ctx, sourceUri ->
|
||||
// Rotation is a container-level property not included in track formats;
|
||||
// read it explicitly and reapply so the output plays back with the correct orientation.
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(ctx, sourceUri)
|
||||
val rotation =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull() ?: 0
|
||||
if (rotation != 0) muxer.setOrientationHint(rotation)
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation is a container-level property not included in track formats;
|
||||
// read it explicitly and reapply so the output plays back with the correct orientation.
|
||||
val retriever = MediaMetadataRetriever()
|
||||
try {
|
||||
retriever.setDataSource(tempInputFile.absolutePath)
|
||||
val rotation =
|
||||
retriever
|
||||
.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull() ?: 0
|
||||
if (rotation != 0) muxer.setOrientationHint(rotation)
|
||||
} finally {
|
||||
retriever.release()
|
||||
}
|
||||
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
|
||||
val buffer = java.nio.ByteBuffer.allocate(REMUX_BUFFER_SIZE)
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
|
||||
while (true) {
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) break
|
||||
|
||||
val outputTrack = trackIndexMap[extractor.sampleTrackIndex] ?: break
|
||||
|
||||
bufferInfo.offset = 0
|
||||
bufferInfo.size = sampleSize
|
||||
bufferInfo.presentationTimeUs = extractor.sampleTime
|
||||
bufferInfo.flags = extractorToCodecFlags(extractor.sampleFlags)
|
||||
|
||||
muxer.writeSampleData(outputTrack, buffer, bufferInfo)
|
||||
extractor.advance()
|
||||
}
|
||||
|
||||
muxer.stop()
|
||||
muxerStarted = false
|
||||
succeeded = true
|
||||
} finally {
|
||||
if (muxerStarted) runCatching { muxer?.stop() }
|
||||
muxer?.release()
|
||||
extractor.release()
|
||||
tempInputFile.delete()
|
||||
if (!succeeded) tempOutputFile.delete()
|
||||
}
|
||||
if (!succeeded) return StrippingResult(uri, false)
|
||||
|
||||
Log.d("MetadataStripper", "Stripped metadata from video")
|
||||
StrippingResult(tempOutputFile.toUri(), true)
|
||||
@@ -249,75 +264,27 @@ class MetadataStripper {
|
||||
context: Context,
|
||||
): StrippingResult {
|
||||
return try {
|
||||
val tempInputFile = File.createTempFile("audio_input_", ".tmp", context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tempInputFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return StrippingResult(uri, false)
|
||||
|
||||
// Verify the primary track is AAC/MP4A before remuxing
|
||||
val extractor = MediaExtractor()
|
||||
var muxer: MediaMuxer? = null
|
||||
var muxerStarted = false
|
||||
var succeeded = false
|
||||
var tempOutputFile: File? = null
|
||||
try {
|
||||
extractor.setDataSource(tempInputFile.absolutePath)
|
||||
|
||||
extractor.setDataSource(context, uri, null)
|
||||
if (extractor.trackCount == 0) return StrippingResult(uri, false)
|
||||
|
||||
val primaryFormat = extractor.getTrackFormat(0)
|
||||
val primaryMime = primaryFormat.getString(MediaFormat.KEY_MIME) ?: ""
|
||||
val primaryMime = extractor.getTrackFormat(0).getString(MediaFormat.KEY_MIME) ?: ""
|
||||
if (!primaryMime.contains("mp4a") && !primaryMime.contains("aac")) {
|
||||
return StrippingResult(uri, false)
|
||||
}
|
||||
|
||||
val extension = ".m4a"
|
||||
|
||||
tempOutputFile = File.createTempFile("stripped_audio_", extension, context.cacheDir)
|
||||
muxer = MediaMuxer(tempOutputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
val trackIndexMap = mutableMapOf<Int, Int>()
|
||||
for (i in 0 until extractor.trackCount) {
|
||||
val trackFormat = extractor.getTrackFormat(i)
|
||||
trackIndexMap[i] = muxer.addTrack(trackFormat)
|
||||
extractor.selectTrack(i)
|
||||
}
|
||||
|
||||
muxer.start()
|
||||
muxerStarted = true
|
||||
|
||||
val buffer = java.nio.ByteBuffer.allocate(REMUX_BUFFER_SIZE)
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
|
||||
while (true) {
|
||||
val sampleSize = extractor.readSampleData(buffer, 0)
|
||||
if (sampleSize < 0) break
|
||||
|
||||
val outputTrack = trackIndexMap[extractor.sampleTrackIndex] ?: break
|
||||
|
||||
bufferInfo.offset = 0
|
||||
bufferInfo.size = sampleSize
|
||||
bufferInfo.presentationTimeUs = extractor.sampleTime
|
||||
bufferInfo.flags = extractorToCodecFlags(extractor.sampleFlags)
|
||||
|
||||
muxer.writeSampleData(outputTrack, buffer, bufferInfo)
|
||||
extractor.advance()
|
||||
}
|
||||
|
||||
muxer.stop()
|
||||
muxerStarted = false
|
||||
succeeded = true
|
||||
} finally {
|
||||
if (muxerStarted) runCatching { muxer?.stop() }
|
||||
muxer?.release()
|
||||
extractor.release()
|
||||
tempInputFile.delete()
|
||||
if (!succeeded) tempOutputFile?.delete()
|
||||
}
|
||||
|
||||
val tempOutputFile = File.createTempFile("stripped_audio_", ".m4a", context.cacheDir)
|
||||
|
||||
val succeeded = remuxTracks(uri, context, tempOutputFile)
|
||||
|
||||
if (!succeeded) return StrippingResult(uri, false)
|
||||
|
||||
Log.d("MetadataStripper", "Stripped metadata from audio")
|
||||
StrippingResult(tempOutputFile!!.toUri(), true)
|
||||
StrippingResult(tempOutputFile.toUri(), true)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.d("MetadataStripper", "Failed to strip audio metadata: ${e.message}")
|
||||
@@ -329,16 +296,20 @@ class MetadataStripper {
|
||||
uri: Uri,
|
||||
context: Context,
|
||||
): StrippingResult {
|
||||
var tempInputFile: File? = null
|
||||
return try {
|
||||
val tempInputFile = File.createTempFile("mp3_input_", ".mp3", context.cacheDir)
|
||||
tempInputFile = File.createTempFile("mp3_input_", ".mp3", context.cacheDir)
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tempInputFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: return StrippingResult(uri, false)
|
||||
} ?: run {
|
||||
tempInputFile.delete()
|
||||
return StrippingResult(uri, false)
|
||||
}
|
||||
|
||||
val fileSize = tempInputFile.length().toInt()
|
||||
var startOffset = 0
|
||||
val fileSize = tempInputFile.length()
|
||||
var startOffset = 0L
|
||||
var endOffset = fileSize
|
||||
|
||||
// Read first 10 bytes to check for ID3v2 header
|
||||
@@ -355,14 +326,14 @@ class MetadataStripper {
|
||||
(header[7].toInt() and 0x7F shl 14) or
|
||||
(header[8].toInt() and 0x7F shl 7) or
|
||||
(header[9].toInt() and 0x7F)
|
||||
startOffset = 10 + size
|
||||
startOffset = 10L + size
|
||||
}
|
||||
|
||||
// Read last 128 bytes to check for ID3v1 tag
|
||||
if (endOffset - startOffset >= 128) {
|
||||
val tail = ByteArray(128)
|
||||
java.io.RandomAccessFile(tempInputFile, "r").use { raf ->
|
||||
raf.seek((endOffset - 128).toLong())
|
||||
raf.seek(endOffset - 128)
|
||||
raf.readFully(tail)
|
||||
}
|
||||
if (tail[0] == 'T'.code.toByte() &&
|
||||
@@ -373,19 +344,20 @@ class MetadataStripper {
|
||||
}
|
||||
}
|
||||
|
||||
if (startOffset == 0 && endOffset == fileSize) {
|
||||
if (startOffset == 0L && endOffset == fileSize) {
|
||||
tempInputFile.delete()
|
||||
tempInputFile = null
|
||||
return StrippingResult(uri, true) // no tags found, already clean
|
||||
}
|
||||
|
||||
val tempOutputFile = File.createTempFile("stripped_mp3_", ".mp3", context.cacheDir)
|
||||
java.io.RandomAccessFile(tempInputFile, "r").use { raf ->
|
||||
raf.seek(startOffset.toLong())
|
||||
raf.seek(startOffset)
|
||||
tempOutputFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
var remaining = endOffset - startOffset
|
||||
while (remaining > 0) {
|
||||
val toRead = minOf(buffer.size, remaining)
|
||||
val toRead = minOf(buffer.size.toLong(), remaining).toInt()
|
||||
val read = raf.read(buffer, 0, toRead)
|
||||
if (read <= 0) break
|
||||
output.write(buffer, 0, read)
|
||||
@@ -394,11 +366,13 @@ class MetadataStripper {
|
||||
}
|
||||
}
|
||||
tempInputFile.delete()
|
||||
tempInputFile = null
|
||||
|
||||
Log.d("MetadataStripper", "Stripped ID3 tags from MP3")
|
||||
StrippingResult(tempOutputFile.toUri(), true)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
tempInputFile?.delete()
|
||||
Log.d("MetadataStripper", "Failed to strip MP3 metadata: ${e.message}")
|
||||
StrippingResult(uri, false)
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
data class ConfirmationCallbacks(
|
||||
val onConfirm: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class SuspendableConfirmation {
|
||||
var state by mutableStateOf<ConfirmationCallbacks?>(null)
|
||||
private set
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
suspend fun awaitConfirmation(): Boolean =
|
||||
mutex.withLock {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
state =
|
||||
ConfirmationCallbacks(
|
||||
onConfirm = {
|
||||
state = null
|
||||
continuation.resume(true)
|
||||
},
|
||||
onCancel = {
|
||||
state = null
|
||||
continuation.resume(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-83
@@ -37,11 +37,6 @@ import kotlinx.coroutines.flow.map
|
||||
import okhttp3.OkHttpClient
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
data class StrippingFailureState(
|
||||
val onConfirm: () -> Unit,
|
||||
val onCancel: () -> Unit,
|
||||
)
|
||||
|
||||
sealed class UploadingState {
|
||||
data object Ready : UploadingState()
|
||||
|
||||
@@ -297,17 +292,36 @@ class UploadOrchestrator {
|
||||
MediaCompressorResult(uri, mimeType, null)
|
||||
}
|
||||
|
||||
fun stripMetadataIfNeeded(
|
||||
uri: Uri,
|
||||
private suspend fun stripAfterCompression(
|
||||
originalUri: Uri,
|
||||
compressed: MediaCompressorResult,
|
||||
mimeType: String?,
|
||||
compressionQuality: CompressorQuality,
|
||||
stripMetadata: Boolean,
|
||||
onStrippingFailed: suspend () -> Boolean,
|
||||
context: Context,
|
||||
): StrippingResult {
|
||||
if (!stripMetadata) return StrippingResult(uri, false)
|
||||
// Always perform actual metadata stripping when requested.
|
||||
// Compression decisions must be made by the caller based on the
|
||||
// already-compressed media, not deferred here.
|
||||
return MetadataStripper().strip(uri, mimeType, context.applicationContext)
|
||||
): Uri? {
|
||||
if (!stripMetadata) return compressed.uri
|
||||
|
||||
val effectiveMimeType = compressed.contentType ?: mimeType
|
||||
val isVideo = effectiveMimeType?.startsWith("video/", ignoreCase = true) == true
|
||||
val compressionRequested = compressionQuality != CompressorQuality.UNCOMPRESSED
|
||||
val compressionApplied = compressionRequested && compressed.uri != originalUri
|
||||
|
||||
val strippingResult =
|
||||
if (isVideo && compressionApplied) {
|
||||
// Compression was requested and actually applied to a video;
|
||||
// assume it stripped metadata successfully.
|
||||
StrippingResult(compressed.uri, true)
|
||||
} else {
|
||||
MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext)
|
||||
}
|
||||
|
||||
if (!strippingResult.stripped) {
|
||||
if (!onStrippingFailed()) return null
|
||||
}
|
||||
|
||||
return strippingResult.uri
|
||||
}
|
||||
|
||||
suspend fun upload(
|
||||
@@ -323,43 +337,11 @@ class UploadOrchestrator {
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
): UploadingFinalState {
|
||||
// First, compress if needed; MediaCompressor may fall back to the original URI.
|
||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
|
||||
|
||||
val effectiveMimeType = compressed.contentType ?: mimeType
|
||||
var finalUri = compressed.uri
|
||||
|
||||
if (stripMetadata) {
|
||||
val isVideo = effectiveMimeType?.startsWith("video/", ignoreCase = true) == true
|
||||
val compressionRequested = compressionQuality != CompressorQuality.UNCOMPRESSED
|
||||
val compressionApplied = compressionRequested && compressed.uri != uri
|
||||
|
||||
val strippingResult =
|
||||
if (isVideo && compressionApplied) {
|
||||
// Compression was requested and actually applied to a video;
|
||||
// assume it stripped metadata successfully.
|
||||
StrippingResult(finalUri, true)
|
||||
} else {
|
||||
// Either not a video, compression wasn't requested, or compression
|
||||
// did not actually change the URI (fallback). In these cases,
|
||||
// perform explicit metadata stripping on the current URI.
|
||||
stripMetadataIfNeeded(
|
||||
finalUri,
|
||||
effectiveMimeType,
|
||||
stripMetadata,
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
finalUri = strippingResult.uri
|
||||
|
||||
if (!strippingResult.stripped) {
|
||||
// Stripping was requested but did not succeed; confirm with the user.
|
||||
if (!onStrippingFailed()) {
|
||||
return error(R.string.upload_cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalUri =
|
||||
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
||||
?: return error(R.string.upload_cancelled)
|
||||
|
||||
return when (server.type) {
|
||||
ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context)
|
||||
@@ -382,43 +364,11 @@ class UploadOrchestrator {
|
||||
stripMetadata: Boolean = true,
|
||||
onStrippingFailed: suspend () -> Boolean = { true },
|
||||
): UploadingFinalState {
|
||||
// First, compress if needed; MediaCompressor may fall back to the original URI.
|
||||
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265)
|
||||
|
||||
val effectiveMimeType = compressed.contentType ?: mimeType
|
||||
var finalUri = compressed.uri
|
||||
|
||||
if (stripMetadata) {
|
||||
val isVideo = effectiveMimeType?.startsWith("video/", ignoreCase = true) == true
|
||||
val compressionRequested = compressionQuality != CompressorQuality.UNCOMPRESSED
|
||||
val compressionApplied = compressionRequested && compressed.uri != uri
|
||||
|
||||
val strippingResult =
|
||||
if (isVideo && compressionApplied) {
|
||||
// Compression was requested and actually applied to a video;
|
||||
// assume it stripped metadata successfully.
|
||||
StrippingResult(finalUri, true)
|
||||
} else {
|
||||
// Either not a video, compression wasn't requested, or compression
|
||||
// did not actually change the URI (fallback). In these cases,
|
||||
// perform explicit metadata stripping on the current URI.
|
||||
stripMetadataIfNeeded(
|
||||
finalUri,
|
||||
effectiveMimeType,
|
||||
stripMetadata,
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
finalUri = strippingResult.uri
|
||||
|
||||
if (!strippingResult.stripped) {
|
||||
// Stripping was requested but did not succeed; confirm with the user.
|
||||
if (!onStrippingFailed()) {
|
||||
return error(R.string.upload_cancelled)
|
||||
}
|
||||
}
|
||||
}
|
||||
val finalUri =
|
||||
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
|
||||
?: return error(R.string.upload_cancelled)
|
||||
|
||||
val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt)
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ fun EditPostView(
|
||||
postViewModel.load(edit, versionLookingAt)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureDialog)
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
|
||||
@@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.StrippingFailureState
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
|
||||
@@ -65,7 +65,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.size
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@Stable
|
||||
open class EditPostViewModel : ViewModel() {
|
||||
@@ -93,23 +92,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
var strippingFailureDialog by mutableStateOf<StrippingFailureState?>(null)
|
||||
private set
|
||||
|
||||
private suspend fun showStrippingFailureDialog(): Boolean =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
strippingFailureDialog =
|
||||
StrippingFailureState(
|
||||
onConfirm = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(true) {}
|
||||
},
|
||||
onCancel = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(false) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Codec selection: false = H264, true = H265
|
||||
var useH265Codec by mutableStateOf(false)
|
||||
@@ -218,7 +201,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
context,
|
||||
useH265Codec,
|
||||
stripMetadata,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
|
||||
@@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.StrippingFailureState
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
@@ -45,7 +45,6 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@Stable
|
||||
open class NewMediaModel : ViewModel() {
|
||||
@@ -62,23 +61,7 @@ open class NewMediaModel : ViewModel() {
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
// Stripping failure dialog
|
||||
var strippingFailureDialog by mutableStateOf<StrippingFailureState?>(null)
|
||||
private set
|
||||
|
||||
private suspend fun showStrippingFailureDialog(): Boolean =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
strippingFailureDialog =
|
||||
StrippingFailureState(
|
||||
onConfirm = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(true) {}
|
||||
},
|
||||
onCancel = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(false) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
|
||||
var mediaQualitySlider by mutableIntStateOf(1)
|
||||
@@ -141,7 +124,7 @@ open class NewMediaModel : ViewModel() {
|
||||
context,
|
||||
useH265Codec,
|
||||
stripMetadata,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
|
||||
@@ -89,7 +89,7 @@ fun NewMediaView(
|
||||
postViewModel.load(account, uris)
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureDialog)
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
|
||||
val strippingResult =
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
MetadataStripper().strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
+8
-3
@@ -25,13 +25,18 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.uploads.StrippingFailureState
|
||||
import com.vitorpamplona.amethyst.service.uploads.ConfirmationCallbacks
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
@Composable
|
||||
fun StrippingFailureDialog(dialogState: StrippingFailureState?) {
|
||||
dialogState ?: return
|
||||
fun StrippingFailureDialog(confirmation: SuspendableConfirmation) {
|
||||
val dialogState = confirmation.state ?: return
|
||||
StrippingFailureDialog(dialogState)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StrippingFailureDialog(dialogState: ConfirmationCallbacks) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { dialogState.onCancel() },
|
||||
title = { Text(stringRes(R.string.metadata_strip_failed_title)) },
|
||||
|
||||
+3
-20
@@ -42,7 +42,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.StrippingFailureState
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
@@ -112,7 +112,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@Stable
|
||||
open class CommentPostViewModel :
|
||||
@@ -165,23 +164,7 @@ open class CommentPostViewModel :
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
var strippingFailureDialog by mutableStateOf<StrippingFailureState?>(null)
|
||||
private set
|
||||
|
||||
private suspend fun showStrippingFailureDialog(): Boolean =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
strippingFailureDialog =
|
||||
StrippingFailureState(
|
||||
onConfirm = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(true) {}
|
||||
},
|
||||
onCancel = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(false) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
@@ -524,7 +507,7 @@ open class CommentPostViewModel :
|
||||
account,
|
||||
context,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ fun GenericCommentPostScreen(
|
||||
) {
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureDialog)
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
|
||||
+12
-3
@@ -139,13 +139,22 @@ class BookmarkGroupMetadataViewModel : ViewModel() {
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
val strippedUri =
|
||||
val sourceUri =
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
MetadataStripper().strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext).uri
|
||||
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
if (!result.stripped) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
|
||||
)
|
||||
onUploading(false)
|
||||
return
|
||||
}
|
||||
result.uri
|
||||
} else {
|
||||
galleryUri.uri
|
||||
}
|
||||
val compResult = MediaCompressor().compress(strippedUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
try {
|
||||
val result =
|
||||
|
||||
+8
-25
@@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.StrippingFailureState
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
|
||||
@@ -100,7 +100,6 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@Stable
|
||||
class ChatNewMessageViewModel :
|
||||
@@ -137,8 +136,7 @@ class ChatNewMessageViewModel :
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
var strippingFailureDialog by mutableStateOf<StrippingFailureState?>(null)
|
||||
private set
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
|
||||
@@ -402,21 +400,6 @@ class ChatNewMessageViewModel :
|
||||
return location!!
|
||||
}
|
||||
|
||||
private suspend fun showStrippingFailureDialog(): Boolean =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
strippingFailureDialog =
|
||||
StrippingFailureState(
|
||||
onConfirm = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(true) {}
|
||||
},
|
||||
onCancel = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(false) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun uploadAndHold(
|
||||
onError: (title: String, message: String) -> Unit,
|
||||
context: Context,
|
||||
@@ -435,14 +418,14 @@ class ChatNewMessageViewModel :
|
||||
pendingRetryMode = RetryMode.HOLD
|
||||
},
|
||||
context,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
) {
|
||||
uploadsWaitingToBeSent += it
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
}
|
||||
} else {
|
||||
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context, onStrippingFailed = ::showStrippingFailureDialog) {
|
||||
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation) {
|
||||
uploadsWaitingToBeSent += it
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
@@ -473,14 +456,14 @@ class ChatNewMessageViewModel :
|
||||
pendingRetryOnceUploaded = onceUploaded
|
||||
},
|
||||
context,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
) {
|
||||
ChatFileSender(room, account).sendNIP17(it)
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
}
|
||||
} else {
|
||||
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context, onStrippingFailed = ::showStrippingFailureDialog) {
|
||||
ChatFileUploader(account).justUploadNIP04(uploadState, onError, context, onStrippingFailed = strippingFailureConfirmation::awaitConfirmation) {
|
||||
ChatFileSender(room, account).sendNIP04(it)
|
||||
draftTag.newVersion()
|
||||
onceUploaded()
|
||||
@@ -529,7 +512,7 @@ class ChatNewMessageViewModel :
|
||||
uploadState,
|
||||
onError ?: accountViewModel.toastManager::toast,
|
||||
context,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
) {
|
||||
uploadsWaitingToBeSent += it
|
||||
draftTag.newVersion()
|
||||
@@ -543,7 +526,7 @@ class ChatNewMessageViewModel :
|
||||
uploadState,
|
||||
onError ?: accountViewModel.toastManager::toast,
|
||||
context,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
) {
|
||||
ChatFileSender(room, account).sendNIP17(it)
|
||||
draftTag.newVersion()
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ fun PrivateMessageEditFieldRow(
|
||||
}
|
||||
}
|
||||
|
||||
StrippingFailureDialog(channelScreenModel.strippingFailureDialog)
|
||||
StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation)
|
||||
|
||||
channelScreenModel.encryptedUploadErrorTitle?.let { title ->
|
||||
EncryptedUploadErrorDialog(
|
||||
|
||||
+12
-3
@@ -208,13 +208,22 @@ class ChannelMetadataViewModel : ViewModel() {
|
||||
val account = account
|
||||
onUploading(true)
|
||||
|
||||
val strippedUri =
|
||||
val sourceUri =
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
MetadataStripper().strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext).uri
|
||||
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
if (!result.stripped) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
|
||||
)
|
||||
onUploading(false)
|
||||
return
|
||||
}
|
||||
result.uri
|
||||
} else {
|
||||
galleryUri.uri
|
||||
}
|
||||
val compResult = MediaCompressor().compress(strippedUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
try {
|
||||
val result =
|
||||
|
||||
+3
-20
@@ -46,7 +46,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.StrippingFailureState
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
@@ -102,7 +102,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@Stable
|
||||
open class ChannelNewMessageViewModel :
|
||||
@@ -133,23 +132,7 @@ open class ChannelNewMessageViewModel :
|
||||
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
var strippingFailureDialog by mutableStateOf<StrippingFailureState?>(null)
|
||||
private set
|
||||
|
||||
private suspend fun showStrippingFailureDialog(): Boolean =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
strippingFailureDialog =
|
||||
StrippingFailureState(
|
||||
onConfirm = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(true) {}
|
||||
},
|
||||
onCancel = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(false) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
val iMetaAttachments = IMetaAttachments()
|
||||
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
|
||||
@@ -371,7 +354,7 @@ open class ChannelNewMessageViewModel :
|
||||
account,
|
||||
context,
|
||||
stripMetadata = uploadState.stripMetadata,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ fun EditFieldRow(
|
||||
nav.popBack()
|
||||
}
|
||||
|
||||
StrippingFailureDialog(channelScreenModel.strippingFailureDialog)
|
||||
StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation)
|
||||
|
||||
channelScreenModel.replyTo.value?.let {
|
||||
DisplayReplyingToNote(it, accountViewModel, nav) {
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ fun NewProductScreen(
|
||||
) {
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureDialog)
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
|
||||
+3
-20
@@ -42,7 +42,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.StrippingFailureState
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
@@ -101,7 +101,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@Stable
|
||||
open class NewProductViewModel :
|
||||
@@ -149,23 +148,7 @@ open class NewProductViewModel :
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
var strippingFailureDialog by mutableStateOf<StrippingFailureState?>(null)
|
||||
private set
|
||||
|
||||
private suspend fun showStrippingFailureDialog(): Boolean =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
strippingFailureDialog =
|
||||
StrippingFailureState(
|
||||
onConfirm = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(true) {}
|
||||
},
|
||||
onCancel = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(false) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Classifieds
|
||||
var title by mutableStateOf(TextFieldValue(""))
|
||||
@@ -432,7 +415,7 @@ open class NewProductViewModel :
|
||||
myAccount,
|
||||
context,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ private fun NewPostScreenInner(
|
||||
) {
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureDialog)
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
|
||||
+3
-20
@@ -45,7 +45,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.StrippingFailureState
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadingState
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
@@ -134,7 +134,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
enum class UserSuggestionAnchor {
|
||||
MAIN_MESSAGE,
|
||||
@@ -194,23 +193,7 @@ open class ShortNotePostViewModel :
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
var strippingFailureDialog by mutableStateOf<StrippingFailureState?>(null)
|
||||
private set
|
||||
|
||||
private suspend fun showStrippingFailureDialog(): Boolean =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
strippingFailureDialog =
|
||||
StrippingFailureState(
|
||||
onConfirm = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(true) {}
|
||||
},
|
||||
onCancel = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(false) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Voice Messages
|
||||
var voiceRecording by mutableStateOf<RecordingResult?>(null)
|
||||
@@ -867,7 +850,7 @@ open class ShortNotePostViewModel :
|
||||
context,
|
||||
useH265,
|
||||
stripMetadata,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
|
||||
+12
-3
@@ -138,13 +138,22 @@ class FollowPackMetadataViewModel : ViewModel() {
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
val strippedUri =
|
||||
val sourceUri =
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
MetadataStripper().strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext).uri
|
||||
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
if (!result.stripped) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
|
||||
)
|
||||
onUploading(false)
|
||||
return
|
||||
}
|
||||
result.uri
|
||||
} else {
|
||||
galleryUri.uri
|
||||
}
|
||||
val compResult = MediaCompressor().compress(strippedUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
try {
|
||||
val result =
|
||||
|
||||
+12
-3
@@ -139,13 +139,22 @@ class PeopleListMetadataViewModel : ViewModel() {
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
val strippedUri =
|
||||
val sourceUri =
|
||||
if (account.settings.stripLocationOnUpload) {
|
||||
MetadataStripper().strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext).uri
|
||||
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
|
||||
if (!result.stripped) {
|
||||
onError(
|
||||
stringRes(context, R.string.metadata_strip_failed_title),
|
||||
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
|
||||
)
|
||||
onUploading(false)
|
||||
return
|
||||
}
|
||||
result.uri
|
||||
} else {
|
||||
galleryUri.uri
|
||||
}
|
||||
val compResult = MediaCompressor().compress(strippedUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
try {
|
||||
val result =
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ fun NewPublicMessageScreen(
|
||||
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureDialog)
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
accountViewModel.launchSigner {
|
||||
|
||||
+3
-20
@@ -42,7 +42,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
|
||||
import com.vitorpamplona.amethyst.service.uploads.StrippingFailureState
|
||||
import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
@@ -115,7 +115,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
@Stable
|
||||
class NewPublicMessageViewModel :
|
||||
@@ -167,23 +166,7 @@ class NewPublicMessageViewModel :
|
||||
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
|
||||
|
||||
// Stripping failure dialog
|
||||
var strippingFailureDialog by mutableStateOf<StrippingFailureState?>(null)
|
||||
private set
|
||||
|
||||
private suspend fun showStrippingFailureDialog(): Boolean =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
strippingFailureDialog =
|
||||
StrippingFailureState(
|
||||
onConfirm = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(true) {}
|
||||
},
|
||||
onCancel = {
|
||||
strippingFailureDialog = null
|
||||
continuation.resume(false) {}
|
||||
},
|
||||
)
|
||||
}
|
||||
val strippingFailureConfirmation = SuspendableConfirmation()
|
||||
|
||||
// Invoices
|
||||
var canAddInvoice by mutableStateOf(false)
|
||||
@@ -476,7 +459,7 @@ class NewPublicMessageViewModel :
|
||||
account,
|
||||
context,
|
||||
stripMetadata = stripMetadata,
|
||||
onStrippingFailed = ::showStrippingFailureDialog,
|
||||
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
|
||||
)
|
||||
|
||||
if (results.allGood) {
|
||||
|
||||
Reference in New Issue
Block a user