From 117c903e7ca7a8b93f34a62f5216b7bbf849da0f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Mar 2026 09:46:07 +0000 Subject: [PATCH 1/6] feat: add missing translation settings to Translation settings menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the translateTo and languagePreferences fields from AccountLanguagePreferences in the Translation settings screen (UserSettingsScreen), which previously only showed the dontTranslateFrom setting. New settings added: - Translate To: dropdown to select the target translation language from the device's configured locales, with a checkmark on the currently selected language. - Language Display Preferences: for each saved language-pair preference (source → target), a dropdown to choose which language to show first; only visible when at least one preference exists. https://claude.ai/code/session_01BheCJbDDZDAAYo6GAJBtFf --- .../loggedIn/settings/UserSettingsScreen.kt | 180 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 5 + 2 files changed, 185 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt index 79c685f82..3237243e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -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 + }, + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 799135f7d..3ec7aaf35 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1500,6 +1500,11 @@ Don\'t Translate From Languages shown here will not be translated. Select a language to remove it and have it translated again. + Translate To + Choose the language to translate content into. + Language Display Preferences + For each translated language pair, choose which language to show first. + %1$s → %2$s Pause Play Open dropdown menu From d4dd5a65e6a56ce4239ca33a98c82489ea657526 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 12 Mar 2026 18:56:17 -0400 Subject: [PATCH 2/6] anySync was returning either on true or false. it was not doing what it was supposed to do which is returning after the first true. --- .../quartz/utils/ParallelUtils.kt | 81 +++++++++++++++---- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ParallelUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ParallelUtils.kt index 0ffae3d04..1330c303a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ParallelUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/ParallelUtils.kt @@ -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 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 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 firstNotNullOrNullAsync( + inputs: List, + timeoutMillis: Long = 30000, + map: suspend (T) -> U?, +): U? { + if (inputs.isEmpty()) { + return null + } + + return withTimeoutOrNull(timeoutMillis) { + val channel = Channel(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 + } +} From fa67030b477005629eca97b5ff78072b1389744f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 12 Mar 2026 18:57:46 -0400 Subject: [PATCH 3/6] Adds support for BUD-10 Blossom URIs --- .../com/vitorpamplona/amethyst/AppModules.kt | 76 +++++++-- .../nipB7Blossom/BlossomServerListState.kt | 6 +- .../amethyst/service/images/BlossomFetcher.kt | 91 +++++++++++ .../service/images/ImageLoaderSetup.kt | 3 + .../service/okhttp/DualHttpClientManager.kt | 30 ++-- .../okhttp/DualHttpClientManagerForRelays.kt | 75 +++++++++ .../service/okhttp/IHttpClientManager.kt | 43 +++++ .../service/okhttp/OkHttpClientFactory.kt | 48 +----- .../okhttp/OkHttpClientFactoryForRelays.kt | 116 ++++++++++++++ .../service/playback/diskCache/VideoCache.kt | 11 +- .../playerPool/CustomMediaSourceFactory.kt | 14 +- .../playback/playerPool/ExoPlayerBuilder.kt | 8 +- .../playback/playerPool/MediaSessionPool.kt | 7 +- .../playback/service/PlaybackService.kt | 72 ++++++--- .../relayClient/RelayProxyClientConnector.kt | 4 +- .../blossom/bud10/BlossomServerResolver.kt | 150 ++++++++++++++++++ .../blossom/bud10/OpenBlossomUriAsIntent.kt | 43 +++++ .../uploads/blossom/bud10/ServerHeadCache.kt | 114 +++++++++++++ .../amethyst/ui/components/ClickableUrl.kt | 16 +- .../amethyst/ui/components/ImageGallery.kt | 2 +- .../amethyst/ui/components/LoadUrlPreview.kt | 2 +- .../amethyst/ui/components/MyAsyncImage.kt | 2 +- .../amethyst/ui/components/RichTextViewer.kt | 88 +++++++++- .../ui/components/ZoomableContentView.kt | 46 ++++-- .../ui/note/creators/previews/PreviewUrl.kt | 2 +- .../ui/screen/AccountSessionManager.kt | 2 + .../send/upload/SuccessfulUploads.kt | 2 + amethyst/src/main/res/values/strings.xml | 3 + .../ComposeSubscriptionManager.kt | 41 ++++- .../commons/richtext/RichTextParser.kt | 80 +++++++--- .../richtext/RichTextParserSegments.kt | 9 +- .../amethyst/commons/richtext/UrlParser.kt | 31 ++-- .../commons/richtext/RichTextParserTest.kt | 30 ++-- .../commons/richtext/UrlParserTest.kt | 16 +- .../quartz/nip01Core/core/HexKey.kt | 2 + .../nipB7Blossom/BlossomServersEvent.kt | 5 +- .../quartz/nipB7Blossom/BlossomUri.kt | 17 +- 37 files changed, 1112 insertions(+), 195 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IHttpClientManager.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/OpenBlossomUriAsIntent.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 5885c1e22..e538d3fa4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 { + 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)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt index 83aad4732..b2d127f46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt new file mode 100644 index 000000000..327efa264 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlossomFetcher.kt @@ -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 { + 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" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index dc308f93a..22a71cd21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index ab415256f..b0c4d0ed2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -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, @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt new file mode 100644 index 000000000..76413155f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManagerForRelays.kt @@ -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, + isMobileDataProvider: StateFlow, + scope: CoroutineScope, +) : IHttpClientManager { + val factory = OkHttpClientFactoryForRelays(userAgent) + + val defaultHttpClient: StateFlow = + combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> + factory.buildHttpClient(proxy, mobile) + }.stateIn( + scope, + SharingStarted.WhileSubscribed(1000), + factory.buildHttpClient(proxyPortProvider.value, isMobileDataProvider.value), + ) + + val defaultHttpClientWithoutProxy: StateFlow = + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IHttpClientManager.kt new file mode 100644 index 000000000..0c7e07ea9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IHttpClientManager.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 82622f1e3..73a7ff78b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt new file mode 100644 index 000000000..1729027c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -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)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt index d59cac031..1f2866dd6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/VideoCache.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt index 51ff47f04..f04bb625c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 665530fc0..4ceb4497b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 48811b130..785efc5ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index b69eaefae..9b259e69c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -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 } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt index c72bde672..512f85a3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -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, - val okHttpClients: DualHttpClientManager, + val okHttpClients: DualHttpClientManagerForRelays, val connManager: ConnectivityManager, val torManager: TorManager, val client: INostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt new file mode 100644 index 000000000..8bf65ed8e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/BlossomServerResolver.kt @@ -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, + val blossomServers: (Set
) -> List>, + val httpClientBuilder: IRoleBasedHttpClientBuilder, +) { + val blossomHitCache: ServerHeadCache = ServerHeadCache() + val uriToUrlCache = LruCache(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
() + + 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, + 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" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/OpenBlossomUriAsIntent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/OpenBlossomUriAsIntent.kt new file mode 100644 index 000000000..d494d8ea6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/OpenBlossomUriAsIntent.kt @@ -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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt new file mode 100644 index 000000000..4653c50af --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/bud10/ServerHeadCache.kt @@ -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(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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt index b0fe1b974..86e111ac2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt @@ -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) + } } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt index 1a815ac60..450125d2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ImageGallery.kt @@ -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)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt index d620de76a..b99fb85e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/LoadUrlPreview.kt @@ -76,7 +76,7 @@ fun LoadUrlPreviewDirect( is UrlPreviewState.Loading -> { WaitAndDisplay { - DisplayUrlWithLoadingSymbol(url) + DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt index 9c29244c4..13d94ae4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt @@ -82,7 +82,7 @@ fun MyAsyncImage( LoadingAnimation(Size40dp, Size6dp) } } else { - DisplayUrlWithLoadingSymbol(imageUrl) + DisplayUrlWithLoadingSymbol(imageUrl, accountViewModel.toastManager::toast) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 1dd39aa23..fe147eb19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index e998711ae..e9f22e2ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt index f3a1c3956..8f4b6ac89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/previews/PreviewUrl.kt @@ -292,7 +292,7 @@ private fun MyLoadUrlPreviewDirectFillWidth( is UrlPreviewState.Loading -> { WaitAndDisplay { - DisplayUrlWithLoadingSymbol(url) + DisplayUrlWithLoadingSymbol(url, accountViewModel.toastManager::toast) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 3c8dd191e..3549f612c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -101,6 +101,8 @@ class AccountSessionManager( private val _accountContent = MutableStateFlow(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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt index cc510cc24..c88223ab0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/SuccessfulUploads.kt @@ -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?, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 799135f7d..149a5c53d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1081,6 +1081,9 @@ No Wallets found to pay a lightning invoice (Error: %1$s). Please install a Lightning wallet to use zaps No Wallets found to pay a lightning invoice. Please install a Lightning wallet to use zaps + Can\'t open Blossom links + Blossom apps were not found. Please install a local Blossom app to see this file + Hidden Words Hide new word or sentence Profile Picture diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt index 9a5465811..b7787ff5c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/composeSubscriptionManagers/ComposeSubscriptionManager.kt @@ -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 : ComposeSubscriptionManagerControls { +abstract class ComposeSubscriptionManager : + ComposeSubscriptionManagerControls, + Subscribable { private var composeSubscriptions: ConcurrentHashMap = 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 : 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 : ComposeSubscriptionManagerControl invalidateKeys() } + override fun subscribe(query: List) { + 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) { + if (query.isEmpty()) return + + query.forEach { + composeSubscriptions.remove(it) + } + + invalidateKeys() + } + fun allKeys() = composeSubscriptions.keys } + +interface Subscribable { + // This is called by main. Keep it really fast. + fun subscribe(query: T?) + + fun unsubscribe(query: T?) + + fun subscribe(query: List) + + fun unsubscribe(query: List) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 7f39960b6..c1dd4975a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -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() } - 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 = + 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", + ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index b56a47b19..79de431fd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -28,8 +28,8 @@ import kotlinx.collections.immutable.ImmutableMap @Immutable class RichTextViewerState( val urlSet: Urls, - val imagesForPager: ImmutableMap, - val imageList: ImmutableList, + val mediaForPager: ImmutableMap, + val mediaList: ImmutableList, val customEmoji: ImmutableMap, val paragraphs: ImmutableList, val tags: ImmutableListOfLists, @@ -143,6 +143,11 @@ class RelayUrlSegment( segment: String, ) : Segment(segment) +@Immutable +class BlossomUriSegment( + segment: String, +) : Segment(segment) + @Immutable class SchemelessUrlSegment( segment: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt index df78d3457..9c6c6007b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParser.kt @@ -33,8 +33,10 @@ class Urls( val emails: Set = emptySet(), val bech32s: Set = emptySet(), val relayUrls: Set = emptySet(), + val blossomUris: Set = 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() val bech32 = mutableSetOf() val relays = mutableSetOf() + val blossom = mutableSetOf() - 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, ) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt index 0fc584c52..745576d5f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserTest.kt @@ -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()) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index ce27e3ef4..ffe539242 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.richtext +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri import kotlin.test.Test import kotlin.test.assertEquals @@ -286,9 +287,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 = mapOf(blossom to BlossomUri.parse(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 = mapOf(blossom to BlossomUri.parse(blossom)!!))) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index d3c4c29b3..e7fefe331 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt index 394f5576b..2b34a31a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServersEvent.kt @@ -59,9 +59,8 @@ class BlossomServersEvent( fun createTagArray(servers: List): Array> = servers - .map { - arrayOf("server", it) - }.plusElement(AltTag.assemble(ALT)) + .map { arrayOf("server", it) } + .plusElement(AltTag.assemble(ALT)) .toTypedArray() suspend fun updateRelayList( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt index b2c06663c..bbd34156d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUri.kt @@ -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, 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() From ebbd7e1a7785b9bf0be9031149c8677d1497598d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 12 Mar 2026 19:06:17 -0400 Subject: [PATCH 4/6] adds today's work to the change log --- CHANGELOG.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a6fc1505..dd274bcac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 21e29bbb3cb5cfa5aa5eaa102bebe9e99721f35d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 12 Mar 2026 19:42:46 -0400 Subject: [PATCH 5/6] Fixes test cases. --- .../vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index ffe539242..9fb2ef84a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri import kotlin.test.Test import kotlin.test.assertEquals @@ -289,12 +288,12 @@ class UrlParserTest { @Test fun testBlossom() { val blossom = "blossom:b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf?xs=cdn.satellite.earth" - test(blossom, Urls(blossomUris = mapOf(blossom to BlossomUri.parse(blossom)!!))) + 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 = mapOf(blossom to BlossomUri.parse(blossom)!!))) + test(blossom, Urls(blossomUris = setOf(blossom))) } } From 0d9f390cd90f243e5b47446990216f7d6aa8cc30 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 12 Mar 2026 19:44:51 -0400 Subject: [PATCH 6/6] missing tests fix --- .../vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt index 9fb2ef84a..b9613c5cd 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/UrlParserTest.kt @@ -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