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.accountsCache.AccountCacheState
|
||||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
|
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
|
||||||
import com.vitorpamplona.amethyst.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
|
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.nip11RelayInfo.Nip11CachedRetriever
|
||||||
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
||||||
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
|
||||||
@@ -111,6 +112,11 @@ class AppModules(
|
|||||||
NamecoinSharedPreferences(appContext, applicationIOScope)
|
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
|
// App services that should be run as soon as there are subscribers to their flows
|
||||||
val locationManager = LocationState(appContext, applicationIOScope)
|
val locationManager = LocationState(appContext, applicationIOScope)
|
||||||
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
||||||
@@ -179,6 +185,7 @@ class AppModules(
|
|||||||
roleBasedHttpClientBuilder::okHttpClientForMoney,
|
roleBasedHttpClientBuilder::okHttpClientForMoney,
|
||||||
roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations,
|
roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations,
|
||||||
otsBlockHeightCache,
|
otsBlockHeightCache,
|
||||||
|
customExplorerUrl = { otsPrefs.current.normalizedUrl() },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Application-wide ots verification cache
|
// 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 okHttpClient: (url: String) -> OkHttpClient,
|
||||||
val isTorActive: (url: String) -> Boolean,
|
val isTorActive: (url: String) -> Boolean,
|
||||||
val cache: OtsBlockHeightCache,
|
val cache: OtsBlockHeightCache,
|
||||||
|
val customExplorerUrl: () -> String? = { null },
|
||||||
) : OtsResolverBuilder {
|
) : OtsResolverBuilder {
|
||||||
fun getAPI(usingTor: Boolean) =
|
fun getAPI(usingTor: Boolean): String =
|
||||||
if (usingTor) {
|
customExplorerUrl()
|
||||||
OkHttpBitcoinExplorer.MEMPOOL_API_URL
|
?: if (usingTor) {
|
||||||
} else {
|
OkHttpBitcoinExplorer.MEMPOOL_API_URL
|
||||||
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
|
} else {
|
||||||
}
|
OkHttpBitcoinExplorer.BLOCKSTREAM_API_URL
|
||||||
|
}
|
||||||
|
|
||||||
override fun build(): OtsResolver =
|
override fun build(): OtsResolver =
|
||||||
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.AllSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
|
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.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.ReactionsSettingsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
|
||||||
@@ -177,6 +178,7 @@ fun AppNavigation(
|
|||||||
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
|
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
|
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
|
||||||
composableFromEnd<Route.NamecoinSettings> { NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, 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.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object NamecoinSettings : Route()
|
@Serializable object NamecoinSettings : Route()
|
||||||
|
|
||||||
|
@Serializable object OtsSettings : Route()
|
||||||
|
|
||||||
@Serializable object Bookmarks : Route()
|
@Serializable object Bookmarks : Route()
|
||||||
|
|
||||||
@Serializable object BookmarkGroups : 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.CloudUpload
|
||||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||||
import androidx.compose.material.icons.outlined.Key
|
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.Security
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material.icons.outlined.ThumbUp
|
import androidx.compose.material.icons.outlined.ThumbUp
|
||||||
@@ -153,6 +154,13 @@ fun AllSettingsScreen(
|
|||||||
onClick = { nav.nav(Route.NamecoinSettings) },
|
onClick = { nav.nav(Route.NamecoinSettings) },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = R.string.ots_explorer_settings,
|
||||||
|
icon = Icons.Outlined.Search,
|
||||||
|
tint = tint,
|
||||||
|
onClick = { nav.nav(Route.OtsSettings) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = R.string.ui_preferences,
|
title = R.string.ui_preferences,
|
||||||
icon = Icons.Outlined.Settings,
|
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="select_all">Select All</string>
|
||||||
<string name="uptime">%1$d%% uptime</string>
|
<string name="uptime">%1$d%% uptime</string>
|
||||||
<string name="namecoin_settings">Namecoin Settings</string>
|
<string name="namecoin_settings">Namecoin Settings</string>
|
||||||
|
<string name="ots_explorer_settings">Bitcoin Explorer (OTS)</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user