Merge branch 'vitorpamplona:main' into kmp-completeness

This commit is contained in:
KotlinGeekDev
2026-03-13 01:12:49 +01:00
committed by GitHub
40 changed files with 1376 additions and 212 deletions
+11 -2
View File
@@ -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))
}
}
@@ -70,9 +70,9 @@ class BlossomServerListState(
val flow =
getBlossomServersListFlow()
.map { normalizeServers(it.note) }
.onStart { emit(normalizeServers(blossomListNote)) }
.flowOn(Dispatchers.IO)
.map {
normalizeServers(it.note)
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
@@ -0,0 +1,91 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.images
import androidx.compose.runtime.Stable
import coil3.ImageLoader
import coil3.Uri
import coil3.annotation.ExperimentalCoilApi
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.network.CacheStrategy
import coil3.network.ConcurrentRequestStrategy
import coil3.network.ConnectivityChecker
import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import okhttp3.Call
import kotlin.coroutines.cancellation.CancellationException
@Stable
class BlossomFetcher(
private val options: Options,
private val data: Uri,
private val blossomServerResolver: BlossomServerResolver,
private val networkFetcher: (url: String) -> Fetcher,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
println("BlossomFetcher: starting $data")
return try {
val urlResult = blossomServerResolver.findServers(data.toString())
println("BlossomFetcher: finished $data to ${urlResult?.serverUrl}")
networkFetcher(urlResult?.serverUrl ?: data.toString()).fetch()
} catch (e: Exception) {
if (e is CancellationException) throw e
println("BlossomFetcher: cancelled or error: $e $data")
null
}
}
@OptIn(ExperimentalCoilApi::class)
class Factory(
val blossomServerResolver: BlossomServerResolver,
val networkClient: (url: String) -> Call.Factory,
) : Fetcher.Factory<Uri> {
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
println("BlossomFetcher: PreFactory $data")
if (!isApplicable(data)) return null
println("BlossomFetcher: Factory $data")
return BlossomFetcher(options, data, blossomServerResolver) { url ->
NetworkFetcher(
url = url,
options = options,
networkClient = lazy { networkClient(url).asNetworkClient() },
diskCache = lazy { imageLoader.diskCache },
cacheStrategy = lazy { CacheStrategy.DEFAULT },
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
concurrentRequestStrategy = lazy { ConcurrentRequestStrategy.UNCOORDINATED },
)
}
}
private fun isApplicable(data: Uri): Boolean = data.scheme?.lowercase() == "blossom"
}
}
@@ -43,6 +43,7 @@ import coil3.svg.SvgDecoder
import coil3.util.Logger
import coil3.video.VideoFrameDecoder
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Call
@@ -63,6 +64,7 @@ class ImageLoaderSetup {
app: Context,
diskCache: () -> DiskCache,
memoryCache: () -> MemoryCache,
blossomServerResolver: BlossomServerResolver,
callFactory: (url: String) -> Call.Factory,
) {
SingletonImageLoader.setUnsafe(
@@ -78,6 +80,7 @@ class ImageLoaderSetup {
add(VideoFrameDecoder.Factory())
add(Base64Fetcher.Factory)
add(BlurHashFetcher.Factory)
add(BlossomFetcher.Factory(blossomServerResolver, callFactory))
add(Base64Fetcher.BKeyer)
add(BlurHashFetcher.BKeyer)
add(OkHttpFactory(callFactory))
@@ -26,16 +26,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.InetSocketAddress
import java.net.Proxy
interface IHttpClientManager {
fun getHttpClient(useProxy: Boolean): OkHttpClient
fun getCurrentProxyPort(useProxy: Boolean): Int?
}
class DualHttpClientManager(
userAgent: String,
proxyPortProvider: StateFlow<Int?>,
@@ -79,18 +75,16 @@ class DualHttpClientManager(
} else {
defaultHttpClientWithoutProxy.value
}
fun getDynamicCallFactory(useProxy: Boolean) = DynamicCallFactory(useProxy, this)
}
object EmptyHttpClientManager : IHttpClientManager {
val rootOkHttpClient by lazy {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
}
override fun getHttpClient(useProxy: Boolean) = rootOkHttpClient
override fun getCurrentProxyPort(useProxy: Boolean) = null
/**
* the okhttp can change on the manager without affecting other systems.
*/
class DynamicCallFactory(
val useProxy: Boolean,
val manager: DualHttpClientManager,
) : Call.Factory {
override fun newCall(request: Request): Call = manager.getHttpClient(useProxy).newCall(request)
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
class DualHttpClientManagerForRelays(
userAgent: String,
proxyPortProvider: StateFlow<Int?>,
isMobileDataProvider: StateFlow<Boolean?>,
scope: CoroutineScope,
) : IHttpClientManager {
val factory = OkHttpClientFactoryForRelays(userAgent)
val defaultHttpClient: StateFlow<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
factory.buildHttpClient(proxy, mobile)
}.stateIn(
scope,
SharingStarted.WhileSubscribed(1000),
factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value),
)
val defaultHttpClientWithoutProxy: StateFlow<OkHttpClient> =
isMobileDataProvider
.map { mobile ->
factory.buildHttpClient(mobile)
}.stateIn(
scope,
SharingStarted.WhileSubscribed(1000),
factory.buildHttpClient(isMobileDataProvider.value),
)
fun getCurrentProxy(): Proxy? = defaultHttpClient.value.proxy
override fun getCurrentProxyPort(useProxy: Boolean): Int? =
if (useProxy) {
(getCurrentProxy()?.address() as? InetSocketAddress)?.port
} else {
null
}
override fun getHttpClient(useProxy: Boolean): OkHttpClient =
if (useProxy) {
defaultHttpClient.value
} else {
defaultHttpClientWithoutProxy.value
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import okhttp3.OkHttpClient
interface IHttpClientManager {
fun getHttpClient(useProxy: Boolean): OkHttpClient
fun getCurrentProxyPort(useProxy: Boolean): Int?
}
object EmptyHttpClientManager : IHttpClientManager {
val rootOkHttpClient by lazy {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
}
override fun getHttpClient(useProxy: Boolean) = rootOkHttpClient
override fun getCurrentProxyPort(useProxy: Boolean) = null
}
@@ -20,9 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.okhttp
import android.os.Build
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Dispatcher
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_IS_MOBILE
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_SOCKS_PORT
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_MOBILE_SECS
import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Companion.DEFAULT_TIMEOUT_ON_WIFI_SECS
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
@@ -32,53 +33,16 @@ class OkHttpClientFactory(
keyCache: EncryptionKeyCache,
val userAgent: String,
) {
companion object {
// by picking a random proxy port, the connection will fail as it should.
const val DEFAULT_SOCKS_PORT: Int = 9050
const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
private fun isEmulator(): Boolean =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.lowercase().contains("emulator") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.lowercase().contains("droid4x") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
"google_sdk" == Build.PRODUCT ||
Build.HARDWARE.contains("goldfish") ||
Build.HARDWARE.contains("ranchu") ||
Build.HARDWARE.contains("vbox86") ||
Build.HARDWARE.contains("nox") ||
Build.HARDWARE.contains("cuttlefish")
}
val logging = LoggingInterceptor()
// val logging = LoggingInterceptor()
val keyDecryptor = EncryptedBlobInterceptor(keyCache)
val myDispatcher =
Dispatcher().apply {
if (!isEmulator()) {
maxRequestsPerHost = 10
maxRequests = 1024
} else {
maxRequestsPerHost = 5
maxRequests = 256
Log.i("OkHttpClientFactory", "Emulator detected, using default maxRequests: 64.")
}
}
private val rootClient =
OkHttpClient
.Builder()
.dispatcher(myDispatcher)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.addNetworkInterceptor(logging)
// .addNetworkInterceptor(logging)
.addNetworkInterceptor(keyDecryptor)
.build()
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.okhttp
import android.os.Build
import com.vitorpamplona.quartz.utils.Log
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
import java.net.Proxy
import java.time.Duration
class OkHttpClientFactoryForRelays(
userAgent: String,
) {
companion object {
// by picking a random proxy port, the connection will fail as it should.
const val DEFAULT_SOCKS_PORT: Int = 9050
const val DEFAULT_IS_MOBILE: Boolean = false
const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10
const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30
private fun isEmulator(): Boolean =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.lowercase().contains("emulator") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.lowercase().contains("droid4x") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
(Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) ||
"google_sdk" == Build.PRODUCT ||
Build.HARDWARE.contains("goldfish") ||
Build.HARDWARE.contains("ranchu") ||
Build.HARDWARE.contains("vbox86") ||
Build.HARDWARE.contains("nox") ||
Build.HARDWARE.contains("cuttlefish")
}
val myDispatcher =
Dispatcher().apply {
if (!isEmulator()) {
maxRequestsPerHost = 10
maxRequests = 1024
} else {
maxRequestsPerHost = 5
maxRequests = 256
Log.i("OkHttpClientFactory", "Emulator detected, using default maxRequests: 64.")
}
}
private val rootClient =
OkHttpClient
.Builder()
.dispatcher(myDispatcher)
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
.build()
fun buildHttpClient(
proxy: Proxy?,
timeoutSeconds: Int,
): OkHttpClient {
val seconds = if (proxy != null) timeoutSeconds * 3 else timeoutSeconds
return rootClient
.newBuilder()
.proxy(proxy)
.connectTimeout(Duration.ofSeconds(seconds.toLong()))
.readTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.writeTimeout(Duration.ofSeconds(seconds.toLong() * 3))
.build()
}
fun buildHttpClient(
localSocksProxyPort: Int?,
isMobile: Boolean?,
): OkHttpClient =
buildHttpClient(
buildLocalSocksProxy(localSocksProxyPort),
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
)
fun buildHttpClient(isMobile: Boolean?): OkHttpClient =
buildHttpClient(
null,
buildTimeout(isMobile ?: DEFAULT_IS_MOBILE),
)
fun buildTimeout(isMobile: Boolean): Int =
if (isMobile) {
DEFAULT_TIMEOUT_ON_MOBILE_SECS
} else {
DEFAULT_TIMEOUT_ON_WIFI_SECS
}
fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT))
}
@@ -23,13 +23,12 @@ package com.vitorpamplona.amethyst.service.playback.diskCache
import android.annotation.SuppressLint
import android.content.Context
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
@SuppressLint("UnsafeOptInUsageError")
@@ -60,18 +59,18 @@ class VideoCache {
}
// This method should be called when proxy setting changes.
fun renewCacheFactory(client: OkHttpClient) {
fun renewCacheFactory(dataSourceFactory: DataSource.Factory) {
cacheDataSourceFactory =
CacheDataSource
.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(client))
.setUpstreamDataSourceFactory(dataSourceFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
fun get(client: OkHttpClient): CacheDataSource.Factory {
fun get(dataSourceFactory: DataSource.Factory): CacheDataSource.Factory {
// Renews the factory because OkHttpMight have changed.
renewCacheFactory(client)
renewCacheFactory(dataSourceFactory)
return cacheDataSourceFactory
}
@@ -22,28 +22,26 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming
import okhttp3.OkHttpClient
/**
* HLS LiveStreams cannot use cache.
*/
@UnstableApi
class CustomMediaSourceFactory(
okHttpClient: OkHttpClient,
videoCache: VideoCache,
dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private var cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(
Amethyst.instance.videoCache.get(okHttpClient),
)
DefaultMediaSourceFactory(videoCache.get(dataSourceFactory))
private var nonCachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
DefaultMediaSourceFactory(dataSourceFactory)
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
@@ -23,23 +23,25 @@ package com.vitorpamplona.amethyst.service.playback.playerPool
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlayer
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher
import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache
import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying
import okhttp3.OkHttpClient
@OptIn(UnstableApi::class)
class ExoPlayerBuilder(
val okHttp: OkHttpClient,
val videoCache: VideoCache,
val dataSourceFactory: DataSource.Factory,
) {
fun build(context: Context): ExoPlayer =
ExoPlayer
.Builder(context)
.apply {
setMediaSourceFactory(CustomMediaSourceFactory(okHttp))
setMediaSourceFactory(CustomMediaSourceFactory(videoCache, dataSourceFactory))
}.build()
.apply {
addListener(AspectRatioCacher(MediaAspectRatioCache))
@@ -29,8 +29,8 @@ import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import com.google.common.util.concurrent.Futures
@@ -41,7 +41,6 @@ import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
class SessionListener(
val session: MediaSession,
@@ -57,7 +56,7 @@ class SessionListener(
*/
class MediaSessionPool(
val exoPlayerPool: ExoPlayerPool,
val okHttpClient: OkHttpClient,
val dataSourceFactory: DataSource.Factory,
val appContext: Context,
val reset: (MediaSession, Boolean) -> Unit,
) {
@@ -101,7 +100,7 @@ class MediaSessionPool(
DataSourceBitmapLoader
.Builder(context)
.setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get())
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
.setDataSourceFactory(dataSourceFactory)
.build(),
)
setId(id)
@@ -20,35 +20,69 @@
*/
package com.vitorpamplona.amethyst.service.playback.service
import android.net.Uri
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.okhttp.DynamicCallFactory
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerBuilder
import com.vitorpamplona.amethyst.service.playback.playerPool.ExoPlayerPool
import com.vitorpamplona.amethyst.service.playback.playerPool.MediaSessionPool
import com.vitorpamplona.amethyst.service.playback.playerPool.SimultaneousPlaybackCalculator
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.quartz.utils.Log
import okhttp3.OkHttpClient
import kotlinx.coroutines.runBlocking
class PlaybackService : MediaSessionService() {
private var poolNoProxy: MediaSessionPool? = null
private var poolWithProxy: MediaSessionPool? = null
@OptIn(UnstableApi::class)
fun newPool(okHttp: OkHttpClient): MediaSessionPool =
MediaSessionPool(
fun newPool(
videoCache: VideoCache,
okHttpClient: DynamicCallFactory,
blossomServerResolver: BlossomServerResolver,
): MediaSessionPool {
val dataSourceFactory = OkHttpDataSource.Factory(okHttpClient)
val resolvingDataSourceFactory: DataSource.Factory =
ResolvingDataSource.Factory(
dataSourceFactory,
ResolvingDataSource.Resolver { dataSpec: DataSpec ->
val originalUri: Uri = dataSpec.uri
val scheme = originalUri.scheme
if (scheme != null && blossomServerResolver.canResolve(scheme)) {
val serverUrl =
runBlocking {
blossomServerResolver.findServers(originalUri.toString())
}
if (serverUrl != null) {
return@Resolver dataSpec.withUri(serverUrl.serverUrl.toUri())
}
}
dataSpec
},
)
return MediaSessionPool(
exoPlayerPool =
ExoPlayerPool(
ExoPlayerBuilder(okHttp),
ExoPlayerBuilder(videoCache, resolvingDataSourceFactory),
poolSize = SimultaneousPlaybackCalculator.max(applicationContext),
),
okHttpClient = okHttp,
dataSourceFactory = resolvingDataSourceFactory,
appContext = applicationContext,
reset = { session, keepPlaying ->
(session.player as ExoPlayer).apply {
@@ -58,6 +92,7 @@ class PlaybackService : MediaSessionService() {
}
},
)
}
@OptIn(UnstableApi::class)
fun lazyPool(proxyPort: Int): MediaSessionPool {
@@ -65,22 +100,23 @@ class PlaybackService : MediaSessionService() {
// no proxy
poolNoProxy?.let { return it }
// creates new
return newPool(Amethyst.instance.okHttpClients.getHttpClient(false)).also { poolNoProxy = it }
} else {
poolWithProxy?.let { pool ->
// with proxy, check if the port is the same.
val okHttp = Amethyst.instance.okHttpClients.getHttpClient(true)
if (okHttp.proxy != null && okHttp.proxy == pool.exoPlayerPool.builder.okHttp.proxy) {
return pool
}
val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(false)
val videoCache = Amethyst.instance.videoCache
val blossomServerResolver = Amethyst.instance.blossomResolver
pool.destroy()
return newPool(okHttp).also { poolWithProxy = it }
}
// creates new
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolNoProxy = it }
} else {
poolWithProxy?.let { return it }
// creates brand new
return newPool(Amethyst.instance.okHttpClients.getHttpClient(true)).also { poolWithProxy = it }
// proxy port can change without affecting the pool because
// the choice of okhttp is resolved in newCall
val okHttpClient = Amethyst.instance.okHttpClients.getDynamicCallFactory(true)
val videoCache = Amethyst.instance.videoCache
val blossomServerResolver = Amethyst.instance.blossomResolver
return newPool(videoCache, okHttpClient, blossomServerResolver).also { poolWithProxy = it }
}
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -45,7 +45,7 @@ import okhttp3.OkHttpClient
class RelayProxyClientConnector(
val torEvaluator: StateFlow<TorRelayEvaluation>,
val okHttpClients: DualHttpClientManager,
val okHttpClients: DualHttpClientManagerForRelays,
val connManager: ConnectivityManager,
val torManager: TorManager,
val client: INostrClient,
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import androidx.collection.LruCache
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import com.vitorpamplona.quartz.utils.firstNotNullOrNullAsync
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import kotlin.collections.toTypedArray
import kotlin.let
class BlossomServerResolver(
val loggedInUsers: () -> List<HexKey>,
val blossomServers: (Set<Address>) -> List<Flow<BlossomServersEvent>>,
val httpClientBuilder: IRoleBasedHttpClientBuilder,
) {
val blossomHitCache: ServerHeadCache = ServerHeadCache()
val uriToUrlCache = LruCache<String, BlossomUriServer>(200)
class BlossomUriServer(
val uri: BlossomUri,
val serverUrl: String,
)
fun cachedFindServer(uriStr: String): BlossomUriServer? = uriToUrlCache[uriStr]
suspend fun findServers(uriStr: String): BlossomUriServer? {
uriToUrlCache[uriStr]?.let { return it }
val result =
withTimeoutOrNull(10000) {
findServersInner(uriStr)
}
if (result != null) {
uriToUrlCache.put(uriStr, result)
}
return result
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
val uri = BlossomUri.parse(uriStr) ?: return null
val expectedMimeType = mimeTypeMap[uri.extension]
val filename = uri.filename()
if (uri.servers.isNotEmpty()) {
val workingUrl = firstWorkingUrl(uri.servers, filename, expectedMimeType, uri.size)
if (workingUrl != null) {
return BlossomUriServer(uri, workingUrl)
}
}
val blossomServerConfigNeeded = mutableSetOf<Address>()
uri.authors.forEach {
if (it.isValid()) {
blossomServerConfigNeeded.add(BlossomServersEvent.createAddress(it))
}
}
loggedInUsers().forEach {
blossomServerConfigNeeded.add(BlossomServersEvent.createAddress(it))
}
val flows =
blossomServers(blossomServerConfigNeeded)
.map { blossomServerFlow ->
blossomServerFlow.transformLatest {
val servers = it.servers()
if (servers.isNotEmpty()) {
firstWorkingUrl(servers, filename, expectedMimeType, uri.size)?.let { serverUrl ->
emit(serverUrl)
}
}
}
}.toTypedArray()
if (flows.isNotEmpty()) {
val serverResult = merge(*flows).first()
return BlossomUriServer(uri, serverResult)
}
return null
}
private suspend fun firstWorkingUrl(
servers: List<String>,
filename: String,
expectedMimeType: String?,
expectedSize: Long?,
): String? =
firstNotNullOrNullAsync(servers, 10000) {
blossomHitCache.urlIfServerHasFile(it, filename, expectedMimeType, expectedSize) { url ->
client(url, expectedMimeType)
}
}
fun client(
url: String,
mimeType: String?,
): OkHttpClient =
if (mimeType == null) {
httpClientBuilder.okHttpClientForPreview(url)
} else if (mimeType.startsWith("audio/") || mimeType.startsWith("video/")) {
httpClientBuilder.okHttpClientForVideo(url)
} else if (mimeType.startsWith("image/")) {
httpClientBuilder.okHttpClientForImage(url)
} else {
httpClientBuilder.okHttpClientForPreview(url)
}
fun canResolve(scheme: String) = scheme == SCHEME
companion object {
const val SCHEME = "blossom"
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import kotlin.coroutines.cancellation.CancellationException
fun openBlossomUriAsIntent(
context: Context,
blossomUri: String,
onError: (Int, Int) -> Unit,
) {
try {
val intent = Intent(Intent.ACTION_VIEW, blossomUri.toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(R.string.no_blossom_apps_found_title, R.string.no_blossom_apps_found_description)
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import androidx.collection.LruCache
import kotlinx.coroutines.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
class ServerHeadCache {
val cache = LruCache<String, HasFile>(200)
sealed interface HasFile {
object NoFile : HasFile
class TypeAndSize(
val mimeType: String,
val size: Long,
) : HasFile
}
suspend fun getFileSizeBytes(
url: String,
client: (url: String) -> OkHttpClient,
): HasFile {
cache[url]?.let { return it }
try {
// Build a HEAD request instead of GET
val request =
Request
.Builder()
.url(url)
.head() // Specifies the HEAD method
.build()
client(url).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
cache.put(url, HasFile.NoFile)
return HasFile.NoFile
}
// Retrieve the "Content-Length" header
val contentLength = response.header("Content-Length")?.toLongOrNull()
val mimeType = response.header("Content-Type")?.toMediaType()?.toString()
if (contentLength != null && mimeType != null) {
val result = HasFile.TypeAndSize(mimeType, contentLength)
cache.put(url, result)
return result
} else {
cache.put(url, HasFile.NoFile)
return HasFile.NoFile
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
cache.put(url, HasFile.NoFile)
return HasFile.NoFile
}
}
suspend fun urlIfServerHasFile(
server: String,
filename: String,
expectedMimeType: String?,
expectedSize: Long?,
client: (url: String) -> OkHttpClient,
): String? {
val url =
if (server.startsWith("http")) {
server.removeSuffix("/") + "/" + filename
} else {
"https://" + server.removeSuffix("/") + "/" + filename
}
val result = getFileSizeBytes(url, client)
if (result is HasFile.TypeAndSize) {
if (expectedSize == null && expectedMimeType == null) {
// any match goes
return url
} else {
if (result.size == expectedSize) {
return url
}
if (expectedSize == null && result.size > 0 && result.mimeType == expectedMimeType) {
return url
}
}
}
return null
}
}
@@ -21,24 +21,32 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
@Composable
fun ClickableUrl(
urlText: String,
url: String,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
val context = LocalContext.current
ClickableTextPrimary(
text = urlText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
overflow = TextOverflow.MiddleEllipsis,
onClick = {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
uri.openUri(doubleCheckedUrl)
if (url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, url, onError)
} else {
runCatching {
val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
uri.openUri(doubleCheckedUrl)
}
}
},
)
@@ -144,7 +144,7 @@ fun ImageGallery(
images.words
.mapNotNull { segment ->
val imageUrl = segment.segmentText
state.imagesForPager[imageUrl] as? MediaUrlImage
state.mediaForPager[imageUrl] as? MediaUrlImage
}.toImmutableList()
Column(modifier = modifier.padding(vertical = Size10dp)) {
@@ -76,7 +76,7 @@ fun LoadUrlPreviewDirect(
is UrlPreviewState.Loading -> {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(url)
DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
}
}
@@ -82,7 +82,7 @@ fun MyAsyncImage(
LoadingAnimation(Size40dp, Size6dp)
}
} else {
DisplayUrlWithLoadingSymbol(imageUrl)
DisplayUrlWithLoadingSymbol(imageUrl, accountViewModel.toastManager::toast)
}
}
}
@@ -47,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
@@ -54,18 +55,21 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
import com.vitorpamplona.amethyst.commons.richtext.EmailSegment
import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment
@@ -93,6 +97,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -112,6 +117,7 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -170,6 +176,10 @@ fun RenderStrangeNamePreview() {
ClickableRelayUrl(word.segmentText, EmptyNav())
}
is BlossomUriSegment -> {
ClickableRelayUrl(word.segmentText, EmptyNav())
}
is SchemelessUrlSegment -> {
NoProtocolUrlRenderer(word.segmentText)
}
@@ -500,6 +510,8 @@ private fun RenderWordWithoutPreview(
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
}
}
@@ -532,19 +544,91 @@ private fun RenderWordWithPreview(
is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav)
is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText)
}
}
@Composable
fun BlossomUriRenderer(
word: String,
state: RichTextViewerState,
callbackUri: String? = null,
accountViewModel: AccountViewModel,
) {
val isMedia = state.mediaForPager.contains(word)
if (isMedia) {
ZoomableContentView(word, state, accountViewModel)
} else {
val serverResultState =
remember(word) {
mutableStateOf(Amethyst.instance.blossomResolver.cachedFindServer(word))
}
if (serverResultState.value == null) {
LaunchedEffect(word) {
serverResultState.value = Amethyst.instance.blossomResolver.findServers(word)
}
}
val serverResult = serverResultState.value
if (serverResult != null && serverResult.serverUrl.isNotBlank()) {
LoadUrlPreview(serverResult.serverUrl, serverResult.uri.filename(), callbackUri, accountViewModel)
} else {
ClickableBlossomUri(word, accountViewModel)
}
}
}
@Composable
fun ClickableBlossomUri(
blossomUri: String,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
ClickableTextPrimary(
text = remember { BlossomUri.parse(blossomUri)?.filename() ?: blossomUri },
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
onClick = { openBlossomUriAsIntent(context, blossomUri, accountViewModel.toastManager::toast) },
)
}
@Composable
fun BlossomUriRendererNoPreview(
word: String,
accountViewModel: AccountViewModel,
) {
val serverResultState =
remember(word) {
mutableStateOf(Amethyst.instance.blossomResolver.cachedFindServer(word))
}
if (serverResultState.value == null) {
LaunchedEffect(word) {
serverResultState.value = Amethyst.instance.blossomResolver.findServers(word)
}
}
val serverResult = serverResultState.value
if (serverResult != null && serverResult.serverUrl.isNotBlank()) {
ClickableUrl(serverResult.uri.filename(), serverResult.serverUrl)
} else {
ClickableBlossomUri(word, accountViewModel)
}
}
@Composable
private fun ZoomableContentView(
word: String,
state: RichTextViewerState,
accountViewModel: AccountViewModel,
) {
state.imagesForPager[word]?.let {
state.mediaForPager[word]?.let {
Box(modifier = HalfVertPadding) {
ZoomableContentView(it, state.imageList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel)
ZoomableContentView(it, state.mediaList, roundedCorner = true, contentScale = ContentScale.FillWidth, accountViewModel)
}
}
}
@@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.InformationDialog
import com.vitorpamplona.amethyst.ui.note.BlankNote
@@ -286,7 +287,7 @@ fun LocalImageView(
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
}
}
}
@@ -408,7 +409,7 @@ fun UrlImageView(
}
} else {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(content)
DisplayUrlWithLoadingSymbol(content, accountViewModel.toastManager::toast)
}
}
}
@@ -580,7 +581,10 @@ fun WaitAndDisplay(content: @Composable (AnimatedVisibilityScope.() -> Unit)) {
}
@Composable
fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
fun DisplayUrlWithLoadingSymbol(
content: BaseMediaContent,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary
@@ -589,6 +593,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
val regularText = remember { SpanStyle(color = background) }
val clickableTextStyle = remember { SpanStyle(color = primary) }
val context = LocalContext.current
val annotatedTermsString =
remember {
buildAnnotatedString {
@@ -614,7 +620,13 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
val pressIndicator =
remember {
if (content is MediaUrlContent) {
Modifier.clickable { runCatching { uri.openUri(content.url) } }
Modifier.clickable {
if (content.url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, content.url, onError)
} else {
runCatching { uri.openUri(content.url) }
}
}
} else {
Modifier
}
@@ -628,10 +640,8 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
) {
Text(
text = annotatedTermsString,
modifier =
pressIndicator
.weight(1f, fill = false),
overflow = TextOverflow.Ellipsis,
modifier = pressIndicator.weight(1f, fill = false),
overflow = TextOverflow.MiddleEllipsis,
maxLines = 1,
)
InlineLoadingIcon()
@@ -639,7 +649,10 @@ fun DisplayUrlWithLoadingSymbol(content: BaseMediaContent) {
}
@Composable
fun DisplayUrlWithLoadingSymbol(url: String) {
fun DisplayUrlWithLoadingSymbol(
url: String,
onError: (Int, Int) -> Unit = { _, _ -> },
) {
val uri = LocalUriHandler.current
val primary = MaterialTheme.colorScheme.primary
@@ -654,7 +667,20 @@ fun DisplayUrlWithLoadingSymbol(url: String) {
}
}
val pressIndicator = remember { Modifier.clickable { runCatching { uri.openUri(url) } } }
val context = LocalContext.current
val pressIndicator =
remember {
Modifier.clickable {
if (url.startsWith("blossom:")) {
openBlossomUriAsIntent(context, url, onError)
} else {
runCatching {
uri.openUri(url)
}
}
}
}
Row(
modifier = Modifier.width(IntrinsicSize.Max),
@@ -292,7 +292,7 @@ private fun MyLoadUrlPreviewDirectFillWidth(
is UrlPreviewState.Loading -> {
WaitAndDisplay {
DisplayUrlWithLoadingSymbol(url)
DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast)
}
}
@@ -101,6 +101,8 @@ class AccountSessionManager(
private val _accountContent = MutableStateFlow<AccountState>(AccountState.Loading)
val accountContent = _accountContent.asStateFlow()
fun loggedInAccount() = (_accountContent.value as? AccountState.LoggedIn)?.account
fun loginWithDefaultAccountIfLoggedOff() {
// pulls account from storage.
if (_accountContent.value !is AccountState.LoggedIn) {
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
@Stable
class SuccessfulUploads(
val result: UploadOrchestrator.OrchestratorResult.ServerResult,
val caption: String?,
@@ -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
},
)
}
}
}
+8
View File
@@ -1081,6 +1081,9 @@
<string name="no_wallet_found_with_error">No Wallets found to pay a lightning invoice (Error: %1$s). Please install a Lightning wallet to use zaps</string>
<string name="no_wallet_found">No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps</string>
<string name="no_blossom_apps_found_title">Can\'t open Blossom links</string>
<string name="no_blossom_apps_found_description">Blossom apps were not found. Please install a local Blossom app to see this file</string>
<string name="hidden_words">Hidden Words</string>
<string name="hide_new_word_label">Hide new word or sentence</string>
<string name="automatically_show_profile_picture">Profile Picture</string>
@@ -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>
@@ -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>)
}
@@ -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",
)
@@ -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,
@@ -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,
)
}
}
@@ -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())
@@ -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
@@ -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
}
}