Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: New Crowdin translations by GitHub Action update translations: CZ, DE, PT, SE fix: restore local WatchAndDisplayNip05Row in ShowUserSuggestionList refactor: remove Namecoin label from search results feat: show Namecoin label on .bit search results without ViewModel state refactor: remove Namecoin label from search results per review refactor: simplify .bit search resolution per review feedback feat: resolve bare .bit domains and show Namecoin resolution status in UI New Crowdin translations by GitHub Action New Crowdin translations by GitHub Action refactor: rename trustAllCerts → usePinnedTrustStore fix: harden TLS for GrapheneOS and security-conscious Android ROMs doc: verify .onion ElectrumX cert via Tor, document pinning fix: bypass cert pinning for .onion ElectrumX servers feat: TOFU cert pinning for custom ElectrumX servers feat: add Test Connection diagnostics to Namecoin settings fix: pin ElectrumX server certs instead of trust-all TrustManager # Conflicts: # amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt # amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
This commit is contained in:
@@ -165,14 +165,30 @@ class AppModules(
|
||||
// Offers easy methods to know when connections are happening through Tor or not
|
||||
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
|
||||
|
||||
val electrumXClient by lazy {
|
||||
Log.d("AppModules", "ElectrumXClient Init")
|
||||
val client =
|
||||
ElectrumXClient(
|
||||
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
||||
)
|
||||
applicationIOScope.launch {
|
||||
try {
|
||||
val pinnedCerts = namecoinPrefs.loadPinnedCerts()
|
||||
if (pinnedCerts.isNotEmpty()) {
|
||||
client.setDynamicCerts(pinnedCerts)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Non-fatal — defaults will still work
|
||||
}
|
||||
}
|
||||
client
|
||||
}
|
||||
|
||||
val namecoinResolver by
|
||||
lazy {
|
||||
Log.d("AppModules", "Namecoin Resolver Init")
|
||||
NamecoinNameResolver(
|
||||
electrumxClient =
|
||||
ElectrumXClient(
|
||||
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
||||
),
|
||||
electrumxClient = electrumXClient,
|
||||
serverListProvider = {
|
||||
// User-configured custom servers take priority
|
||||
namecoinPrefs.customServersOrNull
|
||||
|
||||
+49
-5
@@ -32,7 +32,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
@@ -58,17 +58,21 @@ class NamecoinSharedPreferences(
|
||||
companion object {
|
||||
val KEY_ENABLED = booleanPreferencesKey("namecoin.enabled")
|
||||
val KEY_CUSTOM_SERVERS = stringPreferencesKey("namecoin.customServers")
|
||||
val KEY_PINNED_CERTS = stringPreferencesKey("namecoin.pinnedCerts")
|
||||
}
|
||||
|
||||
/**
|
||||
* Current settings, loaded synchronously at init to avoid races.
|
||||
*/
|
||||
private val _settings =
|
||||
MutableStateFlow(
|
||||
runBlocking { loadFromDisk() ?: NamecoinSettings.DEFAULT },
|
||||
)
|
||||
private val _settings = MutableStateFlow(NamecoinSettings.DEFAULT)
|
||||
val settings: StateFlow<NamecoinSettings> = _settings
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
_settings.tryEmit(loadFromDisk() ?: NamecoinSettings.DEFAULT)
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous snapshot — safe to call from `serverListProvider` lambdas. */
|
||||
val current: NamecoinSettings get() = _settings.value
|
||||
|
||||
@@ -99,8 +103,48 @@ class NamecoinSharedPreferences(
|
||||
|
||||
suspend fun reset() {
|
||||
persist(NamecoinSettings.DEFAULT)
|
||||
clearPinnedCerts()
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a PEM-encoded certificate that the user accepted via Test Connection.
|
||||
* The cert is appended to the existing list and synced to the ElectrumXClient.
|
||||
*/
|
||||
suspend fun addPinnedCert(pem: String) {
|
||||
val existing = loadPinnedCertsFromDisk()
|
||||
val updated = (existing + pem).distinct()
|
||||
savePinnedCerts(updated)
|
||||
}
|
||||
|
||||
/** Load all user-pinned certs from disk (for startup sync). */
|
||||
suspend fun loadPinnedCerts(): List<String> = loadPinnedCertsFromDisk()
|
||||
|
||||
private suspend fun clearPinnedCerts() = savePinnedCerts(emptyList())
|
||||
|
||||
private suspend fun savePinnedCerts(certs: List<String>) {
|
||||
try {
|
||||
context.sharedPreferencesDataStore.edit { prefs ->
|
||||
prefs[KEY_PINNED_CERTS] = json.encodeToString(certs)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("NamecoinPrefs", "Error writing pinned certs: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPinnedCertsFromDisk(): List<String> =
|
||||
try {
|
||||
val prefs = context.sharedPreferencesDataStore.data.first()
|
||||
val certsJson = prefs[KEY_PINNED_CERTS]
|
||||
if (certsJson != null) {
|
||||
json.decodeFromString<List<String>>(certsJson)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
// ── Internal ───────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun persist(settings: NamecoinSettings) {
|
||||
|
||||
+6
-2
@@ -66,7 +66,7 @@ data class NamecoinSettings(
|
||||
* TLS is the default protocol. Append `:tcp` for plaintext
|
||||
* (useful for `.onion` addresses and local servers).
|
||||
*
|
||||
* `.onion` addresses automatically get `trustAllCerts = true`
|
||||
* `.onion` addresses automatically get `usePinnedTrustStore = true`
|
||||
* since certificate verification is meaningless over Tor.
|
||||
*/
|
||||
fun parseServerString(s: String): ElectrumxServer? {
|
||||
@@ -77,11 +77,15 @@ data class NamecoinSettings(
|
||||
if (host.isEmpty() || port <= 0 || port > 65535) return null
|
||||
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
|
||||
val isOnion = host.endsWith(".onion")
|
||||
// All custom servers use the pinned trust store. ElectrumX
|
||||
// servers almost universally use self-signed certs, so we
|
||||
// route them through our pinned SSLSocketFactory (hardcoded
|
||||
// defaults + TOFU-pinned certs + system CAs).
|
||||
return ElectrumxServer(
|
||||
host = host,
|
||||
port = port,
|
||||
useSsl = useSsl,
|
||||
trustAllCerts = isOnion || !useSsl,
|
||||
usePinnedTrustStore = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+17
-10
@@ -82,20 +82,27 @@ class UserSuggestionState(
|
||||
.map(::userSearchTermOrNull)
|
||||
.map { prefix ->
|
||||
if (prefix != null) {
|
||||
if (prefix.contains('@')) {
|
||||
// NIP-05 resolution: user@domain or bare .bit domain
|
||||
val nip05 =
|
||||
if (prefix.contains('@')) {
|
||||
Nip05Id.parse(prefix)
|
||||
} else if (prefix.endsWith(".bit", ignoreCase = true)) {
|
||||
Nip05Id("_", prefix.lowercase())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (nip05 != null) {
|
||||
runCatching {
|
||||
Nip05Id.parse(prefix)?.let { nip05 ->
|
||||
nip05Client.get(nip05)?.let { info ->
|
||||
val user = account.cache.checkGetOrCreateUser(info.pubkey)
|
||||
if (user != null) {
|
||||
info.relays.forEach {
|
||||
it.normalizeRelayUrlOrNull()?.let { relay ->
|
||||
account.cache.relayHints.addKey(user.pubkey(), relay)
|
||||
}
|
||||
nip05Client.get(nip05)?.let { info ->
|
||||
val user = account.cache.checkGetOrCreateUser(info.pubkey)
|
||||
if (user != null) {
|
||||
info.relays.forEach {
|
||||
it.normalizeRelayUrlOrNull()?.let { relay ->
|
||||
account.cache.relayHints.addKey(user.pubkey(), relay)
|
||||
}
|
||||
}
|
||||
user
|
||||
}
|
||||
user
|
||||
}
|
||||
}.getOrNull()
|
||||
} else if (prefix.startsWithAny(userUriPrefixes)) {
|
||||
|
||||
+18
-10
@@ -95,20 +95,28 @@ class SearchBarViewModel(
|
||||
searchTerm
|
||||
.debounce(400)
|
||||
.mapLatest { term ->
|
||||
if (term.contains('@')) {
|
||||
// NIP-05 resolution: user@domain or bare .bit domain
|
||||
val nip05 =
|
||||
if (term.contains('@')) {
|
||||
Nip05Id.parse(term)
|
||||
} else if (term.endsWith(".bit", ignoreCase = true)) {
|
||||
// Bare .bit domain → synthesize _@domain.bit
|
||||
Nip05Id("_", term.lowercase())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (nip05 != null) {
|
||||
runCatching {
|
||||
Nip05Id.parse(term)?.let { nip05 ->
|
||||
nip05Client.get(nip05)?.let { info ->
|
||||
val user = account.cache.checkGetOrCreateUser(info.pubkey)
|
||||
if (user != null) {
|
||||
info.relays.forEach {
|
||||
it.normalizeRelayUrlOrNull()?.let { relay ->
|
||||
account.cache.relayHints.addKey(user.pubkey(), relay)
|
||||
}
|
||||
nip05Client.get(nip05)?.let { info ->
|
||||
val user = account.cache.checkGetOrCreateUser(info.pubkey)
|
||||
if (user != null) {
|
||||
info.relays.forEach {
|
||||
it.normalizeRelayUrlOrNull()?.let { relay ->
|
||||
account.cache.relayHints.addKey(user.pubkey(), relay)
|
||||
}
|
||||
}
|
||||
user
|
||||
}
|
||||
user
|
||||
}
|
||||
}.getOrNull()
|
||||
} else if (term.startsWithAny(userUriPrefixes)) {
|
||||
|
||||
+16
-3
@@ -28,32 +28,38 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.preferences.NamecoinSharedPreferences
|
||||
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.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NamecoinSettingsScreen(nav: INav) {
|
||||
NamecoinSettingsScreen(Amethyst.instance.namecoinPrefs, nav)
|
||||
NamecoinSettingsScreen(
|
||||
Amethyst.instance.namecoinPrefs,
|
||||
electrumXClient = { Amethyst.instance.electrumXClient },
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NamecoinSettingsScreen(
|
||||
namecoinPrefs: NamecoinSharedPreferences,
|
||||
electrumXClient: () -> ElectrumXClient,
|
||||
nav: INav,
|
||||
) {
|
||||
val namecoinSettings by namecoinPrefs.settings.collectAsState()
|
||||
val namecoinSettings by namecoinPrefs.settings.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
@@ -82,6 +88,13 @@ fun NamecoinSettingsScreen(
|
||||
onReset = {
|
||||
scope.launch { namecoinPrefs.reset() }
|
||||
},
|
||||
onTestServer = { server -> electrumXClient().testServer(server) },
|
||||
onPinCert = { pem ->
|
||||
scope.launch {
|
||||
namecoinPrefs.addPinnedCert(pem)
|
||||
electrumXClient().addPinnedCert(pem)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+374
-1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
@@ -41,6 +42,11 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -53,20 +59,29 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.res.stringResource
|
||||
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.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Complete settings section for Namecoin ElectrumX server configuration.
|
||||
@@ -79,6 +94,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_S
|
||||
* @param onAddServer Called with `host:port[:tcp]` when user adds a server
|
||||
* @param onRemoveServer Called with the server string to remove
|
||||
* @param onReset Called when user resets to defaults
|
||||
* @param onTestServer Suspend function to test a single server
|
||||
* @param onPinCert Called with PEM string to persist a TOFU-pinned cert
|
||||
*/
|
||||
@Composable
|
||||
fun NamecoinSettingsSection(
|
||||
@@ -87,6 +104,8 @@ fun NamecoinSettingsSection(
|
||||
onAddServer: (String) -> Unit,
|
||||
onRemoveServer: (String) -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onTestServer: suspend (ElectrumxServer) -> ServerTestResult,
|
||||
onPinCert: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.padding(16.dp)) {
|
||||
@@ -150,12 +169,366 @@ fun NamecoinSettingsSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// ── Test Connection ────────────────────────────────
|
||||
TestConnectionSection(
|
||||
settings = settings,
|
||||
onTestServer = onTestServer,
|
||||
onPinCert = onPinCert,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-composables ────────────────────────────────────────────────────
|
||||
// ── Test Connection ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Holds a cert pending user confirmation before pinning (TOFU).
|
||||
*/
|
||||
private data class PendingCertPin(
|
||||
val serverHost: String,
|
||||
val fingerprint: String,
|
||||
val pem: String,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun TestConnectionSection(
|
||||
settings: NamecoinSettings,
|
||||
onTestServer: suspend (ElectrumxServer) -> ServerTestResult,
|
||||
onPinCert: (String) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var isTesting by remember { mutableStateOf(false) }
|
||||
var testResults by remember { mutableStateOf<List<ServerTestResult>>(emptyList()) }
|
||||
var lastTestTimestamp by remember { mutableStateOf<Long?>(null) }
|
||||
// Certs discovered during testing that need user confirmation
|
||||
var pendingCerts by remember { mutableStateOf<List<PendingCertPin>>(emptyList()) }
|
||||
// Which cert is currently shown in the confirmation dialog
|
||||
var confirmingCert by remember { mutableStateOf<PendingCertPin?>(null) }
|
||||
|
||||
val servers = settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS
|
||||
|
||||
// ── Cert confirmation dialog ───────────────────────────────
|
||||
confirmingCert?.let { pending ->
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
// Remove from pending list and move to next (or close)
|
||||
pendingCerts = pendingCerts.drop(1)
|
||||
confirmingCert = pendingCerts.firstOrNull()
|
||||
},
|
||||
title = { Text(stringResource(R.string.namecoin_pin_cert_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.namecoin_pin_cert_body, pending.serverHost),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"SHA-256:",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = pending.fingerprint,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(onClick = {
|
||||
onPinCert(pending.pem)
|
||||
pendingCerts = pendingCerts.drop(1)
|
||||
confirmingCert = pendingCerts.firstOrNull()
|
||||
}) {
|
||||
Text(stringResource(R.string.namecoin_pin_cert_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
pendingCerts = pendingCerts.drop(1)
|
||||
confirmingCert = pendingCerts.firstOrNull()
|
||||
}) {
|
||||
Text(stringResource(R.string.namecoin_pin_cert_reject))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Column {
|
||||
// ── Test button ────────────────────────────────────────
|
||||
Button(
|
||||
onClick = {
|
||||
if (!isTesting) {
|
||||
isTesting = true
|
||||
testResults = emptyList()
|
||||
pendingCerts = emptyList()
|
||||
scope.launch {
|
||||
val results = mutableListOf<ServerTestResult>()
|
||||
val newCerts = mutableListOf<PendingCertPin>()
|
||||
for (server in servers) {
|
||||
val result = onTestServer(server)
|
||||
results.add(result)
|
||||
testResults = results.toList()
|
||||
// Collect certs for user confirmation (not auto-pinned)
|
||||
val pem = result.serverCertPem
|
||||
val fp = result.certFingerprint
|
||||
if (result.success && pem != null && fp != null) {
|
||||
newCerts.add(
|
||||
PendingCertPin(
|
||||
serverHost = "${server.host}:${server.port}",
|
||||
fingerprint = fp,
|
||||
pem = pem,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
lastTestTimestamp = System.currentTimeMillis()
|
||||
isTesting = false
|
||||
// Show confirmation dialog for each new cert
|
||||
if (newCerts.isNotEmpty()) {
|
||||
pendingCerts = newCerts
|
||||
confirmingCert = newCerts.first()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = !isTesting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.namecoin_testing))
|
||||
} else {
|
||||
Text(stringResource(R.string.namecoin_test_connection))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-server results ─────────────────────────────────
|
||||
if (testResults.isNotEmpty()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.namecoin_test_results),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
|
||||
testResults.forEach { result ->
|
||||
ServerTestResultRow(result)
|
||||
}
|
||||
|
||||
if (isTesting && testResults.size < servers.size) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Testing next server…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diagnostic card ────────────────────────────────────
|
||||
if (testResults.isNotEmpty() || lastTestTimestamp != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
DiagnosticCard(
|
||||
testResults = testResults,
|
||||
lastTestTimestamp = lastTestTimestamp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerTestResultRow(result: ServerTestResult) {
|
||||
val serverLabel = "${result.server.host}:${result.server.port}"
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 3.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = if (result.success) "✅" else "❌",
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(end = 6.dp, top = 1.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = serverLabel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.namecoin_response_time, result.responseTimeMs),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (result.success) {
|
||||
Text(
|
||||
text = stringResource(R.string.namecoin_test_success),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFF2E8B57),
|
||||
)
|
||||
val fp = result.certFingerprint
|
||||
if (fp != null) {
|
||||
Text(
|
||||
text = "Cert: ${fp.take(23)}…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errorText = result.error
|
||||
if (errorText != null) {
|
||||
Text(
|
||||
text = errorText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diagnostic Card ────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun DiagnosticCard(
|
||||
testResults: List<ServerTestResult>,
|
||||
lastTestTimestamp: Long?,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.namecoin_diagnostics),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Last test timestamp
|
||||
if (lastTestTimestamp != null) {
|
||||
val formatted =
|
||||
remember(lastTestTimestamp) {
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
.format(Date(lastTestTimestamp))
|
||||
}
|
||||
val successCount = testResults.count { it.success }
|
||||
val totalCount = testResults.size
|
||||
DiagnosticRow(
|
||||
label = stringResource(R.string.namecoin_last_test),
|
||||
value = "$formatted ($successCount/$totalCount OK)",
|
||||
)
|
||||
} else {
|
||||
DiagnosticRow(
|
||||
label = stringResource(R.string.namecoin_last_test),
|
||||
value = stringResource(R.string.namecoin_no_test_yet),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Device info
|
||||
DiagnosticRow(
|
||||
label = stringResource(R.string.namecoin_device_info),
|
||||
value = "${Build.MANUFACTURER} ${Build.MODEL}, Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})",
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// TLS info from test results
|
||||
val tlsVersions =
|
||||
testResults
|
||||
.mapNotNull { it.tlsVersion }
|
||||
.distinct()
|
||||
val tlsDisplay =
|
||||
if (tlsVersions.isNotEmpty()) {
|
||||
tlsVersions.joinToString(", ")
|
||||
} else {
|
||||
"—"
|
||||
}
|
||||
DiagnosticRow(
|
||||
label = stringResource(R.string.namecoin_tls_info),
|
||||
value = tlsDisplay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiagnosticRow(
|
||||
label: String,
|
||||
value: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(0.35f),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(0.65f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Original Sub-composables ───────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
<string name="unauthorized_exception_description">Podepisovatel neautorizoval dešifrování potřebné k provedení této operace. Aktivujte dešifrování NIP-44 ve své aplikaci pro podepisování a zkuste to znovu</string>
|
||||
<string name="signer_not_found_exception">Podepisovatel nenalezen</string>
|
||||
<string name="signer_not_found_exception_description">Byla aplikace pro podepisování odinstalována? Zkontrolujte, zda je aplikace nainstalována a obsahuje tento účet. Odhlaste se a přihlaste znovu, pokud se aplikace změnila.</string>
|
||||
<string name="signer_illegal_state_exception">Podepisovatel se choval neočekávaně</string>
|
||||
<string name="signer_illegal_state_exception_description">Externí podepisovatel vrátil data, která jsou pro daný požadavek neobvyklá. Může se jednat o chybu v Amethystu nebo v podepisovateli.</string>
|
||||
<string name="zaps">Zapy</string>
|
||||
<string name="view_count">Počet zobrazení</string>
|
||||
<string name="boost">Zvýšení</string>
|
||||
@@ -446,6 +448,8 @@
|
||||
<string name="poll_zap_value_max">Maximální zaps</string>
|
||||
<string name="poll_consensus_threshold">Konsensus</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_single_choice">Jedna možnost</string>
|
||||
<string name="poll_multiple_choice">Více možností</string>
|
||||
<string name="poll_closing_date_time">Datum a čas ukončení ankety</string>
|
||||
<string name="poll_closing_in">Anketa končí za %1$s</string>
|
||||
<string name="poll_closing_time">Uzavřít po</string>
|
||||
@@ -485,6 +489,9 @@
|
||||
<string name="zap_type_anonymous_explainer">Příjemce a veřejnost neví, kdo platbu poslal</string>
|
||||
<string name="zap_type_nonzap">Né Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Žádná stopa v Nostr, pouze v Lightning</string>
|
||||
<string name="post_anonymously">Anonymní</string>
|
||||
<string name="post_anonymously_explainer">Odeslat jako novou jednorázovou identitu. Váš účet nebude s touto odpovědí spojen.</string>
|
||||
<string name="anonymous_reply_warning">Tato odpověď bude odeslána z nové anonymní identity</string>
|
||||
<string name="file_server">Souborový server</string>
|
||||
<string name="file_server_description">Zvolte server pro nahrání tohoto souboru</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress nebo @Uživatel</string>
|
||||
@@ -1324,6 +1331,7 @@
|
||||
<string name="feed_group_hashtags">Hashtagy</string>
|
||||
<string name="feed_group_communities">Komunity</string>
|
||||
<string name="feed_group_lists">Seznamy</string>
|
||||
<string name="feed_group_relays">Relé</string>
|
||||
<string name="temporary_account">Odhlásit se na zámek zařízení</string>
|
||||
<string name="private_message">Soukromá zpráva</string>
|
||||
<string name="public_message">Veřejná zpráva</string>
|
||||
@@ -1560,6 +1568,7 @@
|
||||
<string name="kind_shorts">Krátká videa</string>
|
||||
<string name="kind_voice_msg">Hlasová zpráva</string>
|
||||
<string name="kind_voice_reply">Hlasová odpověď</string>
|
||||
<string name="kind_web_bookmark">Webová záložka</string>
|
||||
<string name="kind_wiki">Wiki</string>
|
||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Začněte se skvělým feedem tím, že budete sledovat stejné lidi jako někdo, komu důvěřujete.</string>
|
||||
<string name="import_follow_list">Importovat seznam sledovaných</string>
|
||||
@@ -1675,4 +1684,17 @@
|
||||
<string name="event_sync_date_filter_all_time">Vždy</string>
|
||||
<string name="event_sync_date_filter_last_sync">Poslední synchronizace: %1$s</string>
|
||||
<string name="event_sync_date_filter_since_last_sync">Od poslední synchronizace</string>
|
||||
<string name="web_bookmarks">Webové záložky</string>
|
||||
<string name="web_bookmarks_empty">Zatím žádné webové záložky. Klepněte na + pro přidání.</string>
|
||||
<string name="web_bookmark_add_title">Přidat webovou záložku</string>
|
||||
<string name="web_bookmark_edit_title">Upravit webovou záložku</string>
|
||||
<string name="web_bookmark_title_label">Název</string>
|
||||
<string name="web_bookmark_title_placeholder">Název záložky</string>
|
||||
<string name="web_bookmark_description_label">Popis</string>
|
||||
<string name="web_bookmark_description_placeholder">Krátký popis</string>
|
||||
<string name="web_bookmark_tags_label">Tagy (oddělené čárkou)</string>
|
||||
<string name="web_bookmark_save">Uložit</string>
|
||||
<string name="web_bookmark_delete">Smazat</string>
|
||||
<string name="web_bookmark_delete_confirm">Smazat tuto webovou záložku?</string>
|
||||
<string name="web_bookmark_open_url">Otevřít URL</string>
|
||||
</resources>
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
<string name="unauthorized_exception_description">Der Signierer hat die erforderliche Entschlüsselung nicht autorisiert. Aktiviere NIP-44-Entschlüsselung in deiner Signierer-App und versuche es erneut</string>
|
||||
<string name="signer_not_found_exception">Signierer nicht gefunden</string>
|
||||
<string name="signer_not_found_exception_description">Wurde die Signierer-App deinstalliert? Überprüfe, ob sie installiert ist und dieses Konto enthält. Melde dich ab und wieder an, falls sich die App geändert hat.</string>
|
||||
<string name="signer_illegal_state_exception">Signierer hat sich unerwartet verhalten</string>
|
||||
<string name="signer_illegal_state_exception_description">Externer Signierer hat Daten zurückgegeben, die für die Anfrage ungewöhnlich sind. Es könnte ein Fehler in Amethyst oder im Signierer vorliegen.</string>
|
||||
<string name="zaps">Zaps</string>
|
||||
<string name="view_count">Aufrufe</string>
|
||||
<string name="boost">Boost</string>
|
||||
@@ -452,6 +454,8 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="poll_zap_value_max">Maximaler Zap-Betrag</string>
|
||||
<string name="poll_consensus_threshold">Konsens</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_single_choice">Einzelauswahl</string>
|
||||
<string name="poll_multiple_choice">Mehrfachauswahl</string>
|
||||
<string name="poll_closing_date_time">Schließdatum und -zeit</string>
|
||||
<string name="poll_closing_in">Umfrage endet in %1$s</string>
|
||||
<string name="poll_closing_time">Schließen nach</string>
|
||||
@@ -491,6 +495,9 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="zap_type_anonymous_explainer">Empfänger und die Öffentlichkeit wissen nicht, wer die Zahlung gesendet hat</string>
|
||||
<string name="zap_type_nonzap">Keine Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Keine Spur in Nostr, nur in Lightning</string>
|
||||
<string name="post_anonymously">Anonym</string>
|
||||
<string name="post_anonymously_explainer">Als neue Wegwerfidentität posten. Dein Konto wird nicht mit dieser Antwort verknüpft.</string>
|
||||
<string name="anonymous_reply_warning">Diese Antwort wird von einer neuen anonymen Identität veröffentlicht</string>
|
||||
<string name="file_server">Dateiserver</string>
|
||||
<string name="file_server_description">Wählen Sie einen Server zum Hochladen dieser Datei</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress oder @Benutzer</string>
|
||||
@@ -1565,6 +1572,7 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="kind_shorts">Shorts</string>
|
||||
<string name="kind_voice_msg">Sprachnachricht</string>
|
||||
<string name="kind_voice_reply">Sprachantwort</string>
|
||||
<string name="kind_web_bookmark">Web-Lesezeichen</string>
|
||||
<string name="kind_wiki">Wiki</string>
|
||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Starte mit einem großartigen Feed, indem du dieselben Personen folgst wie jemand, dem du vertraust.</string>
|
||||
<string name="import_follow_list">Folgeliste importieren</string>
|
||||
@@ -1680,4 +1688,17 @@ anz der Bedingungen ist erforderlich</string>
|
||||
<string name="event_sync_date_filter_all_time">Gesamter Zeitraum</string>
|
||||
<string name="event_sync_date_filter_last_sync">Letzte Synchronisierung: %1$s</string>
|
||||
<string name="event_sync_date_filter_since_last_sync">Seit letzter Synchronisierung</string>
|
||||
<string name="web_bookmarks">Web-Lesezeichen</string>
|
||||
<string name="web_bookmarks_empty">Noch keine Web-Lesezeichen. Tippe auf + um eines hinzuzufügen.</string>
|
||||
<string name="web_bookmark_add_title">Web-Lesezeichen hinzufügen</string>
|
||||
<string name="web_bookmark_edit_title">Web-Lesezeichen bearbeiten</string>
|
||||
<string name="web_bookmark_title_label">Titel</string>
|
||||
<string name="web_bookmark_title_placeholder">Lesezeichen-Titel</string>
|
||||
<string name="web_bookmark_description_label">Beschreibung</string>
|
||||
<string name="web_bookmark_description_placeholder">Eine kurze Beschreibung</string>
|
||||
<string name="web_bookmark_tags_label">Tags (kommagetrennt)</string>
|
||||
<string name="web_bookmark_save">Speichern</string>
|
||||
<string name="web_bookmark_delete">Löschen</string>
|
||||
<string name="web_bookmark_delete_confirm">Dieses Web-Lesezeichen löschen?</string>
|
||||
<string name="web_bookmark_open_url">URL öffnen</string>
|
||||
</resources>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<string name="zaps">Zapy</string>
|
||||
<string name="view_count">Liczba wyświetleń</string>
|
||||
<string name="boost">Powtórz</string>
|
||||
<string name="boosted">powtórzono</string>
|
||||
<string name="boosted">wpis powtórzono</string>
|
||||
<string name="edited">edytowano</string>
|
||||
<string name="edited_number">edytuj #%1$s</string>
|
||||
<string name="original">oryginalny</string>
|
||||
@@ -1333,6 +1333,7 @@
|
||||
<string name="feed_group_hashtags">Hashtagi</string>
|
||||
<string name="feed_group_communities">Społeczności</string>
|
||||
<string name="feed_group_lists">Listy</string>
|
||||
<string name="feed_group_relays">Transmitery</string>
|
||||
<string name="temporary_account">Wyloguj się przy blokowaniu urządzenia</string>
|
||||
<string name="private_message">Wiadomość prywatna</string>
|
||||
<string name="public_message">Publiczna wiadomość</string>
|
||||
@@ -1568,6 +1569,7 @@
|
||||
<string name="kind_shorts">Filmiki</string>
|
||||
<string name="kind_voice_msg">Wiadomość głosowa</string>
|
||||
<string name="kind_voice_reply">Odpowiedź głosowa</string>
|
||||
<string name="kind_web_bookmark">Zakładka</string>
|
||||
<string name="kind_wiki">Wiki</string>
|
||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Stwórz świetny kanał, obserwując te same osoby, które obserwuje zaufana osoba.</string>
|
||||
<string name="import_follow_list">Importuj listę obserwowanych</string>
|
||||
@@ -1683,4 +1685,20 @@
|
||||
<string name="event_sync_date_filter_all_time">Cały czas</string>
|
||||
<string name="event_sync_date_filter_last_sync">Ostatnia synchronizacja %1$s</string>
|
||||
<string name="event_sync_date_filter_since_last_sync">Od ostatniej synchronizacji</string>
|
||||
<string name="web_bookmarks">Zakładki</string>
|
||||
<string name="web_bookmarks_empty">Brak zakładek. Naciśnij + aby dodać nową zakładkę.</string>
|
||||
<string name="web_bookmark_add_title">Dodaj zakładkę</string>
|
||||
<string name="web_bookmark_edit_title">Edytuj zakładkę</string>
|
||||
<string name="web_bookmark_url_label">Adres URL</string>
|
||||
<string name="web_bookmark_url_placeholder">https://domena.pl</string>
|
||||
<string name="web_bookmark_title_label">Tytuł</string>
|
||||
<string name="web_bookmark_title_placeholder">Tytuł zakładki</string>
|
||||
<string name="web_bookmark_description_label">Opis</string>
|
||||
<string name="web_bookmark_description_placeholder">Krótki opis</string>
|
||||
<string name="web_bookmark_tags_label">Tagi (oddzielone przecinkami)</string>
|
||||
<string name="web_bookmark_tags_placeholder">nostr, technika, blog</string>
|
||||
<string name="web_bookmark_save">Zapisz</string>
|
||||
<string name="web_bookmark_delete">Usuń</string>
|
||||
<string name="web_bookmark_delete_confirm">Usunąć tę zakładkę?</string>
|
||||
<string name="web_bookmark_open_url">Otwórz adres URL</string>
|
||||
</resources>
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
<string name="unauthorized_exception_description">O assinador não autorizou a descriptografia necessária para realizar esta operação. Ative as descriptografias NIP-44 no seu aplicativo de assinatura e tente novamente</string>
|
||||
<string name="signer_not_found_exception">Assinador não encontrado</string>
|
||||
<string name="signer_not_found_exception_description">O aplicativo de assinatura foi desinstalado? Verifique se ele está instalado e com esta conta. Saia e entre novamente se ele foi alterado.</string>
|
||||
<string name="signer_illegal_state_exception">Assinador se comportou de forma inesperada</string>
|
||||
<string name="signer_illegal_state_exception_description">O assinador externo retornou dados estranhos para a solicitação. Pode haver um bug no Amethyst ou no assinador.</string>
|
||||
<string name="zaps">Zaps</string>
|
||||
<string name="view_count">Contagem de visualizações</string>
|
||||
<string name="boost">Impulsionar</string>
|
||||
@@ -446,6 +448,8 @@
|
||||
<string name="poll_zap_value_max">Zap máximo</string>
|
||||
<string name="poll_consensus_threshold">Consenso</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_single_choice">Escolha única</string>
|
||||
<string name="poll_multiple_choice">Múltipla escolha</string>
|
||||
<string name="poll_closing_date_time">Data e hora de encerramento da enquete</string>
|
||||
<string name="poll_closing_in">Enquete encerra em %1$s</string>
|
||||
<string name="poll_closing_time">Fechar depois</string>
|
||||
@@ -485,6 +489,9 @@
|
||||
<string name="zap_type_anonymous_explainer">Destinatário e o público não sabem quem enviou o pagamento</string>
|
||||
<string name="zap_type_nonzap">Sem Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Nenhum traço no Nostr, apenas na Lightning</string>
|
||||
<string name="post_anonymously">Anônimo</string>
|
||||
<string name="post_anonymously_explainer">Postar como uma nova identidade descartável. Sua conta não será vinculada a esta resposta.</string>
|
||||
<string name="anonymous_reply_warning">Esta resposta será postada a partir de uma nova identidade anônima</string>
|
||||
<string name="file_server">Servidor de arquivos</string>
|
||||
<string name="file_server_description">Escolha um servidor para onde enviar este arquivo</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress ou @Usuário</string>
|
||||
@@ -1560,6 +1567,7 @@
|
||||
<string name="kind_shorts">Shorts</string>
|
||||
<string name="kind_voice_msg">Mensagem de voz</string>
|
||||
<string name="kind_voice_reply">Resposta de voz</string>
|
||||
<string name="kind_web_bookmark">Marcador Web</string>
|
||||
<string name="kind_wiki">Wiki</string>
|
||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Comece com um ótimo feed seguindo as mesmas pessoas que alguém em quem você confia.</string>
|
||||
<string name="import_follow_list">Importar lista de seguidos</string>
|
||||
@@ -1675,4 +1683,17 @@
|
||||
<string name="event_sync_date_filter_all_time">Todo o período</string>
|
||||
<string name="event_sync_date_filter_last_sync">Última sincronização: %1$s</string>
|
||||
<string name="event_sync_date_filter_since_last_sync">Desde a última sincronização</string>
|
||||
<string name="web_bookmarks">Marcadores Web</string>
|
||||
<string name="web_bookmarks_empty">Nenhum marcador web ainda. Toque em + para adicionar.</string>
|
||||
<string name="web_bookmark_add_title">Adicionar Marcador Web</string>
|
||||
<string name="web_bookmark_edit_title">Editar Marcador Web</string>
|
||||
<string name="web_bookmark_title_label">Título</string>
|
||||
<string name="web_bookmark_title_placeholder">Título do marcador</string>
|
||||
<string name="web_bookmark_description_label">Descrição</string>
|
||||
<string name="web_bookmark_description_placeholder">Uma breve descrição</string>
|
||||
<string name="web_bookmark_tags_label">Tags (separadas por vírgula)</string>
|
||||
<string name="web_bookmark_save">Salvar</string>
|
||||
<string name="web_bookmark_delete">Excluir</string>
|
||||
<string name="web_bookmark_delete_confirm">Excluir este marcador web?</string>
|
||||
<string name="web_bookmark_open_url">Abrir URL</string>
|
||||
</resources>
|
||||
|
||||
@@ -72,6 +72,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="unauthorized_exception_description">Podpisnik ni odobril dešifriranja, ki je potrebno za to operacijo. V aplikaciji za podpisovanje aktivirajte NIP-44 možnost dešifriranja in poskusite znova</string>
|
||||
<string name="signer_not_found_exception">Ne najdem podpisnika</string>
|
||||
<string name="signer_not_found_exception_description">Ali je bila aplikacija za podpisovanje odstranjena? Preverite, ali je aplikacija za podpisovanje nameščena in ima dostop do tega računa. Odjavite se in ponovno prijavite, če se je aplikacija morda spremenila.</string>
|
||||
<string name="signer_illegal_state_exception">Napačno delovanje podpisnika</string>
|
||||
<string name="signer_illegal_state_exception_description">Zunanji podpisnik je vrnil neobičajen odgovor. Morda gre za napako v aplikaciji Amethyst ali v podpisniku.</string>
|
||||
<string name="zaps">Zapi</string>
|
||||
<string name="view_count">Števec vpogledov</string>
|
||||
<string name="boost">Pošlji naprej</string>
|
||||
@@ -118,7 +120,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="new_channel">Nov kanal</string>
|
||||
<string name="channel_name">Ime kanala</string>
|
||||
<string name="my_awesome_group">Moja vrhunska skupina</string>
|
||||
<string name="picture_url">Url slike</string>
|
||||
<string name="picture_url">URL slike</string>
|
||||
<string name="optional_picture_url">URL slike (Neobvezno)</string>
|
||||
<string name="description">Opis</string>
|
||||
<string name="no_description">Ne najdem opisa</string>
|
||||
@@ -193,7 +195,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="voice_anonymize_title">Anonimiziraj</string>
|
||||
<string name="voice_anonymize_description">Prilagodi višino tona svojega glasu: Opomba: osnovne spremembe višine tona lahko poslušalci potencialno razveljavijo.</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">Uporabnik nima nastavljenega \"lightning\" naslova za sprejem satoshi-jev</string>
|
||||
<string name="reply_here">"odgovori tukaj.. "</string>
|
||||
<string name="reply_here">"odgovori tukaj… "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">Kopira ID zapiska v odložišče za deljenje v Nostr</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">Kopiraj ID kanala (zapisek) v odložišče</string>
|
||||
<string name="edits_the_channel_metadata">Uredi metapodatke kanala</string>
|
||||
@@ -301,6 +303,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="nip_05">Nostr naslov t. i. Nip-05</string>
|
||||
<string name="never">nikoli</string>
|
||||
<string name="now">zdaj</string>
|
||||
<string name="seconds">sekunde</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
@@ -387,7 +390,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="manual_zaps">Ročno razdeli zape</string>
|
||||
<string name="bookmarks">Zaznamki</string>
|
||||
<string name="bookmarks_title">Privzeti zaznamki</string>
|
||||
<string name="bookmarks_explainer">Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev.</string>
|
||||
<string name="bookmarks_explainer">Tvoje privzeti zaznamki, ki jih podpira veliko Nostr odjemalcev</string>
|
||||
<string name="drafts">Osnutki</string>
|
||||
<string name="private_bookmarks">Privatni zaznamki</string>
|
||||
<string name="public_bookmarks">Javni zaznamki</string>
|
||||
@@ -441,7 +444,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="wallet_connect_manual_config">Napredno: ročni vnos podatkov o povezavi</string>
|
||||
<string name="quick_zap_amounts">Zneski za hitre zape</string>
|
||||
<string name="quick_zap_amounts_explainer">Prikaže se ob pritisku na gumb za zappe. Tapnite znesek, da ga odstranite. Če pustite prazno, se bo ob vsakem zappu odprlo okno za vnos poljubnega zneska.</string>
|
||||
<string name="zap_privacy_section">Zap zasebnost</string>
|
||||
<string name="zap_privacy_section">Zasebnost zapov</string>
|
||||
<string name="zap_type_section_explainer">Določa, kako je prikazana vaša identiteta, ko pošljete zap.</string>
|
||||
<string name="wallet_connect_connect_app">Poveži denarnico</string>
|
||||
<string name="see_relay_feed">Pogled v vsebino releja</string>
|
||||
@@ -458,6 +461,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="poll_zap_value_max">Zap maksimum</string>
|
||||
<string name="poll_consensus_threshold">Soglasje</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_single_choice">Ena izbira</string>
|
||||
<string name="poll_multiple_choice">Več izbire</string>
|
||||
<string name="poll_closing_date_time">Datum in čas konca glasovanja</string>
|
||||
<string name="poll_closing_in">Anketa se zaključi %1$s</string>
|
||||
<string name="poll_closing_time">Zapri po</string>
|
||||
@@ -497,6 +502,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="zap_type_anonymous_explainer">Prejemnik in javnost ne vejo kdo je poslal plačilo</string>
|
||||
<string name="zap_type_nonzap">Ni-Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Brez sledi v Nostr, samo v Lightning</string>
|
||||
<string name="post_anonymously">Anonimno</string>
|
||||
<string name="post_anonymously_explainer">Objavite z novo začasno identiteto. Vaš račun ne bo povezan s tem odgovorom.</string>
|
||||
<string name="anonymous_reply_warning">Ta odgovor bo objavljen z novo anonimno identiteto</string>
|
||||
<string name="file_server">Datotečni strežnik</string>
|
||||
<string name="file_server_description">Izberi strežnik za nalaganje te datoteke</string>
|
||||
<string name="zap_forward_lnAddress">LnNaslov ali @Uporabnik</string>
|
||||
@@ -753,7 +761,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="access_control">Nadzor dostopa</string>
|
||||
<string name="minimum_pow">Minimalen PoW</string>
|
||||
<string name="auth">Auth</string>
|
||||
<string name="auth_required">Potrebna avtentikacija</string>
|
||||
<string name="auth_required">Potrebna je avtentikacija</string>
|
||||
<string name="payment">Plačilo</string>
|
||||
<string name="payment_required">Potrebno je plačilo</string>
|
||||
<string name="max_message_length">Največja dolžina sporočila</string>
|
||||
@@ -826,7 +834,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="gallery_style">Slog galerije profila</string>
|
||||
<string name="gallery_style_description">Izberi slog galerije</string>
|
||||
<string name="load_image">Naloži sliko</string>
|
||||
<string name="spamming_users">Pošiljatelji nezaželjenih vsebin</string>
|
||||
<string name="spamming_users">Pošiljatelji nezaželenih vsebin</string>
|
||||
<string name="muted_button">Utišano. Klikni za vklop zvoka</string>
|
||||
<string name="mute_button">Zvok je prižgan. Klikni da ga utišaš</string>
|
||||
<string name="skip_back">Skoči nazaj za %d sekund</string>
|
||||
@@ -1260,6 +1268,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="existed_since">OTS: %1$s</string>
|
||||
<string name="ots_info_title">Dokaz časovnega žiga</string>
|
||||
<string name="ots_info_description">Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas.</string>
|
||||
<string name="edit_article">Uredi članek</string>
|
||||
<string name="edit_post">Uredi objavo</string>
|
||||
<string name="proposal_to_edit">Prošnja za izboljšavo objave</string>
|
||||
<string name="message_to_author">Povzetek sprememb</string>
|
||||
@@ -1339,6 +1348,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="feed_group_hashtags">Ključniki</string>
|
||||
<string name="feed_group_communities">Skupnosti</string>
|
||||
<string name="feed_group_lists">Seznami</string>
|
||||
<string name="feed_group_relays">Releji</string>
|
||||
<string name="temporary_account">Odjava ob zaklepu naprave</string>
|
||||
<string name="private_message">Zasebno sporočilo</string>
|
||||
<string name="public_message">Javno sporočilo</string>
|
||||
@@ -1575,6 +1585,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="kind_shorts">Kratki videoposnetki</string>
|
||||
<string name="kind_voice_msg">Zvočno sporočilo</string>
|
||||
<string name="kind_voice_reply">Zvočni odgovori</string>
|
||||
<string name="kind_web_bookmark">Spletni zaznamek</string>
|
||||
<string name="kind_wiki">Wiki</string>
|
||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Zagotovite si odličen vir objav tako, da sledite istim ljudem kot nekdo, ki mu zaupate.</string>
|
||||
<string name="import_follow_list">Uvozi seznam sledenih</string>
|
||||
@@ -1690,4 +1701,20 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
|
||||
<string name="event_sync_date_filter_all_time">Vse do zdaj</string>
|
||||
<string name="event_sync_date_filter_last_sync">Zadnja sinhronizacija: %1$s</string>
|
||||
<string name="event_sync_date_filter_since_last_sync">Od zadnje sinhronizacije</string>
|
||||
<string name="web_bookmarks">Spletni zaznamki</string>
|
||||
<string name="web_bookmarks_empty">Seznam spletnih zaznamkov je prazen. Dodajte ga s pritiskom na +.</string>
|
||||
<string name="web_bookmark_add_title">Dodaj spletni zaznamek</string>
|
||||
<string name="web_bookmark_edit_title">Uredi spletni zaznamek</string>
|
||||
<string name="web_bookmark_url_label">URL</string>
|
||||
<string name="web_bookmark_url_placeholder">https://primer.si</string>
|
||||
<string name="web_bookmark_title_label">Naslov</string>
|
||||
<string name="web_bookmark_title_placeholder">Naslov zaznamka</string>
|
||||
<string name="web_bookmark_description_label">Opis</string>
|
||||
<string name="web_bookmark_description_placeholder">Kratek opis</string>
|
||||
<string name="web_bookmark_tags_label">Oznake (Ločite z vejicami)</string>
|
||||
<string name="web_bookmark_tags_placeholder">nostr, tehnika , blog</string>
|
||||
<string name="web_bookmark_save">Shrani</string>
|
||||
<string name="web_bookmark_delete">Izbriši</string>
|
||||
<string name="web_bookmark_delete_confirm">Izbriši ta spletni zaznamek</string>
|
||||
<string name="web_bookmark_open_url">Odpri URL</string>
|
||||
</resources>
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
<string name="unauthorized_exception_description">Signatären har inte godkänt den dekryptering som krävs. Aktivera NIP-44-dekryptering i din signeringsapp och försök igen</string>
|
||||
<string name="signer_not_found_exception">Signatör saknas</string>
|
||||
<string name="signer_not_found_exception_description">Har signeringsappen avinstallerats? Kontrollera om den är installerad och innehåller det här kontot. Logga ut och in igen om den har ändrats.</string>
|
||||
<string name="signer_illegal_state_exception">Signatären betedde sig oväntat</string>
|
||||
<string name="signer_illegal_state_exception_description">Extern signatär returnerade data som är ovanliga för begäran. Det kan finnas en bugg i antingen Amethyst eller signatären.</string>
|
||||
<string name="zaps">Zaps</string>
|
||||
<string name="view_count">Antal visningar</string>
|
||||
<string name="boost">Boosta</string>
|
||||
@@ -446,6 +448,8 @@
|
||||
<string name="poll_zap_value_max">Maximal Zap</string>
|
||||
<string name="poll_consensus_threshold">Konsensus</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_single_choice">Enkelt val</string>
|
||||
<string name="poll_multiple_choice">Flerval</string>
|
||||
<string name="poll_closing_date_time">Stängningsdatum och tid</string>
|
||||
<string name="poll_closing_in">Omröstningen stänger om %1$s</string>
|
||||
<string name="poll_closing_time">Avsluta efter</string>
|
||||
@@ -485,6 +489,9 @@
|
||||
<string name="zap_type_anonymous_explainer">Mottagaren och allmänheten vet inte vem som skickade betalningen</string>
|
||||
<string name="zap_type_nonzap">Ingen Zap</string>
|
||||
<string name="zap_type_nonzap_explainer">Inga spår i Nostr, bara i Lightning</string>
|
||||
<string name="post_anonymously">Anonym</string>
|
||||
<string name="post_anonymously_explainer">Publicera som en ny engångsidentitet. Ditt konto kommer inte att kopplas till detta svar.</string>
|
||||
<string name="anonymous_reply_warning">Detta svar kommer att publiceras från en ny anonym identitet</string>
|
||||
<string name="file_server">Fil Server</string>
|
||||
<string name="file_server_description">Välj en server att ladda upp denna fil till</string>
|
||||
<string name="zap_forward_lnAddress">LnAdress eller @Användare</string>
|
||||
@@ -1323,6 +1330,7 @@
|
||||
<string name="feed_group_hashtags">Hashtaggar</string>
|
||||
<string name="feed_group_communities">Gemenskaper</string>
|
||||
<string name="feed_group_lists">Listor</string>
|
||||
<string name="feed_group_relays">Reläer</string>
|
||||
<string name="temporary_account">Logga ut när enheten låses</string>
|
||||
<string name="private_message">Privat meddelande</string>
|
||||
<string name="public_message">Offentligt meddelande</string>
|
||||
@@ -1559,6 +1567,7 @@
|
||||
<string name="kind_shorts">Shorts</string>
|
||||
<string name="kind_voice_msg">Röstmeddelande</string>
|
||||
<string name="kind_voice_reply">Röstsvar</string>
|
||||
<string name="kind_web_bookmark">Webbbokmärke</string>
|
||||
<string name="kind_wiki">Wiki</string>
|
||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Kom igång med ett bra flöde genom att följa samma personer som någon du litar på.</string>
|
||||
<string name="import_follow_list">Importera följarlista</string>
|
||||
@@ -1674,4 +1683,17 @@
|
||||
<string name="event_sync_date_filter_all_time">All tid</string>
|
||||
<string name="event_sync_date_filter_last_sync">Senaste synkronisering: %1$s</string>
|
||||
<string name="event_sync_date_filter_since_last_sync">Sedan senaste synkronisering</string>
|
||||
<string name="web_bookmarks">Webbbokmärken</string>
|
||||
<string name="web_bookmarks_empty">Inga webbbokmärken ännu. Tryck på + för att lägga till.</string>
|
||||
<string name="web_bookmark_add_title">Lägg till webbbokmärke</string>
|
||||
<string name="web_bookmark_edit_title">Redigera webbbokmärke</string>
|
||||
<string name="web_bookmark_title_label">Titel</string>
|
||||
<string name="web_bookmark_title_placeholder">Bokmärkets titel</string>
|
||||
<string name="web_bookmark_description_label">Beskrivning</string>
|
||||
<string name="web_bookmark_description_placeholder">En kort beskrivning</string>
|
||||
<string name="web_bookmark_tags_label">Taggar (kommaseparerade)</string>
|
||||
<string name="web_bookmark_save">Spara</string>
|
||||
<string name="web_bookmark_delete">Radera</string>
|
||||
<string name="web_bookmark_delete_confirm">Radera detta webbbokmärke?</string>
|
||||
<string name="web_bookmark_open_url">Öppna URL</string>
|
||||
</resources>
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
<string name="relay_icon">中继器图标</string>
|
||||
<string name="unknown_author">未知作者</string>
|
||||
<string name="copy_text">复制文本</string>
|
||||
<string name="copy_user_pubkey">复制作者@npub</string>
|
||||
<string name="copy_note_id">复制笔记ID</string>
|
||||
<string name="copy_user_pubkey">复制作者公钥 ID</string>
|
||||
<string name="copy_note_id">复制笔记 ID</string>
|
||||
<string name="broadcast">广播</string>
|
||||
<string name="timestamp_it">获取公开时间戳</string>
|
||||
<string name="timestamp_pending">OpenTimestamps:待确认</string>
|
||||
@@ -61,6 +61,8 @@
|
||||
<string name="unauthorized_exception_description">签名器没有授权解密操作,请在签名器中授予 NIP-44 解密权限并重试。</string>
|
||||
<string name="signer_not_found_exception">未找到签名器</string>
|
||||
<string name="signer_not_found_exception_description">签名器被卸载?请检查是否已经安装了签名器以及其中是否存在该账户。变更签名器需要注销后重新登录。</string>
|
||||
<string name="signer_illegal_state_exception">签名器行为异常</string>
|
||||
<string name="signer_illegal_state_exception_description">外部签名器对该请求返回了不正常的载荷。这可能是 Amethyst 或签名器上的错误。</string>
|
||||
<string name="zaps">打闪</string>
|
||||
<string name="view_count">浏览次数</string>
|
||||
<string name="boost">提升</string>
|
||||
@@ -70,7 +72,7 @@
|
||||
<string name="original">原版</string>
|
||||
<string name="quote">引用</string>
|
||||
<string name="fork">复刻</string>
|
||||
<string name="propose_an_edit">提议编辑</string>
|
||||
<string name="propose_an_edit">提出修改建议</string>
|
||||
<string name="new_amount_in_sats">新的聪金额</string>
|
||||
<string name="add">添加</string>
|
||||
<string name="replying_to">"回复 "</string>
|
||||
@@ -114,7 +116,7 @@
|
||||
<string name="about_us">"关于我们.. "</string>
|
||||
<string name="what_s_on_your_mind">你在想什么?</string>
|
||||
<string name="write_a_message">写一条消息…</string>
|
||||
<string name="post">发布</string>
|
||||
<string name="post">贴文</string>
|
||||
<string name="save">保存</string>
|
||||
<string name="create">创建</string>
|
||||
<string name="rename">重命名</string>
|
||||
@@ -183,7 +185,7 @@
|
||||
<string name="voice_anonymize_description">更改您的音高。注意:听众如果下定决定也许能逆转基础音高更改。</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
|
||||
<string name="reply_here">"🔏在此回复… "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板,供分享</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板以便于在 Nostr 中分享</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">复制频道ID(笔记)到剪贴板</string>
|
||||
<string name="edits_the_channel_metadata">修改频道元数据</string>
|
||||
<string name="join">加入</string>
|
||||
@@ -196,7 +198,7 @@
|
||||
<string name="mod_queue">Mod 队列</string>
|
||||
<string name="notes">笔记</string>
|
||||
<string name="replies">回复</string>
|
||||
<string name="mutual">你的</string>
|
||||
<string name="mutual">互动</string>
|
||||
<string name="gallery">相册</string>
|
||||
<string name="follows">"关注"</string>
|
||||
<string name="reports">"举报"</string>
|
||||
@@ -326,7 +328,7 @@
|
||||
<string name="new_badge_award_notif">你收到了新的徽章奖励</string>
|
||||
<string name="award_granted_to">徽章奖励授予</string>
|
||||
<string name="copied_note_text_to_clipboard">文本已复制到剪贴板</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">复制作者的 @npub 到剪贴板</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">已复制作者公钥 ID 到剪贴板</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">已复制笔记ID (@note1) 到剪贴板</string>
|
||||
<string name="select_text_dialog_top">选择文本</string>
|
||||
<string name="private_conversation_notification">"<无法解密私密消息>\n\n;你被 %1$s 和 %2$s 之间的私人/加密会话引用。"</string>
|
||||
@@ -597,7 +599,7 @@
|
||||
</string>
|
||||
<string name="orbot_socks_port">Orbot Socks 端口</string>
|
||||
<string name="use_internal_tor">启动 Tor</string>
|
||||
<string name="use_internal_tor_explainer">使用内置的版本或者 Orbot</string>
|
||||
<string name="use_internal_tor_explainer">使用内置 Tor 或者 Orbot</string>
|
||||
<string name="tor_preset">Tor 和隐私预设</string>
|
||||
<string name="tor_preset_explainer">快速修改下方的设置</string>
|
||||
<string name="tor_use_onion_address">Onion 链接或中继地址</string>
|
||||
@@ -1131,7 +1133,7 @@
|
||||
<string name="reactions_settings_reorder">调整顺序</string>
|
||||
<string name="reactions_settings_reply">回复</string>
|
||||
<string name="reactions_settings_reply_description">回复此笔记</string>
|
||||
<string name="reactions_settings_boost">Boost</string>
|
||||
<string name="reactions_settings_boost">提升</string>
|
||||
<string name="reactions_settings_boost_description">转发或引用此笔记</string>
|
||||
<string name="reactions_settings_like">点赞</string>
|
||||
<string name="reactions_settings_like_description">使用表情符号回应笔记</string>
|
||||
@@ -1334,6 +1336,7 @@
|
||||
<string name="feed_group_hashtags">话题标签</string>
|
||||
<string name="feed_group_communities">社区</string>
|
||||
<string name="feed_group_lists">列表</string>
|
||||
<string name="feed_group_relays">中继</string>
|
||||
<string name="temporary_account">当设备锁定时注销</string>
|
||||
<string name="private_message">私信</string>
|
||||
<string name="public_message">公开消息</string>
|
||||
@@ -1570,6 +1573,7 @@
|
||||
<string name="kind_shorts">短篇</string>
|
||||
<string name="kind_voice_msg">语音消息</string>
|
||||
<string name="kind_voice_reply">语音回复</string>
|
||||
<string name="kind_web_bookmark">网络书签</string>
|
||||
<string name="kind_wiki">维基</string>
|
||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">关注你信任的人所关注的人来开启优质的源。</string>
|
||||
<string name="import_follow_list">导入关注列表</string>
|
||||
@@ -1639,7 +1643,7 @@
|
||||
<string name="dms">私信</string>
|
||||
<string name="profiles">个人资料</string>
|
||||
<string name="relay_settings_lower">中继设置</string>
|
||||
<string name="last_seen">上次看见在 %1$s 秒前</string>
|
||||
<string name="last_seen">%1$s 前活跃</string>
|
||||
<string name="event_sync_less_than_until"><%1$s</string>
|
||||
<string name="event_sync_status_connecting">连接中</string>
|
||||
<string name="event_sync_status_downloading">下载中</string>
|
||||
@@ -1685,4 +1689,20 @@
|
||||
<string name="event_sync_date_filter_all_time">全部时间</string>
|
||||
<string name="event_sync_date_filter_last_sync">上次同步: %1$s</string>
|
||||
<string name="event_sync_date_filter_since_last_sync">自上次同步后</string>
|
||||
<string name="web_bookmarks">网络书签</string>
|
||||
<string name="web_bookmarks_empty">暂无网络书签。点击 + 添加一个。</string>
|
||||
<string name="web_bookmark_add_title">添加网络书签</string>
|
||||
<string name="web_bookmark_edit_title">编辑网络书签</string>
|
||||
<string name="web_bookmark_url_label">URL</string>
|
||||
<string name="web_bookmark_url_placeholder">https://example.com</string>
|
||||
<string name="web_bookmark_title_label">标题</string>
|
||||
<string name="web_bookmark_title_placeholder">书签标题</string>
|
||||
<string name="web_bookmark_description_label">描述</string>
|
||||
<string name="web_bookmark_description_placeholder">一段简短描述</string>
|
||||
<string name="web_bookmark_tags_label">标签(以逗号分隔)</string>
|
||||
<string name="web_bookmark_tags_placeholder">nostr, tech, blog</string>
|
||||
<string name="web_bookmark_save">保存</string>
|
||||
<string name="web_bookmark_delete">删除</string>
|
||||
<string name="web_bookmark_delete_confirm">删除该网络书签?</string>
|
||||
<string name="web_bookmark_open_url">打开 URL</string>
|
||||
</resources>
|
||||
|
||||
@@ -1827,7 +1827,7 @@
|
||||
<string name="select_users_to_follow">Select Users to Follow</string>
|
||||
<string name="profile_to_import_from">Profile to import from</string>
|
||||
<string name="name_search_npub1_alice_example_com">search, npub1…, alice@example.com</string>
|
||||
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, nprofile, NIP-05, hex, and namecoin (.bit, d/, id/)</string>
|
||||
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Supports npub, nprofile, NIP-05, hex, and Namecoin (.bit)</string>
|
||||
<string name="look_up_follow_list">Look Up Follow List</string>
|
||||
<string name="tip">Tip</string>
|
||||
<string name="accounts_found">%1$d accounts found</string>
|
||||
@@ -1849,6 +1849,21 @@
|
||||
<string name="select_all">Select All</string>
|
||||
<string name="uptime">%1$d%% uptime</string>
|
||||
<string name="namecoin_settings">Namecoin Settings</string>
|
||||
<string name="namecoin_test_connection">Test Connection</string>
|
||||
<string name="namecoin_testing">Testing servers…</string>
|
||||
<string name="namecoin_test_success">Connected</string>
|
||||
<string name="namecoin_test_failed">Failed</string>
|
||||
<string name="namecoin_test_results">Test Results</string>
|
||||
<string name="namecoin_diagnostics">Diagnostics</string>
|
||||
<string name="namecoin_last_test">Last test</string>
|
||||
<string name="namecoin_device_info">Device Info</string>
|
||||
<string name="namecoin_tls_info">TLS Info</string>
|
||||
<string name="namecoin_no_test_yet">No test run yet</string>
|
||||
<string name="namecoin_response_time">%dms</string>
|
||||
<string name="namecoin_pin_cert_title">Trust Server Certificate?</string>
|
||||
<string name="namecoin_pin_cert_body">The server %1$s presented a certificate not yet in your trust store. Verify the fingerprint below matches what the server operator published, then choose whether to trust it for future connections.</string>
|
||||
<string name="namecoin_pin_cert_accept">Trust</string>
|
||||
<string name="namecoin_pin_cert_reject">Reject</string>
|
||||
<string name="event_sync_title">Relay Sync</string>
|
||||
<string name="event_sync_section">Relay Sync</string>
|
||||
<string name="event_sync_section_explainer">Re-publish your events across all known relays to keep your outbox, inbox, and DM relays up to date. Requires Wi-Fi — this may use a lot of data.</string>
|
||||
|
||||
+2
-2
@@ -56,7 +56,7 @@ class NamecoinSettingsTest {
|
||||
assertEquals("abc123def.onion", s!!.host)
|
||||
assertEquals(50001, s.port)
|
||||
assertFalse(s.useSsl)
|
||||
assertTrue(s.trustAllCerts)
|
||||
assertTrue(s.usePinnedTrustStore)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -131,7 +131,7 @@ class NamecoinSettingsTest {
|
||||
assertTrue(servers[0].useSsl)
|
||||
assertEquals("server2.onion", servers[1].host)
|
||||
assertFalse(servers[1].useSsl)
|
||||
assertTrue(servers[1].trustAllCerts)
|
||||
assertTrue(servers[1].usePinnedTrustStore)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+29
-8
@@ -44,8 +44,14 @@ data class ElectrumxServer(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val useSsl: Boolean = true,
|
||||
/** If true, accept any certificate (self-signed, expired, etc.) */
|
||||
val trustAllCerts: Boolean = false,
|
||||
/**
|
||||
* If true, use the pinned trust store (hardcoded + TOFU-pinned certs
|
||||
* plus system CAs) instead of the default system-only trust store.
|
||||
*
|
||||
* Required for ElectrumX servers that use self-signed certificates,
|
||||
* which is the norm for the Namecoin ElectrumX ecosystem.
|
||||
*/
|
||||
val usePinnedTrustStore: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -72,12 +78,27 @@ sealed class NamecoinLookupException(
|
||||
) : NamecoinLookupException("All ElectrumX servers unreachable", lastError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of testing connectivity to a single ElectrumX server.
|
||||
*/
|
||||
data class ServerTestResult(
|
||||
val server: ElectrumxServer,
|
||||
val success: Boolean,
|
||||
val responseTimeMs: Long,
|
||||
val error: String? = null,
|
||||
val tlsVersion: String? = null,
|
||||
/** PEM-encoded server certificate, captured during test for TOFU pinning. */
|
||||
val serverCertPem: String? = null,
|
||||
/** SHA-256 fingerprint of the server certificate. */
|
||||
val certFingerprint: String? = null,
|
||||
)
|
||||
|
||||
/** Well-known public Namecoin ElectrumX servers (clearnet). */
|
||||
val DEFAULT_ELECTRUMX_SERVERS =
|
||||
listOf(
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true),
|
||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true),
|
||||
ElectrumxServer("46.229.238.187", 57002, useSsl = true, usePinnedTrustStore = true),
|
||||
)
|
||||
|
||||
/** Tor-preferred server list: onion primary, clearnet fallback. */
|
||||
@@ -87,8 +108,8 @@ val TOR_ELECTRUMX_SERVERS =
|
||||
"i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion",
|
||||
50002,
|
||||
useSsl = true,
|
||||
trustAllCerts = true,
|
||||
usePinnedTrustStore = true,
|
||||
),
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, usePinnedTrustStore = true),
|
||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, usePinnedTrustStore = true),
|
||||
)
|
||||
|
||||
+429
-32
@@ -38,19 +38,22 @@ import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.BufferedReader
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.KeyStore
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.net.SocketFactory
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
@@ -87,25 +90,6 @@ class ElectrumXClient(
|
||||
private val requestId = AtomicInteger(0)
|
||||
private val serverMutexes = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
companion object {
|
||||
private const val PROTOCOL_VERSION = "1.4"
|
||||
|
||||
/**
|
||||
* Namecoin names expire this many blocks after their last update.
|
||||
* From chainparams.cpp: consensus.nNameExpirationDepth = 36000
|
||||
* (~250 days at ~10 min/block).
|
||||
*/
|
||||
const val NAME_EXPIRE_DEPTH = 36_000
|
||||
|
||||
// Namecoin script opcodes
|
||||
private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin
|
||||
private const val OP_2DROP: Byte = 0x6d
|
||||
private const val OP_DROP: Byte = 0x75
|
||||
private const val OP_RETURN: Byte = 0x6a
|
||||
private const val OP_PUSHDATA1: Byte = 0x4c
|
||||
private const val OP_PUSHDATA2: Byte = 0x4d
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a name_show lookup against the given ElectrumX server.
|
||||
*
|
||||
@@ -167,6 +151,162 @@ class ElectrumXClient(
|
||||
throw NamecoinLookupException.ServersUnreachable(lastError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connectivity to a single ElectrumX server.
|
||||
*
|
||||
* Connects, negotiates protocol version, and optionally resolves a test
|
||||
* name. Returns detailed results including response time, TLS version,
|
||||
* and human-readable error messages.
|
||||
*
|
||||
* @param server The server to test
|
||||
* @param testName Optional name to resolve (e.g. "d/testls")
|
||||
* @return [ServerTestResult] with success/failure details
|
||||
*/
|
||||
suspend fun testServer(
|
||||
server: ElectrumxServer,
|
||||
testName: String? = "d/testls",
|
||||
): ServerTestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
val startTime = System.currentTimeMillis()
|
||||
try {
|
||||
val socket = createSocket(server)
|
||||
socket.soTimeout = readTimeoutMs.toInt()
|
||||
|
||||
var tlsVersion: String? = null
|
||||
var serverCertPem: String? = null
|
||||
var certFingerprint: String? = null
|
||||
|
||||
if (socket is javax.net.ssl.SSLSocket) {
|
||||
tlsVersion = socket.session.protocol
|
||||
// Capture the server's leaf certificate for TOFU pinning
|
||||
try {
|
||||
val peerCerts = socket.session.peerCertificates
|
||||
if (peerCerts.isNotEmpty() && peerCerts[0] is java.security.cert.X509Certificate) {
|
||||
val x509 = peerCerts[0] as java.security.cert.X509Certificate
|
||||
// PEM encode
|
||||
val encoded =
|
||||
java.util.Base64
|
||||
.getMimeEncoder(76, "\n".toByteArray())
|
||||
.encodeToString(x509.encoded)
|
||||
serverCertPem = "-----BEGIN CERTIFICATE-----\n$encoded-----END CERTIFICATE-----"
|
||||
// SHA-256 fingerprint
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(x509.encoded)
|
||||
certFingerprint = digest.joinToString(":") { "%02X".format(it) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Non-fatal — cert capture is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
val writer = PrintWriter(socket.getOutputStream(), true)
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
|
||||
|
||||
try {
|
||||
// Negotiate protocol version
|
||||
val versionReq =
|
||||
buildRpcRequest(
|
||||
"server.version",
|
||||
listOf("AmethystNMC/0.1", PROTOCOL_VERSION),
|
||||
)
|
||||
writer.println(versionReq)
|
||||
val versionResponse =
|
||||
reader.readLine()
|
||||
?: return@withContext ServerTestResult(
|
||||
server = server,
|
||||
success = false,
|
||||
responseTimeMs = System.currentTimeMillis() - startTime,
|
||||
error = "Server returned empty response",
|
||||
tlsVersion = tlsVersion,
|
||||
)
|
||||
|
||||
// If a test name is provided, try to resolve it
|
||||
if (testName != null) {
|
||||
val nameScript =
|
||||
buildNameIndexScript(testName.toByteArray(Charsets.US_ASCII))
|
||||
val scriptHash = electrumScriptHash(nameScript)
|
||||
val historyReq =
|
||||
buildRpcRequest(
|
||||
"blockchain.scripthash.get_history",
|
||||
listOf(scriptHash),
|
||||
)
|
||||
writer.println(historyReq)
|
||||
reader.readLine() // consume response
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
ServerTestResult(
|
||||
server = server,
|
||||
success = true,
|
||||
responseTimeMs = elapsed,
|
||||
tlsVersion = tlsVersion,
|
||||
serverCertPem = serverCertPem,
|
||||
certFingerprint = certFingerprint,
|
||||
)
|
||||
} finally {
|
||||
runCatching { writer.close() }
|
||||
runCatching { reader.close() }
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
} catch (e: java.net.ConnectException) {
|
||||
ServerTestResult(
|
||||
server = server,
|
||||
success = false,
|
||||
responseTimeMs = System.currentTimeMillis() - startTime,
|
||||
error = "Connection refused",
|
||||
)
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
ServerTestResult(
|
||||
server = server,
|
||||
success = false,
|
||||
responseTimeMs = System.currentTimeMillis() - startTime,
|
||||
error = "Connection timed out after ${connectTimeoutMs / 1000}s",
|
||||
)
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
ServerTestResult(
|
||||
server = server,
|
||||
success = false,
|
||||
responseTimeMs = System.currentTimeMillis() - startTime,
|
||||
error = "Server unreachable (DNS resolution failed)",
|
||||
)
|
||||
} catch (e: javax.net.ssl.SSLHandshakeException) {
|
||||
val detail =
|
||||
if (e.message?.contains("self-signed", ignoreCase = true) == true ||
|
||||
e.message?.contains("anchor", ignoreCase = true) == true
|
||||
) {
|
||||
"TLS handshake failed (self-signed certificate rejected)"
|
||||
} else {
|
||||
"TLS handshake failed: ${e.message?.take(100) ?: "unknown error"}"
|
||||
}
|
||||
ServerTestResult(
|
||||
server = server,
|
||||
success = false,
|
||||
responseTimeMs = System.currentTimeMillis() - startTime,
|
||||
error = detail,
|
||||
)
|
||||
} catch (e: javax.net.ssl.SSLException) {
|
||||
ServerTestResult(
|
||||
server = server,
|
||||
success = false,
|
||||
responseTimeMs = System.currentTimeMillis() - startTime,
|
||||
error = "TLS error: ${e.message?.take(100) ?: "unknown"}",
|
||||
)
|
||||
} catch (e: java.io.IOException) {
|
||||
ServerTestResult(
|
||||
server = server,
|
||||
success = false,
|
||||
responseTimeMs = System.currentTimeMillis() - startTime,
|
||||
error = "I/O error: ${e.message?.take(100) ?: "unknown"}",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ServerTestResult(
|
||||
server = server,
|
||||
success = false,
|
||||
responseTimeMs = System.currentTimeMillis() - startTime,
|
||||
error = e.message?.take(150) ?: "Unknown error",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────
|
||||
|
||||
private fun connectAndQuery(
|
||||
@@ -461,41 +601,298 @@ class ElectrumXClient(
|
||||
if (!server.useSsl) return baseSocket
|
||||
|
||||
// Upgrade to TLS over the already-connected (possibly proxied) socket.
|
||||
// When usePinnedTrustStore is set, we use a pinned trust store that
|
||||
// contains the known ElectrumX server certs plus system CAs. This is
|
||||
// required because Samsung One UI 7 (Android 16) and GrapheneOS
|
||||
// reject connections that use a no-op "trust-all" X509TrustManager.
|
||||
val sslFactory =
|
||||
if (server.trustAllCerts) {
|
||||
trustAllSslFactory()
|
||||
if (server.host.endsWith(".onion")) {
|
||||
// .onion addresses: prefer the pinned factory (the .onion server's
|
||||
// cert is already in PINNED_ELECTRUMX_CERTS). Fall back to trust-all
|
||||
// only if the pinned handshake fails — this keeps compatibility with
|
||||
// .onion servers whose certs aren't pinned yet, while avoiding a
|
||||
// blanket trust-all that hardened TLS stacks (GrapheneOS, Samsung
|
||||
// Knox) may reject at the Conscrypt/BoringSSL layer.
|
||||
cachedPinnedSslFactory()
|
||||
} else if (server.usePinnedTrustStore) {
|
||||
cachedPinnedSslFactory()
|
||||
} else {
|
||||
SSLSocketFactory.getDefault() as SSLSocketFactory
|
||||
}
|
||||
return sslFactory.createSocket(baseSocket, server.host, server.port, true)
|
||||
val sslSocket: Socket
|
||||
try {
|
||||
sslSocket = sslFactory.createSocket(baseSocket, server.host, server.port, true)
|
||||
} catch (e: javax.net.ssl.SSLHandshakeException) {
|
||||
if (server.host.endsWith(".onion")) {
|
||||
// Pinned factory failed for .onion — fall back to trust-all.
|
||||
// This is safe: Tor provides E2E authentication via the onion
|
||||
// address, and the proxied socket bypasses Knox/GrapheneOS
|
||||
// trust-all rejection in practice.
|
||||
val fallbackSocket = onionSslFactory().createSocket(baseSocket, server.host, server.port, true)
|
||||
if (fallbackSocket is javax.net.ssl.SSLSocket) {
|
||||
val supported = fallbackSocket.supportedProtocols
|
||||
val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" }
|
||||
if (modern.isNotEmpty()) {
|
||||
fallbackSocket.enabledProtocols = modern.toTypedArray()
|
||||
}
|
||||
}
|
||||
return fallbackSocket
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
// Enforce TLSv1.2+ — some OEM Conscrypt forks (Xiaomi MIUI, OnePlus ColorOS)
|
||||
// may negotiate TLS 1.0/1.1 by default for raw socket upgrades.
|
||||
if (sslSocket is javax.net.ssl.SSLSocket) {
|
||||
val supported = sslSocket.supportedProtocols
|
||||
val modern = supported.filter { it == "TLSv1.2" || it == "TLSv1.3" }
|
||||
if (modern.isNotEmpty()) {
|
||||
sslSocket.enabledProtocols = modern.toTypedArray()
|
||||
}
|
||||
}
|
||||
|
||||
return sslSocket
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSLSocketFactory that accepts any certificate.
|
||||
* Used for servers with self-signed certificates.
|
||||
* Fallback SSLSocketFactory for .onion addresses when the pinned
|
||||
* factory fails (e.g. cert rotated, unknown .onion server).
|
||||
*
|
||||
* Only used as a last resort after cachedPinnedSslFactory() throws
|
||||
* SSLHandshakeException. This is safe because:
|
||||
* 1. The connection is already end-to-end encrypted by Tor.
|
||||
* 2. The onion address IS the server's identity proof (public key hash).
|
||||
* 3. Proxied sockets via Tor SOCKS typically bypass OEM trust-all
|
||||
* rejection (Samsung Knox, GrapheneOS hardened Conscrypt).
|
||||
*
|
||||
* Note: GrapheneOS or future Android versions may reject trust-all
|
||||
* TrustManagers even for proxied sockets. If this fallback stops
|
||||
* working, the .onion server's cert should be added to
|
||||
* PINNED_ELECTRUMX_CERTS (it's already there for the known server).
|
||||
*/
|
||||
private fun trustAllSslFactory(): SSLSocketFactory {
|
||||
val trustAllCerts =
|
||||
private fun onionSslFactory(): SSLSocketFactory {
|
||||
val trustAll =
|
||||
arrayOf<TrustManager>(
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String,
|
||||
) {}
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<X509Certificate>,
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String,
|
||||
) {}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
},
|
||||
)
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustAllCerts, SecureRandom())
|
||||
val ctx =
|
||||
try {
|
||||
SSLContext.getInstance("TLSv1.2")
|
||||
} catch (_: Exception) {
|
||||
SSLContext.getInstance("TLS")
|
||||
}
|
||||
ctx.init(null, trustAll, SecureRandom())
|
||||
return ctx.socketFactory
|
||||
}
|
||||
|
||||
/** User-supplied PEM certificates for custom servers (TOFU-pinned). */
|
||||
private val dynamicCerts = mutableListOf<String>()
|
||||
|
||||
/** Lazy-cached SSLSocketFactory for pinned certs. Thread-safe via volatile + DCL. */
|
||||
@Volatile
|
||||
private var pinnedFactory: SSLSocketFactory? = null
|
||||
|
||||
private fun cachedPinnedSslFactory(): SSLSocketFactory {
|
||||
pinnedFactory?.let { return it }
|
||||
synchronized(this) {
|
||||
pinnedFactory?.let { return it }
|
||||
return buildPinnedSslFactory().also { pinnedFactory = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a PEM-encoded certificate to the dynamic trust store.
|
||||
* Typically called after the user confirms a cert fingerprint via
|
||||
* the "Test Connection" flow in settings.
|
||||
*
|
||||
* Invalidates the cached factory so the next connection picks it up.
|
||||
*/
|
||||
fun addPinnedCert(pem: String) {
|
||||
synchronized(this) {
|
||||
dynamicCerts.add(pem)
|
||||
pinnedFactory = null // force rebuild
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all dynamic certs (e.g. loaded from preferences on startup).
|
||||
*/
|
||||
fun setDynamicCerts(pems: List<String>) {
|
||||
synchronized(this) {
|
||||
dynamicCerts.clear()
|
||||
dynamicCerts.addAll(pems)
|
||||
pinnedFactory = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SSLSocketFactory that trusts the pinned ElectrumX server
|
||||
* certificates plus the system CA store.
|
||||
*
|
||||
* Previous versions used a "trust-all" TrustManager, but Samsung
|
||||
* devices running One UI 7 (Android 16) silently reject connections
|
||||
* that use a no-op X509TrustManager. Pinning the known self-signed
|
||||
* certs avoids this while maintaining security.
|
||||
*
|
||||
* Also handles OEM-specific quirks:
|
||||
* - Xiaomi MIUI/HyperOS: KeyStore.getDefaultType() may return unexpected
|
||||
* types; we try the default first, then fall back to "PKCS12".
|
||||
* - OnePlus ColorOS: some versions require explicit TLSv1.2 protocol.
|
||||
* - All OEMs: SSLContext("TLSv1.2") is preferred over ("TLS") which may
|
||||
* resolve to TLS 1.0 on older Conscrypt forks.
|
||||
*/
|
||||
private fun buildPinnedSslFactory(): SSLSocketFactory {
|
||||
val ks =
|
||||
try {
|
||||
KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) }
|
||||
} catch (_: Exception) {
|
||||
// Fallback for Xiaomi devices where getDefaultType() returns an unsupported type
|
||||
KeyStore.getInstance("PKCS12").apply { load(null, null) }
|
||||
}
|
||||
|
||||
val cf = CertificateFactory.getInstance("X.509")
|
||||
|
||||
// Load hardcoded + dynamic pinned certificates into the keystore
|
||||
val allCerts = PINNED_ELECTRUMX_CERTS + dynamicCerts
|
||||
for ((index, pem) in allCerts.withIndex()) {
|
||||
try {
|
||||
val cert = cf.generateCertificate(ByteArrayInputStream(pem.toByteArray(Charsets.US_ASCII)))
|
||||
ks.setCertificateEntry("electrumx_$index", cert)
|
||||
} catch (_: Exception) {
|
||||
// Skip malformed certs — the remaining ones may still work
|
||||
}
|
||||
}
|
||||
|
||||
// Also load system CA certificates so that servers with real certs work too
|
||||
val systemTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
systemTmf.init(null as KeyStore?) // null = system default
|
||||
val systemTm = systemTmf.trustManagers.filterIsInstance<X509TrustManager>().firstOrNull()
|
||||
if (systemTm != null) {
|
||||
for ((index, issuer) in systemTm.acceptedIssuers.withIndex()) {
|
||||
try {
|
||||
ks.setCertificateEntry("system_$index", issuer)
|
||||
} catch (_: Exception) {
|
||||
// Some OEMs return certs that can't be re-inserted; skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(ks)
|
||||
|
||||
// Prefer TLSv1.2 explicitly — SSLContext.getInstance("TLS") can resolve
|
||||
// to TLS 1.0 on some OEM Conscrypt forks (Xiaomi, OnePlus).
|
||||
val sslContext =
|
||||
try {
|
||||
SSLContext.getInstance("TLSv1.2")
|
||||
} catch (_: Exception) {
|
||||
SSLContext.getInstance("TLS")
|
||||
}
|
||||
sslContext.init(null, tmf.trustManagers, SecureRandom())
|
||||
return sslContext.socketFactory
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PROTOCOL_VERSION = "1.4"
|
||||
|
||||
/**
|
||||
* Namecoin names expire this many blocks after their last update.
|
||||
* From chainparams.cpp: consensus.nNameExpirationDepth = 36000
|
||||
* (~250 days at ~10 min/block).
|
||||
*/
|
||||
const val NAME_EXPIRE_DEPTH = 36_000
|
||||
|
||||
// Namecoin script opcodes
|
||||
private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin
|
||||
private const val OP_2DROP: Byte = 0x6d
|
||||
private const val OP_DROP: Byte = 0x75
|
||||
private const val OP_RETURN: Byte = 0x6a
|
||||
private const val OP_PUSHDATA1: Byte = 0x4c
|
||||
private const val OP_PUSHDATA2: Byte = 0x4d
|
||||
|
||||
/**
|
||||
* PEM-encoded certificates for the well-known Namecoin ElectrumX servers.
|
||||
*
|
||||
* These are self-signed certificates that cannot be verified by the
|
||||
* system CA store. We pin them explicitly so that connections succeed
|
||||
* on devices with strict TLS enforcement (e.g. Samsung One UI 7).
|
||||
*
|
||||
* To update: `echo | openssl s_client -connect HOST:PORT 2>/dev/null | openssl x509 -outform PEM`
|
||||
* For .onion: `python3 -c "import socks,ssl,socket,base64; s=socks.socksocket(); s.set_proxy(socks.SOCKS5,'127.0.0.1',9050); s.connect(('HOST',PORT)); ctx=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE; ss=ctx.wrap_socket(s); print(base64.encodebytes(ss.getpeercert(True)).decode())"`
|
||||
*/
|
||||
private val PINNED_ELECTRUMX_CERTS =
|
||||
listOf(
|
||||
// electrumx.testls.space:50002 — expires 2027-05-04
|
||||
// Also covers the .onion hidden service (same operator, same cert):
|
||||
// i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002
|
||||
// SHA-256: 53:65:D5:BB:26:19:F5:40:1C:D8:8E:FC:AF:FB:A5:B2:A0:EA:7A:99:2D:F7:0F:05:7E:9B:CD:50:36:C7:79:9C
|
||||
"""
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDwzCCAqsCFGGKT5mjh7oN98aNyjOCiqafL8VyMA0GCSqGSIb3DQEBCwUAMIGd
|
||||
MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn
|
||||
bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM
|
||||
FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84
|
||||
OUBob3RtYWlsLmNvbTAeFw0yMjA1MDUwNjIzNDFaFw0yNzA1MDQwNjIzNDFaMIGd
|
||||
MQswCQYDVQQGEwJVUzEQMA4GA1UECAwHQ2hpY2FnbzEQMA4GA1UEBwwHQ2hpY2Fn
|
||||
bzESMBAGA1UECgwJSW50ZXJuZXRzMQ8wDQYDVQQLDAZJbnRlcncxHjAcBgNVBAMM
|
||||
FWVsZWN0cnVtLnRlc3Rscy5zcGFjZTElMCMGCSqGSIb3DQEJARYWbWpfZ2lsbF84
|
||||
OUBob3RtYWlsLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO4H
|
||||
+PKCdiiz3jNOA77aAmS2YaU7eOQ8ZGliEVr/PlLcgF5gmthb2DI6iK4KhC1ad34G
|
||||
1n9IhkXPhkVJ94i8wB3uoTBlA7mI5h59m01yhzSkJAoYoU/i6DM9ipbakqWFCTEp
|
||||
P+yE216NTU5MbYwThZdRSAIIABe9RyIliMSidyrwHvKBLfnJPFScghW6rhBWN7PG
|
||||
PA8k0MFGzf+HXbpnV/jAvz08ZC34qiBIjkJrTgh49JweyoZKdppyJcH4UbkslJ2t
|
||||
YUJR3oURBvrPj+D7TwLVRbX36ul7r4+dP3IjgmljsSAHDK4N/PfWrCBdlj9Pc1Cp
|
||||
yX+ZDh8X2NrL4ukHoVMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAeVj6VZNmY/Vb
|
||||
nhzrC7xBSHqVWQ1wkLOClLsdvgKP8cFFJuUoCMQU5bPMi7nWnkfvvsIKH4Eibk5K
|
||||
fqiA9jVsY0FHvQ8gP3KMk1LVuUf/sTcRe5itp3guBOSk/zXZUD5tUz/oRk3k+rdc
|
||||
MsInqhomjNy/dqYmD6Wm4DNPjZh6fWy+AVQKVNOI2t4koaVdpoi8Uv8h4gFGPbdI
|
||||
sVmtoGiIGkKNIWum+6mnF6PfynNrLk+ztH4TrdacVNeoJUPYEAxOuesWXFy3H4r+
|
||||
HKBqA4xAzyjgKLPqoWnjSu7gxj1GIjBhnDxkM6wUOnDq8A0EqxR+A17OcXW9sZ2O
|
||||
2ZIVwmtnyA==
|
||||
-----END CERTIFICATE-----
|
||||
""".trimIndent(),
|
||||
// nmc2.bitcoins.sk:57002 / 46.229.238.187:57002 — expires 2030-10-22
|
||||
"""
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIID+TCCAuGgAwIBAgIUdmJGukmfPvqmAYpTfuGcjRoYHJ8wDQYJKoZIhvcNAQEL
|
||||
BQAwgYsxCzAJBgNVBAYTAlNLMREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwK
|
||||
QnJhdGlzbGF2YTEUMBIGA1UECgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIu
|
||||
Yml0Y29pbnMuc2sxIzAhBgkqhkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3Jn
|
||||
MB4XDTIwMTAyNDE5MjQzOVoXDTMwMTAyMjE5MjQzOVowgYsxCzAJBgNVBAYTAlNL
|
||||
MREwDwYDVQQIDAhTbG92YWtpYTETMBEGA1UEBwwKQnJhdGlzbGF2YTEUMBIGA1UE
|
||||
CgwLYml0Y29pbnMuc2sxGTAXBgNVBAMMEG5tYzIuYml0Y29pbnMuc2sxIzAhBgkq
|
||||
hkiG9w0BCQEWFGRlYWZib3lAY2ljb2xpbmEub3JnMIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEAzBUkZNDfaz7kc28l5tDKohJjekWmz1ynzfGx3ZLsqOZE
|
||||
c+kNfcMaWU+zT/j0mV6pX6KSH7G9pPAku+8PRdKRq+d63wiJDEjGSaFztQWKW6L1
|
||||
vTxgCK5gu+Eir3BkTagJObsrLKS+T6qH610/3+btGgoR3lunB5TzCgB/9oQanjDW
|
||||
zjg2CwmxgR5Iw1Eqfenx7zkSK33FSXSF2SvbUs1Atj2oPU4DLivyrx0RaUmaPemn
|
||||
cmcpnax+py4pQeB6dJWU1INhzXt3hTJRyoqsSGY3vCECIKIBIkh8GsYjAX4z+Y9y
|
||||
6pJx0da2b88qPWdsoxaIMvrQiuWknDrSJwAyw2Yd8QIDAQABo1MwUTAdBgNVHQ4E
|
||||
FgQUT2J83B2/9jxGGdFeWrxMohTzHNwwHwYDVR0jBBgwFoAUT2J83B2/9jxGGdFe
|
||||
WrxMohTzHNwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAsbxX
|
||||
wN8tZaXOybImMZCQS7zfxmKl2IAcqu+R01KPfnIfrFqXPsGDDl3rYLkwh1O4/hYQ
|
||||
NKNW9KTxoJxuBmAkm7EXQQh1XUUzajdEDqDBVRyvR0Z2MdMYnMSAiiMXMl2wUZnc
|
||||
QXYftBo0HbtfsaJjImQdDjmlmRPSzE/RW6iUe+1cesKBC7e8nVf69Yu/fxO4m083
|
||||
VWwAstlWJfk1GyU7jzVc8svealg/oIiDoOMe6CFSLx1BDv2FeHSpRdqd3fn+AC73
|
||||
bK2N2smrHUOQnFijuiFw3WOrjERi0eMhjVNfVu9W9ZYa/Wd6SdIzV55LbG+NpmSf
|
||||
5W7ix41hRvdT6cTAJA==
|
||||
-----END CERTIFICATE-----
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildRpcRequest(
|
||||
method: String,
|
||||
params: List<Any>,
|
||||
|
||||
Reference in New Issue
Block a user