From edbc91094100698962ba3e5f3d309d94820d630a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 21:52:04 -0400 Subject: [PATCH] fix(concord): upload community icon/banner off the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../concord/ConcordImageUploader.kt | 82 +++++++++++-------- .../concord/ConcordMetadataForm.kt | 14 +++- 2 files changed, 58 insertions(+), 38 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt index 6ec70492a0..89abba6951 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt index 67a4928275..c70b4b3f57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -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 }