feat(blossom): route image fetches through local Blossom cache

Adds support for the local-blossom-cache spec
(https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md):
when http://127.0.0.1:24242 responds 2xx to HEAD /, image and video
fetches are routed through it with xs= upstream hints so it can proxy
on miss. Toggle defaults ON per account; disable from Media Servers
settings.

Covers both blossom:// URIs and plain http(s) URLs that carry an imeta
sha256, by rewriting the latter to a synthetic blossom:<sha>?xs=<host>
URI before handing it to Coil/ExoPlayer.
This commit is contained in:
Claude
2026-05-07 12:10:06 +00:00
parent 4538812d26
commit 9c4e87b937
15 changed files with 599 additions and 22 deletions
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.safeCacheDir import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.resourceCacheInit import com.vitorpamplona.amethyst.ui.resourceCacheInit
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
@@ -410,6 +411,10 @@ class AppModules(
} }
} }
val localBlossomCacheProbe by lazy {
LocalBlossomCacheProbe(roleBasedHttpClientBuilder)
}
val blossomResolver by lazy { val blossomResolver by lazy {
Log.d("AppModules", "BlossomServerResolver Init") Log.d("AppModules", "BlossomServerResolver Init")
BlossomServerResolver( BlossomServerResolver(
@@ -426,6 +431,14 @@ class AppModules(
} }
}, },
httpClientBuilder = roleBasedHttpClientBuilder, httpClientBuilder = roleBasedHttpClientBuilder,
useLocalBlossomCache = {
sessionManager
.loggedInAccount()
?.settings
?.useLocalBlossomCache
?.value ?: false
},
localCacheProbe = localBlossomCacheProbe,
) )
} }
@@ -575,6 +588,34 @@ class AppModules(
} }
} }
// Evict the BlossomServerResolver URL cache whenever the local-cache
// toggle flips or the probe transitions up/down so stale entries don't
// outlive the underlying decision.
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
if (state is AccountState.LoggedIn) {
state.account.settings.useLocalBlossomCache
.drop(1)
.collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
localBlossomCacheProbe.invalidate()
}
}
}
}
applicationIOScope.launch {
localBlossomCacheProbe.available.drop(1).collect {
blossomResolver.uriToUrlCache.evictAll()
blossomResolver.blossomHitCache.cache.evictAll()
}
}
// Warm the local-cache probe so the very first image load doesn't pay
// the loopback round-trip cost.
applicationIOScope.launch {
localBlossomCacheProbe.isAvailable()
}
// Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite // Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite
// index over StandaloneDatabaseProvider and walks every cached span on disk — up to a // index over StandaloneDatabaseProvider and walks every cached span on disk — up to a
// few hundred ms on a populated 4 GB cache — so leaving it for the first session's // few hundred ms on a populated 4 GB cache — so leaving it for the first session's
@@ -95,6 +95,7 @@ private object PrefKeys {
const val LOCAL_RELAY_SERVERS = "localRelayServers" const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val DEFAULT_FILE_SERVER = "defaultFileServer" const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload" const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
@@ -346,6 +347,7 @@ object LocalPreferences {
) )
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload) putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
@@ -513,6 +515,7 @@ object LocalPreferences {
Log.d("LocalPreferences") { "Load account from file $npub - keys ready" } Log.d("LocalPreferences") { "Load account from file $npub - keys ready" }
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
@@ -620,6 +623,7 @@ object LocalPreferences {
localRelayServers = MutableStateFlow(localRelayServers), localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer.await(), defaultFileServer = defaultFileServer.await(),
stripLocationOnUpload = stripLocationOnUpload, stripLocationOnUpload = stripLocationOnUpload,
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
@@ -149,6 +149,7 @@ class AccountSettings(
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()), var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
var stripLocationOnUpload: Boolean = true, var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows), val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global), val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global), val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -403,6 +404,13 @@ class AccountSettings(
} }
} }
fun changeUseLocalBlossomCache(enabled: Boolean) {
if (useLocalBlossomCache.value != enabled) {
useLocalBlossomCache.tryEmit(enabled)
saveAccountSettings()
}
}
// --- // ---
// list names // list names
// --- // ---
@@ -41,6 +41,8 @@ class BlossomServerResolver(
val loggedInUsers: () -> List<HexKey>, val loggedInUsers: () -> List<HexKey>,
val blossomServers: (Set<Address>) -> List<Flow<BlossomServersEvent>>, val blossomServers: (Set<Address>) -> List<Flow<BlossomServersEvent>>,
val httpClientBuilder: IRoleBasedHttpClientBuilder, val httpClientBuilder: IRoleBasedHttpClientBuilder,
val useLocalBlossomCache: () -> Boolean = { false },
val localCacheProbe: LocalBlossomCacheProbe? = null,
) { ) {
val blossomHitCache: ServerHeadCache = ServerHeadCache() val blossomHitCache: ServerHeadCache = ServerHeadCache()
val uriToUrlCache = LruCache<String, BlossomUriServer>(200) val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
@@ -71,6 +73,10 @@ class BlossomServerResolver(
suspend fun findServersInner(uriStr: String): BlossomUriServer? { suspend fun findServersInner(uriStr: String): BlossomUriServer? {
val uri = BlossomUri.parse(uriStr) ?: return null val uri = BlossomUri.parse(uriStr) ?: return null
if (useLocalBlossomCache() && localCacheProbe?.isAvailable() == true) {
return BlossomUriServer(uri, uri.toLocalCacheUrl(LocalBlossomCacheProbe.LOCAL_CACHE_BASE))
}
val expectedMimeType = mimeTypeMap[uri.extension] val expectedMimeType = mimeTypeMap[uri.extension]
val filename = uri.filename() val filename = uri.filename()
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.util.concurrent.TimeUnit
/**
* Discovers a local Blossom cache running on `http://127.0.0.1:24242` per
* https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md
*
* Issues a `HEAD /` request and caches the result with separate positive and
* negative TTLs so the loopback isn't probed on every image load.
*/
class LocalBlossomCacheProbe(
private val httpClientBuilder: IRoleBasedHttpClientBuilder,
) {
private val mutex = Mutex()
@Volatile
private var cachedAtMs: Long = 0L
private val _available = MutableStateFlow(false)
val available: StateFlow<Boolean> = _available
suspend fun isAvailable(): Boolean {
val now = currentTimeMs()
val ttl = if (_available.value) POSITIVE_TTL_MS else NEGATIVE_TTL_MS
if (cachedAtMs != 0L && now - cachedAtMs < ttl) {
return _available.value
}
return mutex.withLock {
// Re-check inside the lock in case another caller just refreshed.
val now2 = currentTimeMs()
val ttl2 = if (_available.value) POSITIVE_TTL_MS else NEGATIVE_TTL_MS
if (cachedAtMs != 0L && now2 - cachedAtMs < ttl2) {
return@withLock _available.value
}
val newResult = probe()
_available.value = newResult
cachedAtMs = currentTimeMs()
newResult
}
}
/**
* Forces the next call to [isAvailable] to re-probe regardless of TTL.
*/
fun invalidate() {
cachedAtMs = 0L
}
private suspend fun probe(): Boolean =
try {
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
val client =
baseClient
.newBuilder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
val request =
Request
.Builder()
.url("$LOCAL_CACHE_BASE/")
.head()
.build()
client.newCall(request).executeAsync().use { response ->
// Spec says HEAD / returns 2xx when available. Some implementations
// may answer 405 (method not allowed) while still being a working
// Blossom cache, so treat that as available too.
response.isSuccessful || response.code == 405
}
} catch (e: Exception) {
if (e is CancellationException) throw e
false
}
private fun currentTimeMs(): Long = System.currentTimeMillis()
companion object {
const val LOCAL_CACHE_BASE: String = "http://127.0.0.1:24242"
private const val POSITIVE_TTL_MS = 60_000L
private const val NEGATIVE_TTL_MS = 10_000L
private const val PROBE_TIMEOUT_MS = 1_500L
}
}
@@ -22,20 +22,26 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -57,7 +63,7 @@ fun AllMediaServersScreen(
blossomServersViewModel.load() blossomServersViewModel.load()
} }
MediaServersScaffold(blossomServersViewModel) { MediaServersScaffold(blossomServersViewModel, accountViewModel) {
nav.popBack() nav.popBack()
} }
} }
@@ -66,6 +72,7 @@ fun AllMediaServersScreen(
@Composable @Composable
fun MediaServersScaffold( fun MediaServersScaffold(
blossomServersViewModel: BlossomServersViewModel, blossomServersViewModel: BlossomServersViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
Scaffold( Scaffold(
@@ -105,7 +112,55 @@ fun MediaServersScaffold(
color = MaterialTheme.colorScheme.grayText, color = MaterialTheme.colorScheme.grayText,
) )
LocalBlossomCacheToggle(accountViewModel)
HorizontalDivider()
AllMediaBody(blossomServersViewModel) AllMediaBody(blossomServersViewModel)
} }
} }
} }
@Composable
private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
val enabled by accountViewModel.account.settings.useLocalBlossomCache
.collectAsStateWithLifecycle()
val probeAvailable by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
Column(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(id = R.string.use_local_blossom_cache),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringRes(id = R.string.use_local_blossom_cache_caption),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
Text(
text =
if (enabled && probeAvailable) {
stringRes(id = R.string.local_blossom_cache_detected)
} else if (enabled) {
stringRes(id = R.string.local_blossom_cache_not_detected)
} else {
""
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Switch(
checked = enabled,
onCheckedChange = { accountViewModel.account.settings.changeUseLocalBlossomCache(it) },
)
}
}
}
@@ -75,6 +75,7 @@ import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
@@ -89,6 +90,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
@@ -566,6 +568,11 @@ private fun RenderImageOrVideo(
} }
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val modifier = val modifier =
if (ratio != null) { if (ratio != null) {
@@ -576,7 +583,7 @@ private fun RenderImageOrVideo(
Box(modifier, contentAlignment = Alignment.Center) { Box(modifier, contentAlignment = Alignment.Center) {
VideoViewInner( VideoViewInner(
videoUri = content.url, videoUri = bridgedUrl,
mimeType = content.mimeType, mimeType = content.mimeType,
aspectRatio = ratio, aspectRatio = ratio,
title = content.description, title = content.description,
@@ -66,6 +66,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
@@ -86,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
@@ -148,20 +150,26 @@ fun ZoomableContentView(
sourceBounds = coordinates.boundsInWindow() sourceBounds = coordinates.boundsInWindow()
} }
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
when (content) { when (content) {
is MediaUrlImage -> { is MediaUrlImage -> {
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate( ContentWarningGate(
isSensitive = content.contentWarning != null, isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning), reasons = setOfNotNull(content.contentWarning),
preloadUrls = listOf(content.url), preloadUrls = listOf(bridgedUrl),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale), modifier = mediaSizingModifier(ratio, contentScale),
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } }, backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
) { ) {
if (content.isGif()) { if (content.isGif()) {
GifVideoView( GifVideoView(
videoUri = content.url, videoUri = bridgedUrl,
contentDescription = content.description, contentDescription = content.description,
dimensions = content.dim, dimensions = content.dim,
blurhash = content.blurhash, blurhash = content.blurhash,
@@ -187,6 +195,10 @@ fun ZoomableContentView(
is MediaUrlVideo -> { is MediaUrlVideo -> {
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url) val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate( ContentWarningGate(
isSensitive = content.contentWarning != null, isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning), reasons = setOfNotNull(content.contentWarning),
@@ -200,7 +212,7 @@ fun ZoomableContentView(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
VideoView( VideoView(
videoUri = content.url, videoUri = bridgedUrl,
mimeType = content.mimeType, mimeType = content.mimeType,
title = content.description, title = content.description,
artworkUri = content.artworkUri, artworkUri = content.artworkUri,
@@ -465,17 +477,22 @@ fun UrlImageView(
} }
val context = LocalContext.current val context = LocalContext.current
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val imageModel = val imageModel =
if (fullResolution) { if (fullResolution) {
remember(content.url, context) { remember(bridgedUrl, context) {
ImageRequest ImageRequest
.Builder(context) .Builder(context)
.data(content.url) .data(bridgedUrl)
.size(Size.ORIGINAL) .size(Size.ORIGINAL)
.build() .build()
} }
} else { } else {
content.url bridgedUrl
} }
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
@@ -1183,9 +1200,15 @@ private suspend fun shareLocalVideoFile(
private fun verifyHash(content: MediaUrlContent): Boolean? { private fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null if (content.hash == null) return null
Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot -> val keys = mutableListOf(content.url)
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream()) val bridged = content.toCoilModel(true)
return hashBytes.toHexKey() == content.hash if (bridged != content.url) keys.add(bridged)
for (key in keys) {
Amethyst.instance.diskCache.openSnapshot(key)?.use { snapshot ->
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
return hashBytes.toHexKey() == content.hash
}
} }
return null return null
@@ -188,6 +188,28 @@ class AccountViewModel(
val broadcastTracker = BroadcastTracker() val broadcastTracker = BroadcastTracker()
val feedStates = AccountFeedContentStates(account, viewModelScope) val feedStates = AccountFeedContentStates(account, viewModelScope)
/**
* `true` when both the per-account toggle is enabled AND the local
* Blossom cache HEAD probe currently sees `127.0.0.1:24242` as
* available. UI call sites use this to decide whether to convert plain
* http(s) URLs (with imeta sha256) into `blossom:` URIs so the request
* routes through the local cache.
*/
val useLocalBlossomBridge: StateFlow<Boolean> =
try {
combine(
account.settings.useLocalBlossomCache,
Amethyst.instance.localBlossomCacheProbe.available,
) { toggle, probeUp -> toggle && probeUp }.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
} catch (e: UninitializedPropertyAccessException) {
// Mock/test instances don't initialise Amethyst.instance.
MutableStateFlow(false)
}
val callManager = val callManager =
CallManager( CallManager(
signer = account.signer, signer = account.signer,
@@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImage
@@ -47,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -256,11 +258,16 @@ fun UrlImageView(
val isVideo = content is MediaUrlVideo val isVideo = content is MediaUrlVideo
val artworkUri = (content as? MediaUrlVideo)?.artworkUri val artworkUri = (content as? MediaUrlVideo)?.artworkUri
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
// Coil's VideoFrameDecoder can extract a frame from .mp4/.webm but not from an HLS .m3u8 // Coil's VideoFrameDecoder can extract a frame from .mp4/.webm but not from an HLS .m3u8
// playlist (it's a text manifest). For an HLS video without a separate artwork URL, sending // playlist (it's a text manifest). For an HLS video without a separate artwork URL, sending
// the playlist to SubcomposeAsyncImage just produces an Error state and a stand-in icon. // the playlist to SubcomposeAsyncImage just produces an Error state and a stand-in icon.
// Skip the fetch in that case and render blurhash + play overlay directly. // Skip the fetch in that case and render blurhash + play overlay directly.
val imageModelUrl = artworkUri ?: content.url val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val imageModelUrl = artworkUri ?: bridgedUrl
val canLoadAsImage = !isVideo || artworkUri != null || !isLiveStreaming(content.url) val canLoadAsImage = !isVideo || artworkUri != null || !isLiveStreaming(content.url)
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) { CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
+5
View File
@@ -806,6 +806,11 @@
<string name="media_servers">Media Servers</string> <string name="media_servers">Media Servers</string>
<string name="set_preferred_media_servers">Set your preferred media upload servers.</string> <string name="set_preferred_media_servers">Set your preferred media upload servers.</string>
<string name="use_local_blossom_cache">Use local Blossom cache</string>
<string name="use_local_blossom_cache_caption">When a Blossom cache is running on this device (port 24242), route image and video downloads through it.</string>
<string name="local_blossom_cache_detected">Local cache detected on port 24242.</string>
<string name="local_blossom_cache_not_detected">Local cache not detected on port 24242.</string>
<string name="no_nip96_server_message">You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓</string> <string name="no_nip96_server_message">You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓</string>
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string> <string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
private val sha256HexRegex = Regex("[0-9a-f]{64}")
/**
* Converts this media content into a Coil/ExoPlayer-friendly model string.
*
* When the local-Blossom-cache bridge is active and the content has a
* known sha256 hash, returns a `blossom:<sha256>.<ext>?xs=<originalHostBase>`
* URI. The Coil pipeline will recognise the scheme and route the request
* through `BlossomServerResolver`, which in turn short-circuits to the
* local cache at `127.0.0.1:24242`.
*
* Otherwise (bridge off, no hash, hash invalid, already a `blossom:` URI,
* or a live stream) returns the original URL unchanged so today's
* direct-to-CDN behaviour is preserved.
*/
fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String {
if (!useLocalBlossomBridge) return url
if (this is MediaUrlVideo && isLiveStream) return url
val sha = hash?.lowercase() ?: return url
if (!sha256HexRegex.matches(sha)) return url
if (url.startsWith("blossom:", ignoreCase = true)) return url
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
val ext = guessExtension(url, mimeType)
val hostBase = extractHostBase(url) ?: return url
return BlossomUri(
sha256 = sha,
extension = ext,
servers = listOf(hostBase),
authors = emptyList(),
size = null,
).toUriString()
}
private fun guessExtension(
url: String,
mimeType: String?,
): String {
val pathPart = url.substringBefore('?').substringBefore('#')
val lastDot = pathPart.lastIndexOf('.')
val lastSlash = pathPart.lastIndexOf('/')
if (lastDot > lastSlash && lastDot >= 0) {
val ext = pathPart.substring(lastDot + 1).lowercase()
if (ext.isNotEmpty() && ext.length <= 8 && ext.all { it.isLetterOrDigit() }) {
return ext
}
}
if (mimeType != null) {
for ((extension, mt) in mimeTypeMap) {
if (mt.equals(mimeType, ignoreCase = true)) return extension
}
}
return "bin"
}
private fun extractHostBase(url: String): String? {
val schemeEnd = url.indexOf("://")
if (schemeEnd < 0) return null
val afterScheme = schemeEnd + 3
var end = url.length
for (i in afterScheme until url.length) {
val c = url[i]
if (c == '/' || c == '?' || c == '#') {
end = i
break
}
}
if (end <= afterScheme) return null
return url.substring(0, end)
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.richtext
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MediaUrlContentExtTest {
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
@Test
fun bridgeOffReturnsOriginalUrl() {
val image = MediaUrlImage(url = "https://cdn.example.com/$sha.jpg", hash = sha)
assertEquals("https://cdn.example.com/$sha.jpg", image.toCoilModel(useLocalBlossomBridge = false))
}
@Test
fun nullHashReturnsOriginalUrl() {
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = null)
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun invalidHashReturnsOriginalUrl() {
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = "not-hex")
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun blossomUriReturnedUnchanged() {
val image = MediaUrlImage(url = "blossom:$sha.jpg?xs=https://cdn.example.com", hash = sha)
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun liveStreamReturnsOriginalUrl() {
val video = MediaUrlVideo(url = "https://stream.example.com/play.m3u8", hash = sha, isLiveStream = true)
assertEquals("https://stream.example.com/play.m3u8", video.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeOnRewritesPlainHttpsUrl() {
val image = MediaUrlImage(url = "https://nostr.build/i/abc/$sha.jpg", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://nostr.build", result)
}
@Test
fun bridgeOnInfersExtensionFromMimeType() {
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha, mimeType = "image/png")
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result")
}
@Test
fun bridgeOnFallsBackToBinExtension() {
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result")
}
@Test
fun nonHttpUrlReturnsOriginal() {
val image = MediaUrlImage(url = "ftp://example.com/file", hash = sha)
assertEquals("ftp://example.com/file", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun uppercaseHashNormalisedToLowercase() {
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = sha.uppercase())
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result")
}
}
@@ -65,18 +65,38 @@ data class BlossomUri(
append(sha256) append(sha256)
append('.') append('.')
append(extension) append(extension)
val params = appendQueryString()
buildList {
servers.forEach { add("xs=${percentEncodeQueryValue(it)}") }
authors.forEach { add("as=$it") }
this@BlossomUri.size?.let { add("sz=$it") }
}
if (params.isNotEmpty()) {
append('?')
append(params.joinToString("&"))
}
} }
/**
* Builds an HTTP URL pointing at a local Blossom cache that proxies
* upstream using the same `xs`/`as`/`sz` hints as the canonical URI.
*
* @param base the cache base URL, e.g. `http://127.0.0.1:24242`.
*/
fun toLocalCacheUrl(base: String): String =
buildString {
append(base.removeSuffix("/"))
append('/')
append(sha256)
append('.')
append(extension)
appendQueryString()
}
private fun StringBuilder.appendQueryString() {
val params =
buildList {
servers.forEach { add("xs=${percentEncodeQueryValue(it)}") }
authors.forEach { add("as=$it") }
this@BlossomUri.size?.let { add("sz=$it") }
}
if (params.isNotEmpty()) {
append('?')
append(params.joinToString("&"))
}
}
companion object { companion object {
private const val SCHEME = "blossom:" private const val SCHEME = "blossom:"
@@ -151,4 +151,76 @@ class BlossomUriTest {
assertEquals(listOf(server1, server2), result.servers) assertEquals(listOf(server1, server2), result.servers)
assertEquals(size, result.size) assertEquals(size, result.size)
} }
@Test
fun toLocalCacheUrlMinimal() {
val uri =
BlossomUri(
sha256 = sha256,
extension = "jpg",
servers = emptyList(),
authors = emptyList(),
size = null,
)
assertEquals(
"http://127.0.0.1:24242/$sha256.jpg",
uri.toLocalCacheUrl("http://127.0.0.1:24242"),
)
}
@Test
fun toLocalCacheUrlIncludesHints() {
val uri =
BlossomUri(
sha256 = sha256,
extension = "mp4",
servers = listOf("https://cdn.example.com", "https://backup.example.com"),
authors = listOf(authorPubkey),
size = 1048576L,
)
// BlossomUri.percentEncodeQueryValue lets `:` and `/` through unencoded
// since they're safe in a query value as long as `&`/`=`/`#` are absent.
assertEquals(
"http://127.0.0.1:24242/$sha256.mp4" +
"?xs=https://cdn.example.com" +
"&xs=https://backup.example.com" +
"&as=$authorPubkey" +
"&sz=1048576",
uri.toLocalCacheUrl("http://127.0.0.1:24242"),
)
}
@Test
fun toLocalCacheUrlEncodesAmpersandInServer() {
val uri =
BlossomUri(
sha256 = sha256,
extension = "jpg",
servers = listOf("https://x.example.com/path?a=1&b=2"),
authors = emptyList(),
size = null,
)
// & and = inside the server URL must be encoded so they don't break the query.
assertEquals(
"http://127.0.0.1:24242/$sha256.jpg" +
"?xs=https://x.example.com/path?a%3D1%26b%3D2",
uri.toLocalCacheUrl("http://127.0.0.1:24242"),
)
}
@Test
fun toLocalCacheUrlStripsTrailingSlash() {
val uri =
BlossomUri(
sha256 = sha256,
extension = "jpg",
servers = emptyList(),
authors = emptyList(),
size = null,
)
assertEquals(
"http://127.0.0.1:24242/$sha256.jpg",
uri.toLocalCacheUrl("http://127.0.0.1:24242/"),
)
}
} }