Merge pull request #2786 from greenart7c3/claude/add-blossom-cache-support-ts8mK

Add local Blossom cache bridge for transparent media proxying
This commit is contained in:
Vitor Pamplona
2026-05-08 08:15:51 -04:00
committed by GitHub
30 changed files with 1275 additions and 42 deletions
@@ -41,6 +41,7 @@ fun TranslatableRichTextViewer(
backgroundColor: MutableState<Color>,
id: String,
callbackUri: String? = null,
authorPubKey: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) = ExpandableRichTextViewer(
@@ -52,6 +53,7 @@ fun TranslatableRichTextViewer(
backgroundColor,
id,
callbackUri,
authorPubKey,
accountViewModel,
nav,
)
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
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.ui.resourceCacheInit
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
@@ -111,6 +112,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transform
@@ -216,7 +218,7 @@ class AppModules(
val dnsStore = SurgeDnsStore(appContext, surgeDns)
// manages all the other connections separately from relays.
val okHttpClients =
val okHttpClients: DualHttpClientManager =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
@@ -224,6 +226,15 @@ class AppModules(
keyCache = keyCache,
scope = applicationIOScope,
dns = surgeDns,
// Transparently rewrites sha256-keyed HTTP requests to the local
// Blossom cache when the master toggle is on, the profile-pictures-only
// restriction is off, and the probe sees 127.0.0.1:24242 as available.
shouldBridgeBlossomCache = {
val settings = sessionManager.loggedInAccount()?.settings
val master = settings?.useLocalBlossomCache?.value ?: false
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly && localBlossomCacheProbe.available.value
},
)
// Offers easy methods to know when connections are happening through Tor or not
@@ -412,6 +423,10 @@ class AppModules(
}
}
val localBlossomCacheProbe by lazy {
LocalBlossomCacheProbe(roleBasedHttpClientBuilder)
}
val blossomResolver by lazy {
Log.d("AppModules", "BlossomServerResolver Init")
BlossomServerResolver(
@@ -428,6 +443,14 @@ class AppModules(
}
},
httpClientBuilder = roleBasedHttpClientBuilder,
useLocalBlossomCache = {
sessionManager
.loggedInAccount()
?.settings
?.useLocalBlossomCache
?.value ?: false
},
localCacheProbe = localBlossomCacheProbe,
)
}
@@ -588,6 +611,37 @@ class AppModules(
}
}
// Evict the BlossomServerResolver URL cache whenever either 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) {
merge(
state.account.settings.useLocalBlossomCache
.drop(1),
state.account.settings.localBlossomCacheProfilePicturesOnly
.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
// 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
@@ -95,6 +95,8 @@ private object PrefKeys {
const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
@@ -346,6 +348,8 @@ object LocalPreferences {
)
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
@@ -513,6 +517,8 @@ object LocalPreferences {
Log.d("LocalPreferences") { "Load account from file $npub - keys ready" }
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false)
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
@@ -620,6 +626,8 @@ object LocalPreferences {
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer.await(),
stripLocationOnUpload = stripLocationOnUpload,
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
@@ -149,6 +149,8 @@ class AccountSettings(
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = MutableStateFlow(false),
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -403,6 +405,20 @@ class AccountSettings(
}
}
fun changeUseLocalBlossomCache(enabled: Boolean) {
if (useLocalBlossomCache.value != enabled) {
useLocalBlossomCache.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) {
if (localBlossomCacheProfilePicturesOnly.value != enabled) {
localBlossomCacheProfilePicturesOnly.tryEmit(enabled)
saveAccountSettings()
}
}
fun updateDisableClientTag(disable: Boolean): Boolean =
if (syncedSettings.security.updateDisableClientTag(disable)) {
saveAccountSettings()
@@ -33,12 +33,16 @@ object CachedRichTextParser {
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String?,
authorPubKey: String?,
): Int {
var result = content.hashCode()
result = 31 * result + tags.lists.hashCode()
if (callbackUri != null) {
result = 31 * result + callbackUri.hashCode()
}
if (authorPubKey != null) {
result = 31 * result + authorPubKey.hashCode()
}
return result
}
@@ -46,19 +50,21 @@ object CachedRichTextParser {
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri)]
authorPubKey: String? = null,
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri, authorPubKey)]
fun parseText(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
authorPubKey: String? = null,
): RichTextViewerState {
val key = hashCodeCache(content, tags, callbackUri)
val key = hashCodeCache(content, tags, callbackUri, authorPubKey)
val cached = richTextCache[key]
return if (cached != null) {
cached
} else {
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
val newUrls = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
richTextCache.put(key, newUrls)
newUrls
}
@@ -39,8 +39,9 @@ class DualHttpClientManager(
keyCache: EncryptionKeyCache,
scope: CoroutineScope,
dns: SurgeDns,
shouldBridgeBlossomCache: (() -> Boolean)? = null,
) : IHttpClientManager {
val factory = OkHttpClientFactory(keyCache, userAgent, dns)
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache)
val defaultHttpClient: StateFlow<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
@@ -0,0 +1,119 @@
/*
* 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.okhttp
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Response
/**
* App-wide OkHttp interceptor that transparently rewrites HTTP requests for
* sha256-keyed blobs to a local Blossom cache running on `127.0.0.1:24242`,
* per https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md
*
* Activates when [shouldBridge] returns `true` AND the request URL contains
* a 64-char hex sha256 segment in its path AND the host isn't already
* `127.0.0.1`/`localhost`. The original scheme+host is appended as a `xs=`
* proxy hint so the cache can fetch upstream on miss.
*
* Coil's disk cache keys responses by the original `ImageRequest.data`, so
* disk caching continues to work transparently even though the network
* request now goes to localhost.
*/
class LocalBlossomCacheRedirectInterceptor(
private val shouldBridge: () -> Boolean,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!shouldBridge()) return chain.proceed(request)
val rewritten = rewriteIfApplicable(request.url) ?: return chain.proceed(request)
return chain.proceed(
request
.newBuilder()
.url(rewritten)
.build(),
)
}
private fun rewriteIfApplicable(url: HttpUrl): HttpUrl? {
val host = url.host
if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null
val (shaSegmentIndex, sha, ext) = findSha256AndExtensionInPath(url) ?: return null
val serverBase = buildServerBase(url, shaSegmentIndex)
return "$LOCAL_CACHE_BASE/$sha.$ext"
.toHttpUrl()
.newBuilder()
.addQueryParameter("xs", serverBase)
.build()
}
private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? {
url.pathSegments.forEachIndexed { index, segment ->
val match = SHA256_SEGMENT_REGEX.find(segment) ?: return@forEachIndexed
val sha = match.value.lowercase()
val ext = guessExtensionFrom(segment, sha) ?: "bin"
return Triple(index, sha, ext)
}
return null
}
/**
* Builds the URL prefix that the local cache should append `/<sha>` to.
* Preserves any path prefix the upstream CDN uses (e.g. `/i` for
* `https://cdn.nostr.build/i/<sha>`) so the cache can fetch the blob
* from its actual location on miss.
*/
private fun buildServerBase(
url: HttpUrl,
shaSegmentIndex: Int,
): String {
val origin = "${url.scheme}://${url.host}" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else ""
if (shaSegmentIndex == 0) return origin
val prefixSegments = url.pathSegments.subList(0, shaSegmentIndex)
return "$origin/" + prefixSegments.joinToString("/")
}
private fun guessExtensionFrom(
segment: String,
sha: String,
): String? {
val idx = segment.indexOf(sha, ignoreCase = true)
val after = if (idx >= 0) segment.substring(idx + sha.length) else return null
if (!after.startsWith('.')) return null
val rest = after.substring(1).lowercase()
if (rest.isEmpty() || rest.length > 8) return null
if (!rest.all { it.isLetterOrDigit() }) return null
return rest
}
companion object {
const val LOCAL_CACHE_HOST = "127.0.0.1"
const val LOCAL_CACHE_PORT = 24242
const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT"
private val SHA256_SEGMENT_REGEX = Regex("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
}
}
@@ -36,9 +36,18 @@ class OkHttpClientFactory(
keyCache: EncryptionKeyCache,
val userAgent: String,
private val dns: SurgeDns,
/**
* Returns `true` when sha256-keyed HTTP requests should be transparently
* rewritten to the local Blossom cache (master toggle on, profile-pictures-only
* restriction off, probe up). When `null`, the interceptor is disabled —
* useful for tests or pre-configuration call sites.
*/
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
) {
// val logging = LoggingInterceptor()
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
private val blossomCacheRedirect =
shouldBridgeBlossomCache?.let { LocalBlossomCacheRedirectInterceptor(it) }
// Most images/videos in a feed come from a small set of hosts (e.g. a single
// Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests
@@ -69,6 +78,9 @@ class OkHttpClientFactory(
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.apply {
blossomCacheRedirect?.let { addInterceptor(it) }
}
// .addNetworkInterceptor(logging)
.addNetworkInterceptor(keyDecryptor)
.build()
@@ -41,6 +41,8 @@ class BlossomServerResolver(
val loggedInUsers: () -> List<HexKey>,
val blossomServers: (Set<Address>) -> List<Flow<BlossomServersEvent>>,
val httpClientBuilder: IRoleBasedHttpClientBuilder,
val useLocalBlossomCache: () -> Boolean = { false },
val localCacheProbe: LocalBlossomCacheProbe? = null,
) {
val blossomHitCache: ServerHeadCache = ServerHeadCache()
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
@@ -71,6 +73,10 @@ class BlossomServerResolver(
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
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 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -57,7 +63,7 @@ fun AllMediaServersScreen(
blossomServersViewModel.load()
}
MediaServersScaffold(blossomServersViewModel) {
MediaServersScaffold(blossomServersViewModel, accountViewModel) {
nav.popBack()
}
}
@@ -66,6 +72,7 @@ fun AllMediaServersScreen(
@Composable
fun MediaServersScaffold(
blossomServersViewModel: BlossomServersViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
) {
Scaffold(
@@ -105,7 +112,83 @@ fun MediaServersScaffold(
color = MaterialTheme.colorScheme.grayText,
)
LocalBlossomCacheToggle(accountViewModel)
HorizontalDivider()
AllMediaBody(blossomServersViewModel)
}
}
}
@Composable
private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
val enabled by accountViewModel.account.settings.useLocalBlossomCache
.collectAsStateWithLifecycle()
val profilePicturesOnly by accountViewModel.account.settings.localBlossomCacheProfilePicturesOnly
.collectAsStateWithLifecycle()
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
.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) },
)
}
if (enabled) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(id = R.string.local_blossom_cache_profile_pics_only),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringRes(id = R.string.local_blossom_cache_profile_pics_only_caption),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Switch(
checked = profilePicturesOnly,
onCheckedChange = {
accountViewModel.account.settings.changeLocalBlossomCacheProfilePicturesOnly(it)
},
)
}
}
}
}
@@ -64,6 +64,7 @@ fun ExpandableRichTextViewer(
backgroundColor: MutableState<Color>,
id: String,
callbackUri: String? = null,
authorPubKey: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -100,6 +101,7 @@ fun ExpandableRichTextViewer(
tags,
backgroundColor,
callbackUri,
authorPubKey,
accountViewModel,
nav,
)
@@ -140,6 +140,7 @@ fun RichTextViewer(
tags: ImmutableListOfLists<String>,
backgroundColor: MutableState<Color>,
callbackUri: String? = null,
authorPubKey: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -147,7 +148,7 @@ fun RichTextViewer(
if (remember(content) { isMarkdown(content) }) {
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
} else {
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav)
}
}
}
@@ -347,11 +348,12 @@ private fun RenderRegular(
quotesLeft: Int,
backgroundColor: MutableState<Color>,
callbackUri: String? = null,
authorPubKey: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (canPreview) {
RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier ->
RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier ->
if (paragraph is ImageGalleryParagraph) {
ImageGallery(
images = paragraph,
@@ -375,7 +377,7 @@ private fun RenderRegular(
}
}
} else {
RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier ->
RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier ->
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
RenderWordWithoutPreview(
word,
@@ -412,9 +414,10 @@ fun RenderRegular(
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String? = null,
authorPubKey: String? = null,
renderParagraph: @Composable (ParagraphState, state: RichTextViewerState, Dp, modifier: Modifier) -> Unit,
) {
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) }
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri, authorPubKey)) }
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
@@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -38,17 +39,48 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.asDrawable
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
import com.vitorpamplona.amethyst.commons.richtext.bridgeProfilePictureUrl
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
private fun rememberLocalBlossomBridgeForProfilePics(): Boolean {
val sessionManager =
try {
Amethyst.instance.sessionManager
} catch (e: UninitializedPropertyAccessException) {
return false
}
val probe = Amethyst.instance.localBlossomCacheProbe
val flow =
remember {
combine(
sessionManager.accountContent.flatMapLatest { state ->
if (state is AccountState.LoggedIn) state.account.settings.useLocalBlossomCache else flowOf(false)
},
probe.available,
) { toggle, probeUp -> toggle && probeUp }
}
val state by flow.collectAsStateWithLifecycle(initialValue = false)
return state
}
@Composable
fun RobohashAsyncImage(
@@ -88,16 +120,21 @@ fun RobohashFallbackAsyncImage(
loadRobohash: Boolean,
autoPlayGif: Boolean = true,
) {
if (model != null && loadProfilePicture && isGifUrl(model)) {
val useBridge = rememberLocalBlossomBridgeForProfilePics()
val bridgedModel =
remember(model, robot, useBridge) {
bridgeProfilePictureUrl(model, useBridge, robot)
}
if (bridgedModel != null && loadProfilePicture && isGifUrl(bridgedModel)) {
GifProfilePicture(
userHex = robot,
userPicture = model,
userPicture = bridgedModel,
contentDescription = contentDescription,
modifier = modifier,
loadRobohash = loadRobohash,
autoPlay = autoPlayGif,
)
} else if (model != null && loadProfilePicture) {
} else if (bridgedModel != null && loadProfilePicture) {
val painter =
if (loadRobohash) {
rememberVectorPainter(
@@ -111,7 +148,7 @@ fun RobohashFallbackAsyncImage(
}
AsyncImage(
model = ProfilePictureUrl(model),
model = ProfilePictureUrl(bridgedModel),
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
@@ -75,6 +75,7 @@ import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
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.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
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 useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val modifier =
if (ratio != null) {
@@ -576,7 +583,7 @@ private fun RenderImageOrVideo(
Box(modifier, contentAlignment = Alignment.Center) {
VideoViewInner(
videoUri = content.url,
videoUri = bridgedUrl,
mimeType = content.mimeType,
aspectRatio = ratio,
title = content.description,
@@ -66,6 +66,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import coil3.compose.AsyncImage
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.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
@@ -148,20 +150,26 @@ fun ZoomableContentView(
sourceBounds = coordinates.boundsInWindow()
}
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
when (content) {
is MediaUrlImage -> {
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
preloadUrls = listOf(content.url),
preloadUrls = listOf(bridgedUrl),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
) {
if (content.isGif()) {
GifVideoView(
videoUri = content.url,
videoUri = bridgedUrl,
contentDescription = content.description,
dimensions = content.dim,
blurhash = content.blurhash,
@@ -187,6 +195,10 @@ fun ZoomableContentView(
is MediaUrlVideo -> {
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
@@ -200,7 +212,7 @@ fun ZoomableContentView(
contentAlignment = Alignment.Center,
) {
VideoView(
videoUri = content.url,
videoUri = bridgedUrl,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
@@ -465,17 +477,22 @@ fun UrlImageView(
}
val context = LocalContext.current
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val imageModel =
if (fullResolution) {
remember(content.url, context) {
remember(bridgedUrl, context) {
ImageRequest
.Builder(context)
.data(content.url)
.data(bridgedUrl)
.size(Size.ORIGINAL)
.build()
}
} else {
content.url
bridgedUrl
}
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
@@ -1183,9 +1200,15 @@ private suspend fun shareLocalVideoFile(
private fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null
Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot ->
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
return hashBytes.toHexKey() == content.hash
val keys = mutableListOf(content.url)
val bridged = content.toCoilModel(true)
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
@@ -550,6 +550,7 @@ fun CrossfadeToDisplayComment(
backgroundColor,
comment,
null,
null,
accountViewModel,
nav,
)
@@ -425,6 +425,7 @@ private fun RenderOptionAfterVote(
backgroundColor,
baseNote.idHex + poolOption.descriptor,
baseNote.toNostrUri(),
baseNote.author?.pubkeyHex,
accountViewModel,
nav,
)
@@ -188,6 +188,46 @@ class AccountViewModel(
val broadcastTracker = BroadcastTracker()
val feedStates = AccountFeedContentStates(account, viewModelScope)
/**
* `true` when feed/note media (images and videos in `MediaUrlContent`)
* should be routed through the local Blossom cache. Requires the master
* toggle on, the probe up, AND the profile-pictures-only restriction
* to be off.
*/
val useLocalBlossomBridge: StateFlow<Boolean> =
try {
combine(
account.settings.useLocalBlossomCache,
account.settings.localBlossomCacheProfilePicturesOnly,
Amethyst.instance.localBlossomCacheProbe.available,
) { toggle, profileOnly, probeUp -> toggle && probeUp && !profileOnly }.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
} catch (e: UninitializedPropertyAccessException) {
MutableStateFlow(false)
}
/**
* `true` when profile pictures should be routed through the local
* Blossom cache. Requires only the master toggle and the probe to be
* up; the profile-pictures-only restriction does not gate this flow.
*/
val useLocalBlossomBridgeForProfilePics: StateFlow<Boolean> =
try {
combine(
account.settings.useLocalBlossomCache,
Amethyst.instance.localBlossomCacheProbe.available,
) { toggle, probeUp -> toggle && probeUp }.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
} catch (e: UninitializedPropertyAccessException) {
MutableStateFlow(false)
}
val callManager =
CallManager(
signer = account.signer,
@@ -62,6 +62,7 @@ fun RenderRegularTextNote(
backgroundColor = bgColor,
id = note.idHex,
callbackUri = note.toNostrUri(),
authorPubKey = note.author?.pubkeyHex,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImagePainter
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.MediaUrlVideo
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.service.playback.diskCache.isLiveStreaming
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -256,11 +258,16 @@ fun UrlImageView(
val isVideo = content is MediaUrlVideo
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
// 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.
// 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)
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
+7
View File
@@ -866,6 +866,13 @@
<string name="media_servers">Media 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="local_blossom_cache_profile_pics_only">Only cache profile pictures</string>
<string name="local_blossom_cache_profile_pics_only_caption">Restrict the local cache to profile pictures. Feed images and videos will be fetched directly from the original servers.</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>
@@ -53,6 +53,7 @@ fun TranslatableRichTextViewer(
backgroundColor: MutableState<Color>,
id: String,
callbackUri: String? = null,
authorPubKey: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -70,6 +71,7 @@ fun TranslatableRichTextViewer(
backgroundColor,
id,
callbackUri,
authorPubKey,
accountViewModel,
nav,
)
@@ -0,0 +1,181 @@
/*
* 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.okhttp
import okhttp3.Connection
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Test
class LocalBlossomCacheRedirectInterceptorTest {
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
@Test
fun bridgeOffPassesThrough() {
val interceptor = LocalBlossomCacheRedirectInterceptor { false }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha.jpg", captured))
assertEquals("https://blossom.example.com/$sha.jpg", captured.single())
response.close()
}
@Test
fun bridgeOnRewritesShaInPath() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha.jpg", captured))
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com",
captured.single(),
)
response.close()
}
@Test
fun bridgeOnPreservesNonHexUrls() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://example.com/avatar.jpg", captured))
assertEquals("https://example.com/avatar.jpg", captured.single())
response.close()
}
@Test
fun bridgeOnSkipsLocalhost() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("http://127.0.0.1:24242/$sha.jpg", captured))
assertEquals("http://127.0.0.1:24242/$sha.jpg", captured.single())
response.close()
}
@Test
fun bridgeOnHandlesNonStandardPort() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com:8443/$sha.png", captured))
assertEquals(
"http://127.0.0.1:24242/$sha.png?xs=https%3A%2F%2Fblossom.example.com%3A8443",
captured.single(),
)
response.close()
}
@Test
fun bridgeOnHandlesUppercaseSha() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com/${sha.uppercase()}.jpg", captured))
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com",
captured.single(),
)
response.close()
}
@Test
fun bridgeOnFallsBackToBinExtension() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha", captured))
assertEquals(
"http://127.0.0.1:24242/$sha.bin?xs=https%3A%2F%2Fblossom.example.com",
captured.single(),
)
response.close()
}
@Test
fun bridgeOnHandlesShaInDeeperPathSegment() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://nostr.build/i/cache/$sha.webp", captured))
assertEquals(
"http://127.0.0.1:24242/$sha.webp?xs=https%3A%2F%2Fnostr.build%2Fi%2Fcache",
captured.single(),
)
response.close()
}
@Test
fun bridgeOnPreservesNostrBuildPathPrefix() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://cdn.nostr.build/i/$sha.jpg", captured))
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi",
captured.single(),
)
response.close()
}
private fun fakeChain(
url: String,
captured: MutableList<String>,
): Interceptor.Chain =
object : Interceptor.Chain {
private val request = Request.Builder().url(url.toHttpUrl()).build()
override fun request(): Request = request
override fun proceed(request: Request): Response {
captured.add(request.url.toString())
return Response
.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("".toResponseBody(null))
.build()
}
override fun connection(): Connection? = null
override fun call() = throw UnsupportedOperationException()
override fun connectTimeoutMillis(): Int = 0
override fun readTimeoutMillis(): Int = 0
override fun writeTimeoutMillis(): Int = 0
override fun withConnectTimeout(
timeout: Int,
unit: java.util.concurrent.TimeUnit,
): Interceptor.Chain = this
override fun withReadTimeout(
timeout: Int,
unit: java.util.concurrent.TimeUnit,
): Interceptor.Chain = this
override fun withWriteTimeout(
timeout: Int,
unit: java.util.concurrent.TimeUnit,
): Interceptor.Chain = this
}
}
@@ -42,6 +42,7 @@ abstract class MediaUrlContent(
val uri: String? = null,
val mimeType: String? = null,
thumbhash: String? = null,
val authorPubKey: String? = null,
) : BaseMediaContent(description, dim, blurhash, thumbhash)
@Immutable
@@ -55,7 +56,8 @@ open class MediaUrlImage(
val contentWarning: String? = null,
mimeType: String? = null,
thumbhash: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
authorPubKey: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey)
class EncryptedMediaUrlImage(
url: String,
@@ -70,7 +72,8 @@ class EncryptedMediaUrlImage(
val encryptionKey: ByteArray,
val encryptionNonce: ByteArray,
thumbhash: String? = null,
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType, thumbhash)
authorPubKey: String? = null,
) : MediaUrlImage(url, description, hash, blurhash, dim, uri, contentWarning, mimeType, thumbhash, authorPubKey)
@Immutable
open class MediaUrlPdf(
@@ -82,7 +85,8 @@ open class MediaUrlPdf(
uri: String? = null,
mimeType: String? = null,
thumbhash: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
authorPubKey: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey)
@Immutable
open class MediaUrlVideo(
@@ -98,7 +102,8 @@ open class MediaUrlVideo(
mimeType: String? = null,
thumbhash: String? = null,
val isLiveStream: Boolean = false,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash)
authorPubKey: String? = null,
) : MediaUrlContent(url, description, hash, dim, blurhash, uri, mimeType, thumbhash, authorPubKey)
@Immutable
class EncryptedMediaUrlVideo(
@@ -116,7 +121,8 @@ class EncryptedMediaUrlVideo(
val encryptionKey: ByteArray,
val encryptionNonce: ByteArray,
thumbhash: String? = null,
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash)
authorPubKey: String? = null,
) : MediaUrlVideo(url, description, hash, dim, uri, artworkUri, authorName, blurhash, contentWarning, mimeType, thumbhash, authorPubKey = authorPubKey)
@Immutable
abstract class MediaPreloadedContent(
@@ -0,0 +1,218 @@
/*
* 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}")
private val sha256InPathRegex = Regex("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
/**
* 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>&as=<authorPubKey>`
* URI. The Coil pipeline recognises the scheme and routes the request
* through `BlossomServerResolver`, which 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 =
bridgeUrl(
url = url,
useBridge = useLocalBlossomBridge,
explicitHash = hash,
mimeType = mimeType,
authorPubKey = authorPubKey,
skipBridge = this is MediaUrlVideo && isLiveStream,
)
/**
* Bridge entry point for raw URL strings (e.g. profile pictures) that go
* through a Coil fetcher routed by type (`ProfilePictureUrl`) and therefore
* bypass [com.vitorpamplona.quartz.nipB7Blossom.BlossomUri] processing
* entirely.
*
* Returns a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pubkey>`
* URL so the request can flow through `NetworkFetcher` unchanged. Falls
* back to the original URL when no sha256 can be recovered from the path
* or when the bridge is off.
*
* @param localCacheBase the local Blossom cache origin (default `http://127.0.0.1:24242`).
* @param authorPubKey 64-char lowercase hex pubkey appended as `as=` so the
* cache can consult that author's BUD-03 server list on miss.
*/
fun bridgeProfilePictureUrl(
url: String?,
useBridge: Boolean,
authorPubKey: String? = null,
localCacheBase: String = DEFAULT_LOCAL_CACHE_BASE,
): String? {
if (url == null) return null
if (!useBridge) return url
if (url.startsWith("blossom:", ignoreCase = true)) return url
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
val sha = extractSha256FromUrlPath(url) ?: return url
val ext = guessExtension(url, null)
val serverBase = extractServerBase(url, sha) ?: return url
val params =
buildList {
add("xs=${percentEncode(serverBase)}")
authorPubKey
?.lowercase()
?.takeIf { sha256HexRegex.matches(it) }
?.let { add("as=$it") }
}
return "${localCacheBase.removeSuffix("/")}/$sha.$ext?${params.joinToString("&")}"
}
const val DEFAULT_LOCAL_CACHE_BASE = "http://127.0.0.1:24242"
private fun bridgeUrl(
url: String,
useBridge: Boolean,
explicitHash: String?,
mimeType: String?,
authorPubKey: String?,
skipBridge: Boolean,
): String {
if (!useBridge || skipBridge) return url
if (url.startsWith("blossom:", ignoreCase = true)) return url
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
val sha =
explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) }
?: extractSha256FromUrlPath(url)
?: return url
val ext = guessExtension(url, mimeType)
val serverBase = extractServerBase(url, sha) ?: return url
val authors =
authorPubKey
?.lowercase()
?.takeIf { sha256HexRegex.matches(it) }
?.let { listOf(it) }
?: emptyList()
return BlossomUri(
sha256 = sha,
extension = ext,
servers = listOf(serverBase),
authors = authors,
size = null,
).toUriString()
}
private fun percentEncode(input: String): String {
val sb = StringBuilder(input.length)
for (c in input) {
if (c.isLetterOrDigit() || c in "-._~") {
sb.append(c)
} else {
for (b in c.toString().encodeToByteArray()) {
sb.append('%')
sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase())
}
}
}
return sb.toString()
}
private fun extractSha256FromUrlPath(url: String): String? {
val pathPart = url.substringBefore('?').substringBefore('#')
val match = sha256InPathRegex.find(pathPart) ?: return null
return match.value.lowercase()
}
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"
}
/**
* Returns the URL prefix that the local Blossom cache should append `/<sha>`
* to in order to reach the original blob, preserving any path prefix the
* upstream CDN uses (e.g. `https://cdn.nostr.build/i` for nostr.build's
* `/i/<sha>` scheme). Falls back to scheme+host when the sha can't be
* located in the path.
*/
private fun extractServerBase(
url: String,
sha: String,
): String? {
val pathPart = url.substringBefore('?').substringBefore('#')
val schemeEnd = pathPart.indexOf("://")
if (schemeEnd < 0) return null
val hostStart = schemeEnd + 3
if (hostStart >= pathPart.length) return null
val shaIndex = pathPart.indexOf(sha, ignoreCase = true)
if (shaIndex >= 0) {
// Anchor on the slash immediately preceding the sha so the cache
// can append "/<sha>" verbatim per the local-blossom-cache spec.
val slashBeforeSha = pathPart.lastIndexOf('/', shaIndex - 1)
if (slashBeforeSha > hostStart - 1) {
return pathPart.substring(0, slashBeforeSha)
}
}
return extractHostBase(pathPart)
}
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)
}
@@ -50,6 +50,7 @@ class RichTextParser {
eventTags: Map<String, IMetaTag>,
description: String?,
callbackUri: String? = null,
authorPubKey: String? = null,
): MediaUrlContent? {
val frags = Nip54InlineMetadata().parse(fullUrl)
@@ -87,6 +88,7 @@ class RichTextParser {
uri = callbackUri,
mimeType = contentType,
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
authorPubKey = authorPubKey,
)
} else if (isVideo) {
MediaUrlVideo(
@@ -99,6 +101,7 @@ class RichTextParser {
uri = callbackUri,
mimeType = contentType,
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
authorPubKey = authorPubKey,
)
} else if (isPdf) {
MediaUrlPdf(
@@ -110,6 +113,7 @@ class RichTextParser {
uri = callbackUri,
mimeType = contentType,
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
authorPubKey = authorPubKey,
)
} else {
null
@@ -160,16 +164,17 @@ class RichTextParser {
content: String,
tags: ImmutableListOfLists<String>,
callbackUri: String?,
authorPubKey: String? = null,
): RichTextViewerState {
val imetas = tags.lists.imetasByUrl()
val urlSet = UrlParser().parseValidUrls(content)
val mediaContents =
urlSet.withScheme.mapNotNull { fullUrl ->
createMediaContent(fullUrl, imetas, content, callbackUri)
createMediaContent(fullUrl, imetas, content, callbackUri, authorPubKey)
} +
urlSet.withoutScheme.mapNotNull { fullUrl ->
createMediaContent(fullUrl, imetas, content, callbackUri)
createMediaContent(fullUrl, imetas, content, callbackUri, authorPubKey)
}
val mediaForPager = mediaContents.associateBy { it.url }
@@ -0,0 +1,176 @@
/*
* 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/i/abc", result)
}
@Test
fun bridgeOnPreservesNostrBuildPathPrefix() {
val image = MediaUrlImage(url = "https://cdn.nostr.build/i/$sha.jpg", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://cdn.nostr.build/i", result)
}
@Test
fun bridgeOnFlatBlossomPathYieldsHostOnlyXs() {
val image = MediaUrlImage(url = "https://blossom.primal.net/$sha.jpg", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://blossom.primal.net", 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")
}
@Test
fun authorPubKeyAddedAsAsParam() {
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
val image =
MediaUrlImage(
url = "https://cdn.example.com/foo.jpg",
hash = sha,
authorPubKey = authorPub,
)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com&as=$authorPub", result)
}
@Test
fun invalidAuthorPubKeyDropped() {
val image =
MediaUrlImage(
url = "https://cdn.example.com/foo.jpg",
hash = sha,
authorPubKey = "not-a-pubkey",
)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result)
}
@Test
fun bridgeProfilePictureUrlNullReturnsNull() {
assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true))
}
@Test
fun bridgeProfilePictureUrlOffReturnsOriginal() {
assertEquals(
"https://cdn.example.com/avatar.jpg",
bridgeProfilePictureUrl("https://cdn.example.com/avatar.jpg", useBridge = false),
)
}
@Test
fun bridgeProfilePictureUrlExtractsShaFromPath() {
val url = "https://nostr.build/i/$sha.jpg"
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build%2Fi&as=$authorPub",
bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub),
)
}
@Test
fun bridgeProfilePictureUrlPreservesNostrBuildPath() {
val url = "https://cdn.nostr.build/i/$sha.jpg"
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi",
bridgeProfilePictureUrl(url, useBridge = true),
)
}
@Test
fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() {
val url = "https://nostr.build/avatar.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeProfilePictureUrlBlossomUriReturnedUnchanged() {
val uri = "blossom:$sha.jpg?xs=https://nostr.build"
assertEquals(uri, bridgeProfilePictureUrl(uri, useBridge = true))
}
}
@@ -65,18 +65,38 @@ data class BlossomUri(
append(sha256)
append('.')
append(extension)
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("&"))
}
appendQueryString()
}
/**
* 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 {
private const val SCHEME = "blossom:"
@@ -151,4 +151,76 @@ class BlossomUriTest {
assertEquals(listOf(server1, server2), result.servers)
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/"),
)
}
}