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