mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
Merge pull request #2786 from greenart7c3/claude/add-blossom-cache-support-ts8mK
Add local Blossom cache bridge for transparent media proxying
This commit is contained in:
+2
@@ -41,6 +41,7 @@ fun TranslatableRichTextViewer(
|
||||
backgroundColor: MutableState<Color>,
|
||||
id: String,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) = ExpandableRichTextViewer(
|
||||
@@ -52,6 +53,7 @@ fun TranslatableRichTextViewer(
|
||||
backgroundColor,
|
||||
id,
|
||||
callbackUri,
|
||||
authorPubKey,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.service.safeCacheDir
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
||||
import com.vitorpamplona.amethyst.ui.resourceCacheInit
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
@@ -111,6 +112,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.transform
|
||||
@@ -216,7 +218,7 @@ class AppModules(
|
||||
val dnsStore = SurgeDnsStore(appContext, surgeDns)
|
||||
|
||||
// manages all the other connections separately from relays.
|
||||
val okHttpClients =
|
||||
val okHttpClients: DualHttpClientManager =
|
||||
DualHttpClientManager(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
@@ -224,6 +226,15 @@ class AppModules(
|
||||
keyCache = keyCache,
|
||||
scope = applicationIOScope,
|
||||
dns = surgeDns,
|
||||
// Transparently rewrites sha256-keyed HTTP requests to the local
|
||||
// Blossom cache when the master toggle is on, the profile-pictures-only
|
||||
// restriction is off, and the probe sees 127.0.0.1:24242 as available.
|
||||
shouldBridgeBlossomCache = {
|
||||
val settings = sessionManager.loggedInAccount()?.settings
|
||||
val master = settings?.useLocalBlossomCache?.value ?: false
|
||||
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
|
||||
master && !profileOnly && localBlossomCacheProbe.available.value
|
||||
},
|
||||
)
|
||||
|
||||
// Offers easy methods to know when connections are happening through Tor or not
|
||||
@@ -412,6 +423,10 @@ class AppModules(
|
||||
}
|
||||
}
|
||||
|
||||
val localBlossomCacheProbe by lazy {
|
||||
LocalBlossomCacheProbe(roleBasedHttpClientBuilder)
|
||||
}
|
||||
|
||||
val blossomResolver by lazy {
|
||||
Log.d("AppModules", "BlossomServerResolver Init")
|
||||
BlossomServerResolver(
|
||||
@@ -428,6 +443,14 @@ class AppModules(
|
||||
}
|
||||
},
|
||||
httpClientBuilder = roleBasedHttpClientBuilder,
|
||||
useLocalBlossomCache = {
|
||||
sessionManager
|
||||
.loggedInAccount()
|
||||
?.settings
|
||||
?.useLocalBlossomCache
|
||||
?.value ?: false
|
||||
},
|
||||
localCacheProbe = localBlossomCacheProbe,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -588,6 +611,37 @@ class AppModules(
|
||||
}
|
||||
}
|
||||
|
||||
// Evict the BlossomServerResolver URL cache whenever either local-cache
|
||||
// toggle flips or the probe transitions up/down so stale entries don't
|
||||
// outlive the underlying decision.
|
||||
applicationIOScope.launch {
|
||||
sessionManager.accountContent.collectLatest { state ->
|
||||
if (state is AccountState.LoggedIn) {
|
||||
merge(
|
||||
state.account.settings.useLocalBlossomCache
|
||||
.drop(1),
|
||||
state.account.settings.localBlossomCacheProfilePicturesOnly
|
||||
.drop(1),
|
||||
).collect {
|
||||
blossomResolver.uriToUrlCache.evictAll()
|
||||
blossomResolver.blossomHitCache.cache.evictAll()
|
||||
localBlossomCacheProbe.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
applicationIOScope.launch {
|
||||
localBlossomCacheProbe.available.drop(1).collect {
|
||||
blossomResolver.uriToUrlCache.evictAll()
|
||||
blossomResolver.blossomHitCache.cache.evictAll()
|
||||
}
|
||||
}
|
||||
// Warm the local-cache probe so the very first image load doesn't pay
|
||||
// the loopback round-trip cost.
|
||||
applicationIOScope.launch {
|
||||
localBlossomCacheProbe.isAvailable()
|
||||
}
|
||||
|
||||
// Warms the video cache off the main thread. SimpleCache's constructor opens a SQLite
|
||||
// index over StandaloneDatabaseProvider and walks every cached span on disk — up to a
|
||||
// few hundred ms on a populated 4 GB cache — so leaving it for the first session's
|
||||
|
||||
@@ -95,6 +95,8 @@ private object PrefKeys {
|
||||
const val LOCAL_RELAY_SERVERS = "localRelayServers"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
|
||||
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
|
||||
const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
|
||||
@@ -346,6 +348,8 @@ object LocalPreferences {
|
||||
)
|
||||
|
||||
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
|
||||
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
|
||||
putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value)
|
||||
|
||||
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
|
||||
@@ -513,6 +517,8 @@ object LocalPreferences {
|
||||
Log.d("LocalPreferences") { "Load account from file $npub - keys ready" }
|
||||
|
||||
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
|
||||
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
|
||||
val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false)
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||
@@ -620,6 +626,8 @@ object LocalPreferences {
|
||||
localRelayServers = MutableStateFlow(localRelayServers),
|
||||
defaultFileServer = defaultFileServer.await(),
|
||||
stripLocationOnUpload = stripLocationOnUpload,
|
||||
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
|
||||
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
|
||||
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
|
||||
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
|
||||
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
|
||||
|
||||
@@ -149,6 +149,8 @@ class AccountSettings(
|
||||
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
|
||||
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
|
||||
var stripLocationOnUpload: Boolean = true,
|
||||
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
@@ -403,6 +405,20 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeUseLocalBlossomCache(enabled: Boolean) {
|
||||
if (useLocalBlossomCache.value != enabled) {
|
||||
useLocalBlossomCache.tryEmit(enabled)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) {
|
||||
if (localBlossomCacheProfilePicturesOnly.value != enabled) {
|
||||
localBlossomCacheProfilePicturesOnly.tryEmit(enabled)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateDisableClientTag(disable: Boolean): Boolean =
|
||||
if (syncedSettings.security.updateDisableClientTag(disable)) {
|
||||
saveAccountSettings()
|
||||
|
||||
@@ -33,12 +33,16 @@ object CachedRichTextParser {
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String?,
|
||||
authorPubKey: String?,
|
||||
): Int {
|
||||
var result = content.hashCode()
|
||||
result = 31 * result + tags.lists.hashCode()
|
||||
if (callbackUri != null) {
|
||||
result = 31 * result + callbackUri.hashCode()
|
||||
}
|
||||
if (authorPubKey != null) {
|
||||
result = 31 * result + authorPubKey.hashCode()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -46,19 +50,21 @@ object CachedRichTextParser {
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String? = null,
|
||||
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri)]
|
||||
authorPubKey: String? = null,
|
||||
): RichTextViewerState? = richTextCache[hashCodeCache(content, tags, callbackUri, authorPubKey)]
|
||||
|
||||
fun parseText(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
): RichTextViewerState {
|
||||
val key = hashCodeCache(content, tags, callbackUri)
|
||||
val key = hashCodeCache(content, tags, callbackUri, authorPubKey)
|
||||
val cached = richTextCache[key]
|
||||
return if (cached != null) {
|
||||
cached
|
||||
} else {
|
||||
val newUrls = RichTextParser().parseText(content, tags, callbackUri)
|
||||
val newUrls = RichTextParser().parseText(content, tags, callbackUri, authorPubKey)
|
||||
richTextCache.put(key, newUrls)
|
||||
newUrls
|
||||
}
|
||||
|
||||
+2
-1
@@ -39,8 +39,9 @@ class DualHttpClientManager(
|
||||
keyCache: EncryptionKeyCache,
|
||||
scope: CoroutineScope,
|
||||
dns: SurgeDns,
|
||||
shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns)
|
||||
val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache)
|
||||
|
||||
val defaultHttpClient: StateFlow<OkHttpClient> =
|
||||
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* App-wide OkHttp interceptor that transparently rewrites HTTP requests for
|
||||
* sha256-keyed blobs to a local Blossom cache running on `127.0.0.1:24242`,
|
||||
* per https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md
|
||||
*
|
||||
* Activates when [shouldBridge] returns `true` AND the request URL contains
|
||||
* a 64-char hex sha256 segment in its path AND the host isn't already
|
||||
* `127.0.0.1`/`localhost`. The original scheme+host is appended as a `xs=`
|
||||
* proxy hint so the cache can fetch upstream on miss.
|
||||
*
|
||||
* Coil's disk cache keys responses by the original `ImageRequest.data`, so
|
||||
* disk caching continues to work transparently even though the network
|
||||
* request now goes to localhost.
|
||||
*/
|
||||
class LocalBlossomCacheRedirectInterceptor(
|
||||
private val shouldBridge: () -> Boolean,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
|
||||
if (!shouldBridge()) return chain.proceed(request)
|
||||
|
||||
val rewritten = rewriteIfApplicable(request.url) ?: return chain.proceed(request)
|
||||
|
||||
return chain.proceed(
|
||||
request
|
||||
.newBuilder()
|
||||
.url(rewritten)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun rewriteIfApplicable(url: HttpUrl): HttpUrl? {
|
||||
val host = url.host
|
||||
if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null
|
||||
|
||||
val (shaSegmentIndex, sha, ext) = findSha256AndExtensionInPath(url) ?: return null
|
||||
val serverBase = buildServerBase(url, shaSegmentIndex)
|
||||
|
||||
return "$LOCAL_CACHE_BASE/$sha.$ext"
|
||||
.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addQueryParameter("xs", serverBase)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? {
|
||||
url.pathSegments.forEachIndexed { index, segment ->
|
||||
val match = SHA256_SEGMENT_REGEX.find(segment) ?: return@forEachIndexed
|
||||
val sha = match.value.lowercase()
|
||||
val ext = guessExtensionFrom(segment, sha) ?: "bin"
|
||||
return Triple(index, sha, ext)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the URL prefix that the local cache should append `/<sha>` to.
|
||||
* Preserves any path prefix the upstream CDN uses (e.g. `/i` for
|
||||
* `https://cdn.nostr.build/i/<sha>`) so the cache can fetch the blob
|
||||
* from its actual location on miss.
|
||||
*/
|
||||
private fun buildServerBase(
|
||||
url: HttpUrl,
|
||||
shaSegmentIndex: Int,
|
||||
): String {
|
||||
val origin = "${url.scheme}://${url.host}" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else ""
|
||||
if (shaSegmentIndex == 0) return origin
|
||||
val prefixSegments = url.pathSegments.subList(0, shaSegmentIndex)
|
||||
return "$origin/" + prefixSegments.joinToString("/")
|
||||
}
|
||||
|
||||
private fun guessExtensionFrom(
|
||||
segment: String,
|
||||
sha: String,
|
||||
): String? {
|
||||
val idx = segment.indexOf(sha, ignoreCase = true)
|
||||
val after = if (idx >= 0) segment.substring(idx + sha.length) else return null
|
||||
if (!after.startsWith('.')) return null
|
||||
val rest = after.substring(1).lowercase()
|
||||
if (rest.isEmpty() || rest.length > 8) return null
|
||||
if (!rest.all { it.isLetterOrDigit() }) return null
|
||||
return rest
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LOCAL_CACHE_HOST = "127.0.0.1"
|
||||
const val LOCAL_CACHE_PORT = 24242
|
||||
const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT"
|
||||
private val SHA256_SEGMENT_REGEX = Regex("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||
}
|
||||
}
|
||||
+12
@@ -36,9 +36,18 @@ class OkHttpClientFactory(
|
||||
keyCache: EncryptionKeyCache,
|
||||
val userAgent: String,
|
||||
private val dns: SurgeDns,
|
||||
/**
|
||||
* Returns `true` when sha256-keyed HTTP requests should be transparently
|
||||
* rewritten to the local Blossom cache (master toggle on, profile-pictures-only
|
||||
* restriction off, probe up). When `null`, the interceptor is disabled —
|
||||
* useful for tests or pre-configuration call sites.
|
||||
*/
|
||||
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
|
||||
) {
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
private val blossomCacheRedirect =
|
||||
shouldBridgeBlossomCache?.let { LocalBlossomCacheRedirectInterceptor(it) }
|
||||
|
||||
// Most images/videos in a feed come from a small set of hosts (e.g. a single
|
||||
// Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests
|
||||
@@ -69,6 +78,9 @@ class OkHttpClientFactory(
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.apply {
|
||||
blossomCacheRedirect?.let { addInterceptor(it) }
|
||||
}
|
||||
// .addNetworkInterceptor(logging)
|
||||
.addNetworkInterceptor(keyDecryptor)
|
||||
.build()
|
||||
|
||||
+6
@@ -41,6 +41,8 @@ class BlossomServerResolver(
|
||||
val loggedInUsers: () -> List<HexKey>,
|
||||
val blossomServers: (Set<Address>) -> List<Flow<BlossomServersEvent>>,
|
||||
val httpClientBuilder: IRoleBasedHttpClientBuilder,
|
||||
val useLocalBlossomCache: () -> Boolean = { false },
|
||||
val localCacheProbe: LocalBlossomCacheProbe? = null,
|
||||
) {
|
||||
val blossomHitCache: ServerHeadCache = ServerHeadCache()
|
||||
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
|
||||
@@ -71,6 +73,10 @@ class BlossomServerResolver(
|
||||
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
|
||||
val uri = BlossomUri.parse(uriStr) ?: return null
|
||||
|
||||
if (useLocalBlossomCache() && localCacheProbe?.isAvailable() == true) {
|
||||
return BlossomUriServer(uri, uri.toLocalCacheUrl(LocalBlossomCacheProbe.LOCAL_CACHE_BASE))
|
||||
}
|
||||
|
||||
val expectedMimeType = mimeTypeMap[uri.extension]
|
||||
val filename = uri.filename()
|
||||
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
|
||||
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Discovers a local Blossom cache running on `http://127.0.0.1:24242` per
|
||||
* https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md
|
||||
*
|
||||
* Issues a `HEAD /` request and caches the result with separate positive and
|
||||
* negative TTLs so the loopback isn't probed on every image load.
|
||||
*/
|
||||
class LocalBlossomCacheProbe(
|
||||
private val httpClientBuilder: IRoleBasedHttpClientBuilder,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var cachedAtMs: Long = 0L
|
||||
|
||||
private val _available = MutableStateFlow(false)
|
||||
val available: StateFlow<Boolean> = _available
|
||||
|
||||
suspend fun isAvailable(): Boolean {
|
||||
val now = currentTimeMs()
|
||||
val ttl = if (_available.value) POSITIVE_TTL_MS else NEGATIVE_TTL_MS
|
||||
if (cachedAtMs != 0L && now - cachedAtMs < ttl) {
|
||||
return _available.value
|
||||
}
|
||||
|
||||
return mutex.withLock {
|
||||
// Re-check inside the lock in case another caller just refreshed.
|
||||
val now2 = currentTimeMs()
|
||||
val ttl2 = if (_available.value) POSITIVE_TTL_MS else NEGATIVE_TTL_MS
|
||||
if (cachedAtMs != 0L && now2 - cachedAtMs < ttl2) {
|
||||
return@withLock _available.value
|
||||
}
|
||||
|
||||
val newResult = probe()
|
||||
_available.value = newResult
|
||||
cachedAtMs = currentTimeMs()
|
||||
newResult
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces the next call to [isAvailable] to re-probe regardless of TTL.
|
||||
*/
|
||||
fun invalidate() {
|
||||
cachedAtMs = 0L
|
||||
}
|
||||
|
||||
private suspend fun probe(): Boolean =
|
||||
try {
|
||||
val baseClient = httpClientBuilder.okHttpClientForPreview(LOCAL_CACHE_BASE)
|
||||
val client =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url("$LOCAL_CACHE_BASE/")
|
||||
.head()
|
||||
.build()
|
||||
|
||||
client.newCall(request).executeAsync().use { response ->
|
||||
// Spec says HEAD / returns 2xx when available. Some implementations
|
||||
// may answer 405 (method not allowed) while still being a working
|
||||
// Blossom cache, so treat that as available too.
|
||||
response.isSuccessful || response.code == 405
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
false
|
||||
}
|
||||
|
||||
private fun currentTimeMs(): Long = System.currentTimeMillis()
|
||||
|
||||
companion object {
|
||||
const val LOCAL_CACHE_BASE: String = "http://127.0.0.1:24242"
|
||||
private const val POSITIVE_TTL_MS = 60_000L
|
||||
private const val NEGATIVE_TTL_MS = 10_000L
|
||||
private const val PROBE_TIMEOUT_MS = 1_500L
|
||||
}
|
||||
}
|
||||
+84
-1
@@ -22,20 +22,26 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -57,7 +63,7 @@ fun AllMediaServersScreen(
|
||||
blossomServersViewModel.load()
|
||||
}
|
||||
|
||||
MediaServersScaffold(blossomServersViewModel) {
|
||||
MediaServersScaffold(blossomServersViewModel, accountViewModel) {
|
||||
nav.popBack()
|
||||
}
|
||||
}
|
||||
@@ -66,6 +72,7 @@ fun AllMediaServersScreen(
|
||||
@Composable
|
||||
fun MediaServersScaffold(
|
||||
blossomServersViewModel: BlossomServersViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
@@ -105,7 +112,83 @@ fun MediaServersScaffold(
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
|
||||
LocalBlossomCacheToggle(accountViewModel)
|
||||
HorizontalDivider()
|
||||
|
||||
AllMediaBody(blossomServersViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
|
||||
val enabled by accountViewModel.account.settings.useLocalBlossomCache
|
||||
.collectAsStateWithLifecycle()
|
||||
val profilePicturesOnly by accountViewModel.account.settings.localBlossomCacheProfilePicturesOnly
|
||||
.collectAsStateWithLifecycle()
|
||||
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.use_local_blossom_cache),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(id = R.string.use_local_blossom_cache_caption),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (enabled && probeAvailable) {
|
||||
stringRes(id = R.string.local_blossom_cache_detected)
|
||||
} else if (enabled) {
|
||||
stringRes(id = R.string.local_blossom_cache_not_detected)
|
||||
} else {
|
||||
""
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = { accountViewModel.account.settings.changeUseLocalBlossomCache(it) },
|
||||
)
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringRes(id = R.string.local_blossom_cache_profile_pics_only),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(id = R.string.local_blossom_cache_profile_pics_only_caption),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = profilePicturesOnly,
|
||||
onCheckedChange = {
|
||||
accountViewModel.account.settings.changeLocalBlossomCacheProfilePicturesOnly(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -64,6 +64,7 @@ fun ExpandableRichTextViewer(
|
||||
backgroundColor: MutableState<Color>,
|
||||
id: String,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -100,6 +101,7 @@ fun ExpandableRichTextViewer(
|
||||
tags,
|
||||
backgroundColor,
|
||||
callbackUri,
|
||||
authorPubKey,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
@@ -140,6 +140,7 @@ fun RichTextViewer(
|
||||
tags: ImmutableListOfLists<String>,
|
||||
backgroundColor: MutableState<Color>,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -147,7 +148,7 @@ fun RichTextViewer(
|
||||
if (remember(content) { isMarkdown(content) }) {
|
||||
RenderContentAsMarkdown(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
|
||||
} else {
|
||||
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, accountViewModel, nav)
|
||||
RenderRegular(content, tags, canPreview, quotesLeft, backgroundColor, callbackUri, authorPubKey, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,11 +348,12 @@ private fun RenderRegular(
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (canPreview) {
|
||||
RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier ->
|
||||
RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier ->
|
||||
if (paragraph is ImageGalleryParagraph) {
|
||||
ImageGallery(
|
||||
images = paragraph,
|
||||
@@ -375,7 +377,7 @@ private fun RenderRegular(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RenderRegular(content, tags, callbackUri) { paragraph, state, spaceWidth, modifier ->
|
||||
RenderRegular(content, tags, callbackUri, authorPubKey) { paragraph, state, spaceWidth, modifier ->
|
||||
RenderTextParagraph(paragraph, spaceWidth, modifier) { word ->
|
||||
RenderWordWithoutPreview(
|
||||
word,
|
||||
@@ -412,9 +414,10 @@ fun RenderRegular(
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
renderParagraph: @Composable (ParagraphState, state: RichTextViewerState, Dp, modifier: Modifier) -> Unit,
|
||||
) {
|
||||
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri)) }
|
||||
val state by remember(content, tags) { mutableStateOf(CachedRichTextParser.parseText(content, tags, callbackUri, authorPubKey)) }
|
||||
|
||||
val spaceWidth = measureSpaceWidth(LocalTextStyle.current)
|
||||
|
||||
|
||||
+41
-4
@@ -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,
|
||||
|
||||
+8
-1
@@ -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,
|
||||
|
||||
+32
-9
@@ -66,6 +66,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
@@ -86,6 +87,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
||||
@@ -148,20 +150,26 @@ fun ZoomableContentView(
|
||||
sourceBounds = coordinates.boundsInWindow()
|
||||
}
|
||||
|
||||
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
|
||||
|
||||
when (content) {
|
||||
is MediaUrlImage -> {
|
||||
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
|
||||
val bridgedUrl =
|
||||
remember(content.url, useLocalBlossomBridge) {
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
ContentWarningGate(
|
||||
isSensitive = content.contentWarning != null,
|
||||
reasons = setOfNotNull(content.contentWarning),
|
||||
preloadUrls = listOf(content.url),
|
||||
preloadUrls = listOf(bridgedUrl),
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = mediaSizingModifier(ratio, contentScale),
|
||||
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
|
||||
) {
|
||||
if (content.isGif()) {
|
||||
GifVideoView(
|
||||
videoUri = content.url,
|
||||
videoUri = bridgedUrl,
|
||||
contentDescription = content.description,
|
||||
dimensions = content.dim,
|
||||
blurhash = content.blurhash,
|
||||
@@ -187,6 +195,10 @@ fun ZoomableContentView(
|
||||
|
||||
is MediaUrlVideo -> {
|
||||
val ratio = content.dim?.aspectRatio() ?: MediaAspectRatioCache.get(content.url)
|
||||
val bridgedUrl =
|
||||
remember(content.url, useLocalBlossomBridge) {
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
ContentWarningGate(
|
||||
isSensitive = content.contentWarning != null,
|
||||
reasons = setOfNotNull(content.contentWarning),
|
||||
@@ -200,7 +212,7 @@ fun ZoomableContentView(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
VideoView(
|
||||
videoUri = content.url,
|
||||
videoUri = bridgedUrl,
|
||||
mimeType = content.mimeType,
|
||||
title = content.description,
|
||||
artworkUri = content.artworkUri,
|
||||
@@ -465,17 +477,22 @@ fun UrlImageView(
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
|
||||
val bridgedUrl =
|
||||
remember(content.url, useLocalBlossomBridge) {
|
||||
content.toCoilModel(useLocalBlossomBridge)
|
||||
}
|
||||
val imageModel =
|
||||
if (fullResolution) {
|
||||
remember(content.url, context) {
|
||||
remember(bridgedUrl, context) {
|
||||
ImageRequest
|
||||
.Builder(context)
|
||||
.data(content.url)
|
||||
.data(bridgedUrl)
|
||||
.size(Size.ORIGINAL)
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
content.url
|
||||
bridgedUrl
|
||||
}
|
||||
|
||||
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
|
||||
@@ -1183,9 +1200,15 @@ private suspend fun shareLocalVideoFile(
|
||||
private fun verifyHash(content: MediaUrlContent): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot ->
|
||||
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
|
||||
return hashBytes.toHexKey() == content.hash
|
||||
val keys = mutableListOf(content.url)
|
||||
val bridged = content.toCoilModel(true)
|
||||
if (bridged != content.url) keys.add(bridged)
|
||||
|
||||
for (key in keys) {
|
||||
Amethyst.instance.diskCache.openSnapshot(key)?.use { snapshot ->
|
||||
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
|
||||
return hashBytes.toHexKey() == content.hash
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
@@ -550,6 +550,7 @@ fun CrossfadeToDisplayComment(
|
||||
backgroundColor,
|
||||
comment,
|
||||
null,
|
||||
null,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
@@ -425,6 +425,7 @@ private fun RenderOptionAfterVote(
|
||||
backgroundColor,
|
||||
baseNote.idHex + poolOption.descriptor,
|
||||
baseNote.toNostrUri(),
|
||||
baseNote.author?.pubkeyHex,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
+40
@@ -188,6 +188,46 @@ class AccountViewModel(
|
||||
val broadcastTracker = BroadcastTracker()
|
||||
val feedStates = AccountFeedContentStates(account, viewModelScope)
|
||||
|
||||
/**
|
||||
* `true` when feed/note media (images and videos in `MediaUrlContent`)
|
||||
* should be routed through the local Blossom cache. Requires the master
|
||||
* toggle on, the probe up, AND the profile-pictures-only restriction
|
||||
* to be off.
|
||||
*/
|
||||
val useLocalBlossomBridge: StateFlow<Boolean> =
|
||||
try {
|
||||
combine(
|
||||
account.settings.useLocalBlossomCache,
|
||||
account.settings.localBlossomCacheProfilePicturesOnly,
|
||||
Amethyst.instance.localBlossomCacheProbe.available,
|
||||
) { toggle, profileOnly, probeUp -> toggle && probeUp && !profileOnly }.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
false,
|
||||
)
|
||||
} catch (e: UninitializedPropertyAccessException) {
|
||||
MutableStateFlow(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` when profile pictures should be routed through the local
|
||||
* Blossom cache. Requires only the master toggle and the probe to be
|
||||
* up; the profile-pictures-only restriction does not gate this flow.
|
||||
*/
|
||||
val useLocalBlossomBridgeForProfilePics: StateFlow<Boolean> =
|
||||
try {
|
||||
combine(
|
||||
account.settings.useLocalBlossomCache,
|
||||
Amethyst.instance.localBlossomCacheProbe.available,
|
||||
) { toggle, probeUp -> toggle && probeUp }.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly,
|
||||
false,
|
||||
)
|
||||
} catch (e: UninitializedPropertyAccessException) {
|
||||
MutableStateFlow(false)
|
||||
}
|
||||
|
||||
val callManager =
|
||||
CallManager(
|
||||
signer = account.signer,
|
||||
|
||||
+1
@@ -62,6 +62,7 @@ fun RenderRegularTextNote(
|
||||
backgroundColor = bgColor,
|
||||
id = note.idHex,
|
||||
callbackUri = note.toNostrUri(),
|
||||
authorPubKey = note.author?.pubkeyHex,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
+8
-1
@@ -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) {
|
||||
|
||||
@@ -866,6 +866,13 @@
|
||||
<string name="media_servers">Media Servers</string>
|
||||
<string name="set_preferred_media_servers">Set your preferred media upload servers.</string>
|
||||
|
||||
<string name="use_local_blossom_cache">Use local Blossom cache</string>
|
||||
<string name="use_local_blossom_cache_caption">When a Blossom cache is running on this device (port 24242), route image and video downloads through it.</string>
|
||||
<string name="local_blossom_cache_detected">Local cache detected on port 24242.</string>
|
||||
<string name="local_blossom_cache_not_detected">Local cache not detected on port 24242.</string>
|
||||
<string name="local_blossom_cache_profile_pics_only">Only cache profile pictures</string>
|
||||
<string name="local_blossom_cache_profile_pics_only_caption">Restrict the local cache to profile pictures. Feed images and videos will be fetched directly from the original servers.</string>
|
||||
|
||||
<string name="no_nip96_server_message">You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
||||
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
|
||||
|
||||
|
||||
+2
@@ -53,6 +53,7 @@ fun TranslatableRichTextViewer(
|
||||
backgroundColor: MutableState<Color>,
|
||||
id: String,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
@@ -70,6 +71,7 @@ fun TranslatableRichTextViewer(
|
||||
backgroundColor,
|
||||
id,
|
||||
callbackUri,
|
||||
authorPubKey,
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import okhttp3.Connection
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LocalBlossomCacheRedirectInterceptorTest {
|
||||
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
|
||||
@Test
|
||||
fun bridgeOffPassesThrough() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { false }
|
||||
val captured = mutableListOf<String>()
|
||||
val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha.jpg", captured))
|
||||
assertEquals("https://blossom.example.com/$sha.jpg", captured.single())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnRewritesShaInPath() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
|
||||
val captured = mutableListOf<String>()
|
||||
val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha.jpg", captured))
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com",
|
||||
captured.single(),
|
||||
)
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnPreservesNonHexUrls() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
|
||||
val captured = mutableListOf<String>()
|
||||
val response = interceptor.intercept(fakeChain("https://example.com/avatar.jpg", captured))
|
||||
assertEquals("https://example.com/avatar.jpg", captured.single())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnSkipsLocalhost() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
|
||||
val captured = mutableListOf<String>()
|
||||
val response = interceptor.intercept(fakeChain("http://127.0.0.1:24242/$sha.jpg", captured))
|
||||
assertEquals("http://127.0.0.1:24242/$sha.jpg", captured.single())
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnHandlesNonStandardPort() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
|
||||
val captured = mutableListOf<String>()
|
||||
val response = interceptor.intercept(fakeChain("https://blossom.example.com:8443/$sha.png", captured))
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.png?xs=https%3A%2F%2Fblossom.example.com%3A8443",
|
||||
captured.single(),
|
||||
)
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnHandlesUppercaseSha() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
|
||||
val captured = mutableListOf<String>()
|
||||
val response = interceptor.intercept(fakeChain("https://blossom.example.com/${sha.uppercase()}.jpg", captured))
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com",
|
||||
captured.single(),
|
||||
)
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnFallsBackToBinExtension() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
|
||||
val captured = mutableListOf<String>()
|
||||
val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha", captured))
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.bin?xs=https%3A%2F%2Fblossom.example.com",
|
||||
captured.single(),
|
||||
)
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnHandlesShaInDeeperPathSegment() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
|
||||
val captured = mutableListOf<String>()
|
||||
val response = interceptor.intercept(fakeChain("https://nostr.build/i/cache/$sha.webp", captured))
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.webp?xs=https%3A%2F%2Fnostr.build%2Fi%2Fcache",
|
||||
captured.single(),
|
||||
)
|
||||
response.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnPreservesNostrBuildPathPrefix() {
|
||||
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
|
||||
val captured = mutableListOf<String>()
|
||||
val response = interceptor.intercept(fakeChain("https://cdn.nostr.build/i/$sha.jpg", captured))
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi",
|
||||
captured.single(),
|
||||
)
|
||||
response.close()
|
||||
}
|
||||
|
||||
private fun fakeChain(
|
||||
url: String,
|
||||
captured: MutableList<String>,
|
||||
): Interceptor.Chain =
|
||||
object : Interceptor.Chain {
|
||||
private val request = Request.Builder().url(url.toHttpUrl()).build()
|
||||
|
||||
override fun request(): Request = request
|
||||
|
||||
override fun proceed(request: Request): Response {
|
||||
captured.add(request.url.toString())
|
||||
return Response
|
||||
.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("".toResponseBody(null))
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun connection(): Connection? = null
|
||||
|
||||
override fun call() = throw UnsupportedOperationException()
|
||||
|
||||
override fun connectTimeoutMillis(): Int = 0
|
||||
|
||||
override fun readTimeoutMillis(): Int = 0
|
||||
|
||||
override fun writeTimeoutMillis(): Int = 0
|
||||
|
||||
override fun withConnectTimeout(
|
||||
timeout: Int,
|
||||
unit: java.util.concurrent.TimeUnit,
|
||||
): Interceptor.Chain = this
|
||||
|
||||
override fun withReadTimeout(
|
||||
timeout: Int,
|
||||
unit: java.util.concurrent.TimeUnit,
|
||||
): Interceptor.Chain = this
|
||||
|
||||
override fun withWriteTimeout(
|
||||
timeout: Int,
|
||||
unit: java.util.concurrent.TimeUnit,
|
||||
): Interceptor.Chain = this
|
||||
}
|
||||
}
|
||||
+11
-5
@@ -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(
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
|
||||
private val sha256HexRegex = Regex("[0-9a-f]{64}")
|
||||
private val sha256InPathRegex = Regex("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
|
||||
|
||||
/**
|
||||
* Converts this media content into a Coil/ExoPlayer-friendly model string.
|
||||
*
|
||||
* When the local-Blossom-cache bridge is active and the content has a
|
||||
* known sha256 hash, returns a `blossom:<sha256>.<ext>?xs=<originalHostBase>&as=<authorPubKey>`
|
||||
* URI. The Coil pipeline recognises the scheme and routes the request
|
||||
* through `BlossomServerResolver`, which short-circuits to the local cache
|
||||
* at `127.0.0.1:24242`.
|
||||
*
|
||||
* Otherwise (bridge off, no hash, hash invalid, already a `blossom:` URI,
|
||||
* or a live stream) returns the original URL unchanged so today's
|
||||
* direct-to-CDN behaviour is preserved.
|
||||
*/
|
||||
fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String =
|
||||
bridgeUrl(
|
||||
url = url,
|
||||
useBridge = useLocalBlossomBridge,
|
||||
explicitHash = hash,
|
||||
mimeType = mimeType,
|
||||
authorPubKey = authorPubKey,
|
||||
skipBridge = this is MediaUrlVideo && isLiveStream,
|
||||
)
|
||||
|
||||
/**
|
||||
* Bridge entry point for raw URL strings (e.g. profile pictures) that go
|
||||
* through a Coil fetcher routed by type (`ProfilePictureUrl`) and therefore
|
||||
* bypass [com.vitorpamplona.quartz.nipB7Blossom.BlossomUri] processing
|
||||
* entirely.
|
||||
*
|
||||
* Returns a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pubkey>`
|
||||
* URL so the request can flow through `NetworkFetcher` unchanged. Falls
|
||||
* back to the original URL when no sha256 can be recovered from the path
|
||||
* or when the bridge is off.
|
||||
*
|
||||
* @param localCacheBase the local Blossom cache origin (default `http://127.0.0.1:24242`).
|
||||
* @param authorPubKey 64-char lowercase hex pubkey appended as `as=` so the
|
||||
* cache can consult that author's BUD-03 server list on miss.
|
||||
*/
|
||||
fun bridgeProfilePictureUrl(
|
||||
url: String?,
|
||||
useBridge: Boolean,
|
||||
authorPubKey: String? = null,
|
||||
localCacheBase: String = DEFAULT_LOCAL_CACHE_BASE,
|
||||
): String? {
|
||||
if (url == null) return null
|
||||
if (!useBridge) return url
|
||||
if (url.startsWith("blossom:", ignoreCase = true)) return url
|
||||
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
|
||||
|
||||
val sha = extractSha256FromUrlPath(url) ?: return url
|
||||
val ext = guessExtension(url, null)
|
||||
val serverBase = extractServerBase(url, sha) ?: return url
|
||||
|
||||
val params =
|
||||
buildList {
|
||||
add("xs=${percentEncode(serverBase)}")
|
||||
authorPubKey
|
||||
?.lowercase()
|
||||
?.takeIf { sha256HexRegex.matches(it) }
|
||||
?.let { add("as=$it") }
|
||||
}
|
||||
|
||||
return "${localCacheBase.removeSuffix("/")}/$sha.$ext?${params.joinToString("&")}"
|
||||
}
|
||||
|
||||
const val DEFAULT_LOCAL_CACHE_BASE = "http://127.0.0.1:24242"
|
||||
|
||||
private fun bridgeUrl(
|
||||
url: String,
|
||||
useBridge: Boolean,
|
||||
explicitHash: String?,
|
||||
mimeType: String?,
|
||||
authorPubKey: String?,
|
||||
skipBridge: Boolean,
|
||||
): String {
|
||||
if (!useBridge || skipBridge) return url
|
||||
if (url.startsWith("blossom:", ignoreCase = true)) return url
|
||||
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
|
||||
|
||||
val sha =
|
||||
explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) }
|
||||
?: extractSha256FromUrlPath(url)
|
||||
?: return url
|
||||
|
||||
val ext = guessExtension(url, mimeType)
|
||||
val serverBase = extractServerBase(url, sha) ?: return url
|
||||
|
||||
val authors =
|
||||
authorPubKey
|
||||
?.lowercase()
|
||||
?.takeIf { sha256HexRegex.matches(it) }
|
||||
?.let { listOf(it) }
|
||||
?: emptyList()
|
||||
|
||||
return BlossomUri(
|
||||
sha256 = sha,
|
||||
extension = ext,
|
||||
servers = listOf(serverBase),
|
||||
authors = authors,
|
||||
size = null,
|
||||
).toUriString()
|
||||
}
|
||||
|
||||
private fun percentEncode(input: String): String {
|
||||
val sb = StringBuilder(input.length)
|
||||
for (c in input) {
|
||||
if (c.isLetterOrDigit() || c in "-._~") {
|
||||
sb.append(c)
|
||||
} else {
|
||||
for (b in c.toString().encodeToByteArray()) {
|
||||
sb.append('%')
|
||||
sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase())
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun extractSha256FromUrlPath(url: String): String? {
|
||||
val pathPart = url.substringBefore('?').substringBefore('#')
|
||||
val match = sha256InPathRegex.find(pathPart) ?: return null
|
||||
return match.value.lowercase()
|
||||
}
|
||||
|
||||
private fun guessExtension(
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): String {
|
||||
val pathPart = url.substringBefore('?').substringBefore('#')
|
||||
val lastDot = pathPart.lastIndexOf('.')
|
||||
val lastSlash = pathPart.lastIndexOf('/')
|
||||
if (lastDot > lastSlash && lastDot >= 0) {
|
||||
val ext = pathPart.substring(lastDot + 1).lowercase()
|
||||
if (ext.isNotEmpty() && ext.length <= 8 && ext.all { it.isLetterOrDigit() }) {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType != null) {
|
||||
for ((extension, mt) in mimeTypeMap) {
|
||||
if (mt.equals(mimeType, ignoreCase = true)) return extension
|
||||
}
|
||||
}
|
||||
|
||||
return "bin"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL prefix that the local Blossom cache should append `/<sha>`
|
||||
* to in order to reach the original blob, preserving any path prefix the
|
||||
* upstream CDN uses (e.g. `https://cdn.nostr.build/i` for nostr.build's
|
||||
* `/i/<sha>` scheme). Falls back to scheme+host when the sha can't be
|
||||
* located in the path.
|
||||
*/
|
||||
private fun extractServerBase(
|
||||
url: String,
|
||||
sha: String,
|
||||
): String? {
|
||||
val pathPart = url.substringBefore('?').substringBefore('#')
|
||||
val schemeEnd = pathPart.indexOf("://")
|
||||
if (schemeEnd < 0) return null
|
||||
val hostStart = schemeEnd + 3
|
||||
if (hostStart >= pathPart.length) return null
|
||||
|
||||
val shaIndex = pathPart.indexOf(sha, ignoreCase = true)
|
||||
if (shaIndex >= 0) {
|
||||
// Anchor on the slash immediately preceding the sha so the cache
|
||||
// can append "/<sha>" verbatim per the local-blossom-cache spec.
|
||||
val slashBeforeSha = pathPart.lastIndexOf('/', shaIndex - 1)
|
||||
if (slashBeforeSha > hostStart - 1) {
|
||||
return pathPart.substring(0, slashBeforeSha)
|
||||
}
|
||||
}
|
||||
return extractHostBase(pathPart)
|
||||
}
|
||||
|
||||
private fun extractHostBase(url: String): String? {
|
||||
val schemeEnd = url.indexOf("://")
|
||||
if (schemeEnd < 0) return null
|
||||
val afterScheme = schemeEnd + 3
|
||||
var end = url.length
|
||||
for (i in afterScheme until url.length) {
|
||||
val c = url[i]
|
||||
if (c == '/' || c == '?' || c == '#') {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (end <= afterScheme) return null
|
||||
return url.substring(0, end)
|
||||
}
|
||||
+7
-2
@@ -50,6 +50,7 @@ class RichTextParser {
|
||||
eventTags: Map<String, IMetaTag>,
|
||||
description: String?,
|
||||
callbackUri: String? = null,
|
||||
authorPubKey: String? = null,
|
||||
): MediaUrlContent? {
|
||||
val frags = Nip54InlineMetadata().parse(fullUrl)
|
||||
|
||||
@@ -87,6 +88,7 @@ class RichTextParser {
|
||||
uri = callbackUri,
|
||||
mimeType = contentType,
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
authorPubKey = authorPubKey,
|
||||
)
|
||||
} else if (isVideo) {
|
||||
MediaUrlVideo(
|
||||
@@ -99,6 +101,7 @@ class RichTextParser {
|
||||
uri = callbackUri,
|
||||
mimeType = contentType,
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
authorPubKey = authorPubKey,
|
||||
)
|
||||
} else if (isPdf) {
|
||||
MediaUrlPdf(
|
||||
@@ -110,6 +113,7 @@ class RichTextParser {
|
||||
uri = callbackUri,
|
||||
mimeType = contentType,
|
||||
thumbhash = frags[ThumbhashTag.TAG_NAME] ?: tags[ThumbhashTag.TAG_NAME]?.firstOrNull(),
|
||||
authorPubKey = authorPubKey,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -160,16 +164,17 @@ class RichTextParser {
|
||||
content: String,
|
||||
tags: ImmutableListOfLists<String>,
|
||||
callbackUri: String?,
|
||||
authorPubKey: String? = null,
|
||||
): RichTextViewerState {
|
||||
val imetas = tags.lists.imetasByUrl()
|
||||
val urlSet = UrlParser().parseValidUrls(content)
|
||||
|
||||
val mediaContents =
|
||||
urlSet.withScheme.mapNotNull { fullUrl ->
|
||||
createMediaContent(fullUrl, imetas, content, callbackUri)
|
||||
createMediaContent(fullUrl, imetas, content, callbackUri, authorPubKey)
|
||||
} +
|
||||
urlSet.withoutScheme.mapNotNull { fullUrl ->
|
||||
createMediaContent(fullUrl, imetas, content, callbackUri)
|
||||
createMediaContent(fullUrl, imetas, content, callbackUri, authorPubKey)
|
||||
}
|
||||
|
||||
val mediaForPager = mediaContents.associateBy { it.url }
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MediaUrlContentExtTest {
|
||||
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
|
||||
@Test
|
||||
fun bridgeOffReturnsOriginalUrl() {
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/$sha.jpg", hash = sha)
|
||||
assertEquals("https://cdn.example.com/$sha.jpg", image.toCoilModel(useLocalBlossomBridge = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullHashReturnsOriginalUrl() {
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = null)
|
||||
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidHashReturnsOriginalUrl() {
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = "not-hex")
|
||||
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blossomUriReturnedUnchanged() {
|
||||
val image = MediaUrlImage(url = "blossom:$sha.jpg?xs=https://cdn.example.com", hash = sha)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveStreamReturnsOriginalUrl() {
|
||||
val video = MediaUrlVideo(url = "https://stream.example.com/play.m3u8", hash = sha, isLiveStream = true)
|
||||
assertEquals("https://stream.example.com/play.m3u8", video.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnRewritesPlainHttpsUrl() {
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/abc/$sha.jpg", hash = sha)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://nostr.build/i/abc", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnPreservesNostrBuildPathPrefix() {
|
||||
val image = MediaUrlImage(url = "https://cdn.nostr.build/i/$sha.jpg", hash = sha)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://cdn.nostr.build/i", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnFlatBlossomPathYieldsHostOnlyXs() {
|
||||
val image = MediaUrlImage(url = "https://blossom.primal.net/$sha.jpg", hash = sha)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://blossom.primal.net", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnInfersExtensionFromMimeType() {
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha, mimeType = "image/png")
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeOnFallsBackToBinExtension() {
|
||||
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonHttpUrlReturnsOriginal() {
|
||||
val image = MediaUrlImage(url = "ftp://example.com/file", hash = sha)
|
||||
assertEquals("ftp://example.com/file", image.toCoilModel(useLocalBlossomBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uppercaseHashNormalisedToLowercase() {
|
||||
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = sha.uppercase())
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorPubKeyAddedAsAsParam() {
|
||||
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
|
||||
val image =
|
||||
MediaUrlImage(
|
||||
url = "https://cdn.example.com/foo.jpg",
|
||||
hash = sha,
|
||||
authorPubKey = authorPub,
|
||||
)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com&as=$authorPub", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidAuthorPubKeyDropped() {
|
||||
val image =
|
||||
MediaUrlImage(
|
||||
url = "https://cdn.example.com/foo.jpg",
|
||||
hash = sha,
|
||||
authorPubKey = "not-a-pubkey",
|
||||
)
|
||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
||||
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlNullReturnsNull() {
|
||||
assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlOffReturnsOriginal() {
|
||||
assertEquals(
|
||||
"https://cdn.example.com/avatar.jpg",
|
||||
bridgeProfilePictureUrl("https://cdn.example.com/avatar.jpg", useBridge = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlExtractsShaFromPath() {
|
||||
val url = "https://nostr.build/i/$sha.jpg"
|
||||
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build%2Fi&as=$authorPub",
|
||||
bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlPreservesNostrBuildPath() {
|
||||
val url = "https://cdn.nostr.build/i/$sha.jpg"
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi",
|
||||
bridgeProfilePictureUrl(url, useBridge = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() {
|
||||
val url = "https://nostr.build/avatar.jpg"
|
||||
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bridgeProfilePictureUrlBlossomUriReturnedUnchanged() {
|
||||
val uri = "blossom:$sha.jpg?xs=https://nostr.build"
|
||||
assertEquals(uri, bridgeProfilePictureUrl(uri, useBridge = true))
|
||||
}
|
||||
}
|
||||
@@ -65,18 +65,38 @@ data class BlossomUri(
|
||||
append(sha256)
|
||||
append('.')
|
||||
append(extension)
|
||||
val params =
|
||||
buildList {
|
||||
servers.forEach { add("xs=${percentEncodeQueryValue(it)}") }
|
||||
authors.forEach { add("as=$it") }
|
||||
this@BlossomUri.size?.let { add("sz=$it") }
|
||||
}
|
||||
if (params.isNotEmpty()) {
|
||||
append('?')
|
||||
append(params.joinToString("&"))
|
||||
}
|
||||
appendQueryString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an HTTP URL pointing at a local Blossom cache that proxies
|
||||
* upstream using the same `xs`/`as`/`sz` hints as the canonical URI.
|
||||
*
|
||||
* @param base the cache base URL, e.g. `http://127.0.0.1:24242`.
|
||||
*/
|
||||
fun toLocalCacheUrl(base: String): String =
|
||||
buildString {
|
||||
append(base.removeSuffix("/"))
|
||||
append('/')
|
||||
append(sha256)
|
||||
append('.')
|
||||
append(extension)
|
||||
appendQueryString()
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendQueryString() {
|
||||
val params =
|
||||
buildList {
|
||||
servers.forEach { add("xs=${percentEncodeQueryValue(it)}") }
|
||||
authors.forEach { add("as=$it") }
|
||||
this@BlossomUri.size?.let { add("sz=$it") }
|
||||
}
|
||||
if (params.isNotEmpty()) {
|
||||
append('?')
|
||||
append(params.joinToString("&"))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SCHEME = "blossom:"
|
||||
|
||||
|
||||
@@ -151,4 +151,76 @@ class BlossomUriTest {
|
||||
assertEquals(listOf(server1, server2), result.servers)
|
||||
assertEquals(size, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toLocalCacheUrlMinimal() {
|
||||
val uri =
|
||||
BlossomUri(
|
||||
sha256 = sha256,
|
||||
extension = "jpg",
|
||||
servers = emptyList(),
|
||||
authors = emptyList(),
|
||||
size = null,
|
||||
)
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha256.jpg",
|
||||
uri.toLocalCacheUrl("http://127.0.0.1:24242"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toLocalCacheUrlIncludesHints() {
|
||||
val uri =
|
||||
BlossomUri(
|
||||
sha256 = sha256,
|
||||
extension = "mp4",
|
||||
servers = listOf("https://cdn.example.com", "https://backup.example.com"),
|
||||
authors = listOf(authorPubkey),
|
||||
size = 1048576L,
|
||||
)
|
||||
// BlossomUri.percentEncodeQueryValue lets `:` and `/` through unencoded
|
||||
// since they're safe in a query value as long as `&`/`=`/`#` are absent.
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha256.mp4" +
|
||||
"?xs=https://cdn.example.com" +
|
||||
"&xs=https://backup.example.com" +
|
||||
"&as=$authorPubkey" +
|
||||
"&sz=1048576",
|
||||
uri.toLocalCacheUrl("http://127.0.0.1:24242"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toLocalCacheUrlEncodesAmpersandInServer() {
|
||||
val uri =
|
||||
BlossomUri(
|
||||
sha256 = sha256,
|
||||
extension = "jpg",
|
||||
servers = listOf("https://x.example.com/path?a=1&b=2"),
|
||||
authors = emptyList(),
|
||||
size = null,
|
||||
)
|
||||
// & and = inside the server URL must be encoded so they don't break the query.
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha256.jpg" +
|
||||
"?xs=https://x.example.com/path?a%3D1%26b%3D2",
|
||||
uri.toLocalCacheUrl("http://127.0.0.1:24242"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toLocalCacheUrlStripsTrailingSlash() {
|
||||
val uri =
|
||||
BlossomUri(
|
||||
sha256 = sha256,
|
||||
extension = "jpg",
|
||||
servers = emptyList(),
|
||||
authors = emptyList(),
|
||||
size = null,
|
||||
)
|
||||
assertEquals(
|
||||
"http://127.0.0.1:24242/$sha256.jpg",
|
||||
uri.toLocalCacheUrl("http://127.0.0.1:24242/"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user