Adds support for BUD-10 Blossom URIs

This commit is contained in:
Vitor Pamplona
2026-03-12 18:57:46 -04:00
parent d4dd5a65e6
commit fa67030b47
37 changed files with 1112 additions and 195 deletions
@@ -25,6 +25,8 @@ import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache import coil3.disk.DiskCache
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
@@ -45,6 +47,7 @@ import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
@@ -54,11 +57,15 @@ import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
@@ -73,6 +80,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -80,6 +88,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transform
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@@ -137,16 +149,6 @@ class AppModules(
scope = applicationIOScope, scope = applicationIOScope,
) )
// manages all relay connections
val okHttpClientForRelays =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// Offers easy methods to know when connections are happening through Tor or not // Offers easy methods to know when connections are happening through Tor or not
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
@@ -191,6 +193,15 @@ class AppModules(
applicationIOScope, applicationIOScope,
) )
// manages all relay connections
val okHttpClientForRelays =
DualHttpClientManagerForRelays(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
scope = applicationIOScope,
)
// Connects the NostrClient class with okHttp // Connects the NostrClient class with okHttp
val websocketBuilder = val websocketBuilder =
OkHttpWebSocket.Builder { url -> OkHttpWebSocket.Builder { url ->
@@ -268,6 +279,44 @@ class AppModules(
scope = applicationIOScope, scope = applicationIOScope,
) )
fun subscribedFlow(
address: Address,
account: Account,
): Flow<NoteState> {
val note = cache.getOrCreateAddressableNote(address)
val userSub = UserFinderQueryState(note.author ?: cache.getOrCreateUser(address.pubKeyHex), account)
val noteSub = EventFinderQueryState(note, account)
return note
.flow()
.metadata.stateFlow
.onStart {
sources.userFinder.subscribe(userSub)
sources.eventFinder.subscribe(noteSub)
}.onCompletion {
sources.eventFinder.unsubscribe(noteSub)
sources.userFinder.unsubscribe(userSub)
}
}
val blossomResolver =
BlossomServerResolver(
loggedInUsers = { listOfNotNull(sessionManager.loggedInAccount()?.pubKey) },
blossomServers = { addressesToSubscribe ->
val account = sessionManager.loggedInAccount() ?: return@BlossomServerResolver listOf()
addressesToSubscribe.map { address ->
subscribedFlow(address, account).transform {
val event = it.note.event as? BlossomServersEvent
if (event != null) {
emit(event)
}
}
}
},
httpClientBuilder = roleBasedHttpClientBuilder,
)
// Organizes cache clearing // Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache) val trimmingService = MemoryTrimmingService(cache)
@@ -298,7 +347,12 @@ class AppModules(
fun contentResolverFn(): ContentResolver = appContext.contentResolver fun contentResolverFn(): ContentResolver = appContext.contentResolver
fun setImageLoader() { fun setImageLoader() {
ImageLoaderSetup.setup(appContext, { diskCache }, { memoryCache }) { url -> ImageLoaderSetup.setup(
app = appContext,
diskCache = { diskCache },
memoryCache = { memoryCache },
blossomServerResolver = blossomResolver,
) { url ->
okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url)) okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url))
} }
} }
@@ -70,9 +70,9 @@ class BlossomServerListState(
val flow = val flow =
getBlossomServersListFlow() getBlossomServersListFlow()
.map { normalizeServers(it.note) } .map {
.onStart { emit(normalizeServers(blossomListNote)) } normalizeServers(it.note)
.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
@@ -0,0 +1,91 @@
/*
* 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.images
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.Uri
import coil3.annotation.ExperimentalCoilApi
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.network.CacheStrategy
import coil3.network.ConcurrentRequestStrategy
import coil3.network.ConnectivityChecker
import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import okhttp3.Call
import kotlin.coroutines.cancellation.CancellationException
@Stable
class BlossomFetcher(
private val options: Options,
private val data: Uri,
private val blossomServerResolver: BlossomServerResolver,
private val networkFetcher: (url: String) -> Fetcher,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
println("BlossomFetcher: starting $data")
return try {
val urlResult = blossomServerResolver.findServers(data.toString())
println("BlossomFetcher: finished $data to ${urlResult?.serverUrl}")
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
println("BlossomFetcher: cancelled or error: $e $data")
null
}
}
@OptIn(ExperimentalCoilApi::class)
class Factory(
val blossomServerResolver: BlossomServerResolver,
val networkClient: (url: String) -> Call.Factory,
) : Fetcher.Factory<Uri> {
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
println("BlossomFetcher: PreFactory $data")
if (!isApplicable(data)) return null
println("BlossomFetcher: Factory $data")
return BlossomFetcher(options, data, blossomServerResolver) { url ->
NetworkFetcher(
url = url,
options = options,
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = lazy { CacheStrategy.DEFAULT },
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
)
}
}
private fun isApplicable(data: Uri): Boolean = data.scheme?.lowercase() == "blossom"
}
}
@@ -43,6 +43,7 @@ import coil3.svg.SvgDecoder
import coil3.util.Logger import coil3.util.Logger
import coil3.video.VideoFrameDecoder import coil3.video.VideoFrameDecoder
import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import okhttp3.Call import okhttp3.Call
@@ -63,6 +64,7 @@ class ImageLoaderSetup {
app: Context, app: Context,
diskCache: () -> DiskCache, diskCache: () -> DiskCache,
memoryCache: () -> MemoryCache, memoryCache: () -> MemoryCache,
blossomServerResolver: BlossomServerResolver,
callFactory: (url: String) -> Call.Factory, callFactory: (url: String) -> Call.Factory,
) { ) {
SingletonImageLoader.setUnsafe( SingletonImageLoader.setUnsafe(
@@ -78,6 +80,7 @@ class ImageLoaderSetup {
add(VideoFrameDecoder.Factory()) add(VideoFrameDecoder.Factory())
add(Base64Fetcher.Factory) add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory) add(BlurHashFetcher.Factory)
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
add(Base64Fetcher.BKeyer) add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer) add(BlurHashFetcher.BKeyer)
add(OkHttpFactory(callFactory)) add(OkHttpFactory(callFactory))
@@ -26,16 +26,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import okhttp3.Call
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy import java.net.Proxy
interface IHttpClientManager {
fun getHttpClient(useProxy: Boolean): OkHttpClient
fun getCurrentProxyPort(useProxy: Boolean): Int?
}
class DualHttpClientManager( class DualHttpClientManager(
userAgent: String, userAgent: String,
proxyPortProvider: StateFlow<Int?>, proxyPortProvider: StateFlow<Int?>,
@@ -79,18 +75,16 @@ class DualHttpClientManager(
} else { } else {
defaultHttpClientWithoutProxy.value defaultHttpClientWithoutProxy.value
} }
fun getDynamicCallFactory(useProxy: Boolean) = DynamicCallFactory(useProxy, this)
} }
object EmptyHttpClientManager : IHttpClientManager { /**
val rootOkHttpClient by lazy { * the okhttp can change on the manager without affecting other systems.
OkHttpClient */
.Builder() class DynamicCallFactory(
.followRedirects(true) val useProxy: Boolean,
.followSslRedirects(true) val manager: DualHttpClientManager,
.build() ) : Call.Factory {
} override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request)
override fun getHttpClient(useProxy: Boolean) = rootOkHttpClient
override fun getCurrentProxyPort(useProxy: Boolean) = null
} }
@@ -0,0 +1,75 @@
/*
* 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
class DualHttpClientManagerForRelays(
userAgent: String,
proxyPortProvider: StateFlow<Int?>,
isMobileDataProvider: StateFlow<Boolean?>,
scope: CoroutineScope,
) : IHttpClientManager {
val factory = OkHttpClientFactoryForRelays(userAgent)
val defaultHttpClient: StateFlow<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
factory.buildHttpClient(proxy, mobile)
}.stateIn(
scope,
SharingStarted.WhileSubscribed(1000),
factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value),
)
val defaultHttpClientWithoutProxy: StateFlow<OkHttpClient> =
isMobileDataProvider
.map { mobile ->
factory.buildHttpClient(mobile)
}.stateIn(
scope,
SharingStarted.WhileSubscribed(1000),
factory.buildHttpClient(isMobileDataProvider.value),
)
fun getCurrentProxy(): Proxy? = defaultHttpClient.value.proxy
override fun getCurrentProxyPort(useProxy: Boolean): Int? =
if (useProxy) {
(getCurrentProxy()?.address() as? InetSocketAddress)?.port
} else {
null
}
override fun getHttpClient(useProxy: Boolean): OkHttpClient =
if (useProxy) {
defaultHttpClient.value
} else {
defaultHttpClientWithoutProxy.value
}
}
@@ -0,0 +1,43 @@
/*
* 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.OkHttpClient
interface IHttpClientManager {
fun getHttpClient(useProxy: Boolean): OkHttpClient
fun getCurrentProxyPort(useProxy: Boolean): Int?
}
object EmptyHttpClientManager : IHttpClientManager {
val rootOkHttpClient by lazy {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
}
override fun getHttpClient(useProxy: Boolean) = rootOkHttpClient
override fun getCurrentProxyPort(useProxy: Boolean) = null
}
@@ -20,9 +20,10 @@
*/ */
package com.vitorpamplona.amethyst.service.okhttp package com.vitorpamplona.amethyst.service.okhttp
import android.os.Build import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_IS_MOBILE
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_SOCKS_PORT
import okhttp3.Dispatcher import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_MOBILE_SECS
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_WIFI_SECS
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Proxy import java.net.Proxy
@@ -32,53 +33,16 @@ class OkHttpClientFactory(
keyCache: EncryptionKeyCache, keyCache: EncryptionKeyCache,
val userAgent: String, val userAgent: String,
) { ) {
companion object { // val logging = LoggingInterceptor()
// by picking a random proxy port, the connection will fail as it should.
const val DEFAULT_SOCKS_PORT: Int = 9050
const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
private fun isEmulator(): Boolean =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.lowercase().contains("emulator") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.lowercase().contains("droid4x") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
"google_sdk" == Build.PRODUCT ||
Build.HARDWARE.contains("goldfish") ||
Build.HARDWARE.contains("ranchu") ||
Build.HARDWARE.contains("vbox86") ||
Build.HARDWARE.contains("nox") ||
Build.HARDWARE.contains("cuttlefish")
}
val logging = LoggingInterceptor()
val keyDecryptor = EncryptedBlobInterceptor(keyCache) val keyDecryptor = EncryptedBlobInterceptor(keyCache)
val myDispatcher =
Dispatcher().apply {
if (!isEmulator()) {
maxRequestsPerHost = 10
maxRequests = 1024
} else {
maxRequestsPerHost = 5
maxRequests = 256
Log.i("OkHttpClientFactory", "Emulator detected, using default maxRequests: 64.")
}
}
private val rootClient = private val rootClient =
OkHttpClient OkHttpClient
.Builder() .Builder()
.dispatcher(myDispatcher)
.followRedirects(true) .followRedirects(true)
.followSslRedirects(true) .followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent)) .addInterceptor(DefaultContentTypeInterceptor(userAgent))
.addNetworkInterceptor(logging) // .addNetworkInterceptor(logging)
.addNetworkInterceptor(keyDecryptor) .addNetworkInterceptor(keyDecryptor)
.build() .build()
@@ -0,0 +1,116 @@
/*
* 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 android.os.Build
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
class OkHttpClientFactoryForRelays(
userAgent: String,
) {
companion object {
// by picking a random proxy port, the connection will fail as it should.
const val DEFAULT_SOCKS_PORT: Int = 9050
const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
private fun isEmulator(): Boolean =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.lowercase().contains("emulator") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.lowercase().contains("droid4x") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
"google_sdk" == Build.PRODUCT ||
Build.HARDWARE.contains("goldfish") ||
Build.HARDWARE.contains("ranchu") ||
Build.HARDWARE.contains("vbox86") ||
Build.HARDWARE.contains("nox") ||
Build.HARDWARE.contains("cuttlefish")
}
val myDispatcher =
Dispatcher().apply {
if (!isEmulator()) {
maxRequestsPerHost = 10
maxRequests = 1024
} else {
maxRequestsPerHost = 5
maxRequests = 256
Log.i("OkHttpClientFactory", "Emulator detected, using default maxRequests: 64.")
}
}
private val rootClient =
OkHttpClient
.Builder()
.dispatcher(myDispatcher)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.build()
fun buildHttpClient(
proxy: Proxy?,
timeoutSeconds: Int,
): OkHttpClient {
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
return rootClient
.newBuilder()
.proxy(proxy)
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.build()
}
fun buildHttpClient(
localSocksProxyPort: Int?,
isMobile: Boolean?,
): OkHttpClient =
buildHttpClient(
buildLocalSocksProxy(localSocksProxyPort),
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
)
fun buildHttpClient(isMobile: Boolean?): OkHttpClient =
buildHttpClient(
null,
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
)
fun buildTimeout(isMobile: Boolean): Int =
if (isMobile) {
DEFAULT_TIMEOUT_ON_MOBILE_SECS
} else {
DEFAULT_TIMEOUT_ON_WIFI_SECS
}
fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT))
}
@@ -23,13 +23,12 @@ package com.vitorpamplona.amethyst.service.playback.diskCache
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File import java.io.File
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
@@ -60,18 +59,18 @@ class VideoCache {
} }
// This method should be called when proxy setting changes. // This method should be called when proxy setting changes.
fun renewCacheFactory(client: OkHttpClient) { fun renewCacheFactory(dataSourceFactory: DataSource.Factory) {
cacheDataSourceFactory = cacheDataSourceFactory =
CacheDataSource CacheDataSource
.Factory() .Factory()
.setCache(simpleCache) .setCache(simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(client)) .setUpstreamDataSourceFactory(dataSourceFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
} }
fun get(client: OkHttpClient): CacheDataSource.Factory { fun get(dataSourceFactory: DataSource.Factory): CacheDataSource.Factory {
// Renews the factory because OkHttpMight have changed. // Renews the factory because OkHttpMight have changed.
renewCacheFactory(client) renewCacheFactory(dataSourceFactory)
return cacheDataSourceFactory return cacheDataSourceFactory
} }
@@ -22,28 +22,26 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import okhttp3.OkHttpClient
/** /**
* HLS LiveStreams cannot use cache. * HLS LiveStreams cannot use cache.
*/ */
@UnstableApi @UnstableApi
class CustomMediaSourceFactory( class CustomMediaSourceFactory(
okHttpClient: OkHttpClient, videoCache: VideoCache,
dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory { ) : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory = private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory( DefaultMediaSourceFactory(videoCache.get(dataSourceFactory))
Amethyst.instance.videoCache.get(okHttpClient),
)
private var nonCachingFactory: MediaSource.Factory = private var nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient)) DefaultMediaSourceFactory(dataSourceFactory)
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory { override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider) cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
@@ -23,23 +23,25 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import android.content.Context import android.content.Context
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache
import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying
import okhttp3.OkHttpClient
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class ExoPlayerBuilder( class ExoPlayerBuilder(
val okHttp: OkHttpClient, val videoCache: VideoCache,
val dataSourceFactory: DataSource.Factory,
) { ) {
fun build(context: Context): ExoPlayer = fun build(context: Context): ExoPlayer =
ExoPlayer ExoPlayer
.Builder(context) .Builder(context)
.apply { .apply {
setMediaSourceFactory(CustomMediaSourceFactory(okHttp)) setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
}.build() }.build()
.apply { .apply {
addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(AspectRatioCacher(MediaAspectRatioCache))
@@ -29,8 +29,8 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
@@ -41,7 +41,6 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
class SessionListener( class SessionListener(
val session: MediaSession, val session: MediaSession,
@@ -57,7 +56,7 @@ class SessionListener(
*/ */
class MediaSessionPool( class MediaSessionPool(
val exoPlayerPool: ExoPlayerPool, val exoPlayerPool: ExoPlayerPool,
val okHttpClient: OkHttpClient, val dataSourceFactory: DataSource.Factory,
val appContext: Context, val appContext: Context,
val reset: (MediaSession, Boolean) -> Unit, val reset: (MediaSession, Boolean) -> Unit,
) { ) {
@@ -101,7 +100,7 @@ class MediaSessionPool(
DataSourceBitmapLoader DataSourceBitmapLoader
.Builder(context) .Builder(context)
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get()) .setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient)) .setDataSourceFactory(dataSourceFactory)
.build(), .build(),
) )
setId(id) setId(id)
@@ -20,35 +20,69 @@
*/ */
package com.vitorpamplona.amethyst.service.playback.service package com.vitorpamplona.amethyst.service.playback.service
import android.net.Uri
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.okhttp.DynamicCallFactory
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool
import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool
import com.vitorpamplona.amethyst.service.playback.playerPool.SimultaneousPlaybackCalculator import com.vitorpamplona.amethyst.service.playback.playerPool.SimultaneousPlaybackCalculator
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import okhttp3.OkHttpClient import kotlinx.coroutines.runBlocking
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
private var poolNoProxy: MediaSessionPool? = null private var poolNoProxy: MediaSessionPool? = null
private var poolWithProxy: MediaSessionPool? = null private var poolWithProxy: MediaSessionPool? = null
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun newPool(okHttp: OkHttpClient): MediaSessionPool = fun newPool(
MediaSessionPool( videoCache: VideoCache,
okHttpClient: DynamicCallFactory,
blossomServerResolver: BlossomServerResolver,
): MediaSessionPool {
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
val resolvingDataSourceFactory: DataSource.Factory =
ResolvingDataSource.Factory(
dataSourceFactory,
ResolvingDataSource.Resolver { dataSpec: DataSpec ->
val originalUri: Uri = dataSpec.uri
val scheme = originalUri.scheme
if (scheme != null && blossomServerResolver.canResolve(scheme)) {
val serverUrl =
runBlocking {
blossomServerResolver.findServers(originalUri.toString())
}
if (serverUrl != null) {
return@Resolver dataSpec.withUri(serverUrl.serverUrl.toUri())
}
}
dataSpec
},
)
return MediaSessionPool(
exoPlayerPool = exoPlayerPool =
ExoPlayerPool( ExoPlayerPool(
ExoPlayerBuilder(okHttp), ExoPlayerBuilder(videoCache, resolvingDataSourceFactory),
poolSize = SimultaneousPlaybackCalculator.max(applicationContext), poolSize = SimultaneousPlaybackCalculator.max(applicationContext),
), ),
okHttpClient = okHttp, dataSourceFactory = resolvingDataSourceFactory,
appContext = applicationContext, appContext = applicationContext,
reset = { session, keepPlaying -> reset = { session, keepPlaying ->
(session.player as ExoPlayer).apply { (session.player as ExoPlayer).apply {
@@ -58,6 +92,7 @@ class PlaybackService : MediaSessionService() {
} }
}, },
) )
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun lazyPool(proxyPort: Int): MediaSessionPool { fun lazyPool(proxyPort: Int): MediaSessionPool {
@@ -65,22 +100,23 @@ class PlaybackService : MediaSessionService() {
// no proxy // no proxy
poolNoProxy?.let { return it } poolNoProxy?.let { return it }
// creates new val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(false)
return newPool(Amethyst.instance.okHttpClients.getHttpClient(false)).also { poolNoProxy = it } val videoCache = Amethyst.instance.videoCache
} else { val blossomServerResolver = Amethyst.instance.blossomResolver
poolWithProxy?.let { pool ->
// with proxy, check if the port is the same.
val okHttp = Amethyst.instance.okHttpClients.getHttpClient(true)
if (okHttp.proxy != null && okHttp.proxy == pool.exoPlayerPool.builder.okHttp.proxy) {
return pool
}
pool.destroy() // creates new
return newPool(okHttp).also { poolWithProxy = it } return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolNoProxy = it }
} } else {
poolWithProxy?.let { return it }
// creates brand new // creates brand new
return newPool(Amethyst.instance.okHttpClients.getHttpClient(true)).also { poolWithProxy = it } // proxy port can change without affecting the pool because
// the choice of okhttp is resolved in newCall
val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(true)
val videoCache = Amethyst.instance.videoCache
val blossomServerResolver = Amethyst.instance.blossomResolver
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolWithProxy = it }
} }
} }
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -45,7 +45,7 @@ import okhttp3.OkHttpClient
class RelayProxyClientConnector( class RelayProxyClientConnector(
val torEvaluator: StateFlow<TorRelayEvaluation>, val torEvaluator: StateFlow<TorRelayEvaluation>,
val okHttpClients: DualHttpClientManager, val okHttpClients: DualHttpClientManagerForRelays,
val connManager: ConnectivityManager, val connManager: ConnectivityManager,
val torManager: TorManager, val torManager: TorManager,
val client: INostrClient, val client: INostrClient,
@@ -0,0 +1,150 @@
/*
* 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 androidx.collection.LruCache
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import kotlin.collections.toTypedArray
import kotlin.let
class BlossomServerResolver(
val loggedInUsers: () -> List<HexKey>,
val blossomServers: (Set<Address>) -> List<Flow<BlossomServersEvent>>,
val httpClientBuilder: IRoleBasedHttpClientBuilder,
) {
val blossomHitCache: ServerHeadCache = ServerHeadCache()
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
class BlossomUriServer(
val uri: BlossomUri,
val serverUrl: String,
)
fun cachedFindServer(uriStr: String): BlossomUriServer? = uriToUrlCache[uriStr]
suspend fun findServers(uriStr: String): BlossomUriServer? {
uriToUrlCache[uriStr]?.let { return it }
val result =
withTimeoutOrNull(10000) {
findServersInner(uriStr)
}
if (result != null) {
uriToUrlCache.put(uriStr, result)
}
return result
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
val uri = BlossomUri.parse(uriStr) ?: return null
val expectedMimeType = mimeTypeMap[uri.extension]
val filename = uri.filename()
if (uri.servers.isNotEmpty()) {
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size)
if (workingUrl != null) {
return BlossomUriServer(uri, workingUrl)
}
}
val blossomServerConfigNeeded = mutableSetOf<Address>()
uri.authors.forEach {
if (it.isValid()) {
blossomServerConfigNeeded.add(BlossomServersEvent.createAddress(it))
}
}
loggedInUsers().forEach {
blossomServerConfigNeeded.add(BlossomServersEvent.createAddress(it))
}
val flows =
blossomServers(blossomServerConfigNeeded)
.map { blossomServerFlow ->
blossomServerFlow.transformLatest {
val servers = it.servers()
if (servers.isNotEmpty()) {
firstWorkingUrl(servers, filename, expectedMimeType, uri.size)?.let { serverUrl ->
emit(serverUrl)
}
}
}
}.toTypedArray()
if (flows.isNotEmpty()) {
val serverResult = merge(*flows).first()
return BlossomUriServer(uri, serverResult)
}
return null
}
private suspend fun firstWorkingUrl(
servers: List<String>,
filename: String,
expectedMimeType: String?,
expectedSize: Long?,
): String? =
firstNotNullOrNullAsync(servers, 10000) {
blossomHitCache.urlIfServerHasFile(it, filename, expectedMimeType, expectedSize) { url ->
client(url, expectedMimeType)
}
}
fun client(
url: String,
mimeType: String?,
): OkHttpClient =
if (mimeType == null) {
httpClientBuilder.okHttpClientForPreview(url)
} else if (mimeType.startsWith("audio/") || mimeType.startsWith("video/")) {
httpClientBuilder.okHttpClientForVideo(url)
} else if (mimeType.startsWith("image/")) {
httpClientBuilder.okHttpClientForImage(url)
} else {
httpClientBuilder.okHttpClientForPreview(url)
}
fun canResolve(scheme: String) = scheme == SCHEME
companion object {
const val SCHEME = "blossom"
}
}
@@ -0,0 +1,43 @@
/*
* 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 android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import kotlin.coroutines.cancellation.CancellationException
fun openBlossomUriAsIntent(
context: Context,
blossomUri: String,
onError: (Int, Int) -> Unit,
) {
try {
val intent = Intent(Intent.ACTION_VIEW, blossomUri.toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(R.string.no_blossom_apps_found_title, R.string.no_blossom_apps_found_description)
}
}
@@ -0,0 +1,114 @@
/*
* 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 androidx.collection.LruCache
import kotlinx.coroutines.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
class ServerHeadCache {
val cache = LruCache<String, HasFile>(200)
sealed interface HasFile {
object NoFile : HasFile
class TypeAndSize(
val mimeType: String,
val size: Long,
) : HasFile
}
suspend fun getFileSizeBytes(
url: String,
client: (url: String) -> OkHttpClient,
): HasFile {
cache[url]?.let { return it }
try {
// Build a HEAD request instead of GET
val request =
Request
.Builder()
.url(url)
.head() // Specifies the HEAD method
.build()
client(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
cache.put(url, HasFile.NoFile)
return HasFile.NoFile
}
// Retrieve the "Content-Length" header
val contentLength = response.header("Content-Length")?.toLongOrNull()
val mimeType = response.header("Content-Type")?.toMediaType()?.toString()
if (contentLength != null && mimeType != null) {
val result = HasFile.TypeAndSize(mimeType, contentLength)
cache.put(url, result)
return result
} else {
cache.put(url, HasFile.NoFile)
return HasFile.NoFile
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
cache.put(url, HasFile.NoFile)
return HasFile.NoFile
}
}
suspend fun urlIfServerHasFile(
server: String,
filename: String,
expectedMimeType: String?,
expectedSize: Long?,
client: (url: String) -> OkHttpClient,
): String? {
val url =
if (server.startsWith("http")) {
server.removeSuffix("/") + "/" + filename
} else {
"https://" + server.removeSuffix("/") + "/" + filename
}
val result = getFileSizeBytes(url, client)
if (result is HasFile.TypeAndSize) {
if (expectedSize == null && expectedMimeType == null) {
// any match goes
return url
} else {
if (result.size == expectedSize) {
return url
}
if (expectedSize == null && result.size > 0 && result.mimeType == expectedMimeType) {
return url
}
}
}
return null
}
}
@@ -21,24 +21,32 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
@Composable @Composable
fun ClickableUrl( fun ClickableUrl(
urlText: String, urlText: String,
url: String, url: String,
onError: (Int, Int) -> Unit = { _, _ -> },
) { ) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val context = LocalContext.current
ClickableTextPrimary( ClickableTextPrimary(
text = urlText, text = urlText,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.MiddleEllipsis,
onClick = { onClick = {
runCatching { if (url.startsWith("blossom:")) {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url" openBlossomUriAsIntent(context, url, onError)
uri.openUri(doubleCheckedUrl) } else {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
uri.openUri(doubleCheckedUrl)
}
} }
}, },
) )
@@ -144,7 +144,7 @@ fun ImageGallery(
images.words images.words
.mapNotNull { segment -> .mapNotNull { segment ->
val imageUrl = segment.segmentText val imageUrl = segment.segmentText
state.imagesForPager[imageUrl] as? MediaUrlImage state.mediaForPager[imageUrl] as? MediaUrlImage
}.toImmutableList() }.toImmutableList()
Column(modifier = modifier.padding(vertical = Size10dp)) { Column(modifier = modifier.padding(vertical = Size10dp)) {
@@ -76,7 +76,7 @@ fun LoadUrlPreviewDirect(
is UrlPreviewState.Loading -> { is UrlPreviewState.Loading -> {
WaitAndDisplay { WaitAndDisplay {
DisplayUrlWithLoadingSymbol(url) DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
} }
} }
@@ -82,7 +82,7 @@ fun MyAsyncImage(
LoadingAnimation(Size40dp, Size6dp) LoadingAnimation(Size40dp, Size6dp)
} }
} else { } else {
DisplayUrlWithLoadingSymbol(imageUrl) DisplayUrlWithLoadingSymbol(imageUrl, accountViewModel.toastManager::toast)
} }
} }
} }
@@ -47,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
@@ -54,18 +55,21 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.compose.produceCachedState import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
@@ -93,6 +97,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -112,6 +117,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -170,6 +176,10 @@ fun RenderStrangeNamePreview() {
ClickableRelayUrl(word.segmentText, EmptyNav()) ClickableRelayUrl(word.segmentText, EmptyNav())
} }
is BlossomUriSegment -> {
ClickableRelayUrl(word.segmentText, EmptyNav())
}
is SchemelessUrlSegment -> { is SchemelessUrlSegment -> {
NoProtocolUrlRenderer(word.segmentText) NoProtocolUrlRenderer(word.segmentText)
} }
@@ -500,6 +510,8 @@ private fun RenderWordWithoutPreview(
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
} }
} }
@@ -532,19 +544,91 @@ private fun RenderWordWithPreview(
is RegularTextSegment -> Text(word.segmentText) is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
} }
} }
@Composable
fun BlossomUriRenderer(
word: String,
state: RichTextViewerState,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
) {
val isMedia = state.mediaForPager.contains(word)
if (isMedia) {
ZoomableContentView(word, state, accountViewModel)
} else {
val serverResultState =
remember(word) {
mutableStateOf(Amethyst.instance.blossomResolver.cachedFindServer(word))
}
if (serverResultState.value == null) {
LaunchedEffect(word) {
serverResultState.value = Amethyst.instance.blossomResolver.findServers(word)
}
}
val serverResult = serverResultState.value
if (serverResult != null && serverResult.serverUrl.isNotBlank()) {
LoadUrlPreview(serverResult.serverUrl, serverResult.uri.filename(), callbackUri, accountViewModel)
} else {
ClickableBlossomUri(word, accountViewModel)
}
}
}
@Composable
fun ClickableBlossomUri(
blossomUri: String,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
ClickableTextPrimary(
text = remember { BlossomUri.parse(blossomUri)?.filename() ?: blossomUri },
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
onClick = { openBlossomUriAsIntent(context, blossomUri, accountViewModel.toastManager::toast) },
)
}
@Composable
fun BlossomUriRendererNoPreview(
word: String,
accountViewModel: AccountViewModel,
) {
val serverResultState =
remember(word) {
mutableStateOf(Amethyst.instance.blossomResolver.cachedFindServer(word))
}
if (serverResultState.value == null) {
LaunchedEffect(word) {
serverResultState.value = Amethyst.instance.blossomResolver.findServers(word)
}
}
val serverResult = serverResultState.value
if (serverResult != null && serverResult.serverUrl.isNotBlank()) {
ClickableUrl(serverResult.uri.filename(), serverResult.serverUrl)
} else {
ClickableBlossomUri(word, accountViewModel)
}
}
@Composable @Composable
private fun ZoomableContentView( private fun ZoomableContentView(
word: String, word: String,
state: RichTextViewerState, state: RichTextViewerState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
state.imagesForPager[word]?.let { state.mediaForPager[word]?.let {
Box(modifier = HalfVertPadding) { Box(modifier = HalfVertPadding) {
ZoomableContentView(it, state.imageList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel) ZoomableContentView(it, state.mediaList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel)
} }
} }
} }
@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
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.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.BlankNote
@@ -286,7 +287,7 @@ fun LocalImageView(
} }
} else { } else {
WaitAndDisplay { WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content) DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
} }
} }
} }
@@ -408,7 +409,7 @@ fun UrlImageView(
} }
} else { } else {
WaitAndDisplay { WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content) DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
} }
} }
} }
@@ -580,7 +581,10 @@ fun WaitAndDisplay(content: @Composable (AnimatedVisibilityScope.() -> Unit)) {
} }
@Composable @Composable
fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) { fun DisplayUrlWithLoadingSymbol(
content: BaseMediaContent,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary val primary = MaterialTheme.colorScheme.primary
@@ -589,6 +593,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
val regularText = remember { SpanStyle(color = background) } val regularText = remember { SpanStyle(color = background) }
val clickableTextStyle = remember { SpanStyle(color = primary) } val clickableTextStyle = remember { SpanStyle(color = primary) }
val context = LocalContext.current
val annotatedTermsString = val annotatedTermsString =
remember { remember {
buildAnnotatedString { buildAnnotatedString {
@@ -614,7 +620,13 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
val pressIndicator = val pressIndicator =
remember { remember {
if (content is MediaUrlContent) { if (content is MediaUrlContent) {
Modifier.clickable { runCatching { uri.openUri(content.url) } } Modifier.clickable {
if (content.url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, content.url, onError)
} else {
runCatching { uri.openUri(content.url) }
}
}
} else { } else {
Modifier Modifier
} }
@@ -628,10 +640,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
) { ) {
Text( Text(
text = annotatedTermsString, text = annotatedTermsString,
modifier = modifier = pressIndicator.weight(1f, fill = false),
pressIndicator overflow = TextOverflow.MiddleEllipsis,
.weight(1f, fill = false),
overflow = TextOverflow.Ellipsis,
maxLines = 1, maxLines = 1,
) )
InlineLoadingIcon() InlineLoadingIcon()
@@ -639,7 +649,10 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
} }
@Composable @Composable
fun DisplayUrlWithLoadingSymbol(url: String) { fun DisplayUrlWithLoadingSymbol(
url: String,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary val primary = MaterialTheme.colorScheme.primary
@@ -654,7 +667,20 @@ fun DisplayUrlWithLoadingSymbol(url: String) {
} }
} }
val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } } val context = LocalContext.current
val pressIndicator =
remember {
Modifier.clickable {
if (url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, url, onError)
} else {
runCatching {
uri.openUri(url)
}
}
}
}
Row( Row(
modifier = Modifier.width(IntrinsicSize.Max), modifier = Modifier.width(IntrinsicSize.Max),
@@ -292,7 +292,7 @@ private fun MyLoadUrlPreviewDirectFillWidth(
is UrlPreviewState.Loading -> { is UrlPreviewState.Loading -> {
WaitAndDisplay { WaitAndDisplay {
DisplayUrlWithLoadingSymbol(url) DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
} }
} }
@@ -101,6 +101,8 @@ class AccountSessionManager(
private val _accountContent = MutableStateFlow<AccountState>(AccountState.Loading) private val _accountContent = MutableStateFlow<AccountState>(AccountState.Loading)
val accountContent = _accountContent.asStateFlow() val accountContent = _accountContent.asStateFlow()
fun loggedInAccount() = (_accountContent.value as? AccountState.LoggedIn)?.account
fun loginWithDefaultAccountIfLoggedOff() { fun loginWithDefaultAccountIfLoggedOff() {
// pulls account from storage. // pulls account from storage.
if (_accountContent.value !is AccountState.LoggedIn) { if (_accountContent.value !is AccountState.LoggedIn) {
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.ciphers.AESGCM
@Stable
class SuccessfulUploads( class SuccessfulUploads(
val result: UploadOrchestrator.OrchestratorResult.ServerResult, val result: UploadOrchestrator.OrchestratorResult.ServerResult,
val caption: String?, val caption: String?,
+3
View File
@@ -1081,6 +1081,9 @@
<string name="no_wallet_found_with_error">No Wallets found to pay a lightning invoice (Error: %1$s). Please install a Lightning wallet to use zaps</string> <string name="no_wallet_found_with_error">No Wallets found to pay a lightning invoice (Error: %1$s). Please install a Lightning wallet to use zaps</string>
<string name="no_wallet_found">No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps</string> <string name="no_wallet_found">No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps</string>
<string name="no_blossom_apps_found_title">Can\'t open Blossom links</string>
<string name="no_blossom_apps_found_description">Blossom apps were not found. Please install a local Blossom app to see this file</string>
<string name="hidden_words">Hidden Words</string> <string name="hidden_words">Hidden Words</string>
<string name="hide_new_word_label">Hide new word or sentence</string> <string name="hide_new_word_label">Hide new word or sentence</string>
<string name="automatically_show_profile_picture">Profile Picture</string> <string name="automatically_show_profile_picture">Profile Picture</string>
@@ -21,17 +21,20 @@
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.collections.forEach
/** /**
* This allows composables to directly register their queries * This allows composables to directly register their queries
* to relays. There may be multiple duplications in these * to relays. There may be multiple duplications in these
* subscriptions since we do not control when screens are removed. * subscriptions since we do not control when screens are removed.
*/ */
abstract class ComposeSubscriptionManager<T> : ComposeSubscriptionManagerControls { abstract class ComposeSubscriptionManager<T> :
ComposeSubscriptionManagerControls,
Subscribable<T> {
private var composeSubscriptions: ConcurrentHashMap<T, T> = ConcurrentHashMap() private var composeSubscriptions: ConcurrentHashMap<T, T> = ConcurrentHashMap()
// This is called by main. Keep it really fast. // This is called by main. Keep it really fast.
fun subscribe(query: T?) { override fun subscribe(query: T?) {
if (query == null) return if (query == null) return
composeSubscriptions.put(query, query) composeSubscriptions.put(query, query)
@@ -40,7 +43,7 @@ abstract class ComposeSubscriptionManager<T> : ComposeSubscriptionManagerControl
} }
// This is called by main. Keep it really fast. // This is called by main. Keep it really fast.
fun unsubscribe(query: T?) { override fun unsubscribe(query: T?) {
if (query == null) return if (query == null) return
composeSubscriptions.remove(query) composeSubscriptions.remove(query)
@@ -48,5 +51,37 @@ abstract class ComposeSubscriptionManager<T> : ComposeSubscriptionManagerControl
invalidateKeys() invalidateKeys()
} }
override fun subscribe(query: List<T>) {
if (query.isEmpty()) return
query.forEach {
composeSubscriptions.put(it, it)
}
invalidateKeys()
}
// This is called by main. Keep it really fast.
override fun unsubscribe(query: List<T>) {
if (query.isEmpty()) return
query.forEach {
composeSubscriptions.remove(it)
}
invalidateKeys()
}
fun allKeys() = composeSubscriptions.keys fun allKeys() = composeSubscriptions.keys
} }
interface Subscribable<T> {
// This is called by main. Keep it really fast.
fun subscribe(query: T?)
fun unsubscribe(query: T?)
fun subscribe(query: List<T>)
fun unsubscribe(query: List<T>)
}
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.AltTag
@@ -161,27 +162,35 @@ class RichTextParser {
val emojiMap = CustomEmoji.createEmojiMap(tags.lists) val emojiMap = CustomEmoji.createEmojiMap(tags.lists)
val allUrls = urlSet.withScheme + urlSet.withoutScheme + urlSet.emails + urlSet.bech32s + urlSet.relayUrls val allUrls = urlSet.withScheme + urlSet.withoutScheme + urlSet.emails + urlSet.bech32s + urlSet.relayUrls + urlSet.blossomUris
val newContent = fixMissingSpaces(content, allUrls) val newContent = fixMissingSpaces(content, allUrls)
val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags) val segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags)
val base64Images = segments.flatMap { it.words.filterIsInstance<Base64Segment>() }
val mediaForPagerWithBase64 = val mediaForPagerWithBase64 =
mediaForPager + mediaForPager +
base64Images segments
.mapNotNull { createMediaContent(it.segmentText, emptyMap(), content, callbackUri) } .flatMap { paragraph ->
.associateBy { it.url } paragraph.words
.mapNotNull {
if (it is Base64Segment) {
createMediaContent(it.segmentText, emptyMap(), content, callbackUri)
} else if (it is BlossomUriSegment) {
createMediaContent(it.segmentText, emptyMap(), content, callbackUri)
} else {
null
}
}
}.associateBy { it.url }
return RichTextViewerState( return RichTextViewerState(
urlSet, urlSet = urlSet,
mediaForPagerWithBase64.toImmutableMap(), mediaForPager = mediaForPagerWithBase64.toImmutableMap(),
mediaForPagerWithBase64.values.toImmutableList(), mediaList = mediaForPagerWithBase64.values.toImmutableList(),
emojiMap.toImmutableMap(), customEmoji = emojiMap.toImmutableMap(),
segments, paragraphs = segments,
tags, tags = tags,
) )
} }
@@ -290,6 +299,8 @@ class RichTextParser {
if (urls.relayUrls.contains(word)) return RelayUrlSegment(word) if (urls.relayUrls.contains(word)) return RelayUrlSegment(word)
if (urls.blossomUris.contains(word)) return BlossomUriSegment(word)
if (startsWithNIP19Scheme(word)) return BechSegment(word) if (startsWithNIP19Scheme(word)) return BechSegment(word)
if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word) if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word)
@@ -359,25 +370,12 @@ class RichTextParser {
companion object { companion object {
val longDatePattern: Regex = Regex("^\\d{4}-\\d{2}-\\d{2}$") val longDatePattern: Regex = Regex("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$") val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Regex = Regex("^(-?[\\d.]+)([a-zA-Z%]*)$")
val noProtocolUrlValidator = val noProtocolUrlValidator =
Regex( Regex(
"(([a-zA-Z0-9_-]+@)?([a-zA-Z0-9_-]+\\.)*[a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.]?[a-zA-Z0-9_-]+)*\\/?)(.*)", "(([a-zA-Z0-9_-]+@)?([a-zA-Z0-9_-]+\\.)*[a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.]?[a-zA-Z0-9_-]+)*\\/?)(.*)",
) )
// Splits at spaces AND at ASCII/multibyte character boundaries
// e.g. "ああexample.com" -> ["ああ", "example.com"]
val wordBoundaryRegex = Regex("(?<=[\\u0000-\\u00FF])(?=[\\u0100-\\uFFFF])|(?<=[\\u0100-\\uFFFF])(?=[\\u0000-\\u00FF])| +")
val additionalUrlSchema =
"""^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?"""
.toRegex(RegexOption.IGNORE_CASE)
val HTTPRegex =
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
.toRegex(RegexOption.IGNORE_CASE)
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a") val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a")
@@ -403,6 +401,10 @@ class RichTextParser {
fullUrl fullUrl
} }
fun isImageExtension(ext: String) = imageExtensions.any { it == ext }
fun isImageOrVideoExtension(ext: String) = imageExtensions.any { it == ext } || videoExtensions.any { it == ext }
fun isImageOrVideoUrl(url: String): Boolean { fun isImageOrVideoUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url) val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
@@ -462,3 +464,31 @@ class RichTextParser {
fun isUrlWithoutScheme(url: String) = noProtocolUrlValidator.matches(url) fun isUrlWithoutScheme(url: String) = noProtocolUrlValidator.matches(url)
} }
} }
val mimeTypeMap: Map<String, String> =
mapOf(
// Images
"png" to "image/png",
"jpg" to "image/jpeg",
"jpeg" to "image/jpeg",
"gif" to "image/gif",
"bmp" to "image/bmp",
"webp" to "image/webp",
"svg" to "image/svg+xml",
"avif" to "image/avif",
"tiff" to "image/tiff",
// Video
"mp4" to "video/mp4",
"webm" to "video/webm",
"ogg" to "video/ogg",
"mov" to "video/quicktime",
"avi" to "video/x-msvideo",
"mkv" to "video/x-matroska",
// Audio
"mp3" to "audio/mpeg",
"wav" to "audio/wav",
"ogg" to "audio/ogg",
"m4a" to "audio/mp4",
"aac" to "audio/aac",
"flac" to "audio/flac",
)
@@ -28,8 +28,8 @@ import kotlinx.collections.immutable.ImmutableMap
@Immutable @Immutable
class RichTextViewerState( class RichTextViewerState(
val urlSet: Urls, val urlSet: Urls,
val imagesForPager: ImmutableMap<String, MediaUrlContent>, val mediaForPager: ImmutableMap<String, MediaUrlContent>,
val imageList: ImmutableList<MediaUrlContent>, val mediaList: ImmutableList<MediaUrlContent>,
val customEmoji: ImmutableMap<String, String>, val customEmoji: ImmutableMap<String, String>,
val paragraphs: ImmutableList<ParagraphState>, val paragraphs: ImmutableList<ParagraphState>,
val tags: ImmutableListOfLists<String>, val tags: ImmutableListOfLists<String>,
@@ -143,6 +143,11 @@ class RelayUrlSegment(
segment: String, segment: String,
) : Segment(segment) ) : Segment(segment)
@Immutable
class BlossomUriSegment(
segment: String,
) : Segment(segment)
@Immutable @Immutable
class SchemelessUrlSegment( class SchemelessUrlSegment(
segment: String, segment: String,
@@ -33,8 +33,10 @@ class Urls(
val emails: Set<String> = emptySet(), val emails: Set<String> = emptySet(),
val bech32s: Set<String> = emptySet(), val bech32s: Set<String> = emptySet(),
val relayUrls: Set<String> = emptySet(), val relayUrls: Set<String> = emptySet(),
val blossomUris: Set<String> = emptySet(),
) )
val httpScheme = listOf(DualCase("http"))
val websocketScheme = listOf(DualCase("ws")) val websocketScheme = listOf(DualCase("ws"))
val nostrScheme = listOf(DualCase("nostr")) val nostrScheme = listOf(DualCase("nostr"))
val blossomScheme = listOf(DualCase("blossom")) val blossomScheme = listOf(DualCase("blossom"))
@@ -72,25 +74,31 @@ class UrlParser {
val emails = mutableSetOf<String>() val emails = mutableSetOf<String>()
val bech32 = mutableSetOf<String>() val bech32 = mutableSetOf<String>()
val relays = mutableSetOf<String>() val relays = mutableSetOf<String>()
val blossom = mutableSetOf<String>()
urls.forEach { urls.forEach { url ->
if (it.isValidTopLevelDomain()) { if (url.isValidTopLevelDomain()) {
if (it.wroteWithSchema()) { if (url.wroteWithSchema()) {
if (it.originalUrl.startsWithAny(nostrScheme)) { if (url.originalUrl.startsWithAny(httpScheme)) {
bech32.add(it.originalUrl) // quick exit
} else if (it.originalUrl.startsWithAny(websocketScheme)) { completeUrls.add(url.originalUrl)
relays.add(it.originalUrl) } else if (url.originalUrl.startsWithAny(nostrScheme)) {
bech32.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(websocketScheme)) {
relays.add(url.originalUrl)
} else if (url.originalUrl.startsWithAny(blossomScheme)) {
blossom.add(url.originalUrl)
} else { } else {
completeUrls.add(it.originalUrl) completeUrls.add(url.originalUrl)
} }
} else { } else {
// emails are understood as urls from the detector. // emails are understood as urls from the detector.
if (it.isEmail()) { if (url.isEmail()) {
Patterns.EMAIL_ADDRESS.findAll(it.originalUrl).forEach { Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach {
emails.add(it.value) emails.add(it.value)
} }
} else { } else {
urlsWithoutScheme.add(it.originalUrl) urlsWithoutScheme.add(url.originalUrl)
} }
} }
} }
@@ -102,6 +110,7 @@ class UrlParser {
emails = emails, emails = emails,
bech32s = bech32, bech32s = bech32,
relayUrls = relays, relayUrls = relays,
blossomUris = blossom,
) )
} }
} }
@@ -4050,8 +4050,8 @@ class RichTextParserTest {
) )
} }
assertTrue(state.imagesForPager.isEmpty()) assertTrue(state.mediaForPager.isEmpty())
assertTrue(state.imageList.isEmpty()) assertTrue(state.mediaList.isEmpty())
assertTrue(state.customEmoji.isEmpty()) assertTrue(state.customEmoji.isEmpty())
assertEquals(651, state.paragraphs.size) assertEquals(651, state.paragraphs.size)
} }
@@ -4064,8 +4064,8 @@ class RichTextParserTest {
assertTrue(state.urlSet.withoutScheme.isEmpty()) assertTrue(state.urlSet.withoutScheme.isEmpty())
assertTrue(state.urlSet.withScheme.isEmpty()) assertTrue(state.urlSet.withScheme.isEmpty())
assertTrue(state.urlSet.emails.isEmpty()) assertTrue(state.urlSet.emails.isEmpty())
assertTrue(state.imagesForPager.isEmpty()) assertTrue(state.mediaForPager.isEmpty())
assertTrue(state.imageList.isEmpty()) assertTrue(state.mediaList.isEmpty())
assertTrue(state.customEmoji.isEmpty()) assertTrue(state.customEmoji.isEmpty())
assertEquals( assertEquals(
"Hi, how are you doing?", "Hi, how are you doing?",
@@ -4084,8 +4084,8 @@ class RichTextParserTest {
assertTrue(state.urlSet.withoutScheme.isEmpty()) assertTrue(state.urlSet.withoutScheme.isEmpty())
assertTrue(state.urlSet.withScheme.isEmpty()) assertTrue(state.urlSet.withScheme.isEmpty())
assertTrue(state.urlSet.emails.isEmpty()) assertTrue(state.urlSet.emails.isEmpty())
assertTrue(state.imagesForPager.isEmpty()) assertTrue(state.mediaForPager.isEmpty())
assertTrue(state.imageList.isEmpty()) assertTrue(state.mediaList.isEmpty())
assertTrue(state.customEmoji.isEmpty()) assertTrue(state.customEmoji.isEmpty())
assertEquals( assertEquals(
"\nHi,\nhow\n\n\n are you doing?\n", "\nHi,\nhow\n\n\n are you doing?\n",
@@ -4107,17 +4107,29 @@ class RichTextParserTest {
val state = val state =
RichTextParser() RichTextParser()
.parseText(text, EmptyTagList, null) .parseText(text, EmptyTagList, null)
val urls = state.urlSet.withScheme.toList()
val bech = state.urlSet.bech32s.toList()
assertEquals( assertEquals(
"https://lnshort.it/live-stream-embeds/", "https://lnshort.it/live-stream-embeds/",
state.urlSet.withScheme.firstOrNull(), urls[0],
) )
assertEquals( assertEquals(
"https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg",
state.imagesForPager.keys.firstOrNull(), urls[1],
)
assertEquals(
"nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf",
bech[0],
) )
assertEquals( assertEquals(
"https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", "https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg",
state.imageList.firstOrNull()?.url, state.mediaForPager.keys.firstOrNull(),
)
assertEquals(
"https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg",
state.mediaList.firstOrNull()?.url,
) )
assertTrue(state.customEmoji.isEmpty()) assertTrue(state.customEmoji.isEmpty())
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.commons.richtext package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
@@ -286,9 +287,14 @@ class UrlParserTest {
) )
@Test @Test
fun testBlossom() = fun testBlossom() {
test( val blossom = "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth"
"blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth", test(blossom, Urls(blossomUris = mapOf(blossom to BlossomUri.parse(blossom)!!)))
Urls(withScheme = setOf("blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth")), }
)
@Test
fun testBlossomComplete() {
val blossom = "blossom:a7b3c2d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1.png?xs=cdn.example.com&xs=media.nostr.build&as=781208004e09102d7da3b7345e64fd193cd1bc3fce8fdae6008d77f9cabcd036&as=b53185b9f27962ebdf76b8a9b0a84cd8b27f9f3d4abd59f715788a3bf9e7f75e&sz=2547831"
test(blossom, Urls(blossomUris = mapOf(blossom to BlossomUri.parse(blossom)!!)))
}
} }
@@ -31,6 +31,8 @@ fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this)
fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null
fun HexKey.isValid(): Boolean = length == PUBKEY_LENGTH && Hex.isHex(this)
const val PUBKEY_LENGTH = 64 const val PUBKEY_LENGTH = 64
const val EVENT_ID_LENGTH = 64 const val EVENT_ID_LENGTH = 64
@@ -59,9 +59,8 @@ class BlossomServersEvent(
fun createTagArray(servers: List<String>): Array<Array<String>> = fun createTagArray(servers: List<String>): Array<Array<String>> =
servers servers
.map { .map { arrayOf("server", it) }
arrayOf("server", it) .plusElement(AltTag.assemble(ALT))
}.plusElement(AltTag.assemble(ALT))
.toTypedArray() .toTypedArray()
suspend fun updateRelayList( suspend fun updateRelayList(
@@ -20,7 +20,9 @@
*/ */
package com.vitorpamplona.quartz.nipB7Blossom package com.vitorpamplona.quartz.nipB7Blossom
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Hex
/** /**
* Parsed representation of a BUD-10 Blossom URI. * Parsed representation of a BUD-10 Blossom URI.
@@ -33,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
* @param authors Hex pubkeys of blob uploaders used for BUD-03 server-list lookup (`as` params). * @param authors Hex pubkeys of blob uploaders used for BUD-03 server-list lookup (`as` params).
* @param size Blob size in bytes for verification and progress display (`sz` param). * @param size Blob size in bytes for verification and progress display (`sz` param).
*/ */
@Stable
data class BlossomUri( data class BlossomUri(
val sha256: HexKey, val sha256: HexKey,
val extension: String, val extension: String,
@@ -40,6 +43,17 @@ data class BlossomUri(
val authors: List<HexKey>, val authors: List<HexKey>,
val size: Long?, val size: Long?,
) { ) {
fun filename(): String = "$sha256.$extension"
fun toServerUrl(): String? {
val server = servers.firstOrNull()?.removeSuffix("/") ?: return null
return if (server.startsWith("http")) {
"$server/$sha256.$extension"
} else {
"https://$server/$sha256.$extension"
}
}
/** /**
* Serialises back to a canonical `blossom:` URI string. * Serialises back to a canonical `blossom:` URI string.
* Server URLs are percent-encoded so that `&`, `=`, and `#` inside them * Server URLs are percent-encoded so that `&`, `=`, and `#` inside them
@@ -65,7 +79,6 @@ data class BlossomUri(
companion object { companion object {
private const val SCHEME = "blossom:" private const val SCHEME = "blossom:"
private val SHA256_REGEX = Regex("^[0-9a-f]{64}$")
/** /**
* Parses a BUD-10 URI string into a [BlossomUri], or returns `null` if the * Parses a BUD-10 URI string into a [BlossomUri], or returns `null` if the
@@ -93,7 +106,7 @@ data class BlossomUri(
extension = "bin" extension = "bin"
} }
if (!SHA256_REGEX.matches(sha256)) return null if (sha256.length != 64 || !Hex.isHex64(sha256)) return null
// Collect repeated query parameters. // Collect repeated query parameters.
val servers = mutableListOf<String>() val servers = mutableListOf<String>()