feat: add blockchain explorer settings page for OTS verification

Adds a new "Bitcoin Explorer (OTS)" settings page in App Settings,
modeled after the existing Namecoin settings page.

Users can configure a custom Mempool-compatible blockchain explorer
API URL for OpenTimestamps proof verification. When a custom URL is
set it takes priority over the automatic Tor-aware selection (mempool.space
when Tor is active, blockstream.info otherwise).

Changes:
- OtsSettings: immutable data class for explorer config
- OtsSharedPreferences: DataStore-backed persistence (global/app-level)
- TorAwareOkHttpOtsResolverBuilder: accepts customExplorerUrl lambda
- OtsSettingsSection: UI section with active explorer display, URL input,
  known-explorers reference list, and reset button
- OtsSettingsScreen: full screen wrapping the section
- Routes.OtsSettings: new navigation route
- AllSettingsScreen: new entry under App Settings
- AppModules: wires otsPrefs and passes custom URL to resolver builder

https://claude.ai/code/session_01GX7k36uepGxU3U1fBKYFrx
This commit is contained in:
Claude
2026-03-12 09:35:16 +00:00
parent 548ce0a69d
commit 3e16d317fc
10 changed files with 653 additions and 6 deletions
@@ -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
@@ -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://")
}
}
}
@@ -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(
@@ -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<OtsSettings> = _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
}
}
@@ -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<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav) }
composableFromEnd<Route.OtsSettings> { OtsSettingsScreen(Amethyst.instance.otsPrefs, Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
@@ -51,6 +51,8 @@ sealed class Route {
@Serializable object NamecoinSettings : Route()
@Serializable object OtsSettings : Route()
@Serializable object Bookmarks : Route()
@Serializable object BookmarkGroups : Route()
@@ -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,
@@ -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() }
},
)
}
}
}
@@ -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<String?>(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,
)
}
}
+1
View File
@@ -1748,4 +1748,5 @@
<string name="select_all">Select All</string>
<string name="uptime">%1$d%% uptime</string>
<string name="namecoin_settings">Namecoin Settings</string>
<string name="ots_explorer_settings">Bitcoin Explorer (OTS)</string>
</resources>