Merge branch 'vitorpamplona:main' into kmp-completeness
This commit is contained in:
+11
-2
@@ -21,12 +21,14 @@ Adds support for NIP-52 Calendar appointments
|
||||
|
||||
Adds support for NIP-39 External Identities with kind 10011
|
||||
|
||||
Adds support for NIP-C0 Code Snippets
|
||||
|
||||
Adds support for NIP-66 Relay Monitor and discovery support to Quartz
|
||||
|
||||
Adds support for NIP-C0 Code Snippets to Quartz
|
||||
|
||||
Adds support for NIP-A3 Payment targets (PayTo: 10133) by @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
|
||||
|
||||
Adds support for BUD-10 "Blossom:" URIs
|
||||
|
||||
Adds support for Namecoin .bit urls to NIP-05
|
||||
- Adds choice of ElectrumX server to resolve namecoins.
|
||||
|
||||
@@ -42,6 +44,10 @@ Removes support for NIP-96 and updates Blossom recommendations
|
||||
|
||||
Adds support to upload Documents to all new post screens.
|
||||
|
||||
Content warning improvements:
|
||||
- Adds optional description field for sensitive content warnings in new posts.
|
||||
- Displays additional information on warning composables.
|
||||
|
||||
Redesigns and reorganizes Setting pages
|
||||
- Consolidate drawer settings into a single Settings hub screen
|
||||
- Redesigns Zap Amount and NWC setup screens
|
||||
@@ -109,6 +115,8 @@ Fixes:
|
||||
- Fixes crash when getting OpenGraph tags of invalid URLs
|
||||
- Fixes NIP-44 key mutation in NIP-46 connect
|
||||
- Location permission watcher moved outside screens to avoid recreation
|
||||
- Solves the sorting contract crash on search by precaching all values before sorting users.
|
||||
- Fixes lingering relay connections from loading follows outbox's settings.
|
||||
|
||||
AI:
|
||||
- Add SKILL.md for AI agent customization
|
||||
@@ -136,6 +144,7 @@ Amethyst Desktop by @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7
|
||||
- Adds NIP-46 Bunker Login
|
||||
- Adds Support for Chess
|
||||
- Adds Thread Screens
|
||||
- Adds advanced search with query engine and filter panel
|
||||
- Adds encrypted DMs (NIP-04/NIP-17)
|
||||
- Adds proper empty states with EOSE tracking
|
||||
- Adds multi-column deck layout
|
||||
|
||||
@@ -25,6 +25,8 @@ import android.content.Context
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil3.disk.DiskCache
|
||||
import coil3.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.commons.model.NoteState
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
|
||||
@@ -45,6 +47,7 @@ import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
|
||||
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
@@ -54,11 +57,15 @@ import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
|
||||
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
|
||||
@@ -73,6 +80,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -80,6 +88,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@@ -137,16 +149,6 @@ class AppModules(
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
// manages all relay connections
|
||||
val okHttpClientForRelays =
|
||||
DualHttpClientManager(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
keyCache = keyCache,
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
// Offers easy methods to know when connections are happening through Tor or not
|
||||
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
|
||||
|
||||
@@ -191,6 +193,15 @@ class AppModules(
|
||||
applicationIOScope,
|
||||
)
|
||||
|
||||
// manages all relay connections
|
||||
val okHttpClientForRelays =
|
||||
DualHttpClientManagerForRelays(
|
||||
userAgent = appAgent,
|
||||
proxyPortProvider = torManager.activePortOrNull,
|
||||
isMobileDataProvider = connManager.isMobileOrNull,
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
// Connects the NostrClient class with okHttp
|
||||
val websocketBuilder =
|
||||
OkHttpWebSocket.Builder { url ->
|
||||
@@ -268,6 +279,44 @@ class AppModules(
|
||||
scope = applicationIOScope,
|
||||
)
|
||||
|
||||
fun subscribedFlow(
|
||||
address: Address,
|
||||
account: Account,
|
||||
): Flow<NoteState> {
|
||||
val note = cache.getOrCreateAddressableNote(address)
|
||||
|
||||
val userSub = UserFinderQueryState(note.author ?: cache.getOrCreateUser(address.pubKeyHex), account)
|
||||
val noteSub = EventFinderQueryState(note, account)
|
||||
|
||||
return note
|
||||
.flow()
|
||||
.metadata.stateFlow
|
||||
.onStart {
|
||||
sources.userFinder.subscribe(userSub)
|
||||
sources.eventFinder.subscribe(noteSub)
|
||||
}.onCompletion {
|
||||
sources.eventFinder.unsubscribe(noteSub)
|
||||
sources.userFinder.unsubscribe(userSub)
|
||||
}
|
||||
}
|
||||
|
||||
val blossomResolver =
|
||||
BlossomServerResolver(
|
||||
loggedInUsers = { listOfNotNull(sessionManager.loggedInAccount()?.pubKey) },
|
||||
blossomServers = { addressesToSubscribe ->
|
||||
val account = sessionManager.loggedInAccount() ?: return@BlossomServerResolver listOf()
|
||||
addressesToSubscribe.map { address ->
|
||||
subscribedFlow(address, account).transform {
|
||||
val event = it.note.event as? BlossomServersEvent
|
||||
if (event != null) {
|
||||
emit(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
httpClientBuilder = roleBasedHttpClientBuilder,
|
||||
)
|
||||
|
||||
// Organizes cache clearing
|
||||
val trimmingService = MemoryTrimmingService(cache)
|
||||
|
||||
@@ -298,7 +347,12 @@ class AppModules(
|
||||
fun contentResolverFn(): ContentResolver = appContext.contentResolver
|
||||
|
||||
fun setImageLoader() {
|
||||
ImageLoaderSetup.setup(appContext, { diskCache }, { memoryCache }) { url ->
|
||||
ImageLoaderSetup.setup(
|
||||
app = appContext,
|
||||
diskCache = { diskCache },
|
||||
memoryCache = { memoryCache },
|
||||
blossomServerResolver = blossomResolver,
|
||||
) { url ->
|
||||
okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url))
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -70,9 +70,9 @@ class BlossomServerListState(
|
||||
|
||||
val flow =
|
||||
getBlossomServersListFlow()
|
||||
.map { normalizeServers(it.note) }
|
||||
.onStart { emit(normalizeServers(blossomListNote)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.map {
|
||||
normalizeServers(it.note)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.images
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import coil3.ImageLoader
|
||||
import coil3.Uri
|
||||
import coil3.annotation.ExperimentalCoilApi
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.network.CacheStrategy
|
||||
import coil3.network.ConcurrentRequestStrategy
|
||||
import coil3.network.ConnectivityChecker
|
||||
import coil3.network.NetworkFetcher
|
||||
import coil3.network.okhttp.asNetworkClient
|
||||
import coil3.request.Options
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import okhttp3.Call
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Stable
|
||||
class BlossomFetcher(
|
||||
private val options: Options,
|
||||
private val data: Uri,
|
||||
private val blossomServerResolver: BlossomServerResolver,
|
||||
private val networkFetcher: (url: String) -> Fetcher,
|
||||
) : Fetcher {
|
||||
override suspend fun fetch(): FetchResult? {
|
||||
println("BlossomFetcher: starting $data")
|
||||
return try {
|
||||
val urlResult = blossomServerResolver.findServers(data.toString())
|
||||
println("BlossomFetcher: finished $data to ${urlResult?.serverUrl}")
|
||||
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
println("BlossomFetcher: cancelled or error: $e $data")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
class Factory(
|
||||
val blossomServerResolver: BlossomServerResolver,
|
||||
val networkClient: (url: String) -> Call.Factory,
|
||||
) : Fetcher.Factory<Uri> {
|
||||
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
|
||||
|
||||
override fun create(
|
||||
data: Uri,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher? {
|
||||
println("BlossomFetcher: PreFactory $data")
|
||||
if (!isApplicable(data)) return null
|
||||
|
||||
println("BlossomFetcher: Factory $data")
|
||||
|
||||
return BlossomFetcher(options, data, blossomServerResolver) { url ->
|
||||
NetworkFetcher(
|
||||
url = url,
|
||||
options = options,
|
||||
networkClient = lazy { networkClient(url).asNetworkClient() },
|
||||
diskCache = lazy { imageLoader.diskCache },
|
||||
cacheStrategy = lazy { CacheStrategy.DEFAULT },
|
||||
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
|
||||
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isApplicable(data: Uri): Boolean = data.scheme?.lowercase() == "blossom"
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import coil3.svg.SvgDecoder
|
||||
import coil3.util.Logger
|
||||
import coil3.video.VideoFrameDecoder
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import okhttp3.Call
|
||||
|
||||
@@ -63,6 +64,7 @@ class ImageLoaderSetup {
|
||||
app: Context,
|
||||
diskCache: () -> DiskCache,
|
||||
memoryCache: () -> MemoryCache,
|
||||
blossomServerResolver: BlossomServerResolver,
|
||||
callFactory: (url: String) -> Call.Factory,
|
||||
) {
|
||||
SingletonImageLoader.setUnsafe(
|
||||
@@ -78,6 +80,7 @@ class ImageLoaderSetup {
|
||||
add(VideoFrameDecoder.Factory())
|
||||
add(Base64Fetcher.Factory)
|
||||
add(BlurHashFetcher.Factory)
|
||||
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
|
||||
add(Base64Fetcher.BKeyer)
|
||||
add(BlurHashFetcher.BKeyer)
|
||||
add(OkHttpFactory(callFactory))
|
||||
|
||||
+12
-18
@@ -26,16 +26,12 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
interface IHttpClientManager {
|
||||
fun getHttpClient(useProxy: Boolean): OkHttpClient
|
||||
|
||||
fun getCurrentProxyPort(useProxy: Boolean): Int?
|
||||
}
|
||||
|
||||
class DualHttpClientManager(
|
||||
userAgent: String,
|
||||
proxyPortProvider: StateFlow<Int?>,
|
||||
@@ -79,18 +75,16 @@ class DualHttpClientManager(
|
||||
} else {
|
||||
defaultHttpClientWithoutProxy.value
|
||||
}
|
||||
|
||||
fun getDynamicCallFactory(useProxy: Boolean) = DynamicCallFactory(useProxy, this)
|
||||
}
|
||||
|
||||
object EmptyHttpClientManager : IHttpClientManager {
|
||||
val rootOkHttpClient by lazy {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getHttpClient(useProxy: Boolean) = rootOkHttpClient
|
||||
|
||||
override fun getCurrentProxyPort(useProxy: Boolean) = null
|
||||
/**
|
||||
* the okhttp can change on the manager without affecting other systems.
|
||||
*/
|
||||
class DynamicCallFactory(
|
||||
val useProxy: Boolean,
|
||||
val manager: DualHttpClientManager,
|
||||
) : Call.Factory {
|
||||
override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request)
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
class DualHttpClientManagerForRelays(
|
||||
userAgent: String,
|
||||
proxyPortProvider: StateFlow<Int?>,
|
||||
isMobileDataProvider: StateFlow<Boolean?>,
|
||||
scope: CoroutineScope,
|
||||
) : IHttpClientManager {
|
||||
val factory = OkHttpClientFactoryForRelays(userAgent)
|
||||
|
||||
val defaultHttpClient: StateFlow<OkHttpClient> =
|
||||
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
|
||||
factory.buildHttpClient(proxy, mobile)
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.WhileSubscribed(1000),
|
||||
factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value),
|
||||
)
|
||||
|
||||
val defaultHttpClientWithoutProxy: StateFlow<OkHttpClient> =
|
||||
isMobileDataProvider
|
||||
.map { mobile ->
|
||||
factory.buildHttpClient(mobile)
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.WhileSubscribed(1000),
|
||||
factory.buildHttpClient(isMobileDataProvider.value),
|
||||
)
|
||||
|
||||
fun getCurrentProxy(): Proxy? = defaultHttpClient.value.proxy
|
||||
|
||||
override fun getCurrentProxyPort(useProxy: Boolean): Int? =
|
||||
if (useProxy) {
|
||||
(getCurrentProxy()?.address() as? InetSocketAddress)?.port
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
override fun getHttpClient(useProxy: Boolean): OkHttpClient =
|
||||
if (useProxy) {
|
||||
defaultHttpClient.value
|
||||
} else {
|
||||
defaultHttpClientWithoutProxy.value
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
interface IHttpClientManager {
|
||||
fun getHttpClient(useProxy: Boolean): OkHttpClient
|
||||
|
||||
fun getCurrentProxyPort(useProxy: Boolean): Int?
|
||||
}
|
||||
|
||||
object EmptyHttpClientManager : IHttpClientManager {
|
||||
val rootOkHttpClient by lazy {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getHttpClient(useProxy: Boolean) = rootOkHttpClient
|
||||
|
||||
override fun getCurrentProxyPort(useProxy: Boolean) = null
|
||||
}
|
||||
+6
-42
@@ -20,9 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import okhttp3.Dispatcher
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_IS_MOBILE
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_SOCKS_PORT
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_MOBILE_SECS
|
||||
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_WIFI_SECS
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
@@ -32,53 +33,16 @@ class OkHttpClientFactory(
|
||||
keyCache: EncryptionKeyCache,
|
||||
val userAgent: String,
|
||||
) {
|
||||
companion object {
|
||||
// by picking a random proxy port, the connection will fail as it should.
|
||||
const val DEFAULT_SOCKS_PORT: Int = 9050
|
||||
const val DEFAULT_IS_MOBILE: Boolean = false
|
||||
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
|
||||
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
|
||||
|
||||
private fun isEmulator(): Boolean =
|
||||
Build.FINGERPRINT.startsWith("generic") ||
|
||||
Build.FINGERPRINT.lowercase().contains("emulator") ||
|
||||
Build.MODEL.contains("google_sdk") ||
|
||||
Build.MODEL.lowercase().contains("droid4x") ||
|
||||
Build.MODEL.contains("Emulator") ||
|
||||
Build.MODEL.contains("Android SDK built for x86") ||
|
||||
Build.MANUFACTURER.contains("Genymotion") ||
|
||||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
|
||||
"google_sdk" == Build.PRODUCT ||
|
||||
Build.HARDWARE.contains("goldfish") ||
|
||||
Build.HARDWARE.contains("ranchu") ||
|
||||
Build.HARDWARE.contains("vbox86") ||
|
||||
Build.HARDWARE.contains("nox") ||
|
||||
Build.HARDWARE.contains("cuttlefish")
|
||||
}
|
||||
|
||||
val logging = LoggingInterceptor()
|
||||
// val logging = LoggingInterceptor()
|
||||
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
|
||||
|
||||
val myDispatcher =
|
||||
Dispatcher().apply {
|
||||
if (!isEmulator()) {
|
||||
maxRequestsPerHost = 10
|
||||
maxRequests = 1024
|
||||
} else {
|
||||
maxRequestsPerHost = 5
|
||||
maxRequests = 256
|
||||
Log.i("OkHttpClientFactory", "Emulator detected, using default maxRequests: 64.")
|
||||
}
|
||||
}
|
||||
|
||||
private val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.dispatcher(myDispatcher)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.addNetworkInterceptor(logging)
|
||||
// .addNetworkInterceptor(logging)
|
||||
.addNetworkInterceptor(keyDecryptor)
|
||||
.build()
|
||||
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
|
||||
class OkHttpClientFactoryForRelays(
|
||||
userAgent: String,
|
||||
) {
|
||||
companion object {
|
||||
// by picking a random proxy port, the connection will fail as it should.
|
||||
const val DEFAULT_SOCKS_PORT: Int = 9050
|
||||
const val DEFAULT_IS_MOBILE: Boolean = false
|
||||
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
|
||||
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
|
||||
|
||||
private fun isEmulator(): Boolean =
|
||||
Build.FINGERPRINT.startsWith("generic") ||
|
||||
Build.FINGERPRINT.lowercase().contains("emulator") ||
|
||||
Build.MODEL.contains("google_sdk") ||
|
||||
Build.MODEL.lowercase().contains("droid4x") ||
|
||||
Build.MODEL.contains("Emulator") ||
|
||||
Build.MODEL.contains("Android SDK built for x86") ||
|
||||
Build.MANUFACTURER.contains("Genymotion") ||
|
||||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
|
||||
"google_sdk" == Build.PRODUCT ||
|
||||
Build.HARDWARE.contains("goldfish") ||
|
||||
Build.HARDWARE.contains("ranchu") ||
|
||||
Build.HARDWARE.contains("vbox86") ||
|
||||
Build.HARDWARE.contains("nox") ||
|
||||
Build.HARDWARE.contains("cuttlefish")
|
||||
}
|
||||
|
||||
val myDispatcher =
|
||||
Dispatcher().apply {
|
||||
if (!isEmulator()) {
|
||||
maxRequestsPerHost = 10
|
||||
maxRequests = 1024
|
||||
} else {
|
||||
maxRequestsPerHost = 5
|
||||
maxRequests = 256
|
||||
Log.i("OkHttpClientFactory", "Emulator detected, using default maxRequests: 64.")
|
||||
}
|
||||
}
|
||||
|
||||
private val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.dispatcher(myDispatcher)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.build()
|
||||
|
||||
fun buildHttpClient(
|
||||
proxy: Proxy?,
|
||||
timeoutSeconds: Int,
|
||||
): OkHttpClient {
|
||||
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
|
||||
return rootClient
|
||||
.newBuilder()
|
||||
.proxy(proxy)
|
||||
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
|
||||
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
|
||||
.build()
|
||||
}
|
||||
|
||||
fun buildHttpClient(
|
||||
localSocksProxyPort: Int?,
|
||||
isMobile: Boolean?,
|
||||
): OkHttpClient =
|
||||
buildHttpClient(
|
||||
buildLocalSocksProxy(localSocksProxyPort),
|
||||
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
|
||||
)
|
||||
|
||||
fun buildHttpClient(isMobile: Boolean?): OkHttpClient =
|
||||
buildHttpClient(
|
||||
null,
|
||||
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
|
||||
)
|
||||
|
||||
fun buildTimeout(isMobile: Boolean): Int =
|
||||
if (isMobile) {
|
||||
DEFAULT_TIMEOUT_ON_MOBILE_SECS
|
||||
} else {
|
||||
DEFAULT_TIMEOUT_ON_WIFI_SECS
|
||||
}
|
||||
|
||||
fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT))
|
||||
}
|
||||
+5
-6
@@ -23,13 +23,12 @@ package com.vitorpamplona.amethyst.service.playback.diskCache
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@@ -60,18 +59,18 @@ class VideoCache {
|
||||
}
|
||||
|
||||
// This method should be called when proxy setting changes.
|
||||
fun renewCacheFactory(client: OkHttpClient) {
|
||||
fun renewCacheFactory(dataSourceFactory: DataSource.Factory) {
|
||||
cacheDataSourceFactory =
|
||||
CacheDataSource
|
||||
.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(client))
|
||||
.setUpstreamDataSourceFactory(dataSourceFactory)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
fun get(client: OkHttpClient): CacheDataSource.Factory {
|
||||
fun get(dataSourceFactory: DataSource.Factory): CacheDataSource.Factory {
|
||||
// Renews the factory because OkHttpMight have changed.
|
||||
renewCacheFactory(client)
|
||||
renewCacheFactory(dataSourceFactory)
|
||||
|
||||
return cacheDataSourceFactory
|
||||
}
|
||||
|
||||
+6
-8
@@ -22,28 +22,26 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* HLS LiveStreams cannot use cache.
|
||||
*/
|
||||
@UnstableApi
|
||||
class CustomMediaSourceFactory(
|
||||
okHttpClient: OkHttpClient,
|
||||
videoCache: VideoCache,
|
||||
dataSourceFactory: DataSource.Factory,
|
||||
) : MediaSource.Factory {
|
||||
private var cachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(
|
||||
Amethyst.instance.videoCache.get(okHttpClient),
|
||||
)
|
||||
DefaultMediaSourceFactory(videoCache.get(dataSourceFactory))
|
||||
private var nonCachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||
DefaultMediaSourceFactory(dataSourceFactory)
|
||||
|
||||
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
|
||||
+5
-3
@@ -23,23 +23,25 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
|
||||
import android.content.Context
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class ExoPlayerBuilder(
|
||||
val okHttp: OkHttpClient,
|
||||
val videoCache: VideoCache,
|
||||
val dataSourceFactory: DataSource.Factory,
|
||||
) {
|
||||
fun build(context: Context): ExoPlayer =
|
||||
ExoPlayer
|
||||
.Builder(context)
|
||||
.apply {
|
||||
setMediaSourceFactory(CustomMediaSourceFactory(okHttp))
|
||||
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
|
||||
}.build()
|
||||
.apply {
|
||||
addListener(AspectRatioCacher(MediaAspectRatioCache))
|
||||
|
||||
+3
-4
@@ -29,8 +29,8 @@ import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.google.common.util.concurrent.Futures
|
||||
@@ -41,7 +41,6 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class SessionListener(
|
||||
val session: MediaSession,
|
||||
@@ -57,7 +56,7 @@ class SessionListener(
|
||||
*/
|
||||
class MediaSessionPool(
|
||||
val exoPlayerPool: ExoPlayerPool,
|
||||
val okHttpClient: OkHttpClient,
|
||||
val dataSourceFactory: DataSource.Factory,
|
||||
val appContext: Context,
|
||||
val reset: (MediaSession, Boolean) -> Unit,
|
||||
) {
|
||||
@@ -101,7 +100,7 @@ class MediaSessionPool(
|
||||
DataSourceBitmapLoader
|
||||
.Builder(context)
|
||||
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
|
||||
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||
.setDataSourceFactory(dataSourceFactory)
|
||||
.build(),
|
||||
)
|
||||
setId(id)
|
||||
|
||||
+54
-18
@@ -20,35 +20,69 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.playback.service
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.ResolvingDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DynamicCallFactory
|
||||
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool
|
||||
import com.vitorpamplona.amethyst.service.playback.playerPool.SimultaneousPlaybackCalculator
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import okhttp3.OkHttpClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var poolNoProxy: MediaSessionPool? = null
|
||||
private var poolWithProxy: MediaSessionPool? = null
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun newPool(okHttp: OkHttpClient): MediaSessionPool =
|
||||
MediaSessionPool(
|
||||
fun newPool(
|
||||
videoCache: VideoCache,
|
||||
okHttpClient: DynamicCallFactory,
|
||||
blossomServerResolver: BlossomServerResolver,
|
||||
): MediaSessionPool {
|
||||
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
|
||||
|
||||
val resolvingDataSourceFactory: DataSource.Factory =
|
||||
ResolvingDataSource.Factory(
|
||||
dataSourceFactory,
|
||||
ResolvingDataSource.Resolver { dataSpec: DataSpec ->
|
||||
val originalUri: Uri = dataSpec.uri
|
||||
val scheme = originalUri.scheme
|
||||
if (scheme != null && blossomServerResolver.canResolve(scheme)) {
|
||||
val serverUrl =
|
||||
runBlocking {
|
||||
blossomServerResolver.findServers(originalUri.toString())
|
||||
}
|
||||
if (serverUrl != null) {
|
||||
return@Resolver dataSpec.withUri(serverUrl.serverUrl.toUri())
|
||||
}
|
||||
}
|
||||
dataSpec
|
||||
},
|
||||
)
|
||||
|
||||
return MediaSessionPool(
|
||||
exoPlayerPool =
|
||||
ExoPlayerPool(
|
||||
ExoPlayerBuilder(okHttp),
|
||||
ExoPlayerBuilder(videoCache, resolvingDataSourceFactory),
|
||||
poolSize = SimultaneousPlaybackCalculator.max(applicationContext),
|
||||
),
|
||||
okHttpClient = okHttp,
|
||||
dataSourceFactory = resolvingDataSourceFactory,
|
||||
appContext = applicationContext,
|
||||
reset = { session, keepPlaying ->
|
||||
(session.player as ExoPlayer).apply {
|
||||
@@ -58,6 +92,7 @@ class PlaybackService : MediaSessionService() {
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun lazyPool(proxyPort: Int): MediaSessionPool {
|
||||
@@ -65,22 +100,23 @@ class PlaybackService : MediaSessionService() {
|
||||
// no proxy
|
||||
poolNoProxy?.let { return it }
|
||||
|
||||
// creates new
|
||||
return newPool(Amethyst.instance.okHttpClients.getHttpClient(false)).also { poolNoProxy = it }
|
||||
} else {
|
||||
poolWithProxy?.let { pool ->
|
||||
// with proxy, check if the port is the same.
|
||||
val okHttp = Amethyst.instance.okHttpClients.getHttpClient(true)
|
||||
if (okHttp.proxy != null && okHttp.proxy == pool.exoPlayerPool.builder.okHttp.proxy) {
|
||||
return pool
|
||||
}
|
||||
val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(false)
|
||||
val videoCache = Amethyst.instance.videoCache
|
||||
val blossomServerResolver = Amethyst.instance.blossomResolver
|
||||
|
||||
pool.destroy()
|
||||
return newPool(okHttp).also { poolWithProxy = it }
|
||||
}
|
||||
// creates new
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolNoProxy = it }
|
||||
} else {
|
||||
poolWithProxy?.let { return it }
|
||||
|
||||
// creates brand new
|
||||
return newPool(Amethyst.instance.okHttpClients.getHttpClient(true)).also { poolWithProxy = it }
|
||||
// proxy port can change without affecting the pool because
|
||||
// the choice of okhttp is resolved in newCall
|
||||
val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(true)
|
||||
val videoCache = Amethyst.instance.videoCache
|
||||
val blossomServerResolver = Amethyst.instance.blossomResolver
|
||||
|
||||
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolWithProxy = it }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -45,7 +45,7 @@ import okhttp3.OkHttpClient
|
||||
|
||||
class RelayProxyClientConnector(
|
||||
val torEvaluator: StateFlow<TorRelayEvaluation>,
|
||||
val okHttpClients: DualHttpClientManager,
|
||||
val okHttpClients: DualHttpClientManagerForRelays,
|
||||
val connManager: ConnectivityManager,
|
||||
val torManager: TorManager,
|
||||
val client: INostrClient,
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.isValid
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import kotlin.collections.toTypedArray
|
||||
import kotlin.let
|
||||
|
||||
class BlossomServerResolver(
|
||||
val loggedInUsers: () -> List<HexKey>,
|
||||
val blossomServers: (Set<Address>) -> List<Flow<BlossomServersEvent>>,
|
||||
val httpClientBuilder: IRoleBasedHttpClientBuilder,
|
||||
) {
|
||||
val blossomHitCache: ServerHeadCache = ServerHeadCache()
|
||||
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
|
||||
|
||||
class BlossomUriServer(
|
||||
val uri: BlossomUri,
|
||||
val serverUrl: String,
|
||||
)
|
||||
|
||||
fun cachedFindServer(uriStr: String): BlossomUriServer? = uriToUrlCache[uriStr]
|
||||
|
||||
suspend fun findServers(uriStr: String): BlossomUriServer? {
|
||||
uriToUrlCache[uriStr]?.let { return it }
|
||||
|
||||
val result =
|
||||
withTimeoutOrNull(10000) {
|
||||
findServersInner(uriStr)
|
||||
}
|
||||
|
||||
if (result != null) {
|
||||
uriToUrlCache.put(uriStr, result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
|
||||
val uri = BlossomUri.parse(uriStr) ?: return null
|
||||
|
||||
val expectedMimeType = mimeTypeMap[uri.extension]
|
||||
val filename = uri.filename()
|
||||
|
||||
if (uri.servers.isNotEmpty()) {
|
||||
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size)
|
||||
if (workingUrl != null) {
|
||||
return BlossomUriServer(uri, workingUrl)
|
||||
}
|
||||
}
|
||||
|
||||
val blossomServerConfigNeeded = mutableSetOf<Address>()
|
||||
|
||||
uri.authors.forEach {
|
||||
if (it.isValid()) {
|
||||
blossomServerConfigNeeded.add(BlossomServersEvent.createAddress(it))
|
||||
}
|
||||
}
|
||||
|
||||
loggedInUsers().forEach {
|
||||
blossomServerConfigNeeded.add(BlossomServersEvent.createAddress(it))
|
||||
}
|
||||
|
||||
val flows =
|
||||
blossomServers(blossomServerConfigNeeded)
|
||||
.map { blossomServerFlow ->
|
||||
blossomServerFlow.transformLatest {
|
||||
val servers = it.servers()
|
||||
if (servers.isNotEmpty()) {
|
||||
firstWorkingUrl(servers, filename, expectedMimeType, uri.size)?.let { serverUrl ->
|
||||
emit(serverUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
if (flows.isNotEmpty()) {
|
||||
val serverResult = merge(*flows).first()
|
||||
return BlossomUriServer(uri, serverResult)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun firstWorkingUrl(
|
||||
servers: List<String>,
|
||||
filename: String,
|
||||
expectedMimeType: String?,
|
||||
expectedSize: Long?,
|
||||
): String? =
|
||||
firstNotNullOrNullAsync(servers, 10000) {
|
||||
blossomHitCache.urlIfServerHasFile(it, filename, expectedMimeType, expectedSize) { url ->
|
||||
client(url, expectedMimeType)
|
||||
}
|
||||
}
|
||||
|
||||
fun client(
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): OkHttpClient =
|
||||
if (mimeType == null) {
|
||||
httpClientBuilder.okHttpClientForPreview(url)
|
||||
} else if (mimeType.startsWith("audio/") || mimeType.startsWith("video/")) {
|
||||
httpClientBuilder.okHttpClientForVideo(url)
|
||||
} else if (mimeType.startsWith("image/")) {
|
||||
httpClientBuilder.okHttpClientForImage(url)
|
||||
} else {
|
||||
httpClientBuilder.okHttpClientForPreview(url)
|
||||
}
|
||||
|
||||
fun canResolve(scheme: String) = scheme == SCHEME
|
||||
|
||||
companion object {
|
||||
const val SCHEME = "blossom"
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
fun openBlossomUriAsIntent(
|
||||
context: Context,
|
||||
blossomUri: String,
|
||||
onError: (Int, Int) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, blossomUri.toUri())
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(R.string.no_blossom_apps_found_title, R.string.no_blossom_apps_found_description)
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
|
||||
|
||||
import androidx.collection.LruCache
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
|
||||
class ServerHeadCache {
|
||||
val cache = LruCache<String, HasFile>(200)
|
||||
|
||||
sealed interface HasFile {
|
||||
object NoFile : HasFile
|
||||
|
||||
class TypeAndSize(
|
||||
val mimeType: String,
|
||||
val size: Long,
|
||||
) : HasFile
|
||||
}
|
||||
|
||||
suspend fun getFileSizeBytes(
|
||||
url: String,
|
||||
client: (url: String) -> OkHttpClient,
|
||||
): HasFile {
|
||||
cache[url]?.let { return it }
|
||||
|
||||
try {
|
||||
// Build a HEAD request instead of GET
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.head() // Specifies the HEAD method
|
||||
.build()
|
||||
|
||||
client(url).newCall(request).executeAsync().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
cache.put(url, HasFile.NoFile)
|
||||
return HasFile.NoFile
|
||||
}
|
||||
|
||||
// Retrieve the "Content-Length" header
|
||||
val contentLength = response.header("Content-Length")?.toLongOrNull()
|
||||
val mimeType = response.header("Content-Type")?.toMediaType()?.toString()
|
||||
|
||||
if (contentLength != null && mimeType != null) {
|
||||
val result = HasFile.TypeAndSize(mimeType, contentLength)
|
||||
cache.put(url, result)
|
||||
return result
|
||||
} else {
|
||||
cache.put(url, HasFile.NoFile)
|
||||
return HasFile.NoFile
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
cache.put(url, HasFile.NoFile)
|
||||
return HasFile.NoFile
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun urlIfServerHasFile(
|
||||
server: String,
|
||||
filename: String,
|
||||
expectedMimeType: String?,
|
||||
expectedSize: Long?,
|
||||
client: (url: String) -> OkHttpClient,
|
||||
): String? {
|
||||
val url =
|
||||
if (server.startsWith("http")) {
|
||||
server.removeSuffix("/") + "/" + filename
|
||||
} else {
|
||||
"https://" + server.removeSuffix("/") + "/" + filename
|
||||
}
|
||||
|
||||
val result = getFileSizeBytes(url, client)
|
||||
|
||||
if (result is HasFile.TypeAndSize) {
|
||||
if (expectedSize == null && expectedMimeType == null) {
|
||||
// any match goes
|
||||
return url
|
||||
} else {
|
||||
if (result.size == expectedSize) {
|
||||
return url
|
||||
}
|
||||
if (expectedSize == null && result.size > 0 && result.mimeType == expectedMimeType) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -21,24 +21,32 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
||||
|
||||
@Composable
|
||||
fun ClickableUrl(
|
||||
urlText: String,
|
||||
url: String,
|
||||
onError: (Int, Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableTextPrimary(
|
||||
text = urlText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
onClick = {
|
||||
runCatching {
|
||||
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
|
||||
uri.openUri(doubleCheckedUrl)
|
||||
if (url.startsWith("blossom:")) {
|
||||
openBlossomUriAsIntent(context, url, onError)
|
||||
} else {
|
||||
runCatching {
|
||||
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
|
||||
uri.openUri(doubleCheckedUrl)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ fun ImageGallery(
|
||||
images.words
|
||||
.mapNotNull { segment ->
|
||||
val imageUrl = segment.segmentText
|
||||
state.imagesForPager[imageUrl] as? MediaUrlImage
|
||||
state.mediaForPager[imageUrl] as? MediaUrlImage
|
||||
}.toImmutableList()
|
||||
|
||||
Column(modifier = modifier.padding(vertical = Size10dp)) {
|
||||
|
||||
@@ -76,7 +76,7 @@ fun LoadUrlPreviewDirect(
|
||||
|
||||
is UrlPreviewState.Loading -> {
|
||||
WaitAndDisplay {
|
||||
DisplayUrlWithLoadingSymbol(url)
|
||||
DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ fun MyAsyncImage(
|
||||
LoadingAnimation(Size40dp, Size6dp)
|
||||
}
|
||||
} else {
|
||||
DisplayUrlWithLoadingSymbol(imageUrl)
|
||||
DisplayUrlWithLoadingSymbol(imageUrl, accountViewModel.toastManager::toast)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFontFamilyResolver
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
@@ -54,18 +55,21 @@ import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
|
||||
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
|
||||
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
|
||||
@@ -93,6 +97,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
@@ -112,6 +117,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -170,6 +176,10 @@ fun RenderStrangeNamePreview() {
|
||||
ClickableRelayUrl(word.segmentText, EmptyNav())
|
||||
}
|
||||
|
||||
is BlossomUriSegment -> {
|
||||
ClickableRelayUrl(word.segmentText, EmptyNav())
|
||||
}
|
||||
|
||||
is SchemelessUrlSegment -> {
|
||||
NoProtocolUrlRenderer(word.segmentText)
|
||||
}
|
||||
@@ -500,6 +510,8 @@ private fun RenderWordWithoutPreview(
|
||||
|
||||
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
|
||||
|
||||
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
|
||||
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
|
||||
}
|
||||
}
|
||||
@@ -532,19 +544,91 @@ private fun RenderWordWithPreview(
|
||||
is RegularTextSegment -> Text(word.segmentText)
|
||||
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
|
||||
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
|
||||
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
|
||||
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BlossomUriRenderer(
|
||||
word: String,
|
||||
state: RichTextViewerState,
|
||||
callbackUri: String? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val isMedia = state.mediaForPager.contains(word)
|
||||
|
||||
if (isMedia) {
|
||||
ZoomableContentView(word, state, accountViewModel)
|
||||
} else {
|
||||
val serverResultState =
|
||||
remember(word) {
|
||||
mutableStateOf(Amethyst.instance.blossomResolver.cachedFindServer(word))
|
||||
}
|
||||
|
||||
if (serverResultState.value == null) {
|
||||
LaunchedEffect(word) {
|
||||
serverResultState.value = Amethyst.instance.blossomResolver.findServers(word)
|
||||
}
|
||||
}
|
||||
|
||||
val serverResult = serverResultState.value
|
||||
if (serverResult != null && serverResult.serverUrl.isNotBlank()) {
|
||||
LoadUrlPreview(serverResult.serverUrl, serverResult.uri.filename(), callbackUri, accountViewModel)
|
||||
} else {
|
||||
ClickableBlossomUri(word, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickableBlossomUri(
|
||||
blossomUri: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableTextPrimary(
|
||||
text = remember { BlossomUri.parse(blossomUri)?.filename() ?: blossomUri },
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
onClick = { openBlossomUriAsIntent(context, blossomUri, accountViewModel.toastManager::toast) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BlossomUriRendererNoPreview(
|
||||
word: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val serverResultState =
|
||||
remember(word) {
|
||||
mutableStateOf(Amethyst.instance.blossomResolver.cachedFindServer(word))
|
||||
}
|
||||
|
||||
if (serverResultState.value == null) {
|
||||
LaunchedEffect(word) {
|
||||
serverResultState.value = Amethyst.instance.blossomResolver.findServers(word)
|
||||
}
|
||||
}
|
||||
|
||||
val serverResult = serverResultState.value
|
||||
if (serverResult != null && serverResult.serverUrl.isNotBlank()) {
|
||||
ClickableUrl(serverResult.uri.filename(), serverResult.serverUrl)
|
||||
} else {
|
||||
ClickableBlossomUri(word, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZoomableContentView(
|
||||
word: String,
|
||||
state: RichTextViewerState,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
state.imagesForPager[word]?.let {
|
||||
state.mediaForPager[word]?.let {
|
||||
Box(modifier = HalfVertPadding) {
|
||||
ZoomableContentView(it, state.imageList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel)
|
||||
ZoomableContentView(it, state.mediaList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-10
@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
|
||||
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
@@ -286,7 +287,7 @@ fun LocalImageView(
|
||||
}
|
||||
} else {
|
||||
WaitAndDisplay {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,7 +409,7 @@ fun UrlImageView(
|
||||
}
|
||||
} else {
|
||||
WaitAndDisplay {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,7 +581,10 @@ fun WaitAndDisplay(content: @Composable (AnimatedVisibilityScope.() -> Unit)) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
|
||||
fun DisplayUrlWithLoadingSymbol(
|
||||
content: BaseMediaContent,
|
||||
onError: (Int, Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
@@ -589,6 +593,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
|
||||
val regularText = remember { SpanStyle(color = background) }
|
||||
val clickableTextStyle = remember { SpanStyle(color = primary) }
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val annotatedTermsString =
|
||||
remember {
|
||||
buildAnnotatedString {
|
||||
@@ -614,7 +620,13 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
|
||||
val pressIndicator =
|
||||
remember {
|
||||
if (content is MediaUrlContent) {
|
||||
Modifier.clickable { runCatching { uri.openUri(content.url) } }
|
||||
Modifier.clickable {
|
||||
if (content.url.startsWith("blossom:")) {
|
||||
openBlossomUriAsIntent(context, content.url, onError)
|
||||
} else {
|
||||
runCatching { uri.openUri(content.url) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
@@ -628,10 +640,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
|
||||
) {
|
||||
Text(
|
||||
text = annotatedTermsString,
|
||||
modifier =
|
||||
pressIndicator
|
||||
.weight(1f, fill = false),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = pressIndicator.weight(1f, fill = false),
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
InlineLoadingIcon()
|
||||
@@ -639,7 +649,10 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayUrlWithLoadingSymbol(url: String) {
|
||||
fun DisplayUrlWithLoadingSymbol(
|
||||
url: String,
|
||||
onError: (Int, Int) -> Unit = { _, _ -> },
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
@@ -654,7 +667,20 @@ fun DisplayUrlWithLoadingSymbol(url: String) {
|
||||
}
|
||||
}
|
||||
|
||||
val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } }
|
||||
val context = LocalContext.current
|
||||
|
||||
val pressIndicator =
|
||||
remember {
|
||||
Modifier.clickable {
|
||||
if (url.startsWith("blossom:")) {
|
||||
openBlossomUriAsIntent(context, url, onError)
|
||||
} else {
|
||||
runCatching {
|
||||
uri.openUri(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.width(IntrinsicSize.Max),
|
||||
|
||||
+1
-1
@@ -292,7 +292,7 @@ private fun MyLoadUrlPreviewDirectFillWidth(
|
||||
|
||||
is UrlPreviewState.Loading -> {
|
||||
WaitAndDisplay {
|
||||
DisplayUrlWithLoadingSymbol(url)
|
||||
DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,8 @@ class AccountSessionManager(
|
||||
private val _accountContent = MutableStateFlow<AccountState>(AccountState.Loading)
|
||||
val accountContent = _accountContent.asStateFlow()
|
||||
|
||||
fun loggedInAccount() = (_accountContent.value as? AccountState.LoggedIn)?.account
|
||||
|
||||
fun loginWithDefaultAccountIfLoggedOff() {
|
||||
// pulls account from storage.
|
||||
if (_accountContent.value !is AccountState.LoggedIn) {
|
||||
|
||||
+2
@@ -20,9 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
|
||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||
|
||||
@Stable
|
||||
class SuccessfulUploads(
|
||||
val result: UploadOrchestrator.OrchestratorResult.ServerResult,
|
||||
val caption: String?,
|
||||
|
||||
+180
@@ -20,18 +20,23 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MenuAnchorType
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -47,6 +52,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
@@ -54,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
@@ -88,6 +95,8 @@ fun UserSettingsScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
DontTranslateFromSetting(accountViewModel)
|
||||
TranslateToSetting(accountViewModel)
|
||||
LanguagePreferencesSetting(accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,3 +152,174 @@ fun DontTranslateFromSetting(accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TranslateToSetting(accountViewModel: AccountViewModel) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val currentTranslateTo = accountViewModel.translateTo()
|
||||
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().configuration)
|
||||
|
||||
Column {
|
||||
SettingsRow(
|
||||
name = R.string.translate_to,
|
||||
description = R.string.translate_to_description,
|
||||
) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = JavaLocale(currentTranslateTo).displayName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable),
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
for (i in 0 until languageList.size()) {
|
||||
languageList.get(i)?.let { lang ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (accountViewModel.account.settings.translateToContains(lang)) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Text(text = lang.displayName)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
accountViewModel.updateTranslateTo(lang)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LanguagePreferencesSetting(accountViewModel: AccountViewModel) {
|
||||
val languagePreferences = accountViewModel.account.settings.syncedSettings.languages.languagePreferences
|
||||
|
||||
if (languagePreferences.isEmpty()) return
|
||||
|
||||
Column {
|
||||
SettingsRow(
|
||||
name = R.string.language_preferences,
|
||||
description = R.string.language_preferences_description,
|
||||
) {}
|
||||
|
||||
languagePreferences.forEach { (key, preference) ->
|
||||
val parts = key.split(",")
|
||||
if (parts.size == 2) {
|
||||
LanguagePreferenceItem(
|
||||
source = parts[0],
|
||||
target = parts[1],
|
||||
preference = preference,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LanguagePreferenceItem(
|
||||
source: String,
|
||||
target: String,
|
||||
preference: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val sourceName = JavaLocale(source).displayName
|
||||
val targetName = JavaLocale(target).displayName
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = stringRes(R.string.language_preference_pair, sourceName, targetName),
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = {
|
||||
Text(
|
||||
if (preference == source) {
|
||||
stringRes(R.string.translations_show_in_lang_first, sourceName)
|
||||
} else {
|
||||
stringRes(R.string.translations_show_in_lang_first, targetName)
|
||||
},
|
||||
)
|
||||
},
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryEditable),
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (preference == source) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Text(stringRes(R.string.translations_show_in_lang_first, sourceName))
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
accountViewModel.prefer(source, target, source)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (preference == target) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Text(stringRes(R.string.translations_show_in_lang_first, targetName))
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
accountViewModel.prefer(source, target, target)
|
||||
expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,6 +1081,9 @@
|
||||
<string name="no_wallet_found_with_error">No Wallets found to pay a lightning invoice (Error: %1$s). Please install a Lightning wallet to use zaps</string>
|
||||
<string name="no_wallet_found">No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps</string>
|
||||
|
||||
<string name="no_blossom_apps_found_title">Can\'t open Blossom links</string>
|
||||
<string name="no_blossom_apps_found_description">Blossom apps were not found. Please install a local Blossom app to see this file</string>
|
||||
|
||||
<string name="hidden_words">Hidden Words</string>
|
||||
<string name="hide_new_word_label">Hide new word or sentence</string>
|
||||
<string name="automatically_show_profile_picture">Profile Picture</string>
|
||||
@@ -1500,6 +1503,11 @@
|
||||
|
||||
<string name="dont_translate_from">Don\'t Translate From</string>
|
||||
<string name="dont_translate_from_description">Languages shown here will not be translated. Select a language to remove it and have it translated again.</string>
|
||||
<string name="translate_to">Translate To</string>
|
||||
<string name="translate_to_description">Choose the language to translate content into.</string>
|
||||
<string name="language_preferences">Language Display Preferences</string>
|
||||
<string name="language_preferences_description">For each translated language pair, choose which language to show first.</string>
|
||||
<string name="language_preference_pair">%1$s → %2$s</string>
|
||||
<string name="pause">Pause</string>
|
||||
<string name="play">Play</string>
|
||||
<string name="open_dropdown_menu">Open dropdown menu</string>
|
||||
|
||||
+38
-3
@@ -21,17 +21,20 @@
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.collections.forEach
|
||||
|
||||
/**
|
||||
* This allows composables to directly register their queries
|
||||
* to relays. There may be multiple duplications in these
|
||||
* 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()
|
||||
|
||||
// This is called by main. Keep it really fast.
|
||||
fun subscribe(query: T?) {
|
||||
override fun subscribe(query: T?) {
|
||||
if (query == null) return
|
||||
|
||||
composeSubscriptions.put(query, query)
|
||||
@@ -40,7 +43,7 @@ abstract class ComposeSubscriptionManager<T> : ComposeSubscriptionManagerControl
|
||||
}
|
||||
|
||||
// This is called by main. Keep it really fast.
|
||||
fun unsubscribe(query: T?) {
|
||||
override fun unsubscribe(query: T?) {
|
||||
if (query == null) return
|
||||
|
||||
composeSubscriptions.remove(query)
|
||||
@@ -48,5 +51,37 @@ abstract class ComposeSubscriptionManager<T> : ComposeSubscriptionManagerControl
|
||||
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
|
||||
}
|
||||
|
||||
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.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
|
||||
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
@@ -161,27 +162,35 @@ class RichTextParser {
|
||||
|
||||
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 segments = findTextSegments(newContent, imageUrls, videoUrls, urlSet, emojiMap, tags)
|
||||
|
||||
val base64Images = segments.flatMap { it.words.filterIsInstance<Base64Segment>() }
|
||||
|
||||
val mediaForPagerWithBase64 =
|
||||
mediaForPager +
|
||||
base64Images
|
||||
.mapNotNull { createMediaContent(it.segmentText, emptyMap(), content, callbackUri) }
|
||||
.associateBy { it.url }
|
||||
segments
|
||||
.flatMap { paragraph ->
|
||||
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(
|
||||
urlSet,
|
||||
mediaForPagerWithBase64.toImmutableMap(),
|
||||
mediaForPagerWithBase64.values.toImmutableList(),
|
||||
emojiMap.toImmutableMap(),
|
||||
segments,
|
||||
tags,
|
||||
urlSet = urlSet,
|
||||
mediaForPager = mediaForPagerWithBase64.toImmutableMap(),
|
||||
mediaList = mediaForPagerWithBase64.values.toImmutableList(),
|
||||
customEmoji = emojiMap.toImmutableMap(),
|
||||
paragraphs = segments,
|
||||
tags = tags,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -290,6 +299,8 @@ class RichTextParser {
|
||||
|
||||
if (urls.relayUrls.contains(word)) return RelayUrlSegment(word)
|
||||
|
||||
if (urls.blossomUris.contains(word)) return BlossomUriSegment(word)
|
||||
|
||||
if (startsWithNIP19Scheme(word)) return BechSegment(word)
|
||||
|
||||
if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word)
|
||||
@@ -359,25 +370,12 @@ class RichTextParser {
|
||||
companion object {
|
||||
val longDatePattern: Regex = Regex("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$")
|
||||
val numberPattern: Regex = Regex("^(-?[\\d.]+)([a-zA-Z%]*)$")
|
||||
|
||||
val noProtocolUrlValidator =
|
||||
Regex(
|
||||
"(([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 videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a")
|
||||
|
||||
@@ -403,6 +401,10 @@ class RichTextParser {
|
||||
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 {
|
||||
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
|
||||
|
||||
@@ -462,3 +464,31 @@ class RichTextParser {
|
||||
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
|
||||
class RichTextViewerState(
|
||||
val urlSet: Urls,
|
||||
val imagesForPager: ImmutableMap<String, MediaUrlContent>,
|
||||
val imageList: ImmutableList<MediaUrlContent>,
|
||||
val mediaForPager: ImmutableMap<String, MediaUrlContent>,
|
||||
val mediaList: ImmutableList<MediaUrlContent>,
|
||||
val customEmoji: ImmutableMap<String, String>,
|
||||
val paragraphs: ImmutableList<ParagraphState>,
|
||||
val tags: ImmutableListOfLists<String>,
|
||||
@@ -143,6 +143,11 @@ class RelayUrlSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class BlossomUriSegment(
|
||||
segment: String,
|
||||
) : Segment(segment)
|
||||
|
||||
@Immutable
|
||||
class SchemelessUrlSegment(
|
||||
segment: String,
|
||||
|
||||
+20
-11
@@ -33,8 +33,10 @@ class Urls(
|
||||
val emails: Set<String> = emptySet(),
|
||||
val bech32s: Set<String> = emptySet(),
|
||||
val relayUrls: Set<String> = emptySet(),
|
||||
val blossomUris: Set<String> = emptySet(),
|
||||
)
|
||||
|
||||
val httpScheme = listOf(DualCase("http"))
|
||||
val websocketScheme = listOf(DualCase("ws"))
|
||||
val nostrScheme = listOf(DualCase("nostr"))
|
||||
val blossomScheme = listOf(DualCase("blossom"))
|
||||
@@ -72,25 +74,31 @@ class UrlParser {
|
||||
val emails = mutableSetOf<String>()
|
||||
val bech32 = mutableSetOf<String>()
|
||||
val relays = mutableSetOf<String>()
|
||||
val blossom = mutableSetOf<String>()
|
||||
|
||||
urls.forEach {
|
||||
if (it.isValidTopLevelDomain()) {
|
||||
if (it.wroteWithSchema()) {
|
||||
if (it.originalUrl.startsWithAny(nostrScheme)) {
|
||||
bech32.add(it.originalUrl)
|
||||
} else if (it.originalUrl.startsWithAny(websocketScheme)) {
|
||||
relays.add(it.originalUrl)
|
||||
urls.forEach { url ->
|
||||
if (url.isValidTopLevelDomain()) {
|
||||
if (url.wroteWithSchema()) {
|
||||
if (url.originalUrl.startsWithAny(httpScheme)) {
|
||||
// quick exit
|
||||
completeUrls.add(url.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 {
|
||||
completeUrls.add(it.originalUrl)
|
||||
completeUrls.add(url.originalUrl)
|
||||
}
|
||||
} else {
|
||||
// emails are understood as urls from the detector.
|
||||
if (it.isEmail()) {
|
||||
Patterns.EMAIL_ADDRESS.findAll(it.originalUrl).forEach {
|
||||
if (url.isEmail()) {
|
||||
Patterns.EMAIL_ADDRESS.findAll(url.originalUrl).forEach {
|
||||
emails.add(it.value)
|
||||
}
|
||||
} else {
|
||||
urlsWithoutScheme.add(it.originalUrl)
|
||||
urlsWithoutScheme.add(url.originalUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,6 +110,7 @@ class UrlParser {
|
||||
emails = emails,
|
||||
bech32s = bech32,
|
||||
relayUrls = relays,
|
||||
blossomUris = blossom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+21
-9
@@ -4050,8 +4050,8 @@ class RichTextParserTest {
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(state.imagesForPager.isEmpty())
|
||||
assertTrue(state.imageList.isEmpty())
|
||||
assertTrue(state.mediaForPager.isEmpty())
|
||||
assertTrue(state.mediaList.isEmpty())
|
||||
assertTrue(state.customEmoji.isEmpty())
|
||||
assertEquals(651, state.paragraphs.size)
|
||||
}
|
||||
@@ -4064,8 +4064,8 @@ class RichTextParserTest {
|
||||
assertTrue(state.urlSet.withoutScheme.isEmpty())
|
||||
assertTrue(state.urlSet.withScheme.isEmpty())
|
||||
assertTrue(state.urlSet.emails.isEmpty())
|
||||
assertTrue(state.imagesForPager.isEmpty())
|
||||
assertTrue(state.imageList.isEmpty())
|
||||
assertTrue(state.mediaForPager.isEmpty())
|
||||
assertTrue(state.mediaList.isEmpty())
|
||||
assertTrue(state.customEmoji.isEmpty())
|
||||
assertEquals(
|
||||
"Hi, how are you doing?",
|
||||
@@ -4084,8 +4084,8 @@ class RichTextParserTest {
|
||||
assertTrue(state.urlSet.withoutScheme.isEmpty())
|
||||
assertTrue(state.urlSet.withScheme.isEmpty())
|
||||
assertTrue(state.urlSet.emails.isEmpty())
|
||||
assertTrue(state.imagesForPager.isEmpty())
|
||||
assertTrue(state.imageList.isEmpty())
|
||||
assertTrue(state.mediaForPager.isEmpty())
|
||||
assertTrue(state.mediaList.isEmpty())
|
||||
assertTrue(state.customEmoji.isEmpty())
|
||||
assertEquals(
|
||||
"\nHi,\nhow\n\n\n are you doing?\n",
|
||||
@@ -4107,17 +4107,29 @@ class RichTextParserTest {
|
||||
val state =
|
||||
RichTextParser()
|
||||
.parseText(text, EmptyTagList, null)
|
||||
|
||||
val urls = state.urlSet.withScheme.toList()
|
||||
val bech = state.urlSet.bech32s.toList()
|
||||
|
||||
assertEquals(
|
||||
"https://lnshort.it/live-stream-embeds/",
|
||||
state.urlSet.withScheme.firstOrNull(),
|
||||
urls[0],
|
||||
)
|
||||
assertEquals(
|
||||
"https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg",
|
||||
state.imagesForPager.keys.firstOrNull(),
|
||||
urls[1],
|
||||
)
|
||||
assertEquals(
|
||||
"nostr:npub1048qg5p6kfnpth2l98kq3dffg097tutm4npsz2exygx25ge2k9xqf5x3nf",
|
||||
bech[0],
|
||||
)
|
||||
assertEquals(
|
||||
"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())
|
||||
|
||||
|
||||
+13
-5
@@ -34,6 +34,9 @@ class UrlParserTest {
|
||||
assertEquals(expected.withScheme, urlSet.withScheme)
|
||||
assertEquals(expected.withoutScheme, urlSet.withoutScheme)
|
||||
assertEquals(expected.emails, urlSet.emails)
|
||||
assertEquals(expected.bech32s, urlSet.bech32s)
|
||||
assertEquals(expected.blossomUris, urlSet.blossomUris)
|
||||
assertEquals(expected.relayUrls, urlSet.relayUrls)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -286,9 +289,14 @@ class UrlParserTest {
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testBlossom() =
|
||||
test(
|
||||
"blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth",
|
||||
Urls(withScheme = setOf("blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth")),
|
||||
)
|
||||
fun testBlossom() {
|
||||
val blossom = "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth"
|
||||
test(blossom, Urls(blossomUris = setOf(blossom)))
|
||||
}
|
||||
|
||||
@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 = setOf(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.isValid(): Boolean = length == PUBKEY_LENGTH && Hex.isHex(this)
|
||||
|
||||
const val PUBKEY_LENGTH = 64
|
||||
|
||||
const val EVENT_ID_LENGTH = 64
|
||||
|
||||
+2
-3
@@ -59,9 +59,8 @@ class BlossomServersEvent(
|
||||
|
||||
fun createTagArray(servers: List<String>): Array<Array<String>> =
|
||||
servers
|
||||
.map {
|
||||
arrayOf("server", it)
|
||||
}.plusElement(AltTag.assemble(ALT))
|
||||
.map { arrayOf("server", it) }
|
||||
.plusElement(AltTag.assemble(ALT))
|
||||
.toTypedArray()
|
||||
|
||||
suspend fun updateRelayList(
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipB7Blossom
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/**
|
||||
* 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 size Blob size in bytes for verification and progress display (`sz` param).
|
||||
*/
|
||||
@Stable
|
||||
data class BlossomUri(
|
||||
val sha256: HexKey,
|
||||
val extension: String,
|
||||
@@ -40,6 +43,17 @@ data class BlossomUri(
|
||||
val authors: List<HexKey>,
|
||||
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.
|
||||
* Server URLs are percent-encoded so that `&`, `=`, and `#` inside them
|
||||
@@ -65,7 +79,6 @@ data class BlossomUri(
|
||||
|
||||
companion object {
|
||||
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
|
||||
@@ -93,7 +106,7 @@ data class BlossomUri(
|
||||
extension = "bin"
|
||||
}
|
||||
|
||||
if (!SHA256_REGEX.matches(sha256)) return null
|
||||
if (sha256.length != 64 || !Hex.isHex64(sha256)) return null
|
||||
|
||||
// Collect repeated query parameters.
|
||||
val servers = mutableListOf<String>()
|
||||
|
||||
@@ -21,11 +21,15 @@
|
||||
package com.vitorpamplona.quartz.utils
|
||||
|
||||
import kotlinx.coroutines.CancellableContinuation
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.select
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -219,23 +223,24 @@ suspend fun <T> anyAsync(
|
||||
// Use select to wait for the first deferred to complete with 'true'
|
||||
val foundTrue =
|
||||
withTimeoutOrNull(timeoutMillis) {
|
||||
select {
|
||||
deferredResults.forEach { deferred ->
|
||||
// For each deferred, if it completes and its result is 'true',
|
||||
// this branch of the select expression will be chosen.
|
||||
deferred.onAwait { result ->
|
||||
if (result) {
|
||||
true // Return true from the select expression
|
||||
} else {
|
||||
// If a deferred completes with false, we don't want to
|
||||
// immediately end the select, so we return false, which
|
||||
// lets select continue waiting for other branches.
|
||||
false
|
||||
val remaining = deferredResults.toMutableList()
|
||||
var found = false
|
||||
|
||||
while (remaining.isNotEmpty() && !found) {
|
||||
val (winner, value) =
|
||||
select {
|
||||
remaining.forEach { deferred ->
|
||||
deferred.onAwait { value ->
|
||||
deferred to value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
remaining.remove(winner)
|
||||
if (value) found = true
|
||||
}
|
||||
}
|
||||
|
||||
found
|
||||
} ?: false
|
||||
|
||||
// Once select returns (either with true or after all deferreds complete/are cancelled),
|
||||
// cancel any remaining ongoing operations.
|
||||
@@ -243,5 +248,51 @@ suspend fun <T> anyAsync(
|
||||
// If foundTrue is false, it means all completed with false or were cancelled.
|
||||
deferredResults.forEach { it.cancel() } // Ensure all are cancelled.
|
||||
|
||||
return@coroutineScope foundTrue == true
|
||||
return@coroutineScope foundTrue
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a mapping function asynchronously on each input in the list.
|
||||
* Returns the first result as soon as the first mapping returns not null, cancelling all other ongoing operations.
|
||||
*
|
||||
* @param inputs A list of input objects to process.
|
||||
* @param map A suspend function that takes an input object and returns a Boolean.
|
||||
* @return True if any mapping function returns true, false otherwise.
|
||||
*/
|
||||
suspend fun <T, U> firstNotNullOrNullAsync(
|
||||
inputs: List<T>,
|
||||
timeoutMillis: Long = 30000,
|
||||
map: suspend (T) -> U?,
|
||||
): U? {
|
||||
if (inputs.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return withTimeoutOrNull(timeoutMillis) {
|
||||
val channel = Channel<U>(capacity = Channel.UNLIMITED)
|
||||
|
||||
val jobs =
|
||||
inputs.map { input ->
|
||||
launch(Dispatchers.IO) {
|
||||
val result = map(input)
|
||||
if (result != null) {
|
||||
channel.trySend(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close channel when all jobs complete (handles all-null case)
|
||||
launch {
|
||||
jobs.joinAll()
|
||||
channel.close()
|
||||
}
|
||||
|
||||
// Wait for first non-null result or null if channel closes
|
||||
val result = channel.receiveCatching().getOrNull()
|
||||
|
||||
// Cancel all remaining jobs
|
||||
jobs.forEach { it.cancel() }
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user