refactor: MediaUploader port for the upload pipeline

Move the platform-neutral upload model into commons
(commons.service.uploads): MediaUri, SelectedMedia, CompressorQuality,
MediaCompressorResult, UploadError, UploadingState, UploadOrchestrator,
MultiOrchestrator and the MediaUploader port. Error states carry an
UploadError enum; commonsUI maps it to a string resource.

The Android compress/strip/upload pipeline stays in the app as
AndroidMediaUploader, reached through AccountViewModelHost.mediaUploader.
Composer view models and screens pass the uploader instead of a Context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-29 15:05:42 +00:00
parent 04da017f3b
commit 2fd02a0753
120 changed files with 1658 additions and 1197 deletions
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.appContext
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.contentUriFor
import com.vitorpamplona.amethyst.AvifInstrumentedTestSupport.copyAssetToCache
import com.vitorpamplona.amethyst.commons.service.uploads.AVIF_MIME
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -0,0 +1,592 @@
/*
* 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 android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaCompressorResult
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri
import com.vitorpamplona.amethyst.commons.service.uploads.UploadError
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
/**
* Android's [MediaUploader]: compresses with LightCompressor/Compressor, strips metadata, encrypts,
* and uploads over the app's upload-role OkHttp clients. Reads files through the application
* [appContext]'s content resolver, so it needs no Activity.
*/
class AndroidMediaUploader(
private val appContext: Context,
) : MediaUploader {
override suspend fun upload(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
server: ServerName,
account: Account,
useH265: Boolean,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingFinalState = Job(progress).upload(uri, mimeType, alt, contentWarningReason, compressionQuality, server, account, appContext, useH265, stripMetadata, onStrippingFailed, convertGifToMp4, forcedSigner)
override suspend fun uploadEncrypted(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
encrypt: NostrCipher,
server: ServerName,
account: Account,
useH265: Boolean,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingFinalState = Job(progress).uploadEncrypted(uri, mimeType, alt, contentWarningReason, compressionQuality, encrypt, server, account, appContext, useH265, stripMetadata, onStrippingFailed, convertGifToMp4, forcedSigner)
override suspend fun compressIfNeeded(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
compressionQuality: CompressorQuality,
useH265: Boolean,
convertGifToMp4: Boolean,
): MediaCompressorResult = Job(progress).compressIfNeeded(uri, mimeType, compressionQuality, appContext, useH265, convertGifToMp4)
/** One upload's run: the pipeline, reporting into [progress]. */
private class Job(
val progress: UploadOrchestrator,
) {
fun error(
error: UploadError,
vararg params: String,
) = progress.error(error, *params)
fun finish(result: UploadOrchestrator.OrchestratorResult) = progress.finish(result)
fun updateState(
newProgress: Double,
newState: UploadingState,
) = progress.updateState(newProgress, newState)
private fun uploadNIP95(
fileUri: Uri,
contentType: String?,
originalContentType: String?,
originalHash: String?,
context: Context,
): UploadingFinalState {
updateState(0.4, UploadingState.Uploading)
val bytes =
context.contentResolver.openInputStream(fileUri)?.use {
it.readBytes()
}
if (bytes != null) {
if (bytes.size > 80000) {
return error(UploadError.MEDIA_TOO_BIG_FOR_NIP95)
}
updateState(0.8, UploadingState.Hashing)
val result =
FileHeader.prepare(
bytes,
contentType,
null,
)
result.fold(
onSuccess = {
return finish(UploadOrchestrator.OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash))
},
onFailure = {
return error(UploadError.COULD_NOT_CHECK_DOWNLOADED_FILE, it.message ?: it.javaClass.simpleName)
},
)
} else {
return error(UploadError.COULD_NOT_OPEN_COMPRESSED_FILE)
}
}
private suspend fun uploadNIP96(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
contentWarningReason: String?,
serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account,
forcedSigner: NostrSigner?,
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
return try {
val result =
Nip96Uploader().upload(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = { percent: Float ->
updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
},
httpAuth =
if (forcedSigner != null) {
{ url, method, body -> forcedSigner.sign(HTTPAuthorizationEvent.build(url, method, body)) }
} else {
account::createHTTPAuthorization
},
context = context,
)
verifyHeader(
uploadResult = result,
localContentType = contentType,
originalContentType = contentTypeForResult,
originalHash = originalHash,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
)
} catch (_: SignerExceptions.ReadOnlyException) {
error(UploadError.LOGIN_WITH_PRIVATE_KEY)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(UploadError.FAILED_TO_UPLOAD_MEDIA, e.message ?: e.javaClass.simpleName)
}
}
/**
* Tries [selected], then the rest of the servers the picker offered (see
* [blossomUploadOrder]) until one stores the blob. Every server gets the same blob, so they
* share one signed upload token: the signer is asked once, and a signer that refused, timed
* out or is read-only stops the fallback instead of prompting again per server. Attempts
* don't publish their errors; when every server fails, the error shown is the selected
* server's, the one the user chose and will recognize.
*/
private suspend fun uploadBlossomWithFallback(
selected: ServerName,
account: Account,
uploadTo: suspend (serverBaseUrl: String, auth: SharedUploadAuth) -> UploadingFinalState,
): UploadingFinalState {
val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value)
val auth = SharedUploadAuth()
var firstError: UploadingState.Error? = null
for (server in order) {
when (val result = uploadTo(server.baseUrl, auth)) {
is UploadingState.Finished -> return result
is UploadingState.Error -> {
if (firstError == null) firstError = result
if (auth.signerFailed) return result.also { updateState(0.0, it) }
Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server")
}
}
}
return firstError!!.also { updateState(0.0, it) }
}
/**
* The upload token for one blob, signed at most once and reused by every server tried.
* A signer that returned no token is asked again on the next server; only a token or a
* signer failure is kept.
*/
private class SharedUploadAuth {
private var outcome: Result<BlossomAuthorizationEvent>? = null
var signerFailed = false
private set
suspend fun get(sign: suspend () -> BlossomAuthorizationEvent?): BlossomAuthorizationEvent? {
outcome?.let { return it.getOrThrow() }
return try {
sign()?.also { outcome = Result.success(it) }
} catch (e: SignerExceptions) {
signerFailed = true
outcome = Result.failure(e)
throw e
}
}
}
private suspend fun uploadBlossom(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
contentWarningReason: String?,
serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account,
forcedSigner: NostrSigner?,
context: Context,
sharedAuth: SharedUploadAuth,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer
// path (e.g. NIP-46 draft signing) always uses the bit-exact /upload.
val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value
return try {
val result =
BlossomUploader()
.upload(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
// Use a t=media token when optimizing via /media, otherwise a plain
// t=upload token. Tokens are intentionally NOT server-scoped on the
// upload path: some servers reject an upload whose auth carries a
// `server` tag, and upload-token replay is not the threat scoping
// guards against (delete tokens are — those stay scoped).
httpAuth = { hash, size, alt ->
sharedAuth.get {
when {
forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner)
useMedia -> account.createBlossomMediaAuth(hash, size, alt)
else -> account.createBlossomUploadAuth(hash, size, alt)
}
}
},
context = context,
useMediaEndpoint = useMedia,
)
val finalState =
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
// Fire-and-forget on the account scope AFTER the upload is finished: mirroring is
// pure background redundancy, so it must never delay, alter, or fail the upload the
// user already completed, and must not touch the on-screen progress state.
if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
account.scope.launch(Dispatchers.IO) {
try {
mirrorToOtherServers(result, serverBaseUrl, account)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Background mirror failed", e)
}
}
}
finalState
} catch (_: SignerExceptions.ReadOnlyException) {
UploadingState.Error(UploadError.LOGIN_WITH_PRIVATE_KEY, emptyArray())
} catch (e: BlossomPaymentException) {
// BUD-07: the server wants payment before it will store the blob.
UploadingState.Error(UploadError.BLOSSOM_PAYMENT_REQUIRED, arrayOf(e.payment.reason ?: serverBaseUrl))
} catch (e: Exception) {
if (e is CancellationException) throw e
UploadingState.Error(UploadError.FAILED_TO_UPLOAD_MEDIA, arrayOf(e.message?.ifBlank { null } ?: e.javaClass.simpleName))
}
}
/**
* BUD-04 mirror fan-out: asks every *other* Blossom server in the account's
* kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs
* in the background after the upload is finished (see caller), so it never
* delays the user's post or touches the upload progress UI; per-server failures
* are swallowed. Requires the blob's sha256 so server B can verify the download.
*/
private suspend fun mirrorToOtherServers(
result: MediaUploadResult,
primaryServerBaseUrl: String,
account: Account,
) {
val sourceUrl = result.url ?: return
val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.')
if (hash.length != 64) return
// Only the user's *explicitly configured* kind-10063 servers (flow), NOT the
// DEFAULT_MEDIA_SERVERS fallback that hostNameFlow injects — we must never fan
// uploads out to public defaults the user never opted into.
val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl)
val targets =
account.blossomServers.flow.value
.filter { BlossomServerUrl.domain(it) != primaryDomain }
.distinct()
if (targets.isEmpty()) return
val contentType = result.type ?: "application/octet-stream"
targets.forEach { target ->
try {
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirrorOrUpload(sourceUrl, hash, contentType, target, auth)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Failed to mirror $hash to $target", e)
}
}
}
private suspend fun verifyHeader(
uploadResult: MediaUploadResult,
localContentType: String?,
originalContentType: String?,
originalHash: String?,
okHttpClient: (String) -> OkHttpClient,
): UploadingFinalState {
if (uploadResult.url.isNullOrBlank()) {
return error(UploadError.SERVER_DID_NOT_PROVIDE_URL)
}
updateState(0.6, UploadingState.Downloading)
// Use streaming verification for memory efficiency with large files
val verification =
ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient)
?: return error(UploadError.COULD_NOT_DOWNLOAD_FROM_SERVER)
updateState(0.8, UploadingState.Hashing)
// Create FileHeader with hash from streaming verification
// Note: We skip blurhash/dimensions since we already have them from upload
val fileHeader =
FileHeader(
mimeType = uploadResult.type ?: localContentType ?: verification.contentType,
hash = verification.hash,
size = verification.size.toInt(),
dim = uploadResult.dimension,
blurHash = uploadResult.blurHash,
)
return finish(
UploadOrchestrator.OrchestratorResult.ServerResult(
fileHeader,
uploadResult.url,
uploadResult.magnet,
uploadResult.sha256,
originalContentType,
originalHash,
),
)
}
suspend fun compressIfNeeded(
uri: Uri,
mimeType: String?,
compressionQuality: CompressorQuality,
context: Context,
useH265: Boolean = false,
convertGifToMp4: Boolean = false,
) = if (compressionQuality != CompressorQuality.UNCOMPRESSED || convertGifToMp4) {
updateState(0.02, UploadingState.Compressing)
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265, convertGifToMp4)
} else {
MediaCompressorResult(uri, mimeType, null)
}
private suspend fun stripAfterCompression(
originalUri: Uri,
compressed: MediaCompressorResult,
mimeType: String?,
compressionQuality: CompressorQuality,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
context: Context,
): 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 {
// AvifMetadataNotVerifiableException is allowed to propagate so the caller
// can emit a specific error rather than the generic "Upload cancelled".
MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext)
}
if (!strippingResult.stripped && !onStrippingFailed()) return null
return strippingResult.uri
}
/**
* Deletes a temporary file created during the upload pipeline if its URI
* differs from the original (meaning it's an intermediate temp file, not the user's content).
*/
internal fun deleteTempUri(
tempUri: Uri,
originalUri: Uri,
) {
if (tempUri == originalUri) return
try {
val path = tempUri.path ?: return
val file = File(path)
if (file.delete()) {
Log.d("UploadOrchestrator") { "Deleted temp file: $path" }
}
} catch (e: Exception) {
Log.w("UploadOrchestrator", "Failed to delete temp file: ${tempUri.path}", e)
}
}
suspend fun upload(
uri: Uri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
val finalUri =
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(UploadError.AVIF_METADATA_STRIP_FAILED, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(UploadError.UPLOAD_CANCELLED).also {
deleteTempUri(compressed.uri, uri)
}
if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri)
try {
return when (server.type) {
ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context)
ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context)
ServerType.Blossom ->
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context, auth)
}
}
} finally {
deleteTempUri(finalUri, uri)
}
}
suspend fun uploadEncrypted(
uri: Uri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
encrypt: NostrCipher,
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
val finalUri =
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(UploadError.AVIF_METADATA_STRIP_FAILED, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(UploadError.UPLOAD_CANCELLED).also {
deleteTempUri(compressed.uri, uri)
}
if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri)
val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt)
deleteTempUri(finalUri, uri)
try {
return when (server.type) {
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context)
// The same encrypted file goes to every server tried, so its key, nonce and
// hash stay valid whichever one ends up holding it.
ServerType.Blossom ->
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, auth)
}
}
} finally {
deleteTempUri(encrypted.uri, uri)
}
}
}
}
@@ -26,6 +26,8 @@ import android.net.Uri
import androidx.core.net.toUri
import androidx.media3.common.MimeTypes
import com.davotoula.lightcompressor.video.GifToMp4Converter
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaCompressorResult
import com.vitorpamplona.amethyst.commons.service.uploads.isAvif
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import com.vitorpamplona.quartz.utils.Log
@@ -34,12 +36,6 @@ import id.zelory.compressor.constraint.default
import kotlinx.coroutines.CancellationException
import java.io.File
class MediaCompressorResult(
val uri: Uri,
val contentType: String?,
val size: Long?,
)
class MediaCompressor {
// ALL ERRORS ARE IGNORED. The original file is returned.
suspend fun compress(
@@ -125,24 +121,4 @@ class MediaCompressor {
MediaCompressorResult(uri, contentType, null)
}
}
companion object {
fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality =
when (mediaQualityFloat) {
0 -> CompressorQuality.LOW
1 -> CompressorQuality.MEDIUM
2 -> CompressorQuality.HIGH
3 -> CompressorQuality.UNCOMPRESSED
else -> CompressorQuality.MEDIUM
}
}
}
enum class CompressorQuality {
VERY_LOW,
LOW,
MEDIUM,
HIGH,
VERY_HIGH,
UNCOMPRESSED,
}
@@ -1,602 +0,0 @@
/*
* 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 android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.mediaServers.blossomUploadOrder
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required
import com.vitorpamplona.amethyst.commons.resources.could_not_check_downloaded_file
import com.vitorpamplona.amethyst.commons.resources.could_not_download_from_the_server
import com.vitorpamplona.amethyst.commons.resources.could_not_open_the_compressed_file
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_upload
import com.vitorpamplona.amethyst.commons.resources.media_too_big_for_nip95
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.resources.upload_cancelled
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jetbrains.compose.resources.StringResource
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
sealed class UploadingState {
data object Ready : UploadingState()
data object Compressing : UploadingState()
data object Uploading : UploadingState()
data object ServerProcessing : UploadingState()
data object Downloading : UploadingState()
data object Hashing : UploadingState()
sealed class UploadingFinalState : UploadingState()
class Finished(
val result: UploadOrchestrator.OrchestratorResult,
) : UploadingFinalState()
class Error(
val errorResource: StringResource,
val params: Array<out String>,
) : UploadingFinalState()
}
class UploadOrchestrator {
val progress = MutableStateFlow(0.0)
val progressState = MutableStateFlow<UploadingState>(UploadingState.Ready)
val isUploading =
progressState.map {
progressState.value !is UploadingState.Ready && progressState.value !is UploadingState.Error && progressState.value !is UploadingState.Finished
}
fun error(
resId: StringResource,
vararg params: String,
) = UploadingState.Error(resId, params).also { updateState(0.0, it) }
fun finish(result: OrchestratorResult) =
UploadingState
.Finished(result)
.also { updateState(1.0, it) }
fun updateState(
newProgress: Double,
newState: UploadingState,
) {
progress.value = newProgress
progressState.value = newState
}
private fun uploadNIP95(
fileUri: Uri,
contentType: String?,
originalContentType: String?,
originalHash: String?,
context: Context,
): UploadingFinalState {
updateState(0.4, UploadingState.Uploading)
val bytes =
context.contentResolver.openInputStream(fileUri)?.use {
it.readBytes()
}
if (bytes != null) {
if (bytes.size > 80000) {
return error(Res.string.media_too_big_for_nip95)
}
updateState(0.8, UploadingState.Hashing)
val result =
FileHeader.prepare(
bytes,
contentType,
null,
)
result.fold(
onSuccess = {
return finish(OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash))
},
onFailure = {
return error(Res.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
},
)
} else {
return error(Res.string.could_not_open_the_compressed_file)
}
}
private suspend fun uploadNIP96(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
contentWarningReason: String?,
serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account,
forcedSigner: NostrSigner?,
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
return try {
val result =
Nip96Uploader().upload(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = { percent: Float ->
updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
},
httpAuth =
if (forcedSigner != null) {
{ url, method, body -> forcedSigner.sign(HTTPAuthorizationEvent.build(url, method, body)) }
} else {
account::createHTTPAuthorization
},
context = context,
)
verifyHeader(
uploadResult = result,
localContentType = contentType,
originalContentType = contentTypeForResult,
originalHash = originalHash,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
)
} catch (_: SignerExceptions.ReadOnlyException) {
error(Res.string.login_with_a_private_key_to_be_able_to_upload)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(Res.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
}
}
/**
* Tries [selected], then the rest of the servers the picker offered (see
* [blossomUploadOrder]) until one stores the blob. Every server gets the same blob, so they
* share one signed upload token: the signer is asked once, and a signer that refused, timed
* out or is read-only stops the fallback instead of prompting again per server. Attempts
* don't publish their errors; when every server fails, the error shown is the selected
* server's, the one the user chose and will recognize.
*/
private suspend fun uploadBlossomWithFallback(
selected: ServerName,
account: Account,
uploadTo: suspend (serverBaseUrl: String, auth: SharedUploadAuth) -> UploadingFinalState,
): UploadingFinalState {
val order = blossomUploadOrder(selected, account.blossomServers.hostNameFlow.value)
val auth = SharedUploadAuth()
var firstError: UploadingState.Error? = null
for (server in order) {
when (val result = uploadTo(server.baseUrl, auth)) {
is UploadingState.Finished -> return result
is UploadingState.Error -> {
if (firstError == null) firstError = result
if (auth.signerFailed) return result.also { updateState(0.0, it) }
Log.w("UploadOrchestrator", "Upload to ${server.baseUrl} failed, trying the next server")
}
}
}
return firstError!!.also { updateState(0.0, it) }
}
/**
* The upload token for one blob, signed at most once and reused by every server tried.
* A signer that returned no token is asked again on the next server; only a token or a
* signer failure is kept.
*/
private class SharedUploadAuth {
private var outcome: Result<BlossomAuthorizationEvent>? = null
var signerFailed = false
private set
suspend fun get(sign: suspend () -> BlossomAuthorizationEvent?): BlossomAuthorizationEvent? {
outcome?.let { return it.getOrThrow() }
return try {
sign()?.also { outcome = Result.success(it) }
} catch (e: SignerExceptions) {
signerFailed = true
outcome = Result.failure(e)
throw e
}
}
}
private suspend fun uploadBlossom(
fileUri: Uri,
contentType: String?,
size: Long?,
alt: String?,
contentWarningReason: String?,
serverBaseUrl: String,
contentTypeForResult: String?,
originalHash: String?,
account: Account,
forcedSigner: NostrSigner?,
context: Context,
sharedAuth: SharedUploadAuth,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer
// path (e.g. NIP-46 draft signing) always uses the bit-exact /upload.
val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value
return try {
val result =
BlossomUploader()
.upload(
uri = fileUri,
contentType = contentType,
size = size,
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
// Use a t=media token when optimizing via /media, otherwise a plain
// t=upload token. Tokens are intentionally NOT server-scoped on the
// upload path: some servers reject an upload whose auth carries a
// `server` tag, and upload-token replay is not the threat scoping
// guards against (delete tokens are — those stay scoped).
httpAuth = { hash, size, alt ->
sharedAuth.get {
when {
forcedSigner != null -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner)
useMedia -> account.createBlossomMediaAuth(hash, size, alt)
else -> account.createBlossomUploadAuth(hash, size, alt)
}
}
},
context = context,
useMediaEndpoint = useMedia,
)
val finalState =
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
// Fire-and-forget on the account scope AFTER the upload is finished: mirroring is
// pure background redundancy, so it must never delay, alter, or fail the upload the
// user already completed, and must not touch the on-screen progress state.
if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
account.scope.launch(Dispatchers.IO) {
try {
mirrorToOtherServers(result, serverBaseUrl, account)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Background mirror failed", e)
}
}
}
finalState
} catch (_: SignerExceptions.ReadOnlyException) {
UploadingState.Error(Res.string.login_with_a_private_key_to_be_able_to_upload, emptyArray())
} catch (e: BlossomPaymentException) {
// BUD-07: the server wants payment before it will store the blob.
UploadingState.Error(Res.string.blossom_payment_required, arrayOf(e.payment.reason ?: serverBaseUrl))
} catch (e: Exception) {
if (e is CancellationException) throw e
UploadingState.Error(Res.string.failed_to_upload_media, arrayOf(e.message?.ifBlank { null } ?: e.javaClass.simpleName))
}
}
/**
* BUD-04 mirror fan-out: asks every *other* Blossom server in the account's
* kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs
* in the background after the upload is finished (see caller), so it never
* delays the user's post or touches the upload progress UI; per-server failures
* are swallowed. Requires the blob's sha256 so server B can verify the download.
*/
private suspend fun mirrorToOtherServers(
result: MediaUploadResult,
primaryServerBaseUrl: String,
account: Account,
) {
val sourceUrl = result.url ?: return
val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.')
if (hash.length != 64) return
// Only the user's *explicitly configured* kind-10063 servers (flow), NOT the
// DEFAULT_MEDIA_SERVERS fallback that hostNameFlow injects — we must never fan
// uploads out to public defaults the user never opted into.
val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl)
val targets =
account.blossomServers.flow.value
.filter { BlossomServerUrl.domain(it) != primaryDomain }
.distinct()
if (targets.isEmpty()) return
val contentType = result.type ?: "application/octet-stream"
targets.forEach { target ->
try {
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirrorOrUpload(sourceUrl, hash, contentType, target, auth)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Failed to mirror $hash to $target", e)
}
}
}
private suspend fun verifyHeader(
uploadResult: MediaUploadResult,
localContentType: String?,
originalContentType: String?,
originalHash: String?,
okHttpClient: (String) -> OkHttpClient,
): UploadingFinalState {
if (uploadResult.url.isNullOrBlank()) {
return error(Res.string.server_did_not_provide_a_url_after_uploading)
}
updateState(0.6, UploadingState.Downloading)
// Use streaming verification for memory efficiency with large files
val verification =
ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient)
?: return error(Res.string.could_not_download_from_the_server)
updateState(0.8, UploadingState.Hashing)
// Create FileHeader with hash from streaming verification
// Note: We skip blurhash/dimensions since we already have them from upload
val fileHeader =
FileHeader(
mimeType = uploadResult.type ?: localContentType ?: verification.contentType,
hash = verification.hash,
size = verification.size.toInt(),
dim = uploadResult.dimension,
blurHash = uploadResult.blurHash,
)
return finish(
OrchestratorResult.ServerResult(
fileHeader,
uploadResult.url,
uploadResult.magnet,
uploadResult.sha256,
originalContentType,
originalHash,
),
)
}
sealed class OrchestratorResult {
class NIP95Result(
val fileHeader: FileHeader,
val bytes: ByteArray,
val mimeTypeBeforeEncryption: String?,
val hashBeforeEncryption: String?,
) : OrchestratorResult()
class ServerResult(
val fileHeader: FileHeader,
val url: String,
val magnet: String?,
val uploadedHash: String?,
val mimeTypeBeforeEncryption: String?,
val hashBeforeEncryption: String?,
) : OrchestratorResult()
}
suspend fun compressIfNeeded(
uri: Uri,
mimeType: String?,
compressionQuality: CompressorQuality,
context: Context,
useH265: Boolean = false,
convertGifToMp4: Boolean = false,
) = if (compressionQuality != CompressorQuality.UNCOMPRESSED || convertGifToMp4) {
updateState(0.02, UploadingState.Compressing)
MediaCompressor().compress(uri, mimeType, compressionQuality, context.applicationContext, useH265, convertGifToMp4)
} else {
MediaCompressorResult(uri, mimeType, null)
}
private suspend fun stripAfterCompression(
originalUri: Uri,
compressed: MediaCompressorResult,
mimeType: String?,
compressionQuality: CompressorQuality,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
context: Context,
): 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 {
// AvifMetadataNotVerifiableException is allowed to propagate so the caller
// can emit a specific error rather than the generic "Upload cancelled".
MetadataStripper.strip(compressed.uri, effectiveMimeType, context.applicationContext)
}
if (!strippingResult.stripped && !onStrippingFailed()) return null
return strippingResult.uri
}
/**
* Deletes a temporary file created during the upload pipeline if its URI
* differs from the original (meaning it's an intermediate temp file, not the user's content).
*/
internal fun deleteTempUri(
tempUri: Uri,
originalUri: Uri,
) {
if (tempUri == originalUri) return
try {
val path = tempUri.path ?: return
val file = File(path)
if (file.delete()) {
Log.d("UploadOrchestrator") { "Deleted temp file: $path" }
}
} catch (e: Exception) {
Log.w("UploadOrchestrator", "Failed to delete temp file: ${tempUri.path}", e)
}
}
suspend fun upload(
uri: Uri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
val finalUri =
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(Res.string.upload_cancelled).also {
deleteTempUri(compressed.uri, uri)
}
if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri)
try {
return when (server.type) {
ServerType.NIP95 -> uploadNIP95(finalUri, compressed.contentType, null, null, context)
ServerType.NIP96 -> uploadNIP96(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, server.baseUrl, null, null, account, forcedSigner, context)
ServerType.Blossom ->
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(finalUri, compressed.contentType, compressed.size, alt, contentWarningReason, baseUrl, null, null, account, forcedSigner, context, auth)
}
}
} finally {
deleteTempUri(finalUri, uri)
}
}
suspend fun uploadEncrypted(
uri: Uri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
encrypt: NostrCipher,
server: ServerName,
account: Account,
context: Context,
useH265: Boolean = false,
stripMetadata: Boolean = true,
onStrippingFailed: suspend () -> Boolean = { true },
convertGifToMp4: Boolean = false,
forcedSigner: NostrSigner? = null,
): UploadingFinalState {
val compressed = compressIfNeeded(uri, mimeType, compressionQuality, context, useH265, convertGifToMp4)
val finalUri =
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(Res.string.upload_cancelled).also {
deleteTempUri(compressed.uri, uri)
}
if (compressed.uri != finalUri) deleteTempUri(compressed.uri, uri)
val encrypted = EncryptFiles().encryptFile(context, finalUri, encrypt)
deleteTempUri(finalUri, uri)
try {
return when (server.type) {
ServerType.NIP95 -> uploadNIP95(encrypted.uri, encrypted.contentType, compressed.contentType, encrypted.originalHash, context)
ServerType.NIP96 -> uploadNIP96(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, server.baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context)
// The same encrypted file goes to every server tried, so its key, nonce and
// hash stay valid whichever one ends up holding it.
ServerType.Blossom ->
uploadBlossomWithFallback(server, account) { baseUrl, auth ->
uploadBlossom(encrypted.uri, encrypted.contentType, encrypted.size, alt, contentWarningReason, baseUrl, compressed.contentType, encrypted.originalHash, account, forcedSigner, context, auth)
}
}
} finally {
deleteTempUri(encrypted.uri, uri)
}
}
}
@@ -33,6 +33,8 @@ import com.davotoula.lightcompressor.VideoCompressor
import com.davotoula.lightcompressor.config.AppSpecificStorageConfiguration
import com.davotoula.lightcompressor.config.Configuration
import com.davotoula.lightcompressor.config.VideoResizer
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaCompressorResult
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -59,7 +59,6 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
@@ -122,8 +121,6 @@ fun EditPostView(
val postViewModel: EditPostViewModel = viewModel()
postViewModel.init(accountViewModel)
val context = LocalContext.current
val scrollState = rememberScrollState()
val scope = rememberCoroutineScope()
@@ -280,7 +277,7 @@ fun EditPostView(
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ ->
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context, stripMetadata)
postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, accountViewModel.host.mediaUploader, stripMetadata)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.runtime.Stable
@@ -41,17 +40,19 @@ import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
@@ -170,11 +171,11 @@ open class EditPostViewModel : ViewModel() {
isPrivate: Boolean = false,
server: ServerName,
onError: (String, String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
try {
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context, stripMetadata)
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, uploader, stripMetadata)
} catch (e: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -192,7 +193,7 @@ open class EditPostViewModel : ViewModel() {
isPrivate: Boolean = false,
server: ServerName,
onError: (String, String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
accountViewModel.launchSigner {
@@ -206,10 +207,10 @@ open class EditPostViewModel : ViewModel() {
myMultiOrchestrator.upload(
alt,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQuality),
CompressorQuality.fromSlider(mediaQuality),
server,
myAccount,
context,
uploader,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
@@ -218,11 +219,12 @@ open class EditPostViewModel : ViewModel() {
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 =
myAccount.createNip95(
byteArray = state.result.bytes,
headerInfo = state.result.fileHeader,
byteArray = uploaded.bytes,
headerInfo = uploaded.fileHeader,
alt = alt,
contentWarningReason = if (sensitiveContent) "" else null,
)
@@ -232,29 +234,29 @@ open class EditPostViewModel : ViewModel() {
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
} else if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
IMetaTagBuilder(uploaded.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
hash(uploaded.fileHeader.hash)
size(uploaded.fileHeader.size)
uploaded.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
uploaded.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
uploaded.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
uploaded.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
uploaded.magnet?.let { magnet(it) }
uploaded.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
if (sensitiveContent) sensitiveContent("")
}.build()
iMetaAttachments = iMetaAttachments.filter { it.url != iMeta.url } + iMeta
state.result.url
uploaded.url
} else {
null
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -38,13 +37,15 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
@@ -100,13 +101,13 @@ open class NewMediaModel : ViewModel() {
): Boolean = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url)
fun upload(
context: Context,
uploader: MediaUploader,
accountViewModel: AccountViewModel,
onSucess: () -> Unit,
onError: (String, String) -> Unit,
) {
try {
uploadUnsafe(context, accountViewModel, onSucess, onError)
uploadUnsafe(uploader, accountViewModel, onSucess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -118,7 +119,7 @@ open class NewMediaModel : ViewModel() {
}
fun uploadUnsafe(
context: Context,
uploader: MediaUploader,
accountViewModel: AccountViewModel,
onSucess: () -> Unit,
onError: (String, String) -> Unit,
@@ -135,10 +136,10 @@ open class NewMediaModel : ViewModel() {
myMultiOrchestrator.upload(
caption,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
CompressorQuality.fromSlider(mediaQualitySlider),
serverToUse,
myAccount,
context,
uploader,
useH265Codec,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
@@ -46,7 +46,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -72,7 +71,7 @@ import com.vitorpamplona.amethyst.commons.resources.strip_metadata_label
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_description
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_label
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.SetDialogToEdgeToEdge
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
@@ -101,7 +100,6 @@ fun NewMediaView(
initialCaption: String = "",
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
@@ -130,7 +128,7 @@ fun NewMediaView(
onClose()
},
onPost = {
postViewModel.upload(context, accountViewModel, onClose, accountViewModel.toastManager::toast)
postViewModel.upload(accountViewModel.host.mediaUploader, accountViewModel, onClose, accountViewModel.toastManager::toast)
postViewModel.selectedServer?.let {
account.settings.changeDefaultFileServer(it)
}
@@ -33,11 +33,11 @@ import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_de
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -39,7 +39,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.upload_file
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.actions.uploads
import android.content.Context
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -78,18 +78,20 @@ import com.vitorpamplona.amethyst.commons.resources.uploading_state_hashing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_ready
import com.vitorpamplona.amethyst.commons.resources.uploading_state_server_processing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.commons.ui.note.CloseIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -52,7 +52,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.no_camera_app_found
import com.vitorpamplona.amethyst.commons.resources.record_a_video
import com.vitorpamplona.amethyst.commons.resources.take_a_picture
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
@@ -49,10 +49,11 @@ import com.vitorpamplona.amethyst.commons.resources.uploading_state_hashing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_ready
import com.vitorpamplona.amethyst.commons.resources.uploading_state_server_processing
import com.vitorpamplona.amethyst.commons.resources.uploading_state_uploading
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
@Composable
fun UploadProgressIndicator(
@@ -53,7 +53,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.MediaUri
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUri
import com.vitorpamplona.amethyst.commons.ui.components.imageContentReceiver
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
@@ -90,6 +90,8 @@ import com.vitorpamplona.amethyst.commons.resources.strip_metadata_description
import com.vitorpamplona.amethyst.commons.resources.strip_metadata_label
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_description
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_label
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.commons.ui.note.CancelIcon
@@ -102,8 +104,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import kotlinx.collections.immutable.toImmutableList
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -55,7 +54,12 @@ import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.IAudience
@@ -67,12 +71,9 @@ import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
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.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
@@ -787,11 +788,11 @@ open class CommentPostViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
try {
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, stripMetadata)
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, uploader, stripMetadata)
} catch (_: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -808,7 +809,7 @@ open class CommentPostViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -820,10 +821,10 @@ open class CommentPostViewModel :
myMultiOrchestrator.upload(
alt,
contentWarningReason,
MediaCompressor.intToCompressorQuality(mediaQuality),
CompressorQuality.fromSlider(mediaQuality),
server,
account,
context,
uploader,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
@@ -832,30 +833,31 @@ open class CommentPostViewModel :
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(uploaded.bytes, headerInfo = uploaded.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
} else if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
IMetaTagBuilder(uploaded.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
hash(uploaded.fileHeader.hash)
size(uploaded.fileHeader.size)
uploaded.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
uploaded.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
uploaded.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
uploaded.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
uploaded.magnet?.let { magnet(it) }
uploaded.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
@@ -863,7 +865,7 @@ open class CommentPostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
state.result.url
uploaded.url
} else {
null
}
@@ -78,8 +78,8 @@ import com.vitorpamplona.amethyst.commons.resources.post_anonymously
import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.resources.zapraiser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
@@ -472,7 +472,7 @@ private fun GenericCommentPostBody(
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata)
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, accountViewModel.host.mediaUploader, stripMetadata)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -90,6 +90,9 @@ import com.vitorpamplona.amethyst.commons.resources.share_as_image
import com.vitorpamplona.amethyst.commons.resources.share_as_image_generating
import com.vitorpamplona.amethyst.commons.resources.share_as_image_url
import com.vitorpamplona.amethyst.commons.resources.share_as_image_watermark
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.components.LoadNote
import com.vitorpamplona.amethyst.commons.ui.components.TextSpinner
import com.vitorpamplona.amethyst.commons.ui.components.TitleExplainer
@@ -98,10 +101,8 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackBu
import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -268,7 +269,7 @@ fun ShareNoteAsImageScreen(
compressionQuality = CompressorQuality.UNCOMPRESSED,
server = server,
account = account,
context = context,
uploader = accountViewModel.host.mediaUploader,
stripMetadata = false,
)
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_save_the_video
import com.vitorpamplona.amethyst.commons.resources.video_saved_to_the_gallery
import com.vitorpamplona.amethyst.commons.service.isVideoOnline
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -65,8 +66,8 @@ suspend fun AccountViewModel.checkVideoIsOnline(videoUrl: String): Boolean = htt
suspend fun AccountViewModel.uploadMarmotGroupIcon(
uri: Uri,
mimeType: String?,
context: Context,
): MarmotGroupIconUpload = MarmotGroupIconUploader(account).upload(uri, mimeType, account.settings.defaultFileServer, context)
uploader: MediaUploader,
): MarmotGroupIconUpload = MarmotGroupIconUploader(account).upload(uri, mimeType, account.settings.defaultFileServer, uploader)
fun AccountViewModel.saveMediaToGallery(
videoUri: String?,
@@ -28,10 +28,12 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.service.lnurl.LnurlHttpTransport
import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlTransport
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobFailure
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.tor.MoneyOpRelayRouting
import com.vitorpamplona.amethyst.commons.tor.TorRelayEvaluation
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModelHost
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.dismissNotificationForEvent
import com.vitorpamplona.amethyst.service.uploads.AndroidMediaUploader
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -70,6 +72,8 @@ class AndroidAccountViewModelHost(
override val relayAuthState: StateFlow<PersistentMap<NormalizedRelayUrl, RelayAuthSnapshot>>
get() = modules.authCoordinator.receiver.authStateFlow
override val mediaUploader: MediaUploader by lazy { AndroidMediaUploader(modules.appContext) }
override val savedAccounts: Flow<Set<HexKey>> =
LocalPreferences
.accountsFlow()
@@ -54,7 +54,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
@@ -108,7 +107,6 @@ fun NewBadgeDialog(
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
@@ -152,7 +150,7 @@ fun NewBadgeDialog(
},
onPost = {
postViewModel.upload(
context,
accountViewModel.host.mediaUploader,
onSuccess = onClose,
onError = accountViewModel.toastManager::toast,
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.post
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -36,11 +35,13 @@ import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_de
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag
import kotlinx.collections.immutable.ImmutableList
@@ -110,12 +111,12 @@ class NewBadgeModel : ViewModel() {
name.isNotBlank()
fun upload(
context: Context,
uploader: MediaUploader,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
try {
uploadUnsafe(context, onSuccess, onError)
uploadUnsafe(uploader, onSuccess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -127,7 +128,7 @@ class NewBadgeModel : ViewModel() {
}
private fun uploadUnsafe(
context: Context,
uploader: MediaUploader,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
@@ -142,10 +143,10 @@ class NewBadgeModel : ViewModel() {
orch.upload(
alt = name,
contentWarningReason = null,
mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider),
mediaQuality = CompressorQuality.fromSlider(mediaQualitySlider),
server = serverToUse,
account = myAccount,
context = context,
uploader = uploader,
useH265 = false,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
@@ -39,11 +39,11 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -275,7 +275,7 @@ private fun ImageRow(
if (uri == null) return@rememberLauncherForActivityResult
val mime = context.contentResolver.getType(uri)
scope.launch {
val ok = vm.uploadAndSetImage(uri, mime, context)
val ok = vm.uploadAndSetImage(uri, mime, accountViewModel.host.mediaUploader)
if (!ok) {
accountViewModel.toastManager.toast(
Res.string.calendar_event_image_upload_failed,
@@ -20,17 +20,17 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.parseIsoDateToUnixSeconds
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -157,7 +157,7 @@ class NewCalendarEventViewModel : ViewModel() {
suspend fun uploadAndSetImage(
uri: Uri,
mimeType: String?,
context: Context,
uploader: MediaUploader,
): Boolean {
if (!::account.isInitialized) return false
isUploadingImage.value = true
@@ -172,7 +172,7 @@ class NewCalendarEventViewModel : ViewModel() {
compressionQuality = CompressorQuality.MEDIUM,
server = server,
account = account,
context = context,
uploader = uploader,
)
val serverResult =
(result as? UploadingState.Finished)?.result as? UploadOrchestrator.OrchestratorResult.ServerResult
@@ -103,7 +103,9 @@ import com.vitorpamplona.amethyst.commons.resources.cordn_voice_record
import com.vitorpamplona.amethyst.commons.resources.cordn_voice_stop
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
@@ -117,7 +119,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.model.cordn.CordnMediaService
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessageRecorder
@@ -141,6 +142,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* One cordn room.
@@ -518,7 +520,7 @@ private fun CordnGroupChat(
attaching = true
attachError = null
try {
sendAttachment(context, accountViewModel, room, uploadState)
sendAttachment(context, accountViewModel.host.mediaUploader, accountViewModel, room, uploadState)
// Only on success: a failed upload leaves the dialog up
// with what you picked still in it, so retrying is one
// tap rather than the picker again.
@@ -1006,6 +1008,7 @@ private class CordnAttachmentException(
*/
private suspend fun sendAttachment(
context: Context,
uploader: MediaUploader,
accountViewModel: AccountViewModel,
room: CordnGroupChatroom,
state: ChatFileUploadState,
@@ -1048,8 +1051,8 @@ private suspend fun sendAttachment(
item.orchestrator.compressIfNeeded(
uri = uri,
mimeType = declaredMime,
compressionQuality = MediaCompressor.intToCompressorQuality(state.mediaQualitySlider),
context = context,
compressionQuality = CompressorQuality.fromSlider(state.mediaQualitySlider),
uploader = uploader,
)
}
val mime = compressed.contentType ?: declaredMime
@@ -1092,8 +1095,8 @@ private suspend fun sendAttachment(
// copies of a message that is end-to-end encrypted everywhere else — the
// same reason the voice path deletes its recording. Both calls no-op on the
// user's own file.
item.orchestrator.deleteTempUri(finalUri, uri)
item.orchestrator.deleteTempUri(compressed.uri, uri)
deleteTempFile(finalUri, uri)
deleteTempFile(compressed.uri, uri)
}
} finally {
// Always, not just on success: a failure leaves the dialog up to retry from,
@@ -1331,3 +1334,13 @@ private suspend fun sendVoiceNote(
withContext(Dispatchers.IO) { recording.file.delete() }
}
}
/** Deletes a pipeline temp file; a no-op on the user's own file, which is [originalUri]. */
private fun deleteTempFile(
tempUri: Uri,
originalUri: Uri,
) {
if (tempUri == originalUri) return
val path = tempUri.path ?: return
if (!File(path).delete()) Log.w("CordnGroupChat") { "Could not delete temp file $path" }
}
@@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_relays_not
import com.vitorpamplona.amethyst.commons.resources.marmot_keypackage_relays_not_set_title
import com.vitorpamplona.amethyst.commons.resources.marmot_skip_for_now
import com.vitorpamplona.amethyst.commons.resources.marmot_use_outbox_relays
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -122,7 +122,7 @@ fun CreateGroupScreen(
val iconChange =
pickedIcon?.let { media ->
MarmotGroupIconChange.Set(
accountViewModel.uploadMarmotGroupIcon(media.uri, media.mimeType, context),
accountViewModel.uploadMarmotGroupIcon(media.uri, media.mimeType, accountViewModel.host.mediaUploader),
)
} ?: MarmotGroupIconChange.Keep
// Always commit an initial metadata extension so that
@@ -57,7 +57,7 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_group_name
import com.vitorpamplona.amethyst.commons.resources.marmot_group_name_placeholder
import com.vitorpamplona.amethyst.commons.resources.marmot_legacy_group_no_avatar_url
import com.vitorpamplona.amethyst.commons.resources.save
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -114,7 +114,7 @@ fun EditGroupInfoScreen(
val iconChange =
pickedIcon?.let { media ->
MarmotGroupIconChange.Set(
accountViewModel.uploadMarmotGroupIcon(media.uri, media.mimeType, context),
accountViewModel.uploadMarmotGroupIcon(media.uri, media.mimeType, accountViewModel.host.mediaUploader),
)
} ?: if (removeIcon) MarmotGroupIconChange.Clear else MarmotGroupIconChange.Keep
accountViewModel.updateMarmotGroupMetadata(
@@ -67,8 +67,8 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_b
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_confirm_title
import com.vitorpamplona.amethyst.commons.resources.marmot_out_of_sync_reset
import com.vitorpamplona.amethyst.commons.resources.reply_here
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -389,6 +389,7 @@ private fun MarmotGroupFileUploadDialog(
}
},
context = context,
uploader = accountViewModel.host.mediaUploader,
onceUploaded = { uploads ->
MarmotFileSender(nostrGroupId, accountViewModel).send(uploads)
onUpload()
@@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.commons.resources.marmot_add_photo
import com.vitorpamplona.amethyst.commons.resources.marmot_change_photo
import com.vitorpamplona.amethyst.commons.resources.marmot_group_icon
import com.vitorpamplona.amethyst.commons.resources.marmot_remove_photo
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -25,10 +25,12 @@ import android.net.Uri
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2
import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaReferenceV2
@@ -78,6 +80,7 @@ class MarmotFileUploader(
exporterSecret: ByteArray,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
/**
* Produce `encrypted-media-v2` references instead of MIP-04 ones.
* Decided by the group's policy component, not by the uploader.
@@ -88,7 +91,7 @@ class MarmotFileUploader(
viewState.mediaUploadTracker.startUpload(multiOrchestrator.hasNonMedia())
val results = mutableListOf<Mip04UploadResult>()
val quality = MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider)
val quality = CompressorQuality.fromSlider(viewState.mediaQualitySlider)
val count = multiOrchestrator.size()
for (i in 0 until count) {
@@ -130,13 +133,13 @@ class MarmotFileUploader(
encrypt = cipher,
server = viewState.selectedServer,
account = account,
context = context,
uploader = uploader,
stripMetadata = viewState.stripMetadata,
)
val state = item.orchestrator.progressState.value
if (state is UploadingState.Finished && state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val serverResult = state.result
val serverResult = (state as? UploadingState.Finished)?.result as? UploadOrchestrator.OrchestratorResult.ServerResult
if (serverResult != null) {
// The reference is built from what the cipher recorded while
// encrypting the bytes the pipeline actually uploaded — after
// compression and metadata stripping — because that is what the
@@ -20,16 +20,16 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send
import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.commons.marmot.MarmotGroupIconUpload
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -53,13 +53,13 @@ class MarmotGroupIconUploader(
uri: Uri,
mimeType: String?,
server: ServerName,
context: Context,
uploader: MediaUploader,
): MarmotGroupIconUpload {
// Compress/downscale up front — avatars don't need full resolution, and a smaller
// blob is cheaper for every member to fetch. The MIP-01 crypto uses no AAD and does
// not bind the MIME type, so the (possibly transcoded) output type is irrelevant to
// decryption; we just hand the compressed bytes to the encrypting uploader.
val compressed = MediaCompressor().compress(uri, mimeType, CompressorQuality.MEDIUM, context.applicationContext)
val compressed = UploadOrchestrator().compressIfNeeded(uri, mimeType, CompressorQuality.MEDIUM, uploader)
val uploadMime = compressed.contentType ?: mimeType ?: DEFAULT_MIME
val cipher = MarmotGroupImageCipher.forNewImage()
val uploadKeySeed = MarmotGroupImageEncryption.generateUploadKey()
@@ -76,13 +76,13 @@ class MarmotGroupIconUploader(
encrypt = cipher,
server = server,
account = account,
context = context,
uploader = uploader,
stripMetadata = true,
forcedSigner = uploadSigner,
)
if (state is UploadingState.Finished && state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val serverResult = state.result
val serverResult = (state as? UploadingState.Finished)?.result as? UploadOrchestrator.OrchestratorResult.ServerResult
if (serverResult != null) {
val hash =
serverResult.uploadedHash
?: throw IllegalStateException("Blossom server did not return a content hash for the group icon")
@@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.latestMarmotEdit
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
@@ -67,8 +67,8 @@ import com.vitorpamplona.amethyst.commons.resources.chat_minichat_title
import com.vitorpamplona.amethyst.commons.resources.chat_send_image_title
import com.vitorpamplona.amethyst.commons.resources.concord_open_channel
import com.vitorpamplona.amethyst.commons.resources.reply_here
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription
@@ -240,7 +240,7 @@ fun MinichatScreen(
onEncryptedUploadError = { title, message ->
launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() }
},
context = context,
uploader = accountViewModel.host.mediaUploader,
onceUploaded = { uploads ->
val caption = uploadState.caption
val imetas = if (isConcord) uploads.mapNotNull { it.toConcordImeta() } else uploads.toPlainImetas()
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -48,7 +47,8 @@ import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.note.creators.zapsplits.IZapField
@@ -473,7 +473,7 @@ class ChatNewMessageViewModel :
fun uploadAndHold(
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
onceUploaded: () -> Unit,
) {
val uploadState = uploadState ?: return
@@ -487,7 +487,7 @@ class ChatNewMessageViewModel :
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.HOLD
},
context,
uploader,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
) {
uploadsWaitingToBeSent += it
@@ -499,7 +499,7 @@ class ChatNewMessageViewModel :
fun uploadAndSend(
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
onceUploaded: () -> Unit,
) {
val room = room.value ?: return
@@ -514,10 +514,10 @@ class ChatNewMessageViewModel :
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.SEND
pendingRetryOnError = onError
pendingRetryContext = context
pendingRetryUploader = uploader
pendingRetryOnceUploaded = onceUploaded
},
context,
uploader,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
) {
ChatFileSender(room, account).sendNIP17(it)
@@ -532,7 +532,7 @@ class ChatNewMessageViewModel :
var encryptedUploadErrorMessage by mutableStateOf<String?>(null)
var pendingRetryMode by mutableStateOf<RetryMode?>(null)
var pendingRetryOnError by mutableStateOf<((String, String) -> Unit)?>(null)
var pendingRetryContext by mutableStateOf<Context?>(null)
var pendingRetryUploader by mutableStateOf<MediaUploader?>(null)
var pendingRetryOnceUploaded by mutableStateOf<(() -> Unit)?>(null)
enum class RetryMode { HOLD, SEND }
@@ -542,21 +542,21 @@ class ChatNewMessageViewModel :
encryptedUploadErrorMessage = null
pendingRetryMode = null
pendingRetryOnError = null
pendingRetryContext = null
pendingRetryUploader = null
pendingRetryOnceUploaded = null
}
fun retryWithoutEncryption() {
val mode = pendingRetryMode ?: return
val onError = pendingRetryOnError
val context = pendingRetryContext
val uploader = pendingRetryUploader
val onceUploaded = pendingRetryOnceUploaded
val room = room.value
val uploadState = uploadState
dismissEncryptedUploadError()
if (room == null || uploadState == null || context == null) return
if (room == null || uploadState == null || uploader == null) return
uploadState.encryptFiles = false
@@ -566,7 +566,7 @@ class ChatNewMessageViewModel :
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
context,
uploader,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
) {
uploadsWaitingToBeSent += it
@@ -579,7 +579,7 @@ class ChatNewMessageViewModel :
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
context,
uploader,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
) {
ChatFileSender(room, account).sendNIP17(it)
@@ -25,7 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.service.upload.FileHeader
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.prepare
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
@@ -87,8 +87,8 @@ import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -308,7 +308,7 @@ fun GroupDMScreenContent(
onAdd = { _, server, _, _, _, _, _ ->
postViewModel.uploadAndHold(
accountViewModel.toastManager::toast,
context,
accountViewModel.host.mediaUploader,
onceUploaded = { },
)
accountViewModel.account.settings.changeDefaultFileServer(server)
@@ -42,7 +42,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -62,8 +61,8 @@ import com.vitorpamplona.amethyst.commons.resources.retry_without_encryption
import com.vitorpamplona.amethyst.commons.resources.this_message_will_disappear_in
import com.vitorpamplona.amethyst.commons.resources.upload_without_encryption_warning
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -187,7 +186,6 @@ fun PrivateMessageEditFieldRow(
if (channelScreenModel.wantsExpirationDate) {
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.Center) {
val context = LocalContext.current
Text(
stringRes(
Res.string.this_message_will_disappear_in,
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.uploa
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
@@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -49,7 +48,6 @@ fun RoomChatFileUploadDialog(
) {
val roomState = channelScreenModel.room.collectAsStateWithLifecycle()
val room = roomState.value ?: return
val context = LocalContext.current
ChatFileUploadDialog(
state,
@@ -67,7 +65,7 @@ fun RoomChatFileUploadDialog(
upload = {
channelScreenModel.uploadAndSend(
onError = accountViewModel.toastManager::toast,
context = context,
uploader = accountViewModel.host.mediaUploader,
onceUploaded = onUpload,
)
@@ -20,16 +20,17 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_encrypted_media_message
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_encrypted_media_title
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
@@ -44,7 +45,7 @@ class ChatFileUploader(
viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit,
onEncryptedUploadError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
onStrippingFailed: suspend () -> Boolean = { true },
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) {
@@ -58,11 +59,11 @@ class ChatFileUploader(
orchestrator.uploadEncrypted(
viewState.caption,
viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
CompressorQuality.fromSlider(viewState.mediaQualitySlider),
cipher,
viewState.selectedServer,
account,
context,
uploader,
stripMetadata = viewState.stripMetadata,
onStrippingFailed = onStrippingFailed,
)
@@ -70,8 +71,9 @@ class ChatFileUploader(
if (results.allGood) {
val list =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher)
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(uploaded, viewState.caption, viewState.contentWarningReason, cipher)
} else {
null
}
@@ -97,7 +99,7 @@ class ChatFileUploader(
}
}
} else {
justUploadNIP17Unencrypted(viewState, onError, context, onStrippingFailed, onceUploaded)
justUploadNIP17Unencrypted(viewState, onError, uploader, onStrippingFailed, onceUploaded)
}
viewState.mediaUploadTracker.finishUpload()
@@ -106,7 +108,7 @@ class ChatFileUploader(
suspend fun justUploadNIP17Unencrypted(
viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
onStrippingFailed: suspend () -> Boolean = { true },
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) {
@@ -117,10 +119,10 @@ class ChatFileUploader(
orchestrator.upload(
viewState.caption,
viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
CompressorQuality.fromSlider(viewState.mediaQualitySlider),
viewState.selectedServer,
account,
context,
uploader,
stripMetadata = viewState.stripMetadata,
onStrippingFailed = onStrippingFailed,
)
@@ -128,8 +130,9 @@ class ChatFileUploader(
if (results.allGood) {
val list =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null)
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(uploaded, viewState.caption, viewState.contentWarningReason, null)
} else {
null
}
@@ -153,7 +156,7 @@ class ChatFileUploader(
suspend fun justUploadNIP04(
viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
onStrippingFailed: suspend () -> Boolean = { true },
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) {
@@ -164,10 +167,10 @@ class ChatFileUploader(
orchestrator.upload(
viewState.caption,
viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
CompressorQuality.fromSlider(viewState.mediaQualitySlider),
viewState.selectedServer,
account,
context,
uploader,
stripMetadata = viewState.stripMetadata,
onStrippingFailed = onStrippingFailed,
)
@@ -175,8 +178,9 @@ class ChatFileUploader(
if (results.allGood) {
val list =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null)
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(uploaded, viewState.caption, viewState.contentWarningReason, null)
} else {
null
}
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
@Stable
@@ -73,8 +73,8 @@ import com.vitorpamplona.amethyst.commons.resources.concord_typing_many
import com.vitorpamplona.amethyst.commons.resources.concord_typing_one
import com.vitorpamplona.amethyst.commons.resources.concord_typing_two
import com.vitorpamplona.amethyst.commons.resources.reply_here
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor
import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers
@@ -578,7 +578,7 @@ private fun ConcordFileUploadDialog(
onEncryptedUploadError = { title, message ->
scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() }
},
context = context,
uploader = accountViewModel.host.mediaUploader,
onceUploaded = { uploads ->
val imetas = uploads.mapNotNull { it.toConcordImeta() }
if (imetas.isNotEmpty()) {
@@ -26,10 +26,12 @@ import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
@@ -60,6 +62,7 @@ class ConcordImageUploader(
suspend fun uploadEncrypted(
uri: Uri,
context: Context,
uploader: MediaUploader,
): ImagePointer =
withContext(Dispatchers.IO) {
// Fresh random key + nonce per image; we hold onto them to build the pointer below since the
@@ -76,7 +79,7 @@ class ConcordImageUploader(
encrypt = cipher,
server = resolveBlossomServer(),
account = account,
context = context,
uploader = uploader,
)
val result =
@@ -168,7 +168,7 @@ private fun ConcordIconHero(
uploading = true
scope.launch {
try {
icon.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context)
icon.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context, accountViewModel.host.mediaUploader)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -239,7 +239,7 @@ private fun ConcordBannerHero(
uploading = true
scope.launch {
try {
banner.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context)
banner.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context, accountViewModel.host.mediaUploader)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
@@ -34,7 +34,7 @@ import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
@@ -40,13 +40,13 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -43,11 +43,11 @@ import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -59,7 +58,6 @@ fun ChannelFileUploadDialog(
nav: INav,
) {
val channel = channelScreenModel.channel ?: return
val context = LocalContext.current
ChatFileUploadDialog(
state,
@@ -133,7 +131,7 @@ fun ChannelFileUploadDialog(
upload = {
channelScreenModel.upload(
onError = accountViewModel.toastManager::toast,
context = context,
uploader = accountViewModel.host.mediaUploader,
onceUploaded = onUpload,
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -59,18 +58,20 @@ import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
@@ -495,11 +496,11 @@ open class ChannelNewMessageViewModel :
fun upload(
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
onceUploaded: suspend () -> Unit,
) {
try {
uploadUnsafe(onError, context, onceUploaded)
uploadUnsafe(onError, uploader, onceUploaded)
} catch (_: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -512,7 +513,7 @@ open class ChannelNewMessageViewModel :
fun uploadUnsafe(
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
onceUploaded: suspend () -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -526,10 +527,10 @@ open class ChannelNewMessageViewModel :
myMultiOrchestrator.upload(
uploadState.caption,
uploadState.contentWarningReason,
MediaCompressor.intToCompressorQuality(uploadState.mediaQualitySlider),
CompressorQuality.fromSlider(uploadState.mediaQualitySlider),
uploadState.selectedServer,
account,
context,
uploader,
stripMetadata = uploadState.stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
@@ -537,16 +538,17 @@ open class ChannelNewMessageViewModel :
if (results.allGood) {
val urls =
results.successful.mapNotNull { upload ->
if (upload.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(upload.result.bytes, headerInfo = upload.result.fileHeader, uploadState.caption, uploadState.contentWarningReason)
val uploaded = upload.result
if (uploaded is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(uploaded.bytes, headerInfo = uploaded.fileHeader, uploadState.caption, uploadState.contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.toNostrUri()
} else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason)
} else if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(uploaded, uploadState.caption, uploadState.contentWarningReason)
upload.result.url
uploaded.url
} else {
null
}
@@ -53,8 +53,8 @@ import com.vitorpamplona.amethyst.commons.resources.buzz_editing_banner
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.reply_here
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
@@ -28,9 +28,9 @@ import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import kotlinx.collections.immutable.ImmutableList
@Stable
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.newCommunity
import android.content.Context
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
@@ -39,11 +38,13 @@ import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_de
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -271,12 +272,12 @@ class NewCommunityModel : ViewModel() {
@OptIn(ExperimentalUuidApi::class)
fun publish(
context: Context,
uploader: MediaUploader,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
try {
publishUnsafe(context, onSuccess, onError)
publishUnsafe(uploader, onSuccess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -289,7 +290,7 @@ class NewCommunityModel : ViewModel() {
@OptIn(ExperimentalUuidApi::class)
private fun publishUnsafe(
context: Context,
uploader: MediaUploader,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
@@ -300,7 +301,7 @@ class NewCommunityModel : ViewModel() {
try {
val uploadedUrl =
if (multiOrchestrator != null) {
uploadImageIfAny(context, myAccount, onError) ?: return@launch
uploadImageIfAny(uploader, myAccount, onError) ?: return@launch
} else {
null
}
@@ -371,7 +372,7 @@ class NewCommunityModel : ViewModel() {
}
private suspend fun uploadImageIfAny(
context: Context,
uploader: MediaUploader,
myAccount: Account,
onError: (String, String) -> Unit,
): String? {
@@ -382,10 +383,10 @@ class NewCommunityModel : ViewModel() {
orch.upload(
alt = name.trim().ifBlank { "Community cover" },
contentWarningReason = null,
mediaQuality = MediaCompressor.intToCompressorQuality(mediaQualitySlider),
mediaQuality = CompressorQuality.fromSlider(mediaQualitySlider),
server = serverToUse,
account = myAccount,
context = context,
uploader = uploader,
useH265 = false,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
@@ -58,7 +58,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
@@ -137,7 +136,6 @@ private fun CommunityFormScreen(
nav: INav,
) {
val model: NewCommunityModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel.account) {
model.init(accountViewModel.account)
@@ -185,7 +183,7 @@ private fun CommunityFormScreen(
},
onPost = {
model.publish(
context = context,
uploader = accountViewModel.host.mediaUploader,
onSuccess = { nav.popBack() },
onError = accountViewModel.toastManager::toast,
)
@@ -201,7 +199,7 @@ private fun CommunityFormScreen(
},
onPost = {
model.publish(
context = context,
uploader = accountViewModel.host.mediaUploader,
onSuccess = { nav.popBack() },
onError = accountViewModel.toastManager::toast,
)
@@ -101,9 +101,9 @@ import com.vitorpamplona.amethyst.commons.resources.url_slug_placeholder
import com.vitorpamplona.amethyst.commons.resources.your_article_title
import com.vitorpamplona.amethyst.commons.resources.zapraiser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
@@ -238,15 +238,13 @@ private fun MarkdownPostScreenBody(
.fillMaxWidth()
.verticalScroll(scrollState, reverseScrolling = true),
) {
val context = LocalContext.current
// Banner image area
BannerImageArea(
coverImageUrl = postViewModel.coverImageUrl,
isUploading = postViewModel.isUploadingCoverImage,
accountViewModel = accountViewModel,
onImageSelected = {
postViewModel.uploadCoverImage(it, context, onError = accountViewModel.toastManager::toast)
postViewModel.uploadCoverImage(it, accountViewModel.host.mediaUploader, onError = accountViewModel.toastManager::toast)
},
onClear = {
postViewModel.coverImageUrl = ""
@@ -498,7 +496,7 @@ private fun MarkdownPostScreenBody(
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata, convertGifToMp4 ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, uploadContext, useH265, stripMetadata, convertGifToMp4)
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, accountViewModel.host.mediaUploader, useH265, stripMetadata, convertGifToMp4)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -46,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.model.composer.SplitBuilder
import com.vitorpamplona.amethyst.commons.model.composer.toZapSplitSetup
import com.vitorpamplona.amethyst.commons.model.location.LocationResult
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.resources.Res
@@ -57,7 +55,13 @@ import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
@@ -67,15 +71,9 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
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.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
@@ -122,7 +120,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import kotlin.uuid.ExperimentalUuidApi
@Stable
@@ -467,7 +464,7 @@ class LongFormPostViewModel :
fun uploadCoverImage(
uri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onError: (String, String) -> Unit,
) {
accountViewModel.launchSigner {
@@ -475,7 +472,7 @@ class LongFormPostViewModel :
try {
directUpload(
galleryUri = uri,
context = context,
uploader = uploader,
onError = onError,
)?.let {
coverImageUrl = it
@@ -488,49 +485,36 @@ class LongFormPostViewModel :
private suspend fun directUpload(
galleryUri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onError: (String, String) -> Unit,
): String? {
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
val state =
UploadOrchestrator().upload(
uri = galleryUri.uri,
mimeType = galleryUri.mimeType,
alt = null,
contentWarningReason = null,
compressionQuality = CompressorQuality.MEDIUM,
server = account.settings.defaultFileServer,
account = account,
uploader = uploader,
)
return try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = accountViewModel.httpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
return when (state) {
is UploadingState.Finished -> {
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
uploaded.url
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = accountViewModel.httpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading))
null
}
if (result.url == null) {
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading))
}
result.url
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
null
is UploadingState.Error -> {
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(state.errorResource, *state.params))
null
}
else -> null
}
}
@@ -540,13 +524,13 @@ class LongFormPostViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
useH265: Boolean,
stripMetadata: Boolean = true,
convertGifToMp4: Boolean = false,
) {
try {
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata, convertGifToMp4)
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, uploader, useH265, stripMetadata, convertGifToMp4)
} catch (_: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -563,7 +547,7 @@ class LongFormPostViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
useH265: Boolean,
stripMetadata: Boolean = true,
convertGifToMp4: Boolean = false,
@@ -577,10 +561,10 @@ class LongFormPostViewModel :
myMultiOrchestrator.upload(
alt,
contentWarningReason,
MediaCompressor.intToCompressorQuality(mediaQuality),
CompressorQuality.fromSlider(mediaQuality),
server,
account,
context,
uploader,
useH265,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
@@ -590,29 +574,30 @@ class LongFormPostViewModel :
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
IMetaTagBuilder(uploaded.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
hash(uploaded.fileHeader.hash)
size(uploaded.fileHeader.size)
uploaded.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
uploaded.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
uploaded.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
uploaded.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
uploaded.magnet?.let { magnet(it) }
uploaded.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
}.build()
iMetaAttachments.replace(iMeta.url, iMeta)
val markdownImage = "![${alt ?: ""}](${state.result.url})"
val markdownImage = "![${alt ?: ""}](${uploaded.url})"
markdownImage
} else {
null
@@ -52,8 +52,8 @@ import com.vitorpamplona.amethyst.commons.resources.lightning_invoice
import com.vitorpamplona.amethyst.commons.resources.new_product
import com.vitorpamplona.amethyst.commons.resources.zapraiser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -292,7 +292,7 @@ private fun NewProductBody(
defaultServer = accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata)
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, accountViewModel.host.mediaUploader, stripMetadata)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -52,7 +51,12 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
@@ -61,12 +65,9 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
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.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
@@ -400,7 +401,7 @@ open class NewProductViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -413,40 +414,41 @@ open class NewProductViewModel :
myMultiOrchestrator.upload(
alt,
contentWarningReason,
MediaCompressor.intToCompressorQuality(mediaQuality),
CompressorQuality.fromSlider(mediaQuality),
server,
myAccount,
context,
uploader,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (results.allGood) {
val urls =
results.successful.mapNotNull {
if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
if (it.result.fileHeader.mimeType
results.successful.mapNotNull { finished ->
val uploaded = finished.result
if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
if (uploaded.fileHeader.mimeType
?.startsWith("image") == true
) {
productImages = productImages +
ProductImageMeta(
url = it.result.url,
mimeType = it.result.fileHeader.mimeType,
url = uploaded.url,
mimeType = uploaded.fileHeader.mimeType,
blurhash =
it.result.fileHeader.blurHash
uploaded.fileHeader.blurHash
?.blurhash,
dimension = it.result.fileHeader.dim,
dimension = uploaded.fileHeader.dim,
alt = alt,
hash = it.result.fileHeader.hash,
size = it.result.fileHeader.size,
hash = uploaded.fileHeader.hash,
size = uploaded.fileHeader.size,
thumbhash =
it.result.fileHeader.thumbHash
uploaded.fileHeader.thumbHash
?.thumbhash,
)
} else {
iMetaDescription.add(it.result, alt, contentWarningReason)
iMetaDescription.add(uploaded, alt, contentWarningReason)
it.result.url
uploaded.url
}
} else {
null
@@ -38,10 +38,10 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -42,11 +42,11 @@ import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -67,7 +67,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -142,7 +141,6 @@ fun NewGeocacheScreen(
) {
val model: NewGeocacheViewModel = viewModel()
val scope = rememberCoroutineScope()
val context = LocalContext.current
remember(editKind, editPubKeyHex, editDTag, prefillGeohash) {
model.init(accountViewModel)
@@ -158,7 +156,7 @@ fun NewGeocacheScreen(
val photoPicker =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) scope.launch { model.uploadImage(uri, null, context) }
if (uri != null) scope.launch { model.uploadImage(uri, null, accountViewModel.host.mediaUploader) }
}
Scaffold(
@@ -20,17 +20,17 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.create
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -177,7 +177,7 @@ class NewGeocacheViewModel : ViewModel() {
suspend fun uploadImage(
uri: Uri,
mimeType: String?,
context: Context,
uploader: MediaUploader,
): Boolean {
if (!::account.isInitialized) return false
isUploading.value = true
@@ -191,7 +191,7 @@ class NewGeocacheViewModel : ViewModel() {
compressionQuality = CompressorQuality.MEDIUM,
server = account.settings.defaultFileServer,
account = account,
context = context,
uploader = uploader,
)
val url =
((result as? UploadingState.Finished)?.result as? UploadOrchestrator.OrchestratorResult.ServerResult)?.url
@@ -63,7 +63,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -124,7 +123,6 @@ fun LogGeocacheFindScreen(
val address = remember(kind, pubKeyHex, dTag) { Address(kind, pubKeyHex, dTag) }
val model: LogGeocacheFindViewModel = viewModel(key = "LogGeocacheFind-${address.toValue()}")
val scope = rememberCoroutineScope()
val context = LocalContext.current
remember(address) {
model.init(accountViewModel, address)
@@ -137,7 +135,7 @@ fun LogGeocacheFindScreen(
val photoPicker =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
scope.launch { model.uploadImage(uri, null, context) }
scope.launch { model.uploadImage(uri, null, accountViewModel.host.mediaUploader) }
}
}
@@ -20,17 +20,17 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geocaches.log
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -185,7 +185,7 @@ class LogGeocacheFindViewModel : ViewModel() {
suspend fun uploadImage(
uri: Uri,
mimeType: String?,
context: Context,
uploader: MediaUploader,
): Boolean {
if (!::account.isInitialized) return false
isUploading.value = true
@@ -199,7 +199,7 @@ class LogGeocacheFindViewModel : ViewModel() {
compressionQuality = CompressorQuality.MEDIUM,
server = account.settings.defaultFileServer,
account = account,
context = context,
uploader = uploader,
)
val url =
((result as? UploadingState.Finished)?.result as? UploadOrchestrator.OrchestratorResult.ServerResult)?.url
@@ -27,7 +27,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
@@ -27,7 +27,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
@@ -109,8 +109,8 @@ import com.vitorpamplona.amethyst.commons.resources.schedule_post_always_on_prom
import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.resources.zapraiser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.PostingTopBar
@@ -616,7 +616,7 @@ private fun NewPostScreenBody(
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, useH265, stripMetadata, convertGifToMp4 ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265, stripMetadata, convertGifToMp4)
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, accountViewModel.host.mediaUploader, useH265, stripMetadata, convertGifToMp4)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -69,7 +69,13 @@ import com.vitorpamplona.amethyst.commons.service.ai.WritingTone
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.commons.ui.note.creators.notify.IAudience
@@ -81,17 +87,12 @@ import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.text.setTextAndPlaceCursorAtBeginning
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.ai.WritingAssistantFactory
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.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
@@ -1578,13 +1579,13 @@ open class ShortNotePostViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
useH265: Boolean,
stripMetadata: Boolean = true,
convertGifToMp4: Boolean = false,
) {
try {
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, useH265, stripMetadata, convertGifToMp4)
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, uploader, useH265, stripMetadata, convertGifToMp4)
} catch (_: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -1601,7 +1602,7 @@ open class ShortNotePostViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
useH265: Boolean,
stripMetadata: Boolean = true,
convertGifToMp4: Boolean = false,
@@ -1615,10 +1616,10 @@ open class ShortNotePostViewModel :
myMultiOrchestrator.upload(
alt,
contentWarningReason,
MediaCompressor.intToCompressorQuality(mediaQuality),
CompressorQuality.fromSlider(mediaQuality),
server,
account,
context,
uploader,
useH265,
stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
@@ -1629,30 +1630,31 @@ open class ShortNotePostViewModel :
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(uploaded.bytes, headerInfo = uploaded.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
} else if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
IMetaTagBuilder(uploaded.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
hash(uploaded.fileHeader.hash)
size(uploaded.fileHeader.size)
uploaded.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
uploaded.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
uploaded.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
uploaded.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
uploaded.magnet?.let { magnet(it) }
uploaded.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
@@ -1660,7 +1662,7 @@ open class ShortNotePostViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
state.result.url
uploaded.url
} else {
null
}
@@ -1956,7 +1958,7 @@ open class ShortNotePostViewModel :
compressionQuality = CompressorQuality.UNCOMPRESSED,
server = server,
account = account,
context = appContext,
uploader = accountViewModel.host.mediaUploader,
useH265 = false,
forcedSigner = if (wantsAnonymousPost) anonymousSigner() else null,
)
@@ -38,12 +38,12 @@ import com.vitorpamplona.amethyst.commons.resources.upload_error_voice_message_e
import com.vitorpamplona.amethyst.commons.resources.upload_error_voice_message_failed
import com.vitorpamplona.amethyst.commons.resources.upload_error_voice_message_nip95_not_supported
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag
@@ -204,7 +204,7 @@ class VoiceReplyViewModel : ViewModel() {
compressionQuality = CompressorQuality.UNCOMPRESSED,
server = serverToUse,
account = accountViewModel.account,
context = Amethyst.instance.appContext,
uploader = accountViewModel.host.mediaUploader,
useH265 = false,
)
}
@@ -39,11 +39,11 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -39,11 +39,11 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -50,8 +50,8 @@ import com.vitorpamplona.amethyst.commons.resources.ic_compose
import com.vitorpamplona.amethyst.commons.resources.new_long_video
import com.vitorpamplona.amethyst.commons.resources.record_a_video
import com.vitorpamplona.amethyst.commons.resources.upload_image
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -50,9 +50,9 @@ import coil3.compose.rememberAsyncImagePainter
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
import org.jetbrains.compose.resources.StringResource
@@ -27,14 +27,15 @@ import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
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.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.quartz.experimental.music.playlist.MusicPlaylistEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import kotlinx.collections.immutable.ImmutableList
@@ -214,10 +215,11 @@ class NewMusicPlaylistViewModel : ViewModel() {
coverOrchestrator = coverMedia.value,
existingCoverUrl = coverUrl.value.trim().ifBlank { null },
server = server,
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
quality = CompressorQuality.fromSlider(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
loadedEvent = loadedEvent,
appContext = context.applicationContext,
uploader = accountViewModel.host.mediaUploader,
)
isSending.value = true
@@ -265,6 +267,7 @@ class NewMusicPlaylistViewModel : ViewModel() {
val stripMetadata: Boolean,
val loadedEvent: MusicPlaylistEvent?,
val appContext: Context,
val uploader: MediaUploader,
)
private suspend fun runCoverUpload(
@@ -279,13 +282,13 @@ class NewMusicPlaylistViewModel : ViewModel() {
mediaQuality = snapshot.quality,
server = server,
account = account,
context = snapshot.appContext,
uploader = snapshot.uploader,
useH265 = false,
stripMetadata = snapshot.stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (!res.allGood) {
throw UploadException(formatUploadErrors(res.errors, snapshot.appContext))
throw UploadException(formatUploadErrors(res.errors))
}
return firstUploadedUrl(res.successful)
?: throw UploadException("Server didn't return a URL for the uploaded cover.")
@@ -300,10 +303,7 @@ class NewMusicPlaylistViewModel : ViewModel() {
.firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult }
?.url
private suspend fun formatUploadErrors(
errors: List<UploadingState.Error>,
context: Context,
): String =
private suspend fun formatUploadErrors(errors: List<UploadingState.Error>): String =
errors
.map { loadStringRes(it.errorResource, *it.params) }
.distinct()
@@ -84,8 +84,8 @@ import com.vitorpamplona.amethyst.commons.resources.music_track_title_placeholde
import com.vitorpamplona.amethyst.commons.resources.music_track_uploading_banner
import com.vitorpamplona.amethyst.commons.resources.new_music_track
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -30,13 +30,15 @@ import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
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.UploadOrchestrator
import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import kotlinx.collections.immutable.ImmutableList
@@ -288,10 +290,11 @@ class NewMusicTrackViewModel : ViewModel() {
existingCoverUrl = coverUrl.value.trim().ifBlank { null },
existingAudioUrl = audioUrl.value.trim(),
server = server,
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
quality = CompressorQuality.fromSlider(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
loadedEvent = loadedEvent,
appContext = context.applicationContext,
uploader = accountViewModel.host.mediaUploader,
)
isSending.value = true
@@ -363,6 +366,7 @@ class NewMusicTrackViewModel : ViewModel() {
val stripMetadata: Boolean,
val loadedEvent: MusicTrackEvent?,
val appContext: Context,
val uploader: MediaUploader,
)
/**
@@ -403,13 +407,13 @@ class NewMusicTrackViewModel : ViewModel() {
mediaQuality = snapshot.quality,
server = snapshot.server,
account = account,
context = snapshot.appContext,
uploader = snapshot.uploader,
useH265 = false,
stripMetadata = snapshot.stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
if (!res.allGood) {
throw UploadException(kind, formatUploadErrors(res.errors, snapshot.appContext))
throw UploadException(kind, formatUploadErrors(res.errors))
}
return firstUploadedUrl(res.successful)
?: throw UploadException(kind, "Server didn't return a URL for the uploaded $kind.")
@@ -420,15 +424,12 @@ class NewMusicTrackViewModel : ViewModel() {
details: String,
) : Exception("$kind upload failed: $details")
private fun firstUploadedUrl(successful: List<com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished>): String? =
private fun firstUploadedUrl(successful: List<UploadingState.Finished>): String? =
successful
.firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult }
?.url
private suspend fun formatUploadErrors(
errors: List<com.vitorpamplona.amethyst.service.uploads.UploadingState.Error>,
context: Context,
): String =
private suspend fun formatUploadErrors(errors: List<UploadingState.Error>): String =
errors
.map { loadStringRes(it.errorResource, *it.params) }
.distinct()
@@ -31,11 +31,11 @@ import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_de
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -40,8 +40,8 @@ import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggesti
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.reply_here
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -29,7 +29,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -56,7 +55,6 @@ fun NestFileUploadDialog(
nav: INav,
) {
val room = nestScreenModel.room ?: return
val context = LocalContext.current
val host = room.author ?: return
val title = (room.event as? MeetingSpaceEvent)?.room().orEmpty()
@@ -93,7 +91,7 @@ fun NestFileUploadDialog(
upload = {
nestScreenModel.upload(
onError = accountViewModel.toastManager::toast,
context = context,
uploader = accountViewModel.host.mediaUploader,
onceUploaded = onUpload,
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -50,17 +49,19 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState
@@ -349,11 +350,11 @@ open class NestNewMessageViewModel :
fun upload(
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
onceUploaded: suspend () -> Unit,
) {
try {
uploadUnsafe(onError, context, onceUploaded)
uploadUnsafe(onError, uploader, onceUploaded)
} catch (_: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -366,7 +367,7 @@ open class NestNewMessageViewModel :
fun uploadUnsafe(
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
onceUploaded: suspend () -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -380,10 +381,10 @@ open class NestNewMessageViewModel :
myMultiOrchestrator.upload(
uploadState.caption,
uploadState.contentWarningReason,
MediaCompressor.intToCompressorQuality(uploadState.mediaQualitySlider),
CompressorQuality.fromSlider(uploadState.mediaQualitySlider),
uploadState.selectedServer,
account,
context,
uploader,
stripMetadata = uploadState.stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
@@ -391,16 +392,17 @@ open class NestNewMessageViewModel :
if (results.allGood) {
val urls =
results.successful.mapNotNull { upload ->
if (upload.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(upload.result.bytes, headerInfo = upload.result.fileHeader, uploadState.caption, uploadState.contentWarningReason)
val uploaded = upload.result
if (uploaded is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(uploaded.bytes, headerInfo = uploaded.fileHeader, uploadState.caption, uploadState.contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.toNostrUri()
} else if (upload.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(upload.result, uploadState.caption, uploadState.contentWarningReason)
} else if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(uploaded, uploadState.caption, uploadState.contentWarningReason)
upload.result.url
uploaded.url
} else {
null
}
@@ -47,7 +47,6 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -60,8 +59,8 @@ import com.vitorpamplona.amethyst.commons.resources.messages_new_message_to_capt
import com.vitorpamplona.amethyst.commons.resources.public_message
import com.vitorpamplona.amethyst.commons.resources.zapraiser
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
@@ -260,13 +259,12 @@ fun PublicMessageScreenContent(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
val context = LocalContext.current
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
isUploading = postViewModel.mediaUploadTracker.isUploading,
onAdd = { alt, server, sensitiveContent, mediaQuality, _, stripMetadata, _ ->
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, stripMetadata)
postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, accountViewModel.host.mediaUploader, stripMetadata)
accountViewModel.account.settings.changeDefaultFileServer(server)
},
onDelete = postViewModel::deleteMediaToUpload,
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages
import android.content.Context
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -54,7 +53,12 @@ import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.note.creators.messagefield.IMessageField
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
@@ -63,12 +67,9 @@ import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
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.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
@@ -430,11 +431,11 @@ class NewPublicMessageViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
try {
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, context, stripMetadata)
uploadUnsafe(alt, contentWarningReason, mediaQuality, server, onError, uploader, stripMetadata)
} catch (e: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
@@ -451,7 +452,7 @@ class NewPublicMessageViewModel :
mediaQuality: Int,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
uploader: MediaUploader,
stripMetadata: Boolean = true,
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -463,10 +464,10 @@ class NewPublicMessageViewModel :
myMultiOrchestrator.upload(
alt,
contentWarningReason,
MediaCompressor.intToCompressorQuality(mediaQuality),
CompressorQuality.fromSlider(mediaQuality),
server,
account,
context,
uploader,
stripMetadata = stripMetadata,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
@@ -474,30 +475,31 @@ class NewPublicMessageViewModel :
if (results.allGood) {
val urls =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(state.result.bytes, headerInfo = state.result.fileHeader, alt, contentWarningReason)
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.NIP95Result) {
val nip95 = account.createNip95(uploaded.bytes, headerInfo = uploaded.fileHeader, alt, contentWarningReason)
nip95attachments = nip95attachments + nip95
val note = nip95.let { it1 -> account.consumeNip95(it1.first, it1.second) }
note?.let {
"nostr:" + it.toNEvent()
}
} else if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
} else if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMeta =
IMetaTagBuilder(state.result.url)
IMetaTagBuilder(uploaded.url)
.apply {
hash(state.result.fileHeader.hash)
size(state.result.fileHeader.size)
state.result.fileHeader.mimeType
hash(uploaded.fileHeader.hash)
size(uploaded.fileHeader.size)
uploaded.fileHeader.mimeType
?.let { mimeType(it) }
state.result.fileHeader.dim
uploaded.fileHeader.dim
?.let { dims(it) }
state.result.fileHeader.blurHash
uploaded.fileHeader.blurHash
?.let { blurhash(it.blurhash) }
state.result.fileHeader.thumbHash
uploaded.fileHeader.thumbHash
?.let { thumbhash(it.thumbhash) }
state.result.magnet?.let { magnet(it) }
state.result.uploadedHash?.let { originalHash(it) }
uploaded.magnet?.let { magnet(it) }
uploaded.uploadedHash?.let { originalHash(it) }
alt?.let { alt(it) }
contentWarningReason?.let { sensitiveContent(contentWarningReason) }
@@ -505,7 +507,7 @@ class NewPublicMessageViewModel :
iMetaAttachments.replace(iMeta.url, iMeta)
state.result.url
uploaded.url
} else {
null
}
@@ -51,8 +51,8 @@ import com.vitorpamplona.amethyst.commons.resources.ic_compose
import com.vitorpamplona.amethyst.commons.resources.new_picture
import com.vitorpamplona.amethyst.commons.resources.take_a_picture
import com.vitorpamplona.amethyst.commons.resources.upload_image
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -28,10 +28,11 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata
@@ -168,9 +169,10 @@ class EditPodcastShowViewModel : ViewModel() {
),
coverOrchestrator = coverOrch,
server = server,
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
quality = CompressorQuality.fromSlider(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
appContext = context.applicationContext,
uploader = accountViewModel.host.mediaUploader,
)
isSending.value = true
@@ -186,7 +188,7 @@ class EditPodcastShowViewModel : ViewModel() {
quality = snapshot.quality,
stripMetadata = snapshot.stripMetadata,
alt = snapshot.content.title,
context = snapshot.appContext,
uploader = snapshot.uploader,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
}
@@ -219,9 +221,10 @@ class EditPodcastShowViewModel : ViewModel() {
val content: Podcasting20PodcastMetadata.Content,
val coverOrchestrator: MultiOrchestrator?,
val server: ServerName?,
val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality,
val quality: CompressorQuality,
val stripMetadata: Boolean,
val appContext: Context,
val uploader: MediaUploader,
)
}
@@ -90,8 +90,8 @@ import com.vitorpamplona.amethyst.commons.resources.podcast_episode_video_label
import com.vitorpamplona.amethyst.commons.resources.podcast_new_episode
import com.vitorpamplona.amethyst.commons.resources.podcast_publishing_banner
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SendingTopBar
@@ -29,10 +29,11 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.podcasts.PodcastAudio
@@ -205,9 +206,10 @@ class NewPodcastEpisodeViewModel : ViewModel() {
existingCoverUrl = coverUrl.value.trim().ifBlank { null },
existingAudioUrl = audioUrl.value.trim(),
server = server,
quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value),
quality = CompressorQuality.fromSlider(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
appContext = context.applicationContext,
uploader = accountViewModel.host.mediaUploader,
)
isSending.value = true
@@ -265,9 +267,10 @@ class NewPodcastEpisodeViewModel : ViewModel() {
val existingCoverUrl: String?,
val existingAudioUrl: String,
val server: ServerName,
val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality,
val quality: CompressorQuality,
val stripMetadata: Boolean,
val appContext: Context,
val uploader: MediaUploader,
)
private suspend fun performParallelUploads(snapshot: Snapshot): Pair<String?, String?> =
@@ -294,7 +297,7 @@ class NewPodcastEpisodeViewModel : ViewModel() {
quality = snapshot.quality,
stripMetadata = snapshot.stripMetadata,
alt = snapshot.title.ifBlank { null },
context = snapshot.appContext,
uploader = snapshot.uploader,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
@@ -71,7 +71,7 @@ import com.vitorpamplona.amethyst.commons.resources.podcast_trailer_upload_cta
import com.vitorpamplona.amethyst.commons.resources.podcast_trailer_upload_hint
import com.vitorpamplona.amethyst.commons.resources.podcast_trailer_url_label
import com.vitorpamplona.amethyst.commons.service.upload.ui.StrippingFailureDialog
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.SendingTopBar
@@ -26,10 +26,10 @@ import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@@ -110,7 +110,7 @@ class NewPodcastTrailerViewModel : ViewModel() {
val seasonSnap = season.value.trim().toIntOrNull()
val mimeSnap = pickedMimeType.value
val mediaSnap = media.value
val quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value)
val quality = CompressorQuality.fromSlider(mediaQualitySlider.value)
val strip = stripMetadata.value
val appContext = context.applicationContext
@@ -127,7 +127,7 @@ class NewPodcastTrailerViewModel : ViewModel() {
quality = quality,
stripMetadata = strip,
alt = titleSnap.ifBlank { null },
context = appContext,
uploader = accountViewModel.host.mediaUploader,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
)
} else {
@@ -25,11 +25,13 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.uploads.errorResource
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
@@ -58,7 +60,7 @@ object PodcastComposerMedia {
quality: CompressorQuality,
stripMetadata: Boolean,
alt: String?,
context: Context,
uploader: MediaUploader,
onStrippingFailed: suspend () -> Boolean,
): String {
val res =
@@ -68,12 +70,12 @@ object PodcastComposerMedia {
mediaQuality = quality,
server = server,
account = account,
context = context,
uploader = uploader,
useH265 = false,
stripMetadata = stripMetadata,
onStrippingFailed = onStrippingFailed,
)
if (!res.allGood) throw UploadException(kind, formatUploadErrors(res.errors, context))
if (!res.allGood) throw UploadException(kind, formatUploadErrors(res.errors))
return firstUploadedUrl(res.successful)
?: throw UploadException(kind, "Server didn't return a URL for the uploaded $kind.")
}
@@ -83,10 +85,7 @@ object PodcastComposerMedia {
.firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult }
?.url
private suspend fun formatUploadErrors(
errors: List<UploadingState.Error>,
context: Context,
): String =
private suspend fun formatUploadErrors(errors: List<UploadingState.Error>): String =
errors
.map { loadStringRes(it.errorResource, *it.params) }
.distinct()
@@ -52,8 +52,8 @@ import com.vitorpamplona.amethyst.commons.resources.ic_compose
import com.vitorpamplona.amethyst.commons.resources.new_short_video
import com.vitorpamplona.amethyst.commons.resources.record_a_video
import com.vitorpamplona.amethyst.commons.resources.upload_image
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -27,7 +27,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.note.nip22Comments.CommentPostViewModel
@@ -52,8 +52,8 @@ import com.vitorpamplona.amethyst.commons.resources.new_media
import com.vitorpamplona.amethyst.commons.resources.record_a_video
import com.vitorpamplona.amethyst.commons.resources.take_a_picture
import com.vitorpamplona.amethyst.commons.resources.upload_image
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.GallerySelect
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.painterRes
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.actions.uploads
import android.net.Uri
import com.vitorpamplona.amethyst.commons.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import io.mockk.mockk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -24,7 +24,7 @@ import android.content.Context
import android.net.Uri
import android.os.Looper
import com.davotoula.lightcompressor.VideoCompressor
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import id.zelory.compressor.Compressor
@@ -18,7 +18,7 @@
* 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.commons.ui.actions.uploads
package com.vitorpamplona.amethyst.commons.service.uploads
import android.net.Uri
@@ -0,0 +1,43 @@
/*
* 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.commons.service.uploads
enum class CompressorQuality {
VERY_LOW,
LOW,
MEDIUM,
HIGH,
VERY_HIGH,
UNCOMPRESSED,
;
companion object {
/** The media-quality slider's position (0 low … 3 uncompressed) as a quality. */
fun fromSlider(position: Int): CompressorQuality =
when (position) {
0 -> LOW
1 -> MEDIUM
2 -> HIGH
3 -> UNCOMPRESSED
else -> MEDIUM
}
}
}
@@ -0,0 +1,28 @@
/*
* 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.commons.service.uploads
/** The file to upload after compression: the original when nothing was compressed. */
class MediaCompressorResult(
val uri: MediaUri,
val contentType: String?,
val size: Long?,
)
@@ -0,0 +1,124 @@
/*
* 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.commons.service.uploads
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
/**
* The platform's media pipeline: compresses, strips metadata from, optionally encrypts, and
* uploads a picked file to a NIP-95, NIP-96 or Blossom server, reporting each stage on
* [progress]. Reached through `AccountViewModelHost.mediaUploader`; [UploadOrchestrator] and
* [MultiOrchestrator] are the shared front ends composers use.
*/
interface MediaUploader {
suspend fun upload(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
server: ServerName,
account: Account,
useH265: Boolean,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingState.UploadingFinalState
suspend fun uploadEncrypted(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
encrypt: NostrCipher,
server: ServerName,
account: Account,
useH265: Boolean,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingState.UploadingFinalState
/** Compresses [uri] when [compressionQuality] or [convertGifToMp4] asks for it, else returns it as is. */
suspend fun compressIfNeeded(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
compressionQuality: CompressorQuality,
useH265: Boolean,
convertGifToMp4: Boolean,
): MediaCompressorResult
}
/** No media pipeline on this front end yet: every upload fails with a clear message. */
object UnavailableMediaUploader : MediaUploader {
private const val REASON = "Uploads are not available on this platform yet"
override suspend fun upload(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
server: ServerName,
account: Account,
useH265: Boolean,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingState.UploadingFinalState = progress.error(UploadError.FAILED_TO_UPLOAD_MEDIA, REASON)
override suspend fun uploadEncrypted(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
alt: String?,
contentWarningReason: String?,
compressionQuality: CompressorQuality,
encrypt: NostrCipher,
server: ServerName,
account: Account,
useH265: Boolean,
stripMetadata: Boolean,
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingState.UploadingFinalState = progress.error(UploadError.FAILED_TO_UPLOAD_MEDIA, REASON)
override suspend fun compressIfNeeded(
progress: UploadOrchestrator,
uri: MediaUri,
mimeType: String?,
compressionQuality: CompressorQuality,
useH265: Boolean,
convertGifToMp4: Boolean,
): MediaCompressorResult = MediaCompressorResult(uri, mimeType, null)
}

Some files were not shown because too many files have changed in this diff Show More