Adds support for BUD-10 Blossom URIs
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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))
|
||||
|
||||
+12
-18
@@ -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)
|
||||
}
|
||||
|
||||
+75
@@ -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
|
||||
}
|
||||
}
|
||||
+43
@@ -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
|
||||
}
|
||||
+6
-42
@@ -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()
|
||||
|
||||
|
||||
+116
@@ -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))
|
||||
}
|
||||
+5
-6
@@ -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
|
||||
}
|
||||
|
||||
+6
-8
@@ -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)
|
||||
|
||||
+5
-3
@@ -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))
|
||||
|
||||
+3
-4
@@ -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)
|
||||
|
||||
+54
-18
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+150
@@ -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"
|
||||
}
|
||||
}
|
||||
+43
@@ -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)
|
||||
}
|
||||
}
|
||||
+114
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-10
@@ -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),
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user