fix(concord): upload community icon/banner off the main thread

Picking a community icon or banner always failed with "Failed to upload media".
The upload was launched from the form's rememberCoroutineScope() (the Compose
Main dispatcher), so the very first pipeline step — MediaCompressor.compress —
hit Amethyst's checkNotInMainThread() guard and threw OnMainThreadException
before any bytes left the device.

Run ConcordImageUploader.uploadEncrypted inside withContext(Dispatchers.IO) so
the whole compress → strip → AES-GCM-encrypt → Blossom pipeline is off-main; the
Compose state write stays on the launching (Main) scope.

Also stop the form's catch from swallowing the real cause: surface the actual
exception message in the toast (falling back to the generic string only when it
has none), log it, and rethrow CancellationException instead of eating it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-14 22:00:04 -04:00
co-authored by Claude Opus 4.8
parent 5b54eb88a5
commit edbc910941
2 changed files with 58 additions and 38 deletions
@@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Authors a CORD-02 §6 encrypted community image and returns the [ImagePointer] to seal in the
@@ -49,47 +51,55 @@ import com.vitorpamplona.quartz.utils.ciphers.AESGCM
class ConcordImageUploader(
private val account: Account,
) {
/** Compresses, strips, AES-256-GCM-encrypts and uploads the picked [uri], returning its pointer. */
/**
* Compresses, strips, AES-256-GCM-encrypts and uploads the picked [uri], returning its pointer.
*
* Runs on [Dispatchers.IO]: the compression/upload pipeline asserts it is off the main thread
* ([MediaCompressor] calls `checkNotInMainThread`), and callers launch this from a Compose
* `rememberCoroutineScope()`, which is Main-dispatched — so without this switch the first step
* throws before any bytes leave the device.
*/
suspend fun uploadEncrypted(
uri: Uri,
context: Context,
): ImagePointer {
// Fresh random key + nonce per image; we hold onto them to build the pointer below since the
// orchestrator only surfaces the ciphertext URL, not the cipher it was handed.
val cipher = AESGCM()
): ImagePointer =
withContext(Dispatchers.IO) {
// Fresh random key + nonce per image; we hold onto them to build the pointer below since the
// orchestrator only surfaces the ciphertext URL, not the cipher it was handed.
val cipher = AESGCM()
val finalState =
UploadOrchestrator().uploadEncrypted(
uri = uri,
mimeType = context.contentResolver.getType(uri),
alt = null,
contentWarningReason = null,
compressionQuality = CompressorQuality.MEDIUM,
encrypt = cipher,
server = resolveBlossomServer(),
account = account,
context = context,
val finalState =
UploadOrchestrator().uploadEncrypted(
uri = uri,
mimeType = context.contentResolver.getType(uri),
alt = null,
contentWarningReason = null,
compressionQuality = CompressorQuality.MEDIUM,
encrypt = cipher,
server = resolveBlossomServer(),
account = account,
context = context,
)
val result =
when (finalState) {
is UploadingState.Finished -> finalState.result
is UploadingState.Error -> throw IllegalStateException(stringRes(context, finalState.errorResource, *finalState.params))
}
val server =
result as? UploadOrchestrator.OrchestratorResult.ServerResult
?: throw IllegalStateException("Encrypted community image upload did not return a server URL")
ImagePointer(
url = server.url,
key = cipher.keyBytes.toHexKey(),
nonce = cipher.nonce.toHexKey(),
// hash is over the *plaintext* (post-compression/strip) bytes — the read path verifies it
// after decrypting, so it must match what was actually encrypted, not the original file.
hash = server.hashBeforeEncryption ?: throw IllegalStateException("Upload pipeline did not report the plaintext hash"),
)
val result =
when (finalState) {
is UploadingState.Finished -> finalState.result
is UploadingState.Error -> throw IllegalStateException(stringRes(context, finalState.errorResource, *finalState.params))
}
val server =
result as? UploadOrchestrator.OrchestratorResult.ServerResult
?: throw IllegalStateException("Encrypted community image upload did not return a server URL")
return ImagePointer(
url = server.url,
key = cipher.keyBytes.toHexKey(),
nonce = cipher.nonce.toHexKey(),
// hash is over the *plaintext* (post-compression/strip) bytes — the read path verifies it
// after decrypting, so it must match what was actually encrypted, not the original file.
hash = server.hashBeforeEncryption ?: throw IllegalStateException("Upload pipeline did not report the plaintext hash"),
)
}
}
/** The account's first configured Blossom server, wrapped as a [ServerName], else the default. */
private fun resolveBlossomServer(): ServerName {
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
@@ -79,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@@ -157,8 +159,12 @@ private fun ConcordIconHero(
scope.launch {
try {
icon.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Toast.makeText(context, stringRes(context, R.string.failed_to_upload_media_no_details), Toast.LENGTH_SHORT).show()
Log.w("ConcordImageUpload", "Community icon upload failed", e)
val msg = e.message?.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.failed_to_upload_media_no_details)
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
} finally {
uploading = false
}
@@ -224,8 +230,12 @@ private fun ConcordBannerHero(
scope.launch {
try {
banner.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Toast.makeText(context, stringRes(context, R.string.failed_to_upload_media_no_details), Toast.LENGTH_SHORT).show()
Log.w("ConcordImageUpload", "Community banner upload failed", e)
val msg = e.message?.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.failed_to_upload_media_no_details)
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
} finally {
uploading = false
}