mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
co-authored by
Claude Opus 4.8
parent
5b54eb88a5
commit
edbc910941
+46
-36
@@ -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 {
|
||||
|
||||
+12
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user