diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 5885c1e22..25db5b3f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder +import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences @@ -111,6 +112,11 @@ class AppModules( NamecoinSharedPreferences(appContext, applicationIOScope) } + // OTS blockchain explorer preferences (global, like Tor settings) + val otsPrefs by lazy { + OtsSharedPreferences(appContext, applicationIOScope) + } + // App services that should be run as soon as there are subscribers to their flows val locationManager = LocationState(appContext, applicationIOScope) val connManager = ConnectivityManager(appContext, applicationIOScope) @@ -179,6 +185,7 @@ class AppModules( roleBasedHttpClientBuilder::okHttpClientForMoney, roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations, otsBlockHeightCache, + customExplorerUrl = { otsPrefs.current.normalizedUrl() }, ) // Application-wide ots verification cache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsSettings.kt new file mode 100644 index 000000000..4b7ccb531 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/OtsSettings.kt @@ -0,0 +1,70 @@ +/* + * 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.model.nip03Timestamp + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer +import kotlinx.serialization.Serializable + +/** + * Immutable data class representing the current OTS blockchain explorer config. + * + * When a custom URL is configured, it is used instead of the automatic + * Tor-aware selection (Mempool when Tor is active, Blockstream otherwise). + * This gives users control over which explorer observes their OTS verifications. + */ +@Serializable +@Stable +data class OtsSettings( + /** + * Custom blockchain explorer base API URL. + * When null/blank, the default Tor-aware selection is used. + * Must be a Mempool-compatible REST API (e.g. https://mempool.space/api/). + */ + val customExplorerUrl: String? = null, +) { + /** True when the user has configured a custom explorer URL. */ + val hasCustomExplorer: Boolean get() = !customExplorerUrl.isNullOrBlank() + + /** + * Returns the normalized custom URL (trailing slash ensured) or null if not set. + */ + fun normalizedUrl(): String? { + val url = customExplorerUrl?.trim()?.takeIf { it.isNotBlank() } ?: return null + return if (url.endsWith("/")) url else "$url/" + } + + companion object { + val DEFAULT = OtsSettings() + + val KNOWN_EXPLORERS = + listOf( + OkHttpBitcoinExplorer.MEMPOOL_API_URL to "mempool.space (Tor-friendly)", + OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL to "blockstream.info", + ) + + fun isValidUrl(url: String): Boolean { + val trimmed = url.trim() + if (trimmed.isBlank()) return false + return trimmed.startsWith("http://") || trimmed.startsWith("https://") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt index d075edd0a..dcf34bd96 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip03Timestamp/TorAwareOkHttpOtsResolverBuilder.kt @@ -31,13 +31,15 @@ class TorAwareOkHttpOtsResolverBuilder( val okHttpClient: (url: String) -> OkHttpClient, val isTorActive: (url: String) -> Boolean, val cache: OtsBlockHeightCache, + val customExplorerUrl: () -> String? = { null }, ) : OtsResolverBuilder { - fun getAPI(usingTor: Boolean) = - if (usingTor) { - OkHttpBitcoinExplorer.MEMPOOL_API_URL - } else { - OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL - } + fun getAPI(usingTor: Boolean): String = + customExplorerUrl() + ?: if (usingTor) { + OkHttpBitcoinExplorer.MEMPOOL_API_URL + } else { + OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL + } override fun build(): OtsResolver = OtsResolver( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt new file mode 100644 index 000000000..315278f50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/OtsSharedPreferences.kt @@ -0,0 +1,103 @@ +/* + * 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.model.preferences + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsSettings +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.cancellation.CancellationException + +/** + * Persistent storage for [OtsSettings], following the same pattern as + * [NamecoinSharedPreferences]. + * + * Uses the app-wide [sharedPreferencesDataStore] so OTS explorer settings + * are global — not per-account. + */ +@Stable +class OtsSharedPreferences( + private val context: Context, + private val scope: CoroutineScope, +) { + companion object { + val KEY_CUSTOM_EXPLORER_URL = stringPreferencesKey("ots.customExplorerUrl") + } + + /** + * Current settings, loaded synchronously at init to avoid races. + */ + private val _settings = + MutableStateFlow( + runBlocking { loadFromDisk() ?: OtsSettings.DEFAULT }, + ) + val settings: StateFlow = _settings + + /** Synchronous snapshot — safe to call from resolver builder lambdas. */ + val current: OtsSettings get() = _settings.value + + // ── Mutators ─────────────────────────────────────────────────────── + + suspend fun setCustomExplorerUrl(url: String?) { + val normalized = url?.trim()?.takeIf { it.isNotBlank() } + persist(current.copy(customExplorerUrl = normalized)) + } + + suspend fun reset() { + persist(OtsSettings.DEFAULT) + } + + // ── Internal ─────────────────────────────────────────────────────── + + private suspend fun persist(settings: OtsSettings) { + _settings.value = settings + try { + context.sharedPreferencesDataStore.edit { prefs -> + if (settings.customExplorerUrl != null) { + prefs[KEY_CUSTOM_EXPLORER_URL] = settings.customExplorerUrl + } else { + prefs.remove(KEY_CUSTOM_EXPLORER_URL) + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("OtsPrefs", "Error writing DataStore: ${e.message}") + } + } + + private suspend fun loadFromDisk(): OtsSettings? = + try { + val prefs = context.sharedPreferencesDataStore.data.first() + val url = prefs[KEY_CUSTOM_EXPLORER_URL]?.takeIf { it.isNotBlank() } + OtsSettings(customExplorerUrl = url) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("OtsPrefs", "Error reading DataStore: ${e.message}") + null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 2bb646e98..fa59d0dc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -112,6 +112,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NamecoinSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen @@ -177,6 +178,7 @@ fun AppNavigation( composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } composableFromEnd { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) } + composableFromEnd { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b55b4dd06..24a8e8ad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -51,6 +51,8 @@ sealed class Route { @Serializable object NamecoinSettings : Route() + @Serializable object OtsSettings : Route() + @Serializable object Bookmarks : Route() @Serializable object BookmarkGroups : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 4dcf56f79..5e9e200a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.ThumbUp @@ -153,6 +154,13 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.NamecoinSettings) }, ) HorizontalDivider() + SettingsNavigationRow( + title = R.string.ots_explorer_settings, + icon = Icons.Outlined.Search, + tint = tint, + onClick = { nav.nav(Route.OtsSettings) }, + ) + HorizontalDivider() SettingsNavigationRow( title = R.string.ui_preferences, icon = Icons.Outlined.Settings, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt new file mode 100644 index 000000000..8bbf6d743 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsScreen.kt @@ -0,0 +1,83 @@ +/* + * 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.ui.screen.loggedIn.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.preferences.OtsSharedPreferences +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +import com.vitorpamplona.amethyst.ui.tor.TorType +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OtsSettingsScreen( + otsPrefs: OtsSharedPreferences, + torSettings: TorSettingsFlow, + nav: INav, +) { + val otsSettings by otsPrefs.settings.collectAsState() + val torType by torSettings.torType.collectAsState() + val moneyViaTor by torSettings.moneyOperationsViaTor.collectAsState() + val scope = rememberCoroutineScope() + + val isTorActiveForMoney = torType != TorType.OFF && moneyViaTor + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.ots_explorer_settings), nav::popBack) + }, + ) { + Column( + Modifier + .padding(it) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp), + ) { + OtsSettingsSection( + settings = otsSettings, + isTorActive = isTorActiveForMoney, + onSetCustomUrl = { url -> + scope.launch { otsPrefs.setCustomExplorerUrl(url) } + }, + onReset = { + scope.launch { otsPrefs.reset() } + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsSection.kt new file mode 100644 index 000000000..5f2e861ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/OtsSettingsSection.kt @@ -0,0 +1,369 @@ +/* + * 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.ui.screen.loggedIn.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsSettings +import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer + +/** + * Settings section for configuring the blockchain explorer used for OTS verification. + * + * Displays the currently active explorer and allows the user to provide a + * custom Mempool-compatible REST API URL. When a custom URL is set, it takes + * priority over the automatic Tor-aware selection. + * + * @param settings Current [OtsSettings] state + * @param isTorActive Whether Tor is currently active (determines default explorer shown) + * @param onSetCustomUrl Called with the trimmed URL string when user saves a custom URL + * @param onReset Called when user clears the custom URL and reverts to auto-selection + */ +@Composable +fun OtsSettingsSection( + settings: OtsSettings, + isTorActive: Boolean, + onSetCustomUrl: (String?) -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.padding(16.dp)) { + SectionHeaderOts() + + Spacer(Modifier.height(12.dp)) + + Text( + "OpenTimestamps proofs are verified by querying a Bitcoin blockchain explorer. " + + "By default, mempool.space is used when Tor is active, and blockstream.info otherwise. " + + "Set a custom URL to use your own self-hosted instance or a trusted third party.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + ActiveExplorerDisplay(settings = settings, isTorActive = isTorActive) + + Spacer(Modifier.height(12.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(12.dp)) + + CustomExplorerInput( + currentUrl = settings.customExplorerUrl, + onSave = onSetCustomUrl, + ) + + if (settings.hasCustomExplorer) { + Spacer(Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onReset) { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Reset to auto-select") + } + } + } + + Spacer(Modifier.height(12.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(12.dp)) + + KnownExplorersInfo() + } +} + +// ── Sub-composables ──────────────────────────────────────────────────── + +@Composable +private fun SectionHeaderOts() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Search, + contentDescription = null, + tint = Color(0xFFF7931A), // Bitcoin orange + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(10.dp)) + Column { + Text( + "Blockchain Explorer", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + "Used for OTS timestamp verification", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun ActiveExplorerDisplay( + settings: OtsSettings, + isTorActive: Boolean, +) { + val isCustom = settings.hasCustomExplorer + val activeUrl = + settings.normalizedUrl() + ?: if (isTorActive) OkHttpBitcoinExplorer.MEMPOOL_API_URL else OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Active explorer", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + if (isCustom) { + Text( + "CUSTOM", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFFF7931A), + modifier = + Modifier + .background( + Color(0xFFF7931A).copy(alpha = 0.1f), + RoundedCornerShape(4.dp), + ).padding(horizontal = 6.dp, vertical = 2.dp), + ) + } else { + Text( + if (isTorActive) "AUTO (TOR)" else "AUTO", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(6.dp)) + Text( + text = activeUrl, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(6.dp), + ).padding(horizontal = 10.dp, vertical = 6.dp), + ) + } +} + +@Composable +private fun CustomExplorerInput( + currentUrl: String?, + onSave: (String?) -> Unit, +) { + var input by rememberSaveable(currentUrl) { mutableStateOf(currentUrl ?: "") } + var validationError by remember { mutableStateOf(null) } + val kb = LocalSoftwareKeyboardController.current + + fun trySave() { + val trimmed = input.trim() + if (trimmed.isBlank()) { + // Empty input means clear the custom URL + validationError = null + onSave(null) + kb?.hide() + return + } + if (!OtsSettings.isValidUrl(trimmed)) { + validationError = "Must start with http:// or https://" + return + } + validationError = null + onSave(trimmed) + kb?.hide() + } + + Column { + Text( + "Custom explorer URL", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 6.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + OutlinedTextField( + value = input, + onValueChange = { + input = it + validationError = null + }, + label = { Text("Explorer API base URL") }, + placeholder = { Text("https://mempool.space/api/") }, + singleLine = true, + isError = validationError != null, + supportingText = + validationError?.let { err -> + { Text(err, color = MaterialTheme.colorScheme.error) } + }, + trailingIcon = + if (input.isNotBlank()) { + { + IconButton(onClick = { + input = "" + validationError = null + }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + modifier = Modifier.size(18.dp), + ) + } + } + } else { + null + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(8.dp), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { trySave() }), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + Spacer(Modifier.width(8.dp)) + TextButton( + onClick = { trySave() }, + modifier = Modifier.padding(top = 6.dp), + ) { + Text("Save") + } + } + } +} + +@Composable +private fun KnownExplorersInfo() { + Column { + Text( + "Known compatible explorers", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 6.dp), + ) + OtsSettings.KNOWN_EXPLORERS.forEach { (url, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .border( + 1.dp, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + RoundedCornerShape(6.dp), + ).padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + Text( + text = url, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Spacer(Modifier.height(4.dp)) + Text( + "Any Mempool-compatible REST API is supported (e.g. self-hosted mempool.space).", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 799135f7d..5bad7ddf9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1748,4 +1748,5 @@ Select All %1$d%% uptime Namecoin Settings + Bitcoin Explorer (OTS)