From 9c4e87b937b233593f0674227e4124367a21026d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 12:10:06 +0000 Subject: [PATCH 1/5] 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:?xs= URI before handing it to Coil/ExoPlayer. --- .../com/vitorpamplona/amethyst/AppModules.kt | 41 ++++++ .../amethyst/LocalPreferences.kt | 4 + .../amethyst/model/AccountSettings.kt | 8 ++ .../blossom/bud10/BlossomServerResolver.kt | 6 + .../blossom/bud10/LocalBlossomCacheProbe.kt | 117 ++++++++++++++++++ .../mediaServers/AllMediaServersScreen.kt | 57 ++++++++- .../ui/components/ZoomableContentDialog.kt | 9 +- .../ui/components/ZoomableContentView.kt | 41 ++++-- .../ui/screen/loggedIn/AccountViewModel.kt | 22 ++++ .../loggedIn/profile/gallery/GalleryThumb.kt | 9 +- amethyst/src/main/res/values/strings.xml | 5 + .../commons/richtext/MediaUrlContentExt.kt | 97 +++++++++++++++ .../richtext/MediaUrlContentExtTest.kt | 93 ++++++++++++++ .../quartz/nipB7Blossom/BlossomUri.kt | 40 ++++-- .../quartz/nipB7Blossom/BlossomUriTest.kt | 72 +++++++++++ 15 files changed, 599 insertions(+), 22 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/LocalBlossomCacheProbe.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index e0e0dc6d04..0ef73c462c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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.safeCacheDir 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 @@ -410,6 +411,10 @@ class AppModules( } } + val localBlossomCacheProbe by lazy { + LocalBlossomCacheProbe(roleBasedHttpClientBuilder) + } + val blossomResolver by lazy { Log.d("AppModules", "BlossomServerResolver Init") BlossomServerResolver( @@ -426,6 +431,14 @@ class AppModules( } }, 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 // 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index ce6324ffc5..b499a3c7c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -95,6 +95,7 @@ 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 DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" @@ -346,6 +347,7 @@ object LocalPreferences { ) 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_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) @@ -513,6 +515,7 @@ 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 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 +623,7 @@ object LocalPreferences { localRelayServers = MutableStateFlow(localRelayServers), defaultFileServer = defaultFileServer.await(), stripLocationOnUpload = stripLocationOnUpload, + useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache), defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index b6aace9123..fd8846a739 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -149,6 +149,7 @@ class AccountSettings( var localRelayServers: MutableStateFlow> = MutableStateFlow(setOf()), var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], var stripLocationOnUpload: Boolean = true, + val useLocalBlossomCache: MutableStateFlow = MutableStateFlow(true), val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -403,6 +404,13 @@ class AccountSettings( } } + fun changeUseLocalBlossomCache(enabled: Boolean) { + if (useLocalBlossomCache.value != enabled) { + useLocalBlossomCache.tryEmit(enabled) + saveAccountSettings() + } + } + // --- // list names // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt index 26c38d5df6..e1030da1c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt @@ -41,6 +41,8 @@ class BlossomServerResolver( val loggedInUsers: () -> List, val blossomServers: (Set
) -> List>, val httpClientBuilder: IRoleBasedHttpClientBuilder, + val useLocalBlossomCache: () -> Boolean = { false }, + val localCacheProbe: LocalBlossomCacheProbe? = null, ) { val blossomHitCache: ServerHeadCache = ServerHeadCache() val uriToUrlCache = LruCache(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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/LocalBlossomCacheProbe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/LocalBlossomCacheProbe.kt new file mode 100644 index 0000000000..1b64dbd3d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/LocalBlossomCacheProbe.kt @@ -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 = _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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index 6924b98d81..bee3b5e000 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -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,55 @@ 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 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) }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 332bc16d3c..57144390cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 3728342637..64889c8298 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 60a1d56248..d69dc5ba19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -188,6 +188,28 @@ class AccountViewModel( val broadcastTracker = BroadcastTracker() 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 = + 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 = CallManager( signer = account.signer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 795eb09941..7528c03abb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -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) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0502a8da3d..3ba8f80272 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -806,6 +806,11 @@ Media Servers Set your preferred media upload servers. + Use local Blossom cache + When a Blossom cache is running on this device (port 24242), route image and video downloads through it. + Local cache detected on port 24242. + Local cache not detected on port 24242. + You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓ You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt new file mode 100644 index 0000000000..3b5a07f90b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt @@ -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:.?xs=` + * 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) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt new file mode 100644 index 0000000000..b6da876a8d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt @@ -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") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt index bbd34156d2..f76c82d173 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt @@ -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:" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt index 72265e6f5a..460535f525 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUriTest.kt @@ -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/"), + ) + } } From 119ddf7cadcb77a4d1eb6c5cdbedc10ba347eff6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 08:45:02 +0000 Subject: [PATCH 2/5] feat(blossom): add as= author hint and profile-pictures-only mode - `as=`: when converting plain http(s) imeta URLs into `blossom:` URIs we now include the note author's pubkey, letting the local cache consult their kind:10063 BUD-03 server list on miss. Plumbed via RichTextParser/CachedRichTextParser/RichTextViewer/ ExpandableRichTextViewer/TranslatableRichTextViewer. - Profile-pictures-only mode: a new per-account toggle that restricts the bridge to profile pictures (RobohashFallbackAsyncImage). When on, feed images and videos skip the bridge entirely. Profile pictures recover their sha256 from the URL path when present (covers Blossom-hosted avatars like https://nostr.build/i/.jpg). --- .../components/TranslatableRichTextViewer.kt | 2 + .../com/vitorpamplona/amethyst/AppModules.kt | 20 +++-- .../amethyst/LocalPreferences.kt | 4 + .../amethyst/model/AccountSettings.kt | 8 ++ .../amethyst/service/CachedRichTextParser.kt | 12 ++- .../mediaServers/AllMediaServersScreen.kt | 30 ++++++- .../ui/components/ExpandableRichTextViewer.kt | 2 + .../amethyst/ui/components/RichTextViewer.kt | 11 ++- .../ui/components/RobohashAsyncImage.kt | 45 ++++++++++- .../amethyst/ui/note/MultiSetCompose.kt | 1 + .../amethyst/ui/note/ZapPollNote.kt | 1 + .../ui/screen/loggedIn/AccountViewModel.kt | 30 +++++-- .../chats/feed/types/RenderRegularTextNote.kt | 1 + amethyst/src/main/res/values/strings.xml | 2 + .../components/TranslatableRichTextViewer.kt | 2 + .../commons/richtext/MediaContentModels.kt | 16 ++-- .../commons/richtext/MediaUrlContentExt.kt | 78 ++++++++++++++++--- .../commons/richtext/RichTextParser.kt | 9 ++- .../richtext/MediaUrlContentExtTest.kt | 54 +++++++++++++ 19 files changed, 285 insertions(+), 43 deletions(-) diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index b52b7d568b..b6c79e0a67 100644 --- a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -41,6 +41,7 @@ fun TranslatableRichTextViewer( backgroundColor: MutableState, 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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0ef73c462c..764240d344 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -110,6 +110,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 @@ -588,19 +589,22 @@ class AppModules( } } - // Evict the BlossomServerResolver URL cache whenever the local-cache + // 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) { - state.account.settings.useLocalBlossomCache - .drop(1) - .collect { - blossomResolver.uriToUrlCache.evictAll() - blossomResolver.blossomHitCache.cache.evictAll() - localBlossomCacheProbe.invalidate() - } + merge( + state.account.settings.useLocalBlossomCache + .drop(1), + state.account.settings.localBlossomCacheProfilePicturesOnly + .drop(1), + ).collect { + blossomResolver.uriToUrlCache.evictAll() + blossomResolver.blossomHitCache.cache.evictAll() + localBlossomCacheProbe.invalidate() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index b499a3c7c8..8d2e92badf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -96,6 +96,7 @@ private object PrefKeys { 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" @@ -348,6 +349,7 @@ 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)) @@ -516,6 +518,7 @@ object LocalPreferences { 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) @@ -624,6 +627,7 @@ object LocalPreferences { defaultFileServer = defaultFileServer.await(), stripLocationOnUpload = stripLocationOnUpload, useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache), + localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly), defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index fd8846a739..1bacad5340 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -150,6 +150,7 @@ class AccountSettings( var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], var stripLocationOnUpload: Boolean = true, val useLocalBlossomCache: MutableStateFlow = MutableStateFlow(true), + val localBlossomCacheProfilePicturesOnly: MutableStateFlow = MutableStateFlow(false), val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -411,6 +412,13 @@ class AccountSettings( } } + fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) { + if (localBlossomCacheProfilePicturesOnly.value != enabled) { + localBlossomCacheProfilePicturesOnly.tryEmit(enabled) + saveAccountSettings() + } + } + // --- // list names // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index 70c802ac9f..ef442e3c5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -33,12 +33,16 @@ object CachedRichTextParser { content: String, tags: ImmutableListOfLists, 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, 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, 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index bee3b5e000..25bd2eeb09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -124,7 +124,10 @@ fun MediaServersScaffold( private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { val enabled by accountViewModel.account.settings.useLocalBlossomCache .collectAsStateWithLifecycle() - val probeAvailable by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle() + val profilePicturesOnly by accountViewModel.account.settings.localBlossomCacheProfilePicturesOnly + .collectAsStateWithLifecycle() + val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics + .collectAsStateWithLifecycle() Column( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), @@ -162,5 +165,30 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { 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) + }, + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index ea1eb47d8b..a2e6d52ca9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -64,6 +64,7 @@ fun ExpandableRichTextViewer( backgroundColor: MutableState, id: String, callbackUri: String? = null, + authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -100,6 +101,7 @@ fun ExpandableRichTextViewer( tags, backgroundColor, callbackUri, + authorPubKey, accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index c1166d1e82..ed5559c0f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -140,6 +140,7 @@ fun RichTextViewer( tags: ImmutableListOfLists, backgroundColor: MutableState, 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, 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, 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index ac4a0badb3..4177ab05b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index d3346f3f53..5c2cbd4b5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -550,6 +550,7 @@ fun CrossfadeToDisplayComment( backgroundColor, comment, null, + null, accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt index 3bfc938fbc..ad9f78accf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt @@ -425,6 +425,7 @@ private fun RenderOptionAfterVote( backgroundColor, baseNote.idHex + poolOption.descriptor, baseNote.toNostrUri(), + baseNote.author?.pubkeyHex, accountViewModel, nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index d69dc5ba19..9a86ab8037 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -189,13 +189,32 @@ class AccountViewModel( 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. + * `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 = + 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 = try { combine( account.settings.useLocalBlossomCache, @@ -206,7 +225,6 @@ class AccountViewModel( false, ) } catch (e: UninitializedPropertyAccessException) { - // Mock/test instances don't initialise Amethyst.instance. MutableStateFlow(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt index e12d86eff3..ba32f860f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderRegularTextNote.kt @@ -62,6 +62,7 @@ fun RenderRegularTextNote( backgroundColor = bgColor, id = note.idHex, callbackUri = note.toNostrUri(), + authorPubKey = note.author?.pubkeyHex, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3ba8f80272..f4b5471741 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -810,6 +810,8 @@ When a Blossom cache is running on this device (port 24242), route image and video downloads through it. Local cache detected on port 24242. Local cache not detected on port 24242. + Only cache profile pictures + Restrict the local cache to profile pictures. Feed images and videos will be fetched directly from the original servers. You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓ You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓ diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 004d1bdd78..58b3c6232c 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -53,6 +53,7 @@ fun TranslatableRichTextViewer( backgroundColor: MutableState, id: String, callbackUri: String? = null, + authorPubKey: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -70,6 +71,7 @@ fun TranslatableRichTextViewer( backgroundColor, id, callbackUri, + authorPubKey, accountViewModel, nav, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt index 0ab08e7269..1d2a534741 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt @@ -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( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt index 3b5a07f90b..d0eae4ade1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt @@ -23,40 +23,98 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri private val sha256HexRegex = Regex("[0-9a-f]{64}") +private val sha256InPathRegex = Regex("(?.?xs=` - * 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`. + * known sha256 hash, returns a `blossom:.?xs=&as=` + * 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 { - if (!useLocalBlossomBridge) return url - if (this is MediaUrlVideo && isLiveStream) return url - val sha = hash?.lowercase() ?: return url - if (!sha256HexRegex.matches(sha)) return url +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) where the + * hash isn't available on a structured model. Tries to recover the sha256 + * from the URL path itself; falls back to the original URL when no hash + * can be determined. + * + * @param authorPubKey 64-char lowercase hex pubkey to send as `as=` so the + * local cache can consult that author's BUD-03 server list. + */ +fun bridgeProfilePictureUrl( + url: String?, + useBridge: Boolean, + authorPubKey: String? = null, +): String? { + if (url == null) return null + return bridgeUrl( + url = url, + useBridge = useBridge, + explicitHash = null, + mimeType = null, + authorPubKey = authorPubKey, + skipBridge = false, + ) +} + +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 hostBase = extractHostBase(url) ?: return url + val authors = + authorPubKey + ?.lowercase() + ?.takeIf { sha256HexRegex.matches(it) } + ?.let { listOf(it) } + ?: emptyList() + return BlossomUri( sha256 = sha, extension = ext, servers = listOf(hostBase), - authors = emptyList(), + authors = authors, size = null, ).toUriString() } +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?, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 94f4119fe6..0353bdc1ea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -50,6 +50,7 @@ class RichTextParser { eventTags: Map, 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, 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 } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt index b6da876a8d..cbca9375eb 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt @@ -90,4 +90,58 @@ class MediaUrlContentExtTest { 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( + "blossom:$sha.jpg?xs=https://nostr.build&as=$authorPub", + bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub), + ) + } + + @Test + fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() { + val url = "https://nostr.build/avatar.jpg" + assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) + } } From 9451f7ca9a63300071ce25a794e68b9dcd152e61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 10:02:08 +0000 Subject: [PATCH 3/5] feat(blossom): catch all sha256-keyed URLs via OkHttp interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pictures hosted on Blossom servers (URLs with a 64-char sha256 hex in the path) bypassed the bridge whenever they were rendered through a composable that wasn't explicitly updated to call toCoilModel — link previews, video thumbnails, badge images, audio room covers, etc. Add LocalBlossomCacheRedirectInterceptor as an app-level OkHttp interceptor that catches every HTTP request transparently: - Detects a 64-char hex segment in any path segment - Rewrites the request URL to http://127.0.0.1:24242/.?xs= - Coil's disk cache continues to key by the original URL so cached blobs survive toggling the bridge off Activates only when the master toggle is on, the profile-pictures-only restriction is off, and the probe sees localhost up. Profile-only mode still routes profile pics via the existing composable bridge. --- .../com/vitorpamplona/amethyst/AppModules.kt | 11 +- .../service/okhttp/DualHttpClientManager.kt | 3 +- .../LocalBlossomCacheRedirectInterceptor.kt | 103 +++++++++++ .../service/okhttp/OkHttpClientFactory.kt | 12 ++ ...ocalBlossomCacheRedirectInterceptorTest.kt | 169 ++++++++++++++++++ 5 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 764240d344..b612b71f52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -216,7 +216,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 +224,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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 32679800f3..7cb4d33624 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -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 = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt new file mode 100644 index 0000000000..0ef5f5ffeb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt @@ -0,0 +1,103 @@ +/* + * 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 (sha, ext) = findSha256AndExtensionInPath(url) ?: return null + val originalHostBase = "${url.scheme}://$host" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else "" + + return "$LOCAL_CACHE_BASE/$sha.$ext" + .toHttpUrl() + .newBuilder() + .addQueryParameter("xs", originalHostBase) + .build() + } + + private fun findSha256AndExtensionInPath(url: HttpUrl): Pair? { + for (segment in url.pathSegments) { + val match = SHA256_SEGMENT_REGEX.find(segment) ?: continue + val sha = match.value.lowercase() + val ext = guessExtensionFrom(segment, sha) ?: "bin" + return sha to ext + } + return null + } + + 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("(? 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() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt new file mode 100644 index 0000000000..422a88c785 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt @@ -0,0 +1,169 @@ +/* + * 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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", + captured.single(), + ) + response.close() + } + + private fun fakeChain( + url: String, + captured: MutableList, + ): 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 + } +} From aa598b9949457eef07c6ae1f03ab1eb0df7b0385 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 10:25:30 +0000 Subject: [PATCH 4/5] fix(blossom): profile pictures now route through local cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bridgeProfilePictureUrl previously returned a `blossom:` URI, but profile pictures go through ProfilePictureFetcher (Coil routes by type on `ProfilePictureUrl`), which feeds the URL straight to NetworkFetcher and never sees BlossomFetcher. NetworkFetcher then tries to issue an HTTP request against the `blossom:` scheme and fails — silently — so profile pictures from Blossom servers stopped loading. Return a direct `http://127.0.0.1:24242/.?xs=&as=` URL instead. The OkHttp request goes to the local cache normally; the new redirect interceptor sees it's already on localhost and passes through unchanged. --- .../commons/richtext/MediaUrlContentExt.kt | 62 ++++++++++++++----- .../richtext/MediaUrlContentExtTest.kt | 8 ++- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt index d0eae4ade1..f4c90a25c9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt @@ -49,30 +49,49 @@ fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String = ) /** - * Bridge entry point for raw URL strings (e.g. profile pictures) where the - * hash isn't available on a structured model. Tries to recover the sha256 - * from the URL path itself; falls back to the original URL when no hash - * can be determined. + * 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. * - * @param authorPubKey 64-char lowercase hex pubkey to send as `as=` so the - * local cache can consult that author's BUD-03 server list. + * Returns a direct `http://127.0.0.1:24242/.?xs=&as=` + * 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 - return bridgeUrl( - url = url, - useBridge = useBridge, - explicitHash = null, - mimeType = null, - authorPubKey = authorPubKey, - skipBridge = false, - ) + 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 hostBase = extractHostBase(url) ?: return url + + val params = + buildList { + add("xs=${percentEncode(hostBase)}") + 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, @@ -109,6 +128,21 @@ private fun bridgeUrl( ).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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt index cbca9375eb..2e3e16c737 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt @@ -134,7 +134,7 @@ class MediaUrlContentExtTest { val url = "https://nostr.build/i/$sha.jpg" val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" assertEquals( - "blossom:$sha.jpg?xs=https://nostr.build&as=$authorPub", + "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build&as=$authorPub", bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub), ) } @@ -144,4 +144,10 @@ class MediaUrlContentExtTest { 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)) + } } From bb871f6c71ddd2d5fbec58425fba95b43fc0e0ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 11:35:53 +0000 Subject: [PATCH 5/5] fix(blossom): preserve upstream path prefix in xs= hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge was emitting `xs=https://cdn.nostr.build` for URLs like `https://cdn.nostr.build/i/.jpg`, dropping the `/i/` prefix. The local cache appends `/` to the xs server hint per spec, so it would try to fetch `https://cdn.nostr.build/` — a 404 on nostr.build's non-Blossom CDN scheme. Anchor the server-base extraction on the slash immediately preceding the sha-bearing path segment so we emit `xs=https://cdn.nostr.build/i`. The cache then appends `/` and reaches the original blob. Affects all three bridge entry points: - MediaUrlContent.toCoilModel (note media) - bridgeProfilePictureUrl (profile pictures) - LocalBlossomCacheRedirectInterceptor (everything else via OkHttp) Flat Blossom paths (`/`) keep emitting `xs=` unchanged. --- .../LocalBlossomCacheRedirectInterceptor.kt | 30 +++++++++++---- ...ocalBlossomCacheRedirectInterceptorTest.kt | 14 ++++++- .../commons/richtext/MediaUrlContentExt.kt | 37 +++++++++++++++++-- .../richtext/MediaUrlContentExtTest.kt | 27 +++++++++++++- 4 files changed, 94 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt index 0ef5f5ffeb..1f6afef7eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt @@ -61,26 +61,42 @@ class LocalBlossomCacheRedirectInterceptor( val host = url.host if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null - val (sha, ext) = findSha256AndExtensionInPath(url) ?: return null - val originalHostBase = "${url.scheme}://$host" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else "" + val (shaSegmentIndex, sha, ext) = findSha256AndExtensionInPath(url) ?: return null + val serverBase = buildServerBase(url, shaSegmentIndex) return "$LOCAL_CACHE_BASE/$sha.$ext" .toHttpUrl() .newBuilder() - .addQueryParameter("xs", originalHostBase) + .addQueryParameter("xs", serverBase) .build() } - private fun findSha256AndExtensionInPath(url: HttpUrl): Pair? { - for (segment in url.pathSegments) { - val match = SHA256_SEGMENT_REGEX.find(segment) ?: continue + private fun findSha256AndExtensionInPath(url: HttpUrl): Triple? { + 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 sha to ext + return Triple(index, sha, ext) } return null } + /** + * Builds the URL prefix that the local cache should append `/` to. + * Preserves any path prefix the upstream CDN uses (e.g. `/i` for + * `https://cdn.nostr.build/i/`) 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, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt index 422a88c785..afaff2dcf5 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt @@ -114,7 +114,19 @@ class LocalBlossomCacheRedirectInterceptorTest { val captured = mutableListOf() 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", + "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() + 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() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt index f4c90a25c9..78d121b6ff 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt @@ -76,11 +76,11 @@ fun bridgeProfilePictureUrl( val sha = extractSha256FromUrlPath(url) ?: return url val ext = guessExtension(url, null) - val hostBase = extractHostBase(url) ?: return url + val serverBase = extractServerBase(url, sha) ?: return url val params = buildList { - add("xs=${percentEncode(hostBase)}") + add("xs=${percentEncode(serverBase)}") authorPubKey ?.lowercase() ?.takeIf { sha256HexRegex.matches(it) } @@ -110,7 +110,7 @@ private fun bridgeUrl( ?: return url val ext = guessExtension(url, mimeType) - val hostBase = extractHostBase(url) ?: return url + val serverBase = extractServerBase(url, sha) ?: return url val authors = authorPubKey @@ -122,7 +122,7 @@ private fun bridgeUrl( return BlossomUri( sha256 = sha, extension = ext, - servers = listOf(hostBase), + servers = listOf(serverBase), authors = authors, size = null, ).toUriString() @@ -172,6 +172,35 @@ private fun guessExtension( return "bin" } +/** + * Returns the URL prefix that the local Blossom cache should append `/` + * 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/` 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 "/" 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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt index 2e3e16c737..db82402203 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt @@ -61,7 +61,21 @@ class MediaUrlContentExtTest { 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) + 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 @@ -134,11 +148,20 @@ class MediaUrlContentExtTest { 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&as=$authorPub", + "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"