From 7bfc35e04de636252034cd4ec871d391d0e657e4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 18 Mar 2026 20:27:18 +0100 Subject: [PATCH] =?UTF-8?q?Fixes=20Applied=20=20=20Code=20reuse=20=20=20-?= =?UTF-8?q?=20Extracted=20remuxTracks()=20helper=20to=20deduplicate=20vide?= =?UTF-8?q?o/audio=20remux=20loop=20(~40=20lines)=20=20=20-=20Extracted=20?= =?UTF-8?q?stripAfterCompression()=20to=20deduplicate=20identical=20blocks?= =?UTF-8?q?=20in=20upload()/uploadEncrypted()=20=20=20-=20Replaced=208=20i?= =?UTF-8?q?dentical=20showStrippingFailureDialog()=20copy-pastes=20with=20?= =?UTF-8?q?shared=20SuspendableConfirmation=20utility=20=20=20Privacy=20?= =?UTF-8?q?=20=20-=204=20metadata=20ViewModels=20now=20error=20on=20strip?= =?UTF-8?q?=20failure=20instead=20of=20silently=20uploading=20unstripped?= =?UTF-8?q?=20media=20=20=20Efficiency=20=20=20-=20Eliminated=20temp=20inp?= =?UTF-8?q?ut=20file=20copy=20for=20video/audio=20=E2=80=94=20MediaExtract?= =?UTF-8?q?or=20reads=20URIs=20directly=20=20=20Bug=20fixes=20=20=20-=20Fi?= =?UTF-8?q?xed=20Long.toInt()=20overflow=20for=20MP3=20files=20>2GB=20=20?= =?UTF-8?q?=20-=20Fixed=20MP3=20temp=20file=20leak=20on=20exception=20and?= =?UTF-8?q?=20null=20InputStream=20paths=20=20=20Cleanup=20=20=20-=20Mutex?= =?UTF-8?q?=20field=20and=20wrapping=20the=20suspendCancellableCoroutine?= =?UTF-8?q?=20in=20mutex.withLock.=20Concurrent=20callers=20now=20queued?= =?UTF-8?q?=20=20=20-=20Converted=20MetadataStripper=20from=20class=20to?= =?UTF-8?q?=20object=20(stateless)=20=20=20-=20Moved=20StrippingFailureSta?= =?UTF-8?q?te=20from=20service=20layer=20to=20UI=20as=20generic=20Confirma?= =?UTF-8?q?tionCallbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/uploads/MetadataStripper.kt | 360 ++++++++---------- .../uploads/SuspendableConfirmation.kt | 60 +++ .../service/uploads/UploadOrchestrator.kt | 116 ++---- .../amethyst/ui/actions/EditPostView.kt | 2 +- .../amethyst/ui/actions/EditPostViewModel.kt | 23 +- .../amethyst/ui/actions/NewMediaModel.kt | 23 +- .../amethyst/ui/actions/NewMediaView.kt | 2 +- .../ui/actions/NewUserMetadataViewModel.kt | 2 +- .../ui/actions/StrippingFailureDialog.kt | 11 +- .../nip22Comments/CommentPostViewModel.kt | 23 +- .../nip22Comments/GenericCommentPostScreen.kt | 2 +- .../BookmarkGroupMetadataViewModel.kt | 15 +- .../privateDM/send/ChatNewMessageViewModel.kt | 33 +- .../send/PrivateMessageEditFieldRow.kt | 2 +- .../metadata/ChannelMetadataViewModel.kt | 15 +- .../send/ChannelNewMessageViewModel.kt | 23 +- .../chats/publicChannels/send/EditFieldRow.kt | 2 +- .../nip99Classifieds/NewProductScreen.kt | 2 +- .../nip99Classifieds/NewProductViewModel.kt | 23 +- .../loggedIn/home/ShortNotePostScreen.kt | 2 +- .../loggedIn/home/ShortNotePostViewModel.kt | 23 +- .../metadata/FollowPackMetadataViewModel.kt | 15 +- .../metadata/PeopleListMetadataViewModel.kt | 15 +- .../publicMessages/NewPublicMessageScreen.kt | 2 +- .../NewPublicMessageViewModel.kt | 23 +- 25 files changed, 354 insertions(+), 465 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/SuspendableConfirmation.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt index eecec64aff..79987c74d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MetadataStripper.kt @@ -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() + 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() - 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() - 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/SuspendableConfirmation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/SuspendableConfirmation.kt new file mode 100644 index 0000000000..ca94a7728e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/SuspendableConfirmation.kt @@ -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(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) + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 2079b89d93..ee5ef4e728 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 64bcbc75fe..29ba7a579f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -125,7 +125,7 @@ fun EditPostView( postViewModel.load(edit, versionLookingAt) } - StrippingFailureDialog(postViewModel.strippingFailureDialog) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) Dialog( onDismissRequest = { onClose() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index 8432c20b9e..53cd5a131a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -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(null) // Stripping failure dialog - var strippingFailureDialog by mutableStateOf(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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index a5e3c40d29..563cb0ce69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -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(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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index f5dee6fb7b..63a21a0212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -89,7 +89,7 @@ fun NewMediaView( postViewModel.load(account, uris) } - StrippingFailureDialog(postViewModel.strippingFailureDialog) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) Dialog( onDismissRequest = { onClose() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 2f4948625c..c5ea8056fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/StrippingFailureDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/StrippingFailureDialog.kt index 6088dcd1f0..7500201cdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/StrippingFailureDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/StrippingFailureDialog.kt @@ -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)) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 7c9f770960..10051c2945 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -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(null) // Stripping failure dialog - var strippingFailureDialog by mutableStateOf(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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index bcdcce7a40..1833637a02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -141,7 +141,7 @@ fun GenericCommentPostScreen( ) { WatchAndLoadMyEmojiList(accountViewModel) - StrippingFailureDialog(postViewModel.strippingFailureDialog) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) BackHandler { accountViewModel.launchSigner { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt index f72609b492..c82c179954 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 550c839d16..5c9569a7c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -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(null) // Stripping failure dialog - var strippingFailureDialog by mutableStateOf(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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index a3194c8ae0..130c88e520 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -118,7 +118,7 @@ fun PrivateMessageEditFieldRow( } } - StrippingFailureDialog(channelScreenModel.strippingFailureDialog) + StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation) channelScreenModel.encryptedUploadErrorTitle?.let { title -> EncryptedUploadErrorDialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index 1304a25e74..30d9b0e967 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 5fe146ef2e..489a744f88 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -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(null) // Stripping failure dialog - var strippingFailureDialog by mutableStateOf(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>>(emptyList()) @@ -371,7 +354,7 @@ open class ChannelNewMessageViewModel : account, context, stripMetadata = uploadState.stripMetadata, - onStrippingFailed = ::showStrippingFailureDialog, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) if (results.allGood) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index ae428a45fc..ddf1ad2d4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -71,7 +71,7 @@ fun EditFieldRow( nav.popBack() } - StrippingFailureDialog(channelScreenModel.strippingFailureDialog) + StrippingFailureDialog(channelScreenModel.strippingFailureConfirmation) channelScreenModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 7b83939d19..bd3d93c571 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -138,7 +138,7 @@ fun NewProductScreen( ) { WatchAndLoadMyEmojiList(accountViewModel) - StrippingFailureDialog(postViewModel.strippingFailureDialog) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) BackHandler { accountViewModel.launchSigner { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 07e7663db2..2908f5a793 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -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(null) // Stripping failure dialog - var strippingFailureDialog by mutableStateOf(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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 2d093cd496..35c1f5c1f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -180,7 +180,7 @@ private fun NewPostScreenInner( ) { WatchAndLoadMyEmojiList(accountViewModel) - StrippingFailureDialog(postViewModel.strippingFailureDialog) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) BackHandler { accountViewModel.launchSigner { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 31386bd75b..2e646092bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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(null) // Stripping failure dialog - var strippingFailureDialog by mutableStateOf(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(null) @@ -867,7 +850,7 @@ open class ShortNotePostViewModel : context, useH265, stripMetadata, - onStrippingFailed = ::showStrippingFailureDialog, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, ) if (results.allGood) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt index accd8bf93e..5b089554ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/FollowPackMetadataViewModel.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt index 000e180b30..f2032dae20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/metadata/PeopleListMetadataViewModel.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 2834ce0a1a..3a0e7ae936 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -132,7 +132,7 @@ fun NewPublicMessageScreen( WatchAndLoadMyEmojiList(accountViewModel) - StrippingFailureDialog(postViewModel.strippingFailureDialog) + StrippingFailureDialog(postViewModel.strippingFailureConfirmation) BackHandler { accountViewModel.launchSigner { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index f7825d963b..35e94f93fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -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(null) // Stripping failure dialog - var strippingFailureDialog by mutableStateOf(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) {