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:
davotoula
2026-03-18 20:59:46 +01:00
parent 0cf256e5b2
commit 7bfc35e04d
25 changed files with 354 additions and 465 deletions
@@ -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)
}
@@ -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)
},
)
}
}
}
@@ -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() },
@@ -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
}
@@ -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)) },
@@ -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) {
@@ -141,7 +141,7 @@ fun GenericCommentPostScreen(
) {
WatchAndLoadMyEmojiList(accountViewModel)
StrippingFailureDialog(postViewModel.strippingFailureDialog)
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
BackHandler {
accountViewModel.launchSigner {
@@ -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 =
@@ -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()
@@ -118,7 +118,7 @@ fun PrivateMessageEditFieldRow(
}
}
StrippingFailureDialog(channelScreenModel.strippingFailureDialog)
StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation)
channelScreenModel.encryptedUploadErrorTitle?.let { title ->
EncryptedUploadErrorDialog(
@@ -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 =
@@ -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) {
@@ -71,7 +71,7 @@ fun EditFieldRow(
nav.popBack()
}
StrippingFailureDialog(channelScreenModel.strippingFailureDialog)
StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation)
channelScreenModel.replyTo.value?.let {
DisplayReplyingToNote(it, accountViewModel, nav) {
@@ -138,7 +138,7 @@ fun NewProductScreen(
) {
WatchAndLoadMyEmojiList(accountViewModel)
StrippingFailureDialog(postViewModel.strippingFailureDialog)
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
BackHandler {
accountViewModel.launchSigner {
@@ -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) {
@@ -180,7 +180,7 @@ private fun NewPostScreenInner(
) {
WatchAndLoadMyEmojiList(accountViewModel)
StrippingFailureDialog(postViewModel.strippingFailureDialog)
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
BackHandler {
accountViewModel.launchSigner {
@@ -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) {
@@ -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 =
@@ -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 =
@@ -132,7 +132,7 @@ fun NewPublicMessageScreen(
WatchAndLoadMyEmojiList(accountViewModel)
StrippingFailureDialog(postViewModel.strippingFailureDialog)
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
BackHandler {
accountViewModel.launchSigner {
@@ -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) {