From 0689a7d244793a38690cfec872d708fa433bd2f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 18:04:53 +0000 Subject: [PATCH] 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