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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2
This commit is contained in:
Claude
2026-07-01 18:32:34 +00:00
parent 308058d242
commit 0689a7d244
3 changed files with 33 additions and 4 deletions
@@ -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:"
}
@@ -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)
},
@@ -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