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 coil3.disk.DiskCache
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.accountsCache.AccountCacheState
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.notifications.PokeyReceiver
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.OkHttpWebSocket
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.notifyCommand.model.NotifyCoordinator
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.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
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.NostrClient
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.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -80,6 +88,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
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 java.io.File
@@ -137,16 +149,6 @@ class AppModules(
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
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
@@ -191,6 +193,15 @@ class AppModules(
applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelays =
DualHttpClientManagerForRelays(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
scope = applicationIOScope,
)
// Connects the NostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
@@ -268,6 +279,44 @@ class AppModules(
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
val trimmingService = MemoryTrimmingService(cache)
@@ -298,7 +347,12 @@ class AppModules(
fun contentResolverFn(): ContentResolver = appContext.contentResolver
fun setImageLoader() {
ImageLoaderSetup.setup(appContext, { diskCache }, { memoryCache }) { url ->
ImageLoaderSetup.setup(
app = appContext,
diskCache = { diskCache },
memoryCache = { memoryCache },
blossomServerResolver = blossomResolver,
) { url ->
okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url))
}
}
@@ -70,9 +70,9 @@ class BlossomServerListState(
val flow =
getBlossomServersListFlow()
.map { normalizeServers(it.note) }
.onStart { emit(normalizeServers(blossomListNote)) }
.flowOn(Dispatchers.IO)
.map {
normalizeServers(it.note)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
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.video.VideoFrameDecoder
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Call
@@ -63,6 +64,7 @@ class ImageLoaderSetup {
app: Context,
diskCache: () -> DiskCache,
memoryCache: () -> MemoryCache,
blossomServerResolver: BlossomServerResolver,
callFactory: (url: String) -> Call.Factory,
) {
SingletonImageLoader.setUnsafe(
@@ -78,6 +80,7 @@ class ImageLoaderSetup {
add(VideoFrameDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(OkHttpFactory(callFactory))
@@ -26,16 +26,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.InetSocketAddress
import java.net.Proxy
interface IHttpClientManager {
fun getHttpClient(useProxy: Boolean): OkHttpClient
fun getCurrentProxyPort(useProxy: Boolean): Int?
}
class DualHttpClientManager(
userAgent: String,
proxyPortProvider: StateFlow<Int?>,
@@ -79,18 +75,16 @@ class DualHttpClientManager(
} else {
defaultHttpClientWithoutProxy.value
}
fun getDynamicCallFactory(useProxy: Boolean) = DynamicCallFactory(useProxy, this)
}
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
/**
* the okhttp can change on the manager without affecting other systems.
*/
class DynamicCallFactory(
val useProxy: Boolean,
val manager: DualHttpClientManager,
) : Call.Factory {
override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request)
}
@@ -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
import android.os.Build
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Dispatcher
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_IS_MOBILE
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_SOCKS_PORT
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 java.net.InetSocketAddress
import java.net.Proxy
@@ -32,53 +33,16 @@ class OkHttpClientFactory(
keyCache: EncryptionKeyCache,
val 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 logging = LoggingInterceptor()
// val logging = LoggingInterceptor()
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 =
OkHttpClient
.Builder()
.dispatcher(myDispatcher)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.addNetworkInterceptor(logging)
// .addNetworkInterceptor(logging)
.addNetworkInterceptor(keyDecryptor)
.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.content.Context
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
@SuppressLint("UnsafeOptInUsageError")
@@ -60,18 +59,18 @@ class VideoCache {
}
// This method should be called when proxy setting changes.
fun renewCacheFactory(client: OkHttpClient) {
fun renewCacheFactory(dataSourceFactory: DataSource.Factory) {
cacheDataSourceFactory =
CacheDataSource
.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(client))
.setUpstreamDataSourceFactory(dataSourceFactory)
.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.
renewCacheFactory(client)
renewCacheFactory(dataSourceFactory)
return cacheDataSourceFactory
}
@@ -22,28 +22,26 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import androidx.media3.common.MediaItem
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.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
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 okhttp3.OkHttpClient
/**
* HLS LiveStreams cannot use cache.
*/
@UnstableApi
class CustomMediaSourceFactory(
okHttpClient: OkHttpClient,
videoCache: VideoCache,
dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(
Amethyst.instance.videoCache.get(okHttpClient),
)
DefaultMediaSourceFactory(videoCache.get(dataSourceFactory))
private var nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
DefaultMediaSourceFactory(dataSourceFactory)
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
@@ -23,23 +23,25 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlayer
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.positions.CurrentPlayPositionCacher
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache
import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying
import okhttp3.OkHttpClient
@OptIn(UnstableApi::class)
class ExoPlayerBuilder(
val okHttp: OkHttpClient,
val videoCache: VideoCache,
val dataSourceFactory: DataSource.Factory,
) {
fun build(context: Context): ExoPlayer =
ExoPlayer
.Builder(context)
.apply {
setMediaSourceFactory(CustomMediaSourceFactory(okHttp))
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
}.build()
.apply {
addListener(AspectRatioCacher(MediaAspectRatioCache))
@@ -29,8 +29,8 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import com.google.common.util.concurrent.Futures
@@ -41,7 +41,6 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
class SessionListener(
val session: MediaSession,
@@ -57,7 +56,7 @@ class SessionListener(
*/
class MediaSessionPool(
val exoPlayerPool: ExoPlayerPool,
val okHttpClient: OkHttpClient,
val dataSourceFactory: DataSource.Factory,
val appContext: Context,
val reset: (MediaSession, Boolean) -> Unit,
) {
@@ -101,7 +100,7 @@ class MediaSessionPool(
DataSourceBitmapLoader
.Builder(context)
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
.setDataSourceFactory(dataSourceFactory)
.build(),
)
setId(id)
@@ -20,35 +20,69 @@
*/
package com.vitorpamplona.amethyst.service.playback.service
import android.net.Uri
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.Player
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.session.MediaSession
import androidx.media3.session.MediaSessionService
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.playerPool.ExoPlayerBuilder
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool
import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool
import com.vitorpamplona.amethyst.service.playback.playerPool.SimultaneousPlaybackCalculator
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
import okhttp3.OkHttpClient
import kotlinx.coroutines.runBlocking
class PlaybackService : MediaSessionService() {
private var poolNoProxy: MediaSessionPool? = null
private var poolWithProxy: MediaSessionPool? = null
@OptIn(UnstableApi::class)
fun newPool(okHttp: OkHttpClient): MediaSessionPool =
MediaSessionPool(
fun newPool(
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(
ExoPlayerBuilder(okHttp),
ExoPlayerBuilder(videoCache, resolvingDataSourceFactory),
poolSize = SimultaneousPlaybackCalculator.max(applicationContext),
),
okHttpClient = okHttp,
dataSourceFactory = resolvingDataSourceFactory,
appContext = applicationContext,
reset = { session, keepPlaying ->
(session.player as ExoPlayer).apply {
@@ -58,6 +92,7 @@ class PlaybackService : MediaSessionService() {
}
},
)
}
@OptIn(UnstableApi::class)
fun lazyPool(proxyPort: Int): MediaSessionPool {
@@ -65,22 +100,23 @@ class PlaybackService : MediaSessionService() {
// no proxy
poolNoProxy?.let { return it }
// creates new
return newPool(Amethyst.instance.okHttpClients.getHttpClient(false)).also { poolNoProxy = it }
} else {
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
}
val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(false)
val videoCache = Amethyst.instance.videoCache
val blossomServerResolver = Amethyst.instance.blossomResolver
pool.destroy()
return newPool(okHttp).also { poolWithProxy = it }
}
// creates new
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolNoProxy = it }
} else {
poolWithProxy?.let { return it }
// 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.service.connectivity.ConnectivityManager
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.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -45,7 +45,7 @@ import okhttp3.OkHttpClient
class RelayProxyClientConnector(
val torEvaluator: StateFlow<TorRelayEvaluation>,
val okHttpClients: DualHttpClientManager,
val okHttpClients: DualHttpClientManagerForRelays,
val connManager: ConnectivityManager,
val torManager: TorManager,
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
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
@Composable
fun ClickableUrl(
urlText: String,
url: String,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
val context = LocalContext.current
ClickableTextPrimary(
text = urlText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.MiddleEllipsis,
onClick = {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
uri.openUri(doubleCheckedUrl)
if (url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, url, onError)
} else {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
uri.openUri(doubleCheckedUrl)
}
}
},
)
@@ -144,7 +144,7 @@ fun ImageGallery(
images.words
.mapNotNull { segment ->
val imageUrl = segment.segmentText
state.imagesForPager[imageUrl] as? MediaUrlImage
state.mediaForPager[imageUrl] as? MediaUrlImage
}.toImmutableList()
Column(modifier = modifier.padding(vertical = Size10dp)) {
@@ -76,7 +76,7 @@ fun LoadUrlPreviewDirect(
is UrlPreviewState.Loading -> {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(url)
DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
}
}
@@ -82,7 +82,7 @@ fun MyAsyncImage(
LoadingAnimation(Size40dp, Size6dp)
}
} 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.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
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.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
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.EmailSegment
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.service.CachedRichTextParser
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.components.markdown.RenderContentAsMarkdown
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.innerPostModifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -170,6 +176,10 @@ fun RenderStrangeNamePreview() {
ClickableRelayUrl(word.segmentText, EmptyNav())
}
is BlossomUriSegment -> {
ClickableRelayUrl(word.segmentText, EmptyNav())
}
is SchemelessUrlSegment -> {
NoProtocolUrlRenderer(word.segmentText)
}
@@ -500,6 +510,8 @@ private fun RenderWordWithoutPreview(
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
}
}
@@ -532,19 +544,91 @@ private fun RenderWordWithPreview(
is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
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
private fun ZoomableContentView(
word: String,
state: RichTextViewerState,
accountViewModel: AccountViewModel,
) {
state.imagesForPager[word]?.let {
state.mediaForPager[word]?.let {
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.service.images.BlurhashWrapper
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.InformationDialog
import com.vitorpamplona.amethyst.ui.note.BlankNote
@@ -286,7 +287,7 @@ fun LocalImageView(
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
}
}
}
@@ -408,7 +409,7 @@ fun UrlImageView(
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
}
}
}
@@ -580,7 +581,10 @@ fun WaitAndDisplay(content: @Composable (AnimatedVisibilityScope.() -> Unit)) {
}
@Composable
fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
fun DisplayUrlWithLoadingSymbol(
content: BaseMediaContent,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary
@@ -589,6 +593,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
val regularText = remember { SpanStyle(color = background) }
val clickableTextStyle = remember { SpanStyle(color = primary) }
val context = LocalContext.current
val annotatedTermsString =
remember {
buildAnnotatedString {
@@ -614,7 +620,13 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
val pressIndicator =
remember {
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 {
Modifier
}
@@ -628,10 +640,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
) {
Text(
text = annotatedTermsString,
modifier =
pressIndicator
.weight(1f, fill = false),
overflow = TextOverflow.Ellipsis,
modifier = pressIndicator.weight(1f, fill = false),
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
InlineLoadingIcon()
@@ -639,7 +649,10 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
}
@Composable
fun DisplayUrlWithLoadingSymbol(url: String) {
fun DisplayUrlWithLoadingSymbol(
url: String,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
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(
modifier = Modifier.width(IntrinsicSize.Max),
@@ -292,7 +292,7 @@ private fun MyLoadUrlPreviewDirectFillWidth(
is UrlPreviewState.Loading -> {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(url)
DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
}
}
@@ -101,6 +101,8 @@ class AccountSessionManager(
private val _accountContent = MutableStateFlow<AccountState>(AccountState.Loading)
val accountContent = _accountContent.asStateFlow()
fun loggedInAccount() = (_accountContent.value as? AccountState.LoggedIn)?.account
fun loginWithDefaultAccountIfLoggedOff() {
// pulls account from storage.
if (_accountContent.value !is AccountState.LoggedIn) {
@@ -20,9 +20,11 @@
*/
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.quartz.utils.ciphers.AESGCM
@Stable
class SuccessfulUploads(
val result: UploadOrchestrator.OrchestratorResult.ServerResult,
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">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="hide_new_word_label">Hide new word or sentence</string>
<string name="automatically_show_profile_picture">Profile Picture</string>