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:
@@ -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://")
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -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(
|
||||
|
||||
+103
@@ -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()
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+83
@@ -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() }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+369
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user