From 9c4e87b937b233593f0674227e4124367a21026d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 12:10:06 +0000 Subject: [PATCH] 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 e0e0dc6d0..0ef73c462 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 ce6324ffc..b499a3c7c 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 b6aace912..fd8846a73 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 26c38d5df..e1030da1c 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 000000000..1b64dbd3d --- /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 6924b98d8..bee3b5e00 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 332bc16d3..57144390c 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 372834263..64889c829 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 60a1d5624..d69dc5ba1 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 795eb0994..7528c03ab 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 0502a8da3..3ba8f8027 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 000000000..3b5a07f90 --- /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 000000000..b6da876a8 --- /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 bbd34156d..f76c82d17 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 72265e6f5..460535f52 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/"), + ) + } }