From 0689a7d244793a38690cfec872d708fa433bd2f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 18:04:53 +0000 Subject: [PATCH 1/2] fix: resolve blossom: URIs when saving media to gallery The download/save-to-local button handed the raw content URL straight to OkHttp. For BUD-10 `blossom:` URIs this failed with "expected scheme http or https but was blossom" because OkHttp only speaks http/https. Resolve `blossom:` URIs to a concrete server URL via BlossomServerResolver (the same resolver the Coil/ExoPlayer pipeline uses) before downloading, in both save entry points (AccountViewModel.saveMediaToGallery and the zoomable dialog's save action). When no hosting server can be found, the save reports an error instead of crashing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2 --- .../amethyst/ui/actions/MediaSaverToDisk.kt | 26 ++++++++++++++++--- .../ui/components/ZoomableContentDialog.kt | 6 +++++ .../ui/screen/loggedIn/AccountViewModel.kt | 5 ++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index a341fd5a8e..34ebb9abe4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -44,6 +44,7 @@ import okio.buffer import okio.sink import okio.source import java.io.File +import java.io.IOException import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -53,6 +54,7 @@ object MediaSaverToDisk { okHttpClient: (String) -> OkHttpClient, mimeType: String?, localContext: Context, + resolveBlossom: suspend (String) -> String? = { null }, onSuccess: () -> Any?, onError: (Throwable) -> Any?, ) = withContext(Dispatchers.IO) { @@ -77,6 +79,7 @@ object MediaSaverToDisk { mimeType = mimeType, okHttpClient = okHttpClient, context = localContext, + resolveBlossom = resolveBlossom, onSuccess = onSuccess, onError = onError, ) @@ -87,6 +90,11 @@ object MediaSaverToDisk { /** * Saves the image to the gallery. May require a storage permission. * + * `blossom:` (BUD-10) URIs are resolved to a concrete `http(s)` server URL + * via [resolveBlossom] before downloading, since OkHttp only speaks + * http/https. When resolution fails the save reports an error instead of + * crashing with "expected scheme http or https but was blossom". + * * @see AMETHYST_SUBDIRECTORY */ suspend fun downloadAndSave( @@ -94,26 +102,35 @@ object MediaSaverToDisk { mimeType: String?, okHttpClient: (String) -> OkHttpClient, context: Context, + resolveBlossom: suspend (String) -> String? = { null }, onSuccess: () -> Any?, onError: (Throwable) -> Any?, ) { try { - val client = okHttpClient(url) + val downloadUrl = + if (url.startsWith(BLOSSOM_SCHEME, ignoreCase = true)) { + resolveBlossom(url) + ?: throw IOException("Could not find a Blossom server that hosts $url") + } else { + url + } + + val client = okHttpClient(downloadUrl) val request = Request .Builder() .get() - .url(url) + .url(downloadUrl) .build() client.newCall(request).executeAsync().use { response -> withContext(Dispatchers.IO) { check(response.isSuccessful) { - "Failed to download $url: HTTP ${response.code} ${response.message}" + "Failed to download $downloadUrl: HTTP ${response.code} ${response.message}" } - val trimmedUrl = trimInlineMetaData(url) + val trimmedUrl = trimInlineMetaData(downloadUrl) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val headerType = response @@ -292,4 +309,5 @@ object MediaSaverToDisk { private const val AMETHYST_SUBDIRECTORY = "Amethyst" private const val PDF_MIME_TYPE = "application/pdf" + private const val BLOSSOM_SCHEME = "blossom:" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 56b3440198..defca41a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -83,6 +83,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -532,6 +533,11 @@ internal suspend fun saveMediaToGallery( } }, localContext, + resolveBlossom = { + Amethyst.instance.blossomResolver + .findServers(it) + ?.serverUrl + }, onSuccess = { showToastOnMain(localContext, success) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c4efef0e6b..279cd83ceb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2254,6 +2254,11 @@ class AccountViewModel( okHttpClient = httpClientBuilder::okHttpClientForVideo, mimeType = mimeType, localContext = localContext, + resolveBlossom = { + Amethyst.instance.blossomResolver + .findServers(it) + ?.serverUrl + }, onSuccess = { Handler(Looper.getMainLooper()).post { Toast From f35d40cc0013e5829af208e86e71975b5db15ee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 18:33:19 +0000 Subject: [PATCH 2/2] style: drop redundant EOL comments flagged by ktlint in Hex.kt New KDoc blocks on isHex/isHex64 already state the "~47ns" and "~30% faster" perf notes, so the trailing EOL comments between the KDoc and the function now trip ktlint's standard:no-consecutive-comments rule ("an EOL comment may not be preceded by a KDoc"). Remove them. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2 --- .../src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index 724c252da0..4c834b2e8c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -60,7 +60,6 @@ object Hex { * emoji in `p` tags) instead of throwing. ~47ns in debug on the Emulator; * use [isHex64] when the length is known to be 64. */ - // 47ns in debug on the Emulator fun isHex(hex: String?): Boolean { if (hex == null) return false if (hex.length and 1 != 0) return false @@ -93,7 +92,6 @@ object Hex { * the length is fixed and the checks are unrolled. Assumes [hex] is at least * 64 chars long; it does not verify the total length. */ - // 30% faster than isHex fun isHex64(hex: String): Boolean = try { hexToByte[hex[0].code] >= 0 &&