refactor: single-image uploads go through the shared orchestrator

Add commonsUI uploadToDefaultServer(media, account, uploader, onError) and
use it in place of the five hand-rolled strip/compress/NIP-96/Blossom
copies in the profile, emoji pack, emoji pack metadata, relay group
metadata and long-form cover uploaders. Drop the Context plumbing the
music and podcast composers no longer need.

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:25:21 +00:00
parent 2fd02a0753
commit ccf1b7dce9
21 changed files with 136 additions and 462 deletions
@@ -46,7 +46,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
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.unit.dp
@@ -91,7 +90,6 @@ fun NewUserMetadataScreen(
) {
val postViewModel: NewUserMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
val context = LocalContext.current
val socialExpanded = rememberSaveable { mutableStateOf(false) }
@@ -213,7 +211,7 @@ fun NewUserMetadataScreen(
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp),
) {
postViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast)
postViewModel.uploadForPicture(it, accountViewModel.host.mediaUploader, onError = accountViewModel.toastManager::toast)
}
},
singleLine = true,
@@ -238,7 +236,7 @@ fun NewUserMetadataScreen(
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(start = 5.dp),
) {
postViewModel.uploadForBanner(it, context, onError = accountViewModel.toastManager::toast)
postViewModel.uploadForBanner(it, accountViewModel.host.mediaUploader, onError = accountViewModel.toastManager::toast)
}
},
singleLine = true,
@@ -20,33 +20,19 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
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_media_no_details
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.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.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
import kotlin.coroutines.cancellation.CancellationException
class NewUserMetadataViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
@@ -164,13 +150,13 @@ class NewUserMetadataViewModel : ViewModel() {
fun uploadForPicture(
uri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onError: (String, String) -> Unit,
) {
accountViewModel.launchSigner {
upload(
galleryUri = uri,
context = context,
uploader = uploader,
onError = onError,
)?.let {
picture.value = it
@@ -180,13 +166,13 @@ class NewUserMetadataViewModel : ViewModel() {
fun uploadForBanner(
uri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onError: (String, String) -> Unit,
) {
accountViewModel.launchSigner {
upload(
galleryUri = uri,
context = context,
uploader = uploader,
onError = onError,
)?.let {
banner.value = it
@@ -196,14 +182,14 @@ class NewUserMetadataViewModel : ViewModel() {
fun uploadPictureAndSave(
uri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onError: (String, String) -> Unit,
) {
load()
accountViewModel.launchSigner {
upload(
galleryUri = uri,
context = context,
uploader = uploader,
onError = onError,
)?.let {
picture.value = it
@@ -215,81 +201,12 @@ class NewUserMetadataViewModel : ViewModel() {
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onError: (String, String) -> Unit,
): String? {
isUploadingImageForPicture = true
val strippingResult =
if (account.settings.stripLocationOnUpload) {
try {
MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
} catch (e: AvifMetadataNotVerifiableException) {
isUploadingImageForPicture = false
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName),
)
return null
}
} else {
null
}
val sourceUri =
if (account.settings.stripLocationOnUpload &&
strippingResult != null &&
!strippingResult.stripped
) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.metadata_strip_failed_upload_cancelled),
)
return null
} else {
strippingResult?.uri ?: galleryUri.uri
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
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,
)
} 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,
)
}
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
uploadToDefaultServer(galleryUri, account, uploader, onError)
} finally {
isUploadingImageForPicture = false
}
@@ -60,7 +60,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.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -211,7 +210,6 @@ private fun RelayGroupMetadataScaffold(
// existing group already implies a working relay, so callers pass true.
nip29Support: Boolean? = true,
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
// NIP-29 §Subgroups relay support detection: `"nip29": { "subgroups": true }` in the NIP-11.
@@ -236,7 +234,7 @@ private fun RelayGroupMetadataScaffold(
val onSubmit: () -> Unit = {
viewModel.submit(
context = context,
uploader = accountViewModel.host.mediaUploader,
onSuccess = onSuccess,
onError = accountViewModel.toastManager::toast,
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -29,29 +28,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
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_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_upload
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.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.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_FORUM
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_CHANNEL_TYPE_STREAM
import com.vitorpamplona.quartz.buzz.workspace.newBuzzChannelId
@@ -61,7 +48,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/**
* Backs the create/edit NIP-29 group metadata screens. Holds the full editable metadata
@@ -221,7 +207,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
}
fun submit(
context: Context,
uploader: MediaUploader,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
@@ -231,7 +217,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
try {
val local = pickedMedia
if (local != null) {
val uploadedUrl = uploadImage(local, context, onError) ?: return@launch
val uploadedUrl = uploadImage(local, uploader, onError) ?: return@launch
picture.value = TextFieldValue(uploadedUrl)
pickedMedia = null
}
@@ -311,79 +297,7 @@ class RelayGroupMetadataViewModel : ViewModel() {
private suspend fun uploadImage(
galleryUri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onError: (String, String) -> Unit,
): String? {
val sourceUri =
try {
if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
if (!result.stripped) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.metadata_strip_failed_upload_cancelled),
)
return null
}
result.uri
} else {
galleryUri.uri
}
} catch (e: AvifMetadataNotVerifiableException) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName),
)
return null
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
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 = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
result.url ?: run {
onError(
loadStringRes(Res.string.failed_to_upload_media_no_details),
loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading),
)
null
}
} catch (_: SignerExceptions.ReadOnlyException) {
onError(
loadStringRes(Res.string.failed_to_upload_media_no_details),
loadStringRes(Res.string.login_with_a_private_key_to_be_able_to_upload),
)
null
} 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
}
}
): String? = uploadToDefaultServer(galleryUri, account, uploader, onError)
}
@@ -51,7 +51,6 @@ import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
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.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.service.upload.MediaUploadTracker
import com.vitorpamplona.amethyst.commons.service.upload.SuspendableConfirmation
@@ -61,7 +60,6 @@ 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
@@ -72,6 +70,7 @@ 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.ui.uploads.uploadToDefaultServer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
@@ -487,36 +486,7 @@ class LongFormPostViewModel :
galleryUri: SelectedMedia,
uploader: MediaUploader,
onError: (String, String) -> Unit,
): String? {
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 when (state) {
is UploadingState.Finished -> {
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
uploaded.url
} else {
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading))
null
}
}
is UploadingState.Error -> {
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(state.errorResource, *state.params))
null
}
else -> null
}
}
): String? = uploadToDefaultServer(galleryUri, account, uploader, onError)
fun upload(
alt: String?,
@@ -41,7 +41,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.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -119,7 +118,6 @@ fun AddEmojiDialog(
onValueChange = { url = it },
label = { Text(stringRes(Res.string.emoji_url_label)) },
leadingIcon = {
val context = LocalContext.current
SelectSingleFromGallery(
isUploading = viewModel.isUploadingEmojiImage,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -127,7 +125,7 @@ fun AddEmojiDialog(
) { selected ->
viewModel.uploadEmojiImage(
uri = selected,
context = context,
uploader = accountViewModel.host.mediaUploader,
onUploaded = { uploadedUrl -> url = uploadedUrl },
onError = accountViewModel.toastManager::toast,
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -28,31 +27,15 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
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_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_upload
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.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.loadStringRes
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class EmojiPackViewModel(
@@ -86,14 +69,14 @@ class EmojiPackViewModel(
fun uploadEmojiImage(
uri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
uploader,
onUploading = { isUploadingEmojiImage = it },
onUploaded = onUploaded,
onError = onError,
@@ -103,83 +86,15 @@ class EmojiPackViewModel(
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val sourceUri =
try {
if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
if (!result.stripped) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.metadata_strip_failed_upload_cancelled),
)
onUploading(false)
return
}
result.uri
} else {
galleryUri.uri
}
} catch (e: AvifMetadataNotVerifiableException) {
onUploading(false)
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName),
)
return
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
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 = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
val url = uploadToDefaultServer(galleryUri, account, uploader, onError)
onUploading(false)
if (url != null) onUploaded(url)
}
@Suppress("UNCHECKED_CAST")
@@ -53,7 +53,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.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
@@ -109,7 +108,6 @@ private fun EmojiPackMetadataScaffold(
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
var wantsToPickImage by remember { mutableStateOf(false) }
@@ -127,7 +125,7 @@ private fun EmojiPackMetadataScaffold(
val onSubmit: () -> Unit = {
viewModel.submit(
context = context,
uploader = accountViewModel.host.mediaUploader,
onSuccess = { nav.popBack() },
onError = accountViewModel.toastManager::toast,
)
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -29,32 +28,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.OwnedEmojiPack
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_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_upload
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.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.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.uploads.uploadToDefaultServer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.service.uploads.AvifMetadataNotVerifiableException
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class EmojiPackMetadataViewModel : ViewModel() {
@@ -101,7 +87,7 @@ class EmojiPackMetadataViewModel : ViewModel() {
}
fun submit(
context: Context,
uploader: MediaUploader,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
@@ -111,7 +97,7 @@ class EmojiPackMetadataViewModel : ViewModel() {
try {
val local = pickedMedia
if (local != null) {
val uploadedUrl = uploadImage(local, context, onError)
val uploadedUrl = uploadImage(local, uploader, onError)
if (uploadedUrl == null) {
isWorking = false
return@launch
@@ -165,81 +151,7 @@ class EmojiPackMetadataViewModel : ViewModel() {
private suspend fun uploadImage(
galleryUri: SelectedMedia,
context: Context,
uploader: MediaUploader,
onError: (String, String) -> Unit,
): String? {
val sourceUri =
try {
if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
if (!result.stripped) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.metadata_strip_failed_upload_cancelled),
)
return null
}
result.uri
} else {
galleryUri.uri
}
} catch (e: AvifMetadataNotVerifiableException) {
onError(
loadStringRes(Res.string.metadata_strip_failed_title),
loadStringRes(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName),
)
return null
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
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 = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
result.url
} else {
onError(
loadStringRes(Res.string.failed_to_upload_media_no_details),
loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading),
)
null
}
} catch (_: SignerExceptions.ReadOnlyException) {
onError(
loadStringRes(Res.string.failed_to_upload_media_no_details),
loadStringRes(Res.string.login_with_a_private_key_to_be_able_to_upload),
)
null
} 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
}
}
): String? = uploadToDefaultServer(galleryUri, account, uploader, onError)
}
@@ -57,7 +57,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.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow
@@ -116,7 +115,6 @@ fun NewMusicPlaylistScreen(
nav: INav,
) {
val vm: NewMusicPlaylistViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) {
vm.init(accountViewModel, editDTag)
@@ -155,7 +153,6 @@ fun NewMusicPlaylistScreen(
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(
context = context,
accountViewModel = accountViewModel,
)
},
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.music
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Account
@@ -185,10 +184,7 @@ class NewMusicPlaylistViewModel : ViewModel() {
* runs on [AccountViewModel.viewModelScope] via `launchSigner`, so it keeps running even if
* the user leaves the screen — they get a toast notification when it finishes either way.
*/
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
fun saveAndPublish(accountViewModel: AccountViewModel) {
if (isSending.value) return // double-tap guard
if (!isValid()) return
@@ -218,7 +214,6 @@ class NewMusicPlaylistViewModel : ViewModel() {
quality = CompressorQuality.fromSlider(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
loadedEvent = loadedEvent,
appContext = context.applicationContext,
uploader = accountViewModel.host.mediaUploader,
)
@@ -266,7 +261,6 @@ class NewMusicPlaylistViewModel : ViewModel() {
val quality: CompressorQuality,
val stripMetadata: Boolean,
val loadedEvent: MusicPlaylistEvent?,
val appContext: Context,
val uploader: MediaUploader,
)
@@ -154,7 +154,6 @@ fun NewMusicTrackScreen(
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(
context = context,
accountViewModel = accountViewModel,
)
},
@@ -259,10 +259,7 @@ class NewMusicTrackViewModel : ViewModel() {
* Errors are reported through the global toast manager rather than a callback because
* the screen may already be gone by the time we know the outcome.
*/
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
fun saveAndPublish(accountViewModel: AccountViewModel) {
if (isSending.value) return // double-tap guard
// Snapshot every input the upload needs into immutable locals BEFORE launching, so
@@ -293,7 +290,6 @@ class NewMusicTrackViewModel : ViewModel() {
quality = CompressorQuality.fromSlider(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
loadedEvent = loadedEvent,
appContext = context.applicationContext,
uploader = accountViewModel.host.mediaUploader,
)
@@ -365,7 +361,6 @@ class NewMusicTrackViewModel : ViewModel() {
val quality: CompressorQuality,
val stripMetadata: Boolean,
val loadedEvent: MusicTrackEvent?,
val appContext: Context,
val uploader: MediaUploader,
)
@@ -44,7 +44,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.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@@ -97,7 +96,6 @@ fun EditPodcastShowScreen(
nav: INav,
) {
val vm: EditPodcastShowViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) { vm.init(accountViewModel) }
@@ -124,7 +122,7 @@ fun EditPodcastShowScreen(
isActive = { vm.isValid() && !isBusy },
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(context, accountViewModel)
vm.saveAndPublish(accountViewModel)
},
)
},
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Account
@@ -130,10 +129,7 @@ class EditPodcastShowViewModel : ViewModel() {
fun isValid(): Boolean = title.value.isNotBlank()
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
fun saveAndPublish(accountViewModel: AccountViewModel) {
if (isSending.value) return
val coverOrch = coverMedia.value
@@ -171,7 +167,6 @@ class EditPodcastShowViewModel : ViewModel() {
server = server,
quality = CompressorQuality.fromSlider(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
appContext = context.applicationContext,
uploader = accountViewModel.host.mediaUploader,
)
@@ -223,7 +218,6 @@ class EditPodcastShowViewModel : ViewModel() {
val server: ServerName?,
val quality: CompressorQuality,
val stripMetadata: Boolean,
val appContext: Context,
val uploader: MediaUploader,
)
}
@@ -152,7 +152,7 @@ fun NewPodcastEpisodeScreen(
isActive = { vm.isValid() && !isBusy },
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(context, accountViewModel)
vm.saveAndPublish(accountViewModel)
},
)
},
@@ -174,10 +174,7 @@ class NewPodcastEpisodeViewModel : ViewModel() {
/** Valid with a title and a resolvable audio source (picked file or a pasted URL). */
fun isValid(): Boolean = title.value.isNotBlank() && (audioMedia.value != null || audioUrl.value.isNotBlank())
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
fun saveAndPublish(accountViewModel: AccountViewModel) {
if (isSending.value) return
val server = selectedServer.value
@@ -208,7 +205,6 @@ class NewPodcastEpisodeViewModel : ViewModel() {
server = server,
quality = CompressorQuality.fromSlider(mediaQualitySlider.value),
stripMetadata = stripMetadata.value,
appContext = context.applicationContext,
uploader = accountViewModel.host.mediaUploader,
)
@@ -269,7 +265,6 @@ class NewPodcastEpisodeViewModel : ViewModel() {
val server: ServerName,
val quality: CompressorQuality,
val stripMetadata: Boolean,
val appContext: Context,
val uploader: MediaUploader,
)
@@ -88,7 +88,6 @@ fun NewPodcastTrailerScreen(
nav: INav,
) {
val vm: NewPodcastTrailerViewModel = viewModel()
val context = LocalContext.current
LaunchedEffect(accountViewModel) { vm.init(accountViewModel) }
StrippingFailureDialog(vm.strippingFailureConfirmation)
@@ -112,7 +111,7 @@ fun NewPodcastTrailerScreen(
isActive = { vm.isValid() && !isBusy },
onPost = {
if (!vm.isValid() || isBusy) return@SendingTopBar
vm.saveAndPublish(context, accountViewModel)
vm.saveAndPublish(accountViewModel)
},
)
},
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.commons.model.Account
@@ -91,10 +90,7 @@ class NewPodcastTrailerViewModel : ViewModel() {
fun isValid(): Boolean = title.value.isNotBlank() && (media.value != null || url.value.isNotBlank())
fun saveAndPublish(
context: Context,
accountViewModel: AccountViewModel,
) {
fun saveAndPublish(accountViewModel: AccountViewModel) {
if (isSending.value) return
val server = selectedServer.value
if (server == null) {
@@ -112,7 +108,6 @@ class NewPodcastTrailerViewModel : ViewModel() {
val mediaSnap = media.value
val quality = CompressorQuality.fromSlider(mediaQualitySlider.value)
val strip = stripMetadata.value
val appContext = context.applicationContext
isSending.value = true
accountViewModel.launchSigner {
@@ -49,7 +49,6 @@ import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -194,7 +193,6 @@ private fun ProfilePictureUploadButton(
) {
val postViewModel: NewUserMetadataViewModel = viewModel()
postViewModel.init(accountViewModel)
val context = LocalContext.current
var showGallerySelect by remember { mutableStateOf(false) }
if (showGallerySelect) {
@@ -202,7 +200,7 @@ private fun ProfilePictureUploadButton(
onImageUri = { media ->
showGallerySelect = false
if (media != null) {
postViewModel.uploadPictureAndSave(media, context, accountViewModel.toastManager::toast)
postViewModel.uploadPictureAndSave(media, accountViewModel.host.mediaUploader, accountViewModel.toastManager::toast)
}
},
)
@@ -0,0 +1,90 @@
/*
* 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.ui.uploads
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.resources.login_with_a_private_key_to_be_able_to_upload
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
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.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.CancellationException
/**
* Compresses, strips (when the account asks for it) and uploads one picked file to the account's
* default media server, returning the hosted URL. Every failure, a metadata-stripping one
* included, is reported through [onError] with a user-facing title and message, and yields null.
*
* For single-image fields (avatars, banners, pack and cover images) that only need a URL back.
*/
suspend fun uploadToDefaultServer(
media: SelectedMedia,
account: Account,
uploader: MediaUploader,
onError: (title: String, message: String) -> Unit,
quality: CompressorQuality = CompressorQuality.MEDIUM,
): String? {
val state =
try {
UploadOrchestrator().upload(
uri = media.uri,
mimeType = media.mimeType,
alt = null,
contentWarningReason = null,
compressionQuality = quality,
server = account.settings.defaultFileServer,
account = account,
uploader = uploader,
stripMetadata = account.settings.stripLocationOnUpload,
onStrippingFailed = { false },
)
} catch (_: SignerExceptions.ReadOnlyException) {
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.login_with_a_private_key_to_be_able_to_upload))
return null
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), e.message ?: e::class.simpleName ?: "")
return null
}
return when (state) {
is UploadingState.Finished -> {
val uploaded = state.result
if (uploaded is UploadOrchestrator.OrchestratorResult.ServerResult) {
uploaded.url
} else {
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(Res.string.server_did_not_provide_a_url_after_uploading))
null
}
}
is UploadingState.Error -> {
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(state.errorResource, *state.params))
null
}
}
}