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