From d73be2910a3ab4e1e0473599fbbcc535908e8765 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:32:38 +0000 Subject: [PATCH 01/32] feat: redesign Backup Keys screen with a hand-copyable nsec grid The Account Backup screen was two walls of markdown with small buttons, and no way to copy the key onto paper. It is now: - A short header, then a "Secret key" card that shows the nsec as three numbered rows of 5-4-4-4-4 groups in UPPERCASE monospace, masked until the user taps to reveal. Copy and QR buttons sit under it. - A "Password-protected copy" card (NIP-49 ncryptsec) with a password field and Copy / QR buttons. scrypt now runs off the main thread. - Three one-line tips in place of the markdown blocks. The device-credential prompt now runs once per visit, not on every button. The key is hidden again and the approval dropped when the app goes to the background. The grid (KeyTranscriptionGrid, commonsUI) and the grouping (Bech32Transcription, quartz) are shared; the desktop backup card uses the same grid for its revealed nsec. Login on Android and Desktop now normalizes hand-typed keys, so a key copied from paper (uppercase, dashes/spaces/newlines between groups) logs in. Before, an uppercase nsec fell through to the hex parser and was rejected. Removed string keys are deleted from every locale in this commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP --- .../ui/screen/AccountSessionManager.kt | 20 +- .../loggedIn/keyBackup/AccountBackupScreen.kt | 743 ++++++++++-------- .../screen/loggedOff/login/LoginViewModel.kt | 3 +- .../values-ar-rSA/strings.xml | 14 - .../values-bn-rBD/strings.xml | 14 - .../composeResources/values-cs/strings.xml | 12 - .../values-de-rDE/strings.xml | 12 - .../composeResources/values-de/strings.xml | 12 - .../values-el-rGR/strings.xml | 14 - .../values-eo-rUY/strings.xml | 14 - .../composeResources/values-eo/strings.xml | 16 - .../values-es-rES/strings.xml | 14 - .../values-es-rMX/strings.xml | 14 - .../values-es-rUS/strings.xml | 14 - .../composeResources/values-es/strings.xml | 14 - .../values-fa-rIR/strings.xml | 13 - .../composeResources/values-fa/strings.xml | 13 - .../values-fi-rFI/strings.xml | 14 - .../values-fr-rCA/strings.xml | 14 - .../values-fr-rFR/strings.xml | 14 - .../composeResources/values-fr/strings.xml | 14 - .../values-hi-rIN/strings.xml | 14 - .../values-hu-rHU/strings.xml | 14 - .../values-in-rID/strings.xml | 14 - .../composeResources/values-in/strings.xml | 16 - .../values-it-rIT/strings.xml | 14 - .../values-ja-rJP/strings.xml | 14 - .../composeResources/values-ja/strings.xml | 16 - .../values-ko-rKR/strings.xml | 14 - .../values-lv-rLV/strings.xml | 14 - .../values-nl-rBE/strings.xml | 7 - .../values-nl-rNL/strings.xml | 7 - .../composeResources/values-nl/strings.xml | 14 - .../values-pl-rPL/strings.xml | 14 - .../values-pt-rBR/strings.xml | 12 - .../values-pt-rPT/strings.xml | 12 - .../values-ru-rRU/strings.xml | 14 - .../values-ru-rUA/strings.xml | 14 - .../composeResources/values-ru/strings.xml | 16 - .../values-sl-rSI/strings.xml | 15 - .../values-sr-rSP/strings.xml | 14 - .../values-sv-rSE/strings.xml | 12 - .../composeResources/values-sw/strings.xml | 14 - .../values-ta-rIN/strings.xml | 14 - .../composeResources/values-ta/strings.xml | 16 - .../values-th-rTH/strings.xml | 13 - .../composeResources/values-th/strings.xml | 13 - .../values-tr-rTR/strings.xml | 14 - .../composeResources/values-tr/strings.xml | 16 - .../values-uk-rUA/strings.xml | 14 - .../composeResources/values-uk/strings.xml | 16 - .../values-uz-rUZ/strings.xml | 14 - .../values-vi-rVN/strings.xml | 14 - .../values-zh-rCN/strings.xml | 14 - .../values-zh-rHK/strings.xml | 14 - .../values-zh-rSG/strings.xml | 14 - .../values-zh-rTW/strings.xml | 14 - .../composeResources/values-zh/strings.xml | 14 - .../composeResources/values/strings.xml | 27 +- .../ui/components/KeyTranscriptionGrid.kt | 103 +++ .../desktop/account/AccountManager.kt | 4 +- .../desktop/ui/keyBackup/BackupKeysCard.kt | 24 +- .../account/AccountManagerKeyLoginTest.kt | 14 + .../quartz/nip19Bech32/Bech32Transcription.kt | 83 ++ .../nip19Bech32/Bech32TranscriptionTest.kt | 79 ++ 65 files changed, 728 insertions(+), 1127 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/KeyTranscriptionGrid.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Bech32Transcription.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/Bech32TranscriptionTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index b8f38aab80..df90877846 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 +import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull @@ -202,14 +203,17 @@ class AccountSessionManager( packageName: String = "", onError: (String?) -> Unit, ) { + // Accepts keys copied by hand from the backup screen: UPPERCASE, and split + // into groups by spaces, dashes or line breaks. + val cleanKey = Bech32Transcription.normalize(key) scope.launch(Dispatchers.IO) { - if (key.startsWith("ncryptsec")) { + if (cleanKey.startsWith("ncryptsec")) { val newKey = try { - if (key.isEmpty() || password.isEmpty()) { + if (cleanKey.isEmpty() || password.isEmpty()) { null } else { - Nip49().decrypt(key, password) + Nip49().decrypt(cleanKey, password) } } catch (e: Exception) { if (e is CancellationException) throw e @@ -223,24 +227,24 @@ class AccountSessionManager( } else { loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError) } - } else if (EMAIL_PATTERN.matcher(key).matches()) { + } else if (EMAIL_PATTERN.matcher(cleanKey).matches()) { // Delegate to the shared quartz resolver so NIP-05 handling stays in // lockstep with the CLI and anywhere else we accept user identifiers. try { val hex = com.vitorpamplona.quartz.nip05DnsIdentifiers - .resolveUserHexOrNull(key, nip05ClientBuilder()) + .resolveUserHexOrNull(cleanKey, nip05ClientBuilder()) if (hex == null) { - onError("User not found in the nip05 server: $key") + onError("User not found in the nip05 server: $cleanKey") } else { loginSync(Hex.decode(hex).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError) } } catch (e: Exception) { if (e is CancellationException) throw e - onError("Could not load nip05 address from the server: $key. ${e.message}") + onError("Could not load nip05 address from the server: $cleanKey. ${e.message}") } } else { - loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError) + loginSync(cleanKey, transientAccount, loginWithExternalSigner, packageName, onError) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index de2b248ff6..c90f702085 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -29,21 +29,25 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -53,7 +57,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -62,46 +67,52 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.autofill.ContentType +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.fragment.app.FragmentActivity -import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions -import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser -import com.halilibo.richtext.markdown.BasicMarkdown -import com.halilibo.richtext.ui.RichTextStyle -import com.halilibo.richtext.ui.material3.RichText -import com.halilibo.richtext.ui.resolveDefaults -import com.vitorpamplona.amethyst.R +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.account_backup_tips2_md -import com.vitorpamplona.amethyst.commons.resources.account_backup_tips3_md +import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_body +import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_password +import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_title +import com.vitorpamplona.amethyst.commons.resources.account_backup_headline +import com.vitorpamplona.amethyst.commons.resources.account_backup_intro +import com.vitorpamplona.amethyst.commons.resources.account_backup_qr_code +import com.vitorpamplona.amethyst.commons.resources.account_backup_tap_to_reveal +import com.vitorpamplona.amethyst.commons.resources.account_backup_tip_developers +import com.vitorpamplona.amethyst.commons.resources.account_backup_tip_storage +import com.vitorpamplona.amethyst.commons.resources.account_backup_tip_trust +import com.vitorpamplona.amethyst.commons.resources.account_backup_write_down_hint import com.vitorpamplona.amethyst.commons.resources.backup_keys -import com.vitorpamplona.amethyst.commons.resources.copies_the_nsec_id_your_password_to_the_clipboard_for_backup -import com.vitorpamplona.amethyst.commons.resources.copy_my_secret_key -import com.vitorpamplona.amethyst.commons.resources.encrypt_and_copy_my_secret_key +import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy +import com.vitorpamplona.amethyst.commons.resources.backup_keys_external_signer +import com.vitorpamplona.amethyst.commons.resources.backup_keys_hide +import com.vitorpamplona.amethyst.commons.resources.backup_keys_reveal +import com.vitorpamplona.amethyst.commons.resources.backup_keys_secret_label import com.vitorpamplona.amethyst.commons.resources.failed_to_encrypt_key import com.vitorpamplona.amethyst.commons.resources.hide_password -import com.vitorpamplona.amethyst.commons.resources.ncryptsec_password -import com.vitorpamplona.amethyst.commons.resources.password_is_required import com.vitorpamplona.amethyst.commons.resources.secret_key_copied_to_clipboard -import com.vitorpamplona.amethyst.commons.resources.show_encrypted_private_key_qr_code import com.vitorpamplona.amethyst.commons.resources.show_password -import com.vitorpamplona.amethyst.commons.resources.show_private_key_qr_code +import com.vitorpamplona.amethyst.commons.ui.components.KeyTranscriptionGrid import com.vitorpamplona.amethyst.commons.ui.components.util.getText import com.vitorpamplona.amethyst.commons.ui.components.util.setText import com.vitorpamplona.amethyst.commons.ui.loadStringRes @@ -109,15 +120,9 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.commons.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.commons.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow -import com.vitorpamplona.amethyst.commons.ui.theme.grayText -import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.note.authenticate import com.vitorpamplona.amethyst.ui.note.rememberAuthPromptLabels -import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton @@ -126,13 +131,16 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource +import kotlinx.coroutines.withContext /** Best-effort delay before the plaintext nsec is wiped from the clipboard. */ private const val CLIPBOARD_CLEAR_DELAY_MS = 60_000L +private val CardShape = RoundedCornerShape(16.dp) + @Composable fun AccountBackupScreen( accountViewModel: AccountViewModel, @@ -152,7 +160,6 @@ fun AccountBackupScreenPreview() { } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AccountBackupScreenContent( accountViewModel: AccountViewModel, @@ -162,8 +169,6 @@ private fun AccountBackupScreenContent( // screen is on-screen. Cleared on dispose so the flag never leaks to other // screens. This is the only FLAG_SECURE usage in the app — scoped on purpose. val context = LocalContext.current - val authLabels = rememberAuthPromptLabels() - val authLabelCopyKey = stringRes(Res.string.copy_my_secret_key) DisposableEffect(context) { val window = context.getFragmentActivity()?.window window?.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) @@ -184,217 +189,412 @@ private fun AccountBackupScreenContent( Modifier .fillMaxSize() .padding(it) - .padding(horizontal = 20.dp, vertical = 10.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - val content1 = stringRes(Res.string.account_backup_tips2_md) + BackupHeader() - val astNode1 = - remember { - CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content1) + val nsec = + remember(accountViewModel) { + accountViewModel.account.settings.keyPair.privKey + ?.toNsec() } - RichText( - style = RichTextStyle().resolveDefaults(), - renderer = null, - ) { - BasicMarkdown(astNode1) + if (nsec == null) { + TipRow(MaterialSymbols.Info, stringRes(Res.string.backup_keys_external_signer)) + } else { + val gate = rememberKeyAccessGate(accountViewModel) + + SecretKeyCard(nsec, gate) + + EncryptedKeyCard(accountViewModel, gate) + + BackupTips() } + } + } +} - Spacer(modifier = Modifier.height(20.dp)) +@Composable +private fun BackupHeader() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(28.dp), + ) + } + Spacer(Modifier.height(12.dp)) + Text( + text = stringRes(Res.string.account_backup_headline), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = stringRes(Res.string.account_backup_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} - Row { - Column { - NSecCopyButton(accountViewModel) - } +@Composable +private fun SecretKeyCard( + nsec: String, + gate: KeyAccessGate, +) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() - Column { - QrCodeButton(accountViewModel) + var revealed by remember { mutableStateOf(false) } + var showQr by remember { mutableStateOf(false) } + + // Never leave the key on screen while the app is in the background. + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + revealed = false + showQr = false + gate.lock() + } + + val reveal = { gate.withAccess { revealed = true } } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = CardShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + CardTitle(MaterialSymbols.Key, stringRes(Res.string.backup_keys_secret_label), Modifier.weight(1f)) + IconButton(onClick = { if (revealed) revealed = false else reveal() }) { + Icon( + symbol = if (revealed) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, + contentDescription = stringRes(if (revealed) Res.string.backup_keys_hide else Res.string.backup_keys_reveal), + ) } } - Spacer(modifier = Modifier.height(30.dp)) - - val content = stringRes(Res.string.account_backup_tips3_md) - - val astNode = - remember { - CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content) + Column(Modifier.padding(end = 8.dp)) { + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .clickable(enabled = !revealed, onClick = reveal) + .padding(horizontal = 12.dp, vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + KeyTranscriptionGrid( + bech32 = nsec, + masked = !revealed, + color = if (revealed) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.fillMaxWidth(), + ) + if (!revealed) { + RevealChip() + } } - RichText( - style = RichTextStyle().resolveDefaults(), - renderer = null, - ) { - BasicMarkdown(astNode) + Spacer(Modifier.height(8.dp)) + + Text( + text = stringRes(Res.string.account_backup_write_down_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton( + modifier = Modifier.weight(1f), + onClick = { gate.withAccess { copyNSec(context, scope, nsec, clipboard) } }, + ) { + ButtonContent(MaterialSymbols.ContentCopy, stringRes(Res.string.backup_keys_copy)) + } + FilledTonalButton( + modifier = Modifier.weight(1f), + onClick = { gate.withAccess { showQr = true } }, + ) { + ButtonContent(MaterialSymbols.QrCode2, stringRes(Res.string.account_backup_qr_code)) + } + } } + } + } - val password = remember { mutableStateOf(TextFieldValue("")) } - var errorMessage by remember { mutableStateOf("") } - var showCharsPassword by remember { mutableStateOf(false) } + if (showQr) { + ShowKeyQRDialog(nsec, onClose = { showQr = false }) + } +} - Spacer(modifier = Modifier.height(20.dp)) +@Composable +private fun RevealChip() { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shadowElevation = 2.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(MaterialSymbols.Visibility, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringRes(Res.string.account_backup_tap_to_reveal), style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +private fun EncryptedKeyCard( + accountViewModel: AccountViewModel, + gate: KeyAccessGate, +) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + var working by remember { mutableStateOf(false) } + var qrCode by remember { mutableStateOf(null) } + + // NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread. + fun encryptThen(onEncrypted: suspend (String) -> Unit) { + val privKey = accountViewModel.account.settings.keyPair.privKey ?: return + val currentPassword = password + working = true + scope.launch { + val encrypted = + withContext(Dispatchers.Default) { + runCatching { Nip49().encrypt(privKey.toHexKey(), currentPassword) }.getOrNull() + } + working = false + if (encrypted != null) { + onEncrypted(encrypted) + } else { + Toast.makeText(context, loadStringRes(Res.string.failed_to_encrypt_key), Toast.LENGTH_SHORT).show() + } + } + } + + val canEncrypt = password.isNotBlank() && !working + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = CardShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column(Modifier.padding(16.dp)) { + CardTitle(MaterialSymbols.Lock, stringRes(Res.string.account_backup_encrypted_title)) + + Spacer(Modifier.height(6.dp)) + + Text( + text = stringRes(Res.string.account_backup_encrypted_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) OutlinedTextField( modifier = Modifier - .semantics { contentType = ContentType.Password }, - value = password.value, - onValueChange = { - password.value = it - if (errorMessage.isNotEmpty()) { - errorMessage = "" - } - }, + .fillMaxWidth() + .semantics { contentType = ContentType.NewPassword }, + value = password, + onValueChange = { password = it }, + singleLine = true, + label = { Text(stringRes(Res.string.account_backup_encrypted_password)) }, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, + imeAction = ImeAction.Done, ), - placeholder = { - Text( - text = stringRes(Res.string.ncryptsec_password), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, trailingIcon = { - Row { - IconButton(onClick = { showCharsPassword = !showCharsPassword }) { - Icon( - symbol = if (showCharsPassword) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, - contentDescription = - if (showCharsPassword) { - stringRes(Res.string.show_password) - } else { - stringRes( - Res.string.hide_password, - ) - }, - ) - } + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + symbol = if (showPassword) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, + contentDescription = stringRes(if (showPassword) Res.string.hide_password else Res.string.show_password), + ) } }, - visualTransformation = - if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), ) - if (errorMessage.isNotBlank()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - ) + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + modifier = Modifier.weight(1f), + enabled = canEncrypt, + onClick = { + gate.withAccess { + encryptThen { encrypted -> + clipboard.setText(encrypted) + Toast.makeText(context, loadStringRes(Res.string.secret_key_copied_to_clipboard), Toast.LENGTH_SHORT).show() + } + } + }, + ) { + ButtonContent(MaterialSymbols.ContentCopy, stringRes(Res.string.backup_keys_copy)) + } + OutlinedButton( + modifier = Modifier.weight(1f), + enabled = canEncrypt, + onClick = { gate.withAccess { encryptThen { qrCode = it } } }, + ) { + ButtonContent(MaterialSymbols.QrCode2, stringRes(Res.string.account_backup_qr_code)) + } } - - Spacer(modifier = Modifier.height(10.dp)) - - EncryptNSecCopyButton(accountViewModel, password) } } + + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { qrCode = null } + + qrCode?.let { + ShowKeyQRDialog(it, onClose = { qrCode = null }) + } } @Composable -private fun NSecCopyButton(accountViewModel: AccountViewModel) { - val clipboardManager = LocalClipboard.current +private fun BackupTips() { + Column( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TipRow(MaterialSymbols.EditNote, stringRes(Res.string.account_backup_tip_storage)) + TipRow(MaterialSymbols.Warning, stringRes(Res.string.account_backup_tip_trust)) + TipRow(MaterialSymbols.Shield, stringRes(Res.string.account_backup_tip_developers)) + } +} + +@Composable +private fun CardTitle( + symbol: MaterialSymbol, + title: String, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Icon(symbol, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun TipRow( + symbol: MaterialSymbol, + text: String, +) { + Row(verticalAlignment = Alignment.Top) { + Icon(symbol, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(12.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun ButtonContent( + symbol: MaterialSymbol, + label: String, +) { + Icon(symbol, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(label) +} + +/** + * Asks for the device credential (biometric or lock screen) once per visit, before + * the key is first revealed, copied or shown as a QR code. [lock] forgets the approval, + * e.g. when the app goes to the background. + */ +@Stable +private class KeyAccessGate { + var isUnlocked by mutableStateOf(false) + private set + + /** The action waiting on the keyguard fallback activity to return. */ + var pending: (() -> Unit)? = null + + var prompt: ((onApproved: () -> Unit) -> Unit)? = null + + fun withAccess(action: () -> Unit) { + if (isUnlocked) { + action() + } else { + prompt?.invoke { + isUnlocked = true + action() + } + } + } + + fun lock() { + isUnlocked = false + pending = null + } +} + +@Composable +private fun rememberKeyAccessGate(accountViewModel: AccountViewModel): KeyAccessGate { val context = LocalContext.current val authLabels = rememberAuthPromptLabels() - val authLabelCopyKey = stringRes(Res.string.copy_my_secret_key) - val scope = rememberCoroutineScope() + val authTitle = stringRes(Res.string.backup_keys) + val gate = remember { KeyAccessGate() } val keyguardLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + val action = gate.pending + gate.pending = null if (result.resultCode == Activity.RESULT_OK) { - copyNSec(context, scope, accountViewModel.account, clipboardManager) + action?.invoke() } } - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { + SideEffect { + gate.prompt = { onApproved -> + gate.pending = onApproved authenticate( - title = authLabelCopyKey, + title = authTitle, context = context, labels = authLabels, keyguardLauncher = keyguardLauncher, - onApproved = { copyNSec(context, scope, accountViewModel.account, clipboardManager) }, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, - ) - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Icon( - tint = MaterialTheme.colorScheme.onPrimary, - symbol = MaterialSymbols.Key, - contentDescription = - stringRes(Res.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), - modifier = Modifier.padding(end = 5.dp), - ) - Text( - stringRes(id = Res.string.copy_my_secret_key), - color = MaterialTheme.colorScheme.onPrimary, - ) - } -} - -@Composable -private fun EncryptNSecCopyButton( - accountViewModel: AccountViewModel, - password: MutableState, -) { - val clipboardManager = LocalClipboard.current - val context = LocalContext.current - val authLabels = rememberAuthPromptLabels() - val authLabelCopyKey = stringRes(Res.string.copy_my_secret_key) - val scope = rememberCoroutineScope() - - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) - } - } - - Row { - Column { - OutlinedButton( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { - authenticate( - title = authLabelCopyKey, - context = context, - labels = authLabels, - keyguardLauncher = keyguardLauncher, - onApproved = { encryptCopyNSec(password, context, scope, accountViewModel, clipboardManager) }, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, - ) + onApproved = { + gate.pending = null + onApproved() }, - shape = ButtonBorder, - contentPadding = ButtonPadding, - enabled = password.value.text.isNotBlank(), - ) { - Icon( - symbol = MaterialSymbols.Key, - contentDescription = - stringRes(Res.string.copies_the_nsec_id_your_password_to_the_clipboard_for_backup), - modifier = Modifier.padding(end = 5.dp), - ) - Text( - stringRes(id = Res.string.encrypt_and_copy_my_secret_key), - ) - } - } - - Column { - QrCodeButtonEncrypted(accountViewModel, password) + onError = { title, message -> + gate.pending = null + accountViewModel.toastManager.toast(title, message) + }, + ) } } + + return gate } fun Context.getFragmentActivity(): FragmentActivity? { @@ -411,156 +611,31 @@ fun Context.getFragmentActivity(): FragmentActivity? { private fun copyNSec( context: Context, scope: CoroutineScope, - account: Account, + nsec: String, clipboardManager: Clipboard, ) { - account.settings.keyPair.privKey?.let { - val nsec = it.toNsec() - scope.launch { - clipboardManager.setText(nsec) - Toast - .makeText( - context, - loadStringRes(Res.string.secret_key_copied_to_clipboard), - Toast.LENGTH_SHORT, - ).show() + scope.launch { + clipboardManager.setText(nsec) + Toast + .makeText( + context, + loadStringRes(Res.string.secret_key_copied_to_clipboard), + Toast.LENGTH_SHORT, + ).show() - // Best-effort auto-clear: after a delay, wipe the clipboard only if it - // still holds this exact nsec (don't clobber anything copied since). - // On Android 13+ the OS also shows its own sensitive-content UI. - delay(CLIPBOARD_CLEAR_DELAY_MS) - if (clipboardManager.getText() == nsec) { - clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("", ""))) - } + // Best-effort auto-clear: after a delay, wipe the clipboard only if it + // still holds this exact nsec (don't clobber anything copied since). + // On Android 13+ the OS also shows its own sensitive-content UI. + delay(CLIPBOARD_CLEAR_DELAY_MS) + if (clipboardManager.getText() == nsec) { + clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("", ""))) } } } -private fun encryptCopyNSec( - password: MutableState, - context: Context, - scope: CoroutineScope, - accountViewModel: AccountViewModel, - clipboardManager: Clipboard, -) { - if (password.value.text.isBlank()) { - scope.launch { - Toast - .makeText( - context, - loadStringRes(Res.string.password_is_required), - Toast.LENGTH_SHORT, - ).show() - } - } else { - accountViewModel.account.settings.keyPair.privKey?.let { - val key = runCatching { Nip49().encrypt(it.toHexKey(), password.value.text) }.getOrNull() - if (key != null) { - scope.launch { - clipboardManager.setText(key) - Toast - .makeText( - context, - loadStringRes(Res.string.secret_key_copied_to_clipboard), - Toast.LENGTH_SHORT, - ).show() - } - } else { - scope.launch { - Toast - .makeText( - context, - loadStringRes(Res.string.failed_to_encrypt_key), - Toast.LENGTH_SHORT, - ).show() - } - } - } - } -} - -@Composable -private fun QrCodeButtonBase( - accountViewModel: AccountViewModel, - isEnabled: Boolean = true, - contentDescription: StringResource, - onDialogShow: () -> String?, -) { - val context = LocalContext.current - val authLabels = rememberAuthPromptLabels() - val authLabelCopyKey = stringRes(Res.string.copy_my_secret_key) - - // store the dialog open or close state - var dialogOpen by remember { mutableStateOf(false) } - - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - dialogOpen = true - } - } - - IconButton( - enabled = isEnabled, - onClick = { - authenticate( - title = authLabelCopyKey, - context = context, - labels = authLabels, - keyguardLauncher = keyguardLauncher, - onApproved = { dialogOpen = true }, - onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, - ) - }, - ) { - Icon( - painter = painterRes(R.drawable.ic_qrcode, 4), - contentDescription = stringRes(id = contentDescription), - modifier = Modifier.size(24.dp), - tint = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.grayText, - ) - } - - if (dialogOpen) { - ShowKeyQRDialog( - onDialogShow(), - onClose = { dialogOpen = false }, - ) - } -} - -@Composable -private fun QrCodeButton(accountViewModel: AccountViewModel) { - QrCodeButtonBase( - accountViewModel = accountViewModel, - contentDescription = Res.string.show_private_key_qr_code, - onDialogShow = { - accountViewModel.account.settings.keyPair.privKey - ?.toNsec() - }, - ) -} - -@Composable -private fun QrCodeButtonEncrypted( - accountViewModel: AccountViewModel, - password: MutableState, -) { - QrCodeButtonBase( - accountViewModel = accountViewModel, - isEnabled = password.value.text.isNotBlank(), - contentDescription = Res.string.show_encrypted_private_key_qr_code, - onDialogShow = { - accountViewModel.account.settings.keyPair.privKey - ?.toHexKey() - ?.let { Nip49().encrypt(it, password.value.text) } - }, - ) -} - @Composable private fun ShowKeyQRDialog( - qrCode: String?, + qrCode: String, onClose: () -> Unit, ) { Dialog( @@ -574,7 +649,6 @@ private fun ShowKeyQRDialog( .fillMaxSize() .padding(10.dp), ) { - // Back button at the top Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -582,7 +656,6 @@ private fun ShowKeyQRDialog( BackButton(onPress = onClose) } - // QR Code content Column( modifier = Modifier @@ -591,7 +664,7 @@ private fun ShowKeyQRDialog( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - QrCodeDrawer(qrCode ?: "error") + QrCodeDrawer(qrCode) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt index 5b3af89c9a..66bcb7a2f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.resources.password_is_required import com.vitorpamplona.amethyst.commons.resources.sign_request_rejected_description import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription @Stable class LoginViewModel : ViewModel() { @@ -57,7 +58,7 @@ class LoginViewModel : ViewModel() { var password by mutableStateOf(TextFieldValue("")) val needsPassword by derivedStateOf { - key.text.startsWith("ncryptsec1") + Bech32Transcription.normalize(key.text).startsWith("ncryptsec1") } var isFirstLogin by mutableStateOf(false) diff --git a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml index c93820fa84..9475817c00 100644 --- a/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ar-rSA/strings.xml @@ -227,7 +227,6 @@ Relays الموقع Lightning عنوان - نسخ معرف Nsec (كلمة المرور الخاصة بك) إلى الحافظة للنسخ الاحتياطي إرسال رسالة مباشرة القيام بتحرير metadata للمستخدم رفع الحظر @@ -282,16 +281,6 @@ ضع علامة على جميع الرسائل/الاشعارات المعروفة كمقروء تعيين الجديد كمقروء تعيين الكل كمقروء - ## النصائح الرئيسية للنسخ الاحتياطي والسلامة - \n\n- يتم تأمين حسابك بمفتاح سري. المفتاح السري هو سلسلة عشوائية طويلة تبدأ بـ **nsec1**. أي شخص لديه مفتاحك السري يمكنه نشر المحتوى باستخدام هويتك. - \n\n- لا تقم بوضع مفتاحك السري في أي موقع أو برنامج لا تثق به. - \n- مطوري Amethyst لن يطلبوا منك **أبدا** المفتاح السري الخاص بك. - \n- حافظ على نسخة احتياطية آمنة لمفتاحك السري لاسترداد الحساب. نوصي باستخدام برنامج لادارة كلمات المرور. - - للحصول على أمان إضافي، يمكنك تشفير مفتاحك الخاص بكلمة مرور. يبدأ هذا المفتاح المشفر بـ **ncryptsec1** ولا يمكن استخدامه دون كلمة المرور الخاصة بك. - \n\nإذا فقدت كلمة المرور، لن تتمكن من استرداد مفتاحك الخاص. - - تشفير و نسخ المفتاح الخاص صورة شارة مكافئة ل %1$s "صورة منح الشارة لقد حصلت على شارة جائزة جديدة @@ -2052,7 +2041,6 @@ تم نسخ نص الملاحظة/المنشور إلى الحافظة تم النسخ إلى الحافظة تم نسخ عنوان @npub المؤلف إلى الحافظة - إنسخ مفتاحي السري نسخ إلى الحافظة تعذّر إنشاء LNUrl من عنوان Lightning "%1$s". تحقق من إعداد المستخدم تعذّر التحقق من الملف المنزّل بعد الرفع: %1$s @@ -2958,10 +2946,8 @@ رفع HLS مشاركة أو حفظ مقاطع قصيرة - إظهار رمز QR المفتاح الخاص المشفر تضمين الرسائل المباشرة والجماعية في تبويب الإشعارات. أوقف التشغيل للاحتفاظ بالرسائل في تبويب الرسائل فقط. إظهار الرسائل - إظهار رمز QR المفتاح الخاص تم رفض طلب التوقيع تأكد من أن تطبيق الموقِّع قد أذن بهذه المعاملة أعاد الموقِّع الخارجي بيانات غير متوقعة للطلب. قد يكون هناك خطأ في Amethyst أو في تطبيق الموقِّع. diff --git a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml index e575eee7eb..9ae4fbc467 100644 --- a/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-bn-rBD/strings.xml @@ -217,7 +217,6 @@ রিলেগুলি ওয়েবসাইট বিজলি-ঠিকানা - ব্যাকআপের জন্য আপনার Nsec আইডি (তথা পাসওয়ার্ড) ক্লিপবোর্ডে কপি করে একটি সরাসরি-বার্তা পাঠান ব্যবহারকারীর মেটাডেটা সম্পাদনা করে আনব্লক করুন @@ -271,16 +270,6 @@ পরিচিত সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন নতুন সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন সবগুলোকে পঠিত হিসেবে চিহ্নিত করুন - ## মূল ব্যাকআপ এবং নিরাপত্তা টিপস - \n\n আপনার অ্যাকাউন্ট একটি গোপন কী দ্বারা সুরক্ষিত। মূলটি হল অক্ষরগুলির একটি দীর্ঘ ক্রম যা শুরু হয় **nsec1**. যে কেউ এই গোপন কী অ্যাক্সেস করতে পারে পোস্ট এবং আপনার পরিচয় পরিবর্তন করতে পারেন. - \n\n- আপনি বিশ্বাস করেন না এমন কোনো ওয়েবসাইট বা সফ্টওয়্যারে আপনার গোপন কী **করবেন না**. - \n- অ্যামেথিস্ট ডেভেলপাররা **কখনই** আপনার গোপন কী চাইবে না. - \n- **করুন** অ্যাকাউন্ট পুনরুদ্ধারের জন্য আপনার গোপন কীটির একটি নিরাপদ ব্যাকআপ রাখুন৷. আমরা একটি পাসওয়ার্ড ম্যানেজার ব্যবহার করার পরামর্শ দিই।. - - অতিরিক্ত নিরাপত্তার জন্য, আপনি একটি পাসওয়ার্ড দিয়ে আপনার কী এনক্রিপ্ট করতে পারেন. এই কী দিয়ে শুরু হয় **ncryptsec1** এবং আপনার পাসওয়ার্ড ছাড়া ব্যবহার করা যাবে না।. - \n\n আপনি যদি আপনার পাসওয়ার্ড হারিয়ে ফেলেন, আপনি আপনার কী পুনরুদ্ধার করতে পারবেন না।. - - এনক্রিপ্ট এবং আমার গোপন কী অনুলিপি %1$s -এর জন্য ব্যাজ পুরষ্কারের ছবি "ব্যাজ পুরস্কার চিত্র আপনি একটি নতুন ব্যাজ পুরষ্কার পেয়েছেন @@ -2014,7 +2003,6 @@ নোটের রচনাটি ক্লিপবোর্ডে কপি করা হয়েছে ক্লিপবোর্ডে কপি হয়েছে রচয়িতার @npub ক্লিপবোর্ডে কপি করুন - আমার ব্যক্তিগত চাবিটি কপি করুন ক্লিপবোর্ডে কপি করুন Lightning Address "%1$s" থেকে LNUrl তৈরি করা যায়নি। ব্যবহারকারীর সেটআপ পরীক্ষা করুন আপলোডের পরে ডাউনলোড করা ফাইল পরীক্ষা করা যায়নি: %1$s @@ -2834,10 +2822,8 @@ HLS আপলোড শেয়ার বা সেভ করুন শর্টস - এনক্রিপ্টেড প্রাইভেট কী QR কোড দেখান বিজ্ঞপ্তি ট্যাবে সরাসরি ও গ্রুপ বার্তা অন্তর্ভুক্ত করুন। বার্তাগুলো শুধুমাত্র বার্তা ট্যাবে রাখতে বন্ধ করুন। বার্তা দেখান - প্রাইভেট কী QR কোড দেখান সাইন অনুরোধ প্রত্যাখ্যাত নিশ্চিত করুন যে সাইনার অ্যাপ্লিকেশনটি এই লেনদেন অনুমোদন করেছে এক্সটার্নাল সাইনার অনুরোধের জন্য একটি অপরিচিত পেলোড ফেরত দিয়েছে। Amethyst বা সাইনারে একটি বাগ থাকতে পারে। diff --git a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml index 4611482789..3849dff92e 100644 --- a/commonsUI/src/commonMain/composeResources/values-cs/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-cs/strings.xml @@ -364,7 +364,6 @@ Přeposílání Webová stránka Lightning adresa - Zkopíruje Nsec ID (vaše heslo) do schránky pro zálohování Poslat přímou zprávu Upravit uživatelova metadata Odblokovat @@ -473,19 +472,11 @@ Označit všechny známé jako přečtené Označit všechny nové jako přečtené Označit všechny jako přečtené - ## Tipy pro zálohování klíčů a bezpečnost - \n\nVaše konto je zabezpečeno tajným klíčem. Klíč je dlouhá posloupnost znaků začínající s **nsec1**. Kdo má přístup k tomuto tajnému klíči, může přispívat a měnit vaši identitu. - \n\n- **Nedávejte** svůj tajný klíč na žádnou webovou stránku nebo do žádného softwaru, kterému nedůvěřujete. - \n- Vývojáři Amethystu nikdy **nebudou** žádat o váš tajný klíč. - \n- **Udržujte** bezpečnou zálohu vašeho tajného klíče pro obnovení účtu. Doporučujeme použití správce hesel. - Pro další zabezpečení můžete svůj klíč zašifrovat heslem. Tento klíč začíná s **ncryptsec1** a nelze ho použít bez vašeho hesla. - \n\nPokud zapomenete heslo, nebudete moci obnovit svůj klíč. Zálohujte si klíče Váš tajný klíč je jediný způsob, jak se k tomuto účtu dostat. Pokud ho ztratíte, nelze ho už nikdy obnovit. Uložte si ho někam do bezpečí. Zálohovat nyní Uložil jsem si je Zavřít - Zašifrovat a zkopírovat můj tajný klíč Obrázek ocenění od %1$s Obrázek ocenění Obdrželi jste nové ocenění @@ -3385,7 +3376,6 @@ Text poznámky zkopírován do schránky Zkopírováno do schránky Uživatelovo ID (@npub) zkopírováno do schránky - Zkopírovat můj tajný klíč Kopírovat do schránky Nepodařilo se sestavit LNUrl z Lightning adresy "%1$s". Zkontrolujte nastavení uživatele Nelze zkontrolovat stažený soubor po nahrání: %1$s @@ -4971,10 +4961,8 @@ HLS nahrávání Sdílet nebo Uložit Krátká videa - Zobrazit šifrovaný QR kód soukromého klíče Zahrnout přímé a skupinové zprávy na kartě Oznámení. Vypněte, aby zprávy zůstaly pouze na kartě Zprávy. Zobrazit zprávy - Zobrazit QR kód privátního klíče Požadavek na podpis byl zamítnut Ujistěte se, že podepisující aplikace autorizovala tuto transakci Externí podepisovatel vrátil data, která jsou pro daný požadavek neobvyklá. Může se jednat o chybu v Amethystu nebo v podepisovateli. diff --git a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml index 076f687129..bc13407e5a 100644 --- a/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de-rDE/strings.xml @@ -354,7 +354,6 @@ Relays Website Lightning-Adresse - Kopiert die Nsec-ID (Ihr Passwort) zur Sicherung in die Zwischenablage Sende eine Direktnachricht Bearbeitet die Benutzermetadaten Entsperren @@ -463,19 +462,11 @@ Alle Kontakte als gelesen markieren Alle Unbekannten als gelesen markieren Alle als gelesen markieren - ## Tipps zur Schlüsselsicherheit - \n\nDein Konto ist durch einen privaten Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen privaten Schlüssel hat, kann in deinem Namen posten und dein Profil ändern. - \n\n- Lege deinen privaten Schlüssel **nicht** auf einer Website oder in einer Software ab, der du nicht vertraust. - \n- Die Entwickler von Amethyst werden dich **niemals** nach deinem privaten Schlüssel fragen. - \n- **Bewahre** eine sichere Kopie deines privaten Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers. - Für zusätzliche Sicherheit kannst du deinen Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne dein Passwort nicht verwendet werden. - \n\nWenn du dein Passwort verlierst, kannst du deinen Schlüssel nicht wiederherstellen. Sichere deine Schlüssel Dein geheimer Schlüssel ist der einzige Weg zu diesem Konto. Wenn du ihn verlierst, kann er nie wiederhergestellt werden. Speichere ihn jetzt an einem sicheren Ort. Jetzt sichern Habe ich gespeichert Schließen - Meinen privaten Schlüssel verschlüsseln und kopieren Auszeichnungsbild für %1$s Auszeichnungsbild Du hast eine neue Auszeichnung erhalten @@ -3315,7 +3306,6 @@ Notiztext in die Zwischenablage kopiert In die Zwischenablage kopiert Autor-ID in die Zwischenablage kopiert - Meinen geheimen Schlüssel kopieren In Zwischenablage kopieren Konnte keine LNUrl aus der Lightning-Adresse "%1$s" zusammenstellen. Überprüfe die Konfiguration des Benutzers Die heruntergeladene Datei konnte nach dem Hochladen nicht überprüft werden: %1$s @@ -4763,10 +4753,8 @@ HLS-Upload Teilen oder Speichern Kurzvideos - QR-Code für verschlüsselten privaten Schlüssel anzeigen Direkt- und Gruppennachrichten im Benachrichtigungs-Tab anzeigen. Deaktivieren, um Nachrichten nur im Nachrichten-Tab zu behalten. Nachrichten anzeigen - Privaten QR-Code anzeigen Signaturanfrage abgelehnt Stell sicher, dass die Signer-App diese Transaktion autorisiert hat Externer Signer hat Daten zurückgegeben, die für die Anfrage ungewöhnlich sind. Es könnte ein Fehler in Amethyst oder im Signer vorliegen. diff --git a/commonsUI/src/commonMain/composeResources/values-de/strings.xml b/commonsUI/src/commonMain/composeResources/values-de/strings.xml index fe28311d40..50ac062d85 100644 --- a/commonsUI/src/commonMain/composeResources/values-de/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-de/strings.xml @@ -204,7 +204,6 @@ Relais Webseite Lightning-Adresse - Kopiert die Nsec-ID (Ihr Passwort) zur Sicherung in die Zwischenablage Sende eine Direktnachricht Bearbeitet die Benutzermetadaten Entsperren @@ -261,14 +260,6 @@ anz der Bedingungen ist erforderlich Alle als bekannt markieren Alle als neu markieren Alle als gelesen markieren - ## Schlüsselsicherheits-Tipps - \n\nIhr Konto ist durch einen geheimen Schlüssel gesichert. Der Schlüssel ist eine lange Zeichenfolge, die mit **nsec1** beginnt. Jeder, der Zugriff auf diesen geheimen Schlüssel hat, kann Ihre Identität posten und ändern. - \n\n- Legen Sie Ihren geheimen Schlüssel **nicht** auf eine Website oder in eine Software, der Sie nicht vertrauen. - \n- Die Entwickler von Amethyst werden Sie **niemals** nach Ihrem geheimen Schlüssel fragen. - \n- **Bewahren Sie** eine sichere Sicherung Ihres geheimen Schlüssels zur Kontowiederherstellung auf. Wir empfehlen die Verwendung eines Passwort-Managers. - Für zusätzliche Sicherheit können Sie Ihren Schlüssel mit einem Passwort verschlüsseln. Dieser Schlüssel beginnt mit **ncryptsec1** und kann ohne Ihr Passwort nicht verwendet werden. - \n\nWenn Sie Ihr Passwort verlieren, können Sie Ihren Schlüssel nicht wiederherstellen. - Verschlüsseln und kopieren Sie meinen geheimen Schlüssel Auszeichnungsbild für %1$s Auszeichnungsbild Sie haben eine neue Auszeichnung erhalten @@ -2056,7 +2047,6 @@ anz der Bedingungen ist erforderlich Notiztext in die Zwischenablage kopiert In die Zwischenablage kopiert Autor-ID in die Zwischenablage kopiert - Meinen geheimen Schlüssel kopieren In Zwischenablage kopieren Konnte keine LNUrl aus der Lightning-Adresse "%1$s" zusammenstellen. Überprüfen Sie die Konfiguration des Benutzers Die heruntergeladene Datei konnte nach dem Hochladen nicht überprüft werden: %1$s @@ -2920,10 +2910,8 @@ erie gespeichert HLS-Upload Teilen oder Speichern Kurzvideos - QR-Code für verschlüsselten privaten Schlüssel anzeigen Direkt- und Gruppennachrichten im Benachrichtigungs-Tab anzeigen. Deaktivieren, um Nachrichten nur im Nachrichten-Tab zu behalten. Nachrichten anzeigen - Privaten QR-Code anzeigen Signaturanfrage abgelehnt Stellen Sie sicher, dass die Unterzeichner-Anwendung diese Transaktion autorisiert hat 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. diff --git a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml index 6569dd09c3..c2a4ccac7c 100644 --- a/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-el-rGR/strings.xml @@ -216,7 +216,6 @@ Διαμοιραστές Ιστοσελίδα Διεύθυνση Lightning - Αντιγράφει το "Μυστικό Κλειδί" σας (Nsec ID ή αλλιώς ο μυστικός κωδικός πρόσβασής σας) στο πρόχειρο για δημιουργία αντιγράφου ασφαλείας Αποστολή Άμεσου Μηνύματος Επεξεργασία των Μεταδεδομένων του Χρήστη Άρση Αποκλεισμού @@ -271,16 +270,6 @@ Σήμανση όλων των γνωστοποιήσεων ως αναγνωσμένων Σήμανση όλων ως αναγνωσμένων Σήμανση όλων ως αναγνωσμένα - ## Συμβουλές ασφαλείας και δημιουργίας αντιγράφων ασφαλείας κλειδιού - \n\nΟ λογαριασμός σου ασφαλίζεται με ένα μυστικό κλειδί. Το κλειδί είναι μια μακρά ακολουθία χαρακτήρων που ξεκινά με **nsec1**. Όποιος έχει πρόσβαση σε αυτό το μυστικό κλειδί μπορεί να δημοσιεύει και να αλλάζει την ταυτότητά σου. - \n\n- **Μην** βάζεις το μυστικό σου κλειδί σε κανέναν ιστότοπο ή λογισμικό που δεν εμπιστεύεσαι. - \n- Οι προγραμματιστές του Amethyst **δεν** θα ζητήσουν ποτέ το μυστικό σου κλειδί. - \n- **Διατήρησε** ένα ασφαλές αντίγραφο ασφαλείας του μυστικού σου κλειδιού για ανάκτηση λογαριασμού. Συνιστούμε τη χρήση διαχειριστή κωδικών πρόσβασης. - - Για πρόσθετη ασφάλεια, μπορείς να κρυπτογραφήσεις το κλειδί σου με κωδικό πρόσβασης. Αυτό το κλειδί ξεκινά με **ncryptsec1** και δεν μπορεί να χρησιμοποιηθεί χωρίς τον κωδικό σου. - \n\nΑν χάσεις τον κωδικό σου, δεν θα μπορείς να ανακτήσεις το κλειδί σου. - - Κρυπτογράφηση και αντιγραφή του μυστικού μου κλειδιού Σήμα βράβευσης για %1$s "Εικόνα βραβείου παρασήμου Λάβατε ένα νέο Σήμα Βράβευσης @@ -2000,7 +1989,6 @@ Το κείμενο της δημοσίευσης αποθηκεύτηκε στο πρόχειρο Αντιγράφηκε στο πρόχειρο Το "Δημόσιο Κλειδί" του συντάκτη της δημοσίευσης αποθηκεύτηκε στο πρόχειρο - Αντιγραφή του "Μυστικού Κλειδιού" μου Αντιγραφή στο πρόχειρο Δεν ήταν δυνατή η δημιουργία του LNUrl από τη διεύθυνση Lightning "%1$s". Ελέγξτε ρυθμίσεις χρήστη Δεν ήταν δυνατός ο έλεγχος του ληφθέντος αρχείου μετά το ανέβασμα: %1$s @@ -2783,10 +2771,8 @@ Κοινοποίηση ως URL εικόνας Μεταφόρτωση HLS Κοινοποίηση ή αποθήκευση - Εμφάνιση QR code κρυπτογραφημένου ιδιωτικού κλειδιού Συμπερίληψη άμεσων και ομαδικών μηνυμάτων στην καρτέλα ειδοποιήσεων. Απενεργοποιήστε για να διατηρείτε τα μηνύματα μόνο στην καρτέλα μηνυμάτων. Εμφάνιση μηνυμάτων - Εμφάνιση QR code ιδιωτικού κλειδιού Αίτημα υπογραφής απορρίφθηκε Βεβαιωθείτε ότι η εφαρμογή υπογραφής έχει εξουσιοδοτήσει αυτή τη συναλλαγή Ο εξωτερικός signer επέστρεψε ένα payload που είναι ασυνήθιστο για το αίτημα. Ενδέχεται να υπάρχει σφάλμα στο Amethyst ή στον signer. diff --git a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml index e55ddcf03c..3c68aa5451 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo-rUY/strings.xml @@ -218,7 +218,6 @@ Plusendiloj Retejo Lightning-Adreso - Kopii la Nsec ID (vian pasvorton) al la tondujo por sekurkopio Sendi rektan mesaĝon Redakti metadatumojn de uzanto Malbloki @@ -273,16 +272,6 @@ Marki ĉiujn Konatajn legitaj Marki ĉiujn Novajn legitaj Marki ĉiujn legitajn - ## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco - \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. - \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. - \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. - \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon. - - Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. - \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon. - - Ĉifri kaj kopii mian seketan ŝlosilon Ŝildeto-premia bildo por %1$s "Insigna premibildeto Vi ricevis novan ŝildeto-premion @@ -2023,7 +2012,6 @@ Kopiita tekston de noto al tondujo Kopiita al poŝujo Kopiita @npub de aŭtoro al tondujo - Kopii mian sekretan ŝlosilon Kopii al poŝujo Ne eblis kunmeti LNUrl el Lightning-Adreso "%1$s". Kontrolu la agordon de la uzanto Ne eblis kontroli la elŝutitan dosieron post alŝuto: %1$s @@ -2836,10 +2824,8 @@ HLS Alŝuto Kunhavigi aŭ Konservi Mallongaj videoj - Montri ĉifritan privatan ŝlosilon QR-kode Inkluzivi rektajn kaj grupajn mesaĝojn en la Sciiga langeto. Malŝaltu por konservi mesaĝojn nur en la Mesaĝa langeto. Montri Mesaĝojn - Montri privatan ŝlosilon QR-kode Subskrib-peto rifuzita Certigu ke la subskribista aplikaĵo aŭtoris ĉi tiun transakcion La ekstera subskribanto resendis utilan ŝarĝon kiu estas stranga por la peto. Povas ekzisti eraro en Amethyst aŭ en la Subskribanto. diff --git a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml index f9cf0c8844..7ee7ae021f 100644 --- a/commonsUI/src/commonMain/composeResources/values-eo/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-eo/strings.xml @@ -204,7 +204,6 @@ Plusendiloj Retejo Lightning-Adreso - Kopii la Nsec ID (vian pasvorton) al la tondujo por sekurkopio Sendi rektan mesaĝon Redakti metadatumojn de uzanto Malbloki @@ -259,18 +258,6 @@ Marki ĉiujn Konatajn legitaj Marki ĉiujn Novajn legitaj Marki ĉiujn legitajn - - ## Konsiletoj pri Ŝlosila Sekurkopiao kaj Sekureco - \n\nVia konto estas sekurigita per sekreta ŝlosilo. La ŝlosilo estas longa sinsekvo de signoj komencanta per **nsec1**. Ĉiu kiu havas aliron al ĉi tiu sekreta ŝlosilo povas afiŝi kaj ŝanĝi vian identecon. - \n\n- **Ne** metu vian sekretan ŝlosilon en ajnan retejon aŭ programaron, kiun vi ne fidas. - \n- Amethyst-programistoj **neniam** petos vian sekretan ŝlosilon. - \n- **Faru** sekuran sekurkopion de via sekreta ŝlosilo por reakiro de konto. Ni rekomendas uzi pasvortan administrilon. - - - Por plia sekureco, vi povas ĉifri vian ŝlosilon per pasvorto. Ĉi tiu ŝlosilo komenciĝas per **ncryptsec1** kaj ne povas esti uzata sen via pasvorto. - \n\nSe vi perdas vian pasvorton, vi ne povos reakiri vian ŝlosilon. - - Ĉifri kaj kopii mian seketan ŝlosilon Ŝildeto-premia bildo por %1$s "Insigna premibildeto Vi ricevis novan ŝildeto-premion @@ -2050,7 +2037,6 @@ Kopiita tekston de noto al tondujo Kopiita al poŝujo Kopiita @npub de aŭtoro al tondujo - Kopii mian sekretan ŝlosilon Kopii al poŝujo Ne eblis kunmeti LNUrl el Lightning-Adreso "%1$s". Kontrolu la agordon de la uzanto Ne eblis kontroli la elŝutitan dosieron post alŝuto: %1$s @@ -2889,10 +2875,8 @@ HLS Alŝuto Kunhavigi aŭ Konservi Mallongaj videoj - Montri ĉifritan privatan ŝlosilon QR-kode Inkluzivi rektajn kaj grupajn mesaĝojn en la Sciiga langeto. Malŝaltu por konservi mesaĝojn nur en la Mesaĝa langeto. Montri Mesaĝojn - Montri privatan ŝlosilon QR-kode Subskrib-peto rifuzita Certigu ke la subskribista aplikaĵo aŭtoris ĉi tiun transakcion La ekstera subskribanto resendis utilan ŝarĝon kiu estas stranga por la peto. Povas ekzisti eraro en Amethyst aŭ en la Subskribanto. diff --git a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml index ee9c2f0a5b..a6d0205966 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rES/strings.xml @@ -217,7 +217,6 @@ Retransmisores Sitio web Dirección Lightning - Copia el ID Nsec (tu contraseña) en el portapapeles para hacer una copia de seguridad Enviar un mensaje directo Edita los metadatos del usuario Desbloquear @@ -272,16 +271,6 @@ Marcar los conocidos como leídos Marcar los nuevos como leídos Marcar todos como leídos - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - - Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - - Cifrar y copiar mi clave secreta Imagen del Badge para %1$s "Imagen de premio de insignia Has recibido un nuevo Badge @@ -2020,7 +2009,6 @@ Texto de nota copiado al portapapeles Copiado al portapapeles @npub del autor copiado al portapapeles - Copiar mi clave secreta Copiar al portapapeles No se pudo ensamblar la LNUrl desde la dirección de Lightning "%1$s". Comprueba la configuración del usuario. No se pudo comprobar el archivo descargado después de cargar: %1$s @@ -2849,10 +2837,8 @@ Subida HLS Compartir o guardar Cortos - Mostrar código QR de clave privada cifrada Incluye mensajes directos y de grupo en la pestaña de Notificaciones. Desactívalo para mantener los mensajes solo en la pestaña de Mensajes. Mostrar mensajes - Mostrar código QR de clave privada Solicitud de firma rechazada Asegúrate de que la aplicación firmante haya autorizado esta transacción. El firmante externo devolvió una carga útil que no concuerda con la solicitud. Es posible que haya un error, ya sea en Amethyst o en el firmante. diff --git a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml index f3e6fa1ca8..9b79dc47fe 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rMX/strings.xml @@ -217,7 +217,6 @@ Relés Sitio web Dirección de Lightning - Copia el ID Nsec (tu contraseña) en el portapapeles para hacer una copia de seguridad Enviar un mensaje directo Edita los metadatos del usuario Desbloquear @@ -272,16 +271,6 @@ Marcar todos los conocidos como leídos Marcar todos los nuevos como leídos Marcar todos como leídos - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - - Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - - Cifrar y copiar mi clave secreta Imagen de premio de insignia por %1$s "Imagen de premio de insignia Recibiste un nuevo premio de insignia @@ -2019,7 +2008,6 @@ Texto de la nota copiado al portapapeles Copiado al portapapeles @npub del autor copiado al portapapeles - Copiar mi clave secreta Copiar al portapapeles No se pudo ensamblar la LNUrl desde la dirección de Lightning "%1$s". Comprueba la configuración del usuario. No se pudo comprobar el archivo descargado después de subir: %1$s @@ -2842,10 +2830,8 @@ Subida HLS Compartir o guardar Cortos - Mostrar código QR de clave privada cifrada Incluye mensajes directos y de grupo en la pestaña de Notificaciones. Desactívalo para mantener los mensajes solo en la pestaña de Mensajes. Mostrar mensajes - Mostrar código QR de clave privada Solicitud de firma rechazada Asegúrate de que la aplicación firmante haya autorizado esta transacción. El firmante externo devolvió una carga útil que no concuerda con la solicitud. Es posible que haya un error, ya sea en Amethyst o en el firmante. diff --git a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml index a86f5c2aaf..e3331c1f6a 100644 --- a/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es-rUS/strings.xml @@ -217,7 +217,6 @@ Relés Sitio web Dirección de Lightning - Copia el ID Nsec (tu contraseña) en el portapapeles para hacer una copia de seguridad Enviar un mensaje directo Edita los metadatos del usuario Desbloquear @@ -272,16 +271,6 @@ Marcar todos los conocidos como leídos Marcar todos los nuevos como leídos Marcar todos como leídos - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - - Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - - Cifrar y copiar mi clave secreta Imagen de premio de insignia por %1$s "Imagen de premio de insignia Recibiste un nuevo premio de insignia @@ -2019,7 +2008,6 @@ Texto de la nota copiado al portapapeles Copiado al portapapeles @npub del autor copiado al portapapeles - Copiar mi clave secreta Copiar al portapapeles No se pudo ensamblar la LNUrl desde la dirección de Lightning "%1$s". Comprueba la configuración del usuario. No se pudo comprobar el archivo descargado después de subir: %1$s @@ -2842,10 +2830,8 @@ Subida HLS Compartir o guardar Cortos - Mostrar código QR de clave privada cifrada Incluye mensajes directos y de grupo en la pestaña de Notificaciones. Desactívalo para mantener los mensajes solo en la pestaña de Mensajes. Mostrar mensajes - Mostrar código QR de clave privada Solicitud de firma rechazada Asegúrate de que la aplicación firmante haya autorizado esta transacción. El firmante externo devolvió una carga útil que no concuerda con la solicitud. Es posible que haya un error, ya sea en Amethyst o en el firmante. diff --git a/commonsUI/src/commonMain/composeResources/values-es/strings.xml b/commonsUI/src/commonMain/composeResources/values-es/strings.xml index d9570812e6..0c4de03dde 100644 --- a/commonsUI/src/commonMain/composeResources/values-es/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-es/strings.xml @@ -204,7 +204,6 @@ Retransmisores Sitio web Dirección Lightning - Copia la ID de Nsec (su contraseña) en el portapapeles para hacer una copia de seguridad Enviar un mensaje directo Edita los metadatos del usuario Desbloquear @@ -259,7 +258,6 @@ Marcar los conocidos como leídos Marcar los nuevos como leídos Marcar todos como leídos - Cifrar y copiar mi clave secreta Imagen del Badge para %1$s "Imagen de premio de insignia Has recibido un nuevo Badge @@ -1802,15 +1800,6 @@ URL base de la API del explorador Pagar factura del DVM Pagar %1$s sats al DVM - ## Consejos sobre la protección y la copia de seguridad de las claves - \n\nTu cuenta está protegida por una clave secreta. La clave es una larga secuencia de caracteres que empieza por **nsec1**. Cualquiera que tenga acceso a esta clave secreta puede publicar y cambiar tu identidad. - \n\n- **No** guardes la clave secreta en ningún sitio web o software en los que no confíes. - \n- Los desarrolladores de Amethyst **nunca** te pedirán la clave secreta. - \n- **Sí** puedes realizar una copia de seguridad de la clave secreta para recuperar la cuenta. Para ello, te recomendamos usar un gestor de contraseñas. - - Para mayor seguridad, puedes cifrar la clave con una contraseña. Esta clave empieza por **ncryptsec1** y no puede utilizarse sin tu contraseña. - \n\nSi pierdes la contraseña, no podrás recuperar tu clave. - Usar URL directa nSite: %1$s nApplet: %1$s @@ -2047,7 +2036,6 @@ Texto de nota copiado al portapapeles Copiado al portapapeles @npub del autor copiado al portapapeles - Copiar mi clave secreta Copiar al portapapeles No se pudo ensamblar la LNUrl desde la dirección de Lightning "%1$s". Comprueba la configuración del usuario. No se pudo comprobar el archivo descargado después de cargar: %1$s @@ -2886,10 +2874,8 @@ Subida HLS Compartir o guardar Cortos - Mostrar código QR de clave privada cifrada Incluye mensajes directos y de grupo en la pestaña de Notificaciones. Desactívalo para mantener los mensajes solo en la pestaña de Mensajes. Mostrar mensajes - Mostrar código QR de clave privada Solicitud de firma rechazada Asegúrate de que la aplicación firmante haya autorizado esta transacción. El firmante externo devolvió una carga útil que no concuerda con la solicitud. Es posible que haya un error, ya sea en Amethyst o en el firmante. diff --git a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml index a92f92d82a..236b0b84b4 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa-rIR/strings.xml @@ -219,7 +219,6 @@ رله ها وبسایت آدرس لایتنینگ - شناسه کلید خصوصی (پسوردتان) را در کلیپبورد کپی می کند( ارسال پیام مستقیم متادیتای کاربر را ویرایش می کند از بلاک درآوردن @@ -274,15 +273,6 @@ همه شناس ها خوانده شده کن همه جدیدها را خوانده شده کن همه را خوانده شده کن - نکات ایمنی و ذخیره کلید - -حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. -هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. -سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. -یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم. - برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. -اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید. - رمزنگاری و کپی کلید من تصویر مدال پاداش برای %1$s تصویر جایزه نشان یک مدال پاداش دریافت کردید @@ -2026,7 +2016,6 @@ یادداشت در کلیپبورد کپی شد در کلیپ‌بورد کپی شد @npubنویسنده در کلیپبورد کپی شد - کلید خصوصیم را کپی کن کپی به کلیپ‌بورد LNUrl از آدرس لایتنینگ "%1$s" بدست نیامد. تنظیمات کاربر را بررسی کنید. فایل بارگیری شده پس از بارگذاری بررسی نشد: %1$s @@ -2848,10 +2837,8 @@ آپلود HLS اشتراک گذاری یا ذخیره‌سازی ویدیوهای کوتاه - نمایش کد QR کلید خصوصی رمزنگاری شده پیام‌های مستقیم و گروهی را در تب اعلان‌ها نمایش دهید. غیرفعال کنید تا پیام‌ها فقط در تب پیام‌ها باشند. نمایش پیام‌ها - نمایش کد QR کلید خصوصی درخواست امضا رد شد مطمئن شوید که اپ امضا کننده این تراکنش را تایید کرده است signer خارجی یک پاسخ عجیب برای درخواست بازگرداند. ممکن است باگی در Amethyst یا در signer وجود داشته باشد. diff --git a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml index a20d36b070..d3eb3d5b87 100644 --- a/commonsUI/src/commonMain/composeResources/values-fa/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fa/strings.xml @@ -204,7 +204,6 @@ رله ها وبسایت آدرس لایتنینگ - شناسه کلید خصوصی (پسوردتان) را در کلیپبورد کپی می کند( ارسال پیام مستقیم متادیتای کاربر را ویرایش می کند از بلاک درآوردن @@ -259,15 +258,6 @@ همه شناس ها خوانده شده کن همه جدیدها را خوانده شده کن همه را خوانده شده کن - نکات ایمنی و ذخیره کلید - -حساب کاربری شما با یک کلید خصوصی محافظت می شود. این کلید دنباله ای بلند از کاراکترها است که با **nsec1** آغاز می گردد. هر کس به این کلید خصوصی دسترسی داشته باشد می تواند بجای شما یادداشت پست کرده یا هویت شما را تغییر دهد. -هرگز کلید خصوصی خود را در هیچ وبسایت با نرم افزاری که به آن اطمینان ندارید **وارد نکنید**. -سازندگان امتیست **هرگز** کلیدتان را از شما نمی خواهند. -یک پشتیبان یدکی مخفی از کلید خود به منظور بازیابی **نگه دارید**. ما استفاده از نرم افزار مدیریت پسورد را توصیه می کنیم. - برای ایمنی بیشتر، می توانید کلید خود را با یک رمزعبور رمزنگاری کنید. این نوع کلید با **ncryptsec1** آغاز می شود و نمی تواند بدون رمز عبور شما استفاده شود. -اگر پسوردتان را گم کنید، نخواهید توانست کلیدتان را بازیابی کنید. - رمزنگاری و کپی کلید من تصویر مدال پاداش برای %1$s تصویر جایزه نشان یک مدال پاداش دریافت کردید @@ -2049,7 +2039,6 @@ یادداشت در کلیپبورد کپی شد در کلیپ‌بورد کپی شد @npubنویسنده در کلیپبورد کپی شد - کلید خصوصیم را کپی کن کپی به کلیپ‌بورد LNUrl از آدرس لایتنینگ "%1$s" بدست نیامد. تنظیمات کاربر را بررسی کنید. فایل بارگیری شده پس از بارگذاری بررسی نشد: %1$s @@ -2888,10 +2877,8 @@ آپلود HLS اشتراک گذاری یا ذخیره‌سازی ویدیوهای کوتاه - نمایش کد QR کلید خصوصی رمزنگاری شده پیام‌های مستقیم و گروهی را در تب اعلان‌ها نمایش دهید. غیرفعال کنید تا پیام‌ها فقط در تب پیام‌ها باشند. نمایش پیام‌ها - نمایش کد QR کلید خصوصی درخواست امضا رد شد مطمئن شوید که اپ امضا کننده این تراکنش را تایید کرده است signer خارجی یک پاسخ عجیب برای درخواست بازگرداند. ممکن است باگی در Amethyst یا در signer وجود داشته باشد. diff --git a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml index ede84f29eb..66c5d0dabc 100644 --- a/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fi-rFI/strings.xml @@ -213,7 +213,6 @@ Releet Verkkosivusto Lightning-osoite - Kopioi Nsec-tunniste (salasanasi) leikepöydälle varmuuskopiointia varten Lähetä suora viesti Muokkaa käyttäjän metatietoja Poista esto @@ -268,16 +267,6 @@ Merkitse kaikki Tunnetut luetuiksi Merkitse kaikki Uudet luetuiksi Merkitse kaikki luetuiksi - ## Avainten varmuuskopiointi ja turvallisuusvinkit - \n\nTilisi on suojattu salaisella avaimella. Avain on pitkä merkkijono, joka alkaa **nsec1**:llä. Kuka tahansa, jolla on pääsy tähän salaiseen avaimeen, voi julkaista ja muuttaa henkilöllisyyttäsi. - \n\n- **Älä** anna salaista avaintasi mihinkään verkkosivustoon tai ohjelmistoon, johon et luota. - \n- Amethystin kehittäjät eivät **koskaan** pyydä salaista avaintasi. - \n- **Tee** turvallinen varmuuskopio salaisesta avaimestasi tilin palauttamista varten. Suosittelemme salasananhallintaohjelman käyttöä. - - Lisäturvallisuuden vuoksi voit salata avaimesi salasanalla. Tämä avain alkaa **ncryptsec1**:llä eikä sitä voi käyttää ilman salasanaasi. - \n\nJos kadotat salasanasi, et pysty palauttamaan avaintasi. - - Salaa ja kopioi salainen avaimeni Merkki kohteelle %1$s "Merkkipalkinnon kuva Olet saanut uuden merkin @@ -2003,7 +1992,6 @@ Viestin teksti kopioitu leikepöydälle Kopioitu leikepöydälle Käyttäjän @npub kopioitu leikepöydälle - Kopioi salainen avain Kopioi leikepöydälle Ei voitu koota LNUrlia Lightning-osoitteesta "%1$s". Tarkista käyttäjän asetukset Ladatun tiedoston tarkistus epäonnistui latauksen jälkeen: %1$s @@ -2812,10 +2800,8 @@ HLS-lataus Jaa tai tallenna Lyhytvideot - Näytä salatun yksityisen avaimen QR-koodi Sisällytä suorat viestit ja ryhmäviestit Ilmoitukset-välilehdelle. Poista käytöstä, jos haluat pitää viestit vain Viestit-välilehdellä. Näytä viestit - Näytä yksityisen avaimen QR-koodi Allekirjoituspyyntö hylätty Varmista, että allekirjoittajasovellus on valtuuttanut tämän tapahtuman Ulkoinen allekirjoittaja palautti pyyntöön nähden odottamattoman tietopaketin. Kyseessä voi olla vika Amethystissä tai allekirjoittajassa. diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml index 6dbdac7e80..b58db42624 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rCA/strings.xml @@ -211,7 +211,6 @@ Relais Site Web Adresse Lightning - Copie l'ID Nsec (votre mot de passe) dans le presse-papiers pour le sauvegarder Envoyer un message direct Modifie les métadonnées de l'utilisateur Débloquer @@ -266,16 +265,6 @@ Marquer tous les Connus comme lu Marquer tous les Nouveaux comme lu Tout marquer comme lu - ## Sauvegarde des Clés et Conseils de Sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. - \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. - \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. - - Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. - \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. - - Chiffrer et copier ma clé secrète Image de badge pour %1$s Image du badge de récompense Vous avez reçu un nouveau Badge @@ -1958,7 +1947,6 @@ Copie du contenu de la note dans le presse-papiers Copié dans le presse-papiers Copie du @npub utilisateur dans le presse-papiers - Copier ma clé secrète Copier dans le presse-papiers Impossible d'assembler LNUrl à partir de l'adresse Lightning "%1$s". Vérifiez la configuration de l'utilisateur Impossible de vérifier le fichier téléchargé après le téléversement : %1$s @@ -2732,10 +2720,8 @@ Partager en tant qu'URL de l'image Mise en ligne HLS Partager ou Enregistrer - Afficher le QR code de la clé privée chiffrée Inclure les messages directs et de groupe dans l'onglet Notifications. Désactivez pour conserver les messages uniquement dans l'onglet Messages. Voir les messages - Afficher le QR code de la clé privée Requête de signature rejetée Assurez-vous que l'application signataire a autorisé cette transaction Le signataire externe a renvoyé des données qui sont étranges pour la requête. Il peut y avoir un bug sur Amethyst ou le signataire. diff --git a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml index 41d6f05237..157e3167dd 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr-rFR/strings.xml @@ -213,7 +213,6 @@ Relais Site Web Adresse Lightning - Copie l'ID Nsec (votre mot de passe) dans le presse-papiers pour le sauvegarder Envoyer un message direct Modifie les métadonnées de l'utilisateur Débloquer @@ -268,16 +267,6 @@ Marquer tous les Connus comme lu Marquer tous les Nouveaux comme lu Tout marquer comme lu - ## Sauvegarde des Clés et Conseils de Sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. - \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. - \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. - - Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. - \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. - - Chiffrer et copier ma clé secrète Image de badge pour %1$s Image du badge de récompense Vous avez reçu un nouveau Badge @@ -2147,7 +2136,6 @@ Copie du contenu de la note dans le presse-papiers Copié dans le presse-papiers Copie du @npub utilisateur dans le presse-papiers - Copier ma clé secrète Copier dans le presse-papiers Impossible d'assembler LNUrl à partir de l'adresse Lightning "%1$s". Vérifiez la configuration de l'utilisateur Impossible de vérifier le fichier téléchargé après le téléversement : %1$s @@ -3018,10 +3006,8 @@ Mise en ligne HLS Partager ou Enregistrer Shorts - Afficher le QR code de la clé privée chiffrée Inclure les messages directs et de groupe dans l'onglet Notifications. Désactivez pour conserver les messages uniquement dans l'onglet Messages. Voir les messages - Afficher le QR code de la clé privée Requête de signature rejetée Assurez-vous que l'application signataire a autorisé cette transaction Le signataire externe a renvoyé des données qui sont étranges pour la requête. Il peut y avoir un bug sur Amethyst ou le signataire. diff --git a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml index 2f9de43271..68bc129f3c 100644 --- a/commonsUI/src/commonMain/composeResources/values-fr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-fr/strings.xml @@ -204,7 +204,6 @@ Relais Site Web Adresse Lightning - Copie l'ID Nsec (votre mot de passe) dans le presse-papiers pour le sauvegarder Envoyer un message direct Modifie les métadonnées de l'utilisateur Débloquer @@ -259,16 +258,6 @@ Marquer tous les Connus comme lu Marquer tous les Nouveaux comme lu Tout marquer comme lu - ## Sauvegarde des Clés et Conseils de Sécurité - \n\nVotre compte est sécurisé par une clé secrète. La clé est une longue séquence de caractères commençant par **nsec1**. Toute personne ayant accès à cette clé secrète peut publier et modifier votre identité. - \n\n- Ne mettez **pas** votre clé secrète sur un site web ou un logiciel auquel vous ne faites pas confiance. - \n- Les développeurs d'Amethyst ne vous demanderont **jamais** votre clé secrète. - \n- **Faites** une sauvegarde sécurisée de votre clé secrète pour la récupération de votre compte. Nous vous recommandons d'utiliser un gestionnaire de mots de passe. - - Pour plus de sécurité, vous pouvez chiffrer votre clé avec un mot de passe. Cette clé commence par **ncryptsec1** et ne peut être utilisée sans votre mot de passe. - \n\nSi vous perdez votre mot de passe, vous ne pourrez pas récupérer votre clé. - - Chiffrer et copier ma clé secrète Image de badge pour %1$s Image du badge de récompense Vous avez reçu un nouveau Badge @@ -2047,7 +2036,6 @@ Copie du contenu de la note dans le presse-papiers Copié dans le presse-papiers Copie du @npub utilisateur dans le presse-papiers - Copier ma clé secrète Copier dans le presse-papiers Impossible d'assembler LNUrl à partir de l'adresse Lightning "%1$s". Vérifiez la configuration de l'utilisateur Impossible de vérifier le fichier téléchargé après le téléversement : %1$s @@ -2878,10 +2866,8 @@ Mise en ligne HLS Partager ou Enregistrer Shorts - Afficher le QR code de la clé privée chiffrée Inclure les messages directs et de groupe dans l'onglet Notifications. Désactivez pour conserver les messages uniquement dans l'onglet Messages. Voir les messages - Afficher le QR code de la clé privée Requête de signature rejetée Assurez-vous que l'application signataire a autorisé cette transaction Le signataire externe a renvoyé des données qui sont étranges pour la requête. Il peut y avoir un bug sur Amethyst ou le signataire. diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index 3a1f286d3d..7b58bfaa85 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -537,7 +537,6 @@ पुनःप्रसारक जालस्थान लैटनिंग पता - एनसेक॰ सूचक (आपका गुप्त पारणशब्द) की अनुकृति करता है टाँकाफलक में सुरक्षित रखने के लिए सीधा संदेश भेजें उपयोगकर्ता के उपतथ्य का सम्पादन करता है बाधा हटाएँ @@ -646,21 +645,11 @@ सब ज्ञात को पढ लिया चिह्नित करें सब नये को पढ लिया चिह्नित करें सब को पढ लिया चिह्नित करें - ## कुंचिका सुरक्षार्थ अनुकृति तथा सुरक्षा सुझाव - \n\nआपकी लेखा एक गुप्त कुंचिका से सुरक्षित किया गया है। कुंचिका अक्षरों की एक लम्बी श्रृंखला है जो **nsec1** से आरम्भ होता है। जिस किसी के पास यह कुंचिका है वह पत्र प्रकाशित कर सकेगा तथा आपका परिचय परिवर्तित कर सकेगा। - \n\n- आपकी गुप्त कुंचिका किसी भी जालस्थान अथवा क्रमक में **ना** डालें जिसपर आपका विश्वास ना हो। - \n- अमेथिस्ट क्रमलेख विकासकर्ता आपकी गुप्त कुंचिका **कभी नहीं** पूछेंगे। - \n- आपकी गुप्त कुंचिका की एक सुरक्षित अनुकृति **अवश्य** रखें अपनी लेखा पुनः प्राप्त करने के लिए। पारणशब्ध प्रबंधन क्रमक की पुनः प्रशंसा करते हैं। - - अधिक सुरक्षा के लिए आप अपनी कुंचिका का रहस्यीकरण कर सकते हैं एक पारणशब्द के साथ। यह कुंचिका आरम्भ होगी **ncryptsec1** से तथा पारणशब्द के बिना प्रयोग साध्य नहीं। - \n\n यदि आप अपना पारणशब्द खो देते हैं, तो आप अपनी कुंचिका को पुनः प्राप्त नहीं कर सकते। - अपने कुंचिकाओं को सुरक्षित रखें आपकी गुप्त कुंचिका एकमात्र विधि है इस लेखा का अभिगमन करने के लिए। यदि आप इसे खो देते हैं तो इसे कभी भी पुनःप्राप्त नहीं कर सकते। इसे कहीं सुरक्षित रखें अभी इस समय। अभी इसी समय सुरक्षित अनुकृति बनाएँ उनको मैंने सुरक्षित कर लिया हटाएँ - मेरी गुप्त कुंचिका का रहस्यीकरण तथा अनुकृति करें पदक पुरस्कार चित्र %1$s के लिए "पदक पुरस्कार चित्र आपको नया पदक पुरस्कार प्राप्त हुआ @@ -3577,7 +3566,6 @@ टीका लेख की अनुकृति की गई टाँकाफलक में टाँकाफलक में अनुकृत लेखक के @npub की अनुकृति की गई टाँकाफलक में - मेरी गुप्त कुंचिका की अनुकृति करें टाँकाफलक में अनुकृति करें लै॰जालपता नहीं बना पाए लै॰पता "%1$s" से। उपयोगकर्ता की स्थापना की जाँच करें आरोहण पश्चात अवरोहित अभिलेख की जाँच नहीं हो सकी : %1$s @@ -5054,10 +5042,8 @@ एचएलएस॰ आरोहण बाँटें अथवा अभिलेखन करें लघु चलचित्र - रहस्यीकृत निजी कुंचिका क्यूआर॰ चित्र अक्षरराशि दिखाएँ सूचनाएँ पृष्ठ पर सीधे तथा समूह सन्देशों को समाविष्ट करें। निष्क्रिय करें यदि सन्देशों को केवल सन्देश पृष्ठ में रखना चाहते हों तो। सन्देशों को दिखाएँ - निजी कुंचिका क्यूआर॰ चित्र अक्षरराशि दिखाएँ हस्ताक्षर अनुरोध अस्वीकृत सुनिश्चित करें हस्ताक्षर क्रमक ने इस व्यापार को अनुमति दिया बाहरी हस्ताक्षरकर्ता द्वारा दिया गया उत्तर विचित्र है अनुरोध के लिए। अमेथिस्ट अथवा हस्ताक्षरकर्ता में कुछ दोष हो सकता है। diff --git a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml index 950cdfa8b9..aad484cf81 100644 --- a/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hu-rHU/strings.xml @@ -538,7 +538,6 @@ Átjátszó Weboldal Lightning-cím - Az Nsec azonosítót (az Ön „jelszavát”) a vágólapra másolja biztonsági mentés céljából Közvetlen üzenet küldése Felhasználó metaadatainak szerkesztése Letiltás megszüntetése @@ -647,21 +646,11 @@ Az összes ismert megjelölése olvasottként Az összes új üzenet megjelölése olvasottként Összes megjelölése olvasottként - ## Kulcs- és biztonsági mentési tippek - \n\nFiókját egy titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-vel kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet. - \n\n- **Ne** helyezze el a titkos kulcsát olyan webhelyen vagy szoftverben, amelyben nem bízik. - \n- Az Amethyst fejlesztői az Ön titkos kulcsát **soha** nem fogják Öntől elkérni. - \n- A fiók-helyreállításának érdekében, a titkos kulcsáról **mindig** készítsen biztonsági másolatot. Javasoljuk egy jelszókezelő használatát. - - A nagyobb biztonság érdekében titkosítsa egy jelszóval. A jelszóval védett kulcs **ncryptsec1**-vel kezdődik, és a jelszava nélkül nem használható. - \n\nHa elveszíti a jelszavát, nem tudja visszaállítani a kulcsát. - Biztonsági mentés készítése a kulcsokról A titkos kulcs megadása az egyetlen módja a fiók elérésének. Ha elveszíti, akkor soha nem tudja visszaszerezni. Mentse el biztonságos helyre most. Biztonsági mentés most Elmentettem a kulcsokat Eltüntetés - Saját titkos kulcs titkosítása és másolása Kitűző %1$s számára Kitűző képe Ön egy új kitűzőt kapott @@ -3578,7 +3567,6 @@ Bejegyzés szövege a vágólapra másolva Vágólapra másolva A szerző @npub-ja a vágólapra másolva - Saját titkos kulcs másolása Másolás a vágólapra Nem sikerült az LN-webcímet a(z) „%1$s” Lightning-címről összeállítani. Ellenőrizze a felhasználó beállításait Nem lehetett ellenőrizni a letöltött fájlt a feltöltés után: %1$s @@ -5055,10 +5043,8 @@ HLS-feltöltés Megosztás vagy mentés Rövidek - Jelszóval védett privát kulcs megjelenítése QR-kódként Közvetlen és csoportos üzenetek megjelenítése az Értesítések lapon. Kapcsolja ki, ha az üzeneteket csak az Üzenetek lapon szeretné látni. Üzenetek megjelenítése - Privát kulcs megjelenítése QR-kódként Aláírási kérés elutasítva Győződjön meg arról, hogy ezt a tranzakciót az aláíró-alkalmazás hitelesítette-e A külső aláíró a kéréstől eltérő, szokatlan választ küldött. Lehet, hogy hiba lépett fel az Amethystben vagy az aláíró alkalmazásban. diff --git a/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml b/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml index 7b53a2b3e2..e93a0a8579 100644 --- a/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-in-rID/strings.xml @@ -211,7 +211,6 @@ Relai Situs web Alamat Lightning - Salin ID Nsec (kata sandi Anda) ke papan klip untuk cadangan Kirim Pesan Langsung Ubah metadata pengguna Buka blokir @@ -266,16 +265,6 @@ Tandai semua yang dikenal sebagai telah dibaca Tandai semua yang baru sebagai telah dibaca Tandai semua sebagai telah dibaca - ## Tips Cadangan Kunci dan Keamanan - \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. - \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. - \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. - \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi. - - Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. - \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda. - - Enkripsi dan salin kunci rahasia saya Gambar penghargaan lencana untuk %1$s "Gambar penghargaan lencana Anda menerima penghargaan lencana baru @@ -1983,7 +1972,6 @@ Beri tahu kepada pemilik untuk menambahnya Teks disalin ke papan klip Disalin ke clipboard @npub pemilik telah disalin ke papan klip - Salin kunci rahasia saya Salin ke clipboard Tidak dapat merakit LNUrl dari Alamat Lightning "%1$s". Periksa pengaturan pengguna Tidak dapat memeriksa file yang diunduh setelah diunggah: %1$s @@ -2747,10 +2735,8 @@ Seharusnya %3$s Bagikan sebagai URL Gambar Unggah HLS Bagikan atau Simpan - Tampilkan kode QR kunci privat terenkripsi Sertakan pesan langsung dan grup pada tab Notifikasi. Matikan untuk menyimpan pesan hanya di tab Pesan. Tampilkan Pesan - Tampilkan kode QR kunci privat Permintaan tanda tangan ditolak Pastikan aplikasi penandatangan telah mengizinkan transaksi ini Penanda tangan eksternal mengembalikan payload yang tidak sesuai dengan permintaan. Mungkin ada bug di Amethyst atau di penanda tangan. diff --git a/commonsUI/src/commonMain/composeResources/values-in/strings.xml b/commonsUI/src/commonMain/composeResources/values-in/strings.xml index 7635510369..0e1a25e5bd 100644 --- a/commonsUI/src/commonMain/composeResources/values-in/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-in/strings.xml @@ -202,7 +202,6 @@ Relai Situs Web Alamat Lightning - Salin Nsec ID (kata sandi anda) ke papan klip sebagai cadangan Kirim Pesan Langsung Ubah Metadata Pengguna Batalkan blokir @@ -257,18 +256,6 @@ Tandai semua yg dikenal telah dibaca Tandai pesan baru telah dibaca Tandai semua telah dibaca - - ## Tips Cadangan Kunci dan Keamanan - \n\nAkun Anda diamankan dengan kunci rahasia. Kunci ini adalah rangkaian karakter panjang yang diawali dengan **nsec1**. Siapa pun yang memiliki akses ke kunci rahasia ini dapat memposting dan mengubah identitas Anda. - \n\n- **Jangan** memasukkan kunci rahasia Anda ke situs web atau perangkat lunak yang tidak Anda percaya. - \n- Pengembang Amethyst **tidak akan pernah** meminta kunci rahasia Anda. - \n- **Lakukan** pencadangan kunci rahasia Anda secara aman untuk pemulihan akun. Kami merekomendasikan penggunaan pengelola kata sandi. - - - Untuk keamanan tambahan, Anda dapat mengenkripsi kunci dengan kata sandi. Kunci ini diawali dengan **ncryptsec1** dan tidak dapat digunakan tanpa kata sandi Anda. - \n\nJika Anda kehilangan kata sandi, Anda tidak akan dapat memulihkan kunci Anda. - - Enkripsi dan salin kunci rahasia saya Gambar penghargaan lencana untuk %1$s "Gambar penghargaan lencana Anda Menerima Penghargaan Lencana baru @@ -2040,7 +2027,6 @@ Teks catatan disalin ke papan klip Disalin ke clipboard @npub Penulis disalin ke papan klip - Salin kunci rahasia saya Salin ke clipboard Tidak dapat merakit LNUrl dari Alamat Lightning "%1$s". Periksa pengaturan pengguna Tidak dapat memeriksa file yang diunduh setelah diunggah: %1$s @@ -2853,10 +2839,8 @@ Unggah HLS Bagikan atau Simpan Shorts - Tampilkan kode QR kunci privat terenkripsi Sertakan pesan langsung dan grup pada tab Notifikasi. Matikan untuk menyimpan pesan hanya di tab Pesan. Tampilkan Pesan - Tampilkan kode QR kunci privat Permintaan tanda tangan ditolak Pastikan aplikasi penandatangan telah mengizinkan transaksi ini Penanda tangan eksternal mengembalikan payload yang tidak sesuai dengan permintaan. Mungkin ada bug di Amethyst atau di penanda tangan. diff --git a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml index aff7bdaf21..ec694000ab 100644 --- a/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-it-rIT/strings.xml @@ -212,7 +212,6 @@ Relè Sito web Indirizzo Lightning - Copia l'ID Nsec (la password) negli appunti per il backup Invia un messaggio diretto Modifica i metadati dell'utente Sblocca @@ -266,16 +265,6 @@ Segna tutti i conosciuti come letti Segna tutti i nuovi come letti Segna tutti come letti - ## Suggerimenti per il backup e la sicurezza della chiave - \n\nIl tuo account è protetto da una chiave segreta. La chiave è una lunga sequenza di caratteri che inizia con **nsec1**. Chiunque abbia accesso a questa chiave segreta può pubblicare e modificare la tua identità. - \n\n- **Non** inserire la tua chiave segreta in nessun sito web o software di cui non ti fidi. - \n- Gli sviluppatori di Amethyst non chiederanno **mai** la tua chiave segreta. - \n- **Conserva** un backup sicuro della tua chiave segreta per il recupero dell'account. Si consiglia l'uso di un gestore di password. - - Per maggiore sicurezza, puoi cifrare la tua chiave con una password. Questa chiave inizia con **ncryptsec1** e non può essere utilizzata senza la tua password. - \n\nSe perdi la password, non potrai recuperare la tua chiave. - - Cifra e copia la mia chiave segreta Immagine premio badge per %1$s "Immagine del premio badge Hai ricevuto un nuovo premio Badge @@ -1978,7 +1967,6 @@ Testo della nota copiato negli appunti Copiato negli appunti @npub dell'autore copiata negli appunti - Copia la mia chiave segreta Copia negli appunti Impossibile assemblare LNUrl dall'indirizzo Lightning "%1$s". Controlla la configurazione dell'utente Impossibile verificare il file scaricato dopo il caricamento: %1$s @@ -2777,10 +2765,8 @@ Condividi come URL immagine Caricamento HLS Condividi o salva - Mostra QR code della chiave privata cifrata Includi messaggi diretti e di gruppo nella scheda Notifiche. Disattiva per tenere i messaggi solo nella scheda Messaggi. Mostra messaggi - Mostra QR code della chiave privata Richiesta di firma rifiutata Assicurati che l'applicazione signer abbia autorizzato questa transazione Il firmatario esterno ha restituito un payload inatteso per la richiesta. Potrebbe esserci un bug in Amethyst o nell'app firmatario. diff --git a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml index 04d9abd43e..1172d42516 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja-rJP/strings.xml @@ -216,7 +216,6 @@ リレー ウェブサイト Lightningアドレス - バックアップ用に nsec ID (パスワード) をクリップボードにコピー ダイレクトメッセージを送信 ユーザのメタデータを編集 ブロック解除 @@ -271,16 +270,6 @@ すべての「知り合い」を既読にする すべての「新規リクエスト」を既読にする すべて既読にする - ## 鍵のバックアップとセキュリティのヒント - \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 - \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 - \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 - \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。 - - さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 - \n\nパスワードを忘れると、鍵を復元できなくなります。 - - 秘密鍵を暗号化してコピー %1$sのバッジ授与画像 バッジ授与の画像 新しいバッジが授与されました @@ -2008,7 +1997,6 @@ テキストがクリップボードにコピーされました クリップボードにコピーしました @npubをクリップボードにコピーしました - 秘密鍵をコピー クリップボードにコピー Lightning アドレス「%1$s」から LNUrl を組み立てられませんでした。ユーザーの設定を確認してください アップロード後にダウンロードしたファイルを確認できませんでした:%1$s @@ -2797,10 +2785,8 @@ HLS アップロード 共有または保存 ショート - 暗号化された秘密鍵の QR コードを表示 通知タブにダイレクト・グループメッセージを含めます。オフにするとメッセージはメッセージタブのみに表示されます。 メッセージを表示 - 秘密鍵の QR コードを表示 署名リクエストが拒否されました 署名アプリがこのトランザクションを承認していることを確認してください 外部署名アプリがリクエストに対して予期しないペイロードを返しました。Amethyst または署名アプリにバグがある可能性があります。 diff --git a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml index a9b6f5afcf..40cb52fddd 100644 --- a/commonsUI/src/commonMain/composeResources/values-ja/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ja/strings.xml @@ -202,7 +202,6 @@ リレー ウェブサイト Lightningアドレス - バックアップ用に nsec ID (パスワード) をクリップボードにコピー ダイレクトメッセージを送信 ユーザのメタデータを編集 ブロック解除 @@ -257,18 +256,6 @@ すべての「知り合い」を既読にする すべての「新規リクエスト」を既読にする すべて既読にする - - ## 鍵のバックアップとセキュリティのヒント - \n\nアカウントは秘密鍵によって保護されています。この鍵は **nsec1** で始まる長い文字列です。この秘密鍵にアクセスできる人は誰でも、あなたの名前でポストしたり、プロフィールを変更したりできます。 - \n\n- 信頼していないウェブサイトやアプリに秘密鍵を **入力しないでください**。 - \n- Amethyst の開発者が秘密鍵を **求めることは絶対にありません**。 - \n- アカウントの復元のために、秘密鍵の安全なバックアップを **必ず** 保管してください。パスワードマネージャーの使用をお勧めします。 - - - さらにセキュリティを高めるために、鍵をパスワードで暗号化することができます。この鍵は **ncryptsec1** で始まり、パスワードなしでは使用できません。 - \n\nパスワードを忘れると、鍵を復元できなくなります。 - - 秘密鍵を暗号化してコピー %1$sのバッジ授与画像 バッジ授与の画像 新しいバッジが授与されました @@ -2040,7 +2027,6 @@ テキストがクリップボードにコピーされました クリップボードにコピーしました @npubをクリップボードにコピーしました - 秘密鍵をコピー クリップボードにコピー Lightning アドレス「%1$s」から LNUrl を組み立てられませんでした。ユーザーの設定を確認してください アップロード後にダウンロードしたファイルを確認できませんでした:%1$s @@ -2853,10 +2839,8 @@ HLS アップロード 共有または保存 ショート - 暗号化された秘密鍵の QR コードを表示 通知タブにダイレクト・グループメッセージを含めます。オフにするとメッセージはメッセージタブのみに表示されます。 メッセージを表示 - 秘密鍵の QR コードを表示 署名リクエストが拒否されました 署名アプリがこのトランザクションを承認していることを確認してください 外部署名アプリがリクエストに対して予期しないペイロードを返しました。Amethyst または署名アプリにバグがある可能性があります。 diff --git a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml index 5ef5365607..7550bb0fbd 100644 --- a/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ko-rKR/strings.xml @@ -211,7 +211,6 @@ relay 웹사이트 Lightning 주소 - Nsec ID(비밀번호)를 백업을 위해 클립보드에 복사합니다 다이렉트 메시지 보내기 사용자 메타데이터 편집 차단 해제 @@ -266,16 +265,6 @@ 알려진 항목 모두 읽음 표시 새 항목 모두 읽음 표시 모두 읽음 표시 - ## 키 백업 및 보안 팁 - \n\n계정은 비밀 키로 보호됩니다. 키는 **nsec1**로 시작하는 긴 문자열입니다. 이 비밀 키에 접근할 수 있는 누구든 게시물 작성 및 신원 변경이 가능합니다. - \n\n- 신뢰하지 않는 웹사이트나 소프트웨어에 비밀 키를 **입력하지** 마세요. - \n- Amethyst 개발자는 **절대** 비밀 키를 요청하지 않습니다. - \n- 계정 복구를 위해 비밀 키를 안전하게 백업해 **두세요**. 비밀번호 관리자 사용을 권장합니다. - - 추가 보안을 위해 비밀번호로 키를 암호화할 수 있습니다. 이 키는 **ncryptsec1**로 시작하며 비밀번호 없이는 사용할 수 없습니다. - \n\n비밀번호를 분실하면 키를 복구할 수 없습니다. - - 내 비밀 키 암호화 및 복사 %1$s의 배지 수상 이미지 "배지 수상 이미지 새 배지를 수여받았습니다 @@ -1997,7 +1986,6 @@ 노트 텍스트가 클립보드에 복사되었습니다 클립보드에 복사됨 작성자의 @npub가 클립보드에 복사되었습니다 - 내 비밀 키 복사 클립보드에 복사 Lightning 주소 "%1$s"에서 LNUrl을 구성할 수 없습니다. 사용자 설정을 확인하세요 업로드 후 다운로드된 파일을 확인할 수 없습니다: %1$s @@ -2782,10 +2770,8 @@ 이미지 URL로 공유 HLS 업로드 공유 또는 저장 - 암호화된 개인 키 QR 코드 표시 알림 탭에 다이렉트 및 그룹 메시지를 포함합니다. 메시지를 메시지 탭에만 표시하려면 끄세요. 메시지 표시 - 개인 키 QR 코드 표시 서명 요청이 거부됨 서명 앱이 이 트랜잭션을 승인했는지 확인하세요 외부 서명 앱이 요청과 맞지 않는 응답을 반환했습니다. Amethyst 또는 서명 앱에 버그가 있을 수 있습니다. diff --git a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml index 0ce2714a9a..63d575ba3a 100644 --- a/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-lv-rLV/strings.xml @@ -220,7 +220,6 @@ Relay Vietne Lightning adrese - Kopē Nsec ID (jūsu paroli) starpliktuvē dublēšanai Sūtīt tiešo ziņojumu Rediģē lietotāja metadatus Atbloķēt @@ -277,18 +276,8 @@ Atzīmēt visu zināmo kā lasītu Atzīmēt visu jauno kā lasītu Atzīmēt visu kā lasītu - ## Atslēgas dublēšanas un drošības padomi - \n\nJūsu konts ir aizsargāts ar slepeno atslēgu. Atslēga ir gara rakstzīmju secība, kas sākas ar **nsec1**. Ikviens, kam ir piekļuve šai slepenai atslēgai, var publicēt un mainīt jūsu identitāti. - \n\n- **Neievietojiet** savu slepeno atslēgu nevienā tīmekļa vietnē vai programmatūrā, kurai neuzticaties. - \n- Amethyst izstrādātāji **nekad** neprasīs jūsu slepeno atslēgu. - \n- **Glabājiet** drošu slepenas atslēgas dublējumu konta atjaunošanai. Mēs iesakām izmantot paroļu pārvaldnieku. - - Papildu drošībai variet šifrēt atslēgu ar paroli. Šī atslēga sāksies ar **ncryptsec1** virkni un to nevarēs izmantot bez šīs paroles. - \n\nJa jūs aizmirsīsiet paroli, vairs nevarēsiet atgūt savu atslēgu. - Jūsu slepenā atslēga ir vienīgais veids, kā piekļūt šim kontam. Ja to pazaudēsiet, tad nekad nevarēsiet atgūt piekļuvi kontam. Saglabājiet atslēgu drošā vietā tagad. Esiet tās saglabājuši - Šifrēt un kopēt manu slepeno atslēgu Žetona piešķiršanas attēls par %1$s "Žetona piešķiršanas attēls Jūs saņēmāt jaunu žetonu @@ -2029,7 +2018,6 @@ Piezīmes teksts nokopēts starpliktuvē Nokopēts starpliktuvē Autora @npub nokopēts starpliktuvē - Kopēt manu slepeno atslēgu Kopēt starpliktuvē Nevarēja izveidot LNUrl no Lightning adreses "%1$s". Pārbaudiet lietotāja iestatījumus Nevarēja pārbaudīt lejupielādēto failu pēc augšupielādes: %1$s @@ -2865,10 +2853,8 @@ HLS augšupielāde Kopīgot vai saglabāt Šorti - Rādīt šifrētās privātās atslēgas QR kodu Iekļaut tiešās un grupas ziņas paziņojumu cilnē. Izslēdziet, lai ziņas tiktu rādītas tikai ziņojumu cilnē. Rādīt ziņas - Rādīt privātās atslēgas QR kodu Parakstīšanas pieprasījums noraidīts Pārliecinieties, ka parakstītāja lietotne ir autorizējusi šo darījumu Ārējais parakstītājs atgrieza neparastu atbildi uz pieprasījumu. Iespējams, ir kļūda Amethyst vai parakstītājā. diff --git a/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml index db64544d90..b47be71e33 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl-rBE/strings.xml @@ -204,7 +204,6 @@ Relays Website Lightning-adres - Kopieert je privésleutel (nsec) naar het klembord voor back-up Stuur privébericht Bewerkt gebruikers-metadata Deblokkeren @@ -259,7 +258,6 @@ Alle bekende markeren als gelezen Alle nieuwe markeren als gelezen Alles markeren als gelezen - Versleutel en kopieer mijn privésleutel Badge-awardafbeelding voor %1$s Badge-awardafbeelding Je hebt een nieuwe badge-award ontvangen @@ -1800,8 +1798,6 @@ Explorer API-basis-URL Factuur betalen van de DVM %1$s sats betalen aan de DVM - ## Sleutelback-up en veiligheidstips\n\nJe account wordt beveiligd door een privésleutel (begint met **nsec1**). Iedereen met deze sleutel kan namens jou posten.\n\n- Voer je privésleutel **nooit** in op een website of app die je niet volledig vertrouwt.\n- Amethyst-ontwikkelaars zullen **nooit** om je privésleutel vragen.\n- Maak een veilige back-up van je privésleutel (bijvoorbeeld in een wachtwoordmanager). - Voor extra veiligheid kun je je sleutel versleutelen met een wachtwoord. Deze versleutelde sleutel begint met **ncryptsec1** en kan alleen met je wachtwoord worden gebruikt.\n\nAls je het wachtwoord vergeet, kun je de sleutel niet meer herstellen. Directe URL gebruiken Statische website: %1$s Root-site @@ -2038,7 +2034,6 @@ Note-tekst gekopieerd naar klembord Gekopieerd naar klembord Gebruikers-ID (@npub…) gekopieerd naar klembord - Mijn privésleutel kopiëren Kopiëren naar klembord Kon LNURL niet samenstellen vanuit Lightning-adres "%1$s". Controleer de instellingen van de gebruiker. Kon gedownload bestand niet controleren na upload: %1$s @@ -2863,10 +2858,8 @@ HLS-upload Delen of opslaan Shorts - Toon versleutelde QR-code van privésleutel Directe berichten en groepsberichten opnemen in het tabblad Meldingen. Uitschakelen om berichten alleen in het tabblad Berichten te houden. Berichten tonen - Toon QR-code van privésleutel Ondertekenverzoek afgewezen Zorg dat de signer-app dit verzoek heeft goedgekeurd. Externe signer heeft een onjuiste payload geretourneerd voor dit verzoek. Er kan een bug zitten in Amethyst of in de signer. diff --git a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml index 3ea2d52fc0..7e475098d0 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl-rNL/strings.xml @@ -338,7 +338,6 @@ %1$s rapportages Relays Lightning-adres - Kopieert je privésleutel (nsec) naar het klembord voor back-up Stuur privébericht Bewerkt gebruikers-metadata Deblokkeren @@ -444,14 +443,11 @@ Alle bekende markeren als gelezen Alle nieuwe markeren als gelezen Alles markeren als gelezen - ## Sleutelback-up en veiligheidstips\n\nJe account wordt beveiligd door een privésleutel (begint met **nsec1**). Iedereen met deze sleutel kan namens jou posten.\n\n- Voer je privésleutel **nooit** in op een website of app die je niet volledig vertrouwt.\n- Amethyst-ontwikkelaars zullen **nooit** om je privésleutel vragen.\n- Maak een veilige back-up van je privésleutel (bijvoorbeeld in een wachtwoordmanager). - Voor extra veiligheid kun je je sleutel versleutelen met een wachtwoord. Deze versleutelde sleutel begint met **ncryptsec1** en kan alleen met je wachtwoord worden gebruikt.\n\nAls je het wachtwoord vergeet, kun je de sleutel niet meer herstellen. Maak een back-up van je sleutels Je geheime sleutel is de enige manier om bij dit account te komen. Raak je hem kwijt, dan is hij nooit meer te herstellen. Sla hem nu op een veilige plek op. Nu back-uppen Ik heb ze opgeslagen Sluiten - Versleutel en kopieer mijn privésleutel Badge-awardafbeelding voor %1$s Badge-awardafbeelding Je hebt een nieuwe badge-award ontvangen @@ -3081,7 +3077,6 @@ Note-tekst gekopieerd naar klembord Gekopieerd naar klembord Gebruikers-ID (@npub…) gekopieerd naar klembord - Mijn privésleutel kopiëren Kopiëren naar klembord Kon LNURL niet samenstellen vanuit Lightning-adres "%1$s". Controleer de instellingen van de gebruiker. Kon gedownload bestand niet controleren na upload: %1$s @@ -4473,10 +4468,8 @@ HLS-upload Delen of opslaan Shorts - Toon versleutelde QR-code van privésleutel Directe berichten en groepsberichten opnemen in het tabblad Meldingen. Uitschakelen om berichten alleen in het tabblad Berichten te houden. Berichten tonen - Toon QR-code van privésleutel Ondertekenverzoek afgewezen Zorg dat de signer-app dit verzoek heeft goedgekeurd. Externe signer heeft een onjuiste payload geretourneerd voor dit verzoek. Er kan een bug zitten in Amethyst of in de signer. diff --git a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml index 70ca1190a6..1c8521db59 100644 --- a/commonsUI/src/commonMain/composeResources/values-nl/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-nl/strings.xml @@ -204,7 +204,6 @@ Relays Website Lightning Adress - Kopieert het NSEC ID (uw wachtwoord) naar klembord voor back-up. Stuur een privébericht Bewerkt de metadata van de gebruiker Deblokkeren @@ -259,16 +258,6 @@ Bekende markeren als gelezen Nieuwe markeren als gelezen Alles markeren als gelezen - ## Sleutel back-up en veiligheidstips - \n\nUw account is beveiligd met een privésleutel. De sleutel is een lange, willekeurige reeks die begint met **nsec1**. Iedereen die toegang heeft tot uw privésleutel kan inhoud publiceren met uw identiteit. - \n\n- Voer uw privésleutel **nooit** in een website of software die u niet vertrouwt. - \n- Amethyst ontwikkelaars zullen u **nooit** om uw privésleutel vragen. - \n- **Bewaar** een veilige back-up van uw privésleutel voor accountherstel. Wij raden u aan een wachtwoordmanager te gebruiken. - - Voor meer veiligheid kan je je sleutel versleutelen met een wachtwoord. Deze sleutel begint met **ncryptsec1** en kan niet worden gebruikt zonder uw wachtwoord. - \n\nAls je je wachtwoord verliest, je kan je sleutel niet herstellen. - - Versleutel en kopieer mijn geheime sleutel Badge award afbeelding voor %1$s Badge-awardafbeelding Je hebt een nieuwe Badge award ontvangen @@ -2047,7 +2036,6 @@ Note tekst gekopieerd naar klembord Gekopieerd naar klembord @npub auteur gekopieerd naar klembord - Kopieer mijn privésleutel Kopiëren naar klembord Kon LNUrl niet samenbrengen van Lightning Adress "%1$s". Controleer de instellingen van de gebruiker Kan het gedownloade bestand niet controleren na upload: %1$s @@ -2872,10 +2860,8 @@ HLS-upload Delen of opslaan Shorts - Toon versleutelde QR-code van privésleutel Directe berichten en groepsberichten opnemen in het tabblad Meldingen. Uitschakelen om berichten alleen in het tabblad Berichten te houden. Berichten tonen - Toon QR-code van privésleutel Ondertekenverzoek afgewezen Zorg ervoor dat de teken-app deze transactie heeft geautoriseerd Externe signer heeft een onjuiste payload geretourneerd voor dit verzoek. Er kan een bug zitten in Amethyst of in de signer. diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 55a0998df3..e2f3d0baa4 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -548,7 +548,6 @@ Transmitery Strona www Lightning Adres - Kopiuje Nsec ID (hasło) do schowka w celu wykonania kopii zapasowej Wyślij bezpośrednią wiadomość Edytowanie metadanych użytkowników Odblokuj @@ -657,21 +656,11 @@ Zaznacz wszystkie popularne jako przeczytane Zaznacz wszystkie nowe jako przeczytane Zaznacz wszystkie jako przeczytane - ## Porady dotyczące kopii zapasowej i bezpieczeństwa - \n\nTwoje konto jest zabezpieczone tajnym kluczem. Kluczem jest długa sekwencja znaków, zaczynająca się od **nsec1**. Każdy, kto ma dostęp do tego tajnego klucza może publikować i zmieniać Twoją tożsamość. - \n\n- **nie** umiejszczaj swojego tajnego klucza na żadnej stronie internetowej lub oprogramowaniu, któremu nie ufasz. - \n- Deweloperzy Ametyst **nigdy** nie będą prosić o Twój tajny klucz. - \n- **Zrób ** bezpieczną kopię zapasową swojego tajnego klucza do ewentualnego odzyskania konta. Zalecamy użycie menedżera haseł. - - Dla dodatkowego bezpieczeństwa możesz zaszyfrować swój klucz hasłem. Ten klucz zaczyna się od **ncryptsec1** i nie może być użyty bez hasła. - \n\nJeśli zgubisz hasło, nie będziesz w stanie odzyskać swojego klucza. - Kopia zapasowa kluczy Twój tajny klucz jest jedynym sposobem na dostęp do tego konta. Jeśli go zgubisz, nigdy nie będzie można go odzyskać. Zapisz go w bezpiecznym miejscu. Utwórz kopię zapasową Zapisałem(am) Ignoruj - Zaszyfruj i skopiuj mój tajny klucz Wizerunek odznaki dla %1$s Wizerunek odznaki Otrzymałeś nową odznakę @@ -3648,7 +3637,6 @@ Skopiowano tekst wpisu do schowka Skopiowano do schowka Skopiowano @npub autora do schowka - Kopiuj mój tajny klucz Kopiuj do schowka Nie można stworzyć LNUrl z Adresu "%1$s". Sprawdź konfigurację użytkownika Nie można sprawdzić pobranego pliku po wgraniu: %1$s @@ -5263,10 +5251,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Wgraj HLS Udostępnij lub zapisz Filmiki - Pokaż zaszyfrowany kod QR klucza prywatnego W zakładce „Powiadomienia” znajdują się zarówno wiadomości bezpośrednie, jak i grupowe. Wyłącz tę opcję, aby wiadomości były wyświetlane wyłącznie w zakładce „Wiadomości”. Pokaż wiadomości - Pokaż kod QR klucza prywatnego Prośba o zalogowanie została odrzucona Upewnij się, że aplikacja logującego autoryzuje tę operację Sygnatariusz zewnętrzny zwrócił dane, które są niezgodne z żądaniem. Być może wystąpił błąd w Amethyst lub w samym sygnatariuszu. diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml index 19715d92c0..5d695b87fb 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rBR/strings.xml @@ -354,7 +354,6 @@ Relés Site Endereço Lightning - Copiar a chave Nsec (sua senha) para backup Enviar uma mensagem direta Muda os dados do usuário Desbloquear @@ -463,19 +462,11 @@ Marcar todos conhecidos como lidos Marcar todas novas solicitações como lidas Marcar todas como lidas - ## Dicas de Backup de Chave e Segurança - \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. - \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. - \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. - \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas. - Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. - \n\nSe você perder sua senha, não será possível recuperar sua chave. Faça backup das suas chaves Sua chave secreta é a única forma de acessar esta conta. Se você perdê-la, ela nunca poderá ser recuperada. Guarde-a em um lugar seguro agora. Fazer backup agora Já salvei Dispensar - Criptografar e copiar minha chave secreta Imagem da medalha para %1$s Imagem do prêmio Você recebeu uma nova medalha @@ -3315,7 +3306,6 @@ Texto copiado Copiado para a área de transferência Copiado @npub do autor - Copiar minha chave secreta Copiar para a Área de Transferência Não foi possível montar o LNUrl a partir do Endereço Lightning "%1$s". Verifique a configuração do usuário Não foi possível verificar o arquivo baixado após o upload: %1$s @@ -4761,10 +4751,8 @@ Upload HLS Compartilhar ou Salvar Curtas - Mostrar o código QR da chave privada criptografado Inclui mensagens diretas e de grupo na aba Notificações. Desative para manter as mensagens apenas na aba Mensagens. Mostrar mensagens - Mostrar código QR de chave privada Solicitação de assinatura rejeitada Certifique-se de que a aplicação assinante autorizou esta transação O assinador externo retornou dados estranhos para a solicitação. Pode haver um bug no Amethyst ou no assinador. diff --git a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml index b1953ebdcd..43b822074b 100644 --- a/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pt-rPT/strings.xml @@ -216,7 +216,6 @@ Relés Site Endereço Lightning - Copiar a chave Nsec (sua senha) para backup Enviar uma mensagem direta Muda os dados do usuário Desbloquear @@ -271,14 +270,6 @@ Marcar todos conhecidos como lidos Marcar todas novas solicitações como lidas Marcar todas como lidas - ## Dicas de Backup de Chave e Segurança - \n\nSua conta é protegida por uma chave secreta. A chave é uma sequência longa de caracteres que começa com **nsec1**. Qualquer pessoa que tenha acesso a esta chave secreta pode postar e alterar sua identidade. - \n\n- **Não** coloque sua chave secreta em nenhum site ou software que você não confie. - \n- Os desenvolvedores da Amethyst **nunca** pedirão sua chave secreta. - \n- **Mantenha** uma cópia de segurança segura de sua chave secreta para recuperação da conta. Recomendamos o uso de um gerenciador de senhas. - Para segurança adicional, você pode criptografar sua chave com uma senha. Esta chave começa com **ncryptsec1** e não pode ser usada sem sua senha. - \n\nSe você perder sua senha, não será possível recuperar sua chave. - Criptografar e copiar minha chave secreta Imagem da medalha para %1$s Imagem do prêmio Você recebeu uma nova medalha @@ -1995,7 +1986,6 @@ Texto copiado Copiado para a área de transferência Copiado @npub do autor - Copiar minha chave secreta Copiar para a Área de Transferência Não foi possível montar o LNUrl a partir do Endereço Lightning "%1$s". Verifique a configuração do usuário Não foi possível verificar o arquivo baixado após o upload: %1$s @@ -2795,10 +2785,8 @@ Upload HLS Compartilhar ou Salvar Curtas - Mostrar o código QR da chave privada criptografado Inclui mensagens diretas e de grupo na aba Notificações. Desative para manter as mensagens apenas na aba Mensagens. Mostrar mensagens - Mostrar código QR de chave privada Solicitação de assinatura rejeitada Certifique-se de que a aplicação assinante autorizou esta transação O assinador externo retornou dados estranhos para a solicitação. Pode haver um bug no Amethyst ou no assinador. diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml index 42c411c558..6cce9222db 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rRU/strings.xml @@ -219,7 +219,6 @@ Релеев Сайт Lightning адрес - Копирует Nsec ID (ваш пароль) для резервного копирования Отправить сообщение Редактирует метаданные пользователя Разблокировать @@ -274,16 +273,6 @@ Отметить все известные прочитанными Отметить все новые прочитанными Отметить всё прочитанным - ## Советы по резервному копированию и безопасности ключей - \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. - \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. - \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. - \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. - - Для дополнительной безопасности вы можете зашифровать ваш ключ с помощью пароля. Этот ключ начинается с **ncryptsec1** и не может быть использован без пароля. - \n\nЕсли вы потеряете свой пароль, вы не сможете восстановить ваш ключ. - - Зашифровать и копировать мой секретный ключ Картинка значка-награды %1$s Изображение награды за значок Вы получили в награду новый значок @@ -2038,7 +2027,6 @@ Копировать текст Скопировано в буфер обмена ID (npub) автора скопирован - Скопировать мой приватный ключ Копировать в буфер обмена Не удалось собрать LNUrl из Lightning Address «%1$s». Проверьте настройки пользователя Не удалось проверить загруженный файл после выгрузки: %1$s @@ -2881,10 +2869,8 @@ Поделиться URL изображения Загрузка HLS Поделиться или сохранить - Показать зашифрованный QR-код приватного ключа Включить личные и групповые сообщения во вкладку «Уведомления». Отключите, чтобы сообщения отображались только во вкладке «Сообщения». Показывать сообщения - Показать QR-код приватного ключа Запрос на подпись отклонён Убедитесь, что приложение подписи авторизовало эту транзакцию Внешний подписант вернул неожиданный ответ на запрос. Возможно, ошибка в Amethyst или в приложении подписи. diff --git a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml index f88269516e..706fe5c43e 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru-rUA/strings.xml @@ -212,7 +212,6 @@ Жалобы %1$s жалоб Веб-сайт - Копирует Nsec ID (ваш пароль) в буфер обмена для резервного копирования Отправить личное сообщение Редактирует метаданные пользователя Разблокировать @@ -267,16 +266,6 @@ Отметить все известные как прочитанные Отметить все новые как прочитанные Отметить все как прочитанные - ## Советы по резервному копированию и безопасности ключей - \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. - \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. - \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. - \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. - - Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. - \n\nЕсли вы потеряете пароль, вы не сможете восстановить ключ. - - Зашифровать и скопировать мой секретный ключ Изображение награды для %1$s Изображение награды за значок Вы получили новую награду @@ -2024,7 +2013,6 @@ Текст записи скопирован в буфер обмена Скопировано в буфер обмена @npub автора скопирован в буфер обмена - Скопировать мой секретный ключ Скопировать в буфер обмена Не удалось собрать LNUrl из Lightning Address «%1$s». Проверьте настройки пользователя Не удалось проверить загруженный файл после выгрузки: %1$s @@ -2863,10 +2851,8 @@ Поделиться URL изображения Загрузка HLS Поделиться или сохранить - Показать QR-код зашифрованного приватного ключа Включить личные и групповые сообщения во вкладку «Уведомления». Отключите, чтобы сообщения отображались только во вкладке «Сообщения». Показывать сообщения - Показать QR-код приватного ключа Запрос на подпись отклонён Убедитесь, что приложение подписи авторизовало эту транзакцию Внешний подписант вернул неожиданный ответ на запрос. Возможно, ошибка в Amethyst или в приложении подписи. diff --git a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml index 5a3a0ea0eb..72f62e9ed4 100644 --- a/commonsUI/src/commonMain/composeResources/values-ru/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ru/strings.xml @@ -208,7 +208,6 @@ Релеев Сайт Lightning адрес - Копирует Nsec ID (ваш пароль) для резервного копирования Отправить сообщение Редактирует метаданные пользователя Разблокировать @@ -263,18 +262,6 @@ Отметить все известные прочитанными Отметить все новые прочитанными Отметить всё прочитанным - - ## Советы по резервному копированию и безопасности ключей - \n\nВаш аккаунт защищён секретным ключом. Ключ — это длинная последовательность символов, начинающаяся с **nsec1**. Любой, кто получит доступ к этому секретному ключу, сможет публиковать записи и менять вашу личность. - \n\n- **Не** вводите свой секретный ключ на сайтах или в приложениях, которым не доверяете. - \n- Разработчики Amethyst **никогда** не будут запрашивать ваш секретный ключ. - \n- **Обязательно** сохраните надёжную резервную копию секретного ключа для восстановления аккаунта. Рекомендуем использовать менеджер паролей. - - - Для дополнительной безопасности вы можете зашифровать ваш ключ паролем. Этот ключ начинается с **ncryptsec1** и не может быть использован без вашего пароля. - \n\nЕсли вы потеряете пароль, вы не сможете восстановить ваш ключ. - - Зашифровать и скопировать секретный ключ Картинка значка-награды %1$s Изображение награды за значок Вы получили в награду новый значок @@ -2069,7 +2056,6 @@ Копировать текст Скопировано в буфер обмена ID (npub) автора скопирован - Скопировать мой приватный ключ Копировать в буфер обмена Не удалось собрать LNUrl из Lightning Address «%1$s». Проверьте настройки пользователя Не удалось проверить загруженный файл после выгрузки: %1$s @@ -2960,10 +2946,8 @@ Загрузка HLS Поделиться или сохранить Shorts - Показать QR-код зашифрованного приватного ключа Включить личные и групповые сообщения во вкладку «Уведомления». Отключите, чтобы сообщения отображались только во вкладке «Сообщения». Показывать сообщения - Показать QR-код приватного ключа Запрос на подпись отклонён Убедитесь, что приложение подписи авторизовало эту транзакцию Внешний подписант вернул неожиданный ответ на запрос. Возможно, ошибка в Amethyst или в приложении подписи. diff --git a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml index 2f5ff233bb..136d88ef34 100644 --- a/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sl-rSI/strings.xml @@ -363,7 +363,6 @@ Releji Spletna stran Lightning naslov - Kopira Nsec ID (tvoje geslo) v odložišče za varnostno kopiranje Pošlji direktno sporočilo Uredi uporabnikove metapodatke Deblokiraj @@ -472,22 +471,11 @@ Označi vse znane kot prebrane Označi vse nove kot prebrane Označi vse kot prebrane - - ## Varnostna kopija ključev in varnostni nasveti - \n\nVaš račun je zaščiten z privatnim ključem. Ta ključ ima dolgo sekvenco znakov kateri se začnejo z **nsec1**. Kdorkoli ima dostop, do tega privatnega ključa, lahko pošiljajo zapiske in spremenijo vašo digitalno identiteto. - \n\n- Nikoli in **Nikdar** ne vpisuj svojega privatnega ključa v spletne strani in aplikacije katerim ne zaupaš. - \n- Amethyst ustvarjalci vas ne bojo **nikoli** vprašali po vašem privatnem ključu. - \n- Ustvari **varnostno kopijo** svojega privatnega ključa, za obnovitev računa. Priporoča se uporaba upravljalnika gesel. - - Za dodatno varnost, lahko šifriraš svoj ključ z dodatnim geslom. Ta ključ se začne z **ncryptsec1** in ne more bit uporabljen brez dodatnega gesla. - \n\nČe izgubiš geslo, obnova ključa ni več mogoča. - Varno shrani ključe Vaš zasebni ključ je edini način za dostop do tega računa. Če ga izgubite, ga ne boste mogli več obnoviti. Shranite ga na varno mesto. Varno shrani zdaj shranjeno Opusti - Šifriraj in kopiraj moj privatni ključ Slika nagradne značke za %1$s "Slika nagradne značke Prejel/a si novo nagradno značko @@ -3329,7 +3317,6 @@ Ali želite za to uporabiti trenutne releje iz odhodnega predala? Tekst zapiska kopiran v odložišče Kopirano v odložišče Avtorjev @npub kopiran v odložišče - Kopiraj moj privatni ključ Kopiraj v odložišče Ni bilo mogoče sestaviti LNUrl iz "lightning" naslova '%1$s'. Preverite uporabnikove nastavitve Ni bilo mogoče preveriti prenesene datoteke po nalaganju: %1$s @@ -4881,10 +4868,8 @@ Prijavi se s privatnim ključem za všečkanje sporočila Naloži HLS Deli ali shrani Kratki posnetki - Pokaži QR kodo šifriranega zasebnega ključa V zavihek Obvestila vključite neposredna in skupinska sporočila. Izklopite, če želite sporočila ohraniti samo v zavihku Sporočila. Prikaži sporočilo - Pokaži QR kodo zasebnega ključa Zahteva za vpis zavrnjena Prepričajte se, da je aplikacija za podpisovanje odobrila to transakcijo Zunanji podpisnik je vrnil neobičajen odgovor. Morda gre za napako v aplikaciji Amethyst ali v podpisniku. diff --git a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml index 6ea50394e6..dad584b757 100644 --- a/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sr-rSP/strings.xml @@ -216,7 +216,6 @@ Relay-ovi Veb stranica Lightning adresa - Kopira Nsec ID (vaša lozinka) u međuspremnik radi rezervne kopije Pošalji direktnu poruku Izmeni metapodatke korisnika Odblokiraj @@ -271,16 +270,6 @@ Označi sve poznate kao pročitano Označi sve novo kao pročitano Označi sve kao pročitano - ## Saveti za bekap ključa i bezbednost - \n\nVaš nalog je zaštićen tajnim ključem. Ključ je dugačak niz znakova koji počinje sa **nsec1**. Svako ko ima pristup ovom tajnom ključu može da objavljuje i menja vaš identitet. - \n\n- **Ne** unosite vaš tajni ključ ni na jedan veb-sajt ili softver kojemu ne verujete. - \n- Amethyst programeri **nikada** neće tražiti vaš tajni ključ. - \n- **Čuvajte** bezbednu kopiju vašeg tajnog ključa radi oporavka naloga. Preporučujemo korišćenje menadžera lozinki. - - Za dodatnu bezbednost, možete šifrovati vaš ključ lozinkom. Ovaj ključ počinje sa **ncryptsec1** i ne može se koristiti bez vaše lozinke. - \n\nAko izgubite lozinku, nećete moći da obnovite ključ. - - Šifruj i kopiraj moj tajni ključ Slika značke za %1$s "Slika značke Dobili ste novu značku @@ -2006,7 +1995,6 @@ Tekst beleške kopiran u clipboard Kopirano u privremenu memoriju @npub autora kopiran u clipboard - Kopiraj moj tajni ključ Kopiraj u privremenu memoriju Nije moguće sastaviti LNUrl iz Lightning adrese "%1$s". Proverite podešavanja korisnika Preuzeta datoteka se ne može proveriti nakon otpremanja: %1$s @@ -2843,10 +2831,8 @@ HLS otpremanje Podeli ili sačuvaj Kratki snimci - Prikaži QR kod šifrovanog privatnog ključa Uključi direktne i grupne poruke na kartici Obaveštenja. Isključite da poruke budu samo na kartici Poruke. Prikaži poruke - Prikaži QR kod privatnog ključa Zahtev za potpisivanje odbijen Proverite da li je aplikacija za potpisivanje odobrila ovu transakciju Spoljni potpisnik je vratio podatke koji su neočekivani za zahtev. Možda postoji greška u Amethyst-u ili aplikaciji za potpisivanje. diff --git a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml index f591cc0177..81a81cb73f 100644 --- a/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -354,7 +354,6 @@ Reläer Websida Lightning Adress - Kopierar Nsec ID (ditt lösenord) till urklipp för säkerhetskopiering Skicka ett direkt meddelande Redigerar användarens metadata Sluta blockera @@ -463,19 +462,11 @@ Markera alla kända som lästa Markera alla nya som lästa Markera allt som läst - ## Nyckelsäkerhetsråd - \n\nDitt konto är säkrat med en hemlig nyckel. Nyckeln är en lång följd av tecken som börjar med **nsec1**. Den som har tillgång till denna hemliga nyckel kan posta och ändra din identitet. - \n\n- **Lägg inte** din hemliga nyckel på någon webbplats eller i någon mjukvara som du inte litar på. - \n- Utvecklarna av Amethyst kommer **aldrig** att be om din hemliga nyckel. - \n- **Spara** en säkerhetskopia av din hemliga nyckel för återställning av kontot. Vi rekommenderar att du använder en lösenordshanterare. - För extra säkerhet kan du kryptera din nyckel med ett lösenord. Denna nyckel börjar med **ncryptsec1** och kan inte användas utan ditt lösenord. - \n\nOm du tappar bort ditt lösenord kommer du inte att kunna återställa din nyckel. Säkerhetskopiera dina nycklar Din hemliga nyckel är enda sättet att komma åt det här kontot. Om du förlorar den kan den aldrig återskapas. Spara den på ett säkert ställe nu. Säkerhetskopiera nu Jag har sparat dem Avfärda - Kryptera och kopiera min hemliga nyckel Tilldelad Badge för %1$s Bild på utmärkelse Du har blivit tilldelad en ny Badge @@ -3315,7 +3306,6 @@ Kopierade anteckningstext till urklipp Kopierat till urklipp Kopierade författarens @npub till urklipp - Kopiera min hemliga nyckel Kopiera till Urklipp Kunde inte montera LNUrl från Lightning Address "%1$s". Kontrollera användarens konfiguration Kunde inte kontrollera nedladdad fil efter uppladdning: %1$s @@ -4761,10 +4751,8 @@ HLS-uppladdning Dela eller Spara Kortfilmer - Visa QR-kod för krypterad privat nyckel Inkludera direktmeddelanden och gruppmeddelanden på fliken Aviseringar. Stäng av för att hålla meddelanden enbart på fliken Meddelanden. Visa meddelanden - Visa QR-kod för privat nyckel Signeringsförfrågan avvisad Kontrollera att signeringsprogrammet har godkänt denna transaktion Extern signatär returnerade data som är ovanliga för begäran. Det kan finnas en bugg i antingen Amethyst eller signatären. diff --git a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml index 9c24ec5784..f33a6c85ed 100644 --- a/commonsUI/src/commonMain/composeResources/values-sw/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-sw/strings.xml @@ -213,7 +213,6 @@ %1$s Ripoti Tovuti Anwani ya Umeme - Hunakili Kitambulisho cha Nsec (nywila yako) kwenye ubao wa kunakili kwa ajili ya nakala rudufu Tuma Ujumbe wa Moja kwa Moja Huhariri Metadata ya Mtumiaji Fungua @@ -268,16 +267,6 @@ Funga kama Zimejulikana Funga kama Mpya Funga kama Zimejulikana - ## Vidokezo vya Nakala Rudufu ya Ufunguo na Usalama - \n\nAkaunti yako inalindwa na ufunguo wa siri. Ufunguo ni mfululizo mrefu wa herufi unaoanza na **nsec1**. Mtu yeyote anayepata ufunguo huu wa siri anaweza kuchapisha na kubadilisha utambulisho wako. - \n\n- **Usiweke** ufunguo wako wa siri katika tovuti yoyote au programu unayoitembelewa. - \n- Watengenezaji wa Amethyst **hawatawahi** kuomba ufunguo wako wa siri. - \n- **Hifadhi** nakala salama ya ufunguo wako wa siri kwa ajili ya kurejesha akaunti. Tunapendekeza kutumia kidhibiti cha nenosiri. - - Kwa usalama zaidi, unaweza kusimba ufunguo wako kwa nenosiri. Ufunguo huu unaanza na **ncryptsec1** na hauwezi kutumika bila nenosiri lako. - \n\nUkipoteza nenosiri lako, hutaweza kurejesha ufunguo wako. - - Simba na nakili ufunguo wangu wa siri Picha ya tuzo ya alama kwa %1$s "Picha ya tuzo ya beji Umepokea Tuzo Mpya ya Alama @@ -2008,7 +1997,6 @@ Maudhui ya dokezo yamenakiliwa kwenye ubao wa kunakili Imenakiliwa kwenye ubao wa kunakili Imesakinishwa @npub ya mwandishi kwa ubao wa kunakili - Nakili Ufunguo Wangu wa Siri Nakili kwenye ubao wa kunakili Haikuweza kuunda LNUrl kutoka kwa Anwani ya Lightning "%1$s". Angalia mpangilio wa mtumiaji Haikuweza kukagua faili lililопakuliwa baada ya kupakia: %1$s @@ -2818,10 +2806,8 @@ Pakia HLS Shiriki au Hifadhi Mafupi - Onyesha msimbo wa QR wa ufunguo wa siri uliofichwa Jumuisha ujumbe wa moja kwa moja na wa kikundi kwenye kichupo cha Arifa. Zima ili kuweka ujumbe kwenye kichupo cha Ujumbe pekee. Onyesha Ujumbe - Onyesha msimbo wa QR wa ufunguo wa siri Ombi la kusaini limekataliwa Hakikisha programu ya kusaini imeidhinisha muamala huu Msimamizi wa nje alirejesha data ya ajabu kwa ombi hili. Huenda kuna hitilafu katika Amethyst au Msimamizi. diff --git a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml index 3b46dde816..e29544094f 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta-rIN/strings.xml @@ -217,7 +217,6 @@ ரிலேகள் இணையதளம் லைட்னிங் முகவரி - Nsec IDயை (உங்கள் கடவுச்சொல்) காப்புப்பிரதிக்காக கிளிப்போர்டுக்கு நகலெடுக்கிறது நேரடிச் செய்தியை அனுப்பு பயனரின் தகவலை திருத்துகிறது தடையை நீக்கு @@ -272,16 +271,6 @@ தெரிந்தவற்றைப் படித்ததாகக் குறி புதியனவற்றைப் படித்ததாகக் குறி அனைத்தையும் படித்ததாகக் குறி - ## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் - \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். - \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. - \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். - \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம். - - கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. - \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது. - - என் இரகசிய விசையை மறைகுறியாக்கி நகல் எடுக்கவும் %1$s க்கான பேட்ஜ் விருது படம் "பேட்ஜ் விருது படம் நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள் @@ -2008,7 +1997,6 @@ குறிப்பு கிளிப்போர்டுக்கு நகலெடுக்கப் பட்டது கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது கிளிப்போர்டுக்கு எழுத்தாளரின் @npub நகலெடுக்கப் பட்டது - எனது ரகசிய சாவியை நகலெடுக்கவும் கிளிப்போர்டுக்கு நகலெடு Lightning முகவரி "%1$s" இலிருந்து LNUrl ஐ உருவாக்க முடியவில்லை. பயனரின் அமைப்பை சரிபார்க்கவும் பதிவேற்றத்திற்குப் பிறகு பதிவிறக்கிய கோப்பை சரிபார்க்க முடியவில்லை: %1$s @@ -2807,10 +2795,8 @@ HLS பதிவேற்றம் பகிர் அல்லது சேமி குறுவீடியோக்கள் - மறைகுறியாக்கப்பட்ட தனிப்பட்ட விசை QR குறியீட்டை காட்டு அறிவிப்பு தாவலில் நேரடி மற்றும் குழு செய்திகளை சேர்க்கவும். செய்திகளை செய்திகள் தாவலில் மட்டும் வைக்க அணைக்கவும். செய்திகளை காட்டு - தனிப்பட்ட விசை QR குறியீட்டை காட்டு கையொப்பமிடல் கோரிக்கை நிராகரிக்கப்பட்டது கையொப்பமிடல் செயலி இந்த பரிவர்த்தனையை அங்கீகரித்திருக்கிறதா என சரிபார்க்கவும் வெளிப்புற கையெழுத்திட்டவர் கோரிக்கைக்கு அசாதாரணமான தரவை திரும்ப அனுப்பியது. Amethyst அல்லது கையெழுத்திட்டவரில் ஒரு பிழை இருக்கலாம். diff --git a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml index 18a9e71315..2e74df187a 100644 --- a/commonsUI/src/commonMain/composeResources/values-ta/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-ta/strings.xml @@ -204,7 +204,6 @@ ரிலேகள் இணையதளம் லைட்னிங் முகவரி - Nsec IDயை (உங்கள் கடவுச்சொல்) காப்புப்பிரதிக்காக கிளிப்போர்டுக்கு நகலெடுக்கிறது நேரடிச் செய்தியை அனுப்பு பயனரின் தகவலை திருத்துகிறது தடையை நீக்கு @@ -259,18 +258,6 @@ தெரிந்தவற்றைப் படித்ததாகக் குறி புதியனவற்றைப் படித்ததாகக் குறி அனைத்தையும் படித்ததாகக் குறி - - ## திறவுகோல் காப்புப்பிரதி மற்றும் பாதுகாப்பு குறிப்புகள் - \n\nஉங்கள் கணக்கு ஒரு இரகசிய திறவுகோலால் பாதுகாக்கப்படுகிறது. இந்த திறவுகோல் **nsec1** என்று தொடங்கும் நீண்ட எழுத்துத் தொடராகும். இந்த இரகசிய திறவுகோலை யாராவது பெற்றால் அவர்கள் உங்கள் அடையாளத்தை பயன்படுத்தி இடுகையிடலாம் மற்றும் மாற்றலாம். - \n\n- உங்கள் இரகசிய திறவுகோலை நீங்கள் நம்பாத எந்த வலைத்தளத்திலும் அல்லது மென்பொருளிலும் வைக்க **வேண்டாம்**. - \n- Amethyst டெவலப்பர்கள் உங்கள் இரகசிய திறவுகோலை **ஒருபோதும்** கேட்கமாட்டார்கள். - \n- கணக்கு மீட்டெடுப்புக்காக உங்கள் இரகசிய திறவுகோலை பாதுகாப்பாக காப்புப்பிரதி எடுங்கள். கடவுச்சொல் மேலாளரை பயன்படுத்த பரிந்துரைக்கிறோம். - - - கூடுதல் பாதுகாப்புக்காக, உங்கள் திறவுகோலை கடவுச்சொல்லால் குறியாக்கலாம். இந்த திறவுகோல் **ncryptsec1** என்று தொடங்கும் மற்றும் உங்கள் கடவுச்சொல்லின்றி பயன்படுத்த முடியாது. - \n\nகடவுச்சொல்லை மறந்தால், உங்கள் திறவுகோலை மீட்டெடுக்க முடியாது. - - என் இரகசிய விசையை மறைகுறியாக்கி நகல் எடுக்கவும் %1$s க்கான பேட்ஜ் விருது படம் "பேட்ஜ் விருது படம் நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள் @@ -2050,7 +2037,6 @@ குறிப்பு கிளிப்போர்டுக்கு நகலெடுக்கப் பட்டது கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது கிளிப்போர்டுக்கு எழுத்தாளரின் @npub நகலெடுக்கப் பட்டது - எனது ரகசிய சாவியை நகலெடுக்கவும் கிளிப்போர்டுக்கு நகலெடு Lightning முகவரி "%1$s" இலிருந்து LNUrl ஐ உருவாக்க முடியவில்லை. பயனரின் அமைப்பை சரிபார்க்கவும் பதிவேற்றத்திற்குப் பிறகு பதிவிறக்கிய கோப்பை சரிபார்க்க முடியவில்லை: %1$s @@ -2889,10 +2875,8 @@ HLS பதிவேற்றம் பகிர் அல்லது சேமி குறுவீடியோக்கள் - மறைகுறியாக்கப்பட்ட தனிப்பட்ட விசை QR குறியீட்டை காட்டு அறிவிப்பு தாவலில் நேரடி மற்றும் குழு செய்திகளை சேர்க்கவும். செய்திகளை செய்திகள் தாவலில் மட்டும் வைக்க அணைக்கவும். செய்திகளை காட்டு - தனிப்பட்ட விசை QR குறியீட்டை காட்டு கையொப்பமிடல் கோரிக்கை நிராகரிக்கப்பட்டது கையொப்பமிடல் செயலி இந்த பரிவர்த்தனையை அங்கீகரித்திருக்கிறதா என சரிபார்க்கவும் வெளிப்புற கையெழுத்திட்டவர் கோரிக்கைக்கு அசாதாரணமான தரவை திரும்ப அனுப்பியது. Amethyst அல்லது கையெழுத்திட்டவரில் ஒரு பிழை இருக்கலாம். diff --git a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml index 79bd7785d6..1be6afb011 100644 --- a/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th-rTH/strings.xml @@ -215,7 +215,6 @@ รีเลย์ เว็บไซต์ ไม่ได้ตั้งค่า Lightning Address - คัดลอก Nsec ID (รหัสผ่านของคุณ) ลงคลิปบอร์ดเพื่อสำรองข้อมูล ส่งข้อความส่วนตัว แก้ไข the User's Metadata เลิกบล๊อก @@ -269,15 +268,6 @@ ทำเครื่องหมายอ่านแล้วในแชทที่รู้จักทั้งหมด ทำเครื่องหมายว่าอ่านแล้วทั้งหมดสำหรับข้อความใหม่ ทพเครื่องหมายว่าอ่านแล้วทั้งหมด - ## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ - \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ - - \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ - \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ - \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน - สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ - \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้ - เข้ารหัสและคัดลอกคีย์ความปลอดภัยของฉัน ภาพเหรียญตราสําหรับ %1$s "รูปภาพรางวัลป้าย คุณได้รับเหรียญตราใหม่ @@ -1978,7 +1968,6 @@ คัดลอกข่้อความในโน้ตลงคลิปบอร์ด คัดลอกไปยังคลิปบอร์ดแล้ว คัดลอก @npub ของผู้เขียนลงคลิปบอร์ด - คัดลอก secret key ของฉัน คัดลอก ไม่สามารถสร้าง LNUrl จากที่อยู่ Lightning ได้ "%1$s" โปรดตรวจสอบการตั้งค่า ไม่สามารถตรวจสอบไฟล์ที่ดาวน์โหลดหลังจากอัปโหลด: %1$s @@ -2638,10 +2627,8 @@ แชร์เป็น URL รูปภาพ อัปโหลด HLS แชร์ หรือเก็บไว้ - แสดง QR code คีย์ส่วนตัวแบบเข้ารหัส รวมข้อความส่วนตัวและกลุ่มในแท็บการแจ้งเตือน ปิดเพื่อเก็บข้อความไว้เฉพาะในแท็บข้อความ แสดงข้อความ - แสดง QR code คีย์ส่วนตัว คำขอลงชื่อถูกปฏิเสธ โปรดตรวจสอบให้แน่ใจว่าแอพเข้ารหัส หรือ Signer ได้อนุญาติทำรายการนี้ ผู้ลงนามภายนอกส่งคืนข้อมูลที่ผิดปกติสำหรับคำขอนี้ อาจมีข้อผิดพลาดใน Amethyst หรือแอปผู้ลงนาม diff --git a/commonsUI/src/commonMain/composeResources/values-th/strings.xml b/commonsUI/src/commonMain/composeResources/values-th/strings.xml index 60a0ddb5c5..0226e685bb 100644 --- a/commonsUI/src/commonMain/composeResources/values-th/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-th/strings.xml @@ -202,7 +202,6 @@ รีเลย์ เว็บไซต์ ไม่ได้ตั้งค่า Lightning Address - คัดลอก Nsec ID (รหัสผ่านของคุณ) ลงคลิปบอร์ดเพื่อสำรองข้อมูล ส่งข้อความส่วนตัว แก้ไข the User's Metadata เลิกบล๊อก @@ -257,15 +256,6 @@ ทำเครื่องหมายอ่านแล้วในแชทที่รู้จักทั้งหมด ทำเครื่องหมายว่าอ่านแล้วทั้งหมดสำหรับข้อความใหม่ ทพเครื่องหมายว่าอ่านแล้วทั้งหมด - ## เคล็ดลับการสำรองข้อมูลและความปลอดภัยที่สำคัญ - \n\nบัญชีของคุณได้รับการรักษาความปลอดภัยด้วยคีย์ลับ คีย์เป็นรหัสชุดตัวอักษรยาวขึ้นต้นด้วย **nsec1** ใครก็ตามที่สามารถเข้าถึงรหัสชุดนี้ได้ จะสามารถโพสต์และเปลี่ยนแปลงข้อมูลประจำตัวของคุณได้ - - \n\n- **ห้าม** ใส่รหัสชุดนี้ในเว็บไซต์หรือซอฟต์แวร์ที่คุณไม่ไว้ใจ - \n- ทีมพัฒนา Amethyst **จะไม่มีวัน** ขอรหัสชุดนี้ของคุณ - \n- **ควร** สำรองข้อมูลรหัสชุดนี้ไว้เพื่อกู้คืนบัญชีของคุณ เราแนะนำให้ใช้โปรแกรมจัดการรหัสผ่าน - สำหรับการรักษาความปลอดภัยเพิ่มเติม คุณสามารถเข้ารหัสคีย์ของคุณด้วยรหัสผ่าน คีย์นี้ขึ้นต้นด้วย **ncryptsec1** และไม่สามารถใช้งานได้หากไม่มีรหัสผ่านของคุณ - \n\n**แต่** หากคุณสูญหายรหัสผ่าน คุณจะไม่สามารถกู้คืนคีย์ของคุณได้ - เข้ารหัสและคัดลอกคีย์ความปลอดภัยของฉัน ภาพเหรียญตราสําหรับ %1$s "รูปภาพรางวัลป้าย คุณได้รับเหรียญตราใหม่ @@ -2034,7 +2024,6 @@ คัดลอกข่้อความในโน้ตลงคลิปบอร์ด คัดลอกไปยังคลิปบอร์ดแล้ว คัดลอก @npub ของผู้เขียนลงคลิปบอร์ด - คัดลอก secret key ของฉัน คัดลอก ไม่สามารถสร้าง LNUrl จากที่อยู่ Lightning ได้ "%1$s" โปรดตรวจสอบการตั้งค่า ไม่สามารถตรวจสอบไฟล์ที่ดาวน์โหลดหลังจากอัปโหลด: %1$s @@ -2841,10 +2830,8 @@ อัปโหลด HLS แชร์ หรือเก็บไว้ Shorts - แสดง QR code คีย์ส่วนตัวแบบเข้ารหัส รวมข้อความส่วนตัวและกลุ่มในแท็บการแจ้งเตือน ปิดเพื่อเก็บข้อความไว้เฉพาะในแท็บข้อความ แสดงข้อความ - แสดง QR code คีย์ส่วนตัว คำขอลงชื่อถูกปฏิเสธ โปรดตรวจสอบให้แน่ใจว่าแอพเข้ารหัส หรือ Signer ได้อนุญาติทำรายการนี้ ผู้ลงนามภายนอกส่งคืนข้อมูลที่ผิดปกติสำหรับคำขอนี้ อาจมีข้อผิดพลาดใน Amethyst หรือแอปผู้ลงนาม diff --git a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml index ac074b789c..4f78ecce5b 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr-rTR/strings.xml @@ -217,7 +217,6 @@ Relaylar Web Sitesi Lİghtning Adresi - Yedekleme için Nsec ID'yi (sizin şifreniz) panoya kopyalar Direkt Mesaj Gönder Kullanıcının Üstverisini Düzenler Engelleme @@ -272,16 +271,6 @@ Tüm bilinenleri okundu olarak işaretle Tüm yenileri okundu olarak işaretle Tümünü okundu olarak işaretle - ## Anahtar Yedekleme ve Güvenlik İpuçları - \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. - \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. - \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. - \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz. - - Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. - \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız. - - Gizli anahtarımı şifrele ve kopyala %1$s için rozet ödülü görseli "Rozet ödülü görseli Yeni bir Rozet Ödülü aldınız @@ -2016,7 +2005,6 @@ Not metni panoya kopyalandı Panoya kopyalandı Yazarın @npub'ı panoya kopyalandı - Gizli anahtarımı kopyala Panoya kopyala "%1$s" Lightning Adresinden LNUrl oluşturulamadı. Kullanıcının kurulumunu kontrol edin Yükleme sonrası indirilen dosya kontrol edilemedi: %1$s @@ -2812,10 +2800,8 @@ HLS Yükle Paylaş veya Kaydet Kısa videolar - Şifreli özel anahtar QR kodunu göster Bildirim sekmesine doğrudan ve grup mesajlarını dahil et. Mesajları yalnızca Mesajlar sekmesinde tutmak için kapatın. Mesajları Göster - Özel anahtar QR kodunu göster İmza isteği reddedildi İmzalayıcı uygulamanın bu işlemi yetkilendirdiğinden emin olun Harici imzalayıcı, istek için beklenmedik bir yük döndürdü. Amethyst veya İmzalayıcıda bir hata olabilir. diff --git a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml index 989465fdb4..dc85d36fb8 100644 --- a/commonsUI/src/commonMain/composeResources/values-tr/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-tr/strings.xml @@ -204,7 +204,6 @@ Relaylar Web Sitesi Lİghtning Adresi - Yedekleme için Nsec ID'yi (sizin şifreniz) panoya kopyalar Direkt Mesaj Gönder Kullanıcının Üstverisini Düzenler Engelleme @@ -259,18 +258,6 @@ Tüm bilinenleri okundu olarak işaretle Tüm yenileri okundu olarak işaretle Tümünü okundu olarak işaretle - - ## Anahtar Yedekleme ve Güvenlik İpuçları - \n\nHesabınız bir gizli anahtarla korunmaktadır. Anahtar, **nsec1** ile başlayan uzun bir karakter dizisidir. Bu gizli anahtara erişimi olan herkes kimliğiniz adına gönderi yapabilir ve kimliğinizi değiştirebilir. - \n\n- Gizli anahtarınızı güvenmediğiniz herhangi bir web sitesine veya yazılıma **koymayın**. - \n- Amethyst geliştiricileri gizli anahtarınızı **asla** istemez. - \n- Hesap kurtarma için gizli anahtarınızın güvenli bir yedeğini **alın**. Bir parola yöneticisi kullanmanızı öneririz. - - - Ek güvenlik için anahtarınızı bir parola ile şifreleyebilirsiniz. Bu anahtar **ncryptsec1** ile başlar ve parolanız olmadan kullanılamaz. - \n\nParolanızı kaybederseniz anahtarınızı kurtaramazsınız. - - Gizli anahtarımı şifrele ve kopyala %1$s için rozet ödülü görseli "Rozet ödülü görseli Yeni bir Rozet Ödülü aldınız @@ -2050,7 +2037,6 @@ Not metni panoya kopyalandı Panoya kopyalandı Yazarın @npub'ı panoya kopyalandı - Gizli anahtarımı kopyala Panoya kopyala "%1$s" Lightning Adresinden LNUrl oluşturulamadı. Kullanıcının kurulumunu kontrol edin Yükleme sonrası indirilen dosya kontrol edilemedi: %1$s @@ -2889,10 +2875,8 @@ HLS Yükle Paylaş veya Kaydet Kısa videolar - Şifreli özel anahtar QR kodunu göster Bildirim sekmesine doğrudan ve grup mesajlarını dahil et. Mesajları yalnızca Mesajlar sekmesinde tutmak için kapatın. Mesajları Göster - Özel anahtar QR kodunu göster İmza isteği reddedildi İmzalayıcı uygulamanın bu işlemi yetkilendirdiğinden emin olun Harici imzalayıcı, istek için beklenmedik bir yük döndürdü. Amethyst veya İmzalayıcıda bir hata olabilir. diff --git a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml index a6e1bee6c5..ed58d3b9e6 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk-rUA/strings.xml @@ -218,7 +218,6 @@ Релеїв Сайт Lightning адреса - Копіює Nsec ID (ваш пароль) для резервного копіювання Відправити повідомлення Редагує метадані користувача Розблокувати @@ -273,16 +272,6 @@ Позначити всі відомі прочитаними Позначити всі нові прочитаними Позначити все прочитаним - ## Поради щодо резервного копіювання та безпеки ключа - \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. - \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. - \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. - \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів. - - Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. - \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ. - - Зашифрувати та скопіювати мій секретний ключ Картинка значка-нагороди %1$s "Зображення нагороди значком Ви отримали в нагороду новий значок @@ -2029,7 +2018,6 @@ Скопіювати текст Скопійовано до буфера обміну ID (npub) автора скопійовано - Скопіювати мій приватний ключ Скопіювати в буфер Не вдалося скласти LNUrl з Lightning адреси «%1$s». Перевірте налаштування користувача Не вдалося перевірити завантажений файл після вивантаження: %1$s @@ -2868,10 +2856,8 @@ Поділитися як зображення Поділитися як URL зображення Поділитися або зберегти - Показати QR-код зашифрованого приватного ключа Включати особисті та групові повідомлення на вкладку «Сповіщення». Вимкніть, щоб залишити повідомлення лише на вкладці «Повідомлення». Показувати повідомлення - Показати QR-код приватного ключа Запит на підписання відхилено Переконайтеся, що застосунок-підписант авторизував цю транзакцію Зовнішній підписувач повернув дані, що не відповідають запиту. Можливо, є помилка в Amethyst або підписувачі. diff --git a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml index 53159e447e..15def19aff 100644 --- a/commonsUI/src/commonMain/composeResources/values-uk/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uk/strings.xml @@ -208,7 +208,6 @@ Релеїв Сайт Lightning адреса - Копіює Nsec ID (ваш пароль) для резервного копіювання Відправити повідомлення Редагує метадані користувача Розблокувати @@ -263,18 +262,6 @@ Позначити всі відомі прочитаними Позначити всі нові прочитаними Позначити все прочитаним - - ## Поради щодо резервного копіювання та безпеки ключа - \n\nВаш обліковий запис захищений секретним ключем. Ключ — це довга послідовність символів, що починається з **nsec1**. Будь-хто, хто має доступ до цього секретного ключа, може публікувати та змінювати вашу особу. - \n\n- **Не** вводьте свій секретний ключ на жодному сайті чи в програмі, якій ви не довіряєте. - \n- Розробники Amethyst **ніколи** не запитуватимуть ваш секретний ключ. - \n- **Зробіть** надійне резервне копіювання секретного ключа для відновлення облікового запису. Рекомендуємо використовувати менеджер паролів. - - - Для додаткової безпеки ви можете зашифрувати ключ паролем. Цей ключ починається з **ncryptsec1** і не може бути використаний без вашого пароля. - \n\nЯкщо ви забудете пароль, ви не зможете відновити ключ. - - Зашифрувати та скопіювати мій секретний ключ Картинка значка-нагороди %1$s "Зображення нагороди значком Ви отримали в нагороду новий значок @@ -2070,7 +2057,6 @@ Скопіювати текст Скопійовано до буфера обміну ID (npub) автора скопійовано - Скопіювати мій приватний ключ Скопіювати в буфер Не вдалося скласти LNUrl з Lightning адреси «%1$s». Перевірте налаштування користувача Не вдалося перевірити завантажений файл після вивантаження: %1$s @@ -2961,10 +2947,8 @@ HLS Upload Поділитися або зберегти Shorts - Показати QR-код зашифрованого приватного ключа Включати особисті та групові повідомлення на вкладку «Сповіщення». Вимкніть, щоб залишити повідомлення лише на вкладці «Повідомлення». Показувати повідомлення - Показати QR-код приватного ключа Запит на підписання відхилено Переконайтеся, що застосунок-підписант авторизував цю транзакцію Зовнішній підписувач повернув дані, що не відповідають запиту. Можливо, є помилка в Amethyst або підписувачі. diff --git a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml index 332e2bae8f..21a4b69726 100644 --- a/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-uz-rUZ/strings.xml @@ -218,7 +218,6 @@ Relay lar Veb-sayt Lightning manzili - Nsec ID (parolingiz) nusxasini zaxiralash uchun buferga nusxalaydi To'g'ridan-to'g'ri xabar yuborish Foydalanuvchi metama'lumotlarini tahrirlaydi Blokdan chiqarish @@ -273,16 +272,6 @@ Barcha ma'lumlarni o'qilgan deb belgilash Barcha yangilarni o'qilgan deb belgilash Barchasini o'qilgan deb belgilash - ## Kalit zaxiralash va xavfsizlik maslahatlari - \n\nHisobingiz maxfiy kalit bilan himoyalangan. Kalit **nsec1** bilan boshlanadigan uzun belgilar ketma-ketligidir. Ushbu maxfiy kalitga kirish huquqiga ega bo'lgan har kim sizning nomingizdan post qo'yishi va identifikatoringizni o'zgartirishi mumkin. - \n\n- Maxfiy kalitingizni ishonmagan veb-sayt yoki dasturga **kiritmang**. - \n- Amethyst ishlab chiquvchilari maxfiy kalitingizni hech **qachon** so'ramaydi. - \n- Hisob tiklash uchun maxfiy kalitingizning xavfsiz zaxira nusxasini saqlang. Parol menejeridan foydalanishni tavsiya qilamiz. - - Qo'shimcha xavfsizlik uchun kalitingizni parol bilan shifrlashingiz mumkin. Bu kalit **ncryptsec1** bilan boshlanadi va parolsiz ishlatib bo'lmaydi. - \n\nParolingizni unutsangiz, kalitingizni tiklay olmaysiz. - - Maxfiy kalitni shifrlash va nusxalash %1$s uchun nishon mukofoti rasmi "Nishon mukofoti rasmi Siz yangi nishon mukofoti oldingiz @@ -2004,7 +1993,6 @@ Eslatma matni buferga nusxalandi Buferga nusxalandi Muallifning @npub manzili buferga nusxalandi - Mening maxfiy kalitimni nusxalash Buferga nusxalash Lightning manzilidan LNUrl yig'ib bo'lmadi "%1$s". Foydalanuvchi sozlamasini tekshiring Yuklashdan keyin yuklab olingan faylni tekshirib bo'lmadi: %1$s @@ -2821,10 +2809,8 @@ HLS yuklash Ulashish yoki saqlash Shortslar - Shifrlangan shaxsiy kalit QR kodini ko'rsatish Bildirishnoma yorlig'iga to'g'ridan-to'g'ri va guruh xabarlarini kiriting. Xabarlarni faqat Xabarlar yorlig'ida saqlash uchun o'chiring. Xabarlarni Ko'rsatish - Shaxsiy kalit QR kodini ko'rsatish Imzo so'rovi rad etildi Imzolash ilovasi ushbu tranzaksiyaga ruxsat berganligini tekshiring Tashqi imzolash dasturi so'rov uchun g'alati javob qaytardi. Amethyst yoki imzolash dasturida xato bo'lishi mumkin. diff --git a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml index a875b48f0e..0c8d3c8bb2 100644 --- a/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-vi-rVN/strings.xml @@ -212,7 +212,6 @@ Relay Trang web Địa chỉ Lightning - Sao chép Nsec ID (mật khẩu của bạn) vào clipboard để sao lưu Gửi tin nhắn trực tiếp Chỉnh sửa siêu dữ liệu người dùng Bỏ chặn @@ -267,16 +266,6 @@ Đánh dấu tất cả đã biết là đã đọc Đánh dấu tất cả mới là đã đọc Đánh dấu tất cả là đã đọc - ## Mẹo sao lưu khóa và bảo mật - \n\nTài khoản của bạn được bảo mật bằng một khóa bí mật. Khóa là một chuỗi ký tự dài bắt đầu bằng **nsec1**. Bất kỳ ai có quyền truy cập vào khóa bí mật này đều có thể đăng bài và thay đổi danh tính của bạn. - \n\n- **Không** đưa khóa bí mật của bạn vào bất kỳ trang web hoặc phần mềm nào mà bạn không tin tưởng. - \n- Các nhà phát triển Amethyst sẽ **không bao giờ** yêu cầu khóa bí mật của bạn. - \n- **Hãy** giữ bản sao lưu an toàn của khóa bí mật để khôi phục tài khoản. Chúng tôi khuyến nghị sử dụng trình quản lý mật khẩu. - - Để bảo mật thêm, bạn có thể mã hóa khóa của mình bằng mật khẩu. Khóa này bắt đầu bằng **ncryptsec1** và không thể sử dụng nếu không có mật khẩu của bạn. - \n\nNếu bạn mất mật khẩu, bạn sẽ không thể khôi phục khóa của mình. - - Mã hóa và sao chép khóa bí mật của tôi Hình ảnh huy hiệu được trao cho %1$s "Hình ảnh huy hiệu được trao Bạn đã nhận được huy hiệu mới @@ -1992,7 +1981,6 @@ Đã sao chép nội dung ghi chú vào clipboard Đã sao chép vào clipboard Đã sao chép @npub của tác giả vào clipboard - Sao chép khóa bí mật của tôi Sao chép vào clipboard Không thể tạo LNUrl từ Địa chỉ Lightning "%1$s". Hãy kiểm tra cài đặt của người dùng Không thể kiểm tra tệp đã tải xuống sau khi tải lên: %1$s @@ -2769,10 +2757,8 @@ Chia sẻ dưới dạng URL hình ảnh Tải lên HLS Chia sẻ hoặc Lưu - Hiển thị mã QR khóa riêng tư đã mã hóa Bao gồm tin nhắn trực tiếp và nhóm trên tab Thông báo. Tắt để chỉ giữ tin nhắn trong tab Tin nhắn. Hiển thị tin nhắn - Hiển thị mã QR khóa riêng tư Yêu cầu ký bị từ chối Hãy đảm bảo ứng dụng ký đã cho phép giao dịch này Trình ký bên ngoài trả về dữ liệu không hợp lệ cho yêu cầu. Có thể có lỗi trong Amethyst hoặc trong trình ký. diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml index d16f98dff4..6ad24e1eaa 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -303,7 +303,6 @@ 中继 网站 闪电地址 - 复制 Nsec ID(您的私人密钥)到剪贴板以备备份 发送直接消息 编辑用户元数据 不隐藏 @@ -389,16 +388,6 @@ 将所有已知内容标记为已读 将所有新内容标记为已读 将所有内容标记为已读 - ## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - - 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - - 加密并复制我的密钥。 颁发给 %1$s 的徽章图片 "徽章图片 你收到了新的徽章奖励 @@ -2632,7 +2621,6 @@ 文本已复制到剪贴板 已复制到剪贴板 已复制作者公钥 ID 到剪贴板 - 复制我的私人密钥 复制到剪贴板 无法从闪电地址集合LNURL"%1$s"。请检查用户设置 无法检查经过上传后再下载的文件:%1$s @@ -3777,10 +3765,8 @@ HLS 上传 分享或保存 短视频 - 显示加密私钥二维码 在通知选项卡上包含私信和群消息。如关闭只在消息选项卡中保留消息。 显示消息 - 显示私钥二维码 签名请求已被拒绝 请确保签名应用程序已授权此交易 外部签名器对该请求返回了不正常的载荷。这可能是 Amethyst 或签名器上的错误。 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml index 9eb6ba5bc8..ab29283ac7 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rHK/strings.xml @@ -221,7 +221,6 @@ 中繼 網站 閃電地址 - 複製 Nsec ID(您的私人密鑰)到剪貼板以備備份 發送直接消息 編輯用户元數據 不隱藏 @@ -276,16 +275,6 @@ 將所有已知內容標記為已讀 將所有新內容標記為已讀 將所有內容標記為已讀 - ## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - - 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - - 加密并复制我的密钥。 頒發給 %1$s 的徽章圖片 "徽章图片 您收到了新的徽章獎勵 @@ -2029,7 +2018,6 @@ 文本已複製到剪貼板 已复制到剪贴板 複製作者的 @npub 到剪貼板 - 複製我的私人密鑰 复制到剪贴板 无法从闪电地址集合LNURL"%1$s"。请检查用户设置 无法检查经过上传后再下载的文件:%1$s @@ -2830,10 +2818,8 @@ HLS 上传 分享或保存 短视频 - 显示加密私钥二维码 在通知选项卡上包含私信和群消息。如关闭只在消息选项卡中保留消息。 显示消息 - 显示私钥二维码 签名请求已被拒绝 请确保签名应用程序已授权此交易 外部签名器对该请求返回了不正常的载荷。这可能是 Amethyst 或签名器上的错误。 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml index 4649ff00d5..4391ef6518 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rSG/strings.xml @@ -221,7 +221,6 @@ 中继 网站 闪电地址 - 复制 Nsec ID(您的私人密钥)到剪贴板以备备份 发送直接消息 编辑用户元数据 不隐藏 @@ -276,16 +275,6 @@ 将所有已知内容标记为已读 将所有新内容标记为已读 将所有内容标记为已读 - ## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - - 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - - 加密并复制我的密钥。 颁发给 %1$s 的徽章图片 "徽章图片 你收到了新的徽章奖励 @@ -2029,7 +2018,6 @@ 文本已复制到剪贴板 已复制到剪贴板 已复制作者公钥 ID 到剪贴板 - 复制我的私人密钥 复制到剪贴板 无法从闪电地址集合LNURL"%1$s"。请检查用户设置 无法检查经过上传后再下载的文件:%1$s @@ -2830,10 +2818,8 @@ HLS 上传 分享或保存 短视频 - 显示加密私钥二维码 在通知选项卡上包含私信和群消息。如关闭只在消息选项卡中保留消息。 显示消息 - 显示私钥二维码 签名请求已被拒绝 请确保签名应用程序已授权此交易 外部签名器对该请求返回了不正常的载荷。这可能是 Amethyst 或签名器上的错误。 diff --git a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml index 6bd229d72c..eb702eba33 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -216,7 +216,6 @@ 中繼 網站 閃電地址 - 複製 NSec (私人密鑰)到剪貼板以備備份 發送直接消息 編輯用户元數據 不隱藏 @@ -271,16 +270,6 @@ 將所有已知內容標記為已讀 將所有新內容標記為已讀 將所有內容標記為已讀 - ## 備份與安全提示 - \n\n你的帳戶由一個私鑰保護。私鑰是以 **nsec1** 開頭的隨機字符串。任何擁有你的私鑰的人都可以使用你的身份發佈內容。 - \n\n- **不要**將你的私鑰添加到任何你不信任的網站或軟件,亦不要在網上公開。 - \n- Amethyst 開發人員**永遠不會**要求你提供私鑰。 - \n- **請**保留你的私鑰的安全備份,以備帳戶恢復。我們建議使用密碼管理器。 - - 爲了額外的安全性,你可以用密碼加密你的密鑰。此密鑰以 **ncryptsec1** 開頭,沒有密碼就不能使用。 - \n\n如果你丟失密碼,你將無法恢復你的密鑰。 - - 加密並複製我的密鑰 頒發給 %1$s 的徽章圖片 "徽章獎勵圖片 您收到了新的徽章獎勵 @@ -2014,7 +2003,6 @@ 文本已複製到剪貼板 已複製到剪貼簿 複製作者的 @npub 到剪貼板 - 複製我的私人密鑰 複製至剪貼簿 無法從閃電地址集合 LNURL “%1$s”。請檢查用戶設置 上傳後無法檢查已下載的檔案:%1$s @@ -2794,10 +2782,8 @@ HLS 上傳 分享或保存 短片 - 顯示加密私鑰 QR 碼 在通知分頁中包含私訊及群組訊息。關閉後,訊息將只出現在訊息分頁中。 顯示訊息 - 顯示私鑰 QR 碼 簽名請求已被拒絕 請確保簽名應用程式已授權此交易 外部簽署器回傳了與請求不符的酬載。Amethyst 或簽署器可能存在錯誤。 diff --git a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml index 643d37c01f..8e042731e8 100644 --- a/commonsUI/src/commonMain/composeResources/values-zh/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-zh/strings.xml @@ -202,7 +202,6 @@ 中继 网站 闪电地址 - 复制 Nsec ID(您的私人密钥)到剪贴板以备备份 发送直接消息 编辑用户元数据 不隐藏 @@ -257,7 +256,6 @@ 将所有已知内容标记为已读 将所有新内容标记为已读 将所有内容标记为已读 - 加密并复制我的密钥。 颁发给 %1$s 的徽章图片 "徽章图片 您收到了新的徽章奖励 @@ -1801,15 +1799,6 @@ Explorer API 基础URL 支付来自DVM的发票 支付 %1$s sats 到 DVM - ## 备份与安全提示 - \n\n你的帐户由一个私钥保护。私钥是以**nsec1**开头的随机字符串。任何拥有你的私钥的人都可以使用你的身份发布内容。 - \n\n- **不要**将你的私钥添加到任何你不信任的网站或软件,亦不要在网上公开。 - \n- Amethyst 开发人员**永远不会**要求你提供私钥。 - \n- **请**保留你的私钥的安全备份,以备帐户恢复。我们建议使用密码管理器。 - - 为了额外的安全性,你可以用密码加密你的密钥。 此密钥以 **ncryptsec1** 开头,没有密码就不能使用。 - \n\n如果你丢失密码, 你将无法恢复你的密钥。 - 使用直接链接 静态网站: %1$s nApple: %1$s @@ -2046,7 +2035,6 @@ 文本已复制到剪贴板 已复制到剪贴板 复制作者的 @npub 到剪贴板 - 复制我的私人密钥 复制到剪贴板 无法从闪电地址集合LNURL"%1$s"。请检查用户设置 无法检查经过上传后再下载的文件:%1$s @@ -2883,10 +2871,8 @@ HLS 上传 分享或保存 短视频 - 显示加密私钥二维码 在通知选项卡上包含私信和群消息。如关闭只在消息选项卡中保留消息。 显示消息 - 显示私钥二维码 签名请求已被拒绝 请确保签名应用程序已授权此交易 外部签名器对该请求返回了不正常的载荷。这可能是 Amethyst 或签名器上的错误。 diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index a0b587b986..88a3764832 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -550,7 +550,6 @@ Relays Website Lightning Address - Copies the Nsec ID (your password) to the clipboard for backup Send a Direct Message Edits the User's Metadata Unblock @@ -660,23 +659,22 @@ Mark all Known as read Mark all New as read Mark all as read - - ## Key Backup and Safety Tips - \n\nYour account is secured by a secret key. The key is a long sequence of characters starting with **nsec1**. Anyone who has access to this secret key can post and change your identity. - \n\n- Do **not** put your secret key in any website or software you do not trust. - \n- Amethyst developers will **never** ask for your secret key. - \n- **Do** keep a secure backup of your secret key for account recovery. We recommend using a password manager. - - - For additional security, you can encrypt your key with a password. This key starts with **ncryptsec1** and cannot be used without your password. - \n\nIf you lose your password, you will not be able to recover your key. - + Your secret key is your account + Anyone who has it can post as you. If you lose it, nobody can recover it, not even Amethyst. + Tap to reveal + Write it down in this order. Upper or lower case both work. + QR code + Password-protected copy + Password + Locks your key with a password into an ncryptsec1… that only works together with that password. Forget the password and this copy is useless. + Keep the paper somewhere only you can find it, or save the key in a password manager. + Never paste your key into websites or apps you don't trust. + Amethyst developers will never ask for your key. Back up your keys Your secret key is the only way to access this account. If you lose it, it can never be recovered. Save it somewhere safe now. Back up now I saved them Dismiss - Encrypt and copy my secret key Badge award image for %1$s "Badge award image You Received a new Badge Award @@ -3621,7 +3619,6 @@ Copied note text to clipboard Copied to clipboard Copied author’s @npub to clipboard - Copy my secret key Copy to clipboard Could not assemble LNUrl from Lightning Address "%1$s". Check the user's setup Could not check downloaded file after upload: %1$s @@ -5123,10 +5120,8 @@ HLS Upload Share or Save Shorts - Show encrypted private key QR code Include direct and group messages on the Notification tab. Turn off to keep messages only in the Messages tab. Show Messages - Show private key QR code Sign request rejected Make sure the signer application has authorized this transaction External signer returned a payload that is strange for the request. There might be a bug on either Amethyst or the Signer. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/KeyTranscriptionGrid.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/KeyTranscriptionGrid.kt new file mode 100644 index 0000000000..c0f95f4db0 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/KeyTranscriptionGrid.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription + +private const val MASK_CHAR = '•' + +/** + * Shows a bech32 key (usually an nsec) as numbered rows of 5-4-4-4-4 character + * groups (see [Bech32Transcription]) so it can be copied onto paper by hand. + * + * Monospace keeps the groups aligned as columns across rows, and each row is + * auto-sized to stay on one line on narrow screens and large font scales. + * [masked] keeps the exact same layout with every character replaced by a dot, + * so revealing the key doesn't shift the screen. + */ +@Composable +fun KeyTranscriptionGrid( + bech32: String, + modifier: Modifier = Modifier, + masked: Boolean = false, + uppercase: Boolean = true, + color: Color = MaterialTheme.colorScheme.onSurface, + lineNumberColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + val rows = + remember(bech32, masked, uppercase) { + val source = if (uppercase) bech32.uppercase() else bech32 + Bech32Transcription.groups(source).map { groups -> + groups.joinToString(" ") { group -> + if (masked) MASK_CHAR.toString().repeat(group.length) else group + } + } + } + + val keyStyle = + MaterialTheme.typography.titleLarge.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + letterSpacing = 1.sp, + ) + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + rows.forEachIndexed { index, row -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = (index + 1).toString(), + style = MaterialTheme.typography.labelMedium, + color = lineNumberColor, + modifier = Modifier.width(20.dp), + ) + Text( + text = row, + style = keyStyle, + color = color, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + autoSize = TextAutoSize.StepBased(minFontSize = 10.sp, maxFontSize = keyStyle.fontSize), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 02b582fc95..4c2b520189 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.toNpub @@ -700,7 +701,8 @@ class AccountManager internal constructor( } fun loginWithKey(keyInput: String): Result { - val trimmedInput = keyInput.trim() + // Also accepts keys copied by hand: UPPERCASE, split into dash/space-separated groups. + val trimmedInput = Bech32Transcription.normalize(keyInput) val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput) if (privKeyHex != null) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt index 13e8e650b0..1ca4f222fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt @@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.commons.resources.backup_keys_show_qr import com.vitorpamplona.amethyst.commons.resources.backup_keys_title import com.vitorpamplona.amethyst.commons.resources.backup_keys_unlock_subtitle import com.vitorpamplona.amethyst.commons.resources.backup_keys_unlock_title +import com.vitorpamplona.amethyst.commons.ui.components.KeyTranscriptionGrid import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.security.DesktopLockScreen import com.vitorpamplona.amethyst.desktop.ui.auth.QrCodeCanvas @@ -314,7 +315,16 @@ private fun RevealedSecret( nsec: String, onHide: () -> Unit, ) { - MonospaceKeyValue(value = nsec, isSensitive = true) + KeyTranscriptionGrid( + bech32 = nsec, + modifier = + Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 12.dp, vertical = 16.dp), + ) Spacer(Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -431,19 +441,11 @@ private fun EncryptedCopy(nsec: String) { } @Composable -private fun MonospaceKeyValue( - value: String, - isSensitive: Boolean = false, -) { +private fun MonospaceKeyValue(value: String) { Text( value, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = - if (isSensitive) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .fillMaxWidth() diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt index 6059f961fe..9cdb2b604d 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.model.account.SignerType import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec import io.mockk.coEvery @@ -84,6 +85,19 @@ class AccountManagerKeyLoginTest { assertEquals(SignerType.Internal, state.signerType) } + @Test + fun loginWithHandCopiedNsecReturnsLoggedIn() { + val keyPair = KeyPair() + val nsec = keyPair.privKey!!.toNsec() + // As written down from the backup screen: uppercase, in dash-separated groups, one row per line. + val handCopied = + Bech32Transcription.groups(nsec.uppercase()).joinToString("\n") { it.joinToString("-") } + + val state = manager.loginWithKey(handCopied).getOrThrow() + assertFalse(state.isReadOnly) + assertEquals(nsec, state.nsec) + } + @Test fun loginWithNpubReturnsReadOnly() { val keyPair = KeyPair() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Bech32Transcription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Bech32Transcription.kt new file mode 100644 index 0000000000..ba64067994 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Bech32Transcription.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip19Bech32 + +/** + * Helpers for copying a bech32 key (usually an `nsec1…`) by hand — onto paper, a + * steel plate, or into another device's keyboard — and for reading it back. + * + * A 32-byte key encodes to 63 chars (`nsec1` + 52 data + 6 checksum), which splits + * evenly into [ROWS_PER_KEY] rows of [ROW_PATTERN] (5-4-4-4-4 = 21): + * + * ``` + * nsec1 ptuh w9cg 9ays zgxs + * nr6zm 5akh s9ju w2r9 eujr + * ha0r5 q6fz wghj hsl4 vqtl + * ``` + * + * Bech32 is case-insensitive as long as the whole string uses one case, so the + * transcription may be shown in UPPERCASE: the alphabet has no `b`, `i` or `o`, + * and `1` only appears as the separator, which removes most handwriting mix-ups. + */ +object Bech32Transcription { + /** Group sizes of one row. The leading 5 keeps the `nsec1` prefix in one group. */ + val ROW_PATTERN = intArrayOf(5, 4, 4, 4, 4) + + const val ROWS_PER_KEY = 3 + + /** + * Splits [bech32] into rows of groups following [ROW_PATTERN]. The last row may be + * shorter when the input isn't a multiple of the row length. + */ + fun groups(bech32: String): List> { + val rows = mutableListOf>() + var i = 0 + while (i < bech32.length) { + val row = mutableListOf() + for (size in ROW_PATTERN) { + if (i >= bech32.length) break + val end = minOf(i + size, bech32.length) + row.add(bech32.substring(i, end)) + i = end + } + rows.add(row) + } + return rows + } + + /** + * Undoes a hand transcription: if [input], once whitespace and `-` separators are + * removed, starts with a NIP-19 / NIP-49 prefix (any case), returns it compacted and + * lowercased so the bech32 decoder and prefix checks accept it. Anything else is + * returned trimmed but otherwise untouched (hex keys, mnemonics, NIP-05 addresses). + * + * Neither whitespace nor `-` is in the bech32 alphabet, so dropping them can't turn + * one valid key into another. + */ + fun normalize(input: String): String { + val trimmed = input.trim() + val compact = trimmed.filterNot { it.isWhitespace() || it == '-' } + val lower = compact.lowercase() + return if (TRANSCRIBABLE_PREFIXES.any { lower.startsWith(it) }) lower else trimmed + } + + private val TRANSCRIBABLE_PREFIXES = arrayOf("nsec1", "npub1", "ncryptsec1", "nprofile1") +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/Bech32TranscriptionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/Bech32TranscriptionTest.kt new file mode 100644 index 0000000000..c8d13a445b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/Bech32TranscriptionTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip19Bech32 + +import kotlin.test.Test +import kotlin.test.assertEquals + +class Bech32TranscriptionTest { + private val nsec = "nsec1ptuhw9cg9ayszgxsnr6zm5akhs9juw2r9eujrha0r5q6fzwghjhsl4vqtl" + + @Test + fun splitsAnNsecIntoThreeRowsOf54444() { + assertEquals( + listOf( + listOf("nsec1", "ptuh", "w9cg", "9ays", "zgxs"), + listOf("nr6zm", "5akh", "s9ju", "w2r9", "eujr"), + listOf("ha0r5", "q6fz", "wghj", "hsl4", "vqtl"), + ), + Bech32Transcription.groups(nsec), + ) + } + + @Test + fun groupsRejoinToTheOriginal() { + assertEquals(nsec, Bech32Transcription.groups(nsec).flatten().joinToString("")) + } + + @Test + fun shortInputLeavesAPartialLastRow() { + assertEquals( + listOf(listOf("nsec1", "ptuh", "w9cg", "9ays", "zgxs"), listOf("nr")), + Bech32Transcription.groups("nsec1ptuhw9cg9ayszgxsnr"), + ) + } + + @Test + fun uppercaseHyphenatedTranscriptionDecodesToTheSameKey() { + val written = + """ + NSEC1-PTUH-W9CG-9AYS-ZGXS + NR6ZM-5AKH-S9JU-W2R9-EUJR + HA0R5-Q6FZ-WGHJ-HSL4-VQTL + """.trimIndent() + + val normalized = Bech32Transcription.normalize(written) + assertEquals(nsec, normalized) + assertEquals(decodePrivateKeyAsHexOrNull(nsec), decodePrivateKeyAsHexOrNull(normalized)) + } + + @Test + fun spacesAndMixedCaseAreAccepted() { + assertEquals(nsec, Bech32Transcription.normalize(" Nsec1 ptuh w9cg 9ays zgxs nr6zm 5akh s9ju w2r9 eujr ha0r5 q6fz wghj hsl4 vqtl ")) + } + + @Test + fun nonBech32InputIsOnlyTrimmed() { + assertEquals("alice@example.com", Bech32Transcription.normalize(" alice@example.com ")) + assertEquals("abandon ability able", Bech32Transcription.normalize("abandon ability able")) + assertEquals("ABCDEF-01", Bech32Transcription.normalize("ABCDEF-01")) + } +} From 33ea20effc84e311931c3e7bd0b78a0d3d192d6a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:22:26 +0000 Subject: [PATCH 02/32] feat: confirm-then-encrypt flow for the password-protected backup The ncryptsec section was one blind password field feeding two buttons that each re-ran scrypt and produced a different ciphertext. A single typo made the backup permanently useless, and nothing showed what had been produced. - The card is collapsed by default, so the paper backup stays the focus. - The password must be typed twice. A mismatch is flagged inline and a check mark shows when the two match; IME Done encrypts. - One "Encrypt key" button shows progress while scrypt runs off the main thread. It then shows the ncryptsec with Copy / QR buttons that reuse the same value, plus "Use a different password" to start over. - The result and its QR are dropped when the app goes to the background. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP --- .../loggedIn/keyBackup/AccountBackupScreen.kt | 275 +++++++++++++----- .../composeResources/values/strings.xml | 8 +- 2 files changed, 206 insertions(+), 77 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index c90f702085..2e63408b3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -29,6 +29,7 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -45,16 +46,18 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect @@ -74,6 +77,7 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -91,9 +95,15 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypt +import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypt_again import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_body import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_password +import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_password_mismatch +import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_repeat_password +import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_saved_hint import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_title +import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypting import com.vitorpamplona.amethyst.commons.resources.account_backup_headline import com.vitorpamplona.amethyst.commons.resources.account_backup_intro import com.vitorpamplona.amethyst.commons.resources.account_backup_qr_code @@ -374,108 +384,221 @@ private fun EncryptedKeyCard( val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() + var expanded by remember { mutableStateOf(false) } var password by remember { mutableStateOf("") } + var repeated by remember { mutableStateOf("") } var showPassword by remember { mutableStateOf(false) } var working by remember { mutableStateOf(false) } - var qrCode by remember { mutableStateOf(null) } + var encrypted by remember { mutableStateOf(null) } + var showQr by remember { mutableStateOf(false) } - // NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread. - fun encryptThen(onEncrypted: suspend (String) -> Unit) { - val privKey = accountViewModel.account.settings.keyPair.privKey ?: return - val currentPassword = password - working = true - scope.launch { - val encrypted = - withContext(Dispatchers.Default) { - runCatching { Nip49().encrypt(privKey.toHexKey(), currentPassword) }.getOrNull() + // Never leave the encrypted key or the typed password around while in the background. + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + encrypted = null + showQr = false + } + + // A typo in the password makes the backup permanently useless, so it must be typed twice. + val mismatch = repeated.isNotEmpty() && repeated != password + val canEncrypt = password.isNotBlank() && repeated == password && !working + + fun encrypt() { + if (!canEncrypt) return + gate.withAccess { + val privKey = accountViewModel.account.settings.keyPair.privKey ?: return@withAccess + val currentPassword = password + working = true + // NIP-49 runs scrypt, which takes a noticeable moment: keep it off the main thread. + scope.launch { + val result = + withContext(Dispatchers.Default) { + runCatching { Nip49().encrypt(privKey.toHexKey(), currentPassword) }.getOrNull() + } + working = false + if (result != null) { + encrypted = result + } else { + Toast.makeText(context, loadStringRes(Res.string.failed_to_encrypt_key), Toast.LENGTH_SHORT).show() } - working = false - if (encrypted != null) { - onEncrypted(encrypted) - } else { - Toast.makeText(context, loadStringRes(Res.string.failed_to_encrypt_key), Toast.LENGTH_SHORT).show() } } } - val canEncrypt = password.isNotBlank() && !working - Surface( modifier = Modifier.fillMaxWidth(), shape = CardShape, color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - Column(Modifier.padding(16.dp)) { - CardTitle(MaterialSymbols.Lock, stringRes(Res.string.account_backup_encrypted_title)) - - Spacer(Modifier.height(6.dp)) - - Text( - text = stringRes(Res.string.account_backup_encrypted_body), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( + Column { + Row( modifier = Modifier .fillMaxWidth() - .semantics { contentType = ContentType.NewPassword }, - value = password, - onValueChange = { password = it }, - singleLine = true, - label = { Text(stringRes(Res.string.account_backup_encrypted_password)) }, - keyboardOptions = - KeyboardOptions( - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - trailingIcon = { - IconButton(onClick = { showPassword = !showPassword }) { - Icon( - symbol = if (showPassword) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, - contentDescription = stringRes(if (showPassword) Res.string.hide_password else Res.string.show_password), + .clickable { expanded = !expanded } + .padding(start = 16.dp, end = 12.dp, top = 16.dp, bottom = if (expanded) 8.dp else 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + CardTitle(MaterialSymbols.Lock, stringRes(Res.string.account_backup_encrypted_title)) + Spacer(Modifier.height(4.dp)) + Text( + text = stringRes(Res.string.account_backup_encrypted_body), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 28.dp), + ) + } + Spacer(Modifier.width(8.dp)) + Icon( + symbol = if (expanded) MaterialSymbols.KeyboardArrowUp else MaterialSymbols.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + AnimatedVisibility(visible = expanded) { + Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp)) { + val encryptedValue = encrypted + if (encryptedValue == null) { + val visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation() + + OutlinedTextField( + modifier = + Modifier + .fillMaxWidth() + .semantics { contentType = ContentType.NewPassword }, + value = password, + onValueChange = { password = it }, + singleLine = true, + label = { Text(stringRes(Res.string.account_backup_encrypted_password)) }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + trailingIcon = { + IconButton(onClick = { showPassword = !showPassword }) { + Icon( + symbol = if (showPassword) MaterialSymbols.VisibilityOff else MaterialSymbols.Visibility, + contentDescription = stringRes(if (showPassword) Res.string.hide_password else Res.string.show_password), + ) + } + }, + visualTransformation = visualTransformation, ) - } - }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton( - modifier = Modifier.weight(1f), - enabled = canEncrypt, - onClick = { - gate.withAccess { - encryptThen { encrypted -> - clipboard.setText(encrypted) - Toast.makeText(context, loadStringRes(Res.string.secret_key_copied_to_clipboard), Toast.LENGTH_SHORT).show() + OutlinedTextField( + modifier = + Modifier + .fillMaxWidth() + .semantics { contentType = ContentType.NewPassword }, + value = repeated, + onValueChange = { repeated = it }, + singleLine = true, + label = { Text(stringRes(Res.string.account_backup_encrypted_repeat_password)) }, + isError = mismatch, + supportingText = + if (mismatch) { + { Text(stringRes(Res.string.account_backup_encrypted_password_mismatch)) } + } else { + null + }, + trailingIcon = + if (canEncrypt) { + { Icon(MaterialSymbols.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) } + } else { + null + }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { encrypt() }), + visualTransformation = visualTransformation, + ) + + Spacer(Modifier.height(12.dp)) + + FilledTonalButton( + modifier = Modifier.fillMaxWidth(), + enabled = canEncrypt, + onClick = ::encrypt, + ) { + if (working) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text(stringRes(Res.string.account_backup_encrypting)) + } else { + ButtonContent(MaterialSymbols.Lock, stringRes(Res.string.account_backup_encrypt)) } } - }, - ) { - ButtonContent(MaterialSymbols.ContentCopy, stringRes(Res.string.backup_keys_copy)) - } - OutlinedButton( - modifier = Modifier.weight(1f), - enabled = canEncrypt, - onClick = { gate.withAccess { encryptThen { qrCode = it } } }, - ) { - ButtonContent(MaterialSymbols.QrCode2, stringRes(Res.string.account_backup_qr_code)) + } else { + Text( + text = encryptedValue, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(12.dp), + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringRes(Res.string.account_backup_encrypted_saved_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilledTonalButton( + modifier = Modifier.weight(1f), + onClick = { + scope.launch { + clipboard.setText(encryptedValue) + Toast.makeText(context, loadStringRes(Res.string.secret_key_copied_to_clipboard), Toast.LENGTH_SHORT).show() + } + }, + ) { + ButtonContent(MaterialSymbols.ContentCopy, stringRes(Res.string.backup_keys_copy)) + } + FilledTonalButton( + modifier = Modifier.weight(1f), + onClick = { showQr = true }, + ) { + ButtonContent(MaterialSymbols.QrCode2, stringRes(Res.string.account_backup_qr_code)) + } + } + + TextButton( + modifier = Modifier.align(Alignment.CenterHorizontally), + onClick = { + encrypted = null + password = "" + repeated = "" + }, + ) { + Text(stringRes(Res.string.account_backup_encrypt_again)) + } + } } } } } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { qrCode = null } - - qrCode?.let { - ShowKeyQRDialog(it, onClose = { qrCode = null }) + encrypted?.let { + if (showQr) { + ShowKeyQRDialog(it, onClose = { showQr = false }) + } } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 88a3764832..798b9931e9 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -666,7 +666,13 @@ QR code Password-protected copy Password - Locks your key with a password into an ncryptsec1… that only works together with that password. Forget the password and this copy is useless. + An ncryptsec1… that only works with your password. + Repeat password + Passwords don't match + Encrypt key + Encrypting… + Safe to keep in a password manager or cloud notes: it can't be opened without the password, and a forgotten password can't be recovered. + Use a different password Keep the paper somewhere only you can find it, or save the key in a password manager. Never paste your key into websites or apps you don't trust. Amethyst developers will never ask for your key. From e0f200a5acbb04cd0cc7e0022ca0cc3fa98f7b06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:32:13 +0000 Subject: [PATCH 03/32] fix(nip49): trust the AEAD tag, zero derived keys, add JVM spec tests - decrypt() ignored the XChaCha20-Poly1305 authentication result and treated "no byte > 0" as a wrong password. A valid key whose bytes are all >= 0x80 (signed-negative) was rejected even with the right password. It now checks the tag. Reproduced first by Nip49SpecTest.keysWithNoPositiveSignedByteDecrypt. - encrypt() ignored the AEAD result too, and on failure would have returned an ncryptsec of an all-zero buffer that no password opens. It now throws. - The scrypt-derived key and the password bytes are zeroed after use, as NIP-49 recommends. - Nip49SpecTest runs the spec vectors on the JVM: decrypt vector, 91-byte v2 payload, NFKC normalization vector, non-determinism, wrong password, arbitrary password shapes, and a hand-copied (uppercase, grouped) ncryptsec. The existing vectors only ran as device/iOS tests, which neither `./gradlew test` nor pre-push executes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP --- .../quartz/nip49PrivKeyEnc/Nip49.kt | 64 ++++++---- .../quartz/nip49PrivKeyEnc/Nip49SpecTest.kt | 109 ++++++++++++++++++ 2 files changed, 151 insertions(+), 22 deletions(-) create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt index 1e82206ad5..76d808e9e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt @@ -48,18 +48,28 @@ class Nip49 { val key = SCrypt.scrypt(normalizedPassword, encryptedInfo.salt, n, 8, 1, 32) val m = ByteArray(32) - LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfDecrypt( - m, - key, - encryptedInfo.encryptedKey, - byteArrayOf(encryptedInfo.keySecurity), - encryptedInfo.nonce, - key, - ) + try { + // The Poly1305 tag is what tells a wrong password apart. Inspecting the output + // instead (e.g. "any byte > 0") rejects valid keys whose bytes are all >= 0x80. + val authenticated = + LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfDecrypt( + m, + key, + encryptedInfo.encryptedKey, + byteArrayOf(encryptedInfo.keySecurity), + encryptedInfo.nonce, + key, + ) - check(m.any { it > 0 }) { "Incorrect password" } + check(authenticated) { "Incorrect password" } - return m.toHexKey() + return m.toHexKey() + } finally { + // NIP-49: the symmetric key should be zeroed and discarded after use. + key.fill(0) + normalizedPassword.fill(0) + m.fill(0) + } } fun encrypt( @@ -84,18 +94,28 @@ class Nip49 { val key = SCrypt.scrypt(normalizedPassword, salt, n, 8, 1, 32) val ciphertext = ByteArray(48) - // byte[] c, long[] cLen, - // byte[] m, long mLen, - // byte[] ad, long adLen, - // byte[] nSec, byte[] nPub, byte[] k - LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfEncrypt( - ciphertext, - secretKey, - byteArrayOf(ksb), - key, - nonce, - key, - ) + try { + // byte[] c, long[] cLen, + // byte[] m, long mLen, + // byte[] ad, long adLen, + // byte[] nSec, byte[] nPub, byte[] k + val encrypted = + LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfEncrypt( + ciphertext, + secretKey, + byteArrayOf(ksb), + key, + nonce, + key, + ) + // Never hand back an ncryptsec of an untouched (all-zero) buffer: it would be a + // backup that no password can ever open. + check(encrypted) { "Failed to encrypt the key" } + } finally { + // NIP-49: the symmetric key should be zeroed and discarded after use. + key.fill(0) + normalizedPassword.fill(0) + } return EncryptedInfo( EncryptedInfo.V, diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt new file mode 100644 index 0000000000..79a0023191 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip49PrivKeyEnc + +import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals + +/** + * NIP-49 conformance on the JVM. The device test (androidDeviceTest/NIP49Test) covers the + * same vectors but runs in neither `./gradlew test` nor pre-push, so nothing on the + * default path proved the spec vectors still decrypt. + */ +class Nip49SpecTest { + private val nip49 = Nip49() + + private val specNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" + private val specKey = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" + + @Test + fun decryptsTheSpecVector() { + assertEquals(specKey, nip49.decrypt(specNcryptsec, "nostr")) + } + + @Test + fun specVectorFieldsDecode() { + val info = Nip49.EncryptedInfo.decodePayload(specNcryptsec)!! + assertEquals(0x02.toByte(), info.version) + assertEquals(16.toByte(), info.logn) + assertEquals(16, info.salt.size) + assertEquals(24, info.nonce.size) + assertEquals(48, info.encryptedKey.size) + } + + @Test + fun encryptProducesA91BytePayloadWithVersion2() { + val payload = nip49.encrypt(specKey, "nostr").bechToBytes() + assertEquals(91, payload.size) + assertEquals(0x02.toByte(), payload[0]) + assertEquals(16.toByte(), payload[1]) + assertEquals(Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK, payload[2 + 16 + 24]) + } + + @Test + fun encryptionIsNonDeterministic() { + assertNotEquals(nip49.encrypt(specKey, "nostr"), nip49.encrypt(specKey, "nostr")) + } + + @Test + fun passwordsAreNfkcNormalized() { + // Spec vector: U+212B U+2126 U+1E9B U+0323 normalizes to U+00C5 U+03A9 U+1E69. + val typed = "ÅΩẛ̣" + val normalized = "ÅΩṩ" + assertNotEquals(typed, normalized) + + val encrypted = nip49.encrypt(specKey, typed, 8, Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK) + assertEquals(specKey, nip49.decrypt(encrypted, normalized)) + } + + @Test + fun wrongPasswordIsRejected() { + assertFailsWith { nip49.decrypt(specNcryptsec, "nostr2") } + } + + @Test + fun anyNonEmptyPasswordShapeRoundTrips() { + // NIP-49 puts no length or character-class rules on the password. + listOf("a", " ", " leading and trailing ", "ção", "🔑🔒", "x".repeat(1000)).forEach { + val encrypted = nip49.encrypt(specKey, it, 8, Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK) + assertEquals(specKey, nip49.decrypt(encrypted, it)) + } + } + + @Test + fun keysWithNoPositiveSignedByteDecrypt() { + // Every byte >= 0x80 is negative as a signed Kotlin Byte. A valid key (well below + // the secp256k1 order), so the correct password must decrypt it. + val key = "80".repeat(32) + val encrypted = nip49.encrypt(key, "nostr", 8, Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK) + assertEquals(key, nip49.decrypt(encrypted, "nostr")) + } + + @Test + fun handCopiedNcryptsecDecrypts() { + val handCopied = Bech32Transcription.groups(specNcryptsec.uppercase()).joinToString("\n") { it.joinToString("-") } + assertEquals(specKey, nip49.decrypt(Bech32Transcription.normalize(handCopied), "nostr")) + } +} From b3aebaa95bc0bc400b5d1490c2816e2830b5b374 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:54:20 +0000 Subject: [PATCH 04/32] feat: require 12+ character passwords when creating an ncryptsec NIP-49 sets no minimum, but an ncryptsec can be brute-forced offline with no rate limit at one scrypt(2^16) per guess. The Android backup screen, the desktop backup card and desktop onboarding now require at least 12 characters before encrypting. A hint under the password field turns primary once it is met. The rule lives in quartz (Nip49.MIN_PASSWORD_LENGTH / isLongEnough). It counts code points of the NFKC-normalized password, i.e. what scrypt actually sees, so an emoji counts once. It applies only at creation: decrypting and login still accept any password, so ncryptsecs made elsewhere keep opening. The CLI is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP --- .../loggedIn/keyBackup/AccountBackupScreen.kt | 10 ++++++++- .../composeResources/values/strings.xml | 1 + .../desktop/ui/auth/NewKeyOnboardingScreen.kt | 12 +++++----- .../desktop/ui/keyBackup/BackupKeysCard.kt | 12 +++++----- .../quartz/nip49PrivKeyEnc/Nip49.kt | 22 +++++++++++++++++++ .../quartz/nip49PrivKeyEnc/Nip49SpecTest.kt | 11 ++++++++++ 6 files changed, 57 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index 2e63408b3a..74248e5fb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -106,6 +106,7 @@ import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypted_tit import com.vitorpamplona.amethyst.commons.resources.account_backup_encrypting import com.vitorpamplona.amethyst.commons.resources.account_backup_headline import com.vitorpamplona.amethyst.commons.resources.account_backup_intro +import com.vitorpamplona.amethyst.commons.resources.account_backup_password_min_length import com.vitorpamplona.amethyst.commons.resources.account_backup_qr_code import com.vitorpamplona.amethyst.commons.resources.account_backup_tap_to_reveal import com.vitorpamplona.amethyst.commons.resources.account_backup_tip_developers @@ -400,7 +401,8 @@ private fun EncryptedKeyCard( // A typo in the password makes the backup permanently useless, so it must be typed twice. val mismatch = repeated.isNotEmpty() && repeated != password - val canEncrypt = password.isNotBlank() && repeated == password && !working + val longEnough = Nip49.isLongEnough(password) + val canEncrypt = longEnough && repeated == password && !working fun encrypt() { if (!canEncrypt) return @@ -471,6 +473,12 @@ private fun EncryptedKeyCard( onValueChange = { password = it }, singleLine = true, label = { Text(stringRes(Res.string.account_backup_encrypted_password)) }, + supportingText = { + Text( + text = stringRes(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH), + color = if (longEnough) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 798b9931e9..d925bac463 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -667,6 +667,7 @@ Password-protected copy Password An ncryptsec1… that only works with your password. + At least %1$d characters Repeat password Passwords don't match Encrypt key diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt index ea0d611615..5475ced1ee 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/NewKeyOnboardingScreen.kt @@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.account_backup_password_min_length import com.vitorpamplona.amethyst.commons.resources.action_copy import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_plain_warning @@ -409,12 +410,13 @@ private fun EncryptedCopySection(nsec: String) { label = { Text(stringResource(Res.string.new_key_encrypt_password_label)) }, singleLine = true, isError = error, - supportingText = + supportingText = { if (error) { - { Text(stringResource(Res.string.backup_keys_encrypt_failed)) } + Text(stringResource(Res.string.backup_keys_encrypt_failed)) } else { - null - }, + Text(stringResource(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH)) + } + }, visualTransformation = if (showChars) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { @@ -450,7 +452,7 @@ private fun EncryptedCopySection(nsec: String) { } } }, - enabled = password.isNotBlank() && !working, + enabled = Nip49.isLongEnough(password) && !working, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.secondaryContainer, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt index 1ca4f222fe..a81b62423f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/keyBackup/BackupKeysCard.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.LockScope import com.vitorpamplona.amethyst.commons.privacylock.LockState import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.account_backup_password_min_length import com.vitorpamplona.amethyst.commons.resources.backup_keys_copied import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy import com.vitorpamplona.amethyst.commons.resources.backup_keys_copy_encrypted @@ -373,12 +374,13 @@ private fun EncryptedCopy(nsec: String) { label = { Text(stringResource(Res.string.backup_keys_encrypt_password_label)) }, singleLine = true, isError = error, - supportingText = + supportingText = { if (error) { - { Text(stringResource(Res.string.backup_keys_encrypt_failed)) } + Text(stringResource(Res.string.backup_keys_encrypt_failed)) } else { - null - }, + Text(stringResource(Res.string.account_backup_password_min_length, Nip49.MIN_PASSWORD_LENGTH)) + } + }, visualTransformation = if (showChars) VisualTransformation.None else PasswordVisualTransformation(), trailingIcon = { @@ -416,7 +418,7 @@ private fun EncryptedCopy(nsec: String) { } } }, - enabled = password.isNotBlank() && !working, + enabled = Nip49.isLongEnough(password) && !working, ) { Icon( symbol = MaterialSymbols.ContentCopy, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt index 76d808e9e8..101f1305ce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt @@ -31,6 +31,28 @@ import com.vitorpamplona.quartz.utils.UnicodeNormalizer import kotlin.math.pow class Nip49 { + companion object { + /** + * Shortest password Amethyst accepts when *creating* an ncryptsec. NIP-49 sets no + * minimum, and decrypting must accept any password other clients allowed, so this + * is a creation-time policy only. + * + * An ncryptsec can be attacked offline with no rate limit, at one scrypt(2^16) + * per guess. 12 characters keeps even a random-looking password out of reach of + * large GPU farms, while staying typeable on a phone at login. + */ + const val MIN_PASSWORD_LENGTH = 12 + + /** + * Length as scrypt sees it: code points of the NFKC-normalized password, so + * an emoji (a UTF-16 surrogate pair) counts once and compatibility forms + * count as what they normalize to. + */ + fun passwordLength(password: String): Int = UnicodeNormalizer().normalizeNFKC(password).count { !it.isLowSurrogate() } + + fun isLongEnough(password: String): Boolean = passwordLength(password) >= MIN_PASSWORD_LENGTH + } + fun decrypt( nCryptSec: String, password: String, diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt index 79a0023191..e671727181 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt @@ -106,4 +106,15 @@ class Nip49SpecTest { val handCopied = Bech32Transcription.groups(specNcryptsec.uppercase()).joinToString("\n") { it.joinToString("-") } assertEquals(specKey, nip49.decrypt(Bech32Transcription.normalize(handCopied), "nostr")) } + + @Test + fun passwordLengthCountsNormalizedCodePoints() { + assertEquals(11, Nip49.passwordLength("x".repeat(11))) + assertEquals(false, Nip49.isLongEnough("x".repeat(11))) + assertEquals(true, Nip49.isLongEnough("x".repeat(12))) + // A surrogate-pair emoji is one character, not two. + assertEquals(1, Nip49.passwordLength("\uD83D\uDD11")) + // The spec's normalization vector: 4 code points typed, 3 after NFKC. + assertEquals(3, Nip49.passwordLength("\u212B\u2126\u1E9B\u0323")) + } } From 83ccbbd2a39321aef57dcfbb8f781eb7ab46ca54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:29:47 +0000 Subject: [PATCH 05/32] fix: audit fixes for the key backup and NIP-49 changes Backup screen (Android): - The key-access gate lost its queued action on Android 8-10. There the device-credential activity always runs, stops the screen, and ON_STOP cleared the action. It was also lost when a wrong fingerprint (a soft failure) preceded the lockout PIN fallback. Now only a new request or the keyguard result replaces it. - The nsec clipboard auto-clear ran in a composition scope, so leaving the screen within 60 s cancelled it and left the nsec on the clipboard. It now runs in the AccountViewModel scope. - The copied nsec is flagged IS_SENSITIVE, so Android 13+ hides it in the system copy overlay, which is outside FLAG_SECURE. - Password fields are disabled while scrypt runs, so the result always matches what is shown. Encrypt uses the ByteArray overload (no hex copy of the key). NIP-49 (quartz): - decrypt() rejects ciphertexts that are not 48 bytes. A shorter one with a valid tag decoded as a zero-padded key (test reproduces it). - An OutOfMemoryError from scrypt, e.g. an ncryptsec made elsewhere at LOG_N 20 (1 GiB), becomes an IllegalStateException, so login reports it instead of crashing. - The decrypted plaintext buffer inside LibSodiumInstance is wiped after copying out. CLI: `amy login` and `amy key decrypt` accept hand-copied keys (UPPERCASE, grouped), like the apps. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP --- .../loggedIn/keyBackup/AccountBackupScreen.kt | 45 +++++++++++++------ .../amethyst/cli/commands/KeyCommands.kt | 3 +- .../amethyst/cli/commands/LoginCommand.kt | 3 +- .../quartz/nip49PrivKeyEnc/Nip49.kt | 30 +++++++++++-- .../quartz/utils/LibSodiumInstance.kt | 2 + .../quartz/nip49PrivKeyEnc/Nip49SpecTest.kt | 15 +++++++ 6 files changed, 79 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index 74248e5fb4..a318279157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -24,6 +24,7 @@ import android.app.Activity import android.content.ClipData import android.content.Context import android.content.ContextWrapper +import android.os.PersistableBundle import android.view.WindowManager import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -91,6 +92,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -138,7 +140,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer -import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import kotlinx.coroutines.CoroutineScope @@ -217,7 +218,7 @@ private fun AccountBackupScreenContent( } else { val gate = rememberKeyAccessGate(accountViewModel) - SecretKeyCard(nsec, gate) + SecretKeyCard(nsec, gate, accountViewModel) EncryptedKeyCard(accountViewModel, gate) @@ -269,10 +270,10 @@ private fun BackupHeader() { private fun SecretKeyCard( nsec: String, gate: KeyAccessGate, + accountViewModel: AccountViewModel, ) { val context = LocalContext.current val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() var revealed by remember { mutableStateOf(false) } var showQr by remember { mutableStateOf(false) } @@ -337,7 +338,7 @@ private fun SecretKeyCard( Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilledTonalButton( modifier = Modifier.weight(1f), - onClick = { gate.withAccess { copyNSec(context, scope, nsec, clipboard) } }, + onClick = { gate.withAccess { copyNSec(context, accountViewModel.viewModelScope, nsec, clipboard) } }, ) { ButtonContent(MaterialSymbols.ContentCopy, stringRes(Res.string.backup_keys_copy)) } @@ -393,7 +394,8 @@ private fun EncryptedKeyCard( var encrypted by remember { mutableStateOf(null) } var showQr by remember { mutableStateOf(false) } - // Never leave the encrypted key or the typed password around while in the background. + // Drop the result while in the background. The typed password is kept so switching to a + // password manager to fetch it doesn't wipe the fields. LifecycleEventEffect(Lifecycle.Event.ON_STOP) { encrypted = null showQr = false @@ -414,7 +416,9 @@ private fun EncryptedKeyCard( scope.launch { val result = withContext(Dispatchers.Default) { - runCatching { Nip49().encrypt(privKey.toHexKey(), currentPassword) }.getOrNull() + runCatching { + Nip49().encrypt(privKey, currentPassword, Nip49.DEFAULT_LOG_N, Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK) + }.getOrNull() } working = false if (result != null) { @@ -471,6 +475,8 @@ private fun EncryptedKeyCard( .semantics { contentType = ContentType.NewPassword }, value = password, onValueChange = { password = it }, + // What gets encrypted is the password at tap time: don't let it change underneath. + enabled = !working, singleLine = true, label = { Text(stringRes(Res.string.account_backup_encrypted_password)) }, supportingText = { @@ -505,6 +511,7 @@ private fun EncryptedKeyCard( .semantics { contentType = ContentType.NewPassword }, value = repeated, onValueChange = { repeated = it }, + enabled = !working, singleLine = true, label = { Text(stringRes(Res.string.account_backup_encrypted_repeat_password)) }, isError = mismatch, @@ -667,7 +674,11 @@ private class KeyAccessGate { var isUnlocked by mutableStateOf(false) private set - /** The action waiting on the keyguard fallback activity to return. */ + /** + * The action waiting on the keyguard fallback activity to return. Only a new request or + * that activity's result replaces it: the activity stops this screen (so ON_STOP must not + * clear it), and a wrong fingerprint before the lockout fallback is not a final failure. + */ var pending: (() -> Unit)? = null var prompt: ((onApproved: () -> Unit) -> Unit)? = null @@ -685,7 +696,6 @@ private class KeyAccessGate { fun lock() { isUnlocked = false - pending = null } } @@ -717,10 +727,7 @@ private fun rememberKeyAccessGate(accountViewModel: AccountViewModel): KeyAccess gate.pending = null onApproved() }, - onError = { title, message -> - gate.pending = null - accountViewModel.toastManager.toast(title, message) - }, + onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, ) } } @@ -745,8 +752,9 @@ private fun copyNSec( nsec: String, clipboardManager: Clipboard, ) { + // The auto-clear below must outlive this screen, so [scope] is not a composition scope. scope.launch { - clipboardManager.setText(nsec) + clipboardManager.setClipEntry(ClipEntry(sensitiveClip(nsec))) Toast .makeText( context, @@ -756,7 +764,6 @@ private fun copyNSec( // Best-effort auto-clear: after a delay, wipe the clipboard only if it // still holds this exact nsec (don't clobber anything copied since). - // On Android 13+ the OS also shows its own sensitive-content UI. delay(CLIPBOARD_CLEAR_DELAY_MS) if (clipboardManager.getText() == nsec) { clipboardManager.setClipEntry(ClipEntry(ClipData.newPlainText("", ""))) @@ -764,6 +771,16 @@ private fun copyNSec( } } +/** + * Marks the clip as sensitive so Android 13+ hides it in the copy confirmation overlay and + * clipboard previews. That overlay is a system window, outside this screen's FLAG_SECURE. + */ +private fun sensitiveClip(text: String): ClipData = + ClipData.newPlainText("", text).apply { + // ClipDescription.EXTRA_IS_SENSITIVE, spelled out: the constant is API 33, the key works earlier. + description.extras = PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } + } + @Composable private fun ShowKeyQRDialog( qrCode: String, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt index 5385284091..14c76a545d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.cli.Identity import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 @@ -121,7 +122,7 @@ object KeyCommands { private fun decrypt(rest: Array): Int { val args = Args(rest) - val ncryptsec = args.positional(0, "ncryptsec").trim() + val ncryptsec = Bech32Transcription.normalize(args.positional(0, "ncryptsec")) if (!ncryptsec.startsWith("ncryptsec")) return Output.error("bad_args", "expected an ncryptsec1… string") // Read both spellings eagerly so passing both doesn't trip rejectUnknown(). val pwAlias = args.flag("pw") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt index 3f97d9c8ba..839041a49f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 +import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 @@ -90,7 +91,7 @@ object LoginCommand { args.rejectUnknown("password", "pw", "private") val identity = - resolveIdentity(key, args) + resolveIdentity(Bech32Transcription.normalize(key), args) ?: return Output.error( "bad_key", "could not parse '$key' as any supported identifier", diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt index 101f1305ce..d704d2a940 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt @@ -43,6 +43,9 @@ class Nip49 { */ const val MIN_PASSWORD_LENGTH = 12 + /** scrypt cost used when creating: 2^16 rounds, 64 MiB. Safe on low-end phones. */ + const val DEFAULT_LOG_N = 16 + /** * Length as scrypt sees it: code points of the NFKC-normalized password, so * an emoji (a UTF-16 surrogate pair) counts once and compatibility forms @@ -64,10 +67,13 @@ class Nip49 { ): String { check(encryptedInfo != null) { "Couldn't decode key" } check(encryptedInfo.version == EncryptedInfo.V) { "invalid version" } + // 32-byte key + 16-byte tag. A shorter payload can still carry a valid tag and + // would otherwise come back as a zero-padded "key". + check(encryptedInfo.encryptedKey.size == 48) { "invalid encrypted key length" } val normalizedPassword = UnicodeNormalizer().normalizeNFKC(password).encodeToByteArray() val n = 2.0.pow(encryptedInfo.logn.toDouble()).toInt() - val key = SCrypt.scrypt(normalizedPassword, encryptedInfo.salt, n, 8, 1, 32) + val key = deriveKey(normalizedPassword, encryptedInfo.salt, n, encryptedInfo.logn.toInt()) val m = ByteArray(32) try { @@ -97,7 +103,7 @@ class Nip49 { fun encrypt( secretKeyHex: String, password: String, - logn: Int = 16, + logn: Int = DEFAULT_LOG_N, ksb: Byte = EncryptedInfo.CLIENT_DOES_NOT_TRACK, ): String = encrypt(secretKeyHex.hexToByteArray(), password, logn, ksb) @@ -113,7 +119,7 @@ class Nip49 { val normalizedPassword = UnicodeNormalizer().normalizeNFKC(password).encodeToByteArray() val n = 2.0.pow(logn.toDouble()).toInt() - val key = SCrypt.scrypt(normalizedPassword, salt, n, 8, 1, 32) + val key = deriveKey(normalizedPassword, salt, n, logn) val ciphertext = ByteArray(48) try { @@ -149,6 +155,24 @@ class Nip49 { ).encodePayload() } + /** + * scrypt allocates 128 * r * N bytes up front: 1 GiB at LOG_N 20, which NIP-49 allows and + * other clients may choose. Past the heap that is an OutOfMemoryError, which callers' + * `catch (e: Exception)` would miss, crashing instead of reporting the key as unusable. + */ + private fun deriveKey( + normalizedPassword: ByteArray, + salt: ByteArray, + n: Int, + logn: Int, + ): ByteArray = + try { + SCrypt.scrypt(normalizedPassword, salt, n, 8, 1, 32) + } catch (e: Error) { + normalizedPassword.fill(0) + throw IllegalStateException("Not enough memory for this key's scrypt cost (LOG_N $logn)", e) + } + class EncryptedInfo( val version: Byte, val logn: Byte, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt index 2f5b0225c0..9fa3806a60 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/LibSodiumInstance.kt @@ -39,6 +39,8 @@ object LibSodiumInstance { try { val plaintext = XChaCha20Poly1305.decrypt(ciphertext, ad, nPub, k) plaintext.copyInto(message) + // The caller owns the only copy it should have; don't leave another on the heap. + plaintext.fill(0) true } catch (_: Exception) { false diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt index e671727181..275c84138d 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip49PrivKeyEnc import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import com.vitorpamplona.quartz.nip44Encryption.crypto.XChaCha20Poly1305 import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -117,4 +118,18 @@ class Nip49SpecTest { // The spec's normalization vector: 4 code points typed, 3 after NFKC. assertEquals(3, Nip49.passwordLength("\u212B\u2126\u1E9B\u0323")) } + + @Test + fun authenticatedButShortCiphertextIsRejected() { + // A tag-valid payload that encrypts only 16 bytes must not decode as a zero-padded key. + val password = "nostr" + val salt = ByteArray(16) { it.toByte() } + val nonce = ByteArray(24) { (it + 1).toByte() } + val ksb = Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK + val key = SCrypt.scrypt(password.encodeToByteArray(), salt, 2, 8, 1, 32) + val shortCiphertext = XChaCha20Poly1305.encrypt(ByteArray(16) { 7 }, byteArrayOf(ksb), nonce, key) + + val info = Nip49.EncryptedInfo(Nip49.EncryptedInfo.V, 1, salt, nonce, ksb, shortCiphertext) + assertFailsWith { nip49.decrypt(info, password) } + } } From bce81773101895094300f79ff06ee379c391abbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:00:08 +0000 Subject: [PATCH 06/32] test(nip49): pin ncryptsecs from nostr-tools, rust-nostr and go-nostr Cross-implementation check of NIP-49. The same 14 cases were encrypted with Amethyst, nostr-tools 2.25.2, rust-nostr 0.45.5 and go-nostr 0.52.3, and every library decrypted every other one's output. All pairs succeed, apart from rust-nostr's deliberate LOG_N > 18 cap. Cases: key-security bytes 0/1/2, NFKC (typed vs normalized form), emoji, significant spaces, empty password, a key with all bytes >= 0x80, LOG_N 8/16/18/20, and uppercase ncryptsec input. Nip49InteropTest pins 27 of the foreign ciphertexts (nine cases per library, LOG_N <= 16 to keep it fast). They must keep decrypting to the original key with matching version, LOG_N and key-security header bytes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP --- .../nip49PrivKeyEnc/Nip49InteropTest.kt | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49InteropTest.kt diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49InteropTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49InteropTest.kt new file mode 100644 index 0000000000..70ea503d27 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49InteropTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip49PrivKeyEnc + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * ncryptsecs produced by other NIP-49 implementations, which Amethyst must keep opening. + * + * Generated once by encrypting the same cases with each library (encryption is randomized, + * so only decryption can be pinned). Cases cover the three key-security bytes, NFKC (the + * password is given in its un-normalized typed form), astral-plane emoji, significant + * spaces, an empty password, a key whose bytes are all >= 0x80, and a non-default LOG_N. + * The reverse direction (those libraries opening Amethyst's output) was checked at the + * same time against the same versions. + */ +class Nip49InteropTest { + private class Vector( + val producer: String, + val case: String, + val ncryptsec: String, + val password: String, + val secretKeyHex: String, + val logn: Int, + val ksb: Int, + ) + + private val vectors = + listOf( + Vector("nostr-tools 2.25.2", "spec-key-ascii", "ncryptsec1qggzef0f9c0e4jahr32p98weq679sydr6ea4chnz0kzys4egytrt89dc8e3ml28z8jfq9daj6hn8rkd3kltru9mxzvpc4v2ppgmyp4dew7ugcp98hfayqyehss36xdl95eulzrjhv9z8mk745yvkq9la", "nostr", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("nostr-tools 2.25.2", "ksb0", "ncryptsec1qgg2mwlypqz2an0qvkx5wj5kw2xnaunl6648utjxs4acpud2ewpgp4wcv3xwvhf2vgmqqaudk4zl9rcdtryg97j8z7jg6ex0q3tdsl0e2zh4rsedj43jr0th48z4cv5vgausj6fxhsjjzfs8lvkfdu4v", "correct horse battery", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 0), + Vector("nostr-tools 2.25.2", "ksb1", "ncryptsec1qggxwrt3vtsyy6fn8mha8danwhdwe4dx39r4l3r0fgh393kr6f7a2yt230vsmkuug0lsrjedklp7r4eltcfckdp37x5afc8uk5fzaxkcnvx5q3cyt69n4fgfdddxuejjmlkzerggxlgjqpesn5vk2djk", "correct horse battery", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 1), + Vector("nostr-tools 2.25.2", "nfkc-typed-form", "ncryptsec1qgggze6805h7slevp5evyekxhh42fl2qk8hlla2uej8934l0f65v7xfhd2njmtdnm2ws9grf6tuzeugj59hctn3rg8dp03s8e62vycvynmf9ymcxp9sczvhx7mntl8wm8gujg00vx9rjvxxt4s8nq893", "\u212B\u2126\u1E9B\u0323", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("nostr-tools 2.25.2", "emoji", "ncryptsec1qggpnka7pcakst6nszv492xqk53q07ctdjztklevawatwfwcapyw2qvfmm6xuz3kl4sqyefsw8c5g96zpj6p7dhq8kcjkynm2fz7nwy5q7mtx7vf5dwh9mmhj82snl2tnlpcz6rzdqhl7aan7ygz08f8", "\uD83D\uDD11\uD83D\uDD12 key lock \uD83E\uDDE1", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("nostr-tools 2.25.2", "spaces", "ncryptsec1qgg0j53e68fs5ty9d6kvup5qwpglml0knavrkrzhu88345kkrgrfju9hpwksnre3uveqys4elvdjtjz3770l2rdm6a7rfepmumgnp9kzl9wjp9sy575m02xm9rgc86npyj8jgspcq97s9lgd7gtz5lg2", " leading and trailing ", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("nostr-tools 2.25.2", "empty-password", "ncryptsec1qgg0mnjj0x8um28yrku7k0pxhcwg70l8286lqng949qw2xgw3auejrs8g2whr9qavgus9w7etxlxwuxtw9d4t9uzknq558c6d83vxz48ka25xn064df9cxgv7ty820yrej8yll9d9wd0lyfpn5yuny8r", "", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("nostr-tools 2.25.2", "all-high-bytes", "ncryptsec1qgg97h3hl9jwjwneecfa8nfycyh8vh9h4v2td75qxpq2y2xyw2epa9xzjgerxwyl73asymlcmusgx9nygg26kxgtrhepn77aqqej6ufgly4hlzxwl9jdxkt24mpl85lgfus0xcge78pg7ytljva58eyu", "high bytes key!", "8080808080808080808080808080808080808080808080808080808080808080", 16, 2), + Vector("nostr-tools 2.25.2", "logn8", "ncryptsec1qgydpemrwe496lqjlu9hsd8x39yyrv7w0tpt0s5wh2e0amzq9vwshrqs3enjew7hlwnsym8v0jtrzg7r4l7ku8lx9yw9gcy66std9aluchvy8mxpatqkdqlexmdap6hgv2p2l936zlwks3529q8p57vk", "cheap scrypt pw", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 8, 2), + Vector("rust-nostr 0.45.5", "spec-key-ascii", "ncryptsec1qggv857u8hqfyknh9vntpa7vpuw2mlw9fwu46qkfzfd4w07rz3nfyyptcxac2a3qcuxqy3v80kxxkzryjdryu5dl6ygwvq9tvgaft5aap27s6arz7wdjhw23h2jdsknvfndjmtarcu0w4nc4gc39zeu4", "nostr", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("rust-nostr 0.45.5", "ksb0", "ncryptsec1qggw5euqnv6jwzzu4kaqp4rgda8q87ydpyccvujvrvgn4z2mderjhkr6fvyvv85ve0jspp90gpdh9nlqfjj9clvse4xjyd0aq28uwu4t594yxwlx00k7n6dh50l6ag7xksnwy6dulskrgl4rv52pfcv3", "correct horse battery", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 0), + Vector("rust-nostr 0.45.5", "ksb1", "ncryptsec1qgg2jalwa5khtqhydjdpv7qku64knkcpppr9calc90g3vvryerqgtpvjef8vqjxh934qr68zxp5fdrn3478mhna7n6rk85hjsnnn6zkwqjxl5gm3fnrjv58m85n9x4frdu8u7mdl3elkggcg0s0ajrar", "correct horse battery", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 1), + Vector("rust-nostr 0.45.5", "nfkc-typed-form", "ncryptsec1qgg23tn24g595ph4rjz3qw6zaha9p8zh6rwauzfknp3wqywqvlsa9lf4yc54yl9gq6rs9yzl3fvpc63dkahjqqmye7wken7jcjkws4h506ul0qpjh8c5skcyhc08tem7n9hr07484ypmpgaf6sgd470q", "\u212B\u2126\u1E9B\u0323", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("rust-nostr 0.45.5", "emoji", "ncryptsec1qgg2x6rkclt0vwulrq83cclekghuytcyp8ju5lfuka9jmt0qh8u4mqgc6nqllw86lz2syqs5mt39fyk4tj9f00dn8hx9g2ckjurgnpajwy9as9lgta25x7jxxnpjh62j4hlr2a06j9qnzkukzq0jph3s", "\uD83D\uDD11\uD83D\uDD12 key lock \uD83E\uDDE1", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("rust-nostr 0.45.5", "spaces", "ncryptsec1qggzz6tnfts4xrepjkeuy5shg7z5y62levqknlf94tqry8zwefag3uux0qzlyfqh35ms9vzavurvh9adv36v8lhzllw4yf9zdd3nqajlcqje3k8s73jlyzcufpgwsgqu8pcq5la9mr7pg3zp2yegzzk2", " leading and trailing ", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("rust-nostr 0.45.5", "empty-password", "ncryptsec1qggyfqwg66hfdylpgw8drhuwg4yu3940zjn9u39tvaape267qs072a0qmpf48pcm8cgqyq4v0e4ygzuyf70e5req74htar44za03klj9em447gpfnshha6ez3rxh4eah3w2tj6vntqhewcjfasdwaa2k", "", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("rust-nostr 0.45.5", "all-high-bytes", "ncryptsec1qggdxzuj6y3ha9s240p2a4e9yhpatjzq4tjfc764sqc4kvcqhfn9nljhwdtdpspdx6cs95adxsc6gy86j6xkle2cp6fffr2zsv5k4ua0e266l9l2wgl5x0xavwqg8urtx6xe7z4cftdna49axgws6j6a", "high bytes key!", "8080808080808080808080808080808080808080808080808080808080808080", 16, 2), + Vector("rust-nostr 0.45.5", "logn8", "ncryptsec1qgywmddn2ymrmjkn3df9vcr87g2jzm63u0apcf28vu5u64lydlg438ztpvu9svjuqensysr7088xpp8ufu7kmzgtnal4w5gcrmfwsegst9aj8trhjllx74pew92wts8sh4lvpmu5552dfhg4zgnu8klt", "cheap scrypt pw", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 8, 2), + Vector("go-nostr 0.52.3", "spec-key-ascii", "ncryptsec1qggtku4x8v2x5wys9kaf885kslc9g69u7squzxf9v7kz733znjymywgcl7j06p5agvzsya685ztkgnl87gtulwa8htl7w8dp9j45qf77dq3lqak46nchd62cm7lfwt5p4326me9gfvedlv3qgg0xn6h5", "nostr", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("go-nostr 0.52.3", "ksb0", "ncryptsec1qggrt6nkhufyfk39fh2ectkm8yc4rkpxdzq20nawkn0nef9lcx2f7qu25njy6zw8jscqqd7t9p4yhn2mhflgsaltqm6hecngcfv0ezhftcy586xd4n2xpgu0e3sxc5ht4phe2pkrpz4u9n0u4vgggrjj", "correct horse battery", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 0), + Vector("go-nostr 0.52.3", "ksb1", "ncryptsec1qggt49pvsll6rd2efefsv5m6klzwr3adr6t9nuta26yjtkzmapr3falp9w7yqa3u5ktqrtj63skj44r3tfud48t739es23a38vdnqwu9dqnkv4gxj0p4guqx2l38m3tveknycj2wqjmc68lpect44efl", "correct horse battery", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 1), + Vector("go-nostr 0.52.3", "nfkc-typed-form", "ncryptsec1qggp6pz4dk3jsv2k79razl0j0zd35hry3qkd74a4e2qstnsc7h3etcdhwrt2av0a7v5q97vzw7pmnawpxeadt4lxcqj9s25hfheqfudakdkuk8kmczkuq60z8xxz8emm34yu4mkgt6t7jpve0vasa0pm", "\u212B\u2126\u1E9B\u0323", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("go-nostr 0.52.3", "emoji", "ncryptsec1qggx7yhdu7smxvxmszep4wzunthhdxpwnp85yjrkx44gq7u63ylvppp4nez95xlkjafq9270j8g5lsj2tlxf3lv5ecemgcrlu2789zse0a2ywjyf7twxzatz5m3ckt3cj0xth5le35msu7y0wywnuhd4", "\uD83D\uDD11\uD83D\uDD12 key lock \uD83E\uDDE1", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("go-nostr 0.52.3", "spaces", "ncryptsec1qgggqt59jswdm0ph58z45sqynzdpjak894ndu9ueykm9qdnccptnkwz6rsvfmfkhulwsy9atea9jqdqpenfhpqsmgckjw9vr64p4vqznfsua8ty0f9j0y6enk24xfulqy3n04wvj774ct6sg3yfghqdx", " leading and trailing ", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("go-nostr 0.52.3", "empty-password", "ncryptsec1qgg2tmxquvf9924env8er4dxlctjglqqrtksjhnnwu0qvapzn6trs36lqmsswnwvy29s9sdrap2m8m6656hytvr99k9364eqqsy9xe4jyd6ntetvvxnvcxl6ktlpt4dw9yjxe5w9mqhl2ywj8guslyp4", "", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 16, 2), + Vector("go-nostr 0.52.3", "all-high-bytes", "ncryptsec1qggyhcn43gycdv5hftawfywfje8w3hzj5pr4g8v70q9x49vgutfeda8lx0syku8lcras90vzt9hzzdl2t60wnytrkcfhya5t6gkrajkjkdq25t3thlcxmhhklkte7nn6ktfqsys8ynmscwy6cutzpfj2", "high bytes key!", "8080808080808080808080808080808080808080808080808080808080808080", 16, 2), + Vector("go-nostr 0.52.3", "logn8", "ncryptsec1qgyqqtmjm9k402reqysneeg8fj700l6sd03vmjwlt6g2waxrg4de2hs5q2jrtktt72uqynxqa76hsz2kfm7834pra0rwjlcgwy920r7uhg797zw63d6a0aee64wqxugxxflzynrhzvt2c2zk2s9u7cxl", "cheap scrypt pw", "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683", 8, 2), + ) + + @Test + fun decryptsEveryForeignVector() { + val nip49 = Nip49() + vectors.forEach { + val label = "${it.producer} / ${it.case}" + val info = Nip49.EncryptedInfo.decodePayload(it.ncryptsec)!! + assertEquals(2, info.version.toInt(), label) + assertEquals(it.logn, info.logn.toInt(), label) + assertEquals(it.ksb, info.keySecurity.toInt(), label) + assertEquals(it.secretKeyHex, nip49.decrypt(it.ncryptsec, it.password), label) + } + } +} From 37e2a2c62fe04a56c9ed1f156f6006a71b4810c8 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 18 Jun 2026 11:45:17 +0200 Subject: [PATCH 07/32] fix(cast): request ACCESS_LOCAL_NETWORK for Android 17 device discovery Android 17 (API 37) introduced Local Network Protection, which gates the mDNS/multicast discovery the Cast SDK relies on behind the new dangerous ACCESS_LOCAL_NETWORK runtime permission. Declare ACCESS_LOCAL_NETWORK in the Play manifest (cast is Play-only; F-Droid ships no casting) and request it at runtime when the user opens the Cast picker, guarded by SDK_INT >= 37 so older OSes are unaffected. --- .../composable/controls/RenderTopButtons.kt | 10 +- .../amethyst/ui/cast/CastPermissions.kt | 122 ++++++++++++++++++ amethyst/src/play/AndroidManifest.xml | 7 + .../composeResources/values/strings.xml | 3 + 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastPermissions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 79b381c4be..db06db131e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -80,6 +80,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaIte import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog +import com.vitorpamplona.amethyst.ui.cast.rememberCastWithLocalNetworkPermission import com.vitorpamplona.amethyst.ui.components.ShareMediaAction import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -313,6 +314,7 @@ fun RenderTopButtons( val captionsContentDescription = stringRes(if (captionsEnabled) Res.string.captions_turn_off else Res.string.captions_turn_on) val shareDialogVisible = remember { mutableStateOf(false) } + val context = LocalContext.current val castDialogVisible = remember { mutableStateOf(false) } val castSessionState by Amethyst.instance.castRegistry.sessionState .collectAsStateWithLifecycle() @@ -321,15 +323,19 @@ fun RenderTopButtons( val castIcon = if (isThisVideoCasting) MaterialSymbols.CastConnected else MaterialSymbols.Cast val castContentDescription = stringRes(if (isThisVideoCasting) Res.string.cast_stop_casting else Res.string.cast_to_device) + // Android 17 Local Network Protection blocks Cast device discovery until the + // user grants ACCESS_LOCAL_NETWORK, so gate the picker behind the request. + val showCastPicker = remember { { castDialogVisible.value = true } } + val openCastPicker = rememberCastWithLocalNetworkPermission(context, showCastPicker) val onCastButtonClick = - remember(isThisVideoCasting) { + remember(isThisVideoCasting, openCastPicker) { { if (isThisVideoCasting) { Amethyst.instance.applicationIOScope.launch { Amethyst.instance.castRegistry.stopCasting() } } else { - castDialogVisible.value = true + openCastPicker() } Unit } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastPermissions.kt new file mode 100644 index 0000000000..a2903564d2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastPermissions.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.cast + +import android.Manifest +import android.content.Context +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.cast_local_network_permission_message +import com.vitorpamplona.amethyst.commons.resources.cast_local_network_permission_open_settings +import com.vitorpamplona.amethyst.commons.resources.cast_local_network_permission_title +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.ui.call.hasPermission +import com.vitorpamplona.amethyst.ui.call.openAppSettings + +// Android 17 (API 37) Local Network Protection gates the mDNS/multicast device +// discovery the Cast SDK relies on behind the dangerous ACCESS_LOCAL_NETWORK +// runtime permission. Without it the picker silently finds zero receivers. +// Older OSes have no LNP and never define the permission, so the gate is a +// no-op there. +private const val LOCAL_NETWORK_PROTECTION_SDK = 37 + +private fun needsLocalNetworkPermission(): Boolean = Build.VERSION.SDK_INT >= LOCAL_NETWORK_PROTECTION_SDK + +fun hasLocalNetworkPermission(context: Context): Boolean = !needsLocalNetworkPermission() || hasPermission(context, Manifest.permission.ACCESS_LOCAL_NETWORK) + +/** + * Returns a click handler that ensures [Manifest.permission.ACCESS_LOCAL_NETWORK] + * is granted before running [onGranted] (which opens the Cast device picker). + * + * On Android 17+ the permission is requested on first tap; if the user denies + * it, a dialog deep-links to app settings. On older OSes the permission does + * not exist, so [onGranted] runs immediately. + */ +@Composable +fun rememberCastWithLocalNetworkPermission( + context: Context, + onGranted: () -> Unit, +): () -> Unit { + var showDeniedDialog by remember { mutableStateOf(false) } + + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + // A silent deny (permanently-denied, where Android skips the dialog) + // also lands here with granted=false, so surface the deep-link + // dialog rather than failing silently with an empty picker. + if (granted) onGranted() else showDeniedDialog = true + } + + if (showDeniedDialog) { + LocalNetworkPermissionDeniedDialog( + onDismiss = { showDeniedDialog = false }, + onOpenSettings = { + showDeniedDialog = false + openAppSettings(context) + }, + ) + } + + return remember(onGranted) { + { + if (hasLocalNetworkPermission(context)) { + onGranted() + } else { + launcher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } + } + } +} + +@Composable +private fun LocalNetworkPermissionDeniedDialog( + onDismiss: () -> Unit, + onOpenSettings: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.cast_local_network_permission_title)) }, + text = { Text(stringRes(Res.string.cast_local_network_permission_message)) }, + confirmButton = { + TextButton(onClick = onOpenSettings) { + Text(stringRes(Res.string.cast_local_network_permission_open_settings)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(Res.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/play/AndroidManifest.xml b/amethyst/src/play/AndroidManifest.xml index 60c2dfb7a1..1ead319210 100644 --- a/amethyst/src/play/AndroidManifest.xml +++ b/amethyst/src/play/AndroidManifest.xml @@ -2,6 +2,13 @@ + + + diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 3782cc7eb2..3ef72ba264 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2694,6 +2694,9 @@ Only visible to you Cast to… Searching for devices on your Wi-Fi… + Permission needed + Amethyst needs local network access to find Cast devices on your Wi-Fi. Please enable it in the app settings. + Open settings Pick a video Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. Change From bc8dcd4da9ef9c4d684ef80b9c5a492afa3ac570 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 11:28:06 -0400 Subject: [PATCH 08/32] fix(backup): keep the password card above the keyboard and clear used passwords - The scroll column had no IME padding, so on a tablet the keyboard covered the Repeat field, the Encrypt button and the result: taps on Repeat landed on the keyboard. It now pads by the IME inset. - Done closes the keyboard so the ncryptsec and its buttons are visible. It hides it directly: clearFocus() hands focus to the embed tab's RemoteImeView, which keeps the keyboard up. - The passwords are cleared once encryption succeeds, so dropping the result on background no longer leaves the used password in the fields. A password not yet used is still kept across a password-manager trip. Co-Authored-By: Claude Opus 5.5 --- .../loggedIn/keyBackup/AccountBackupScreen.kt | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index a318279157..5468d27777 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -76,6 +77,7 @@ import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily @@ -128,6 +130,7 @@ import com.vitorpamplona.amethyst.commons.resources.show_password import com.vitorpamplona.amethyst.commons.ui.components.KeyTranscriptionGrid import com.vitorpamplona.amethyst.commons.ui.components.util.getText import com.vitorpamplona.amethyst.commons.ui.components.util.setText +import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav @@ -201,6 +204,9 @@ private fun AccountBackupScreenContent( Modifier .fillMaxSize() .padding(it) + .consumeWindowInsets(it) + // Keeps the password fields and the Encrypt button above the keyboard. + .imePaddingSafe() .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -385,6 +391,7 @@ private fun EncryptedKeyCard( val context = LocalContext.current val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() + val keyboardController = LocalSoftwareKeyboardController.current var expanded by remember { mutableStateOf(false) } var password by remember { mutableStateOf("") } @@ -394,8 +401,8 @@ private fun EncryptedKeyCard( var encrypted by remember { mutableStateOf(null) } var showQr by remember { mutableStateOf(false) } - // Drop the result while in the background. The typed password is kept so switching to a - // password manager to fetch it doesn't wipe the fields. + // Drop the result while in the background. A password not yet used is kept so switching to + // a password manager to fetch it doesn't wipe the fields; a used one is cleared on success. LifecycleEventEffect(Lifecycle.Event.ON_STOP) { encrypted = null showQr = false @@ -423,6 +430,9 @@ private fun EncryptedKeyCard( working = false if (result != null) { encrypted = result + // The password has done its job: don't leave it in the fields behind the result. + password = "" + repeated = "" } else { Toast.makeText(context, loadStringRes(Res.string.failed_to_encrypt_key), Toast.LENGTH_SHORT).show() } @@ -533,7 +543,16 @@ private fun EncryptedKeyCard( keyboardType = KeyboardType.Password, imeAction = ImeAction.Done, ), - keyboardActions = KeyboardActions(onDone = { encrypt() }), + keyboardActions = + KeyboardActions( + onDone = { + // Close the keyboard so the result and its buttons are visible. Not via + // clearFocus(): that hands focus to the embed tab's RemoteImeView, which + // keeps the keyboard up. + keyboardController?.hide() + encrypt() + }, + ), visualTransformation = visualTransformation, ) From 0cb384a9ed17daf514f9ad11b522100158d5e6e5 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:31:51 +0000 Subject: [PATCH 09/32] chore: sync Crowdin translations and seed translator npub placeholders --- .../values-hi-rIN/strings.xml | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index 2bd0dcf453..e120171ccc 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -5300,10 +5300,28 @@ समायोजक इस यन्त्र समायोजक + सेवासंगणक जो आपके समूहों को रखते हैं कुंचिका पोटलियाँ + जिससे लोग आपको एक समूह से जोड सकते हैं सुरक्षित अनुकृति तथा पुनःस्थापन पारणवाक्याम्श द्वारा रहस्यीकृत एक अभिलेख जो आप रखते हैं अन्य संचारयन्त्र तक विस्थापन + अन्य संचारयन्त्र तक विस्थापन + हस्तान्तरण आरम्भ करें + प्रकाशनमध्य… + नए संचारयन्त्र में इसे परखें + इस यन्त्र ने अपने समूहों का हस्तान्तरण कर दिया + इस यन्त्र को आप पुनः ले जाएँ + अन्य संचारयन्त्र से प्राप्त करें + अक्षरराशि परखें + अथवा चिपकाएँ + समूहों को यहाँ ले आएँ + लाया जा रहा है… + + %1$d समूह स्थानान्तरित + %1$d समूह स्थानान्तरित + + इस यन्त्र से क्या जाता है त्रिमा वस्तु %1$d अस्र। %2$d मुख इस त्रिमा वस्तु का पठन असफल। नियम %1$s @@ -5347,6 +5365,65 @@ %1$d अष्टकों का अमुक जिसे यह ग्राहक पढता नहीं + आवृत + योजक अनुकृति + ढूंढें + लेख आकार + लेखनपीठ संगणक + मुख्यपृष्ठ पर जोडें + अन्य जालवीक्षक + पूर्ण पटल + पता सम्पादन + पीछे + आगे + पुनःआवहन + आवहन रोकें + इष्टसूची में जोडें + इष्टसूची में से हटाएँ + बाँटें + योजक अनुकृति + पृष्ठ में ढूँढें + लेख आकार + मुख्यपृष्ठ पर जोडें + लेखनपीठ संगणक जालस्थान + जालवीक्षक मे खोलें + %1$s में खोलें + पूर्ण पटल + आपने %1$s छोडा + क्रमक तक लौट जाएँ + अल्पतर आकार लेख + बृहत्तर आकार लेख + पुनःस्थापन + %1$d प्रतिशत + गोपनीयता + बहुरहस्यावृत संचार द्वारा + यह जालस्थान आपका अंकीय जालपता नहीं देख सकता + यह जालस्थान आपका अंकीय जालपता देख सकता है + जालस्थान स्थापना विकल्प + कुछ भी अनुमत नहीं अब तक + इसके लिए क्या अभिगम्य है + आपके कुंचिकाएँ कभी भी अमेथिस्ट से बाहर नहीं जाते + कोई विशेष अभिगमन अनुमति नहीं + अनुमतियाँ प्रबन्धन + नोस्टरस्थान। पृथककृत पर्यावरण + नोस्टरसंलग्नक्रमक। पृथककृत पर्यावरण + कोष्ठक + सुरक्षित संयोजन + असुरक्षित + बहुरहस्यावृत संचार द्वारा + पर्यावरण पृथककृत क्रमक + चित्रग्राहक + ध्वनिग्राहक + स्थान + पूछें + अनुमत + बाधित + %1$s %2$s + अनुमत + बाधित + ढूँढें अथवा पता प्रविष्ट करें + जाएँ + रिक्त पुनःबाधित करें %1$s को रखें %1$s पुनःजुडें %1$s From 9e4f33a31baf146203799b66818dec93c62a117b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 11:54:59 -0400 Subject: [PATCH 10/32] fix(embed): stop RemoteImeView taking focus from the rest of the app RemoteImeView, the hidden EditText that hosts the keyboard for embedded tabs, lives in the main window whether or not a browser tab is open, and was always focusable in touch mode. So it was the fallback focus target for the whole window: whenever a Compose text field lost focus (clearFocus(), or its screen being popped), Android gave focus to it, it became the IME target, and the keyboard stayed up over a screen with no field. It also reopened on every resume. Reproduced on main (SM-T220, Android 14): focus the Settings search, tap back. The Home feed keeps the keyboard up with RemoteImeView as the served view, and it is still up after Home -> resume. Happens without ever opening a browser tab. It is now focusable only while it mirrors a page field: onPageFocus turns focus on, onPageBlur turns it off (which also clears its focus). Both flags are set so a hardware keyboard or D-pad can't focus it either. Device-verified on the fixed build: the repro leaves the keyboard down (served view goes back to AndroidComposeView) across two runs; in an embedded tab, typing, dismiss + re-tap, a Home round trip mid-typing (keyboard and caret restored), and a page-side blur() all still work. Co-Authored-By: Claude Opus 5.5 --- .../ui/screen/loggedIn/embed/RemoteImeView.kt | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt index 047fedb698..aed8de884d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/RemoteImeView.kt @@ -90,9 +90,10 @@ class RemoteImeView( private val flush = Runnable { flushState() } init { - // Invisible but focusable: the IME needs a laid-out, visible target, but the user must never see - // this field or its cursor/selection handles — only the embedded page. - isFocusableInTouchMode = true + // Invisible: the IME needs a laid-out, visible target, but the user must never see this field or its + // cursor/selection handles — only the embedded page. Focusable only while it mirrors a page field + // (see [setFocusTarget]). + setFocusTarget(false) alpha = 0f background = null setTextColor(0x00000000) @@ -186,6 +187,7 @@ class RemoteImeView( ) { configureFor(focus) mirroring = true + setFocusTarget(true) fieldReadOnly = focus.readOnly // Focus the EditText BEFORE seeding text/selection. An EditText jumps its caret to the end when it // gains focus; if we seed first, that end-position then overrides the seed and gets shipped to the @@ -313,12 +315,28 @@ class RemoteImeView( // whatever arrives to the field focused THEN, not the one it was computed from. Nothing this mirror // holds belongs to the page once the field has blurred, so suppress the echo and drop the queue. applyingRemote = true - clearFocus() + // Not focusable again until the next page focus. Dropping the flag clears our focus as well, so no + // separate clearFocus() is needed (and one alone could hand focus straight back to us). + setFocusTarget(false) applyingRemote = false removeCallbacks(flush) imm.hideSoftInputFromWindow(windowToken, 0) } + /** + * Whether this view can take focus. It lives in the main window whether or not a browser tab is open, so + * while it is focusable it is the fallback focus target for the whole window: when a Compose text field + * elsewhere loses focus (clearFocus(), or its screen being popped), Android hands focus to this EditText. + * It then becomes the IME's target, so the keyboard stays up over a screen with no field, and it comes + * back every time the app resumes. So it is focusable only while it actually mirrors a page field. + * Both flags are set: `isFocusable` alone would still let it take focus outside touch mode (a hardware + * keyboard or D-pad). + */ + private fun setFocusTarget(enabled: Boolean) { + isFocusable = enabled + isFocusableInTouchMode = enabled + } + private fun applyRemote( newText: String, selStart: Int, From 15588d2267f5659841879e1a643b64cd61732c75 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:06:22 +0000 Subject: [PATCH 11/32] chore: sync Crowdin translations and seed translator npub placeholders --- .../values-hi-rIN/strings.xml | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index 1e15f8dc77..ce6505f9b1 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -5417,6 +5417,42 @@ ढूँढें अथवा पता प्रविष्ट करें जाएँ रिक्त + चिपकाएँ तथा जाएँ + इस पता का उपयोग करें + पृष्ठ में ढूँढें + %2$d में से %1$d + कोई परिणाम नहीं + पूर्व परिणाम + अगला परिणाम + पृष्ठ खोज आवृत करें + सभी + अपक्रम + चेतावनियाँ + अनुकृति + रिक्त + कुछ भी अभिलेखित नहीं अब तक + यह जालस्थान इसका उपयोग करना चाहता है + देखें आपका चित्रग्राहक क्या देख रहा है + सुनें आपका ध्वनिग्राहक क्या सुन रहा है + जानें आप कहाँ हैं स्थूलतः + अभ्यागमन कालान्तर अनुमत + केवल इस बार + अनुमति ना दें + %1$s कहता है + यह पृष्ठ कहता है + आपका उत्तर + क्या जालस्थान छोड दें। + निर्गमन + ठीक + निरस्त + संयोजन + संयोजन रहस्यीकृत + संयोजन अरहस्यीकृत + टोर पर बहुरहस्यावृत संचार द्वारा + खुला जाल + प्रमाणपत्र + निर्गत %1$s को %2$s द्वारा। मान्य %3$s तक + अनुमतियाँ पुनःबाधित करें %1$s को रखें %1$s पुनःजुडें %1$s @@ -5741,4 +5777,22 @@ सेवाएँ जिनपर आप विश्वास करते है आपके लिए लोगों का सत्यापन तथा श्रेणीकरण करने के लिए। विश्वसनीय पुनःप्रसारक सूची पुनःप्रसारक जिनपर आप पर्याप्त विश्वास करते हैं बिना पूछे उनसे संयोजन करने के लिए। + %1$s इस समुह में नहीं है। + वह व्यक्ति + अभिलेख में क्या है + निर्यात + अनुकृति सुरक्षित। + वह कार्य असफल था। पारणवाक्याम्श तथा अभिलेख की जाँच करें। + पारणवाक्याम्श + पुनःस्थापन + क्या इस यन्त्र के कोर्डन समूहों का प्रतिस्थापन करें। + पुनःस्थापन + पुनःस्थापित। + कोई सन्देश नहीं अब तक + नए सन्देश + एक दिन पूर्व + सन्देश + जोडें + उस समायोजक को खोलने में असफल। + समायोजक के एनपुब॰ की अनुकृति From 5ab25b89d20c905c264bf3bbf66d24383954ac39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:16:24 +0000 Subject: [PATCH 12/32] feat: serve NIP-FE from geode and add the client side to quartz geode (KtorRelay): - POST /req, /count, /event on the engine through quartz's HttpRelayHandler, answered as application/x-ndjson and streamed chunk by chunk; one refusal line with its status otherwise. - WWW-Authenticate: Nostr on 401, Retry-After on 429/503, CORS preflight (any origin, Authorization + Content-Type), X-Accel-Buffering: no. - Per-client and global concurrency caps (429 / 503), counted per peer address or, behind a listed trusted proxy, per the address it forwards. - 413 for bodies over [http].max_body_bytes; the NIP-86 route shares the bounded body reader. - --auth / --optional-auth now accept a NIP-98-proved key: stock FullAuthPolicy refuses transport sign-in, which left every signed HTTP request on an AUTH-gated geode at 401. - [http] config section (enabled, caps, deadline, body cap, alternate_urls for a .onion, trusted_proxies); FE in the default NIP-11 list, dropped when disabled; supported_nips accepts "FE". quartz: - HttpRelayAnswerReader: turns NIP-FE lines back into frames and tells a complete answer from a cut-off one, as the NIP requires of clients. - HttpRelayClient (jvmAndroid, OkHttp): req/count/publish, reads the answer incrementally, signs a NIP-98 token and retries on 401. - HttpRelayCommand.url()/body() and HttpRelayHandler.refusal() for hosts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RbNrTdV2e7kW5S9tkMoPgh --- geode/README.md | 29 +- geode/config.example.toml | 32 +- .../com/vitorpamplona/geode/KtorRelay.kt | 33 ++ .../kotlin/com/vitorpamplona/geode/Main.kt | 7 +- .../com/vitorpamplona/geode/RelayInfo.kt | 3 +- .../com/vitorpamplona/geode/SignInPolicies.kt | 43 +++ .../geode/config/StaticConfig.kt | 76 ++++- .../vitorpamplona/geode/server/BoundedBody.kt | 48 +++ .../geode/server/HttpAdmission.kt | 89 +++++ .../geode/server/HttpCommandSettings.kt | 53 +++ .../geode/server/Nip86HttpRoute.kt | 46 +-- .../geode/server/NipFEHttpRoute.kt | 168 ++++++++++ .../com/vitorpamplona/geode/NipFEHttpTest.kt | 307 ++++++++++++++++++ .../geode/config/StaticConfigTest.kt | 53 ++- .../geode/server/HttpAdmissionTest.kt | 71 ++++ .../nipFERelayOverHttp/HttpRelayAnswer.kt | 100 ++++++ .../nipFERelayOverHttp/HttpRelayCommand.kt | 9 + .../nipFERelayOverHttp/HttpRelayHandler.kt | 9 +- .../HttpRelayAnswerReaderTest.kt | 127 ++++++++ .../nipFERelayOverHttp/HttpRelayClient.kt | 153 +++++++++ 20 files changed, 1396 insertions(+), 60 deletions(-) create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/SignInPolicies.kt create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpAdmission.kt create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/server/HttpAdmissionTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt diff --git a/geode/README.md b/geode/README.md index 4c45f710fc..07c5c73a20 100644 --- a/geode/README.md +++ b/geode/README.md @@ -3,8 +3,8 @@ A standalone [Nostr](https://github.com/nostr-protocol/nips) relay for the JVM, built on Quartz's relay-server code (Ktor CIO). It speaks the core relay protocol plus NIP-11 (info doc), NIP-42 (AUTH), NIP-45 (COUNT), NIP-50 -(full-text search), NIP-77 (Negentropy sync), and NIP-86 (relay management), -stores events in SQLite (or a filesystem backend), and can mirror upstream +(full-text search), NIP-77 (Negentropy sync), NIP-86 (relay management) and +NIP-FE (REQ/COUNT/EVENT over plain HTTP), stores events in SQLite (or a filesystem backend), and can mirror upstream relays strfry-router style. geode depends only on `:quartz` — no Android, no Compose. `amy serve` (the @@ -97,8 +97,29 @@ geode --version Key sections: `[info]` (NIP-11 doc), `[network]` (bind + thread pools), `[database]` (SQLite path/tuning), `[options]` (AUTH / verify / search), -`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), and -`[admin]` (NIP-86 management). See the example file for every knob. +`[authorization]` (allow/deny lists), `[[mirror]]` (upstream mirroring), +`[http]` (NIP-FE limits) and `[admin]` (NIP-86 management). See the example +file for every knob. + +### Commands over HTTP (NIP-FE) + +Besides the websocket, geode answers one command per HTTP `POST` beside the +relay path, streamed back as NDJSON — no socket, no subscription left open: + +```bash +curl -N -d '{"kinds":[1],"limit":2}' http://localhost:7447/req +# ["EVENT",{"id":"…","kind":1,…}] +# ["EVENT",{"id":"…","kind":1,…}] +# ["EOSE"] +curl -d '{"kinds":[1]}' http://localhost:7447/count # ["COUNT",{"count":2}] +curl -d @signed-event.json http://localhost:7447/event # ["OK","",true,""] +``` + +A body that does not end on `EOSE`/`CLOSED` (REQ), `COUNT`/`CLOSED` (COUNT) or +`OK` (EVENT) was cut off. On an AUTH-gated relay the answer is `401` until the +request carries a NIP-98 `Authorization: Nostr …` header whose `payload` is the +body's sha256. Quartz's `HttpRelayClient` does all of this for JVM/Android +clients. ## Verbs diff --git a/geode/config.example.toml b/geode/config.example.toml index 4651105623..2340424c6e 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -19,7 +19,8 @@ contact = "admin@example.com" # pubkey = "..." # Override the supported NIPs advertised on the NIP-11 endpoint. If # omitted, the relay advertises the NIPs it actually implements. -# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62] +# Hex-named NIPs go in as strings. +# supported_nips = [1, 9, 11, 40, 42, 45, 50, 62, "FE"] [network] host = "0.0.0.0" @@ -165,6 +166,35 @@ require_auth = false # backfill_seconds = 3600 # filter = '{"kinds":[0,1,3,7],"#t":["nostr"]}' +[http] +# NIP-FE: relay commands over HTTP. One command per POST to +# /req, /count or /event, answered as NDJSON +# (application/x-ndjson) and streamed as it is found; nothing stays open +# afterwards. NIP-98 `Authorization: Nostr ...` headers sign a request +# in, exactly as NIP-42 AUTH would on the socket. On by default; turning +# it off also drops "FE" from the default NIP-11 list. +enabled = true +# Every request is its own connection, so the websocket's +# per-connection limits don't bound HTTP clients. These do: over the +# per-client cap a request gets 429, over the global cap 503, both with +# Retry-After. 0 = no limit. +max_concurrent_requests = 256 +max_requests_per_client = 16 +# An answer still running after this ends on a CLOSED line. +deadline_seconds = 30 +max_body_bytes = 524288 +retry_after_seconds = 1 +# Other addresses this relay is reachable at: a NIP-98 token may name +# the endpoint under any of them, as well as under [info].relay_url. +# alternate_urls = ["ws://youraddress.onion/"] +# Behind a reverse proxy every request comes from the proxy's address. +# List the proxies here and the per-client cap counts the address the +# proxy writes in client_address_header instead (its last entry). The +# header is ignored from anyone else. Also set `proxy_buffering off` +# (nginx) or equivalent; the relay sends X-Accel-Buffering: no. +# trusted_proxies = ["127.0.0.1"] +# client_address_header = "X-Forwarded-For" + [admin] # NIP-86 relay management API. When `pubkeys` is non-empty, the relay # accepts HTTP POST application/nostr+json+rpc on the same URL, diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index 0a1af895c1..8c05dbadb4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -20,13 +20,17 @@ */ package com.vitorpamplona.geode +import com.vitorpamplona.geode.server.HttpCommandSettings import com.vitorpamplona.geode.server.Nip11HttpRoute import com.vitorpamplona.geode.server.Nip86HttpRoute +import com.vitorpamplona.geode.server.NipFEHttpRoute import com.vitorpamplona.geode.server.WebSocketSessionPump import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import io.ktor.server.application.install import io.ktor.server.application.serverConfig import io.ktor.server.cio.CIO @@ -34,6 +38,7 @@ import io.ktor.server.cio.CIOApplicationEngine import io.ktor.server.engine.connector import io.ktor.server.engine.embeddedServer import io.ktor.server.routing.get +import io.ktor.server.routing.options import io.ktor.server.routing.post import io.ktor.server.routing.routing import io.ktor.server.websocket.WebSockets @@ -77,6 +82,11 @@ class KtorRelay( val workerGroupSize: Int? = null, /** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */ val callGroupSize: Int? = null, + /** + * NIP-FE: relay commands over HTTP at `/req`, `/count` and `/event`. On by default; null + * turns the endpoints off (and the operator should then drop `FE` from the NIP-11 doc). + */ + val httpCommands: HttpCommandSettings? = HttpCommandSettings(), ) { /** * NIP-86 HTTP adapter. Wraps the engine's [RelayEngine.nip86Server] @@ -104,6 +114,20 @@ class KtorRelay( private val nip11Route = Nip11HttpRoute(liveJson = { relay.info.json }) + /** + * NIP-FE. Each request runs on its own session of the same engine, so the websocket's policies + * and limits apply. A NIP-98 token must name the endpoint under `relay.url` read as http(s), or + * under one of the configured alternate URLs (a .onion). + */ + private val nipFERoute = + httpCommands?.let { settings -> + val origins = (listOf(relay.url) + settings.alternateUrls).map { it.toHttp() } + NipFEHttpRoute( + handler = HttpRelayHandler(relay.server, origins = { origins }, deadline = settings.deadline), + settings = settings, + ) + } + private var engine: CIOApplicationEngine? = null private var resolvedPort: Int = -1 @@ -171,6 +195,15 @@ class KtorRelay( post(path) { nip86Route.handle(call) } + // NIP-FE: one command per POST, answered as NDJSON. The paths + // hang off the relay's own path, as the NIP-98 `u` does. + nipFERoute?.let { route -> + HttpRelayCommand.entries.forEach { command -> + val endpoint = path.trimEnd('/') + command.path + post(endpoint) { route.handle(call, command) } + options(endpoint) { route.preflight(call) } + } + } webSocket(path) { if (shuttingDown) { // Just return — Ktor closes the WS for us. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 1ed17bbcdb..e4ddc8bdea 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -35,9 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy -import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy -import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy @@ -356,6 +354,7 @@ private fun serve(args: Array) { connectionGroupSize = config.network.connection_group_size, workerGroupSize = config.network.worker_group_size, callGroupSize = config.network.call_group_size, + httpCommands = config.http.toSettings(), ).start() // `[[mirror]]` upstreams: dial each configured relay and stream its @@ -526,9 +525,9 @@ private fun composePolicy( val pieces = mutableListOf() if (requireAuth) { - pieces += FullAuthPolicy(advertisedUrl) + pieces += SignInPolicy(advertisedUrl) } else if (optionalAuth) { - pieces += OptionalAuthPolicy(advertisedUrl) + pieces += OptionalSignInPolicy(advertisedUrl) } config.options.reject_future_seconds?.let { secs -> diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt index 4170fa14b4..92f294b519 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt @@ -62,9 +62,10 @@ data class RelayInfo( * - 62 NIP-62 right to vanish * - 77 NIP-77 negentropy reconciliation * - 86 NIP-86 relay management API (when admin pubkeys configured) + * - FE NIP-FE relay commands over HTTP (KtorRelay; `[http].enabled`) */ val SUPPORTED_NIPS: List = - listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86") + listOf("1", "9", "11", "40", "42", "45", "50", "62", "77", "86", "FE") /** Pre-built default for `RelayEngine(url = ...)` — advertises the supported NIPs. */ fun default(url: NormalizedRelayUrl): RelayInfo = diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/SignInPolicies.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/SignInPolicies.kt new file mode 100644 index 0000000000..b246219bab --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/SignInPolicies.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy + +/** + * Geode's AUTH decides nothing past the NIP-42 proof, so a key a NIP-98 header proved on a NIP-FE + * command signs in just the same: the transport's proof is all the socket's would have been. + */ +internal class SignInPolicy( + relay: NormalizedRelayUrl, +) : FullAuthPolicy(relay) { + override suspend fun authorizeTransport(pubkey: HexKey): String? = null +} + +/** [SignInPolicy] for optional AUTH. */ +internal class OptionalSignInPolicy( + relay: NormalizedRelayUrl, +) : OptionalAuthPolicy(relay) { + override suspend fun authorizeTransport(pubkey: HexKey): String? = null +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 8cfecb4e41..3528cbc8e4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -21,12 +21,15 @@ package com.vitorpamplona.geode.config import cc.ekblad.toml.decode +import cc.ekblad.toml.model.TomlValue import cc.ekblad.toml.tomlMapper import com.vitorpamplona.geode.RelayInfo +import com.vitorpamplona.geode.server.HttpCommandSettings import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import java.io.File +import kotlin.time.Duration.Companion.seconds /** * Operator-facing **boot-time** configuration. Parsed once from a TOML @@ -45,10 +48,14 @@ data class StaticConfig( val authorization: AuthorizationSection = AuthorizationSection(), val admin: AdminSection = AdminSection(), val negentropy: NegentropySection = NegentropySection(), + val http: HttpSection = HttpSection(), /** `[[mirror]]` entries — upstream relays this relay streams from. */ val mirror: List = emptyList(), ) { - fun resolveInfo(fullTextSearch: Boolean = true): RelayInfo = + fun resolveInfo( + fullTextSearch: Boolean = true, + httpCommands: Boolean = http.enabled, + ): RelayInfo = RelayInfo( Nip11RelayInformation( name = info.name ?: RelayInfo.NAME, @@ -59,10 +66,10 @@ data class StaticConfig( software = info.software ?: RelayInfo.SOFTWARE, version = info.version ?: RelayInfo.VERSION, supported_nips = - info.supported_nips?.map(Int::toString) + info.supported_nips // An explicit [info] nips list is operator-authoritative; - // the default list stays honest about search. - ?: if (fullTextSearch) RelayInfo.SUPPORTED_NIPS else RelayInfo.SUPPORTED_NIPS - "50", + // the default list stays honest about search and HTTP commands. + ?: RelayInfo.SUPPORTED_NIPS.filter { (fullTextSearch || it != "50") && (httpCommands || it != "FE") }, privacy_policy = info.privacy_policy, terms_of_service = info.terms_of_service, relay_countries = info.relay_countries, @@ -80,7 +87,8 @@ data class StaticConfig( val icon: String? = null, val software: String? = null, val version: String? = null, - val supported_nips: List? = null, + /** NIP numbers, and the hex-named NIPs as strings: `[1, 11, 42, "FE"]`. */ + val supported_nips: List? = null, val privacy_policy: String? = null, val terms_of_service: String? = null, val relay_countries: List? = null, @@ -223,6 +231,53 @@ data class StaticConfig( val live_index: Boolean = true, ) + /** + * NIP-FE: relay commands over HTTP — `POST /req`, `/count`, `/event`, one command per + * request, answered as NDJSON. Each request is its own connection, so the concurrency caps here + * stand in for the websocket's per-connection limits. + */ + data class HttpSection( + val enabled: Boolean = true, + /** Requests running at once across all clients; over it, 503. 0 = no limit. */ + val max_concurrent_requests: Int = 256, + /** Requests one client address may run at once; over it, 429. 0 = no limit. */ + val max_requests_per_client: Int = 16, + /** How long one answer may run before it ends on a `CLOSED` line. */ + val deadline_seconds: Long = 30, + /** Largest request body read; larger is 413. */ + val max_body_bytes: Int = 512 * 1024, + /** `Retry-After` on the relay's own 429 and 503. */ + val retry_after_seconds: Int = 1, + /** + * Other `ws(s)://` URLs this relay is reachable at (a `.onion` beside the clearnet name). + * A NIP-98 token's `u` may name the endpoint under any of them or under `[info].relay_url`. + */ + val alternate_urls: List = emptyList(), + /** + * Addresses of the reverse proxies in front of the relay. Only from these peers is + * [client_address_header] believed when counting a client's requests. + */ + val trusted_proxies: List = emptyList(), + val client_address_header: String = "X-Forwarded-For", + ) { + /** The transport settings, or null when the endpoints are off. */ + fun toSettings(): HttpCommandSettings? = + if (!enabled) { + null + } else { + HttpCommandSettings( + maxConcurrent = max_concurrent_requests, + maxPerClient = max_requests_per_client, + deadline = deadline_seconds.seconds, + maxBodyBytes = max_body_bytes, + retryAfterSeconds = retry_after_seconds, + alternateUrls = alternate_urls.map { it.normalizeRelayUrl() }, + trustedProxies = trusted_proxies.toSet(), + clientAddressHeader = client_address_header, + ) + } + } + data class AuthorizationSection( val pubkey_whitelist: List = emptyList(), val pubkey_blacklist: List = emptyList(), @@ -297,6 +352,11 @@ data class StaticConfig( * the store. */ fun validate() { + require(http.max_concurrent_requests >= 0) { "[http].max_concurrent_requests must be >= 0 (0 = no limit), got ${http.max_concurrent_requests}" } + require(http.max_requests_per_client >= 0) { "[http].max_requests_per_client must be >= 0 (0 = no limit), got ${http.max_requests_per_client}" } + require(http.deadline_seconds > 0) { "[http].deadline_seconds must be > 0, got ${http.deadline_seconds}" } + require(http.max_body_bytes > 0) { "[http].max_body_bytes must be > 0, got ${http.max_body_bytes}" } + require(http.retry_after_seconds >= 0) { "[http].retry_after_seconds must be >= 0, got ${http.retry_after_seconds}" } database.readers?.let { require(it >= 1) { "[database].readers must be >= 1 (got $it); a 0/negative pool can never answer a query" } } @@ -308,7 +368,11 @@ data class StaticConfig( } companion object { - private val mapper = tomlMapper { } + private val mapper = + tomlMapper { + // `supported_nips = [1, 11, "FE"]`: numbered NIPs are integers, hex-named ones strings. + decoder { it: TomlValue.Integer -> it.value.toString() } + } fun fromToml(toml: String): StaticConfig = mapper.decode(toml) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt new file mode 100644 index 0000000000..7634d48939 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode.server + +import io.ktor.http.HttpHeaders +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.receiveChannel +import io.ktor.utils.io.readAvailable + +/** + * Reads the request body up to [cap] bytes. Returns null when it is larger — by its declared + * `Content-Length` or by what actually arrives — without reading past the cap; the caller answers 413. + */ +internal suspend fun readBoundedBody( + call: ApplicationCall, + cap: Int, +): ByteArray? { + val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() + if (declared != null && declared > cap) return null + val ch = call.receiveChannel() + val buf = ByteArray(cap + 1) + var pos = 0 + while (pos <= cap) { + val read = ch.readAvailable(buf, pos, buf.size - pos) + if (read <= 0) break + pos += read + } + if (pos > cap) return null + return buf.copyOfRange(0, pos) +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpAdmission.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpAdmission.kt new file mode 100644 index 0000000000..939d3ab22d --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpAdmission.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode.server + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +/** + * How many NIP-FE requests run at once, per client and in all. Each HTTP request is its own + * connection, so the per-connection limits the websocket leans on (open subscriptions, one search at + * a time) bound nothing here; this does. A request over its client's share is that client's to + * slow down (429); one over the relay's is everyone's (503). + */ +internal class HttpAdmission( + /** Requests running at once across all clients; 0 is no limit. */ + private val maxConcurrent: Int, + /** Requests one client may run at once; 0 is no limit. */ + private val maxPerClient: Int, +) { + enum class Verdict { ADMITTED, CLIENT_BUSY, RELAY_BUSY } + + private val running = AtomicInteger() + private val perClient = ConcurrentHashMap() + + /** Number of requests running now. */ + val inFlight: Int get() = running.get() + + /** Runs [block] if [client] and the relay have room; otherwise says which of them had none. */ + suspend fun admit( + client: String, + block: suspend () -> Unit, + ): Verdict { + val verdict = enter(client) + if (verdict != Verdict.ADMITTED) return verdict + try { + block() + } finally { + leave(client) + } + return verdict + } + + private fun enter(client: String): Verdict { + var clientFull = false + perClient.compute(client) { _, n -> + val now = n ?: 0 + if (maxPerClient in 1..now) { + clientFull = true + n + } else { + now + 1 + } + } + if (clientFull) return Verdict.CLIENT_BUSY + if (running.incrementAndGet().let { maxConcurrent in 1 until it }) { + running.decrementAndGet() + release(client) + return Verdict.RELAY_BUSY + } + return Verdict.ADMITTED + } + + private fun leave(client: String) { + running.decrementAndGet() + release(client) + } + + private fun release(client: String) { + perClient.computeIfPresent(client) { _, n -> if (n <= 1) null else n - 1 } + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt new file mode 100644 index 0000000000..9955f3884d --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode.server + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler +import kotlin.time.Duration + +/** + * NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: `POST` to + * `/req`, `/count` and `/event`. The engine's policies and limits apply as they do on + * the websocket; these bound what the websocket's per-connection limits cannot, since every request + * is its own connection. + */ +data class HttpCommandSettings( + /** Requests running at once across all clients before the rest get 503; 0 is no limit. */ + val maxConcurrent: Int = 256, + /** Requests one client address may run at once before its next gets 429; 0 is no limit. */ + val maxPerClient: Int = 16, + /** How long one answer may run, first byte to last. */ + val deadline: Duration = HttpRelayHandler.DEFAULT_DEADLINE, + /** The largest body read; the engine's own message limit, when it has one and it is smaller, wins. */ + val maxBodyBytes: Int = 512 * 1024, + /** The `Retry-After` sent with a 429 or 503 the relay decides itself. */ + val retryAfterSeconds: Int = 1, + /** Other URLs this relay answers at (its .onion); a NIP-98 `u` may name any of them. */ + val alternateUrls: List = emptyList(), + /** + * Peers whose [clientAddressHeader] is believed: the reverse proxies in front of the relay. A + * request from anyone else is counted under its own address, whatever the header says. + */ + val trustedProxies: Set = emptySet(), + /** Where a trusted proxy writes the client's address; its last entry is the one the proxy saw. */ + val clientAddressHeader: String = "X-Forwarded-For", +) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt index 5c3e49d800..13dd281efb 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/Nip86HttpRoute.kt @@ -26,9 +26,7 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.request.header -import io.ktor.server.request.receiveChannel import io.ktor.server.response.respondText -import io.ktor.utils.io.readAvailable /** * Ktor adapter for the canonical NIP-86 HTTP flow encapsulated by @@ -55,7 +53,12 @@ internal class Nip86HttpRoute( private val handler: Nip86HttpHandler, ) { suspend fun handle(call: ApplicationCall) { - val body = readBoundedBody(call, handler.maxBodyBytes) ?: return // 413 already sent + val body = + readBoundedBody(call, handler.maxBodyBytes) ?: return call.respondText( + "request body exceeds ${handler.maxBodyBytes}-byte cap", + ContentType.Text.Plain, + HttpStatusCode.PayloadTooLarge, + ) val authHeader = call.request.header(HttpHeaders.Authorization) when (val r = handler.handle(authHeader, body)) { @@ -111,43 +114,6 @@ internal class Nip86HttpRoute( } } - /** - * Bounded read using [cap]. Returns null after sending a 413 if - * the request body exceeds the cap — either the declared - * `Content-Length` or what we actually pull off the wire. - */ - private suspend fun readBoundedBody( - call: ApplicationCall, - cap: Int, - ): ByteArray? { - val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() - if (declared != null && declared > cap) { - call.respondText( - "request body exceeds $cap-byte cap", - ContentType.Text.Plain, - HttpStatusCode.PayloadTooLarge, - ) - return null - } - val ch = call.receiveChannel() - val buf = ByteArray(cap + 1) - var pos = 0 - while (pos <= cap) { - val read = ch.readAvailable(buf, pos, buf.size - pos) - if (read <= 0) break - pos += read - } - if (pos > cap) { - call.respondText( - "request body exceeds $cap-byte cap", - ContentType.Text.Plain, - HttpStatusCode.PayloadTooLarge, - ) - return null - } - return buf.copyOfRange(0, pos) - } - /** * Single-line stderr audit. Operators grep on "nip86" / pubkey / * method without needing a logging framework. Best-effort — a diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt new file mode 100644 index 0000000000..fb616548ae --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode.server + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayLines +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayRequest +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayResponse +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayStatus +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.header +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondBytesWriter +import io.ktor.server.response.respondText +import io.ktor.utils.io.writeStringUtf8 + +/** + * NIP-FE over Ktor: the host half of [HttpRelayHandler]. It admits the request, reads the body up to + * the cap, and writes the handler's answer as `application/x-ndjson` — one refusal line with its + * status, or a 200 streamed and flushed frame by frame — with the headers the status calls for + * (`WWW-Authenticate` on 401, `Retry-After` on 429 and 503) and the CORS and no-buffering headers + * every answer carries. + */ +internal class NipFEHttpRoute( + private val handler: HttpRelayHandler, + private val settings: HttpCommandSettings, +) { + private val admission = HttpAdmission(settings.maxConcurrent, settings.maxPerClient) + + private val bodyCap: Int = minOf(settings.maxBodyBytes.toLong(), handler.maxBodyBytes ?: Long.MAX_VALUE).toInt() + + /** Requests being answered right now. */ + val inFlight: Int get() = admission.inFlight + + /** A CORS preflight: any origin may POST with a NIP-98 `Authorization`; no cookies are involved. */ + suspend fun preflight(call: ApplicationCall) { + call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") + call.response.header(HttpHeaders.AccessControlAllowMethods, "POST, OPTIONS") + call.response.header(HttpHeaders.AccessControlAllowHeaders, "Authorization, Content-Type") + call.response.header(HttpHeaders.AccessControlMaxAge, PREFLIGHT_MAX_AGE_SECONDS.toString()) + call.respond(HttpStatusCode.NoContent) + } + + /** Answers one [command]. Admission runs first, so a refused request spends no NIP-98 token. */ + suspend fun handle( + call: ApplicationCall, + command: HttpRelayCommand, + ) { + call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") + call.response.header(HttpHeaders.AccessControlExposeHeaders, "${HttpHeaders.WWWAuthenticate}, ${HttpHeaders.RetryAfter}") + call.response.header(HttpHeaders.CacheControl, "no-store") + // nginx and friends buffer responses by default, which holds back the lines streaming delivers. + call.response.header(ACCEL_BUFFERING, "no") + + val verdict = + admission.admit(clientOf(call)) { + val body = + readBoundedBody(call, bodyCap) + ?: return@admit respondLine( + call, + HttpRelayStatus.PAYLOAD_TOO_LARGE, + HttpRelayHandler.refusal(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), + ) + handler.handle(HttpRelayRequest(command, call.request.header(HttpHeaders.Authorization), body), Answer(call)) + } + when (verdict) { + HttpAdmission.Verdict.ADMITTED -> {} + + HttpAdmission.Verdict.CLIENT_BUSY -> { + respondLine( + call, + HttpRelayStatus.TOO_MANY_REQUESTS, + HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")), + ) + } + + HttpAdmission.Verdict.RELAY_BUSY -> { + respondLine( + call, + HttpRelayStatus.UNAVAILABLE, + HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("the relay is at capacity")), + ) + } + } + } + + /** + * Who the request counts against: the peer's address, or, when the peer is a trusted proxy, the + * last address in its [HttpCommandSettings.clientAddressHeader] — the one that proxy saw. Earlier + * entries are whatever the client claimed and are never believed. + */ + private fun clientOf(call: ApplicationCall): String { + val peer = call.request.local.remoteAddress + if (peer !in settings.trustedProxies) return peer + return call.request + .header(settings.clientAddressHeader) + ?.substringAfterLast(',') + ?.trim() + ?.ifEmpty { null } ?: peer + } + + /** A one-line answer: a refusal, or a command answered at once. */ + private suspend fun respondLine( + call: ApplicationCall, + status: Int, + frame: String, + ) { + when (status) { + HttpRelayStatus.UNAUTHORIZED -> call.response.header(HttpHeaders.WWWAuthenticate, WWW_AUTHENTICATE) + HttpRelayStatus.TOO_MANY_REQUESTS, HttpRelayStatus.UNAVAILABLE -> call.response.header(HttpHeaders.RetryAfter, settings.retryAfterSeconds.toString()) + } + call.respondText(frame + "\n", NDJSON, HttpStatusCode.fromValue(status)) + } + + private inner class Answer( + private val call: ApplicationCall, + ) : HttpRelayResponse { + override suspend fun single( + status: Int, + frame: String, + ) = respondLine(call, status, frame) + + /** Chunked: each flush puts what the handler wrote on the wire, and a full socket suspends the writer. */ + override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) = + call.respondBytesWriter(NDJSON, HttpStatusCode.OK) { + val out = this + object : HttpRelayLines { + override suspend fun line(frame: String) { + out.writeStringUtf8(frame) + out.writeStringUtf8("\n") + } + + override suspend fun flush() = out.flush() + }.lines() + } + } + + companion object { + val NDJSON = ContentType("application", "x-ndjson") + const val WWW_AUTHENTICATE = "Nostr" + const val ACCEL_BUFFERING = "X-Accel-Buffering" + const val PREFLIGHT_MAX_AGE_SECONDS = 86_400 + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt new file mode 100644 index 0000000000..e9fd20927a --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode + +import com.vitorpamplona.geode.server.HttpCommandSettings +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayClient +import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.net.ServerSocket +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration + +/** + * NIP-FE end to end: quartz's [HttpRelayClient] and raw OkHttp requests against a real [KtorRelay], + * covering the answer shape, the status table, the headers each status carries, NIP-98 sign-in + * on an AUTH-gated relay, and where the endpoints live. + */ +class NipFEHttpTest { + private val http = OkHttpClient.Builder().build() + private val alice = NostrSignerInternal(KeyPair()) + private val running = mutableListOf>() + + @AfterTest + fun teardown() { + running.forEach { (server, relay) -> + server.stop(0, 1_000) + relay.close() + } + } + + /** A relay whose advertised URL is the one it listens on, so a NIP-98 `u` names a reachable endpoint. */ + private fun start( + path: String = "/", + settings: HttpCommandSettings? = HttpCommandSettings(), + policy: ((NormalizedRelayUrl) -> IRelayPolicy)? = null, + ): NormalizedRelayUrl { + val port = ServerSocket(0).use { it.localPort } + val url = "ws://127.0.0.1:$port$path".normalizeRelayUrl() + val relay = if (policy == null) RelayEngine(url) else RelayEngine(url, policyBuilder = { policy(url) }) + val server = KtorRelay(relay, host = "127.0.0.1", port = port, path = path, httpCommands = settings).start() + running += server to relay + return url + } + + private fun client(signer: NostrSignerInternal? = null) = HttpRelayClient(http, signer) + + private suspend fun note(text: String): Event = alice.sign(TextNoteEvent.build(text)) + + private fun post( + url: String, + body: String, + authorization: String? = null, + ): Response = + http + .newCall( + Request + .Builder() + .url(url) + .post(body.toRequestBody("text/plain".toMediaType())) + .apply { authorization?.let { header("Authorization", it) } } + .build(), + ).execute() + + private fun Response.lines() = body.string().lines().filter { it.isNotEmpty() } + + @Test + fun aReqStreamsWhatWasPublishedAndEndsOnEose() = + runBlocking { + val relay = start() + val notes = listOf(note("one"), note("two"), note("three")) + notes.forEach { assertTrue(assertIs(client().publish(relay, it).last).success) } + + val got = mutableListOf() + val answer = client().req(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), got::add) + assertEquals(200, answer.status) + assertTrue(answer.complete) + assertIs(answer.last) + assertEquals(notes.map { it.id }.toSet(), got.map { it.id }.toSet()) + } + + @Test + fun theWireIsNdjsonWithoutSubscriptionIds() = + runBlocking { + val relay = start() + val n = note("hello") + client().publish(relay, n) + post(HttpRelayCommand.REQ.url(relay), """{"ids":["${n.id}"]}""").use { response -> + assertEquals(200, response.code) + assertTrue(response.header("Content-Type")!!.startsWith("application/x-ndjson")) + assertEquals("no", response.header("X-Accel-Buffering")) + assertEquals("*", response.header("Access-Control-Allow-Origin")) + val lines = response.lines() + assertEquals(listOf("""["EVENT",${n.toJson()}]""", """["EOSE"]"""), lines) + } + } + + @Test + fun aCountIsOneCountLine() = + runBlocking { + val relay = start() + client().publish(relay, note("a")) + client().publish(relay, note("b")) + val answer = client().count(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND)))) + assertTrue(answer.complete) + assertEquals(2, assertIs(answer.last).result.count) + } + + @Test + fun aDuplicateIs200AndAForgeryIs400() = + runBlocking { + val relay = start(policy = { VerifyPolicy }) + val n = note("once") + assertEquals(200, client().publish(relay, n).status) + assertEquals(200, client().publish(relay, n).status) + + val forged = n.toJson().replace("\"once\"", "\"twice\"") + post(HttpRelayCommand.EVENT.url(relay), forged).use { response -> + assertEquals(400, response.code) + val line = response.lines().single() + assertTrue(line.startsWith("""["OK","${n.id}",false,"invalid:"""), line) + } + } + + @Test + fun aBodyThatIsNotTheCommandIs400AndOneOverTheCapIs413() { + val relay = start(settings = HttpCommandSettings(maxBodyBytes = 64)) + post(HttpRelayCommand.REQ.url(relay), "hello").use { response -> + assertEquals(400, response.code) + assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:""")) + } + post(HttpRelayCommand.REQ.url(relay), """{"authors":["${"a".repeat(64)}"]}""").use { response -> + assertEquals(413, response.code) + assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:""")) + } + } + + @Test + fun aRateLimitedRefusalIs429WithRetryAfter() { + val relay = + start( + settings = HttpCommandSettings(retryAfterSeconds = 7), + policy = { + object : PassThroughPolicy() { + override fun accept(cmd: ReqCmd): PolicyResult = PolicyResult.Rejected("rate-limited: slow down") + } + }, + ) + post(HttpRelayCommand.REQ.url(relay), "{}").use { response -> + assertEquals(429, response.code) + assertEquals("7", response.header("Retry-After")) + assertEquals("""["CLOSED","rate-limited: slow down"]""", response.lines().single()) + } + } + + @Test + fun aPreflightLetsAnyOriginPostWithAuthorization() { + val relay = start() + val preflight = + Request + .Builder() + .url(HttpRelayCommand.REQ.url(relay)) + .method("OPTIONS", null) + .header("Origin", "https://app.example") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "authorization") + .build() + http.newCall(preflight).execute().use { response -> + assertEquals(204, response.code) + assertEquals("*", response.header("Access-Control-Allow-Origin")) + assertTrue(response.header("Access-Control-Allow-Methods")!!.contains("POST")) + assertTrue(response.header("Access-Control-Allow-Headers")!!.contains("Authorization")) + } + } + + @Test + fun anAuthGatedRelayAnswers401UntilANip98TokenSignsTheRequestIn() = + runBlocking { + val relay = start(policy = ::SignInPolicy) + post(HttpRelayCommand.REQ.url(relay), "{}").use { response -> + assertEquals(401, response.code) + assertEquals("Nostr", response.header("WWW-Authenticate")) + assertTrue(response.lines().single().startsWith("""["CLOSED","auth-required:""")) + } + + val unsigned = client().req(relay, listOf(Filter(kinds = listOf(1)))) {} + assertEquals(401, unsigned.status) + assertTrue(unsigned.complete) + + val n = note("signed in") + val published = client(alice).publish(relay, n) + assertEquals(200, published.status) + assertTrue(assertIs(published.last).success) + + val got = mutableListOf() + val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), got::add) + assertEquals(200, read.status) + assertTrue(read.complete) + assertEquals(listOf(n.id), got.map { it.id }) + } + + @Test + fun aTokenForAnotherBodyDoesNotSignIn() = + runBlocking { + val relay = start(policy = ::SignInPolicy) + val url = HttpRelayCommand.REQ.url(relay) + val token = alice.sign(HTTPAuthorizationEvent.build(url, "POST", """{"kinds":[1]}""".encodeToByteArray())).toAuthToken() + post(url, """{"kinds":[0]}""", token).use { response -> + assertEquals(401, response.code) + assertTrue(response.lines().single().contains("payload")) + } + post(url, """{"kinds":[1]}""", token).use { assertEquals(200, it.code) } + } + + @Test + fun aTokenSignedAtTheOnionAddressVerifies() = + runBlocking { + val onion = "ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/".normalizeRelayUrl() + val relay = start(policy = ::SignInPolicy, settings = HttpCommandSettings(alternateUrls = listOf(onion))) + val body = """{"kinds":[1]}""" + val token = alice.sign(HTTPAuthorizationEvent.build(HttpRelayCommand.REQ.url(onion), "POST", body.encodeToByteArray())).toAuthToken() + post(HttpRelayCommand.REQ.url(relay), body, token).use { assertEquals(200, it.code) } + } + + @Test + fun theEndpointsHangOffTheRelayPath() = + runBlocking { + val relay = start(path = "/nostr") + assertTrue(HttpRelayCommand.REQ.url(relay).endsWith("/nostr/req")) + assertTrue(client().req(relay, listOf(Filter(kinds = listOf(1)))) {}.complete) + post(HttpRelayCommand.REQ.url(relay).replace("/nostr/req", "/req"), "{}").use { assertEquals(404, it.code) } + } + + @Test + fun turnedOffThereAreNoEndpoints() { + val relay = start(settings = null) + post(HttpRelayCommand.REQ.url(relay), "{}").use { assertEquals(404, it.code) } + } + + @Test + fun nip11AdvertisesFE() { + val relay = start() + val request = + Request + .Builder() + .url(relay.url.replace("ws://", "http://")) + .header("Accept", "application/nostr+json") + .build() + http.newCall(request).execute().use { response -> + assertTrue(response.body.string().contains("\"FE\"")) + } + } + + @Test + fun noFirstFrameWithinTheDeadlineIs503WithRetryAfter() = + runBlocking { + val relay = start(settings = HttpCommandSettings(deadline = Duration.ZERO, retryAfterSeconds = 3)) + val answer = client().req(relay, listOf(Filter(kinds = listOf(1)))) {} + assertEquals(503, answer.status) + assertEquals("3", answer.retryAfter) + assertTrue(answer.complete) + assertFalse(answer.last is EoseMessage) + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt index 2ba0823441..a9469e37aa 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/config/StaticConfigTest.kt @@ -28,6 +28,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds class StaticConfigTest { @Test @@ -219,7 +220,7 @@ class StaticConfigTest { assertEquals("wss://relay.example.com/", c.info.relay_url) assertEquals("Example", c.info.name) - assertEquals(listOf(1, 9, 11, 42), c.info.supported_nips) + assertEquals(listOf("1", "9", "11", "42"), c.info.supported_nips) assertEquals("127.0.0.1", c.network.host) assertEquals(9988, c.network.port) @@ -249,6 +250,56 @@ class StaticConfigTest { assertEquals(listOf("1", "11", "42"), info.document.supported_nips) } + @Test + fun supportedNipsTakeHexNamedNipsAsStrings() { + val c = + StaticConfig.fromToml( + """ + [info] + supported_nips = [1, 11, "FE"] + """.trimIndent(), + ) + assertEquals(listOf("1", "11", "FE"), c.resolveInfo().document.supported_nips) + } + + @Test + fun httpCommandsAreOnByDefaultAndAdvertised() { + val c = StaticConfig.fromToml("") + assertTrue(c.http.enabled) + assertNotNull(c.http.toSettings()) + assertTrue("FE" in c.resolveInfo().document.supported_nips!!) + } + + @Test + fun httpSectionParsesAndTurningItOffDropsFE() { + val c = + StaticConfig.fromToml( + """ + [http] + enabled = false + max_concurrent_requests = 10 + max_requests_per_client = 2 + deadline_seconds = 5 + alternate_urls = ["ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/"] + trusted_proxies = ["127.0.0.1"] + """.trimIndent(), + ) + assertEquals(10, c.http.max_concurrent_requests) + assertEquals(2, c.http.max_requests_per_client) + assertEquals(listOf("127.0.0.1"), c.http.trusted_proxies) + assertEquals(null, c.http.toSettings()) + assertTrue("FE" !in c.resolveInfo().document.supported_nips!!) + val on = c.copy(http = c.http.copy(enabled = true)).http.toSettings()!! + assertEquals(5.seconds, on.deadline) + assertEquals(1, on.alternateUrls.size) + } + + @Test + fun httpLimitsMustBeSane() { + assertFailsWith { StaticConfig.fromToml("[http]\ndeadline_seconds = 0").validate() } + assertFailsWith { StaticConfig.fromToml("[http]\nmax_requests_per_client = -1").validate() } + } + @Test fun loadsTheBundledExampleConfigCleanly() { // The example file lives at the module root so operators have a diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/server/HttpAdmissionTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/server/HttpAdmissionTest.kt new file mode 100644 index 0000000000..ed2cbb77af --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/server/HttpAdmissionTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode.server + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals + +class HttpAdmissionTest { + @Test + fun aClientOverItsShareIsBusyAndARelayOverItsCapIsTooWhileOthersStillRun() = + runBlocking { + val admission = HttpAdmission(maxConcurrent = 2, maxPerClient = 1) + val release = CompletableDeferred() + val a = async { admission.admit("a") { release.await() } } + val b = async { admission.admit("b") { release.await() } } + while (admission.inFlight < 2) yield() + + assertEquals(HttpAdmission.Verdict.CLIENT_BUSY, admission.admit("a") {}) + assertEquals(HttpAdmission.Verdict.RELAY_BUSY, admission.admit("c") {}) + + release.complete(Unit) + assertEquals(HttpAdmission.Verdict.ADMITTED, a.await()) + assertEquals(HttpAdmission.Verdict.ADMITTED, b.await()) + assertEquals(0, admission.inFlight) + assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("a") {}) + assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("c") {}) + } + + @Test + fun zeroIsNoLimit() = + runBlocking { + val admission = HttpAdmission(maxConcurrent = 0, maxPerClient = 0) + val release = CompletableDeferred() + val held = List(50) { async { admission.admit("a") { release.await() } } } + while (admission.inFlight < 50) yield() + assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("a") {}) + release.complete(Unit) + held.forEach { assertEquals(HttpAdmission.Verdict.ADMITTED, it.await()) } + } + + @Test + fun aFailingRequestStillLeaves() = + runBlocking { + val admission = HttpAdmission(maxConcurrent = 1, maxPerClient = 1) + runCatching { admission.admit("a") { error("boom") } } + assertEquals(0, admission.inFlight) + assertEquals(HttpAdmission.Verdict.ADMITTED, admission.admit("a") {}) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt new file mode 100644 index 0000000000..7af34712f4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message + +/** + * A NIP-FE answer as the client got it. [complete] is false when the body stopped before the frame + * that ends the command's answer: what came is a prefix, and a REQ's prefix looks exactly like a + * short result, so a caller MUST NOT read an incomplete answer as the whole one. + */ +class HttpRelayAnswer( + val status: Int, + /** The frame the answer ended on (EOSE, CLOSED, COUNT, OK, NOTICE), or the last one read when cut off. */ + val last: Message?, + /** Whether the answer reached its end, rather than being cut off. */ + val complete: Boolean, + /** The `Retry-After` the relay sent with a 429 or 503. */ + val retryAfter: String? = null, +) + +/** + * NIP-FE, client side: reads an answer one line at a time as it arrives and keeps track of whether + * it ended where [command]'s answer ends. A 200 streams up to that frame; any other status is one + * refusal line, which is the whole answer whatever frame it is. + */ +class HttpRelayAnswerReader( + val command: HttpRelayCommand, + val status: Int, +) { + var last: Message? = null + private set + + private var lines = 0 + private var ended = false + private var broken = false + + /** + * The frame on [line], typed with [HttpRelayCommand.SUB_ID] as its subscription id, or null for + * a blank line. A line that does not parse, or anything after the answer ended, marks the answer + * incomplete: the body is not what this NIP says it is. + */ + fun read(line: String): Message? { + if (line.isBlank()) return null + if (ended || broken) { + broken = true + return null + } + val message = + try { + OptimizedJsonMapper.fromJsonToMessage(withSubId(line.trim())) + } catch (_: Exception) { + broken = true + return null + } + lines++ + last = message + ended = status != HttpRelayStatus.OK || command.ends(message) + return message + } + + /** Whether the body read so far is a whole answer. Asked once the body ends. */ + val complete: Boolean get() = ended && !broken && (status == HttpRelayStatus.OK || lines == 1) + + fun answer(retryAfter: String? = null) = HttpRelayAnswer(status, last, complete, retryAfter) +} + +/** + * [frame] with the subscription id NIP-FE leaves out put back, so the websocket's parser reads it: + * `["EVENT",{…}]` → `["EVENT","http",{…}]`, `["EOSE"]` → `["EOSE","http"]`. The inverse of + * [withoutSubId]; frames that carry no subscription id pass as they are. + */ +internal fun withSubId(frame: String): String { + if (!frame.startsWith('[')) return frame + var open = 1 + while (open < frame.length && frame[open].isWhitespace()) open++ + if (open >= frame.length || frame[open] != '"') return frame + val verbEnd = frame.indexOf('"', open + 1) + if (verbEnd < 0 || frame.substring(open + 1, verbEnd) !in SUBSCRIPTION_FRAMES) return frame + return frame.substring(0, verbEnd + 1) + SUB_ID_FIELD + frame.substring(verbEnd + 1) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt index cb5a865ea6..d0f51da283 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt @@ -29,6 +29,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp /** * NIP-FE: the client commands HTTP carries, one path each. A body is the command's arguments @@ -69,6 +72,9 @@ enum class HttpRelayCommand( } } + /** This command's endpoint on [relay]: the relay URL read as http(s), host and path kept, plus [path]. */ + fun url(relay: NormalizedRelayUrl): String = relay.toHttp().trimEnd('/') + path + /** Whether [message] is the last frame of this command's answer. */ fun ends(message: Message): Boolean = message is NoticeMessage || @@ -86,5 +92,8 @@ enum class HttpRelayCommand( const val SUB_ID = "http" fun forPath(path: String): HttpRelayCommand? = entries.firstOrNull { it.path == path } + + /** A REQ or COUNT body: the filters as the array that follows the subscription id. */ + fun body(filters: List): String = filters.joinToString(",", "[", "]") { it.toJson() } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt index a4054bbd08..d4333623f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt @@ -309,9 +309,12 @@ class HttpRelayHandler( return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${refusal?.reason}")) } - private fun closed(reason: String) = withoutSubId(ClosedMessage(HttpRelayCommand.SUB_ID, reason).toJson()) + private fun closed(reason: String) = refusal(reason) companion object { + /** A `CLOSED` line with [reason], as NIP-FE sends it: for a host that refuses before the handler runs (413, 429, 503). */ + fun refusal(reason: String) = withoutSubId(ClosedMessage(HttpRelayCommand.SUB_ID, reason).toJson()) + val DEFAULT_DEADLINE = 30_000.milliseconds /** The websocket's slow-consumer bound in the reference relays. */ @@ -322,9 +325,9 @@ class HttpRelayHandler( } /** The frames that carry a subscription id in the engine; NIP-FE sends them without it. */ -private val SUBSCRIPTION_FRAMES = setOf("EVENT", "EOSE", "CLOSED", "COUNT") +internal val SUBSCRIPTION_FRAMES = setOf("EVENT", "EOSE", "CLOSED", "COUNT") -private const val SUB_ID_FIELD = ",\"" + HttpRelayCommand.SUB_ID + "\"" +internal const val SUB_ID_FIELD = ",\"" + HttpRelayCommand.SUB_ID + "\"" /** * [frame] as NIP-FE sends it: the engine's frame with its `"http"` subscription id taken out, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt new file mode 100644 index 0000000000..3c00b6c9a1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** NIP-FE, client side: an answer's lines back into frames, and a whole answer told from a cut one. */ +class HttpRelayAnswerReaderTest { + private val event = + """{"id":"a8e0b2f2c1e7e4f5b0a1f9c1b3d2e6a7c8b9d0e1f2a3b4c5d6e7f8091a2b3c4d","pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",""" + + """"created_at":1700000000,"kind":1,"tags":[],"content":"hi","sig":"${"0".repeat(128)}"}""" + + private fun read( + command: HttpRelayCommand, + status: Int, + vararg lines: String, + ) = HttpRelayAnswerReader(command, status).also { reader -> lines.forEach { reader.read(it) } } + + @Test + fun theSubscriptionIdGoesBackWhereItWasTakenOut() { + for (frame in listOf("""["EVENT","http",$event]""", """["EOSE","http"]""", """["CLOSED","http","error: x"]""", """["COUNT","http",{"count":3}]""")) { + assertEquals(frame, withSubId(withoutSubId(frame))) + } + assertEquals("""["OK","id",true,""]""", withSubId("""["OK","id",true,""]""")) + assertEquals("""["NOTICE","hi"]""", withSubId("""["NOTICE","hi"]""")) + assertEquals("""[ "EOSE","http"]""", withSubId("""[ "EOSE"]""")) + } + + @Test + fun aReqThatEndsOnEoseIsComplete() { + val reader = read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE"]""") + assertTrue(reader.complete) + assertIs(reader.last) + } + + @Test + fun aReqWithItsTailMissingIsIncomplete() { + val reader = HttpRelayAnswerReader(HttpRelayCommand.REQ, 200) + val message = reader.read("""["EVENT",$event]""") + assertIs(message) + assertEquals("hi", message.event.content) + assertEquals(HttpRelayCommand.SUB_ID, message.subId) + assertFalse(reader.complete) + assertFalse(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).complete, "an empty body is no answer") + } + + @Test + fun aClosedEndsAReqAndACountButNotAnEvent() { + assertTrue(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["CLOSED","error: the answer ran past 30s"]""").complete) + assertTrue(read(HttpRelayCommand.COUNT, 200, """["CLOSED","error: x"]""").complete) + assertFalse(read(HttpRelayCommand.EVENT, 200, """["CLOSED","error: x"]""").complete) + } + + @Test + fun aCountAndAnOkAreWholeAnswers() { + val count = read(HttpRelayCommand.COUNT, 200, """["COUNT",{"count":7}]""") + assertTrue(count.complete) + assertEquals(7, assertIs(count.last).result.count) + val ok = read(HttpRelayCommand.EVENT, 200, """["OK","abc",true,""]""") + assertTrue(ok.complete) + assertTrue(assertIs(ok.last).success) + } + + @Test + fun aRefusalIsOneLineWhateverItsFrame() { + val refused = read(HttpRelayCommand.EVENT, 401, """["CLOSED","auth-required: sign in"]""") + assertTrue(refused.complete) + assertEquals("auth-required: sign in", assertIs(refused.last).message) + assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","blocked: no"]""", """["EOSE"]""").complete) + } + + @Test + fun anythingAfterTheEndOrALineThatIsNoFrameBreaksTheAnswer() { + assertFalse(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", """["EVENT",$event]""").complete) + assertFalse(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE""").complete) + assertTrue(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", "", " ").complete, "blank lines are not frames") + } + + @Test + fun blankLinesAreSkipped() { + assertNull(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).read("")) + } + + @Test + fun theEndpointsHangOffTheRelayUrl() { + assertEquals("https://relay.example/req", HttpRelayCommand.REQ.url(NormalizedRelayUrl("wss://relay.example/"))) + assertEquals("http://127.0.0.1:7447/nostr/count", HttpRelayCommand.COUNT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr"))) + assertEquals("http://127.0.0.1:7447/nostr/event", HttpRelayCommand.EVENT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr/"))) + } + + @Test + fun aFilterBodyIsTheArrayAfterTheSubscriptionId() { + val body = HttpRelayCommand.body(listOf(Filter(kinds = listOf(1), limit = 2), Filter(kinds = listOf(0)))) + assertEquals("""[{"kinds":[1],"limit":2},{"kinds":[0]}]""", body) + assertEquals("""["REQ","http",{"kinds":[1],"limit":2},{"kinds":[0]}]""", HttpRelayCommand.REQ.frameOf(body)) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt new file mode 100644 index 0000000000..fca059543e --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync +import okio.IOException + +/** + * NIP-FE over OkHttp: one relay command per request, its answer read line by line as the relay + * writes it. Nothing stays open after a call returns. + * + * With a [signer], a request the relay refuses with 401 goes once more carrying a NIP-98 token for + * its exact body, as a websocket client answers a NIP-42 challenge; without one, the 401 is the + * answer. [signFirst] sends the token with the first try instead, saving the round trip against a + * relay known to want it. + */ +class HttpRelayClient( + private val http: OkHttpClient, + private val signer: NostrSigner? = null, + private val signFirst: Boolean = false, +) { + /** Stored events matching [filters], each to [onEvent] as it arrives. Complete only if it ended on EOSE or CLOSED. */ + suspend fun req( + relay: NormalizedRelayUrl, + filters: List, + onEvent: (Event) -> Unit, + ): HttpRelayAnswer = + send(relay, HttpRelayCommand.REQ, HttpRelayCommand.body(filters)) { + if (it is EventMessage) onEvent(it.event) + } + + /** NIP-45: [HttpRelayAnswer.last] is the COUNT, or the refusal. */ + suspend fun count( + relay: NormalizedRelayUrl, + filters: List, + ): HttpRelayAnswer = send(relay, HttpRelayCommand.COUNT, HttpRelayCommand.body(filters)) + + /** Publishes [event]: [HttpRelayAnswer.last] is its OK, or the refusal. */ + suspend fun publish( + relay: NormalizedRelayUrl, + event: Event, + ): HttpRelayAnswer = send(relay, HttpRelayCommand.EVENT, event.toJson()) + + /** Posts [body] to [command]'s endpoint on [relay], handing every frame to [onMessage] as it is read. */ + suspend fun send( + relay: NormalizedRelayUrl, + command: HttpRelayCommand, + body: String, + onMessage: (Message) -> Unit = {}, + ): HttpRelayAnswer { + val url = command.url(relay) + val bytes = body.encodeToByteArray() + if (signer == null || signFirst) return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) + val first = post(command, url, bytes, null, onMessage, retrying = true) + if (first.status != HttpRelayStatus.UNAUTHORIZED) return first + return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) + } + + private suspend fun token( + url: String, + body: ByteArray, + ): String? = signer?.sign(HTTPAuthorizationEvent.build(url, "POST", body))?.toAuthToken() + + private suspend fun post( + command: HttpRelayCommand, + url: String, + body: ByteArray, + authorization: String?, + onMessage: (Message) -> Unit, + /** A signed try follows a 401, so that refusal is not the answer and is not handed on. */ + retrying: Boolean, + ): HttpRelayAnswer = + coroutineScope { + val request = + Request + .Builder() + .url(url) + .post(body.toRequestBody(JSON)) + .header("Accept", NDJSON) + .apply { authorization?.let { header("Authorization", it) } } + .build() + val call = http.newCall(request) + // A blocking read does not see coroutine cancellation; cancelling the call unblocks it. + val watcher = + launch { + try { + awaitCancellation() + } finally { + call.cancel() + } + } + try { + call.executeAsync().use { response -> + withContext(Dispatchers.IO) { + val reader = HttpRelayAnswerReader(command, response.code) + val deliver = !(retrying && response.code == HttpRelayStatus.UNAUTHORIZED) + val source = response.body.source() + try { + while (true) { + val line = source.readUtf8Line() ?: break + val message = reader.read(line) + if (message != null && deliver) onMessage(message) + } + } catch (_: IOException) { + // The connection dropped mid-answer: what came is what the reader says it is. + } + reader.answer(response.header("Retry-After")) + } + } + } finally { + watcher.cancel() + } + } + + companion object { + const val NDJSON = "application/x-ndjson" + private val JSON = "application/json".toMediaType() + } +} From b8dad13265a5719f08084aef5bf8444966567d4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:27:13 +0000 Subject: [PATCH 13/32] feat(android): opt into ARM Memory Tagging Extension (async) Declare android:memtagMode on the application so devices with MTE enabled (Pixel 8+ with the developer toggle or Advanced Protection, GrapheneOS) tag-check our native code: WebRTC, zxing-cpp, bundled SQLite, secp256k1 and Arti. Release and benchmark builds use async, the low-overhead production mode; debug uses sync so a tag fault crashes at the exact faulting access. The attribute is ignored on devices without MTE and below API 31. Closes #4196 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TUQgtrHcA21Npt8ajDjWYC --- amethyst/build.gradle.kts | 7 +++++++ amethyst/src/main/AndroidManifest.xml | 1 + 2 files changed, 8 insertions(+) diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 1f432394be..432eac5d92 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -140,6 +140,12 @@ android { buildConfigField("String", "RELEASE_NOTES_ID", "\"f7914e7a7e293988485439eb2bea29c09c388d54c452c4a19f89e106dbf1969e\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // ARM Memory Tagging Extension for our native code (WebRTC, zxing-cpp, SQLite, + // secp256k1, Arti). Async is the low-overhead production mode; debug overrides + // it to sync so a tag fault crashes at the exact faulting access. Ignored on + // devices without MTE hardware or with it switched off. + manifestPlaceholders["memtagMode"] = "async" vectorDrawables { useSupportLibrary = true } @@ -267,6 +273,7 @@ android { applicationIdSuffix = ".debug" versionNameSuffix = "-DEBUG" resValue("string", "app_name", "@string/app_name_debug") + manifestPlaceholders["memtagMode"] = "sync" } create("benchmark") { initWith(getByName("release")) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index d2b9dabc65..91acb98039 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -184,6 +184,7 @@ android:networkSecurityConfig="@xml/network_security_config" android:hardwareAccelerated="true" android:localeConfig="@xml/locales_config" + android:memtagMode="${memtagMode}" tools:targetApi="34"> Date: Sat, 8 Aug 2026 19:05:10 +0200 Subject: [PATCH 14/32] chore(cast): surface the receiver's verdict on load(), at INFO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Casting to an LG webOS TV connected but never rendered, and nothing in the log said why. Two gaps hid it: - `RemoteMediaClient.load()` returns a PendingResult carrying the receiver's accept/refuse verdict, and it was being discarded. A refused load was indistinguishable from a successful one: the coroutine reported Casting and the TV sat on its splash screen. Capture the result callback and log the refusal code and message. - Debug builds default to `LogLevel.INFO` (Amethyst.DEFAULT_LOG_LEVEL), so every cast diagnostic — all of them `Log.d` — was invisible on a debug client. Promote the low-volume lifecycle lines (discovery, route counts, session start/end, media status) to INFO and the hard failures to WARN. Also decode Cast's bare int status codes via CastStatusCodes.getStatusCodeString and log the ACCESS_LOCAL_NETWORK grant state at discovery time, since a denied LNP permission produces an empty picker with no error of any kind. `updateRoutes` now logs the unfiltered router total next to the kept count: seen=0 means discovery saw nothing, seen>0 kept=0 means the routes exist but none advertises the Cast control category — two faults that look identical from the UI. --- .../cast/chromecast/ChromecastCaster.kt | 82 +++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index 9529ef6418..feef14d78f 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -20,13 +20,18 @@ */ package com.vitorpamplona.amethyst.service.cast.chromecast +import android.Manifest import android.content.Context +import android.content.pm.PackageManager +import android.os.Build import android.os.Handler import android.os.Looper +import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.CastStatusCodes import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaLoadRequestData import com.google.android.gms.cast.MediaMetadata @@ -55,6 +60,9 @@ private const val TAG = "ChromecastCaster" private const val SESSION_START_TIMEOUT_MS = 30_000L private const val STOP_AWAIT_TIMEOUT_MS = 5_000L +/** Android 17 — the first release to enforce Local Network Protection. See [ChromecastCaster.localNetworkState]. */ +private const val LOCAL_NETWORK_PROTECTION_SDK = 37 + /** * Google Cast (Chromecast) caster. * @@ -123,7 +131,7 @@ class ChromecastCaster( override fun onStatusUpdated() { val client = currentMediaClient ?: return val status = client.mediaStatus - Log.d(TAG) { + Log.i(TAG) { "media.onStatusUpdated playerState=${playerStateName(client.playerState)} " + "idleReason=${idleReasonName(status?.idleReason ?: -1)} " + "pos=${client.approximateStreamPosition}/${client.streamDuration}ms" @@ -155,6 +163,14 @@ class ChromecastCaster( currentMediaClient = null } + /** + * Cast surfaces failures as bare ints spread across several unrelated ranges (CommonStatusCodes, + * CastStatusCodes, and internal codes documented nowhere). [CastStatusCodes.getStatusCodeString] + * is the SDK's own lookup, so it decodes far more than the public constants do — keep the raw + * number alongside it for the ones it doesn't recognise either. + */ + private fun statusName(code: Int): String = "$code(${CastStatusCodes.getStatusCodeString(code)})" + private fun playerStateName(state: Int): String = when (state) { MediaStatus.PLAYER_STATE_IDLE -> "IDLE" @@ -185,7 +201,7 @@ class ChromecastCaster( session: CastSession, sessionId: String, ) { - Log.d(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" } + Log.i(TAG) { "session.onStarted id=$sessionId connected=${session.isConnected} hasClient=${session.remoteMediaClient != null}" } attachMediaClientCallback(session) pendingSessionStart?.complete(true) pendingSessionStart = null @@ -195,7 +211,7 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.w(TAG) { "session.onStartFailed error=$error" } + Log.w(TAG) { "session.onStartFailed error=${statusName(error)}" } pendingSessionStart?.complete(false) pendingSessionStart = null sessionFlow.value = CastSessionState.Error(currentDevice(), "Cast session failed (code $error)") @@ -209,7 +225,7 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.d(TAG) { "session.onEnded error=$error" } + Log.i(TAG) { "session.onEnded error=${statusName(error)}" } // If a cast() was awaiting a session start, this is also a terminal // outcome — the session never reached a usable state. Without // completing here the cast coroutine hangs and the discovery @@ -242,7 +258,7 @@ class ChromecastCaster( session: CastSession, error: Int, ) { - Log.w(TAG) { "session.onResumeFailed error=$error" } + Log.w(TAG) { "session.onResumeFailed error=${statusName(error)}" } pendingSessionStart?.complete(false) pendingSessionStart = null } @@ -275,7 +291,7 @@ class ChromecastCaster( val gms = GoogleApiAvailability.getInstance() val status = gms.isGooglePlayServicesAvailable(appContext) if (status != ConnectionResult.SUCCESS) { - Log.d(TAG) { "Google Play services unavailable (status=$status); Chromecast disabled." } + Log.w(TAG) { "Google Play services unavailable (status=$status); Chromecast disabled." } return null } return try { @@ -301,15 +317,15 @@ class ChromecastCaster( } fun startDiscovery() { - Log.d(TAG) { "startDiscovery (already registered? $registered)" } + Log.i(TAG) { "startDiscovery (already registered? $registered) ${localNetworkState()}" } main.post { if (registered) { - Log.d(TAG) { "startDiscovery: already registered, no-op" } + Log.i(TAG) { "startDiscovery: already registered, no-op" } return@post } val ctx = ensureCastContext() if (ctx == null) { - Log.d(TAG) { "startDiscovery: CastContext unavailable, aborting" } + Log.w(TAG) { "startDiscovery: CastContext unavailable, aborting" } return@post } val router = MediaRouter.getInstance(appContext) @@ -322,11 +338,25 @@ class ChromecastCaster( mediaRouter = router routeSelector = selector registered = true - Log.d(TAG) { "startDiscovery: registered router callback (sessionListener already attached)" } + Log.i(TAG) { "startDiscovery: registered router callback (sessionListener already attached)" } updateRoutes(router) } } + /** + * Android 17 (API 37) Local Network Protection gates the mDNS/multicast traffic the Cast SDK + * uses for discovery behind [Manifest.permission.ACCESS_LOCAL_NETWORK]. When it is denied the + * SDK reports no error at all — the picker simply stays empty forever — so the grant state is + * the single most important thing a discovery log can tell us apart from the route count. + */ + private fun localNetworkState(): String { + if (Build.VERSION.SDK_INT < LOCAL_NETWORK_PROTECTION_SDK) return "lnp=n/a(sdk${Build.VERSION.SDK_INT})" + val granted = + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_LOCAL_NETWORK) == + PackageManager.PERMISSION_GRANTED + return "lnp=sdk${Build.VERSION.SDK_INT} ACCESS_LOCAL_NETWORK=${if (granted) "GRANTED" else "DENIED"}" + } + fun stopDiscovery() { Log.d(TAG) { "stopDiscovery (registered=$registered)" } main.post { @@ -357,7 +387,11 @@ class ChromecastCaster( name = route.name, ) } - Log.d(TAG) { "updateRoutes: count=${list.size} -> [${list.joinToString { it.name }}]" } + // Log the unfiltered router total alongside the kept count: an empty picker with + // seen=0 means discovery itself never saw anything (LNP / Wi-Fi / mDNS), whereas + // seen>0 with count=0 means the routes exist but none advertises the Cast control + // category — two completely different faults that look identical from the UI. + Log.i(TAG) { "updateRoutes: seen=${router.routes.size} kept=${list.size} -> [${list.joinToString { it.name }}]" } devicesFlow.value = list } @@ -365,7 +399,7 @@ class ChromecastCaster( device: CastDevice, request: CastRequest, ) { - Log.d(TAG) { "cast device=${device.name} url=${request.url}" } + Log.i(TAG) { "cast device=${device.name} url=${request.url}" } val ctx = withContext(Dispatchers.Main) { ensureCastContext() } if (ctx == null) { Log.w(TAG, "cast: CastContext unavailable") @@ -438,8 +472,28 @@ class ChromecastCaster( false } else { try { - client.load(buildLoadRequest(request)) - Log.d(TAG) { "cast: load() submitted (status=${client.playerState})" } + val loadRequest = buildLoadRequest(request) + val info = loadRequest.mediaInfo + Log.i(TAG) { + "cast: load() submitting contentType=${info?.contentType} " + + "streamType=${info?.streamType} url=${info?.contentId}" + } + // load() returns a PendingResult carrying the receiver's verdict. Dropping it + // (as this used to) makes a refused load indistinguishable from a successful + // one: the coroutine reports Casting, the TV sits on its splash screen, and + // nothing anywhere records why. This callback is the only place the receiver + // ever tells us what it disliked about the media. + client.load(loadRequest).setResultCallback { result -> + val status = result.status + if (status.isSuccess) { + Log.i(TAG) { "cast: load() accepted by receiver" } + } else { + Log.w(TAG) { + "cast: load() REFUSED by receiver code=${statusName(status.statusCode)} " + + "msg=${status.statusMessage}" + } + } + } true } catch (t: Throwable) { Log.w(TAG, "cast: remoteMediaClient.load failed", t) From 61d7800391b51198e2e0d56b5ada3f16ee457d02 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 19:34:11 +0200 Subject: [PATCH 15/32] Manual testing fixes fix(cast): stop a receiver switch from cancelling its own cast fix(cast): tell the receiver when a stream is live --- .../amethyst/service/cast/CastDevice.kt | 22 +++++ .../amethyst/service/cast/CastRoutePlan.kt | 65 +++++++++++++++ .../composable/controls/RenderTopButtons.kt | 9 +++ .../cast/chromecast/ChromecastCaster.kt | 57 +++++++++++-- .../amethyst/service/cast/CastLivenessTest.kt | 53 ++++++++++++ .../service/cast/CastRoutePlanTest.kt | 80 +++++++++++++++++++ 6 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlan.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastLivenessTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlanTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt index 670d34d339..99d18c6067 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -34,8 +34,30 @@ data class CastRequest( val mimeType: String? = null, val title: String? = null, val artworkUri: String? = null, + /** + * Whether the receiver should treat this as an endless live stream rather than a seekable + * recording. Resolve it with [resolveCastLiveness] — never from the URL, which cannot tell a + * live `.m3u8` from an on-demand one. + */ + val isLive: Boolean = false, ) +/** + * Decides what to tell the Cast receiver about a stream's liveness. + * + * [learned] is ExoPlayer's verdict for this URL (see HlsLivenessCache), recorded once it has parsed + * the playlist — the only signal that actually distinguishes a live `.m3u8` from an on-demand one. + * It wins whenever we have it. [metadataFlag] is the kind:30311 live-activity flag, which is right + * when set but absent for a live stream shared in a plain kind:1 note, so it is only the fallback. + * + * Defaulting to non-live when we know nothing keeps the previous behaviour for the common case + * (progressive MP4), where a live claim would cost the receiver its seek bar and duration. + */ +fun resolveCastLiveness( + learned: Boolean?, + metadataFlag: Boolean, +): Boolean = learned ?: metadataFlag + // Critical for HLS: sending an `.m3u8` URL with `video/mp4` makes the default // Cast receiver try to demux a playlist as MP4 and crash, wiping the TV's Cast // service from the network in the process. Strip query/fragment first so diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlan.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlan.kt new file mode 100644 index 0000000000..ba6a0a9847 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlan.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cast + +/** + * How a cast request should reach its target receiver, given what is already connected. + * + * @param needsRouteSelection whether MediaRouter has to be asked to move. + * @param expectsPreviousSessionToEnd whether an existing session will be torn down as a *result* of + * that move. The caller must not read that teardown as the new attempt failing. + */ +enum class CastRoutePlan( + val needsRouteSelection: Boolean, + val expectsPreviousSessionToEnd: Boolean, +) { + /** Already connected to this very receiver — load straight onto the live session. */ + REUSE_SESSION(needsRouteSelection = false, expectsPreviousSessionToEnd = false), + + /** Connected to a *different* receiver — selecting the target ends that session first. */ + SWAP_RECEIVER(needsRouteSelection = true, expectsPreviousSessionToEnd = true), + + /** Nothing connected — select the route and wait for the session to start. */ + SELECT_FRESH(needsRouteSelection = true, expectsPreviousSessionToEnd = false), +} + +/** + * Chooses the [CastRoutePlan] for a cast to [targetRouteId]. + * + * The distinction that matters is between reusing a session and swapping receivers. Treating any + * connected session as reusable — as this used to — means a session on the soundbar is taken as + * proof that a cast to the TV is already connected: the start completes instantly, `selectRoute()` + * then tears that session down, and by the time the media is loaded the session is gone, so the + * load is skipped and the TV sits on its splash screen having "connected" successfully. + * + * [hasConnectedSession] must reflect a session that is actually connected; a stale or suspended one + * cannot take a load and has to go through a fresh start. + */ +fun planRouteSelection( + targetRouteId: String, + selectedRouteId: String?, + hasConnectedSession: Boolean, +): CastRoutePlan = + when { + !hasConnectedSession -> CastRoutePlan.SELECT_FRESH + selectedRouteId == targetRouteId -> CastRoutePlan.REUSE_SESSION + else -> CastRoutePlan.SWAP_RECEIVER + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index db06db131e..0e63c53ff3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -74,10 +74,12 @@ import com.vitorpamplona.amethyst.model.VideoButtonLocation import com.vitorpamplona.amethyst.model.VideoPlayerAction import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState +import com.vitorpamplona.amethyst.service.cast.resolveCastLiveness import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia +import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity import com.vitorpamplona.amethyst.ui.cast.CastDevicePickerDialog import com.vitorpamplona.amethyst.ui.cast.rememberCastWithLocalNetworkPermission @@ -501,6 +503,13 @@ fun RenderTopButtons( mimeType = mediaData.mimeType, title = mediaData.title, artworkUri = mediaData.artworkUri, + // By the time the cast button is reachable the player has already parsed the + // playlist, so the learned verdict is normally available here. + isLive = + resolveCastLiveness( + learned = HlsLivenessCache.verdict(mediaData.videoUri), + metadataFlag = mediaData.isLiveStream, + ), ), onDismiss = { castDialogVisible.value = false }, ) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index feef14d78f..18f99565a2 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -45,8 +45,10 @@ import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.images.WebImage import com.vitorpamplona.amethyst.service.cast.CastDevice import com.vitorpamplona.amethyst.service.cast.CastRequest +import com.vitorpamplona.amethyst.service.cast.CastRoutePlan import com.vitorpamplona.amethyst.service.cast.CastSessionState import com.vitorpamplona.amethyst.service.cast.effectiveMimeType +import com.vitorpamplona.amethyst.service.cast.planRouteSelection import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -225,12 +227,21 @@ class ChromecastCaster( session: CastSession, error: Int, ) { + detachMediaClientCallback() + // Moving to a different receiver ends the outgoing session by design, and that + // callback lands *after* cast() has installed the pending start for the incoming + // one. Failing it here is what made every device-to-device switch report + // "session start refused" and skip the load. + if (expectingSessionSwapEnd) { + expectingSessionSwapEnd = false + Log.i(TAG) { "session.onEnded error=${statusName(error)} — expected teardown while switching receivers" } + return + } Log.i(TAG) { "session.onEnded error=${statusName(error)}" } // If a cast() was awaiting a session start, this is also a terminal // outcome — the session never reached a usable state. Without // completing here the cast coroutine hangs and the discovery // ref-count leaks +1 for every failed attempt. - detachMediaClientCallback() pendingSessionStart?.complete(false) pendingSessionStart = null sessionFlow.value = CastSessionState.Idle @@ -279,6 +290,15 @@ class ChromecastCaster( @Volatile private var pendingSessionStart: CompletableDeferred? = null + /** + * Armed while a [CastRoutePlan.SWAP_RECEIVER] is in flight — between asking MediaRouter to move + * to a different receiver and the outgoing session's teardown callback. That teardown is the + * expected consequence of the move, not the new attempt failing, and must not complete the + * pending start. Cleared as soon as the attempt resolves, so a later genuine end still counts. + */ + @Volatile + private var expectingSessionSwapEnd = false + private fun currentDevice(): CastDevice? = when (val s = sessionFlow.value) { is CastSessionState.Connecting -> s.device @@ -424,21 +444,34 @@ class ChromecastCaster( Log.d(TAG) { "cast: existing session connected=${existing?.isConnected} hasClient=${existing?.remoteMediaClient != null}" } + val plan = + planRouteSelection( + targetRouteId = route.id, + selectedRouteId = mediaRouter?.selectedRoute?.id, + hasConnectedSession = existing?.isConnected == true, + ) + Log.i(TAG) { "cast: plan=$plan target=${route.id} selected=${mediaRouter?.selectedRoute?.id}" } + val pending = CompletableDeferred() // If a previous cast() is still awaiting a callback, fail it // before swapping in our deferred — otherwise the earlier call // hangs to the 30s timeout. pendingSessionStart?.complete(false) pendingSessionStart = pending + // Arm this before selectRoute, not after: the teardown callback for the outgoing + // session can arrive on the very next main-thread tick. + expectingSessionSwapEnd = plan.expectsPreviousSessionToEnd try { - Log.d(TAG) { "cast: selectRoute id=${route.id}" } - mediaRouter?.selectRoute(route) - if (existing?.isConnected == true) { - Log.d(TAG) { "cast: reusing already-connected session, completing immediately" } + if (plan.needsRouteSelection) { + Log.i(TAG) { "cast: selectRoute id=${route.id}" } + mediaRouter?.selectRoute(route) + } else { + Log.i(TAG) { "cast: reusing the session already connected to this receiver" } pending.complete(true) } } catch (t: Throwable) { Log.w(TAG, "cast: selectRoute threw", t) + expectingSessionSwapEnd = false pending.complete(false) } // Defence in depth: if a callback is somehow missed (SDK bug, @@ -451,6 +484,9 @@ class ChromecastCaster( Log.w(TAG) { "cast: session start timed out after ${SESSION_START_TIMEOUT_MS}ms" } pendingSessionStart = null } + // However this attempt ended, the swap it was waiting on is over. Leaving the flag + // armed would make the *next* genuine session end get swallowed as an expected one. + expectingSessionSwapEnd = false outcome ?: false } @@ -516,10 +552,19 @@ class ChromecastCaster( request.artworkUri?.let { runCatching { metadata.addImage(WebImage(it.toUri())) } } + // A live HLS playlist has no #EXT-X-ENDLIST and no duration. Declaring it BUFFERED asks the + // receiver for a seekable stream of known length, which it cannot resolve — the LG webOS + // receiver sits in LOADING forever rather than reporting an error. + val streamType = + if (request.isLive) { + MediaInfo.STREAM_TYPE_LIVE + } else { + MediaInfo.STREAM_TYPE_BUFFERED + } val info = MediaInfo .Builder(request.url) - .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setStreamType(streamType) .setContentType(request.effectiveMimeType()) .setMetadata(metadata) .build() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastLivenessTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastLivenessTest.kt new file mode 100644 index 0000000000..7dfad36d1f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastLivenessTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cast + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CastLivenessTest { + @Test + fun playerVerdictWinsOverMetadataWhenItSaysLive() { + // A live .m3u8 shared in a plain kind:1 note carries no live-activity flag, so only + // ExoPlayer's parsed verdict can save it from being cast as a seekable recording. + assertTrue(resolveCastLiveness(learned = true, metadataFlag = false)) + } + + @Test + fun playerVerdictWinsOverMetadataWhenItSaysOnDemand() { + // A kind:30311 recording stays flagged live long after the broadcast ended; the parsed + // playlist is the ground truth and must override it. + assertFalse(resolveCastLiveness(learned = false, metadataFlag = true)) + } + + @Test + fun fallsBackToMetadataWhileTheUrlIsStillUnclassified() { + assertTrue(resolveCastLiveness(learned = null, metadataFlag = true)) + assertFalse(resolveCastLiveness(learned = null, metadataFlag = false)) + } + + @Test + fun progressiveMediaStaysBufferedWhenNothingIsKnown() { + // The common case: an MP4 with no verdict and no flag must keep its seek bar. + assertFalse(resolveCastLiveness(learned = null, metadataFlag = false)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlanTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlanTest.kt new file mode 100644 index 0000000000..51e92b8651 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastRoutePlanTest.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cast + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CastRoutePlanTest { + private val tv = "route-tv" + private val soundbar = "route-soundbar" + + @Test + fun reusesTheSessionOnlyWhenItBelongsToTheTargetRoute() { + assertEquals( + CastRoutePlan.REUSE_SESSION, + planRouteSelection(targetRouteId = tv, selectedRouteId = tv, hasConnectedSession = true), + ) + } + + @Test + fun switchingReceiversIsASwapNotAReuse() { + // The regression: a session connected to the soundbar was treated as reusable for the TV. + // cast() completed its start immediately, selectRoute() then tore that session down, and the + // load was skipped against a dead session — the TV connected and showed nothing. + assertEquals( + CastRoutePlan.SWAP_RECEIVER, + planRouteSelection(targetRouteId = tv, selectedRouteId = soundbar, hasConnectedSession = true), + ) + } + + @Test + fun aDisconnectedSessionOnTheTargetRouteStillNeedsAFreshStart() { + assertEquals( + CastRoutePlan.SELECT_FRESH, + planRouteSelection(targetRouteId = tv, selectedRouteId = tv, hasConnectedSession = false), + ) + } + + @Test + fun theFirstCastOfTheSessionSelectsFresh() { + assertEquals( + CastRoutePlan.SELECT_FRESH, + planRouteSelection(targetRouteId = tv, selectedRouteId = null, hasConnectedSession = false), + ) + } + + @Test + fun onlyASwapExpectsTheOldSessionToEnd() { + // This is what stops the outgoing session's onSessionEnded from failing the incoming + // attempt, which is why switching devices used to report "session start refused". + assertEquals(true, CastRoutePlan.SWAP_RECEIVER.expectsPreviousSessionToEnd) + assertEquals(false, CastRoutePlan.SELECT_FRESH.expectsPreviousSessionToEnd) + assertEquals(false, CastRoutePlan.REUSE_SESSION.expectsPreviousSessionToEnd) + } + + @Test + fun onlyReuseSkipsTheRouteSelection() { + assertEquals(false, CastRoutePlan.REUSE_SESSION.needsRouteSelection) + assertEquals(true, CastRoutePlan.SWAP_RECEIVER.needsRouteSelection) + assertEquals(true, CastRoutePlan.SELECT_FRESH.needsRouteSelection) + } +} From 2dc540294820a807b8bee6ce00ba76c2ca13fc2b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 20:33:43 +0200 Subject: [PATCH 16/32] New features feat(cast): time out a load the receiver silently abandons feat(cast): tell the user when the receiver refuses a video --- .../amethyst/service/cast/CastDevice.kt | 24 +++- .../ui/cast/CastDevicePickerDialog.kt | 14 +- .../cast/chromecast/ChromecastCaster.kt | 121 ++++++++++++++++-- .../composeResources/values/strings.xml | 4 + 4 files changed, 150 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt index 99d18c6067..a9f1372860 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.cast import androidx.compose.runtime.Immutable +import org.jetbrains.compose.resources.StringResource @Immutable data class CastDevice( @@ -88,6 +89,27 @@ sealed class CastSessionState { data class Error( val device: CastDevice?, - val message: String, + val message: CastErrorMessage, ) : CastSessionState() } + +/** + * What went wrong, in a form the picker resolves in composition. The caster reports failures from + * Cast SDK callbacks, where there is no blocking way to read a Compose resource, so it hands over + * the resource and the UI formats it. + */ +@Immutable +sealed interface CastErrorMessage { + /** Not yet localized. */ + data class Raw( + val text: String, + ) : CastErrorMessage + + /** + * A `%1$s`-shaped message naming the receiver — the picker supplies the [CastSessionState.Error] + * device's name, or a generic noun when the device is unknown. + */ + data class Localized( + val text: StringResource, + ) : CastErrorMessage +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt index be65b69133..727ed86cf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -31,12 +31,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cast_generic_receiver import com.vitorpamplona.amethyst.commons.resources.cast_searching_for_devices import com.vitorpamplona.amethyst.commons.resources.cast_to_device_dialog_title import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.service.cast.CastErrorMessage import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState @@ -105,9 +107,10 @@ fun CastDevicePickerDialog( } } - if (sessionState is CastSessionState.Error) { + val error = sessionState as? CastSessionState.Error + if (error != null) { Text( - text = (sessionState as CastSessionState.Error).message, + text = castErrorText(error), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), @@ -115,3 +118,10 @@ fun CastDevicePickerDialog( } } } + +@Composable +private fun castErrorText(error: CastSessionState.Error): String = + when (val message = error.message) { + is CastErrorMessage.Raw -> message.text + is CastErrorMessage.Localized -> stringRes(message.text, error.device?.name ?: stringRes(Res.string.cast_generic_receiver)) + } diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index 18f99565a2..dbd0f80339 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -32,6 +32,7 @@ import androidx.mediarouter.media.MediaRouteSelector import androidx.mediarouter.media.MediaRouter import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.CastStatusCodes +import com.google.android.gms.cast.MediaError import com.google.android.gms.cast.MediaInfo import com.google.android.gms.cast.MediaLoadRequestData import com.google.android.gms.cast.MediaMetadata @@ -43,7 +44,12 @@ import com.google.android.gms.cast.framework.media.RemoteMediaClient import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.images.WebImage +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed +import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding +import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media import com.vitorpamplona.amethyst.service.cast.CastDevice +import com.vitorpamplona.amethyst.service.cast.CastErrorMessage import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastRoutePlan import com.vitorpamplona.amethyst.service.cast.CastSessionState @@ -62,6 +68,16 @@ private const val TAG = "ChromecastCaster" private const val SESSION_START_TIMEOUT_MS = 30_000L private const val STOP_AWAIT_TIMEOUT_MS = 5_000L +/** + * How long the receiver gets to move off LOADING before we call it stalled. + * + * A healthy receiver takes ~1s. A wedged one — the state an LG webOS TV lands in after its Cast + * service crashes, cleared only by power-cycling the TV — accepts the load, reports LOADING, and + * then reports nothing ever again: no progress, no error, no session end. Generous by design, since + * overshooting only delays an error message while undershooting aborts a slow but working load. + */ +private const val LOAD_PROGRESS_TIMEOUT_MS = 20_000L + /** Android 17 — the first release to enforce Local Network Protection. See [ChromecastCaster.localNetworkState]. */ private const val LOCAL_NETWORK_PROTECTION_SDK = 37 @@ -133,15 +149,26 @@ class ChromecastCaster( override fun onStatusUpdated() { val client = currentMediaClient ?: return val status = client.mediaStatus + val idleReason = status?.idleReason ?: -1 Log.i(TAG) { "media.onStatusUpdated playerState=${playerStateName(client.playerState)} " + - "idleReason=${idleReasonName(status?.idleReason ?: -1)} " + + "idleReason=${idleReasonName(idleReason)} " + "pos=${client.approximateStreamPosition}/${client.streamDuration}ms" } + // Any real progress means the receiver is alive and the watchdog has done its job. + if (client.playerState == MediaStatus.PLAYER_STATE_PLAYING || client.playerState == MediaStatus.PLAYER_STATE_BUFFERING) { + cancelLoadWatchdog() + } + // The receiver can also fail without ever calling onMediaError — dropping to IDLE + // with an ERROR reason is the terminal signal in that case. + if (client.playerState == MediaStatus.PLAYER_STATE_IDLE && idleReason == MediaStatus.IDLE_REASON_ERROR) { + reportMediaFailure(null) + } } - override fun onMediaError(mediaError: com.google.android.gms.cast.MediaError) { + override fun onMediaError(mediaError: MediaError) { Log.w(TAG) { "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} type=${mediaError.type}" } + reportMediaFailure(mediaError.detailedErrorCode) } } @@ -216,7 +243,7 @@ class ChromecastCaster( Log.w(TAG) { "session.onStartFailed error=${statusName(error)}" } pendingSessionStart?.complete(false) pendingSessionStart = null - sessionFlow.value = CastSessionState.Error(currentDevice(), "Cast session failed (code $error)") + sessionFlow.value = CastSessionState.Error(currentDevice(), CastErrorMessage.Raw("Cast session failed (code $error)")) } override fun onSessionEnding(session: CastSession) { @@ -238,6 +265,7 @@ class ChromecastCaster( return } Log.i(TAG) { "session.onEnded error=${statusName(error)}" } + cancelLoadWatchdog() // If a cast() was awaiting a session start, this is also a terminal // outcome — the session never reached a usable state. Without // completing here the cast coroutine hangs and the discovery @@ -299,6 +327,68 @@ class ChromecastCaster( @Volatile private var expectingSessionSwapEnd = false + /** + * Fires when the receiver accepted a load and then went quiet — see [LOAD_PROGRESS_TIMEOUT_MS]. + * Nothing else covers this: the media callbacks only speak when the receiver does, and the + * session is still perfectly connected, so without this the picker claims to be casting forever + * while the device sits on its splash screen. + */ + private val loadWatchdog = + Runnable { + val state = currentMediaClient?.playerState + val progressed = state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_BUFFERING + if (progressed || sessionFlow.value is CastSessionState.Error) return@Runnable + + val device = watchedDevice ?: currentDevice() + Log.w(TAG) { + "load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}" + } + sessionFlow.value = + CastSessionState.Error(device, CastErrorMessage.Localized(Res.string.cast_error_receiver_not_responding)) + watchedDevice = null + } + + /** The device a load is currently being watched for, so the message can name it. */ + @Volatile + private var watchedDevice: CastDevice? = null + + private fun armLoadWatchdog(device: CastDevice) { + main.removeCallbacks(loadWatchdog) + watchedDevice = device + main.postDelayed(loadWatchdog, LOAD_PROGRESS_TIMEOUT_MS) + } + + private fun cancelLoadWatchdog() { + main.removeCallbacks(loadWatchdog) + watchedDevice = null + } + + /** + * Turns a receiver-side playback failure into a [CastSessionState.Error] the picker can show. + * + * Without this the UI stays on [CastSessionState.Casting] — set the moment `load()` is + * submitted — while the receiver has already given up, so a rejected video looks exactly like a + * working one: the device sits on its splash screen and nothing ever explains why. + * + * The first report wins. A failure usually arrives twice (onMediaError, then IDLE/ERROR) and the + * earlier one carries the detailed code, so it is the more specific of the two. + */ + private fun reportMediaFailure(detailedErrorCode: Int?) { + cancelLoadWatchdog() + if (sessionFlow.value is CastSessionState.Error) return + val device = currentDevice() + val message = + when (detailedErrorCode) { + MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED, + MediaError.DetailedErrorCode.MEDIA_DECODE, + -> + Res.string.cast_error_unsupported_media + else -> Res.string.cast_error_playback_failed + } + Log.w(TAG) { "media failure surfaced to UI: code=$detailedErrorCode device=${device?.name}" } + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Localized(message)) + } + private fun currentDevice(): CastDevice? = when (val s = sessionFlow.value) { is CastSessionState.Connecting -> s.device @@ -423,7 +513,7 @@ class ChromecastCaster( val ctx = withContext(Dispatchers.Main) { ensureCastContext() } if (ctx == null) { Log.w(TAG, "cast: CastContext unavailable") - sessionFlow.value = CastSessionState.Error(device, "Google Play services unavailable") + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Google Play services unavailable")) return } val route = @@ -432,7 +522,7 @@ class ChromecastCaster( } if (route == null) { Log.w(TAG) { "cast: route ${device.id} not in current set; offline?" } - sessionFlow.value = CastSessionState.Error(device, "Device went offline") + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Device went offline")) return } @@ -492,7 +582,7 @@ class ChromecastCaster( if (!started) { Log.w(TAG, "cast: session start refused") - sessionFlow.value = CastSessionState.Error(device, "Cast session refused") + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Cast session refused")) return } @@ -519,6 +609,7 @@ class ChromecastCaster( // one: the coroutine reports Casting, the TV sits on its splash screen, and // nothing anywhere records why. This callback is the only place the receiver // ever tells us what it disliked about the media. + armLoadWatchdog(device) client.load(loadRequest).setResultCallback { result -> val status = result.status if (status.isSuccess) { @@ -528,21 +619,30 @@ class ChromecastCaster( "cast: load() REFUSED by receiver code=${statusName(status.statusCode)} " + "msg=${status.statusMessage}" } + // The receiver answered, so the watchdog is moot — but nothing else + // turns a refusal into something the user can read. + reportMediaFailure(null) } } true } catch (t: Throwable) { + cancelLoadWatchdog() Log.w(TAG, "cast: remoteMediaClient.load failed", t) false } } } + // A receiver can reject the media before this coroutine gets here — onMediaError has been + // seen ~150ms after load(). This attempt set Connecting on entry, so any Error sitting here + // now came from its own callbacks and carries the receiver's reason; don't paper over it + // with a Casting state that claims a video is playing when it already failed. + val reportedFailure = sessionFlow.value as? CastSessionState.Error sessionFlow.value = - if (ok) { - CastSessionState.Casting(device, request) - } else { - CastSessionState.Error(device, "Could not load media on receiver") + when { + reportedFailure != null -> reportedFailure + ok -> CastSessionState.Casting(device, request) + else -> CastSessionState.Error(device, CastErrorMessage.Raw("Could not load media on receiver")) } } @@ -577,6 +677,7 @@ class ChromecastCaster( suspend fun stopCasting() { Log.d(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" } + withContext(Dispatchers.Main) { cancelLoadWatchdog() } // Await MEDIA_STOP before endCurrentSession() — racing them on the // same main-thread tick loses the stop on some receivers (LG webOS). val client = currentMediaClient diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 3ef72ba264..88b63a2a5d 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2697,6 +2697,10 @@ Permission needed Amethyst needs local network access to find Cast devices on your Wi-Fi. Please enable it in the app settings. Open settings + The cast device + %1$s can't play this video's format + %1$s couldn't play this video + %1$s stopped responding. Restarting the device usually fixes it. Pick a video Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. Change From 68f59acfceb2228f8ac00c4ab8cd7b3c67a176a7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 21:37:46 +0200 Subject: [PATCH 17/32] Code review fixes fix(cast): don't let two teardowns run at once fix(cast): let the session end before unselecting the route fix(cast): stop the receiver app instead of parking it on its splash screen fix(cast): replace raw SDK error codes in the picker with usable messages --- .../amethyst/service/cast/CastDevice.kt | 21 ++-- .../ui/cast/CastDevicePickerDialog.kt | 7 +- .../cast/chromecast/ChromecastCaster.kt | 110 +++++++++++++++--- .../composeResources/values/strings.xml | 4 + 4 files changed, 107 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt index a9f1372860..fffadcc5b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -97,19 +97,12 @@ sealed class CastSessionState { * What went wrong, in a form the picker resolves in composition. The caster reports failures from * Cast SDK callbacks, where there is no blocking way to read a Compose resource, so it hands over * the resource and the UI formats it. + * + * [text] is `%1$s`-shaped, naming the receiver: the picker supplies the [CastSessionState.Error] + * device's name, or a generic noun when the failure happens before or after we know which device + * it was. */ @Immutable -sealed interface CastErrorMessage { - /** Not yet localized. */ - data class Raw( - val text: String, - ) : CastErrorMessage - - /** - * A `%1$s`-shaped message naming the receiver — the picker supplies the [CastSessionState.Error] - * device's name, or a generic noun when the device is unknown. - */ - data class Localized( - val text: StringResource, - ) : CastErrorMessage -} +data class CastErrorMessage( + val text: StringResource, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt index 727ed86cf1..671b3b9f7f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -38,7 +38,6 @@ import com.vitorpamplona.amethyst.commons.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.commons.ui.components.M3ActionRow import com.vitorpamplona.amethyst.commons.ui.components.M3ActionSection import com.vitorpamplona.amethyst.commons.ui.stringRes -import com.vitorpamplona.amethyst.service.cast.CastErrorMessage import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.cast.CastRequest import com.vitorpamplona.amethyst.service.cast.CastSessionState @@ -120,8 +119,4 @@ fun CastDevicePickerDialog( } @Composable -private fun castErrorText(error: CastSessionState.Error): String = - when (val message = error.message) { - is CastErrorMessage.Raw -> message.text - is CastErrorMessage.Localized -> stringRes(message.text, error.device?.name ?: stringRes(Res.string.cast_generic_receiver)) - } +private fun castErrorText(error: CastSessionState.Error): String = stringRes(error.message.text, error.device?.name ?: stringRes(Res.string.cast_generic_receiver)) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index dbd0f80339..f9c082b772 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -45,6 +45,10 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.images.WebImage import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cast_error_connect_failed +import com.vitorpamplona.amethyst.commons.resources.cast_error_device_offline +import com.vitorpamplona.amethyst.commons.resources.cast_error_load_failed +import com.vitorpamplona.amethyst.commons.resources.cast_error_play_services_unavailable import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media @@ -61,6 +65,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -68,6 +73,16 @@ private const val TAG = "ChromecastCaster" private const val SESSION_START_TIMEOUT_MS = 30_000L private const val STOP_AWAIT_TIMEOUT_MS = 5_000L +/** + * How long to wait for the session to actually end after asking the receiver app to stop. + * + * Unselecting the route drops the connection, so doing it before the receiver has processed + * STOP_APP leaves the app running and the TV parked on the Default Media Receiver splash — the + * "default renderer" screen the user has to leave with the TV remote. Observed at ~25-170ms on a + * webOS TV, longer the more playback there was to flush, so this is generous. + */ +private const val SESSION_END_TIMEOUT_MS = 5_000L + /** * How long the receiver gets to move off LOADING before we call it stalled. * @@ -241,9 +256,21 @@ class ChromecastCaster( error: Int, ) { Log.w(TAG) { "session.onStartFailed error=${statusName(error)}" } + cancelLoadWatchdog() pendingSessionStart?.complete(false) pendingSessionStart = null - sessionFlow.value = CastSessionState.Error(currentDevice(), CastErrorMessage.Raw("Cast session failed (code $error)")) + if (error == CastStatusCodes.CANCELED) { + // The user backed out of the connection; that is not a failure to report. + sessionFlow.value = CastSessionState.Idle + return + } + // The raw SDK integer belongs in the log, not in front of the user. Every code that + // reaches here means the same thing to them — the device would not accept a + // connection — and on a webOS TV whose Cast service has died (2252, seen repeatedly + // once it wedges) restarting it is genuinely the fix. + val device = currentDevice() + sessionFlow.value = + CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_connect_failed)) } override fun onSessionEnding(session: CastSession) { @@ -255,6 +282,9 @@ class ChromecastCaster( error: Int, ) { detachMediaClientCallback() + // Whoever is tearing down gets told first, before any of the swap/failure handling + // below decides to return early — stopCasting() is blocked on this. + pendingSessionEnd?.complete(Unit) // Moving to a different receiver ends the outgoing session by design, and that // callback lands *after* cast() has installed the pending start for the incoming // one. Failing it here is what made every device-to-device switch report @@ -344,7 +374,7 @@ class ChromecastCaster( "load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}" } sessionFlow.value = - CastSessionState.Error(device, CastErrorMessage.Localized(Res.string.cast_error_receiver_not_responding)) + CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_receiver_not_responding)) watchedDevice = null } @@ -352,6 +382,10 @@ class ChromecastCaster( @Volatile private var watchedDevice: CastDevice? = null + /** Set while [stopCasting] waits for the receiver app to actually go away. */ + @Volatile + private var pendingSessionEnd: CompletableDeferred? = null + private fun armLoadWatchdog(device: CastDevice) { main.removeCallbacks(loadWatchdog) watchedDevice = device @@ -386,7 +420,7 @@ class ChromecastCaster( else -> Res.string.cast_error_playback_failed } Log.w(TAG) { "media failure surfaced to UI: code=$detailedErrorCode device=${device?.name}" } - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Localized(message)) + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message)) } private fun currentDevice(): CastDevice? = @@ -513,7 +547,8 @@ class ChromecastCaster( val ctx = withContext(Dispatchers.Main) { ensureCastContext() } if (ctx == null) { Log.w(TAG, "cast: CastContext unavailable") - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Google Play services unavailable")) + sessionFlow.value = + CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_play_services_unavailable)) return } val route = @@ -522,7 +557,7 @@ class ChromecastCaster( } if (route == null) { Log.w(TAG) { "cast: route ${device.id} not in current set; offline?" } - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Device went offline")) + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_device_offline)) return } @@ -582,7 +617,10 @@ class ChromecastCaster( if (!started) { Log.w(TAG, "cast: session start refused") - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage.Raw("Cast session refused")) + // onSessionStartFailed usually got here first with a better-informed message; keep it. + sessionFlow.value = + sessionFlow.value as? CastSessionState.Error + ?: CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_connect_failed)) return } @@ -642,7 +680,7 @@ class ChromecastCaster( when { reportedFailure != null -> reportedFailure ok -> CastSessionState.Casting(device, request) - else -> CastSessionState.Error(device, CastErrorMessage.Raw("Could not load media on receiver")) + else -> CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_load_failed)) } } @@ -675,8 +713,29 @@ class ChromecastCaster( .build() } + /** + * Serialises teardown. A stop against an unresponsive receiver can take seconds to ack, so the + * user reasonably presses stop again — and two overlapping teardowns wreck each other: both + * call `stop()` (the second erroring), both call `endCurrentSession`, and the later one installs + * its session-end wait after `onSessionEnded` has already fired, so it waits out the full + * timeout for an event that will never come again. + */ + private val stopInFlight = Mutex() + suspend fun stopCasting() { - Log.d(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" } + if (!stopInFlight.tryLock()) { + Log.i(TAG) { "stopCasting: a teardown is already running; ignoring the repeat request" } + return + } + try { + stopCastingLocked() + } finally { + stopInFlight.unlock() + } + } + + private suspend fun stopCastingLocked() { + Log.i(TAG) { "stopCasting (hasClient=${currentMediaClient != null})" } withContext(Dispatchers.Main) { cancelLoadWatchdog() } // Await MEDIA_STOP before endCurrentSession() — racing them on the // same main-thread tick loses the stop on some receivers (LG webOS). @@ -687,8 +746,8 @@ class ChromecastCaster( try { client.stop().setResultCallback { result -> val status = result.status - Log.d(TAG) { - "remoteMediaClient.stop ack code=${status.statusCode} msg=${status.statusMessage}" + Log.i(TAG) { + "remoteMediaClient.stop ack code=${statusName(status.statusCode)} msg=${status.statusMessage}" } stopAck.complete(status.statusCode) } @@ -702,16 +761,37 @@ class ChromecastCaster( Log.w(TAG) { "remoteMediaClient.stop did not ack within ${STOP_AWAIT_TIMEOUT_MS}ms" } } } + val ended = CompletableDeferred() withContext(Dispatchers.Main) { + pendingSessionEnd = ended try { - // false: receiver app already halted media via stop() above. - // true previously triggered an extra teardown that compounded - // the race — keep the receiver running on its splash screen - // so the next cast can reuse the connection cleanly. - castContext?.sessionManager?.endCurrentSession(false) + // true: shut the receiver application down, don't just detach from it. + // + // This was false, to "keep the receiver running on its splash screen so the next + // cast can reuse the connection cleanly". That is what strands an LG webOS TV on the + // Default Media Receiver holding screen instead of returning it to its home screen, + // and the reused connection is not clean: the SDK hands the same session id back to + // later casts, and after a few stop/start cycles the receiver stops accepting + // connections at all (every start fails 2252) until the TV is power-cycled. + // + // The race that motivated false is now handled directly — the MEDIA_STOP ack is + // awaited above before we get here, and a receiver swap no longer mistakes the + // outgoing session's teardown for a failure of the incoming one. + Log.i(TAG) { "stopCasting: ending session and stopping the receiver app" } + castContext?.sessionManager?.endCurrentSession(true) } catch (t: Throwable) { Log.w(TAG, "endCurrentSession failed", t) + ended.complete(Unit) } + } + // Wait for the session to be gone before unselecting. Unselecting drops the connection the + // STOP_APP message travels over, so doing it on the same tick — as this used to — can beat + // the message to the TV and leave the receiver running on its splash screen. + if (withTimeoutOrNull(SESSION_END_TIMEOUT_MS) { ended.await() } == null) { + Log.w(TAG) { "stopCasting: session did not end within ${SESSION_END_TIMEOUT_MS}ms; unselecting anyway" } + } + withContext(Dispatchers.Main) { + pendingSessionEnd = null mediaRouter?.unselect(MediaRouter.UNSELECT_REASON_STOPPED) } sessionFlow.value = CastSessionState.Idle diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 88b63a2a5d..8ff7d6e8de 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2701,6 +2701,10 @@ %1$s can't play this video's format %1$s couldn't play this video %1$s stopped responding. Restarting the device usually fixes it. + Couldn't connect to %1$s. Restarting the device usually fixes it. + %1$s went offline + Couldn't load this video on %1$s + Casting needs Google Play services, which aren't available on this device Pick a video Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. Change From 9138745e245e35af00441702a8ae8a01ec7f524b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 22:23:25 +0200 Subject: [PATCH 18/32] feat(cast): say what the video was when the receiver won't play it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A receiver that dislikes a video is close to silent about it: the LG webOS TV accepts the load, reports LOADING, then says nothing at all — no error, no reason. "It didn't play" was the entire diagnosis available, for a file that plays fine on the phone and on the soundbar. The phone has already decoded the same media locally, so describe it from the player's own track format: "H.265 (HEVC) 1920x1080". Codec names are the ones from device spec sheets rather than raw mime types, because recognising "HEVC" is what tells the user why their TV refused it and whether another copy would work. Reported in the failure message and logged with the load request. The summary comes from the highest-resolution track in the group, since that is the one adaptive playback climbs to and therefore the one a receiver with limited codec or resolution support fails on. Also logs MediaError.customData, which can carry the receiver's own explanation and was being discarded. --- .../amethyst/service/cast/CastDevice.kt | 13 +++- .../amethyst/service/cast/CastMediaSummary.kt | 64 ++++++++++++++++++ .../composable/controls/RenderTopButtons.kt | 8 +++ .../controls/VideoQualityControls.kt | 19 ++++++ .../ui/cast/CastDevicePickerDialog.kt | 7 +- .../cast/chromecast/ChromecastCaster.kt | 41 +++++++++--- .../service/cast/CastMediaSummaryTest.kt | 66 +++++++++++++++++++ .../composeResources/values/strings.xml | 3 + 8 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummary.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummaryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt index fffadcc5b9..14bb4ee49b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastDevice.kt @@ -41,6 +41,12 @@ data class CastRequest( * live `.m3u8` from an on-demand one. */ val isLive: Boolean = false, + /** + * What the video is — "H.265 (HEVC) 1920x1080" — taken from the local player, which has already + * decoded it. Reported when a cast fails, because the receiver itself rarely says why. See + * [summarizeCastFormat]. + */ + val formatSummary: String? = null, ) /** @@ -98,11 +104,12 @@ sealed class CastSessionState { * Cast SDK callbacks, where there is no blocking way to read a Compose resource, so it hands over * the resource and the UI formats it. * - * [text] is `%1$s`-shaped, naming the receiver: the picker supplies the [CastSessionState.Error] - * device's name, or a generic noun when the failure happens before or after we know which device - * it was. + * [text] names the receiver as `%1$s`: the picker supplies the [CastSessionState.Error] device's + * name, or a generic noun when the failure happens before or after we know which device it was. + * A `_detail` resource also takes [detail] — what the video was — as `%2$s`. */ @Immutable data class CastErrorMessage( val text: StringResource, + val detail: String? = null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummary.kt new file mode 100644 index 0000000000..5b0a272230 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummary.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cast + +/** + * Short human-readable description of what a video actually is — "H.265 (HEVC) 1920x1080". + * + * A Cast receiver that dislikes a video usually says nothing useful: an LG webOS TV accepts the + * load, reports LOADING, and then goes silent, with no error and no reason. The phone, meanwhile, + * has already decoded the same file locally and knows exactly what it is. Reporting that turns "it + * didn't play" into "it's HEVC", which is the fact that explains the failure and tells the user + * whether another device or another copy would do better. + * + * Codec names are the ones people recognise from device spec sheets rather than the raw mime types, + * because the point of the message is to be recognisable. Anything unrecognised passes through + * unchanged — a mime type we don't have a friendly name for is still better than nothing. + */ +fun summarizeCastFormat( + sampleMimeType: String?, + codecs: String?, + width: Int, + height: Int, +): String? { + val codec = friendlyCodecName(sampleMimeType) ?: codecs?.takeIf { it.isNotBlank() } + val resolution = if (width > 0 && height > 0) "${width}x$height" else null + return when { + codec != null && resolution != null -> "$codec $resolution" + codec != null -> codec + resolution != null -> resolution + else -> null + } +} + +private fun friendlyCodecName(sampleMimeType: String?): String? { + val mime = sampleMimeType?.takeIf { it.isNotBlank() } ?: return null + return when (mime.lowercase()) { + "video/avc" -> "H.264" + "video/hevc", "video/dolby-vision" -> "H.265 (HEVC)" + "video/av01" -> "AV1" + "video/x-vnd.on2.vp9" -> "VP9" + "video/x-vnd.on2.vp8" -> "VP8" + "video/mp4v-es" -> "MPEG-4" + "video/mpeg2" -> "MPEG-2" + else -> mime + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt index 0e63c53ff3..cd0a3d15dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -232,6 +232,7 @@ fun RenderTopButtons( RenderTopButtons( mediaData = mediaData, hasMultipleQualities = hasMultipleQualities, + castFormatSummary = castFormatSummary(videoGroup), qualityButton = { VideoQualityButton( player = player, @@ -297,6 +298,9 @@ fun RenderTopButtons( fun RenderTopButtons( mediaData: MediaItemData, hasMultipleQualities: Boolean, + // What the local player decoded this as. Only the Cast failure messages use it: a receiver that + // refuses a video usually will not say why, so this is the only description of it available. + castFormatSummary: String? = null, qualityButton: @Composable () -> Unit, controllerVisible: MutableState, startingMuteState: Boolean, @@ -510,6 +514,10 @@ fun RenderTopButtons( learned = HlsLivenessCache.verdict(mediaData.videoUri), metadataFlag = mediaData.isLiveStream, ), + // The local player has already decoded this, so it knows what the receiver + // is about to be handed — the only description of the media available when + // the receiver refuses it without saying why. + formatSummary = castFormatSummary, ), onDismiss = { castDialogVisible.value = false }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt index 8acee68d0a..d3030d2f9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/VideoQualityControls.kt @@ -26,10 +26,12 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.media3.common.C +import androidx.media3.common.Format import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi +import com.vitorpamplona.amethyst.service.cast.summarizeCastFormat import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel @@ -37,6 +39,23 @@ import kotlin.math.ceil internal fun getVideoTrackGroup(tracks: Tracks): Tracks.Group? = tracks.groups.firstOrNull { it.type == C.TRACK_TYPE_VIDEO && it.length > 0 } +// Describes the video being played, for the Cast failure messages. Uses the highest-resolution +// track in the group: that is the one an adaptive stream will climb to, so it is the one a +// receiver with limited codec or resolution support will choke on. +@OptIn(UnstableApi::class) +internal fun castFormatSummary(group: Tracks.Group?): String? { + if (group == null) return null + var best: Format? = null + for (i in 0 until group.length) { + val format = group.getTrackFormat(i) + if (best == null || format.width.toLong() * format.height > best!!.width.toLong() * best!!.height) { + best = format + } + } + val format = best ?: return null + return summarizeCastFormat(format.sampleMimeType, format.codecs, format.width, format.height) +} + /** * Constrains adaptive selection to the area the player is actually drawn in. * diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt index 671b3b9f7f..d1cff911b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/cast/CastDevicePickerDialog.kt @@ -119,4 +119,9 @@ fun CastDevicePickerDialog( } @Composable -private fun castErrorText(error: CastSessionState.Error): String = stringRes(error.message.text, error.device?.name ?: stringRes(Res.string.cast_generic_receiver)) +private fun castErrorText(error: CastSessionState.Error): String = + stringRes( + error.message.text, + error.device?.name ?: stringRes(Res.string.cast_generic_receiver), + error.message.detail, + ) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index f9c082b772..b12ebb1d96 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -50,8 +50,11 @@ import com.vitorpamplona.amethyst.commons.resources.cast_error_device_offline import com.vitorpamplona.amethyst.commons.resources.cast_error_load_failed import com.vitorpamplona.amethyst.commons.resources.cast_error_play_services_unavailable import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed +import com.vitorpamplona.amethyst.commons.resources.cast_error_playback_failed_detail import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding +import com.vitorpamplona.amethyst.commons.resources.cast_error_receiver_not_responding_detail import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media +import com.vitorpamplona.amethyst.commons.resources.cast_error_unsupported_media_detail import com.vitorpamplona.amethyst.service.cast.CastDevice import com.vitorpamplona.amethyst.service.cast.CastErrorMessage import com.vitorpamplona.amethyst.service.cast.CastRequest @@ -182,7 +185,10 @@ class ChromecastCaster( } override fun onMediaError(mediaError: MediaError) { - Log.w(TAG) { "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} type=${mediaError.type}" } + Log.w(TAG) { + "media.onMediaError code=${mediaError.detailedErrorCode} reason=${mediaError.reason} " + + "type=${mediaError.type} media=${castingFormat ?: "unknown"} customData=${mediaError.customData}" + } reportMediaFailure(mediaError.detailedErrorCode) } } @@ -373,8 +379,14 @@ class ChromecastCaster( Log.w(TAG) { "load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}" } - sessionFlow.value = - CastSessionState.Error(device, CastErrorMessage(Res.string.cast_error_receiver_not_responding)) + val format = castingFormat + val message = + if (format != null) { + Res.string.cast_error_receiver_not_responding_detail + } else { + Res.string.cast_error_receiver_not_responding + } + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message, format)) watchedDevice = null } @@ -382,6 +394,10 @@ class ChromecastCaster( @Volatile private var watchedDevice: CastDevice? = null + /** What the in-flight cast is, so a failure can say what the receiver refused. */ + @Volatile + private var castingFormat: String? = null + /** Set while [stopCasting] waits for the receiver app to actually go away. */ @Volatile private var pendingSessionEnd: CompletableDeferred? = null @@ -411,16 +427,19 @@ class ChromecastCaster( cancelLoadWatchdog() if (sessionFlow.value is CastSessionState.Error) return val device = currentDevice() + val unsupported = + detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED || + detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_DECODE + val format = castingFormat val message = - when (detailedErrorCode) { - MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED, - MediaError.DetailedErrorCode.MEDIA_DECODE, - -> - Res.string.cast_error_unsupported_media + when { + unsupported && format != null -> Res.string.cast_error_unsupported_media_detail + unsupported -> Res.string.cast_error_unsupported_media + format != null -> Res.string.cast_error_playback_failed_detail else -> Res.string.cast_error_playback_failed } Log.w(TAG) { "media failure surfaced to UI: code=$detailedErrorCode device=${device?.name}" } - sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message)) + sessionFlow.value = CastSessionState.Error(device, CastErrorMessage(message, format)) } private fun currentDevice(): CastDevice? = @@ -638,9 +657,11 @@ class ChromecastCaster( try { val loadRequest = buildLoadRequest(request) val info = loadRequest.mediaInfo + castingFormat = request.formatSummary Log.i(TAG) { "cast: load() submitting contentType=${info?.contentType} " + - "streamType=${info?.streamType} url=${info?.contentId}" + "streamType=${info?.streamType} media=${request.formatSummary ?: "unknown"} " + + "url=${info?.contentId}" } // load() returns a PendingResult carrying the receiver's verdict. Dropping it // (as this used to) makes a refused load indistinguishable from a successful diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummaryTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummaryTest.kt new file mode 100644 index 0000000000..cdbb588aa2 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/cast/CastMediaSummaryTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.cast + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CastMediaSummaryTest { + @Test + fun namesTheCodecTheReceiverIsMostLikelyToRefuse() { + // The whole point: "HEVC" is the word that explains why a TV took the file and went quiet. + assertEquals( + "H.265 (HEVC) 1920x1080", + summarizeCastFormat("video/hevc", codecs = "hev1.1.6.L93.B0", width = 1920, height = 1080), + ) + } + + @Test + fun namesTheCommonCodecsInTermsPeopleRecognise() { + assertEquals("H.264 640x480", summarizeCastFormat("video/avc", null, 640, 480)) + assertEquals("AV1 3840x2160", summarizeCastFormat("video/av01", null, 3840, 2160)) + assertEquals("VP9 1280x720", summarizeCastFormat("video/x-vnd.on2.vp9", null, 1280, 720)) + } + + @Test + fun fallsBackToTheRawMimeTypeForCodecsWeDoNotNameOurselves() { + assertEquals("video/quirky 100x50", summarizeCastFormat("video/quirky", null, 100, 50)) + } + + @Test + fun omitsTheResolutionWhenItIsNotKnown() { + assertEquals("H.264", summarizeCastFormat("video/avc", null, width = -1, height = -1)) + assertEquals("H.264", summarizeCastFormat("video/avc", null, width = 0, height = 0)) + } + + @Test + fun fallsBackToTheCodecStringWhenTheMimeTypeIsMissing() { + // ExoPlayer can report a codec string without a sample mime type on some containers. + assertEquals("hev1.1.6.L93.B0 1920x1080", summarizeCastFormat(null, "hev1.1.6.L93.B0", 1920, 1080)) + } + + @Test + fun isNullWhenThereIsNothingWorthSaying() { + assertNull(summarizeCastFormat(null, null, -1, -1)) + assertNull(summarizeCastFormat("", "", 0, 0)) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 8ff7d6e8de..3cf81df338 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -2705,6 +2705,9 @@ %1$s went offline Couldn't load this video on %1$s Casting needs Google Play services, which aren't available on this device + %1$s can't play this video's format (%2$s) + %1$s couldn't play this video (%2$s) + %1$s never started playing this video (%2$s). It may not support that format, or restarting the device may help. Pick a video Your video will be transcoded into multiple resolutions so viewers get smooth playback on any connection. Change From a121dd21bc4cafd99c95fd44f49e584fcc758896 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 8 Aug 2026 22:33:07 +0200 Subject: [PATCH 19/32] fix(cast): don't report a stop the user asked for as a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing stop on a cast that had not started playing produced an error message. The receiver answers an abandoned load with CANCELED, and the refusal handler treated that like any other refusal — so the user got told something went wrong immediately after successfully doing what they intended. Skip the report when the refusal is CANCELED, and keep the target device on the caster for the length of the cast. currentDevice() reads the device back off the session state, which a teardown has already reset by the time a late failure arrives, so the message named "the cast device" instead of the TV in front of them — visible in the log as `device=The cast device`. --- .../cast/chromecast/ChromecastCaster.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt index b12ebb1d96..314dd81d0e 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/cast/chromecast/ChromecastCaster.kt @@ -375,7 +375,7 @@ class ChromecastCaster( val progressed = state == MediaStatus.PLAYER_STATE_PLAYING || state == MediaStatus.PLAYER_STATE_BUFFERING if (progressed || sessionFlow.value is CastSessionState.Error) return@Runnable - val device = watchedDevice ?: currentDevice() + val device = watchedDevice ?: currentDevice() ?: castingDevice Log.w(TAG) { "load watchdog: still ${playerStateName(state ?: -1)} after ${LOAD_PROGRESS_TIMEOUT_MS}ms on ${device?.name}" } @@ -398,6 +398,14 @@ class ChromecastCaster( @Volatile private var castingFormat: String? = null + /** + * Which device the in-flight cast targets. [currentDevice] reads it back off the session state, + * which a teardown has usually already reset by the time a late failure arrives — leaving the + * message to name "the cast device" instead of the TV the user was looking at. + */ + @Volatile + private var castingDevice: CastDevice? = null + /** Set while [stopCasting] waits for the receiver app to actually go away. */ @Volatile private var pendingSessionEnd: CompletableDeferred? = null @@ -426,7 +434,7 @@ class ChromecastCaster( private fun reportMediaFailure(detailedErrorCode: Int?) { cancelLoadWatchdog() if (sessionFlow.value is CastSessionState.Error) return - val device = currentDevice() + val device = currentDevice() ?: castingDevice val unsupported = detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_SRC_NOT_SUPPORTED || detailedErrorCode == MediaError.DetailedErrorCode.MEDIA_DECODE @@ -658,6 +666,7 @@ class ChromecastCaster( val loadRequest = buildLoadRequest(request) val info = loadRequest.mediaInfo castingFormat = request.formatSummary + castingDevice = device Log.i(TAG) { "cast: load() submitting contentType=${info?.contentType} " + "streamType=${info?.streamType} media=${request.formatSummary ?: "unknown"} " + @@ -678,9 +687,12 @@ class ChromecastCaster( "cast: load() REFUSED by receiver code=${statusName(status.statusCode)} " + "msg=${status.statusMessage}" } - // The receiver answered, so the watchdog is moot — but nothing else - // turns a refusal into something the user can read. - reportMediaFailure(null) + // CANCELED is what the receiver answers when the load was + // abandoned because we tore the session down — i.e. the user pressed + // stop. That is the outcome they asked for, not a failure to report. + if (status.statusCode != CastStatusCodes.CANCELED) { + reportMediaFailure(null) + } } } true From bb92054f6538f42aebf28d32774ec59ae539ed95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 16:51:41 +0000 Subject: [PATCH 20/32] refactor: NIP-FE frames in, frames out, on the relay URL Follows the NIP revision: the body is one REQ, COUNT or EVENT frame as the websocket carries it, POSTed to the relay's own URL, and the answer is the session's frames verbatim, the client's subscription id included. quartz: - HttpRelayHandler parses the body to refuse what HTTP does not carry and to know how the answer ends, then feeds the text to the session as socket text. The body splicing and the subscription-id stripping are gone; refusals are the command's own CLOSED / OK false, and pre-run refusals (400/413/503) a NOTICE. - NIP-98: the token is checked once, against the address its `u` names (any of the relay's, trailing slash or not), within 60 seconds, and may repeat for the same body. - HttpRelayCommand is the three kinds, their end frames and refusals; HttpRelayAnswerReader parses lines with the socket parser; HttpRelayClient sends ReqCmd/CountCmd/EventCmd frames. geode: - One POST on the relay path: application/nostr+json+rpc goes to NIP-86, anything else is a command. One CORS preflight. With [http] off, every POST goes to NIP-86 as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RbNrTdV2e7kW5S9tkMoPgh --- geode/README.md | 24 +-- geode/config.example.toml | 16 +- .../com/vitorpamplona/geode/KtorRelay.kt | 30 ++- .../geode/config/StaticConfig.kt | 10 +- .../geode/server/HttpCommandSettings.kt | 8 +- .../geode/server/NipFEHttpRoute.kt | 32 +-- .../com/vitorpamplona/geode/NipFEHttpTest.kt | 81 +++++--- .../nipFERelayOverHttp/HttpRelayAnswer.kt | 22 +-- .../nipFERelayOverHttp/HttpRelayCommand.kt | 78 +++----- .../nipFERelayOverHttp/HttpRelayHandler.kt | 153 +++++++------- .../HttpRelayAnswerReaderTest.kt | 50 ++--- .../nipFERelayOverHttp/HttpRelayClient.kt | 29 ++- .../HttpRelayHandlerTest.kt | 187 +++++++++--------- 13 files changed, 350 insertions(+), 370 deletions(-) diff --git a/geode/README.md b/geode/README.md index 07c5c73a20..a32af00068 100644 --- a/geode/README.md +++ b/geode/README.md @@ -103,23 +103,25 @@ file for every knob. ### Commands over HTTP (NIP-FE) -Besides the websocket, geode answers one command per HTTP `POST` beside the -relay path, streamed back as NDJSON — no socket, no subscription left open: +Besides the websocket, geode answers one command per HTTP `POST` to the relay +URL: the body is the frame you would send on the socket, and the answer is the +socket's frames as NDJSON, streamed — no socket, no subscription left open: ```bash -curl -N -d '{"kinds":[1],"limit":2}' http://localhost:7447/req -# ["EVENT",{"id":"…","kind":1,…}] -# ["EVENT",{"id":"…","kind":1,…}] -# ["EOSE"] -curl -d '{"kinds":[1]}' http://localhost:7447/count # ["COUNT",{"count":2}] -curl -d @signed-event.json http://localhost:7447/event # ["OK","",true,""] +curl -N -d '["REQ","q",{"kinds":[1],"limit":2}]' http://localhost:7447/ +# ["EVENT","q",{"id":"…","kind":1,…}] +# ["EVENT","q",{"id":"…","kind":1,…}] +# ["EOSE","q"] +curl -d '["COUNT","c",{"kinds":[1]}]' http://localhost:7447/ # ["COUNT","c",{"count":2}] +curl -d "[\"EVENT\",$(cat signed-event.json)]" http://localhost:7447/ # ["OK","",true,""] ``` A body that does not end on `EOSE`/`CLOSED` (REQ), `COUNT`/`CLOSED` (COUNT) or `OK` (EVENT) was cut off. On an AUTH-gated relay the answer is `401` until the -request carries a NIP-98 `Authorization: Nostr …` header whose `payload` is the -body's sha256. Quartz's `HttpRelayClient` does all of this for JVM/Android -clients. +request carries a NIP-98 `Authorization: Nostr …` header whose `u` is the +relay's http URL and whose `payload` is the body's sha256. NIP-86 admin calls +share the URL, told apart by `Content-Type: application/nostr+json+rpc`. +Quartz's `HttpRelayClient` does all of this for JVM/Android clients. ## Verbs diff --git a/geode/config.example.toml b/geode/config.example.toml index 2340424c6e..a2a46fe6c7 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -167,12 +167,14 @@ require_auth = false # filter = '{"kinds":[0,1,3,7],"#t":["nostr"]}' [http] -# NIP-FE: relay commands over HTTP. One command per POST to -# /req, /count or /event, answered as NDJSON -# (application/x-ndjson) and streamed as it is found; nothing stays open -# afterwards. NIP-98 `Authorization: Nostr ...` headers sign a request -# in, exactly as NIP-42 AUTH would on the socket. On by default; turning -# it off also drops "FE" from the default NIP-11 list. +# NIP-FE: relay commands over HTTP. One REQ, COUNT or EVENT frame per +# POST to the relay URL, exactly as it would go on the websocket, +# answered as NDJSON (application/x-ndjson) in the socket's own frames and +# streamed as it is found; nothing stays open afterwards. NIP-86 calls +# share the URL, told apart by their application/nostr+json+rpc type. +# NIP-98 `Authorization: Nostr ...` headers sign a request in, exactly as +# NIP-42 AUTH would on the socket. On by default; turning it off also +# drops "FE" from the default NIP-11 list. enabled = true # Every request is its own connection, so the websocket's # per-connection limits don't bound HTTP clients. These do: over the @@ -185,7 +187,7 @@ deadline_seconds = 30 max_body_bytes = 524288 retry_after_seconds = 1 # Other addresses this relay is reachable at: a NIP-98 token may name -# the endpoint under any of them, as well as under [info].relay_url. +# any of them, as well as [info].relay_url. # alternate_urls = ["ws://youraddress.onion/"] # Behind a reverse proxy every request comes from the proxy's address. # List the proxies here and the per-client cap counts the address the diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt index 8c05dbadb4..4385521331 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/KtorRelay.kt @@ -29,7 +29,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.server.RelaySession import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler -import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import io.ktor.server.application.install import io.ktor.server.application.serverConfig @@ -83,8 +82,8 @@ class KtorRelay( /** Ktor CIO call-handling thread count. `null` keeps Ktor's default. */ val callGroupSize: Int? = null, /** - * NIP-FE: relay commands over HTTP at `/req`, `/count` and `/event`. On by default; null - * turns the endpoints off (and the operator should then drop `FE` from the NIP-11 doc). + * NIP-FE: relay commands POSTed to the relay's URL, beside NIP-86. On by default; null turns them + * off, every POST going to NIP-86 again (and the operator should then drop `FE` from NIP-11). */ val httpCommands: HttpCommandSettings? = HttpCommandSettings(), ) { @@ -116,8 +115,8 @@ class KtorRelay( /** * NIP-FE. Each request runs on its own session of the same engine, so the websocket's policies - * and limits apply. A NIP-98 token must name the endpoint under `relay.url` read as http(s), or - * under one of the configured alternate URLs (a .onion). + * and limits apply. A NIP-98 token must name `relay.url` read as http(s), or one of the + * configured alternate URLs (a .onion). */ private val nipFERoute = httpCommands?.let { settings -> @@ -188,21 +187,18 @@ class KtorRelay( get(path) { nip11Route.handle(call) } - // NIP-86: POST application/nostr+json+rpc with a NIP-98 - // signed Authorization header → JSON-RPC dispatch. - // Always mounted; an empty admin allow-list on the engine just means - // every request fails the allow-list check (403). + // Two POSTs share the relay URL, told apart by Content-Type: + // - NIP-86: application/nostr+json+rpc with a NIP-98 signed + // Authorization header → JSON-RPC dispatch. Always mounted; an + // empty admin allow-list just means every call fails it (403). + // - NIP-FE: anything else is one REQ/COUNT/EVENT frame, answered + // as NDJSON in the socket's own frames. post(path) { - nip86Route.handle(call) + val commands = nipFERoute + if (commands != null && commands.isCommand(call)) commands.handle(call) else nip86Route.handle(call) } - // NIP-FE: one command per POST, answered as NDJSON. The paths - // hang off the relay's own path, as the NIP-98 `u` does. nipFERoute?.let { route -> - HttpRelayCommand.entries.forEach { command -> - val endpoint = path.trimEnd('/') + command.path - post(endpoint) { route.handle(call, command) } - options(endpoint) { route.preflight(call) } - } + options(path) { route.preflight(call) } } webSocket(path) { if (shuttingDown) { diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 3528cbc8e4..b3d888565c 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -232,9 +232,9 @@ data class StaticConfig( ) /** - * NIP-FE: relay commands over HTTP — `POST /req`, `/count`, `/event`, one command per - * request, answered as NDJSON. Each request is its own connection, so the concurrency caps here - * stand in for the websocket's per-connection limits. + * NIP-FE: relay commands over HTTP — one REQ, COUNT or EVENT frame POSTed to the relay's URL, + * answered as NDJSON in the socket's own frames. Each request is its own connection, so the + * concurrency caps here stand in for the websocket's per-connection limits. */ data class HttpSection( val enabled: Boolean = true, @@ -250,7 +250,7 @@ data class StaticConfig( val retry_after_seconds: Int = 1, /** * Other `ws(s)://` URLs this relay is reachable at (a `.onion` beside the clearnet name). - * A NIP-98 token's `u` may name the endpoint under any of them or under `[info].relay_url`. + * A NIP-98 token's `u` may name any of them, read as http(s), or `[info].relay_url`. */ val alternate_urls: List = emptyList(), /** @@ -260,7 +260,7 @@ data class StaticConfig( val trusted_proxies: List = emptyList(), val client_address_header: String = "X-Forwarded-For", ) { - /** The transport settings, or null when the endpoints are off. */ + /** The transport settings, or null when commands over HTTP are off. */ fun toSettings(): HttpCommandSettings? = if (!enabled) { null diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt index 9955f3884d..5d1017889e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import kotlin.time.Duration /** - * NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: `POST` to - * `/req`, `/count` and `/event`. The engine's policies and limits apply as they do on - * the websocket; these bound what the websocket's per-connection limits cannot, since every request - * is its own connection. + * NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: a `POST` to + * the relay's URL carrying one REQ, COUNT or EVENT frame. The engine's policies and limits apply as + * they do on the websocket; these bound what the websocket's per-connection limits cannot, since + * every request is its own connection. */ data class HttpCommandSettings( /** Requests running at once across all clients before the rest get 503; 0 is no limit. */ diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt index fb616548ae..77b7ac51b3 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.geode.server import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix -import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand +import com.vitorpamplona.quartz.nip86RelayManagement.server.Nip86HttpHandler import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayLines import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayRequest @@ -31,6 +31,7 @@ import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.contentType import io.ktor.server.request.header import io.ktor.server.response.header import io.ktor.server.response.respond @@ -39,7 +40,8 @@ import io.ktor.server.response.respondText import io.ktor.utils.io.writeStringUtf8 /** - * NIP-FE over Ktor: the host half of [HttpRelayHandler]. It admits the request, reads the body up to + * NIP-FE over Ktor: the host half of [HttpRelayHandler], on POSTs to the relay's URL that are not + * NIP-86 calls (see [isCommand]). It admits the request, reads the body up to * the cap, and writes the handler's answer as `application/x-ndjson` — one refusal line with its * status, or a 200 streamed and flushed frame by frame — with the headers the status calls for * (`WWW-Authenticate` on 401, `Retry-After` on 429 and 503) and the CORS and no-buffering headers @@ -65,11 +67,18 @@ internal class NipFEHttpRoute( call.respond(HttpStatusCode.NoContent) } - /** Answers one [command]. Admission runs first, so a refused request spends no NIP-98 token. */ - suspend fun handle( - call: ApplicationCall, - command: HttpRelayCommand, - ) { + /** + * Whether a POST to the relay's URL is a NIP-FE command: anything but NIP-86's + * `application/nostr+json+rpc`, since commands need no `Content-Type` at all. + */ + fun isCommand(call: ApplicationCall): Boolean = + !call.request + .contentType() + .withoutParameters() + .match(NIP86) + + /** Answers the command in the body. Admission runs first, so a refused request spends no NIP-98 token. */ + suspend fun handle(call: ApplicationCall) { call.response.header(HttpHeaders.AccessControlAllowOrigin, "*") call.response.header(HttpHeaders.AccessControlExposeHeaders, "${HttpHeaders.WWWAuthenticate}, ${HttpHeaders.RetryAfter}") call.response.header(HttpHeaders.CacheControl, "no-store") @@ -83,9 +92,9 @@ internal class NipFEHttpRoute( ?: return@admit respondLine( call, HttpRelayStatus.PAYLOAD_TOO_LARGE, - HttpRelayHandler.refusal(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), + HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), ) - handler.handle(HttpRelayRequest(command, call.request.header(HttpHeaders.Authorization), body), Answer(call)) + handler.handle(HttpRelayRequest(call.request.header(HttpHeaders.Authorization), body), Answer(call)) } when (verdict) { HttpAdmission.Verdict.ADMITTED -> {} @@ -94,7 +103,7 @@ internal class NipFEHttpRoute( respondLine( call, HttpRelayStatus.TOO_MANY_REQUESTS, - HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")), + HttpRelayHandler.notice(MachineReadablePrefix.RATE_LIMITED.format("over ${settings.maxPerClient} requests at once from this client")), ) } @@ -102,7 +111,7 @@ internal class NipFEHttpRoute( respondLine( call, HttpRelayStatus.UNAVAILABLE, - HttpRelayHandler.refusal(MachineReadablePrefix.RATE_LIMITED.format("the relay is at capacity")), + HttpRelayHandler.notice(MachineReadablePrefix.RATE_LIMITED.format("the relay is at capacity")), ) } } @@ -161,6 +170,7 @@ internal class NipFEHttpRoute( companion object { val NDJSON = ContentType("application", "x-ndjson") + private val NIP86 = ContentType.parse(Nip86HttpHandler.CONTENT_TYPE) const val WWW_AUTHENTICATE = "Nostr" const val ACCEL_BUFFERING = "X-Accel-Buffering" const val PREFLIGHT_MAX_AGE_SECONDS = 86_400 diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt index e9fd20927a..8650bfb503 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult @@ -38,7 +39,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayClient -import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayCommand import kotlinx.coroutines.runBlocking import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -57,7 +57,7 @@ import kotlin.time.Duration /** * NIP-FE end to end: quartz's [HttpRelayClient] and raw OkHttp requests against a real [KtorRelay], * covering the answer shape, the status table, the headers each status carries, NIP-98 sign-in - * on an AUTH-gated relay, and where the endpoints live. + * on an AUTH-gated relay, and sharing the relay URL with NIP-86. */ class NipFEHttpTest { private val http = OkHttpClient.Builder().build() @@ -94,13 +94,14 @@ class NipFEHttpTest { url: String, body: String, authorization: String? = null, + contentType: String = "text/plain", ): Response = http .newCall( Request .Builder() .url(url) - .post(body.toRequestBody("text/plain".toMediaType())) + .post(body.toRequestBody(contentType.toMediaType())) .apply { authorization?.let { header("Authorization", it) } } .build(), ).execute() @@ -115,7 +116,7 @@ class NipFEHttpTest { notes.forEach { assertTrue(assertIs(client().publish(relay, it).last).success) } val got = mutableListOf() - val answer = client().req(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), got::add) + val answer = client().req(relay, listOf(Filter(kinds = listOf(TextNoteEvent.KIND))), onEvent = got::add) assertEquals(200, answer.status) assertTrue(answer.complete) assertIs(answer.last) @@ -123,18 +124,18 @@ class NipFEHttpTest { } @Test - fun theWireIsNdjsonWithoutSubscriptionIds() = + fun theWireIsNdjsonInTheSocketsOwnFrames() = runBlocking { val relay = start() val n = note("hello") client().publish(relay, n) - post(HttpRelayCommand.REQ.url(relay), """{"ids":["${n.id}"]}""").use { response -> + post(relay.toHttp(), """["REQ","q",{"ids":["${n.id}"]}]""").use { response -> assertEquals(200, response.code) assertTrue(response.header("Content-Type")!!.startsWith("application/x-ndjson")) assertEquals("no", response.header("X-Accel-Buffering")) assertEquals("*", response.header("Access-Control-Allow-Origin")) val lines = response.lines() - assertEquals(listOf("""["EVENT",${n.toJson()}]""", """["EOSE"]"""), lines) + assertEquals(listOf("""["EVENT","q",${n.toJson()}]""", """["EOSE","q"]"""), lines) } } @@ -158,7 +159,7 @@ class NipFEHttpTest { assertEquals(200, client().publish(relay, n).status) val forged = n.toJson().replace("\"once\"", "\"twice\"") - post(HttpRelayCommand.EVENT.url(relay), forged).use { response -> + post(relay.toHttp(), """["EVENT",$forged]""").use { response -> assertEquals(400, response.code) val line = response.lines().single() assertTrue(line.startsWith("""["OK","${n.id}",false,"invalid:"""), line) @@ -166,15 +167,17 @@ class NipFEHttpTest { } @Test - fun aBodyThatIsNotTheCommandIs400AndOneOverTheCapIs413() { + fun aBodyThatIsNotACommandIs400AndOneOverTheCapIs413() { val relay = start(settings = HttpCommandSettings(maxBodyBytes = 64)) - post(HttpRelayCommand.REQ.url(relay), "hello").use { response -> - assertEquals(400, response.code) - assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:""")) + for (body in listOf("hello", """{"kinds":[1]}""", """["CLOSE","q"]""")) { + post(relay.toHttp(), body).use { response -> + assertEquals(400, response.code, body) + assertTrue(response.lines().single().startsWith("""["NOTICE","invalid:""")) + } } - post(HttpRelayCommand.REQ.url(relay), """{"authors":["${"a".repeat(64)}"]}""").use { response -> + post(relay.toHttp(), """["REQ","q",{"authors":["${"a".repeat(64)}"]}]""").use { response -> assertEquals(413, response.code) - assertTrue(response.lines().single().startsWith("""["CLOSED","invalid:""")) + assertTrue(response.lines().single().startsWith("""["NOTICE","invalid:""")) } } @@ -189,10 +192,10 @@ class NipFEHttpTest { } }, ) - post(HttpRelayCommand.REQ.url(relay), "{}").use { response -> + post(relay.toHttp(), """["REQ","q",{}]""").use { response -> assertEquals(429, response.code) assertEquals("7", response.header("Retry-After")) - assertEquals("""["CLOSED","rate-limited: slow down"]""", response.lines().single()) + assertEquals("""["CLOSED","q","rate-limited: slow down"]""", response.lines().single()) } } @@ -202,7 +205,7 @@ class NipFEHttpTest { val preflight = Request .Builder() - .url(HttpRelayCommand.REQ.url(relay)) + .url(relay.toHttp()) .method("OPTIONS", null) .header("Origin", "https://app.example") .header("Access-Control-Request-Method", "POST") @@ -220,10 +223,10 @@ class NipFEHttpTest { fun anAuthGatedRelayAnswers401UntilANip98TokenSignsTheRequestIn() = runBlocking { val relay = start(policy = ::SignInPolicy) - post(HttpRelayCommand.REQ.url(relay), "{}").use { response -> + post(relay.toHttp(), """["REQ","q",{}]""").use { response -> assertEquals(401, response.code) assertEquals("Nostr", response.header("WWW-Authenticate")) - assertTrue(response.lines().single().startsWith("""["CLOSED","auth-required:""")) + assertTrue(response.lines().single().startsWith("""["CLOSED","q","auth-required:""")) } val unsigned = client().req(relay, listOf(Filter(kinds = listOf(1)))) {} @@ -236,7 +239,7 @@ class NipFEHttpTest { assertTrue(assertIs(published.last).success) val got = mutableListOf() - val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), got::add) + val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), onEvent = got::add) assertEquals(200, read.status) assertTrue(read.complete) assertEquals(listOf(n.id), got.map { it.id }) @@ -246,13 +249,15 @@ class NipFEHttpTest { fun aTokenForAnotherBodyDoesNotSignIn() = runBlocking { val relay = start(policy = ::SignInPolicy) - val url = HttpRelayCommand.REQ.url(relay) - val token = alice.sign(HTTPAuthorizationEvent.build(url, "POST", """{"kinds":[1]}""".encodeToByteArray())).toAuthToken() - post(url, """{"kinds":[0]}""", token).use { response -> + val url = relay.toHttp() + val signed = """["REQ","q",{"kinds":[1]}]""" + val token = alice.sign(HTTPAuthorizationEvent.build(url, "POST", signed.encodeToByteArray())).toAuthToken() + post(url, """["REQ","q",{"kinds":[0]}]""", token).use { response -> assertEquals(401, response.code) assertTrue(response.lines().single().contains("payload")) } - post(url, """{"kinds":[1]}""", token).use { assertEquals(200, it.code) } + post(url, signed, token).use { assertEquals(200, it.code) } + post(url, signed, token).use { assertEquals(200, it.code, "good again for the same body within its window") } } @Test @@ -260,24 +265,36 @@ class NipFEHttpTest { runBlocking { val onion = "ws://2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion/".normalizeRelayUrl() val relay = start(policy = ::SignInPolicy, settings = HttpCommandSettings(alternateUrls = listOf(onion))) - val body = """{"kinds":[1]}""" - val token = alice.sign(HTTPAuthorizationEvent.build(HttpRelayCommand.REQ.url(onion), "POST", body.encodeToByteArray())).toAuthToken() - post(HttpRelayCommand.REQ.url(relay), body, token).use { assertEquals(200, it.code) } + val body = """["REQ","q",{"kinds":[1]}]""" + val token = alice.sign(HTTPAuthorizationEvent.build(onion.toHttp(), "POST", body.encodeToByteArray())).toAuthToken() + post(relay.toHttp(), body, token).use { assertEquals(200, it.code) } } @Test - fun theEndpointsHangOffTheRelayPath() = + fun commandsGoToTheRelayUrlPathIncluded() = runBlocking { val relay = start(path = "/nostr") - assertTrue(HttpRelayCommand.REQ.url(relay).endsWith("/nostr/req")) + assertTrue(relay.toHttp().trimEnd('/').endsWith("/nostr")) assertTrue(client().req(relay, listOf(Filter(kinds = listOf(1)))) {}.complete) - post(HttpRelayCommand.REQ.url(relay).replace("/nostr/req", "/req"), "{}").use { assertEquals(404, it.code) } + post(relay.toHttp().replace("/nostr", ""), """["REQ","q",{}]""").use { assertEquals(404, it.code) } } @Test - fun turnedOffThereAreNoEndpoints() { + fun nip86CallsKeepTheRelayUrlByTheirContentType() { + val relay = start() + post(relay.toHttp(), """{"method":"supportedmethods","params":[]}""", contentType = "application/nostr+json+rpc").use { response -> + assertEquals(401, response.code, "NIP-86 asks for its own NIP-98 token") + assertFalse(response.header("Content-Type")!!.startsWith("application/x-ndjson")) + } + } + + @Test + fun turnedOffEveryPostIsNip86Again() { val relay = start(settings = null) - post(HttpRelayCommand.REQ.url(relay), "{}").use { assertEquals(404, it.code) } + post(relay.toHttp(), """["REQ","q",{}]""").use { response -> + assertFalse(response.header("Content-Type")!!.startsWith("application/x-ndjson")) + assertEquals(401, response.code) + } } @Test diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt index 7af34712f4..98b04bfd88 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswer.kt @@ -55,9 +55,8 @@ class HttpRelayAnswerReader( private var broken = false /** - * The frame on [line], typed with [HttpRelayCommand.SUB_ID] as its subscription id, or null for - * a blank line. A line that does not parse, or anything after the answer ended, marks the answer - * incomplete: the body is not what this NIP says it is. + * The frame on [line], or null for a blank line. A line that does not parse, or anything after + * the answer ended, marks the answer incomplete: the body is not what this NIP says it is. */ fun read(line: String): Message? { if (line.isBlank()) return null @@ -67,7 +66,7 @@ class HttpRelayAnswerReader( } val message = try { - OptimizedJsonMapper.fromJsonToMessage(withSubId(line.trim())) + OptimizedJsonMapper.fromJsonToMessage(line) } catch (_: Exception) { broken = true return null @@ -83,18 +82,3 @@ class HttpRelayAnswerReader( fun answer(retryAfter: String? = null) = HttpRelayAnswer(status, last, complete, retryAfter) } - -/** - * [frame] with the subscription id NIP-FE leaves out put back, so the websocket's parser reads it: - * `["EVENT",{…}]` → `["EVENT","http",{…}]`, `["EOSE"]` → `["EOSE","http"]`. The inverse of - * [withoutSubId]; frames that carry no subscription id pass as they are. - */ -internal fun withSubId(frame: String): String { - if (!frame.startsWith('[')) return frame - var open = 1 - while (open < frame.length && frame[open].isWhitespace()) open++ - if (open >= frame.length || frame[open] != '"') return frame - val verbEnd = frame.indexOf('"', open + 1) - if (verbEnd < 0 || frame.substring(open + 1, verbEnd) !in SUBSCRIPTION_FRAMES) return frame - return frame.substring(0, verbEnd + 1) + SUB_ID_FIELD + frame.substring(verbEnd + 1) -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt index d0f51da283..35b074fcb8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayCommand.kt @@ -26,56 +26,22 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp /** - * NIP-FE: the client commands HTTP carries, one path each. A body is the command's arguments - * after its subscription id (a lone object where the command takes one); the answer ends on the - * first frame [ends] accepts. + * NIP-FE: the client commands an HTTP request may carry, each as the frame a client sends on the + * websocket, and the frame that ends each one's answer. */ -enum class HttpRelayCommand( - val path: String, -) { - REQ("/req"), - COUNT("/count"), - EVENT("/event"), +enum class HttpRelayCommand { + REQ, + COUNT, + EVENT, ; - /** - * The client frame [body] stands for, or null when it plainly is not this command's arguments. - * The body is spliced in as sent and the engine parses the frame, as it parses socket text, so - * any other malformed body is the engine's NOTICE. The verb and subscription id come first and - * the parser reads one value, so nothing a body holds can make it another command. - */ - fun frameOf(body: String): String? { - val text = body.trim() - return when (this) { - REQ, COUNT -> { - val filters = - when { - text.startsWith('{') -> text - text.startsWith('[') && text.endsWith(']') -> text.substring(1, text.length - 1).trim().ifEmpty { return null } - else -> return null - } - "[\"${if (this == REQ) ReqCmd.LABEL else CountCmd.LABEL}\",\"$SUB_ID\",$filters]" - } - - EVENT -> { - if (!text.startsWith('{')) return null - "[\"${EventCmd.LABEL}\",$text]" - } - } - } - - /** This command's endpoint on [relay]: the relay URL read as http(s), host and path kept, plus [path]. */ - fun url(relay: NormalizedRelayUrl): String = relay.toHttp().trimEnd('/') + path - - /** Whether [message] is the last frame of this command's answer. */ + /** Whether [message] is the last frame of this command's answer. A NOTICE ends any: the command never ran. */ fun ends(message: Message): Boolean = message is NoticeMessage || when (this) { @@ -85,15 +51,25 @@ enum class HttpRelayCommand( } companion object { - /** - * The subscription id every HTTP command runs under inside the engine. NIP-FE answers carry - * none, so [HttpRelayHandler] takes it back out of each frame before it goes out. - */ - const val SUB_ID = "http" + /** The kind of [cmd], or null for one HTTP does not carry (AUTH, CLOSE, NEG-*). */ + fun of(cmd: Command): HttpRelayCommand? = + when (cmd) { + is ReqCmd -> REQ + is CountCmd -> COUNT + is EventCmd -> EVENT + else -> null + } - fun forPath(path: String): HttpRelayCommand? = entries.firstOrNull { it.path == path } - - /** A REQ or COUNT body: the filters as the array that follows the subscription id. */ - fun body(filters: List): String = filters.joinToString(",", "[", "]") { it.toJson() } + /** The frame that refuses [cmd] with [reason], as the socket would: CLOSED for a REQ or COUNT, OK false for an EVENT. */ + fun refusal( + cmd: Command, + reason: String, + ): Message = + when (cmd) { + is EventCmd -> OkMessage(cmd.event.id, false, reason) + is ReqCmd -> ClosedMessage(cmd.subId, reason) + is CountCmd -> ClosedMessage(cmd.queryId, reason) + else -> NoticeMessage(reason) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt index d4333623f6..d4c96f7438 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt @@ -21,13 +21,16 @@ package com.vitorpamplona.quartz.nipFERelayOverHttp import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage -import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.server.RelayServerBase import com.vitorpamplona.quartz.nip01Core.relay.server.SessionSink import com.vitorpamplona.quartz.nip98HttpAuth.Nip98AuthVerifier +import com.vitorpamplona.quartz.nip98HttpAuth.tags.UrlTag import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.TimeoutCancellationException @@ -36,18 +39,19 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeSource -/** One NIP-FE request as the handler needs it. The host routes by [HttpRelayCommand.path]. */ +/** One NIP-FE request as the handler needs it: a POST to the relay's URL that is not a NIP-86 call. */ class HttpRelayRequest( - val command: HttpRelayCommand, /** The `Authorization` header as sent, or null. */ val authorization: String?, /** - * The body. Hosts bound the read at [HttpRelayHandler.maxBodyBytes]: the engine measures a - * frame in characters, and a UTF-8 character takes up to three bytes. + * The body: one client frame. Hosts bound the read at [HttpRelayHandler.maxBodyBytes]: the + * engine measures a frame in characters, and a UTF-8 character takes up to three bytes. */ val body: ByteArray, ) @@ -82,16 +86,17 @@ interface HttpRelayLines { class HttpRelayReaderStalled : Exception("the client stopped reading the answer") /** - * NIP-FE: one relay command per HTTP request, run on its own [RelayServerBase] session, so every - * limit and policy the socket applies applies here, and answered with the relay's own frames up to - * the command's answer, without their subscription id. Nothing outlives the request. + * NIP-FE: one relay command per HTTP request. The body is the frame a client would send on the + * websocket; it runs on its own [RelayServerBase] session, fed as socket text, so every limit and + * policy the socket applies applies here; and the answer is the session's frames as the socket + * would carry them, up to the one that ends the command's answer. Nothing outlives the request. * * Admission (how many requests a client may run) is the host's: gate before calling [handle], so a * refused request does not spend a NIP-98 token the handler would have verified. */ class HttpRelayHandler( private val server: RelayServerBase, - /** The prefixes a NIP-98 `u` may carry (the relay's http origin, its .onion), asked per request; never from the request. */ + /** The URLs a NIP-98 `u` may name (the relay's http URL, its .onion), asked per request; never from the request. */ private val origins: () -> List, /** How long one answer may run, first byte to last. [Duration.INFINITE] turns the deadline off. */ private val deadline: Duration = DEFAULT_DEADLINE, @@ -107,36 +112,35 @@ class HttpRelayHandler( request: HttpRelayRequest, response: HttpRelayResponse, ) { - val command = request.command val max = server.limits?.maxMessageLength + val tooLarge = "invalid: the command exceeds $max characters" maxBodyBytes?.let { cap -> - if (request.body.size > cap) { - return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, closed("invalid: the command exceeds $max characters")) - } + if (request.body.size > cap) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge)) } - val frame = - command.frameOf(request.body.decodeToString()) - ?: return response.single(HttpRelayStatus.BAD_REQUEST, closed("invalid: the body is not ${command.name}'s arguments")) + val text = request.body.decodeToString() // Characters, as the engine's own limit counts them. - if (max != null && frame.length > max) { - return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, closed("invalid: the command exceeds $max characters")) - } + if (max != null && text.length > max) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge)) + + // Read here as well as in the engine, to refuse the commands HTTP does not carry and to + // know what ends the answer and how to refuse it. The engine still parses the text itself, + // as socket text, so the policies that judge raw frames run. + val cmd = + try { + OptimizedJsonMapper.fromJsonToCommand(text) + } catch (_: Exception) { + null + } + val command = + cmd?.let { HttpRelayCommand.of(it) } + ?: return response.single(HttpRelayStatus.BAD_REQUEST, notice("invalid: the body is not one REQ, COUNT or EVENT frame")) + val signedIn = when (val proof = proofOf(request)) { - is Proof.Anonymous -> { - null - } - - is Proof.Signed -> { - proof.pubkey - } - - is Proof.Refused -> { - val reason = proof.reason - return response.single(HttpRelayStatus.forReason(reason), closed(reason)) - } + is Proof.Anonymous -> null + is Proof.Signed -> proof.pubkey + is Proof.Refused -> return response.single(HttpRelayStatus.forReason(proof.reason), HttpRelayCommand.refusal(cmd, proof.reason).toJson()) } - exchange(frame, command, signedIn, response) + exchange(text, cmd, command, signedIn, response) } /** A frame as queued: its wire text, its type when the engine built one, and whether it ends the answer. */ @@ -147,7 +151,8 @@ class HttpRelayHandler( ) private suspend fun exchange( - frame: String, + text: String, + cmd: Command, command: HttpRelayCommand, signedIn: HexKey?, response: HttpRelayResponse, @@ -165,23 +170,25 @@ class HttpRelayHandler( } } - fun fail(reason: String) = offer(Frame(closed(reason), ClosedMessage(HttpRelayCommand.SUB_ID, reason), last = true)) + fun refusal(reason: String) = HttpRelayCommand.refusal(cmd, reason) + + fun fail(reason: String) = refusal(reason).let { offer(Frame(it.toJson(), it, last = true)) } val sink = object : SessionSink { override fun message(message: Message) { // The challenge every connection opens with; this one proves its key by NIP-98 instead. if (message is AuthMessage) return - offer(Frame(withoutSubId(message.toJson()), message, command.ends(message))) + offer(Frame(message.toJson(), message, command.ends(message))) } - override fun raw(json: String) = offer(Frame(withoutSubId(json), null, last = false)) + override fun raw(json: String) = offer(Frame(json, null, last = false)) } val session = launch { try { server.serve(sink) { session -> val refused = signedIn?.let { session.authenticateByTransport(it) } - if (refused != null) fail(refused) else session.receive(frame) + if (refused != null) fail(refused) else session.receive(text) ended.await() } } catch (e: CancellationException) { @@ -202,7 +209,7 @@ class HttpRelayHandler( val status = HttpRelayStatus.of(first?.message) when { first == null -> { - single(HttpRelayStatus.UNAVAILABLE, closed("error: no answer within $deadline")) + single(HttpRelayStatus.UNAVAILABLE, notice("error: no answer within $deadline")) } first.last || status != HttpRelayStatus.OK -> { @@ -214,8 +221,8 @@ class HttpRelayHandler( bounded(due) { when (drain(first, frames, due)) { Ending.ANSWERED -> {} - Ending.DEADLINE -> line(closed("error: the answer ran past $deadline")) - Ending.CUT -> line(closed("error: slow reader, over $maxQueuedFrames frames waiting")) + Ending.DEADLINE -> line(refusal("error: the answer ran past $deadline").toJson()) + Ending.CUT -> line(refusal("error: slow reader, over $maxQueuedFrames frames waiting").toJson()) } flush() } @@ -284,36 +291,46 @@ class HttpRelayHandler( } /** - * A NIP-98 header, checked against every address in [origins], so a token signed at the .onion - * verifies there. It must bind the body's hash: it authorizes one command. - * Another scheme (a proxy's Basic, a client's Bearer) is not addressed to the relay and is ignored. + * A NIP-98 header. Its `u` may name any address in [origins], with or without the trailing + * slash, so a token signed at the .onion verifies there; the token is checked once, against the + * address it names. It must bind the body's hash and be within 60 seconds of now; within that + * window it may come again for the same body. Another scheme (a proxy's Basic, a client's + * Bearer) is not addressed to the relay and is ignored. */ private suspend fun proofOf(request: HttpRelayRequest): Proof { val header = request.authorization?.trim().orEmpty() val scheme = Nip98AuthVerifier.SCHEME if (!header.regionMatches(0, scheme, 0, scheme.length, ignoreCase = true)) return Proof.Anonymous val token = scheme + header.substring(scheme.length).trim() - val accepted = origins().map { it.trimEnd('/') + request.command.path } - if (accepted.isEmpty()) return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("this relay names no url to sign")) + val addresses = origins() + if (addresses.isEmpty()) return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("this relay names no url to sign")) + // The address the token names, when it is one of ours; otherwise the first, and the + // verifier refuses the mismatch (or whatever else is wrong with the token) itself. + val signed = claimedUrl(token) + val url = signed?.takeIf { u -> addresses.any { it.trimEnd('/') == u.trimEnd('/') } } ?: addresses.first() // A fresh verifier each time: it remembers the tokens it accepts, and a NIP-FE token is not - // single-use. A request can land on any instance, which no one process's memory can follow, - // and the body's hash already limits a captured token to the command it signs, in its window. - var refusal: Nip98AuthVerifier.Result.Malformed? = null - for (url in accepted) { - when (val r = Nip98AuthVerifier().verify(token, "POST", url, request.body)) { - is Nip98AuthVerifier.Result.Verified -> return Proof.Signed(r.pubkey) - is Nip98AuthVerifier.Result.Missing -> return Proof.Anonymous - is Nip98AuthVerifier.Result.Malformed -> refusal = refusal ?: r - } + // single-use. Every command it can sign is idempotent, so a repeat only repeats a read or + // re-sends an event the relay has, and a client may retry without signing again. + return when (val r = Nip98AuthVerifier(toleranceSeconds = TOKEN_WINDOW_SECONDS).verify(token, "POST", url, request.body)) { + is Nip98AuthVerifier.Result.Verified -> Proof.Signed(r.pubkey) + is Nip98AuthVerifier.Result.Missing -> Proof.Anonymous + is Nip98AuthVerifier.Result.Malformed -> Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${r.reason}")) } - return Proof.Refused(MachineReadablePrefix.AUTH_REQUIRED.format("NIP-98 ${refusal?.reason}")) } - private fun closed(reason: String) = refusal(reason) + /** The `u` a NIP-98 [token] names, or null when it does not decode; the verifier then says why. */ + @OptIn(ExperimentalEncodingApi::class) + private fun claimedUrl(token: String): String? = + try { + val event = OptimizedJsonMapper.fromJson(Base64.decode(token.substring(Nip98AuthVerifier.SCHEME.length)).decodeToString()) + event.tags.firstNotNullOfOrNull(UrlTag::parse) + } catch (_: Exception) { + null + } companion object { - /** A `CLOSED` line with [reason], as NIP-FE sends it: for a host that refuses before the handler runs (413, 429, 503). */ - fun refusal(reason: String) = withoutSubId(ClosedMessage(HttpRelayCommand.SUB_ID, reason).toJson()) + /** A `NOTICE` line: how a request is refused before its command runs (400, 413, 429, 503), by the handler or its host. */ + fun notice(reason: String) = NoticeMessage(reason).toJson() val DEFAULT_DEADLINE = 30_000.milliseconds @@ -321,22 +338,8 @@ class HttpRelayHandler( const val DEFAULT_MAX_QUEUED_FRAMES = 8192 val DEFAULT_TAIL_GRACE = 5_000.milliseconds + + /** NIP-FE: a token is good for 60 seconds either side of its `created_at`. */ + const val TOKEN_WINDOW_SECONDS = 60L } } - -/** The frames that carry a subscription id in the engine; NIP-FE sends them without it. */ -internal val SUBSCRIPTION_FRAMES = setOf("EVENT", "EOSE", "CLOSED", "COUNT") - -internal const val SUB_ID_FIELD = ",\"" + HttpRelayCommand.SUB_ID + "\"" - -/** - * [frame] as NIP-FE sends it: the engine's frame with its `"http"` subscription id taken out, - * `["EVENT","http",{…}]` → `["EVENT",{…}]`, `["EOSE","http"]` → `["EOSE"]`. Other frames pass as they are. - */ -internal fun withoutSubId(frame: String): String { - if (!frame.startsWith("[\"")) return frame - val verbEnd = frame.indexOf('"', 2) - if (verbEnd < 0 || frame.substring(2, verbEnd) !in SUBSCRIPTION_FRAMES) return frame - if (!frame.startsWith(SUB_ID_FIELD, verbEnd + 1)) return frame - return frame.substring(0, verbEnd + 1) + frame.substring(verbEnd + 1 + SUB_ID_FIELD.length) -} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt index 3c00b6c9a1..085290682e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayAnswerReaderTest.kt @@ -25,8 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -46,19 +44,9 @@ class HttpRelayAnswerReaderTest { vararg lines: String, ) = HttpRelayAnswerReader(command, status).also { reader -> lines.forEach { reader.read(it) } } - @Test - fun theSubscriptionIdGoesBackWhereItWasTakenOut() { - for (frame in listOf("""["EVENT","http",$event]""", """["EOSE","http"]""", """["CLOSED","http","error: x"]""", """["COUNT","http",{"count":3}]""")) { - assertEquals(frame, withSubId(withoutSubId(frame))) - } - assertEquals("""["OK","id",true,""]""", withSubId("""["OK","id",true,""]""")) - assertEquals("""["NOTICE","hi"]""", withSubId("""["NOTICE","hi"]""")) - assertEquals("""[ "EOSE","http"]""", withSubId("""[ "EOSE"]""")) - } - @Test fun aReqThatEndsOnEoseIsComplete() { - val reader = read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE"]""") + val reader = read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["EOSE","q"]""") assertTrue(reader.complete) assertIs(reader.last) } @@ -66,24 +54,24 @@ class HttpRelayAnswerReaderTest { @Test fun aReqWithItsTailMissingIsIncomplete() { val reader = HttpRelayAnswerReader(HttpRelayCommand.REQ, 200) - val message = reader.read("""["EVENT",$event]""") + val message = reader.read("""["EVENT","q",$event]""") assertIs(message) assertEquals("hi", message.event.content) - assertEquals(HttpRelayCommand.SUB_ID, message.subId) + assertEquals("q", message.subId) assertFalse(reader.complete) assertFalse(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).complete, "an empty body is no answer") } @Test fun aClosedEndsAReqAndACountButNotAnEvent() { - assertTrue(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["CLOSED","error: the answer ran past 30s"]""").complete) - assertTrue(read(HttpRelayCommand.COUNT, 200, """["CLOSED","error: x"]""").complete) - assertFalse(read(HttpRelayCommand.EVENT, 200, """["CLOSED","error: x"]""").complete) + assertTrue(read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["CLOSED","q","error: the answer ran past 30s"]""").complete) + assertTrue(read(HttpRelayCommand.COUNT, 200, """["CLOSED","q","error: x"]""").complete) + assertFalse(read(HttpRelayCommand.EVENT, 200, """["CLOSED","q","error: x"]""").complete) } @Test fun aCountAndAnOkAreWholeAnswers() { - val count = read(HttpRelayCommand.COUNT, 200, """["COUNT",{"count":7}]""") + val count = read(HttpRelayCommand.COUNT, 200, """["COUNT","c",{"count":7}]""") assertTrue(count.complete) assertEquals(7, assertIs(count.last).result.count) val ok = read(HttpRelayCommand.EVENT, 200, """["OK","abc",true,""]""") @@ -93,35 +81,21 @@ class HttpRelayAnswerReaderTest { @Test fun aRefusalIsOneLineWhateverItsFrame() { - val refused = read(HttpRelayCommand.EVENT, 401, """["CLOSED","auth-required: sign in"]""") + val refused = read(HttpRelayCommand.EVENT, 401, """["CLOSED","q","auth-required: sign in"]""") assertTrue(refused.complete) assertEquals("auth-required: sign in", assertIs(refused.last).message) - assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","blocked: no"]""", """["EOSE"]""").complete) + assertFalse(read(HttpRelayCommand.REQ, 403, """["CLOSED","q","blocked: no"]""", """["EOSE","q"]""").complete) } @Test fun anythingAfterTheEndOrALineThatIsNoFrameBreaksTheAnswer() { - assertFalse(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", """["EVENT",$event]""").complete) - assertFalse(read(HttpRelayCommand.REQ, 200, """["EVENT",$event]""", """["EOSE""").complete) - assertTrue(read(HttpRelayCommand.REQ, 200, """["EOSE"]""", "", " ").complete, "blank lines are not frames") + assertFalse(read(HttpRelayCommand.REQ, 200, """["EOSE","q"]""", """["EVENT","q",$event]""").complete) + assertFalse(read(HttpRelayCommand.REQ, 200, """["EVENT","q",$event]""", """["EOSE","q""").complete) + assertTrue(read(HttpRelayCommand.REQ, 200, """["EOSE","q"]""", "", " ").complete, "blank lines are not frames") } @Test fun blankLinesAreSkipped() { assertNull(HttpRelayAnswerReader(HttpRelayCommand.REQ, 200).read("")) } - - @Test - fun theEndpointsHangOffTheRelayUrl() { - assertEquals("https://relay.example/req", HttpRelayCommand.REQ.url(NormalizedRelayUrl("wss://relay.example/"))) - assertEquals("http://127.0.0.1:7447/nostr/count", HttpRelayCommand.COUNT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr"))) - assertEquals("http://127.0.0.1:7447/nostr/event", HttpRelayCommand.EVENT.url(NormalizedRelayUrl("ws://127.0.0.1:7447/nostr/"))) - } - - @Test - fun aFilterBodyIsTheArrayAfterTheSubscriptionId() { - val body = HttpRelayCommand.body(listOf(Filter(kinds = listOf(1), limit = 2), Filter(kinds = listOf(0)))) - assertEquals("""[{"kinds":[1],"limit":2},{"kinds":[0]}]""", body) - assertEquals("""["REQ","http",{"kinds":[1],"limit":2},{"kinds":[0]}]""", HttpRelayCommand.REQ.frameOf(body)) - } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt index fca059543e..5eb4bd6365 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt @@ -21,10 +21,16 @@ package com.vitorpamplona.quartz.nipFERelayOverHttp import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import kotlinx.coroutines.Dispatchers @@ -40,8 +46,9 @@ import okhttp3.coroutines.executeAsync import okio.IOException /** - * NIP-FE over OkHttp: one relay command per request, its answer read line by line as the relay - * writes it. Nothing stays open after a call returns. + * NIP-FE over OkHttp: one relay command per request, POSTed to the relay's URL as the frame the + * websocket would carry, its answer read line by line as the relay writes it, in the socket's own + * frames. Nothing stays open after a call returns. * * With a [signer], a request the relay refuses with 401 goes once more carrying a NIP-98 token for * its exact body, as a websocket client answers a NIP-42 challenge; without one, the 401 is the @@ -57,9 +64,10 @@ class HttpRelayClient( suspend fun req( relay: NormalizedRelayUrl, filters: List, + subId: String = newSubId(), onEvent: (Event) -> Unit, ): HttpRelayAnswer = - send(relay, HttpRelayCommand.REQ, HttpRelayCommand.body(filters)) { + send(relay, ReqCmd(subId, filters)) { if (it is EventMessage) onEvent(it.event) } @@ -67,23 +75,24 @@ class HttpRelayClient( suspend fun count( relay: NormalizedRelayUrl, filters: List, - ): HttpRelayAnswer = send(relay, HttpRelayCommand.COUNT, HttpRelayCommand.body(filters)) + queryId: String = newSubId(), + ): HttpRelayAnswer = send(relay, CountCmd(queryId, filters)) /** Publishes [event]: [HttpRelayAnswer.last] is its OK, or the refusal. */ suspend fun publish( relay: NormalizedRelayUrl, event: Event, - ): HttpRelayAnswer = send(relay, HttpRelayCommand.EVENT, event.toJson()) + ): HttpRelayAnswer = send(relay, EventCmd(event)) - /** Posts [body] to [command]'s endpoint on [relay], handing every frame to [onMessage] as it is read. */ + /** Posts [cmd] (a REQ, COUNT or EVENT) to [relay], handing every frame to [onMessage] as it is read. */ suspend fun send( relay: NormalizedRelayUrl, - command: HttpRelayCommand, - body: String, + cmd: Command, onMessage: (Message) -> Unit = {}, ): HttpRelayAnswer { - val url = command.url(relay) - val bytes = body.encodeToByteArray() + val command = requireNotNull(HttpRelayCommand.of(cmd)) { "NIP-FE carries REQ, COUNT and EVENT, not ${cmd.label()}" } + val url = relay.toHttp() + val bytes = cmd.toJson().encodeToByteArray() if (signer == null || signFirst) return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) val first = post(command, url, bytes, null, onMessage, retrying = true) if (first.status != HttpRelayStatus.UNAUTHORIZED) return first diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandlerTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandlerTest.kt index 31695d8da3..5612fd3c81 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandlerTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandlerTest.kt @@ -160,10 +160,15 @@ class HttpRelayHandlerTest { ) = HttpRelayHandler(MemoryRelay(backend, signedInOnly), origins = { listOf(origin) }, deadline = deadline) private fun HttpRelayHandler.ask( - command: HttpRelayCommand, - body: String, + frame: String, authorization: String? = null, - ) = Recorded().also { runBlocking { handle(HttpRelayRequest(command, authorization, body.encodeToByteArray()), it) } } + ) = Recorded().also { runBlocking { handle(HttpRelayRequest(authorization, frame.encodeToByteArray()), it) } } + + private fun req(filter: String) = """["REQ","q",$filter]""" + + private fun count(filter: String) = """["COUNT","c",$filter]""" + + private fun publish(event: Event) = """["EVENT",${event.toJson()}]""" private fun note( content: String, @@ -171,19 +176,19 @@ class HttpRelayHandlerTest { ) = alice.sign(at, 1, emptyArray(), content) private fun token( - command: HttpRelayCommand, - body: String, - ) = alice.sign(HTTPAuthorizationEvent.build(origin + command.path, "POST", body.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken() + frame: String, + url: String = origin, + ) = alice.sign(HTTPAuthorizationEvent.build(url, "POST", frame.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken() @Test fun aReqStreamsItsEventsAndEndsOnEose() { val a = note("a") val b = note("b") backend.events += listOf(a, b) - val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}""") + val answer = handler().ask(req("""{"kinds":[1]}""")) assertEquals(200, answer.status) assertTrue(answer.streamed) - assertEquals("""["EOSE"]""", answer.lines.last()) + assertEquals("""["EOSE","q"]""", answer.lines.last()) assertEquals( setOf(a.id, b.id), answer.lines @@ -193,23 +198,31 @@ class HttpRelayHandlerTest { ) } + @Test + fun framesGoOutAsTheSocketSendsThemWithTheClientsSubscriptionId() { + val found = note("found") + backend.events += found + val answer = handler().ask("""["REQ","mine",{"kinds":[1]}]""") + assertEquals(listOf("""["EVENT","mine",${found.toJson()}]""", """["EOSE","mine"]"""), answer.lines) + } + @Test fun anEmptyReqIsOneEoseLine() { - val answer = handler().ask(HttpRelayCommand.REQ, """[{"kinds":[30000]}]""") + val answer = handler().ask(req("""{"kinds":[30000]}""")) assertEquals(200, answer.status) - assertEquals(listOf("""["EOSE"]"""), answer.lines) + assertEquals(listOf("""["EOSE","q"]"""), answer.lines) } @Test fun anEventIsAnsweredByItsOkAndAForgeryIsRefused() { val posted = note("posted") - val ok = handler().ask(HttpRelayCommand.EVENT, posted.toJson()) + val ok = handler().ask(publish(posted)) assertEquals(200, ok.status) assertEquals(listOf("""["OK","${posted.id}",true,""]"""), ok.lines) assertTrue(backend.events.any { it.id == posted.id }) val forged = Event(posted.id, posted.pubKey, posted.createdAt, posted.kind, posted.tags, "tampered", posted.sig) - val refused = handler().ask(HttpRelayCommand.EVENT, forged.toJson()) + val refused = handler().ask(publish(forged)) assertEquals(400, refused.status, refused.lines.toString()) assertTrue(refused.lines.single().startsWith("""["OK","${forged.id}",false,"""), refused.lines.toString()) } @@ -217,76 +230,89 @@ class HttpRelayHandlerTest { @Test fun aCountIsOneCountLine() { backend.events += listOf(note("a"), note("b"), note("c")) - val answer = handler().ask(HttpRelayCommand.COUNT, """{"kinds":[1]}""") + val answer = handler().ask(count("""{"kinds":[1]}""")) assertEquals(200, answer.status) - assertTrue(answer.lines.single().startsWith("""["COUNT",{"count":3"""), answer.lines.toString()) + assertTrue(answer.lines.single().startsWith("""["COUNT","c",{"count":3"""), answer.lines.toString()) } @Test - fun aBodyThatIsNotTheCommandsArgumentsIsA400AndOneOverTheLimitA413() { - // Not the command's shape: refused before any session opens. - for ((command, body) in listOf( - HttpRelayCommand.REQ to "[]", - HttpRelayCommand.EVENT to """[{"id":"x"}]""", - HttpRelayCommand.COUNT to "not json", - )) { - val answer = handler().ask(command, body) - assertEquals(400, answer.status, "$command '$body'") - assertTrue(answer.lines.single().startsWith("""["CLOSED","invalid:"""), answer.lines.toString()) + fun aBodyThatIsNotAReqCountOrEventIsA400AndOneOverTheLimitA413() { + for (body in listOf("[]", """{"kinds":[1]}""", "not json", """["CLOSE","q"]""", """["AUTH",{"id":"x"}]""", """["NEG-CLOSE","n"]""")) { + val answer = handler().ask(body) + assertEquals(400, answer.status, body) + assertTrue(answer.lines.single().startsWith("""["NOTICE","invalid:"""), answer.lines.toString()) } - // The right shape with an inside the engine cannot read: its own NOTICE, the command never ran. - val unreadable = handler().ask(HttpRelayCommand.EVENT, """{"id":"not an event"}""") - assertEquals(400, unreadable.status) - assertTrue(unreadable.lines.single().startsWith("""["NOTICE","""), unreadable.lines.toString()) - assertEquals(413, handler().ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(5_000)}"}""").status) - // Under the byte cap, over it once wrapped in its frame: the engine measures the frame. - assertEquals(413, handler().ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(4_096 - 20)}"}""").status) + // A REQ the engine refuses as a command: its own NOTICE, the command never ran. + val empty = handler().ask("""["REQ","",{"kinds":[1]}]""") + assertEquals(400, empty.status) + assertTrue(empty.lines.single().startsWith("""["NOTICE","""), empty.lines.toString()) + // Over the relay's message length, in characters, as the socket measures it. + val big = handler().ask(req("""{"search":"${"x".repeat(4_096)}"}""")) + assertEquals(413, big.status) + assertTrue(big.lines.single().startsWith("""["NOTICE","invalid:"""), big.lines.toString()) } @Test fun aNip98SignatureSignsTheSessionInAndAnotherSchemeDoesNot() { backend.events += note("gated") val gated = handler(signedInOnly = true) - val body = """{"kinds":[1]}""" + val frame = req("""{"kinds":[1]}""") - val anonymous = gated.ask(HttpRelayCommand.REQ, body) + val anonymous = gated.ask(frame) assertEquals(401, anonymous.status) - assertTrue(anonymous.lines.single().startsWith("""["CLOSED","auth-required:""")) + assertTrue(anonymous.lines.single().startsWith("""["CLOSED","q","auth-required:""")) - assertEquals(401, gated.ask(HttpRelayCommand.REQ, body, "Basic dXNlcjpwYXNz").status, "Basic is not addressed to the relay") + assertEquals(401, gated.ask(frame, "Basic dXNlcjpwYXNz").status, "Basic is not addressed to the relay") - val signed = gated.ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body)) + val signed = gated.ask(frame, token(frame)) assertEquals(200, signed.status, signed.lines.toString()) - assertEquals("""["EOSE"]""", signed.lines.last()) + assertEquals("""["EOSE","q"]""", signed.lines.last()) } @Test - fun aTokenSignsOnlyItsBodyAndIsNotSingleUse() { + fun aTokenSignsOnlyItsBodyAndIsGoodAgainWithinItsWindow() { val h = handler() - val body = """{"kinds":[1]}""" - val signed = token(HttpRelayCommand.REQ, body) - assertEquals(200, h.ask(HttpRelayCommand.REQ, body, signed).status) - assertEquals(200, h.ask(HttpRelayCommand.REQ, body, signed).status, "any instance may answer it, so none remembers it") - val other = h.ask(HttpRelayCommand.REQ, """{"kinds":[0]}""", token(HttpRelayCommand.REQ, body)) + val frame = req("""{"kinds":[1]}""") + val signed = token(frame) + assertEquals(200, h.ask(frame, signed).status) + assertEquals(200, h.ask(frame, signed).status, "the same body again only repeats the read") + val other = h.ask(req("""{"kinds":[0]}"""), signed) assertEquals(401, other.status) assertTrue("payload" in other.lines.single(), other.lines.toString()) + assertTrue(other.lines.single().startsWith("""["CLOSED","q","auth-required:"""), other.lines.toString()) + } + + @Test + fun aTokenOutsideItsSixtySecondsIsRefused() { + val frame = req("""{"kinds":[1]}""") + val stale = alice.sign(HTTPAuthorizationEvent.build(origin, "POST", frame.encodeToByteArray(), System.currentTimeMillis() / 1000 - 120) {}).toAuthToken() + assertEquals(401, handler().ask(frame, stale).status) + } + + @Test + fun anEventRefusedForItsTokenIsAnOkFalse() { + val posted = publish(note("unsigned")) + val answer = handler().ask(posted, token(req("{}"))) + assertEquals(401, answer.status) + assertTrue(answer.lines.single().startsWith("""["OK","""), answer.lines.toString()) + assertTrue(""",false,"auth-required:""" in answer.lines.single(), answer.lines.toString()) } @Test fun noFirstFrameWithinTheDeadlineIsA503() { - val answer = handler(deadline = 300.milliseconds).ask(HttpRelayCommand.REQ, """{"kinds":[$STALLED_KIND]}""") + val answer = handler(deadline = 300.milliseconds).ask(req("""{"kinds":[$STALLED_KIND]}""")) assertEquals(503, answer.status) - assertTrue(answer.lines.single().startsWith("""["CLOSED","error: no answer""")) + assertTrue(answer.lines.single().startsWith("""["NOTICE","error: no answer""")) } @Test fun aDeadlineMidAnswerEndsOnAClosedLine() { val found = note("found") backend.events += found - val answer = handler(deadline = 300.milliseconds).ask(HttpRelayCommand.REQ, """{"kinds":[1,$TRICKLE_KIND]}""") + val answer = handler(deadline = 300.milliseconds).ask(req("""{"kinds":[1,$TRICKLE_KIND]}""")) assertEquals(200, answer.status) assertTrue(found.id in answer.lines.first()) - assertTrue(answer.lines.last().startsWith("""["CLOSED","error: the answer ran past"""), answer.lines.toString()) + assertTrue(answer.lines.last().startsWith("""["CLOSED","q","error: the answer ran past"""), answer.lines.toString()) } @Test @@ -308,7 +334,7 @@ class HttpRelayHandlerTest { }.lines() } assertFailsWith { - runBlocking { h.handle(HttpRelayRequest(HttpRelayCommand.REQ, null, """{"kinds":[1,$TRICKLE_KIND]}""".encodeToByteArray()), stalled) } + runBlocking { h.handle(HttpRelayRequest(null, req("""{"kinds":[1,$TRICKLE_KIND]}""").encodeToByteArray()), stalled) } } } @@ -322,26 +348,17 @@ class HttpRelayHandlerTest { session.close() } - @Test - fun framesCarryNoSubscriptionId() { - val found = note("found") - backend.events += found - val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}""") - assertTrue(answer.lines.first().startsWith("""["EVENT",{"""), answer.lines.toString()) - assertEquals("""["EOSE"]""", answer.lines.last()) - } - @Test fun aDeeplyNestedBodyIsA400NotAStackOverflow() { - for (body in listOf("""{"a":""".repeat(2_000) + "1" + "}".repeat(2_000), "[".repeat(20_000) + "]".repeat(20_000))) { - val answer = HttpRelayHandler(MemoryRelay(backend, { VerifyPolicy }, limits = null), origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body) + for (body in listOf(req("""{"a":""".repeat(2_000) + "1" + "}".repeat(2_000)), "[".repeat(20_000) + "]".repeat(20_000))) { + val answer = HttpRelayHandler(MemoryRelay(backend, { VerifyPolicy }, limits = null), origins = { listOf(origin) }).ask(body) assertEquals(400, answer.status, body.take(20)) } } @Test fun aFullAuthPolicyRefusesTransportSignInUntilItOptsIn() { - val body = """{"kinds":[1]}""" + val frame = req("""{"kinds":[1]}""") val relayUrl = RelayUrlNormalizer.normalize("wss://relay.example") val refusing = MemoryRelay(backend, { @@ -349,9 +366,9 @@ class HttpRelayHandlerTest { override suspend fun authorize(event: RelayAuthEvent): Unit = error("backend rejected user") } }) - val refused = HttpRelayHandler(refusing, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body)) + val refused = HttpRelayHandler(refusing, origins = { listOf(origin) }).ask(frame, token(frame)) assertEquals(403, refused.status, refused.lines.toString()) - assertTrue(refused.lines.single().startsWith("""["CLOSED","restricted:"""), refused.lines.toString()) + assertTrue(refused.lines.single().startsWith("""["CLOSED","q","restricted:"""), refused.lines.toString()) val optingIn = MemoryRelay(backend, { @@ -359,14 +376,14 @@ class HttpRelayHandlerTest { override suspend fun authorizeTransport(pubkey: HexKey): String? = null } }) - val signed = HttpRelayHandler(optingIn, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, body, token(HttpRelayCommand.REQ, body)) + val signed = HttpRelayHandler(optingIn, origins = { listOf(origin) }).ask(frame, token(frame)) assertEquals(200, signed.status, signed.lines.toString()) } @Test fun aMessageLimitInThePolicyChainStillRuns() { val limited = MemoryRelay(backend, { LimitsPolicy(RelayLimits(maxMessageLength = 4096)) + VerifyPolicy }, limits = null) - val answer = HttpRelayHandler(limited, origins = { listOf(origin) }).ask(HttpRelayCommand.REQ, """{"search":"${"x".repeat(20_000)}"}""") + val answer = HttpRelayHandler(limited, origins = { listOf(origin) }).ask(req("""{"search":"${"x".repeat(20_000)}"}""")) assertEquals(400, answer.status, answer.lines.toString()) assertTrue(answer.lines.single().startsWith("""["NOTICE","invalid: message too large"""), answer.lines.toString()) } @@ -374,13 +391,12 @@ class HttpRelayHandlerTest { @Test fun aMultiByteEventUnderTheCharacterLimitIsAccepted() { // 1,500 CJK characters: about 4,500 UTF-8 bytes, well under 4,096 characters as the engine counts. - val posted = note("\u4E2D".repeat(1_500)) - val answer = handler().ask(HttpRelayCommand.EVENT, posted.toJson()) + val answer = handler().ask(publish(note("中".repeat(1_500)))) assertEquals(200, answer.status, answer.lines.toString()) } @Test - fun aBackendFailureIsA500Line() { + fun aBackendFailureIsA500OkFalse() { val failing = object : SessionBackend by backend { override suspend fun submit( @@ -388,17 +404,18 @@ class HttpRelayHandlerTest { onComplete: (IEventStore.InsertOutcome) -> Unit, ): Unit = error("db is down") } - val answer = HttpRelayHandler(MemoryRelay(failing, { VerifyPolicy }), origins = { listOf(origin) }).ask(HttpRelayCommand.EVENT, note("lost").toJson()) + val lost = note("lost") + val answer = HttpRelayHandler(MemoryRelay(failing, { VerifyPolicy }), origins = { listOf(origin) }).ask(publish(lost)) assertEquals(500, answer.status, answer.lines.toString()) - assertTrue(answer.lines.single().startsWith("""["CLOSED","error:"""), answer.lines.toString()) + assertTrue(answer.lines.single().startsWith("""["OK","${lost.id}",false,"error:"""), answer.lines.toString()) } @Test fun anInfiniteDeadlineStillStreams() { backend.events += note("forever") - val answer = handler(deadline = Duration.INFINITE).ask(HttpRelayCommand.REQ, """{"kinds":[1]}""") + val answer = handler(deadline = Duration.INFINITE).ask(req("""{"kinds":[1]}""")) assertEquals(200, answer.status) - assertEquals("""["EOSE"]""", answer.lines.last()) + assertEquals("""["EOSE","q"]""", answer.lines.last()) } @Test @@ -414,37 +431,27 @@ class HttpRelayHandlerTest { override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) = error("single") } assertFailsWith { - runBlocking { h.handle(HttpRelayRequest(HttpRelayCommand.COUNT, null, """{"kinds":[1]}""".encodeToByteArray()), stalled) } + runBlocking { h.handle(HttpRelayRequest(null, count("""{"kinds":[1]}""").encodeToByteArray()), stalled) } } } - @Test - fun aBodyIsSplicedIntoItsFrameAsSent() { - assertEquals("""["REQ","http",{"kinds":[1]}]""", HttpRelayCommand.REQ.frameOf(""" {"kinds":[1]} """)) - assertEquals("""["COUNT","http",{"a":"]"},{"b":"\"["}]""", HttpRelayCommand.COUNT.frameOf("""[{"a":"]"},{"b":"\"["}]""")) - assertEquals("""["EVENT",{"id":"x"}]""", HttpRelayCommand.EVENT.frameOf("""{"id":"x"}""")) - for (bad in listOf("", "[]", "[ ]", "[{}", "1", "null", "\"x\"")) assertEquals(null, HttpRelayCommand.REQ.frameOf(bad), "REQ '$bad'") - for (bad in listOf("""[{"id":"x"}]""", "1")) assertEquals(null, HttpRelayCommand.EVENT.frameOf(bad), "EVENT '$bad'") - } - @Test fun aBodyCannotCarryASecondCommand() { val smuggled = note("smuggled") - val answer = handler().ask(HttpRelayCommand.REQ, """{"kinds":[1]}],["EVENT",${smuggled.toJson()}""") - assertTrue(answer.lines.last().let { it == """["EOSE"]""" || it.startsWith("""["NOTICE",""") }, answer.lines.toString()) + val answer = handler().ask(req("""{"kinds":[1]}""") + publish(smuggled)) + assertTrue(answer.lines.last().let { it == """["EOSE","q"]""" || it.startsWith("""["NOTICE",""") }, answer.lines.toString()) assertTrue(backend.events.none { it.id == smuggled.id }, "only the REQ ran") } @Test fun aTokenSignedAtAnyOfTheRelaysAddressesVerifies() { - val onion = "http://relayxyz.onion" + val onion = "http://relayxyz.onion/" val h = HttpRelayHandler(MemoryRelay(backend, true), origins = { listOf(origin, onion) }) - val body = """{"kinds":[1]}""" - - fun at(base: String) = alice.sign(HTTPAuthorizationEvent.build(base + "/req", "POST", body.encodeToByteArray(), System.currentTimeMillis() / 1000) {}).toAuthToken() - assertEquals(200, h.ask(HttpRelayCommand.REQ, body, at(onion)).status) - assertEquals(200, h.ask(HttpRelayCommand.REQ, body, at(origin)).status) - assertEquals(401, h.ask(HttpRelayCommand.REQ, body, at("https://elsewhere.example")).status) + val frame = req("""{"kinds":[1]}""") + assertEquals(200, h.ask(frame, token(frame, onion)).status) + assertEquals(200, h.ask(frame, token(frame, "http://relayxyz.onion")).status, "with or without the trailing slash") + assertEquals(200, h.ask(frame, token(frame, "$origin/")).status) + assertEquals(401, h.ask(frame, token(frame, "https://elsewhere.example")).status) } private companion object { From af8fb44d466b0627d4dc5f9026280422f0862b51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:01:32 +0000 Subject: [PATCH 21/32] fix(nav): keep the bottom bar on screens opened from the drawer Since the April change that hid AppBottomBar on every canPop() entry, any section picked from the navigation drawer (Pictures, Articles, Wallet, Settings, the own profile, ...) lost the bottom bar, because the drawer pushes those screens like any in-app navigation. Fixes #4141. The drawer now opens its destinations through INav.navDrawer, which stamps the new back-stack entry with DRAWER_ROOT_KEY, and the bar asks INav.showsBottomBar() instead of !canPop(): tab roots, Home and drawer destinations show it, in-app pushes (a profile or chat opened from a note, etc.) still don't. The back arrow is unchanged, since drawer screens can still pop. FabBottomBarPadding follows the same rule so FABs don't float 50dp above the bar that now shows. navBottomBar already drops any non-tab- root entry before switching tabs, so tapping a tab from a drawer screen lands on the tab without saving the drawer screen into it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018otSD34gHeE1RT31UgzY1b --- .../ui/navigation/NavigationEffects.kt | 8 ++ .../ui/navigation/bottombars/AppBottomBar.kt | 8 +- .../ui/navigation/drawer/DrawerContent.kt | 12 +- .../amethyst/ui/navigation/navs/Nav.kt | 55 ++++++-- .../concord/ConcordChannelListScreen.kt | 2 +- .../concord/ConcordHomeScreen.kt | 2 +- .../PublicChatChannelScreen.kt | 2 +- .../relayGroup/RelayGroupChannelListScreen.kt | 2 +- .../relayGroup/RelayGroupChatScreen.kt | 2 +- .../ui/navigation/NavBottomBarStackTest.kt | 4 +- .../amethyst/ui/navigation/NavDrawerTest.kt | 118 ++++++++++++++++++ .../ui/layouts/DisappearingScaffold.kt | 2 +- .../bottombars/FabBottomBarPadding.kt | 6 +- .../commons/ui/navigation/navs/INav.kt | 17 +++ 14 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavDrawerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index d7e44af2db..31ad12ee41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -54,6 +54,14 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot" fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) == true +// Per-entry hint stamped by Nav.navDrawer marking that the entry was opened +// from the navigation drawer. It sits on top of the stack like any push (back +// arrow, slide animation), but Nav.showsBottomBar keeps the bottom bar on it: +// a drawer destination is a top-level section, not a detail screen. +const val DRAWER_ROOT_KEY = "drawerRoot" + +fun NavBackStackEntry.isDrawerRoot(): Boolean = savedStateHandle.get(DRAWER_ROOT_KEY) == true + /** * The shell's current layout tier, mirrored for the transition specs below. Transition * lambdas run when a navigation starts — outside composition — so they can't read diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index ec0ca2e514..efaf7c00ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -68,7 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** Content height of the [AppBottomBar] (the 50.dp Column inside [RenderBottomMenu]), * exclusive of the system navigation-bar inset. Used by FAB callers that want to - * reserve the same vertical space when the bar hides itself on canPop entries. */ + * reserve the same vertical space when the bar hides itself on in-app pushes. */ val AppBottomBarHeight = 50.dp @Composable @@ -94,9 +94,9 @@ fun AppBottomBar( // permanently docked drawer (Expanded). if (LocalScreenLayout.current.isLargeScreen) return - // Hide the bar on entries that aren't a tab root (drawer or in-app - // pushes). Mirrors the back-arrow rule in canPop(). - if (nav.canPop()) return + // Hide the bar on in-app pushes. Tab roots, Home and screens opened from + // the drawer keep it, even though the drawer ones still show a back arrow. + if (!nav.showsBottomBar()) return val items by accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems .collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index be821834a5..ca068e3aba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -210,7 +210,7 @@ private fun DrawerContentBody( accountViewModel: AccountViewModel, ) { val onClickUser = { - nav.nav(routeFor(accountViewModel.userProfile())) + nav.navDrawer(routeFor(accountViewModel.userProfile())) nav.closeDrawer() } @@ -742,7 +742,7 @@ private fun ScheduledPostsNavigationRow( badgeCount = pendingCount, onClick = { nav.closeDrawer() - nav.nav { def.resolveRoute(accountViewModel) } + nav.navDrawer { def.resolveRoute(accountViewModel) } }, ) } @@ -850,7 +850,7 @@ fun NavigationRow( tint, onClick = { nav.closeDrawer() - nav.nav(route) + nav.navDrawer(route) }, ) } @@ -871,7 +871,7 @@ fun NavigationRow( tint, onClick = { nav.closeDrawer() - nav.nav(computeRoute) + nav.navDrawer(computeRoute) }, ) } @@ -890,7 +890,7 @@ fun NavigationRow( tint = tint, onClick = { nav.closeDrawer() - nav.nav(route) + nav.navDrawer(route) }, ) } @@ -909,7 +909,7 @@ fun NavigationRow( tint = tint, onClick = { nav.closeDrawer() - nav.nav(computeRoute) + nav.navDrawer(computeRoute) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 0818cc8a42..41780eb4e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -36,7 +36,9 @@ import androidx.navigation.NavHostController import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.BOTTOM_NAV_ROOT_KEY +import com.vitorpamplona.amethyst.ui.navigation.DRAWER_ROOT_KEY import com.vitorpamplona.amethyst.ui.navigation.isBottomNavRoot +import com.vitorpamplona.amethyst.ui.navigation.isDrawerRoot import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -89,6 +91,31 @@ class Nav( } } + override fun navDrawer(route: Route) { + navigationScope.launch { + ime.settle() + navigateFromDrawer(route) + } + } + + override fun navDrawer(computeRoute: suspend () -> Route?) { + navigationScope.launch { + ime.settle() + computeRoute()?.let { navigateFromDrawer(it) } + } + } + + /** + * Same push as [nav], then stamps the new entry [DRAWER_ROOT_KEY] so [showsBottomBar] keeps + * the bottom bar on it. The stamp lands in the same frame as the navigate, before the entry + * composes, the way [navBottomBar] stamps its tab roots. + */ + private fun navigateFromDrawer(route: Route) { + if (getRouteWithArguments(route::class, controller) == route) return + controller.navigate(route) + controller.currentBackStackEntry?.savedStateHandle?.set(DRAWER_ROOT_KEY, true) + } + override fun newStack(route: Route) { navigationScope.launch { ime.settle() @@ -107,9 +134,10 @@ class Nav( // A nav-bar tap asks for a tab, never for whatever the user pushed on top of one. Drop // those pushes first, and without saving them, so the restoreState below can never hand - // a deep stack back. On phones this is always a no-op — AppBottomBar hides itself off - // tab roots, so the bar is only ever tapped from one — but the large-screen rail stays - // on screen the whole time and is routinely tapped from three screens deep. + // a deep stack back. On phones the bar shows only on tab roots and on screens opened + // from the drawer (dropped here, back to the tab they were opened over), but the + // large-screen rail stays on screen the whole time and is routinely tapped from three + // screens deep. popPushesAboveTabRoot() // Dropping those pushes is often the whole job — re-tapping the tab the user is inside, @@ -211,11 +239,24 @@ class Nav( // Outside a NavHost destination (shell chrome, drawer) the current owner // is the account-scoped ViewModelStoreOwner, not an entry; fall back to // the globally-current entry so those callers keep their prior behavior. - val entry = - (LocalViewModelStoreOwner.current as? NavBackStackEntry) - ?: controller.currentBackStackEntry - ?: return false + val entry = ownEntry() ?: return false + return canPop(entry) + } + @Composable + override fun showsBottomBar(): Boolean { + val entry = ownEntry() ?: return true + // Drawer destinations can pop (they sit on whatever screen the drawer was opened over), + // but they are top-level sections, so they keep the bar the tab roots have. + return entry.isDrawerRoot() || !canPop(entry) + } + + @Composable + private fun ownEntry(): NavBackStackEntry? = + (LocalViewModelStoreOwner.current as? NavBackStackEntry) + ?: controller.currentBackStackEntry + + private fun canPop(entry: NavBackStackEntry): Boolean { // Hidden on tab roots (reached via the bottom nav) and on Home (the // graph's start destination): nothing sits below either that a back // arrow could return to. Every other entry is a push on top of Home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index d95788cda4..8e12603fd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -353,7 +353,7 @@ fun ConcordChannelListScreen( ) }, bottomBar = { - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so a pinned Concord community works both as a pushed detail and as a bottom-nav tab. AppBottomBar(Route.ConcordServer(communityId), nav, accountViewModel) { route -> if (route != Route.ConcordServer(communityId)) nav.navBottomBar(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 95a86e69f7..5852cb0061 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -137,7 +137,7 @@ fun ConcordHomeScreen( ) }, bottomBar = { - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so the same screen works both as a pushed destination and a bottom-nav tab. AppBottomBar(Route.Concords, nav, accountViewModel) { route -> if (route != Route.Concords) nav.navBottomBar(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt index da19432246..10e544030b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/PublicChatChannelScreen.kt @@ -56,7 +56,7 @@ fun PublicChatChannelScreen( PublicChatTopBar(it, accountViewModel, nav) } }, - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so a pinned public chat works both as a pushed detail and as a bottom-nav tab. bottomBar = { AppBottomBar(selfRoute, nav, accountViewModel) { route -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 30efeb7f3b..0bd74d7ba7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -439,7 +439,7 @@ fun RelayGroupChannelListScreen( ) }, bottomBar = { - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so a pinned NIP-29 relay works both as a pushed detail and as a bottom-nav tab. AppBottomBar(selfRoute, nav, accountViewModel) { route -> if (route != selfRoute) nav.navBottomBar(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt index d6b6f376a1..e938259b76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChatScreen.kt @@ -58,7 +58,7 @@ fun RelayGroupChatScreen( RelayGroupTopBar(it, inviteCode, accountViewModel, nav) } }, - // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // Hidden on in-app pushes (AppBottomBar renders only when nav.showsBottomBar()), // so a pinned relay group works both as a pushed detail and as a bottom-nav tab. bottomBar = { AppBottomBar(selfRoute, nav, accountViewModel) { route -> diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt index edc44460a9..cac7218dfd 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavBottomBarStackTest.kt @@ -42,8 +42,8 @@ import org.junit.Test /** * A nav-bar tap must land on the tab itself, never on whatever the user had pushed on top of one. * - * The phone bottom bar gets this for free — it hides itself off tab roots, so it can only ever be - * tapped from one. The large-screen navigation rail stays on screen the whole time, which is where + * The phone bottom bar shows only on tab roots and drawer destinations, so it is tapped from at most + * one screen above a tab. The large-screen navigation rail stays on screen the whole time, which is where * the gap showed: tapping Home from a thread three screens deep came back to that thread instead of * the feed. * diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavDrawerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavDrawerTest.kt new file mode 100644 index 0000000000..580b1f63b6 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/navigation/NavDrawerTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.navigation + +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavHostController +import com.vitorpamplona.amethyst.commons.model.navigation.Route +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * A screen opened from the navigation drawer is a top-level section: it keeps the bottom bar + * (issue #4141, where Pictures and every other drawer destination lost it). It still sits on top of + * the screen the drawer was opened over, so it keeps its back arrow too; the bar is told apart + * from an in-app push by the [DRAWER_ROOT_KEY] stamp these tests pin down. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NavDrawerTest { + private fun entry( + isTabRoot: Boolean = false, + isDrawerRoot: Boolean = false, + ): NavBackStackEntry = + mockk(relaxed = true) { + every { savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) } returns isTabRoot + every { savedStateHandle.get(DRAWER_ROOT_KEY) } returns isDrawerRoot + } + + /** A controller whose current entry becomes [pushed] once [route] is navigated to. */ + private fun controllerPushing( + route: Route, + pushed: NavBackStackEntry, + ): NavHostController { + var current = entry(isTabRoot = true) + return mockk(relaxed = true) { + every { currentBackStackEntry } answers { current } + every { navigate(route) } answers { current = pushed } + } + } + + @Test + fun stampsTheEntryItOpens() = + runTest { + val pushed = entry() + val controller = controllerPushing(Route.Pictures(), pushed) + + Nav(controller, this).navDrawer(Route.Pictures()) + advanceUntilIdle() + + verify(exactly = 1) { controller.navigate(Route.Pictures()) } + verify(exactly = 1) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = true } + } + + @Test + fun stampsTheEntryOfAResolvedRoute() = + runTest { + val pushed = entry() + val controller = controllerPushing(Route.Articles, pushed) + + Nav(controller, this).navDrawer { Route.Articles } + advanceUntilIdle() + + verify(exactly = 1) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = true } + } + + @Test + fun plainPushesAreNotStamped() = + runTest { + val pushed = entry() + val controller = controllerPushing(Route.Pictures(), pushed) + + Nav(controller, this).nav(Route.Pictures()) + advanceUntilIdle() + + verify(exactly = 0) { pushed.savedStateHandle[DRAWER_ROOT_KEY] = any() } + } + + @Test + fun aTabTappedFromADrawerScreenDropsItFirst() = + runTest { + // Home > Pictures (from the drawer): the bar now shows on Pictures, so it can be tapped + // from there. The drawer entry is not a tab root and must not be saved into the tab. + val controller = + mockk(relaxed = true) { + every { currentBackStackEntry } returnsMany + listOf(entry(isDrawerRoot = true), entry(isTabRoot = true)) + every { popBackStack() } returns true + } + + Nav(controller, this).navBottomBar(Route.Message) + advanceUntilIdle() + + verify(exactly = 1) { controller.popBackStack() } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingScaffold.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingScaffold.kt index 972ba2c8b0..7db01dd845 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingScaffold.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/DisappearingScaffold.kt @@ -199,7 +199,7 @@ private fun ScaffoldLayout( }.firstOrNull()?.measure(looseConstraints) } // When the bar lambda is provided but its content emits nothing (e.g. AppBottomBar - // hides itself on canPop entries, or while the keyboard is up), reserve the + // hides itself on in-app pushes, or while the keyboard is up), reserve the // system-nav-bar inset so the FAB and content stay clear of the navigation bar instead // of sliding under it. Subtract the IME inset: the root imePadding has already lifted the // whole scaffold above the keyboard, and the IME inset spans the nav-bar band, so diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/FabBottomBarPadding.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/FabBottomBarPadding.kt index 723231f179..592ee4cd94 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/FabBottomBarPadding.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/bottombars/FabBottomBarPadding.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav /** * Reserves the visual space the `AppBottomBar` occupies on root tab entries so a * FloatingActionButton stays at the same vertical position whether or not the bar is - * rendered. `AppBottomBar` hides itself on canPop entries (drawer pushes, in-app + * rendered. `AppBottomBar` hides itself off [INav.showsBottomBar] entries (in-app * navigations) — without this padding the FAB drops by `AppBottomBarHeight` there. * * The system-navigation-bar inset is already handled by the surrounding Scaffold, so @@ -41,8 +41,8 @@ val FABPaddingFromBottom = 30.dp @Composable fun Modifier.fabBottomBarPadding(nav: INav): Modifier = - if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) { - // canPop entries hide the bar on phones; large screens never render it at all. + if (!nav.showsBottomBar() || LocalScreenLayout.current.isLargeScreen) { + // In-app pushes hide the bar on phones; large screens never render it at all. padding(bottom = FABPaddingFromBottom) } else { this diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt index 08261b7691..9a3a5d3261 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt @@ -52,9 +52,26 @@ interface INav { fun navBottomBar(route: Route) + /** + * Opens [route] as a top-level destination picked from the navigation drawer. It still + * stacks on top of the current screen (so it [canPop]), but unlike an in-app push it keeps + * the bottom bar — see [showsBottomBar]. + */ + fun navDrawer(route: Route) = nav(route) + + /** [navDrawer], for a route that has to be resolved first. */ + fun navDrawer(computeRoute: suspend () -> Route?) = nav(computeRoute) + @Composable fun canPop(): Boolean + /** + * Whether this screen renders the bottom bar: tab roots, the start destination and + * destinations opened from the drawer ([navDrawer]) do; in-app pushes don't. + */ + @Composable + fun showsBottomBar(): Boolean = !canPop() + fun popBack() fun popUpTo( From 4397d82c81bff27ccfe07ca60cc4e2415513caa7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 27 Sep 2026 19:01:58 +0200 Subject: [PATCH 22/32] Upgrade AGP to 9.4.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0aa82c3a96..c449266534 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ activityCompose = "1.13.0" # 9.4.0's PerModuleBundleTask rejects an AAB entry whose name contains a colon, which every # .kotlin_module named after a Gradle project path has. Worked around in amethyst/build.gradle.kts # (stripColonNamedEntries) rather than by pinning to 9.3.1; drop that block if AGP fixes it. -agp = "9.4.0" +agp = "9.4.1" android-compileSdk = "37" android-minSdk = "26" android-targetSdk = "37" From 8f3e74d2294baa27d046a206b959f7572cd8ee5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 17:07:14 +0000 Subject: [PATCH 23/32] feat: gzip NIP-FE answers with a sync flush; pluggable HTTP transport geode: - Streamed answers are gzipped when the client sends Accept-Encoding: gzip (not q=0), with Vary: Accept-Encoding. GzipLines sync-flushes at every flush the handler makes, so lines still arrive as they are found; compressed bytes also move to the socket past 16 KiB so a long burst keeps backpressure. JDK zip only, no new dependency. One-line answers stay plain. [http].gzip turns it off. - Tests hold EOSE in the store after the first event and require that event to arrive, inflated, before EOSE is released: read raw through okio's GzipSource and through HttpRelayClient. Both time out when the sync flush is turned off. quartz: - HttpRelayClient moves to commonMain over an HttpRelayTransport interface, as NostrClient sits over a WebsocketBuilder. OkHttpRelayTransport (jvmAndroid) picks the OkHttpClient per relay, like BasicOkHttpWebSocket.Builder, so .onion relays can go via Tor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RbNrTdV2e7kW5S9tkMoPgh --- geode/README.md | 7 +- geode/config.example.toml | 4 + .../geode/config/StaticConfig.kt | 3 + .../vitorpamplona/geode/server/GzipLines.kt | 74 +++++++++++++ .../geode/server/HttpCommandSettings.kt | 2 + .../geode/server/NipFEHttpRoute.kt | 61 +++++++++-- .../com/vitorpamplona/geode/NipFEHttpTest.kt | 103 +++++++++++++++++- .../nipFERelayOverHttp/HttpRelayClient.kt | 93 +++++----------- .../nipFERelayOverHttp/HttpRelayTransport.kt | 47 ++++++++ .../OkHttpRelayTransport.kt | 92 ++++++++++++++++ 10 files changed, 408 insertions(+), 78 deletions(-) create mode 100644 geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt rename quartz/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt (61%) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayTransport.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/OkHttpRelayTransport.kt diff --git a/geode/README.md b/geode/README.md index a32af00068..11732aaad9 100644 --- a/geode/README.md +++ b/geode/README.md @@ -108,7 +108,7 @@ URL: the body is the frame you would send on the socket, and the answer is the socket's frames as NDJSON, streamed — no socket, no subscription left open: ```bash -curl -N -d '["REQ","q",{"kinds":[1],"limit":2}]' http://localhost:7447/ +curl -N --compressed -d '["REQ","q",{"kinds":[1],"limit":2}]' http://localhost:7447/ # ["EVENT","q",{"id":"…","kind":1,…}] # ["EVENT","q",{"id":"…","kind":1,…}] # ["EOSE","q"] @@ -121,7 +121,10 @@ A body that does not end on `EOSE`/`CLOSED` (REQ), `COUNT`/`CLOSED` (COUNT) or request carries a NIP-98 `Authorization: Nostr …` header whose `u` is the relay's http URL and whose `payload` is the body's sha256. NIP-86 admin calls share the URL, told apart by `Content-Type: application/nostr+json+rpc`. -Quartz's `HttpRelayClient` does all of this for JVM/Android clients. +Streamed answers are gzipped for clients that send `Accept-Encoding: gzip` +(`curl --compressed`), sync-flushed so events still arrive as they are found; +`[http].gzip = false` turns it off. Quartz's `HttpRelayClient` does all of this +for clients, over OkHttp via `OkHttpRelayTransport` or any `HttpRelayTransport`. ## Verbs diff --git a/geode/config.example.toml b/geode/config.example.toml index a2a46fe6c7..891ed025dc 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -185,6 +185,10 @@ max_requests_per_client = 16 # An answer still running after this ends on a CLOSED line. deadline_seconds = 30 max_body_bytes = 524288 +# Gzip streamed answers for clients that send Accept-Encoding: gzip. Each +# batch of lines is sync-flushed, so events still arrive as they are +# found. Turn off if a proxy in front already compresses. +gzip = true retry_after_seconds = 1 # Other addresses this relay is reachable at: a NIP-98 token may name # any of them, as well as [info].relay_url. diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index b3d888565c..6212aa7178 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -246,6 +246,8 @@ data class StaticConfig( val deadline_seconds: Long = 30, /** Largest request body read; larger is 413. */ val max_body_bytes: Int = 512 * 1024, + /** Gzip streamed answers when the client sends `Accept-Encoding: gzip`, flushed line by line. */ + val gzip: Boolean = true, /** `Retry-After` on the relay's own 429 and 503. */ val retry_after_seconds: Int = 1, /** @@ -270,6 +272,7 @@ data class StaticConfig( maxPerClient = max_requests_per_client, deadline = deadline_seconds.seconds, maxBodyBytes = max_body_bytes, + compress = gzip, retryAfterSeconds = retry_after_seconds, alternateUrls = alternate_urls.map { it.normalizeRelayUrl() }, trustedProxies = trusted_proxies.toSet(), diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt new file mode 100644 index 0000000000..aa4f4c4e22 --- /dev/null +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.geode.server + +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.writeFully +import java.io.ByteArrayOutputStream +import java.util.zip.GZIPOutputStream + +/** + * A gzip body written line by line onto [out], for NIP-FE's streamed answers. Every [flush] is a + * gzip sync flush: the deflater gives up everything it holds, byte-aligned, so the client can + * inflate every line written so far. Without it the compressor sits on the first lines until its + * window fills, and streaming delivers nothing until the answer is nearly done. + */ +internal class GzipLines( + private val out: ByteWriteChannel, +) { + private val compressed = ByteArrayOutputStream(BUFFER) + private val gzip = GZIPOutputStream(compressed, BUFFER, true) + + /** Compresses [line] and its newline. Moves compressed bytes to the socket once enough piled up, so a long burst still meets backpressure. */ + suspend fun line(line: String) { + gzip.write(line.encodeToByteArray()) + gzip.write(NEWLINE) + if (compressed.size() >= BUFFER) drain() + } + + /** A sync flush, then everything onto the socket. */ + suspend fun flush() { + gzip.flush() + drain() + out.flush() + } + + /** Writes the gzip trailer; the body is complete after this. */ + suspend fun finish() { + gzip.finish() + drain() + out.flush() + } + + /** Frees the deflater, finished or not. */ + fun close() = gzip.close() + + private suspend fun drain() { + if (compressed.size() == 0) return + out.writeFully(compressed.toByteArray()) + compressed.reset() + } + + companion object { + private const val BUFFER = 16 * 1024 + private val NEWLINE = byteArrayOf('\n'.code.toByte()) + } +} diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt index 5d1017889e..0508511eb7 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt @@ -39,6 +39,8 @@ data class HttpCommandSettings( val deadline: Duration = HttpRelayHandler.DEFAULT_DEADLINE, /** The largest body read; the engine's own message limit, when it has one and it is smaller, wins. */ val maxBodyBytes: Int = 512 * 1024, + /** Gzip streamed answers for clients that accept it, sync-flushed so lines still arrive as found. */ + val compress: Boolean = true, /** The `Retry-After` sent with a 429 or 503 the relay decides itself. */ val retryAfterSeconds: Int = 1, /** Other URLs this relay answers at (its .onion); a NIP-98 `u` may name any of them. */ diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt index 77b7ac51b3..8980c3031c 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt @@ -132,6 +132,27 @@ internal class NipFEHttpRoute( ?.ifEmpty { null } ?: peer } + /** + * Whether `Accept-Encoding` takes gzip: listed by name or as `*`, without `q=0`. A one-line + * answer is never compressed; only a streamed one is worth it. + */ + private fun acceptsGzip(call: ApplicationCall): Boolean = + call.request + .header(HttpHeaders.AcceptEncoding) + .orEmpty() + .split(',') + .any { entry -> + val parts = entry.split(';').map { it.trim() } + val coding = parts.first().lowercase() + val q = + parts + .drop(1) + .firstOrNull { it.startsWith("q=") } + ?.removePrefix("q=") + ?.toDoubleOrNull() ?: 1.0 + (coding == "gzip" || coding == "*") && q > 0.0 + } + /** A one-line answer: a refusal, or a command answered at once. */ private suspend fun respondLine( call: ApplicationCall, @@ -153,19 +174,41 @@ internal class NipFEHttpRoute( frame: String, ) = respondLine(call, status, frame) - /** Chunked: each flush puts what the handler wrote on the wire, and a full socket suspends the writer. */ - override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) = + /** + * Chunked: each flush puts what the handler wrote on the wire, and a full socket suspends the + * writer. Gzipped when the client takes it and the relay allows it, sync-flushed at every + * flush so the lines still arrive as they are found. + */ + override suspend fun stream(lines: suspend HttpRelayLines.() -> Unit) { + val gzip = settings.compress && acceptsGzip(call) + call.response.header(HttpHeaders.Vary, HttpHeaders.AcceptEncoding) + if (gzip) call.response.header(HttpHeaders.ContentEncoding, "gzip") call.respondBytesWriter(NDJSON, HttpStatusCode.OK) { val out = this - object : HttpRelayLines { - override suspend fun line(frame: String) { - out.writeStringUtf8(frame) - out.writeStringUtf8("\n") - } + if (gzip) { + val body = GzipLines(out) + try { + object : HttpRelayLines { + override suspend fun line(frame: String) = body.line(frame) - override suspend fun flush() = out.flush() - }.lines() + override suspend fun flush() = body.flush() + }.lines() + body.finish() + } finally { + body.close() + } + } else { + object : HttpRelayLines { + override suspend fun line(frame: String) { + out.writeStringUtf8(frame) + out.writeStringUtf8("\n") + } + + override suspend fun flush() = out.flush() + }.lines() + } } + } } companion object { diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt index 8650bfb503..0403e7fde4 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt @@ -31,21 +31,34 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.RawEvent +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayClient +import com.vitorpamplona.quartz.nipFERelayOverHttp.OkHttpRelayTransport +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import okio.GzipSource +import okio.buffer import java.net.ServerSocket +import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -53,6 +66,7 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * NIP-FE end to end: quartz's [HttpRelayClient] and raw OkHttp requests against a real [KtorRelay], @@ -77,16 +91,18 @@ class NipFEHttpTest { path: String = "/", settings: HttpCommandSettings? = HttpCommandSettings(), policy: ((NormalizedRelayUrl) -> IRelayPolicy)? = null, + store: (IEventStore) -> IEventStore = { it }, ): NormalizedRelayUrl { val port = ServerSocket(0).use { it.localPort } val url = "ws://127.0.0.1:$port$path".normalizeRelayUrl() - val relay = if (policy == null) RelayEngine(url) else RelayEngine(url, policyBuilder = { policy(url) }) + val events = store(EventStore(dbName = null, relay = url, indexStrategy = RelayIndexingStrategy)) + val relay = RelayEngine(url, events, policyBuilder = { policy?.invoke(url) ?: EmptyPolicy }) val server = KtorRelay(relay, host = "127.0.0.1", port = port, path = path, httpCommands = settings).start() running += server to relay return url } - private fun client(signer: NostrSignerInternal? = null) = HttpRelayClient(http, signer) + private fun client(signer: NostrSignerInternal? = null) = HttpRelayClient(OkHttpRelayTransport { http }, signer) private suspend fun note(text: String): Event = alice.sign(TextNoteEvent.build(text)) @@ -239,7 +255,7 @@ class NipFEHttpTest { assertTrue(assertIs(published.last).success) val got = mutableListOf() - val read = HttpRelayClient(http, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), onEvent = got::add) + val read = HttpRelayClient(OkHttpRelayTransport { http }, alice, signFirst = true).req(relay, listOf(Filter(ids = listOf(n.id))), onEvent = got::add) assertEquals(200, read.status) assertTrue(read.complete) assertEquals(listOf(n.id), got.map { it.id }) @@ -297,6 +313,82 @@ class NipFEHttpTest { } } + /** Answers a filter naming [HELD_KIND] with what is stored, then holds its EOSE until [release]. */ + private fun holdingEose(release: CompletableDeferred): (IEventStore) -> IEventStore = + { real -> + object : IEventStore by real { + override suspend fun rawQuery( + filters: List, + onEach: (RawEvent) -> Unit, + ) { + real.rawQuery(filters, onEach) + if (filters.any { it.kinds?.contains(HELD_KIND) == true }) release.await() + } + } + } + + @Test + fun aGzippedAnswerStillArrivesLineByLine() = + runBlocking { + val release = CompletableDeferred() + val relay = start(store = holdingEose(release)) + val held = alice.sign(TimeUtils.now(), HELD_KIND, emptyArray(), "held") + client().publish(relay, held) + + // Asking for gzip by hand turns off OkHttp's own inflating, so the body is read as sent. + val patient = http.newBuilder().readTimeout(10, TimeUnit.SECONDS).build() + val request = + Request + .Builder() + .url(relay.toHttp()) + .post("""["REQ","q",{"kinds":[$HELD_KIND]}]""".toRequestBody("text/plain".toMediaType())) + .header("Accept-Encoding", "gzip") + .build() + patient.newCall(request).execute().use { response -> + assertEquals("gzip", response.header("Content-Encoding")) + val lines = GzipSource(response.body.source()).buffer() + // The store has not answered EOSE: this line can only be here if the gzip was flushed. + assertEquals("""["EVENT","q",${held.toJson()}]""", lines.readUtf8Line()) + assertFalse(release.isCompleted) + release.complete(Unit) + assertEquals("""["EOSE","q"]""", lines.readUtf8Line()) + assertEquals(null, lines.readUtf8Line()) + } + } + + @Test + fun theClientReadsAGzippedAnswerAsItStreams() = + runBlocking { + val release = CompletableDeferred() + val relay = start(store = holdingEose(release)) + val held = alice.sign(TimeUtils.now(), HELD_KIND, emptyArray(), "held") + client().publish(relay, held) + + val first = CompletableDeferred() + val answer = async(Dispatchers.IO) { client().req(relay, listOf(Filter(kinds = listOf(HELD_KIND))), onEvent = { first.complete(it) }) } + assertEquals(held.id, withTimeout(10.seconds) { first.await() }.id, "the event arrives before EOSE is written") + release.complete(Unit) + assertTrue(answer.await().complete) + } + + @Test + fun withoutGzipOrWithItOffTheBodyIsPlain() { + for ((settings, accept) in listOf(HttpCommandSettings() to "identity", HttpCommandSettings(compress = false) to "gzip")) { + val relay = start(settings = settings) + val request = + Request + .Builder() + .url(relay.toHttp()) + .post("""["REQ","q",{"kinds":[1]}]""".toRequestBody("text/plain".toMediaType())) + .header("Accept-Encoding", accept) + .build() + http.newCall(request).execute().use { response -> + assertEquals(null, response.header("Content-Encoding"), accept) + assertEquals(listOf("""["EOSE","q"]"""), response.lines()) + } + } + } + @Test fun nip11AdvertisesFE() { val relay = start() @@ -321,4 +413,9 @@ class NipFEHttpTest { assertTrue(answer.complete) assertFalse(answer.last is EoseMessage) } + + private companion object { + /** A regular kind (stored), not a text note, so no other test reads it. */ + const val HELD_KIND = 7_777 + } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt similarity index 61% rename from quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt index 5eb4bd6365..af7eb13305 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayClient.kt @@ -33,22 +33,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.coroutines.executeAsync -import okio.IOException /** - * NIP-FE over OkHttp: one relay command per request, POSTed to the relay's URL as the frame the + * NIP-FE client: one relay command per request, POSTed to the relay's URL as the frame the * websocket would carry, its answer read line by line as the relay writes it, in the socket's own - * frames. Nothing stays open after a call returns. + * frames. Nothing stays open after a call returns. [transport] carries the bytes (OkHttp on + * JVM/Android: `OkHttpRelayTransport`), as a websocket builder does for the NostrClient. * * With a [signer], a request the relay refuses with 401 goes once more carrying a NIP-98 token for * its exact body, as a websocket client answers a NIP-42 challenge; without one, the 401 is the @@ -56,7 +46,7 @@ import okio.IOException * relay known to want it. */ class HttpRelayClient( - private val http: OkHttpClient, + private val transport: HttpRelayTransport, private val signer: NostrSigner? = null, private val signFirst: Boolean = false, ) { @@ -92,11 +82,11 @@ class HttpRelayClient( ): HttpRelayAnswer { val command = requireNotNull(HttpRelayCommand.of(cmd)) { "NIP-FE carries REQ, COUNT and EVENT, not ${cmd.label()}" } val url = relay.toHttp() - val bytes = cmd.toJson().encodeToByteArray() - if (signer == null || signFirst) return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) - val first = post(command, url, bytes, null, onMessage, retrying = true) + val body = cmd.toJson().encodeToByteArray() + if (signer == null || signFirst) return post(relay, command, url, body, token(url, body), onMessage, retrying = false) + val first = post(relay, command, url, body, null, onMessage, retrying = true) if (first.status != HttpRelayStatus.UNAUTHORIZED) return first - return post(command, url, bytes, token(url, bytes), onMessage, retrying = false) + return post(relay, command, url, body, token(url, body), onMessage, retrying = false) } private suspend fun token( @@ -105,6 +95,7 @@ class HttpRelayClient( ): String? = signer?.sign(HTTPAuthorizationEvent.build(url, "POST", body))?.toAuthToken() private suspend fun post( + relay: NormalizedRelayUrl, command: HttpRelayCommand, url: String, body: ByteArray, @@ -112,51 +103,25 @@ class HttpRelayClient( onMessage: (Message) -> Unit, /** A signed try follows a 401, so that refusal is not the answer and is not handed on. */ retrying: Boolean, - ): HttpRelayAnswer = - coroutineScope { - val request = - Request - .Builder() - .url(url) - .post(body.toRequestBody(JSON)) - .header("Accept", NDJSON) - .apply { authorization?.let { header("Authorization", it) } } - .build() - val call = http.newCall(request) - // A blocking read does not see coroutine cancellation; cancelling the call unblocks it. - val watcher = - launch { - try { - awaitCancellation() - } finally { - call.cancel() - } - } - try { - call.executeAsync().use { response -> - withContext(Dispatchers.IO) { - val reader = HttpRelayAnswerReader(command, response.code) - val deliver = !(retrying && response.code == HttpRelayStatus.UNAUTHORIZED) - val source = response.body.source() - try { - while (true) { - val line = source.readUtf8Line() ?: break - val message = reader.read(line) - if (message != null && deliver) onMessage(message) - } - } catch (_: IOException) { - // The connection dropped mid-answer: what came is what the reader says it is. - } - reader.answer(response.header("Retry-After")) - } - } - } finally { - watcher.cancel() - } - } - - companion object { - const val NDJSON = "application/x-ndjson" - private val JSON = "application/json".toMediaType() + ): HttpRelayAnswer { + var reader: HttpRelayAnswerReader? = null + var retryAfter: String? = null + var deliver = true + transport.post( + relay = relay, + url = url, + body = body, + authorization = authorization, + onStatus = { status, after -> + reader = HttpRelayAnswerReader(command, status) + retryAfter = after + deliver = !(retrying && status == HttpRelayStatus.UNAUTHORIZED) + }, + onLine = { line -> + val message = checkNotNull(reader) { "a line before the status" }.read(line) + if (message != null && deliver) onMessage(message) + }, + ) + return checkNotNull(reader) { "the transport returned without a status" }.answer(retryAfter) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayTransport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayTransport.kt new file mode 100644 index 0000000000..918bb8e921 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayTransport.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * How [HttpRelayClient] reaches a relay over HTTP: one POST, its response read line by line. The + * NIP-FE side of the exchange (which URL, what body, signing, reading the answer) stays in the + * client; an implementation only moves bytes, the way a + * [com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder] does for [com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient]. + */ +interface HttpRelayTransport { + /** + * POSTs [body] to [url], [relay]'s HTTP URL, with an `Authorization` header when [authorization] + * is not null. Calls [onStatus] once with the status and any `Retry-After`, then [onLine] for each + * body line as it arrives, and returns when the body ends. A connection that drops mid-body + * returns normally: the answer reader tells a cut-off answer from a whole one. Cancelling the + * caller cancels the request. + */ + suspend fun post( + relay: NormalizedRelayUrl, + url: String, + body: ByteArray, + authorization: String?, + onStatus: (status: Int, retryAfter: String?) -> Unit, + onLine: (String) -> Unit, + ) +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/OkHttpRelayTransport.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/OkHttpRelayTransport.kt new file mode 100644 index 0000000000..ee65bda983 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/OkHttpRelayTransport.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipFERelayOverHttp + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.coroutines.executeAsync +import okio.IOException + +/** + * [HttpRelayTransport] over OkHttp. [httpClient] picks the client per relay, as + * [com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket.Builder] does for + * websockets, so a .onion relay can go through Tor and a clearnet one directly. OkHttp asks for + * gzip and inflates it as the body streams, so a relay's sync-flushed gzip arrives line by line. + */ +class OkHttpRelayTransport( + private val httpClient: (NormalizedRelayUrl) -> OkHttpClient, +) : HttpRelayTransport { + override suspend fun post( + relay: NormalizedRelayUrl, + url: String, + body: ByteArray, + authorization: String?, + onStatus: (status: Int, retryAfter: String?) -> Unit, + onLine: (String) -> Unit, + ) = coroutineScope { + val request = + Request + .Builder() + .url(url) + .post(body.toRequestBody(JSON)) + .header("Accept", NDJSON) + .apply { authorization?.let { header("Authorization", it) } } + .build() + val call = httpClient(relay).newCall(request) + // A blocking read does not see coroutine cancellation; cancelling the call unblocks it. + val watcher = + launch { + try { + awaitCancellation() + } finally { + call.cancel() + } + } + try { + call.executeAsync().use { response -> + onStatus(response.code, response.header("Retry-After")) + withContext(Dispatchers.IO) { + val source = response.body.source() + try { + while (true) onLine(source.readUtf8Line() ?: break) + } catch (_: IOException) { + // The connection dropped mid-answer: what came is what the reader says it is. + } + } + } + } finally { + watcher.cancel() + } + } + + companion object { + const val NDJSON = "application/x-ndjson" + private val JSON = "application/json".toMediaType() + } +} From 7a06b453e4455f0a1031a972d1d34501994c0451 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:11:24 +0000 Subject: [PATCH 24/32] chore: sync Crowdin translations and seed translator npub placeholders --- .../values-hi-rIN/strings.xml | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index ce6505f9b1..aced0f2ff6 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -5453,6 +5453,10 @@ प्रमाणपत्र निर्गत %1$s को %2$s द्वारा। मान्य %3$s तक अनुमतियाँ + सेवाप्रसंग अभिलेख तथा जालस्थान जानकारी + प्रवेशांकन तथा आद्यताएँ जो इस जालस्थान ने अभिलेखित किया इस लेखा के लिए + जालस्थान जानकारी रिक्त करें + क्या इस जालस्थान से निर्गमनांकन करें तथा इसकी जानकारी मिटा दें। पुनःबाधित करें %1$s को रखें %1$s पुनःजुडें %1$s @@ -5777,6 +5781,14 @@ सेवाएँ जिनपर आप विश्वास करते है आपके लिए लोगों का सत्यापन तथा श्रेणीकरण करने के लिए। विश्वसनीय पुनःप्रसारक सूची पुनःप्रसारक जिनपर आप पर्याप्त विश्वास करते हैं बिना पूछे उनसे संयोजन करने के लिए। + मिटाएँ + सम्पादन + आपके सन्देश का सम्पादन + सन्देश पर जाएँ + टाँकें + उत्तर + टाँका हटाएँ + वह परिवर्तन असफल। %1$s इस समुह में नहीं है। वह व्यक्ति अभिलेख में क्या है @@ -5795,4 +5807,112 @@ जोडें उस समायोजक को खोलने में असफल। समायोजक के एनपुब॰ की अनुकृति + समायोजकों के लिए ढूँढें + जोडें + घषणाओं को पढने में असफल + आपके पुनःप्रसारकों में कोई भी घषणा नहीं कर रहा। + घोषित %1$s पूर्व + पूर्व घोषित %1$s + उत्तर दे रहा है। + एक आह्वान असफल। पुनःप्रयासमध्य। + उससे कुछ पूछा नहीं गया अब तक। + पूछें कि कौन हैं + समायोजक कुंचिका + उसके लिए एक नाम। (आपका। विककल्पात्मक) + अधिक क्रियाएँ + कोई समायोजक नहीं अब तक। + मिटाएँ + क्या इस समायोजक को मिटा दें। + %1$s + उत्तर सक्रिय + %1$s स %2$d अधिक + हटाएँ + पुनःनामकरण + कहता है कि यह है। %1$s + उत्तर दिया। पर कुछ भी नामित नहीं। + सब कुछ दिखाएँ %1$d + अल्पतर संख्या दिखाएँ + समूह बनाएँ + + बनाएँ तथा %1$d व्यक्ति को आमन्त्रण दें + बनाएँ तथा %1$d व्यक्तियों को आमन्त्रण दें + + किसी को जोडें + प्रशासक + केवल मैं इस समूह का प्रबन्धक + समायोजक + परिवर्तन + समायोजक ढूँढें + हो गया + एक अन्य समायोजक + समायोजक ख्याप्य कुंचिका। षोडशांक अथवा एनपुब॰ + पुनःप्रसारक जिन पर यह उत्तर देता है। एक प्रति पंक्ति + विवरण (विकल्पात्मक) + सभी जोड सकते हैं तथा हटा सकते हैं + समूह बनाने में असफल। + समूह नाम + कोई समायोजक चयनित नहीं + समायोजक ने आमन्त्रण अस्वीकार किया + योजक भेजें + चर्चा खोलें + कोई कुंचिका नहीं यहाँ। इसलिए कोई स्वागत सन्देश छोडा नहीं जा सका + समूह है + उनकी प्रतीक्षा में + हो गया + कोई नहीं अब तक। दबाएँ लोगों को जोडने के लिए + + अभिगम्य %1$d समायोजक पर + अभिगम्य %1$d समायोजकों पर + + कोई कुंचिका नहीं आपके द्वारा उपयुक्त किसी समायोजक के पास + अब तक जाँच नहीं की + कौन जोड सकता है तथा हटा सकता है + नामकरण + कौन इसमें हैं + कहाँ उसका आवास + नया कोर्डन समूह + आप। सर्वदा प्रशासक + सन्देश लौटाएँ + समायोजक द्वारा स्वीकृत + समझ गया + यह समायोजक क्या देख सकता है + समूह जानकारी + + %1$d समूह + %1$d समूह + + समायोजक + कोई कोर्डन समूह नहीं अब तक + समायोजक कुंचिका + समायोजक योजक + समायोजक एनप्रोफैल॰ की अनुकृति + विवरण सम्पादन + युग + समूह परिचायक + इस समायोजक पर जोडा जा सकता है + सदस्य + क्या समूह से हटाएँ। + हटाएँ + अभिलेखन + तन्त्रज्ञानात्मक विवरण + जुडें + अस्वीकार + आमन्त्रणों को पढने में असफल। + स %1$d अधिक + कोई आमन्त्रण प्रतीक्षा नहीं कर रहा। + पुनःजाचें + एक आमन्त्रण छोडा गया एक अन्य यन्त्र के लिए + कोर्डन आमन्त्रण + %1$s ने उत्तर नहीं दिया + %1$s द्वारा + कुंचिका पोटलियाँ पढने में असफल। + एक का प्रकाशन + अन्तिम उपाय का प्रकाशन + सब लौटा लें + उनको लौटा लें + इस समूह से जुडने का अनुरोध करें + जुडने का अनुरोध असफल। + परखें + उस अभिलेख को खोलने में असफल। + वह अभिलेख पठनशक्य नहीं। From 0f488d10d24ffdf893dca0cc8263e65d81a11bc9 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:12:22 +0000 Subject: [PATCH 25/32] chore: sync Crowdin translations and seed translator npub placeholders --- .../values-hi-rIN/strings.xml | 50 +++++++++++++++++++ .../values-pl-rPL/strings.xml | 30 +++++++++++ 2 files changed, 80 insertions(+) diff --git a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml index aced0f2ff6..a518b1b8dc 100644 --- a/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-hi-rIN/strings.xml @@ -653,6 +653,19 @@ सब ज्ञात को पढ लिया चिह्नित करें सब नये को पढ लिया चिह्नित करें सब को पढ लिया चिह्नित करें + आपकी गुप्त कुंचिका आपका लेखा है + दबाएँ देखने के लिए + इसे इस क्रम मे लिख लें। बडे अथवा छोटे अक्षर दोनों कार्य करेंगे। + क्यूआर अक्षरराशि + पारणशब्द रक्षित अनुकृति + पारणशब्द + एक ncryptsec1… जो केवल आपके पारणशब्द से कार्य करता है। + न्यूनतम %1$d अक्षर + पारणशब्द दोहराएँ + पारणशब्दों में मेल नहीं + रहस्यीकरण कुंचिका + रहस्यीकरणमध्य… + एक पृथक पारणशब्द का उपयोग करें अपने कुंचिकाओं को सुरक्षित रखें आपकी गुप्त कुंचिका एकमात्र विधि है इस लेखा का अभिगमन करने के लिए। यदि आप इसे खो देते हैं तो इसे कभी भी पुनःप्राप्त नहीं कर सकते। इसे कहीं सुरक्षित रखें अभी इस समय। अभी इसी समय सुरक्षित अनुकृति बनाएँ @@ -5883,6 +5896,12 @@ समायोजक कोई कोर्डन समूह नहीं अब तक + एक का आरम्भ करें + किसी को जोडें + उस नाम से कोई प्राप्त नहीं। + नाम अथवा एनपुब॰ अथवा नाम@जालक्षेत्र + प्रशासक + समायोजक समायोजक कुंचिका समायोजक योजक समायोजक एनप्रोफैल॰ की अनुकृति @@ -5915,4 +5934,35 @@ परखें उस अभिलेख को खोलने में असफल। वह अभिलेख पठनशक्य नहीं। + उस अभिलेख को भेजने में असफल। + + %1$d सदस्य + %1$d सदस्य + + सन्देश मिटाया गया + समायोजक + धावक + भेजा गया + सन्देश विवरण + सम्पादित + टाँका गया %1$s द्वारा + अगला टाँका गया सन्देश + पिछला टाँका गया सन्देश + सभी टाँके गए सन्देशों को दिखाएँ + टाँके गए सन्देश + प्रतिक्रियाएँ + जोडें + अनुरोधों के लिए जाँचें + हटाएँ + अनुरोधों को पढने में असफल। + जुडने की प्रतीक्षा में कोई नहीं। + जुडने के अनुरोध + भेजें + भेजने में असफल। + अनुकृत + योजक अनुकृति + इस समूह को बाँटें + ध्वनि टीका चलाएँ + ध्वनि टीका का अभिलेखन करें + रोकें तथा भेजें diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 8b1f5cd7f1..68e2da7cfc 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -669,6 +669,24 @@ Zaznacz wszystkie popularne jako przeczytane Zaznacz wszystkie nowe jako przeczytane Zaznacz wszystkie jako przeczytane + Twój tajny klucz to Twoje konto + Każdy, kto go posiada, może publikować posty pod twoim imieniem. Jeśli go zgubisz, nikt nie będzie w stanie go odzyskać, nawet Amethyst. + Kliknij, aby przejrzeć + Zapisz to w tej kolejności. Można używać zarówno wielkich, jak i małych liter. + Kod QR + Kopia chroniona hasłem + Hasło + Ncryptsec1… który działa tylko z twoim hasłem. + Co najmniej %1$d znaków + Powtórz hasło + Hasła nie są identyczne + Zaszyfruj klucz + Szyfrowanie… + Można je bezpiecznie przechowywać w menedżerze haseł lub notatkach w chmurze: nie da się ich otworzyć bez hasła, a zapomnianego hasła nie da się odzyskać. + Użyj innego hasła + Schowaj ten zapis w miejscu, które znasz tylko ty, albo zapisz klucz w menedżerze haseł. + Nigdy nie wpisuj swojego klucza na stronach internetowych ani w aplikacjach, do których nie masz zaufania. + Programiści serwisu Amethyst nigdy nie poproszą Cię o podanie klucza. Kopia zapasowa kluczy Twój tajny klucz jest jedynym sposobem na dostęp do tego konta. Jeśli go zgubisz, nigdy nie będzie można go odzyskać. Zapisz go w bezpiecznym miejscu. Utwórz kopię zapasową @@ -5476,6 +5494,12 @@ Jeszcze się nie skontaktowano Odpowiedź Brak odpowiedzi + + %1$d nieudana próba z rzędu + %1$d nieudanych prób z rzędu + %1$d nieudanych prób z rzędu + %1$d nieudane próby z rzędu + Wklej link „cordn1…” udostępniony przez inną osobę, aby sprawdzić, który koordynator prowadzi tę grupę oraz jakie informacje o tobie uzyskałby ten operator, gdybyś do niej dołączył. cordn1… Zbadaj @@ -5564,6 +5588,12 @@ Przynieś grupy tutaj Pobieranie… Powyższe dane zastępują wszelkie grupy „cordn” już istniejące w tym telefonie. Nie można ich połączyć. + + %1$d grupa przeniesiona + %1$d grup przeniesionych + %1$d grup przeniesiono + %1$d grupy przeniesione + Na tym urządzeniu nie ma żadnych grup cordn, które można by przenieść. Najpierw skonfiguruj serwer multimediów — zaszyfrowane dokumenty muszą gdzieś się znaleźć, dopóki drugi telefon je pobierze. Co opuszcza to urządzenie From 845ae7be145274e9a533c701e1d39afe10e2b278 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:18:48 +0000 Subject: [PATCH 26/32] =?UTF-8?q?fix:=20NIP-FE=20audit=20=E2=80=94=20slow?= =?UTF-8?q?=20bodies,=20malformed=20Content-Type,=20body=20buffers,=20one?= =?UTF-8?q?=20parse,=20faster=20gzip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs (reproduced on a live geode before fixing, now regression tests): - A body trickled in forever held its admission slot forever: a client that declared 100 bytes and sent 5 still had its slot after 20 s, so a few addresses could fill the global cap. The body read is now bounded by [http].body_timeout_seconds (10 s): 408, Connection: close. - A malformed Content-Type made the NIP-86 check throw, answering 500. It is now compared as text, and a command needs no type at all. Performance: - Every request allocated a cap-sized body buffer (512 KiB) for what is usually a 50-byte frame. The buffer is now sized to Content-Length, or grows from 4 KiB for a chunked body. - The session parsed each body again after the handler had. RelaySession.receive(text, parsed) runs the raw-text policies on the text and dispatches the parsed command. - Gzip at level 1: 42% of raw against 39% at the default 6 on Nostr-shaped events, for about 2.5x less CPU. Compressed bytes go to the socket from their own buffer instead of a copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RbNrTdV2e7kW5S9tkMoPgh --- geode/config.example.toml | 2 + .../geode/config/StaticConfig.kt | 4 ++ .../vitorpamplona/geode/server/BoundedBody.kt | 10 ++- .../vitorpamplona/geode/server/GzipLines.kt | 20 +++++- .../geode/server/HttpCommandSettings.kt | 3 + .../geode/server/NipFEHttpRoute.kt | 32 ++++++---- .../com/vitorpamplona/geode/NipFEHttpTest.kt | 61 +++++++++++++++++++ .../nip01Core/relay/server/RelaySession.kt | 23 +++++-- .../nipFERelayOverHttp/HttpRelayHandler.kt | 8 +-- 9 files changed, 139 insertions(+), 24 deletions(-) diff --git a/geode/config.example.toml b/geode/config.example.toml index 891ed025dc..89f6a6c196 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -182,6 +182,8 @@ enabled = true # Retry-After. 0 = no limit. max_concurrent_requests = 256 max_requests_per_client = 16 +# A body still arriving after this is dropped with 408, freeing its slot. +body_timeout_seconds = 10 # An answer still running after this ends on a CLOSED line. deadline_seconds = 30 max_body_bytes = 524288 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 6212aa7178..e12781ad13 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -242,6 +242,8 @@ data class StaticConfig( val max_concurrent_requests: Int = 256, /** Requests one client address may run at once; over it, 429. 0 = no limit. */ val max_requests_per_client: Int = 16, + /** How long a request body may take to arrive before the request is dropped with 408. */ + val body_timeout_seconds: Long = 10, /** How long one answer may run before it ends on a `CLOSED` line. */ val deadline_seconds: Long = 30, /** Largest request body read; larger is 413. */ @@ -270,6 +272,7 @@ data class StaticConfig( HttpCommandSettings( maxConcurrent = max_concurrent_requests, maxPerClient = max_requests_per_client, + bodyTimeout = body_timeout_seconds.seconds, deadline = deadline_seconds.seconds, maxBodyBytes = max_body_bytes, compress = gzip, @@ -357,6 +360,7 @@ data class StaticConfig( fun validate() { require(http.max_concurrent_requests >= 0) { "[http].max_concurrent_requests must be >= 0 (0 = no limit), got ${http.max_concurrent_requests}" } require(http.max_requests_per_client >= 0) { "[http].max_requests_per_client must be >= 0 (0 = no limit), got ${http.max_requests_per_client}" } + require(http.body_timeout_seconds > 0) { "[http].body_timeout_seconds must be > 0, got ${http.body_timeout_seconds}" } require(http.deadline_seconds > 0) { "[http].deadline_seconds must be > 0, got ${http.deadline_seconds}" } require(http.max_body_bytes > 0) { "[http].max_body_bytes must be > 0, got ${http.max_body_bytes}" } require(http.retry_after_seconds >= 0) { "[http].retry_after_seconds must be >= 0, got ${http.retry_after_seconds}" } diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt index 7634d48939..571c6312b2 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/BoundedBody.kt @@ -28,6 +28,8 @@ import io.ktor.utils.io.readAvailable /** * Reads the request body up to [cap] bytes. Returns null when it is larger — by its declared * `Content-Length` or by what actually arrives — without reading past the cap; the caller answers 413. + * The buffer is sized to the declared length, or grows from a small start, so a 50-byte command does + * not cost a cap-sized allocation. */ internal suspend fun readBoundedBody( call: ApplicationCall, @@ -36,13 +38,17 @@ internal suspend fun readBoundedBody( val declared = call.request.headers[HttpHeaders.ContentLength]?.toLongOrNull() if (declared != null && declared > cap) return null val ch = call.receiveChannel() - val buf = ByteArray(cap + 1) + // One byte past the declared length, so a body longer than it claimed is still caught at the cap. + var buf = ByteArray(if (declared != null) declared.toInt() + 1 else minOf(cap + 1, INITIAL_BODY_BUFFER)) var pos = 0 while (pos <= cap) { + if (pos == buf.size) buf = buf.copyOf(minOf(cap + 1, buf.size * 2)) val read = ch.readAvailable(buf, pos, buf.size - pos) if (read <= 0) break pos += read } if (pos > cap) return null - return buf.copyOfRange(0, pos) + return if (pos == buf.size) buf else buf.copyOf(pos) } + +private const val INITIAL_BODY_BUFFER = 4 * 1024 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt index aa4f4c4e22..f1b50e171a 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/GzipLines.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.geode.server import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.writeFully import java.io.ByteArrayOutputStream +import java.util.zip.Deflater import java.util.zip.GZIPOutputStream /** @@ -34,8 +35,16 @@ import java.util.zip.GZIPOutputStream internal class GzipLines( private val out: ByteWriteChannel, ) { - private val compressed = ByteArrayOutputStream(BUFFER) - private val gzip = GZIPOutputStream(compressed, BUFFER, true) + private val compressed = Pending() + + // Level 1: on Nostr events it compresses to ~42% of raw against ~39% at the JDK's default 6, for + // about 2.5x less CPU; ids, keys and signatures are hex and barely compress at any level. + private val gzip = + object : GZIPOutputStream(compressed, BUFFER, true) { + init { + def.setLevel(Deflater.BEST_SPEED) + } + } /** Compresses [line] and its newline. Moves compressed bytes to the socket once enough piled up, so a long burst still meets backpressure. */ suspend fun line(line: String) { @@ -63,10 +72,15 @@ internal class GzipLines( private suspend fun drain() { if (compressed.size() == 0) return - out.writeFully(compressed.toByteArray()) + compressed.writeTo(out) compressed.reset() } + /** The compressed bytes not yet on the socket, written from its own array rather than a copy. */ + private class Pending : ByteArrayOutputStream(BUFFER) { + suspend fun writeTo(out: ByteWriteChannel) = out.writeFully(buf, 0, count) + } + companion object { private const val BUFFER = 16 * 1024 private val NEWLINE = byteArrayOf('\n'.code.toByte()) diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt index 0508511eb7..9d4e75d2d4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/HttpCommandSettings.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.geode.server import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nipFERelayOverHttp.HttpRelayHandler import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds /** * NIP-FE (relay commands over HTTP) as [com.vitorpamplona.geode.KtorRelay] serves it: a `POST` to @@ -35,6 +36,8 @@ data class HttpCommandSettings( val maxConcurrent: Int = 256, /** Requests one client address may run at once before its next gets 429; 0 is no limit. */ val maxPerClient: Int = 16, + /** How long the body may take to arrive; past it, 408. It holds an admission slot meanwhile. */ + val bodyTimeout: Duration = 10.seconds, /** How long one answer may run, first byte to last. */ val deadline: Duration = HttpRelayHandler.DEFAULT_DEADLINE, /** The largest body read; the engine's own message limit, when it has one and it is smaller, wins. */ diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt index 8980c3031c..57aeefbfa4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/server/NipFEHttpRoute.kt @@ -31,13 +31,14 @@ import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall -import io.ktor.server.request.contentType import io.ktor.server.request.header import io.ktor.server.response.header import io.ktor.server.response.respond import io.ktor.server.response.respondBytesWriter import io.ktor.server.response.respondText import io.ktor.utils.io.writeStringUtf8 +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout /** * NIP-FE over Ktor: the host half of [HttpRelayHandler], on POSTs to the relay's URL that are not @@ -71,11 +72,11 @@ internal class NipFEHttpRoute( * Whether a POST to the relay's URL is a NIP-FE command: anything but NIP-86's * `application/nostr+json+rpc`, since commands need no `Content-Type` at all. */ - fun isCommand(call: ApplicationCall): Boolean = - !call.request - .contentType() - .withoutParameters() - .match(NIP86) + fun isCommand(call: ApplicationCall): Boolean { + // Compared as text: parsing it would throw on a malformed header, and a command needs none. + val type = call.request.header(HttpHeaders.ContentType) ?: return true + return !type.substringBefore(';').trim().equals(Nip86HttpHandler.CONTENT_TYPE, ignoreCase = true) + } /** Answers the command in the body. Admission runs first, so a refused request spends no NIP-98 token. */ suspend fun handle(call: ApplicationCall) { @@ -87,13 +88,22 @@ internal class NipFEHttpRoute( val verdict = admission.admit(clientOf(call)) { + // Bounded in time too: a body trickled in forever would hold this admission slot forever. val body = - readBoundedBody(call, bodyCap) - ?: return@admit respondLine( + try { + withTimeout(settings.bodyTimeout) { readBoundedBody(call, bodyCap) } + } catch (_: TimeoutCancellationException) { + call.response.header(HttpHeaders.Connection, "close") + return@admit respondLine( call, - HttpRelayStatus.PAYLOAD_TOO_LARGE, - HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), + REQUEST_TIMEOUT, + HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the body did not arrive within ${settings.bodyTimeout}")), ) + } ?: return@admit respondLine( + call, + HttpRelayStatus.PAYLOAD_TOO_LARGE, + HttpRelayHandler.notice(MachineReadablePrefix.INVALID.format("the command exceeds $bodyCap bytes")), + ) handler.handle(HttpRelayRequest(call.request.header(HttpHeaders.Authorization), body), Answer(call)) } when (verdict) { @@ -213,7 +223,7 @@ internal class NipFEHttpRoute( companion object { val NDJSON = ContentType("application", "x-ndjson") - private val NIP86 = ContentType.parse(Nip86HttpHandler.CONTENT_TYPE) + const val REQUEST_TIMEOUT = 408 const val WWW_AUTHENTICATE = "Nostr" const val ACCEL_BUFFERING = "X-Accel-Buffering" const val PREFLIGHT_MAX_AGE_SECONDS = 86_400 diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt index 0403e7fde4..fa1f25b872 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/NipFEHttpTest.kt @@ -58,6 +58,7 @@ import okhttp3.Response import okio.GzipSource import okio.buffer import java.net.ServerSocket +import java.net.Socket import java.util.concurrent.TimeUnit import kotlin.test.AfterTest import kotlin.test.Test @@ -66,6 +67,7 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -389,6 +391,65 @@ class NipFEHttpTest { } } + /** Writes [request] on a plain socket to the relay at [relay] and returns the status line of the answer. */ + private fun rawStatus( + relay: NormalizedRelayUrl, + request: String, + ): String = + Socket( + "127.0.0.1", + relay + .toHttp() + .substringAfterLast(':') + .trimEnd('/') + .toInt(), + ).use { socket -> + socket.soTimeout = 10_000 + socket.getOutputStream().write(request.encodeToByteArray()) + socket.getOutputStream().flush() + socket.getInputStream().bufferedReader().readLine() + } + + private fun chunked(body: String) = "${body.length.toString(16)}\r\n$body\r\n0\r\n\r\n" + + @Test + fun aMalformedContentTypeIsStillACommand() { + val relay = start() + val body = """["REQ","q",{"kinds":[1]}]""" + val status = rawStatus(relay, "POST / HTTP/1.1\r\nHost: x\r\nContent-Type: garbage\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body") + assertEquals("HTTP/1.1 200 OK", status) + } + + @Test + fun aBodyThatNeverFinishesIsDroppedAndFreesItsSlot() { + val relay = start(settings = HttpCommandSettings(maxPerClient = 1, bodyTimeout = 500.milliseconds)) + val port = + relay + .toHttp() + .substringAfterLast(':') + .trimEnd('/') + .toInt() + Socket("127.0.0.1", port).use { slow -> + slow.soTimeout = 10_000 + slow.getOutputStream().write("POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\n[\"REQ\"".encodeToByteArray()) + slow.getOutputStream().flush() + assertEquals("HTTP/1.1 408 Request Timeout", slow.getInputStream().bufferedReader().readLine()) + } + val body = """["REQ","q",{"kinds":[1]}]""" + assertEquals("HTTP/1.1 200 OK", rawStatus(relay, "POST / HTTP/1.1\r\nHost: x\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body")) + } + + @Test + fun aChunkedBodyGrowsItsBufferAndStopsAtTheCap() { + val relay = start(settings = HttpCommandSettings(maxBodyBytes = 20_000)) + // No Content-Length: the buffer starts small and grows past it. + val big = """["REQ","q",{"search":"${"x".repeat(10_000)}"}]""" + val head = "POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + assertEquals("HTTP/1.1 200 OK", rawStatus(relay, head + chunked(big))) + val over = """["REQ","q",{"search":"${"x".repeat(30_000)}"}]""" + assertEquals("HTTP/1.1 413 Payload Too Large", rawStatus(relay, head + chunked(over))) + } + @Test fun nip11AdvertisesFE() { val relay = start() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt index df77566329..adc9b47ec2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelaySession.kt @@ -189,10 +189,25 @@ class RelaySession( } /** - * Dispatches an already-parsed command, for a transport that parsed and - * sized it itself (NIP-FE's HTTP bodies). [IRelayPolicy.acceptMessage] - * is not run here — it judges wire text — so such a caller applies the - * message-length limit before calling. + * [receive] for a transport that already parsed [parsed] out of [command] + * (NIP-FE's HTTP bodies): [IRelayPolicy.acceptMessage] still judges the + * wire text, as it would on the socket, but the frame is not parsed twice. + */ + suspend fun receive( + command: String, + parsed: Command, + ) { + policy.acceptMessage(command)?.let { reason -> + send(NoticeMessage(reason)) + return + } + receive(parsed) + } + + /** + * Dispatches an already-parsed command. [IRelayPolicy.acceptMessage] is + * not run here — it judges wire text — so a caller that has the text + * uses the overload that takes both. */ suspend fun receive(cmd: Command) { if (!cmd.isValid()) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt index d4c96f7438..0b845206bf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipFERelayOverHttp/HttpRelayHandler.kt @@ -121,9 +121,9 @@ class HttpRelayHandler( // Characters, as the engine's own limit counts them. if (max != null && text.length > max) return response.single(HttpRelayStatus.PAYLOAD_TOO_LARGE, notice(tooLarge)) - // Read here as well as in the engine, to refuse the commands HTTP does not carry and to - // know what ends the answer and how to refuse it. The engine still parses the text itself, - // as socket text, so the policies that judge raw frames run. + // Parsed here, once: to refuse the commands HTTP does not carry, to know what ends the + // answer and how to refuse it, and for the session, which still runs the policies that + // judge the raw text before it dispatches the parsed command. val cmd = try { OptimizedJsonMapper.fromJsonToCommand(text) @@ -188,7 +188,7 @@ class HttpRelayHandler( try { server.serve(sink) { session -> val refused = signedIn?.let { session.authenticateByTransport(it) } - if (refused != null) fail(refused) else session.receive(text) + if (refused != null) fail(refused) else session.receive(text, cmd) ended.await() } } catch (e: CancellationException) { From 348f294e2655570dde13a0e0ffe56e74d58474c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 18:20:59 +0000 Subject: [PATCH 27/32] fix(nav): bottom bar on every pinnable tab, and FAB padding on drawer screens Audit follow-up to the drawer bottom-bar fix (#4141): - Napplets, Nsites, Marmot Groups, Cordn Groups, the NIP-46 signer and My Blossom data are all pinnable bottom-bar tabs (BottomBarCategories) and drawer destinations, but their screens never rendered AppBottomBar: tapping one of them as a tab made the bar vanish, the same bug the issue reports. They now host the bar like every other tab screen. - That also fixes a regression from the previous commit: showsBottomBar() is true on drawer-opened entries, so FabBottomBarPadded dropped its padding on Marmot Groups, which had no bar to sit above. For the same reason the drawer's Create rows (HLS video, the debug Chess lobby) now open as plain pushes: they are composer/tool screens without a bar. - Marmot Groups showed its back arrow unconditionally, including as a tab root; it now follows canPop() like the other tab screens. - Geocaches and Geocache Hunts are two pinnable tabs of one screen, but the screen always selected Route.Geocaches() and treated any Geocaches route as a re-tap: the Hunts tab never highlighted and tapping one tab from the other scrolled to top instead of switching. It now matches its exact route. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018otSD34gHeE1RT31UgzY1b --- .../mediaServers/BlossomBlobManagerScreen.kt | 6 +++++ .../ui/navigation/drawer/DrawerContent.kt | 22 +++++++++++++------ .../chats/cordnGroup/CordnGroupListScreen.kt | 6 +++++ .../marmotGroup/MarmotGroupListScreen.kt | 19 +++++++++++----- .../loggedIn/geocaches/GeocachesScreen.kt | 8 +++++-- .../loggedIn/napplets/NappletsScreen.kt | 7 ++++++ .../ui/screen/loggedIn/nsites/NsitesScreen.kt | 7 ++++++ .../settings/nip46/Nip46SignerScreen.kt | 6 +++++ 8 files changed, 67 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index cb59cc7a72..7be384edf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -136,6 +136,7 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -221,6 +222,11 @@ fun BlossomBlobManagerScreen( }, ) }, + bottomBar = { + AppBottomBar(Route.ManageBlossomBlobs, nav, accountViewModel) { route -> + if (route != Route.ManageBlossomBlobs) nav.navBottomBar(route) + } + }, ) { padding -> Column( modifier = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index ca068e3aba..91a4198d97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -641,24 +641,32 @@ fun ListContent( } } -/** The Create section's rows — composer entry points, none of which is a catalog destination. */ +/** + * The Create section's rows — composer entry points, none of which is a catalog destination. They + * open as plain pushes rather than through [INav.navDrawer]: those screens host no bottom bar, and + * a drawer stamp would tell FabBottomBarPadding that one is showing. + */ @Composable private fun CreateRows(nav: INav) { - NavigationRow( + IconRow( title = Res.string.share_hls_video, icon = MaterialSymbols.SettingsInputAntenna, tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.NewHlsVideo, + onClick = { + nav.closeDrawer() + nav.nav(Route.NewHlsVideo) + }, ) if (isDebug) { - NavigationRow( + IconRow( title = Res.string.route_chess, icon = MaterialSymbols.ChessKnight, tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.Chess, + onClick = { + nav.closeDrawer() + nav.nav(Route.Chess) + }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt index 13876ffbe1..d29ffb280a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupListScreen.kt @@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.model.cordn.CordnRuntime +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.CordnGroupRoomCompose @@ -125,6 +126,11 @@ fun CordnGroupListScreen( } } }, + bottomBar = { + AppBottomBar(Route.CordnGroupList, nav, accountViewModel) { route -> + if (route != Route.CordnGroupList) nav.navBottomBar(route) + } + }, ) { padding -> if (runtime == null) { EmptyState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt index d94e18af51..ec72529de9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupListScreen.kt @@ -91,6 +91,7 @@ import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size55dp import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.hasEncryptedMediaV2 @@ -136,16 +137,24 @@ fun MarmotGroupListScreen( topBar = { TopAppBar( navigationIcon = { - IconButton(onClick = { nav.popBack() }) { - Icon( - symbol = MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = stringRes(Res.string.back), - ) + // No arrow as a bottom-nav tab root: there is nothing below it to return to. + if (nav.canPop()) { + IconButton(onClick = { nav.popBack() }) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = stringRes(Res.string.back), + ) + } } }, title = { Text(stringRes(Res.string.marmot_groups_title)) }, ) }, + bottomBar = { + AppBottomBar(Route.MarmotGroupList, nav, accountViewModel) { route -> + if (route != Route.MarmotGroupList) nav.navBottomBar(route) + } + }, floatingActionButton = { FabBottomBarPadded(nav) { FloatingActionButton(onClick = { nav.nav(Route.CreateMarmotGroup) }, shape = CircleShape) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocachesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocachesScreen.kt index 6a271c49e2..fb41b9f2e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocachesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geocaches/GeocachesScreen.kt @@ -107,8 +107,12 @@ fun GeocachesScreen( } }, bottomBar = { - AppBottomBar(Route.Geocaches(), nav, accountViewModel) { route -> - if (route is Route.Geocaches) { + // Geocaches and Hunts are two separate pinnable tabs of this one screen, so match the + // exact route: a class check highlighted the wrong one and turned a tap on the other + // into a scroll-to-top instead of a tab switch. + val selfRoute = Route.Geocaches(initialTab) + AppBottomBar(selfRoute, nav, accountViewModel) { route -> + if (route == selfRoute) { nearby.sendToTop() } else { nav.navBottomBar(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt index 385ed65e3e..bea2b1ae8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt @@ -37,11 +37,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.napplet_none_found import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.napplet.NappletLauncher +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription @@ -90,6 +92,11 @@ fun NappletsScreen( Scaffold( topBar = { NappletsTopBar(accountViewModel, nav) }, + bottomBar = { + AppBottomBar(Route.Napplets, nav, accountViewModel) { route -> + if (route != Route.Napplets) nav.navBottomBar(route) + } + }, ) { padding -> if (visible.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/NsitesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/NsitesScreen.kt index 4bee779bf0..5ef15b0073 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/NsitesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nsites/NsitesScreen.kt @@ -37,10 +37,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.nsite_none_found import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssemblerSubscription @@ -88,6 +90,11 @@ fun NsitesScreen( Scaffold( topBar = { NsitesTopBar(accountViewModel, nav) }, + bottomBar = { + AppBottomBar(Route.Nsites, nav, accountViewModel) { route -> + if (route != Route.Nsites) nav.navBottomBar(route) + } + }, ) { padding -> if (visible.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index df18f48a12..569c6c6ab9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -125,6 +125,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackBu import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner @@ -193,6 +194,11 @@ fun Nip46SignerScreen( Scaffold( topBar = { TopBarWithBackButton(stringRes(Res.string.nip46_signer_title), nav) }, + bottomBar = { + AppBottomBar(Route.Nip46Signer(), nav, accountViewModel) { route -> + if (route != Route.Nip46Signer()) nav.navBottomBar(route) + } + }, ) { padding -> Column( modifier = From e2844b14f2c12e4cf126acc30b5775ef68d0bfa8 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 15:11:43 -0400 Subject: [PATCH 28/32] fix(marmot): treat the relay echo of our own unconfirmed commit as its confirmation A commit whose publish got no OK in time (timeout, dropped socket, slow Tor circuit) stays an unresolved obligation and the group stays at its old epoch. But the relay had stored it, peers applied it, and when the relay echoed it back the inbound pipeline processed it as someone else's commit. Its sender is our own leaf and a committer never encrypts the path secret to itself, so it failed with "UpdatePath at common ancestor carries no ciphertext for us" and the group forked: peers at epoch n+1, us at n, every later message from them undecryptable. Found testing White Noise (MDK 0.10.4) against Amethyst on a tablet over Tor: granting White Noise admin forked the group. The device error was (my_leaf=0, resolution=[2], held_path_nodes=[1]) in a two-member group, which only fits a commit from our own leaf. processGroupEvent now checks whether an inbound kind:445 is one of the group's pending obligations (same event id). If so it confirms the obligation exactly as a confirmed publish would: install the pending state, mark the commit processed, record it in the fork window, and sync retention and system rows. That follow-up is now one function shared with the retry path. The UpdatePath error also names the sender leaf, leaf count, filtered direct path and copath, so a tree-shape disagreement is diagnosable from the log. Tests: MarmotPublishBeforeApplyTest.theRelayEchoOfAnUnconfirmedCommitConfirmsIt reproduces the device error exactly (sender_leaf=0, my_leaf=0, resolution=[2]) before the fix and passes after. Marmot/MLS suites green (commons 132, quartz 782). Harness test 30 (wn's first commit after an amy AppDataUpdate commit) added; passes against MDK 0.10.4. Co-Authored-By: Claude Opus 5.5 --- cli/tests/marmot/marmot-interop-headless.sh | 1 + cli/tests/marmot/tests-manage.sh | 51 +++++++++++ .../amethyst/commons/marmot/MarmotManager.kt | 88 +++++++++++++------ .../marmot/MarmotPublishBeforeApplyTest.kt | 68 ++++++++++++++ .../quartz/mls/group/MlsGroup.kt | 4 +- 5 files changed, 186 insertions(+), 26 deletions(-) diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index e1d7e8d2e8..7f1fa230e5 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -210,6 +210,7 @@ ALL_TESTS=( test_27_deletion_wn_to_amy test_28_retention_wn_to_amy test_29_disband_amy_to_wn + test_30_wn_commit_after_app_data_update ) # --tests runs a subset in the order given. Most tests read state a previous diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 9a8fcf5fb1..ec0f884906 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -250,3 +250,54 @@ test_17_group_image_commit() { record_result "$id" fail "wn could not decrypt A's post-image message — image commit not applied" fi } + +# The device sequence that forked Amethyst out of a group White Noise joined: +# amy creates, invites wn, commits an AppDataUpdate (the app's "Use encrypted +# attachments" is one; set-retention is the same proposal type), promotes wn, +# and then wn makes its FIRST commit — a rename carrying an UpdatePath. On the +# device Amethyst refused that commit ("UpdatePath at common ancestor carries +# no ciphertext for us") and every later wn message failed to decrypt. +test_30_wn_commit_after_app_data_update() { + banner "Test 30 — wn's first commit after an amy AppDataUpdate commit" + local id="30 wn commit after app-data" + + local out gid mls_gid b_gid + out=$(amy_json marmot group create --name "Interop-30") || { + record_result "$id" fail "amy group create failed"; return + } + gid=$(printf '%s' "$out" | jq -r '.group_id') + mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id') + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { + record_result "$id" fail "amy could not invite wn"; return + } + b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; } + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { + record_result "$id" fail "wn never surfaced the group"; return + } + + wn_b messages send "$mls_gid" "30 before" >/dev/null 2>&1 || true + amy_json marmot await message "$gid" --match "30 before" --timeout 90 >/dev/null || { + record_result "$id" fail "amy never received wn's first message"; return + } + + amy_json marmot group set-retention "$gid" 3600 >/dev/null || { + record_result "$id" fail "amy set-retention failed"; return + } + sleep 3 + amy_json marmot group promote "$gid" "$B_NPUB" >/dev/null || { + record_result "$id" fail "amy promote failed"; return + } + sleep 5 + + wn_b groups rename "$mls_gid" "Interop-30-by-wn" >/dev/null 2>&1 || true + if ! amy_json marmot await rename "$gid" --name "Interop-30-by-wn" --timeout 120 >/dev/null; then + record_result "$id" fail "amy did not apply wn's rename (commit after app-data update)"; return + fi + wn_b messages send "$mls_gid" "30 after" >/dev/null 2>&1 || true + if amy_json marmot await message "$gid" --match "30 after" --timeout 90 >/dev/null; then + record_result "$id" pass + else + record_result "$id" fail "amy applied the rename but cannot decrypt wn's next message (forked)" + fi +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index b100491b67..74d4bd86a1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -97,6 +97,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.cancellation.CancellationException import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -276,32 +277,11 @@ class MarmotManager( } } val state = - publishGate.resolve( - obligation.obligationId, - if (confirmed) PublishOutcome.CONFIRMED else PublishOutcome.UNKNOWN, - ) - // A confirmed retry makes the commit canonical exactly as - // [commitAndPublish] would, so it owes the same follow-up. Resolving - // the obligation and stopping there was enough to install the state - // and no more: the relay's echo of THIS event was never marked - // processed, so the inbound pipeline met an unknown kind:445 at an - // epoch we had already merged and opened a convergence pass against - // ourselves — a restart could put a healthy group into Recovering - // purely by succeeding. - if (confirmed) { - val framedCommit = framedCommitOf(obligation, event) - if (framedCommit != null) { - inboundProcessor.markMessageProcessed(sha256(framedCommit).toHexKey()) - inboundProcessor.recordLocalCommit( - groupId = obligation.groupId, - framedCommitBytes = framedCommit, - sourceEpoch = obligation.priorState.groupContext.epoch, - preState = obligation.priorState, - ) + if (confirmed) { + confirmObligation(obligation, event) + } else { + publishGate.resolve(obligation.obligationId, PublishOutcome.UNKNOWN) } - recordRetentionForCurrentEpoch(obligation.groupId) - syncGroupSystemRows(obligation.groupId, actor = signer.pubKey) - } Log.d("MarmotManager") { "retryPendingPublishObligations(): ${obligation.groupId.take(8)}… " + "confirmed=$confirmed lifecycle=$state" @@ -309,6 +289,58 @@ class MarmotManager( } } + /** + * Make a confirmed obligation's commit canonical, with every follow-up + * [commitAndPublish] owes a commit a relay acknowledged. + * + * Resolving the obligation alone installs the state and no more: the + * relay's echo of THIS event would not be marked processed, so the inbound + * pipeline would meet an unknown kind:445 at an epoch we had already merged + * and open a convergence pass against ourselves. + */ + private suspend fun confirmObligation( + obligation: MarmotPublishObligation, + event: Event, + ): GroupLifecycleState { + val state = publishGate.resolve(obligation.obligationId, PublishOutcome.CONFIRMED) + val framedCommit = framedCommitOf(obligation, event) + if (framedCommit != null) { + inboundProcessor.markMessageProcessed(sha256(framedCommit).toHexKey()) + inboundProcessor.recordLocalCommit( + groupId = obligation.groupId, + framedCommitBytes = framedCommit, + sourceEpoch = obligation.priorState.groupContext.epoch, + preState = obligation.priorState, + ) + } + recordRetentionForCurrentEpoch(obligation.groupId) + syncGroupSystemRows(obligation.groupId, actor = signer.pubKey) + return state + } + + /** + * The relay's copy of one of our own unconfirmed commits, or null. + * + * A relay only echoes what it stored, so seeing our pending kind:445 come + * back is the acceptance whose OK never arrived (a timeout, a dropped + * socket, a slow Tor circuit). It must confirm the obligation. Handed to + * the inbound pipeline instead, it reads as a peer's commit whose sender is + * our own leaf; a committer never encrypts the path secret to itself, so it + * fails ("no ciphertext for us") while every peer applies it, and the group + * forks with us one epoch behind. + */ + private suspend fun pendingObligationEchoed(groupEvent: GroupEvent): MarmotPublishObligation? { + val groupId = groupEvent.groupId() ?: return null + return publishGate.pendingFor(groupId).firstOrNull { obligation -> + try { + Event.fromJson(obligation.outboundBytes.decodeToString()).id == groupEvent.id + } catch (e: Exception) { + if (e is CancellationException) throw e + false + } + } + } + /** * Recover the framed MLS commit from a stored obligation. * @@ -390,6 +422,12 @@ class MarmotManager( * Returns the inner event JSON if it was an application message. */ suspend fun processGroupEvent(groupEvent: GroupEvent): GroupEventResult { + pendingObligationEchoed(groupEvent)?.let { obligation -> + confirmObligation(obligation, groupEvent) + subscriptionManager.updateGroupSince(obligation.groupId, groupEvent.createdAt) + return GroupEventResult.CommitProcessed(obligation.groupId, obligation.pendingState.groupContext.epoch) + } + val result = inboundProcessor.processGroupEvent(groupEvent) // A fork just opened a bounded pass. Inbound traffic settles it diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt index 7e20aa987a..66135d9951 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotPublishBeforeApplyTest.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.marmot +import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState import com.vitorpamplona.quartz.marmot.protocolCore.LocalOutboundGate import com.vitorpamplona.quartz.nip01Core.core.Event @@ -439,6 +441,72 @@ class MarmotPublishBeforeApplyTest { assertEquals(listOf(relay), fx.manager.groupRelays(fx.groupId)) } + /** + * The relay echo of our own unconfirmed commit is the confirmation that + * never arrived as an OK. + * + * Reproduced against White Noise on a slow (Tor) link: the admin-grant + * publish timed out, so the commit stayed an unresolved obligation and the + * group stayed at its old epoch. The relay HAD stored it, the peer applied + * it, and when the relay echoed it back our inbound pipeline processed it as + * someone else's commit. Its sender is our own leaf, and a committer does not + * encrypt the path secret to itself, so it failed with "UpdatePath at common + * ancestor carries no ciphertext for us" and the group forked: the peer at + * epoch n+1, us still at n, every later message undecryptable. + */ + @Test + fun theRelayEchoOfAnUnconfirmedCommitConfirmsIt() = + runBlocking { + val fx = foundedFixture(accepts = false) + val epochBefore = + fx.manager.groupManager + .getGroup(fx.groupId)!! + .epoch + + fx.manager.updateGroupMetadata( + fx.groupId, + MarmotGroupData(nostrGroupId = fx.groupId, name = "renamed", adminPubkeys = listOf(fx.manager.signer.pubKey)), + listOf(relay), + ) + assertEquals(GroupLifecycleState.PENDING_PUBLISH, fx.manager.lifecycle(fx.groupId)) + + // What the relay sends back: the same signed kind:445, byte for byte. + val echo = + Event.fromJson( + fx.publisher.published + .single() + .toJson(), + ) as GroupEvent + val result = fx.manager.processGroupEvent(echo) + + assertTrue(result !is GroupEventResult.Error, "our own commit's echo must not be processed as a foreign commit: $result") + assertEquals( + epochBefore + 1, + fx.manager.groupManager + .getGroup(fx.groupId)!! + .epoch, + "the echo proves a relay took the commit, so it becomes canonical", + ) + assertEquals(GroupLifecycleState.STABLE, fx.manager.lifecycle(fx.groupId)) + + // A second echo (another relay, or a retry) is just a duplicate. + val again = + fx.manager.processGroupEvent( + Event.fromJson( + fx.publisher.published + .single() + .toJson(), + ) as GroupEvent, + ) + assertTrue(again !is GroupEventResult.Error, "a repeated echo must stay harmless: $again") + assertEquals( + epochBefore + 1, + fx.manager.groupManager + .getGroup(fx.groupId)!! + .epoch, + ) + } + private class InMemoryStateStore : com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore { private val states = mutableMapOf() private val retained = mutableMapOf>() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt index 2c211240c4..a252731f40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/mls/group/MlsGroup.kt @@ -1992,7 +1992,9 @@ class MlsGroup private constructor( } check(candidates.isNotEmpty()) { "UpdatePath at common ancestor carries no ciphertext for us " + - "(my_leaf=$myLeafIndex, my_node=$myNodeIdx, resolution=$resolution, " + + "(sender_leaf=$senderLeafIndex, leaf_count=${tree.leafCount}, filtered_dp=$filteredDp, " + + "filtered_cp=$filteredCp, common_ancestor=$commonAncestorNode, new_leaves=$newLeavesInCommit, " + + "my_leaf=$myLeafIndex, my_node=$myNodeIdx, resolution=$resolution, " + "held_path_nodes=${pathPrivateKeys.keys.sorted()}, " + "encrypted_path_secrets=${pathNode.encryptedPathSecret.size})" } From ab5119b75ba53f897fc543fa19dcd39b3a9330d6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 15:38:31 -0400 Subject: [PATCH 29/32] fix(marmot): send reactions and deletions inside the group, not as NIP-17 DMs A Marmot message is an unsigned rumor, so the app's generic reaction and deletion paths treated it as a NIP-17 private note: a reaction was gift-wrapped to the author as a DM, and a deletion went the same way (or to nobody, for our own message). Neither reached the MLS group. White Noise never showed an Amethyst reaction or deletion, and group activity left the group's channel as DMs. Only the CLI used the in-group builders, which is why the interop harness passed. Account.reactTo, delete and deletePrivately now check whether the target is a Marmot message (MarmotGroupList.groupIdForNote) and send an inner kind:7 or kind:5 through sendMarmotGroupMessage, like any other group message. Unreact already goes through deletePrivately with the reacted-to message as its target, so it is covered. Custom-emoji reactions carry their emoji tag, as on the public path. Verified on device (Amethyst tablet <-> White Noise Android, MDK 0.10.4): react, unreact and delete from Amethyst all show in White Noise. No gift wraps were sent for them. The White Noise app only shows an incoming reaction after its chat is reopened; wn's materialized timeline has it immediately, so that is White Noise's live refresh. Harness test 31 checks that an amy reaction appears in wn's MATERIALIZED timeline (test 09 only checks the raw event log, which the app does not render). Builder unit tests added. Co-Authored-By: Claude Opus 5.5 --- .../vitorpamplona/amethyst/model/Account.kt | 21 ++++- .../amethyst/model/AccountMarmotActions.kt | 49 +++++++++++ cli/tests/marmot/marmot-interop-headless.sh | 1 + cli/tests/marmot/tests-manage.sh | 36 ++++++++ .../amethyst/commons/marmot/MarmotManager.kt | 30 +++++-- .../marmot/MarmotInnerRumorBuildersTest.kt | 83 +++++++++++++++++++ 6 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotInnerRumorBuildersTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e2a51f8237..7f149f0610 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1538,6 +1538,12 @@ class Account( note: Note, reaction: String, ) { + // A Marmot message reacts inside its MLS group; see AccountMarmotActions.reactToMarmotMessage. + marmot.marmotGroupOf(note)?.let { groupId -> + marmot.reactToMarmotMessage(groupId, note, reaction) + return + } + // Reactions to NIP-17 groups and unsealed rumors are gift-wrapped: the // inner kind-7 only ever travels as ciphertext, so mining it is pure // waste — those targets skip the queue and sign with the plain signer. @@ -1703,7 +1709,14 @@ class Account( suspend fun delete(notes: List) { if (!isWriteable()) return - val myNotes = notes.filter { it.author == userProfile() && it.event != null } + // Marmot messages are retracted inside their group. A public NIP-09 here would e-tag + // the group's private rumor ids onto public relays. + val (marmotNotes, otherNotes) = notes.partition { marmot.marmotGroupOf(it) != null } + marmotNotes.groupBy { marmot.marmotGroupOf(it)!! }.forEach { (groupId, groupNotes) -> + marmot.deleteMarmotMessages(groupId, groupNotes) + } + + val myNotes = otherNotes.filter { it.author == userProfile() && it.event != null } if (myNotes.isNotEmpty()) { // chunks in 200 elements to avoid going over the 65KB limit for events. myNotes.chunked(200).forEach { chunkedList -> @@ -1733,6 +1746,12 @@ class Account( if (!isWriteable()) return val targetEvent = target.event ?: return + // In a Marmot group the deletion goes to the group, not to the target's author as a DM. + marmot.marmotGroupOf(target)?.let { groupId -> + marmot.deleteMarmotMessages(groupId, notes) + return + } + val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event } if (myRumors.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 68c223da21..bdb1f9a9e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 @@ -246,6 +247,54 @@ class AccountMarmotActions( manager.persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) } + /** + * The Marmot group [note] was received or sent in, or null when it is not a + * Marmot message. + * + * Only chat rows are indexed, so pass the MESSAGE a reaction or deletion is + * about, not the reaction itself. + */ + fun marmotGroupOf(note: Note): HexKey? = account.marmotGroupList.groupIdForNote(note.idHex) + + /** + * React to a Marmot message inside its group. + * + * A Marmot message is an unsigned rumor, which the generic reaction path + * handles as a NIP-17 private note: it gift-wrapped the kind:7 to the + * author as a DM. That never reached the group, so no other client showed + * it, and it moved group activity out of the group's channel. The reaction + * is an ordinary inner kind:7 (MIP-03), encrypted to the group like any + * message. + */ + suspend fun reactToMarmotMessage( + nostrGroupId: HexKey, + target: Note, + reaction: String, + ) { + val manager = account.marmotManager ?: return + val targetEvent = target.event ?: return + if (target.hasReacted(account.userProfile(), reaction)) return + val rumor = manager.buildReactionRumor(targetEvent, reaction) + sendMarmotGroupMessage(nostrGroupId, rumor, marmotGroupRelays(nostrGroupId)) + } + + /** + * Retract our own messages or reactions in a Marmot group with an inner + * kind:5, for the same reason [reactToMarmotMessage] exists: the generic + * private path sent a gift-wrapped NIP-09 to the target's author, so the + * other members never saw the deletion. + */ + suspend fun deleteMarmotMessages( + nostrGroupId: HexKey, + notes: List, + ) { + val manager = account.marmotManager ?: return + val mine = notes.filter { it.author == account.userProfile() }.mapNotNull { it.event } + if (mine.isEmpty()) return + val rumor = manager.buildDeletionRumor(mine) + sendMarmotGroupMessage(nostrGroupId, rumor, marmotGroupRelays(nostrGroupId)) + } + /** * Fetch a user's KeyPackage from relays and add them to a Marmot group. * Returns a status message describing the outcome. diff --git a/cli/tests/marmot/marmot-interop-headless.sh b/cli/tests/marmot/marmot-interop-headless.sh index e1d7e8d2e8..afd2fbb738 100755 --- a/cli/tests/marmot/marmot-interop-headless.sh +++ b/cli/tests/marmot/marmot-interop-headless.sh @@ -210,6 +210,7 @@ ALL_TESTS=( test_27_deletion_wn_to_amy test_28_retention_wn_to_amy test_29_disband_amy_to_wn + test_31_reaction_materializes_on_wn ) # --tests runs a subset in the order given. Most tests read state a previous diff --git a/cli/tests/marmot/tests-manage.sh b/cli/tests/marmot/tests-manage.sh index 9a8fcf5fb1..29d8eff84e 100644 --- a/cli/tests/marmot/tests-manage.sh +++ b/cli/tests/marmot/tests-manage.sh @@ -250,3 +250,39 @@ test_17_group_image_commit() { record_result "$id" fail "wn could not decrypt A's post-image message — image commit not applied" fi } + +# A reaction White Noise can SEE. Test 09 only proves wn's raw event log holds a +# kind:7; the app renders the materialized timeline, which attaches a reaction +# to its target by its own rules. An amy reaction the raw log kept but the +# timeline dropped read as a pass there and as "no reaction" in the app. +test_31_reaction_materializes_on_wn() { + banner "Test 31 — amy's reaction shows in wn's materialized timeline" + local id="31 reaction materialized" + + local out gid mls_gid b_gid anchor_id + out=$(amy_json marmot group create --name "Interop-31") || { record_result "$id" fail "amy group create failed"; return; } + gid=$(printf '%s' "$out" | jq -r '.group_id') + mls_gid=$(printf '%s' "$out" | jq -r '.mls_group_id') + amy_json marmot group add "$gid" "$B_NPUB" >/dev/null || { record_result "$id" fail "amy could not invite wn"; return; } + b_gid=$(wait_for_invite B 60) || { record_result "$id" fail "wn never received the Welcome"; return; } + wn_b groups accept "$b_gid" >/dev/null 2>&1 || true + wn_group_field_becomes "$mls_gid" '.group.group_id // empty' "$mls_gid" 120 || { record_result "$id" fail "wn never surfaced the group"; return; } + + wn_b messages send "$mls_gid" "31 anchor" >/dev/null 2>&1 || true + amy_json marmot await message "$gid" --match "31 anchor" --timeout 90 >/dev/null || { record_result "$id" fail "amy never got the anchor"; return; } + anchor_id=$(amy_json marmot message list "$gid" --limit 50 2>/dev/null | jq_list messages \ + | jq -r 'select((.plaintext // .content // "") == "31 anchor") | (.message_id // .event_id)' | head -n 1) + [[ -n "$anchor_id" && "$anchor_id" != "null" ]] || { record_result "$id" fail "no anchor id in amy's log"; return; } + amy_json marmot message react "$gid" "$anchor_id" "🍕" >/dev/null || { record_result "$id" fail "amy react failed"; return; } + + local deadline=$(( $(date +%s) + 90 )) tl="" + while [[ $(date +%s) -lt $deadline ]]; do + tl=$(wn_b --json messages timeline list "$mls_gid" --limit 50 2>/dev/null || true) + if printf '%s' "$tl" | grep -q '🍕'; then + record_result "$id" pass; return + fi + sleep 3 + done + printf '%s\n' "$tl" >> "$LOG_FILE" + record_result "$id" fail "wn's timeline never showed amy's reaction (timeline JSON in the log)" +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index b100491b67..f145024fa1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -87,6 +87,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QEventTag import com.vitorpamplona.quartz.nip18Reposts.quotes.quote +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -570,13 +571,20 @@ class MarmotManager( targetEvent: Event, reaction: String, ): Event { + val hint = + com.vitorpamplona.quartz.nip01Core.hints + .EventHintBundle(targetEvent) + // A custom-emoji reaction (":name:url") carries its image in an emoji + // tag, exactly as the public NIP-25 path builds it. + val emojiUrl = if (reaction.startsWith(":")) EmojiUrlTag.decode(reaction) else null val template = - com.vitorpamplona.quartz.nip25Reactions.ReactionEvent - .build( - reaction, - com.vitorpamplona.quartz.nip01Core.hints - .EventHintBundle(targetEvent), - ) + if (emojiUrl != null) { + com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + .build(emojiUrl, hint) + } else { + com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + .build(reaction, hint) + } return com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler .assembleRumor( signer.pubKey, @@ -739,14 +747,18 @@ class MarmotManager( targetEvents: List, persistOwn: Boolean = true, ): TextMessageBundle { - require(targetEvents.isNotEmpty()) { "buildDeletionMessage: targetEvents must not be empty" } - val template = DeletionRequestEvent.build(targetEvents) - val innerEvent = RumorAssembler.assembleRumor(signer.pubKey, template) + val innerEvent = buildDeletionRumor(targetEvents) val outbound = buildGroupMessage(nostrGroupId, innerEvent) if (persistOwn) persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) return TextMessageBundle(outbound = outbound, innerEvent = innerEvent) } + /** The inner kind:5 deletion rumor alone. See [buildTextRumor]. */ + suspend fun buildDeletionRumor(targetEvents: List): DeletionRequestEvent { + require(targetEvents.isNotEmpty()) { "buildDeletionRumor: targetEvents must not be empty" } + return RumorAssembler.assembleRumor(signer.pubKey, DeletionRequestEvent.build(targetEvents)) + } + /** * A single invitee for [addMemberInvites]: whose KeyPackage is consumed, the bare * KeyPackage bytes as published, and the id of the event that carried them diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotInnerRumorBuildersTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotInnerRumorBuildersTest.kt new file mode 100644 index 0000000000..3ce3da21f8 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotInnerRumorBuildersTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.marmot + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The inner rumors the app sends for reactions and deletions in a Marmot group. + * Both used to leave the group as NIP-17 gift wraps to the target's author; they + * are now plain MIP-03 inner events, so their shape is what peers validate. + */ +class MarmotInnerRumorBuildersTest { + private val signer = NostrSignerInternal(KeyPair()) + private val manager = MarmotManager(signer, SnapshotStateStore()) + + private val target = + Event( + id = "b".repeat(64), + pubKey = "c".repeat(64), + createdAt = 1_790_000_000, + kind = 9, + tags = emptyArray(), + content = "react to me", + sig = "", + ) + + @Test + fun aReactionNamesItsTargetFirstAndIsAnUnsignedRumor() = + runBlocking { + val rumor = manager.buildReactionRumor(target, "🎉") + assertEquals(ReactionEvent.KIND, rumor.kind) + assertEquals("🎉", rumor.content) + assertEquals(signer.pubKey, rumor.pubKey) + assertTrue(rumor.sig.isEmpty(), "MIP-03 inner events are unsigned rumors") + // MDK attaches a reaction to the FIRST e tag. + assertEquals(target.id, rumor.tags.first { it[0] == "e" }[1]) + } + + @Test + fun aCustomEmojiReactionCarriesItsImage() = + runBlocking { + val rumor = manager.buildReactionRumor(target, ":soapbox:https://example.com/soapbox.png") + assertEquals(":soapbox:", rumor.content) + val emoji = rumor.tags.first { it[0] == "emoji" } + assertEquals(listOf("emoji", "soapbox", "https://example.com/soapbox.png"), emoji.take(3)) + } + + @Test + fun aDeletionTargetsEachRetractedEvent() = + runBlocking { + val rumor = manager.buildDeletionRumor(listOf(target)) + assertEquals(DeletionRequestEvent.KIND, rumor.kind) + assertEquals(signer.pubKey, rumor.pubKey) + assertTrue(rumor.sig.isEmpty()) + assertEquals(listOf(target.id), rumor.tags.filter { it[0] == "e" }.map { it[1] }) + } +} From 8f42e60b76ba639bd2a9c709291e7c5738778a6e Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:51:58 +0000 Subject: [PATCH 30/32] chore: sync Crowdin translations and seed translator npub placeholders --- .../composeResources/values-pl-rPL/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml index 68e2da7cfc..d5296874d7 100644 --- a/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values-pl-rPL/strings.xml @@ -2710,6 +2710,18 @@ Widoczne tylko dla Ciebie Zrzuć do… Wyszukiwanie urządzeń w sieci Wi-Fi… + Wymagane uprawnienia + Aplikacja Amethyst potrzebuje dostępu do sieci lokalnej, aby wykryć urządzenia obsługujące funkcję Cast w Twojej sieci Wi-Fi. Włącz tę opcję w ustawieniach aplikacji. + Otwórz ustawienia + %1$s nie może odtworzyć tego formatu wideo + %1$s nie mógł odtworzyć tego filmu + %1$s przestał odpowiadać. Ponowne uruchomienie urządzenia zwykle naprawia. + Nie można połączyć się z %1$s. Ponowne uruchomienie urządzenia zwykle je naprawia. + Nie można załadować tego filmu na %1$s + Udostępnianie wymaga usług Google Play, które nie są dostępne na tym urządzeniu + %1$s nie może odtworzyć formatu tego wideo (%2$s) + %1$s nie mógł odtworzyć tego wideo (%2$s) + %1$s nigdy nie rozpoczął odtwarzania tego wideo (%2$s). Może nie obsługiwać tego formatu, lub ponowne uruchomienie urządzenia może pomóc. Wybierz wideo Twoje wideo zostanie przekodowane do różnych rozdzielczości, dzięki czemu widzowie będą mogli cieszyć się płynnym odtwarzaniem niezależnie od szybkości połączenia. Zmień @@ -6149,6 +6161,10 @@ Przypnij Odpowiedz Odepnij + Ta zmiana nie mogła zostać wprowadzona. + Koordynator nie odpowiedział, więc nic się nie zmieniło. Spróbuj ponownie za chwilę. + %1$s nie opublikował jeszcze pakietu kluczy na tym koordynatorze, więc nie można go jeszcze dodać. Poproś go, aby najpierw uruchomił program cordn na tym koordynatorze. + Nie można samodzielnie usunąć się z grupy cordn. Czy zamienić grupy cordn tego urządzenia? Przywróć Przywrócono. From e9ccc110cbe00ffd25df1f7fd51b7f05c5015187 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 16:40:37 -0400 Subject: [PATCH 31/32] fix(relay): drop a kind the relay refuses by name instead of losing the whole REQ relay.us.whitenoise.chat (strfry plus a kind allowlist) CLOSES any REQ whose filter names a kind it does not serve, even when the other kinds are fine: ERROR: bad req: filter validation failed: kind not allowed: 21059 Amethyst's gift-wrap subscription asks for [1059, 21059] in one filter, so an account whose DM relay is that relay never received a gift wrap. That meant no NIP-17 DMs and no Marmot Welcomes: White Noise invites never arrived. The same relay refused 17 other kinds across other subscriptions. The pool treated each CLOSED as a refusal of the whole filter set, so the kinds it does serve were lost too. RelayReqRefusals now learns the kinds named in a CLOSED reason ("kind not allowed: N", "kinds not allowed: a, b", "kind N is not allowed"), per relay. PoolRequests narrows every REQ it builds for that relay (on change and on reconnect) with RelayReqRefusals.narrow: a refused kind is stripped, and a filter left with no kinds is dropped rather than sent empty (which would ask for every kind). Because the kind is learned before the CLOSED sub is re-decided, the refused subscription goes straight back out without it. Verified on device: after the change the tablet joined both White Noise invites that had been waiting on the relay (the White Noise app's 1:1 chat and a wn CLI group), and each kind refusal from that relay appears once instead of on every re-issue. Tests: PoolRequestsKindNotAllowedTest (immediate narrowed retry, reconnects stay narrowed, an only-refused-kind filter is not sent, other relays are unaffected; the first three failed before the change) and parser cases in RelayReqRefusalsTest. Quartz relay suites green (515). Not covered here: the same relay also CLOSES REQs with 4+ filters ("invalid number of filters: N") and advertises no max_filters in NIP-11. Co-Authored-By: Claude Opus 5.5 --- .../relay/client/pool/PoolRequests.kt | 5 +- .../relay/client/pool/RelayReqRefusals.kt | 71 +++++++- .../pool/PoolRequestsKindNotAllowedTest.kt | 154 ++++++++++++++++++ .../relay/client/pool/RelayReqRefusalsTest.kt | 14 ++ 4 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsKindNotAllowedTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 4630a5c58b..bddc058da3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -373,7 +373,7 @@ class PoolRequests( // this relay has structurally refused too many times — replaying it on // every reconnect is the doomed-REQ loop this guard exists to stop. desiredSubs.forEach { subId, perRelayFilters -> - val filters = perRelayFilters[relay] + val filters = perRelayFilters[relay]?.let { relayRefusals.narrow(relay, it) } if (!filters.isNullOrEmpty()) { val send = subState(subId).let { state -> @@ -478,7 +478,8 @@ class PoolRequests( relay: NormalizedRelayUrl, ): Command? { val oldFilters = state.currentFilters(relay) - val newFilters = desiredSubs.get(subId)?.get(relay) + // As the relay will serve them: kinds it refused by name are stripped. + val newFilters = desiredSubs.get(subId)?.get(relay)?.let { relayRefusals.narrow(relay, it) } return when { newFilters.isNullOrEmpty() -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt index 0ec74ff71d..8aa828e211 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusals.kt @@ -99,6 +99,7 @@ class RelayReqRefusals( relay: NormalizedRelayUrl, reason: String, ): Boolean { + learnDisallowedKinds(relay, reason) val candidate = classify(reason) ?: return false // NO_READS is the strictest verdict; once reached, nothing softens it. if (blocked[relay] == Policy.NO_READS) return false @@ -126,14 +127,60 @@ class RelayReqRefusals( relay: NormalizedRelayUrl, filters: List, ): Boolean = - when (blocked[relay]) { - Policy.NO_READS -> true - Policy.SEARCH_ONLY -> filters.isNotEmpty() && filters.all { it.search.isNullOrEmpty() } - null -> false - } + // Everything this REQ asks for is a kind the relay refused. + (filters.isNotEmpty() && narrow(relay, filters).isEmpty()) || + when (blocked[relay]) { + Policy.NO_READS -> true + Policy.SEARCH_ONLY -> filters.isNotEmpty() && filters.all { it.search.isNullOrEmpty() } + null -> false + } fun blockedRelays(): Map = blocked.snapshot() + // Kinds a relay has said it will not serve at all ("kind not allowed: 21059"). + private val disallowedKinds = ConcurrentMap>() + + /** + * Learn the kinds a relay named as not allowed in a CLOSED reason. + * + * A relay with a kind allowlist refuses the WHOLE REQ over one kind it doesn't + * serve, so the kinds it does serve in that filter are lost with it. The message + * names the kind, so one refusal is enough to learn it: nothing is guessed, and + * [narrow] strips exactly that kind for exactly this relay. + */ + private fun learnDisallowedKinds( + relay: NormalizedRelayUrl, + reason: String, + ) { + val kinds = parseDisallowedKinds(reason) + if (kinds.isEmpty()) return + disallowedKinds.merge(relay, kinds) { old, new -> old + new } + } + + /** + * [filters] as [relay] will actually serve them: kinds the relay refused are + * removed. A filter whose kinds all got removed is dropped, never sent with an + * empty kind list, which would ask for EVERY kind. A filter with no kind list + * is left alone. + */ + fun narrow( + relay: NormalizedRelayUrl, + filters: List, + ): List { + val refused = disallowedKinds[relay] ?: return filters + return filters.mapNotNull { filter -> + val kinds = filter.kinds ?: return@mapNotNull filter + val kept = kinds.filterNot { it in refused } + when { + kept.size == kinds.size -> filter + kept.isEmpty() -> null + else -> filter.copy(kinds = kept) + } + } + } + + fun disallowedKinds(relay: NormalizedRelayUrl): Set = disallowedKinds[relay] ?: emptySet() + private fun classify(reason: String): Policy? { val t = reason.lowercase() if (SEARCH_REQUIRED_MARKERS.any { it in t }) return Policy.SEARCH_ONLY @@ -142,6 +189,20 @@ class RelayReqRefusals( } companion object { + // "kind not allowed: 21059", "kinds not allowed: 7374, 30382", "kind 21059 is not allowed" + private val KINDS_AFTER_MARKER = Regex("""kinds? (?:is |are )?not allowed:?\s*([0-9][0-9,\s]*)""") + private val KIND_BEFORE_MARKER = Regex("""kind ([0-9]+) (?:is )?not allowed""") + + fun parseDisallowedKinds(reason: String): Set { + val t = reason.lowercase() + val kinds = mutableSetOf() + KINDS_AFTER_MARKER.findAll(t).forEach { m -> + m.groupValues[1].split(',', ' ').mapNotNullTo(kinds) { it.trim().toIntOrNull() } + } + KIND_BEFORE_MARKER.findAll(t).forEach { m -> m.groupValues[1].toIntOrNull()?.let { kinds.add(it) } } + return kinds + } + // The relay only serves NIP-50 search REQs (a plain feed REQ is refused). private val SEARCH_REQUIRED_MARKERS = listOf( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsKindNotAllowedTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsKindNotAllowedTest.kt new file mode 100644 index 0000000000..e87a6220e1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequestsKindNotAllowedTest.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A relay with a kind allowlist CLOSES any REQ that names a kind it doesn't serve, + * even when the other kinds in the filter are fine. relay.us.whitenoise.chat answers + * a `{kinds:[1059, 21059], #p:[me]}` gift-wrap REQ with + * `ERROR: bad req: filter validation failed: kind not allowed: 21059`, so an account + * whose only DM relay it is never received a DM or a Marmot Welcome: the whole + * subscription was refused because of the ephemeral kind riding along. + */ +class PoolRequestsKindNotAllowedTest { + private val relay = NormalizedRelayUrl("wss://relay.us.whitenoise.chat/") + private val other = NormalizedRelayUrl("wss://nos.lol/") + private val refusal = "ERROR: bad req: filter validation failed: kind not allowed: 21059" + + private class RecordingRelayClient( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + val sent = mutableListOf() + + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun sendOrConnectAndSync(cmd: Command) { + sent.add(cmd) + } + + override fun sendIfConnected(cmd: Command) { + sent.add(cmd) + } + + override fun disconnect() = Unit + } + + private fun giftWraps() = listOf(Filter(kinds = listOf(1059, 21059), tags = mapOf("p" to listOf("a".repeat(64))))) + + private fun sync( + pool: PoolRequests, + url: NormalizedRelayUrl, + ): List { + pool.onConnecting(url) + val sent = mutableListOf() + pool.syncState(url) { sent.add(it) } + return sent.filterIsInstance() + } + + @Test + fun theRefusedKindIsDroppedAndTheRestIsRequestedAgainAtOnce() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("giftwraps", mapOf(relay to giftWraps()), null) + assertEquals( + listOf(1059, 21059), + sync(pool, relay) + .single() + .filters + .single() + .kinds, + ) + + val client = RecordingRelayClient(relay) + pool.onIncomingMessage(client, ClosedMessage("giftwraps", refusal)) + + val retry = client.sent.filterIsInstance().single() + assertEquals(listOf(1059), retry.filters.single().kinds, "the CLOSED sub comes straight back without 21059") + assertEquals(mapOf("p" to listOf("a".repeat(64))), retry.filters.single().tags, "and nothing else about it changes") + } + + @Test + fun laterReconnectsNeverOfferTheRefusedKindAgain() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("giftwraps", mapOf(relay to giftWraps()), null) + sync(pool, relay) + pool.onIncomingMessage(RecordingRelayClient(relay), ClosedMessage("giftwraps", refusal)) + + repeat(3) { + assertEquals( + listOf(1059), + sync(pool, relay) + .single() + .filters + .single() + .kinds, + ) + } + } + + @Test + fun aFilterAskingOnlyForTheRefusedKindIsNotSent() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("giftwraps", mapOf(relay to giftWraps()), null) + sync(pool, relay) + pool.onIncomingMessage(RecordingRelayClient(relay), ClosedMessage("giftwraps", refusal)) + + pool.addOrUpdate("ephemeral", mapOf(relay to listOf(Filter(kinds = listOf(21059)))), null) + val reqs = sync(pool, relay) + assertTrue(reqs.none { it.subId == "ephemeral" }, "a REQ with no kind left would ask for EVERY kind") + } + + @Test + fun theKindIsOnlyRefusedOnTheRelayThatRefusedIt() = + kotlinx.coroutines.test.runTest { + val pool = PoolRequests() + pool.addOrUpdate("giftwraps", mapOf(relay to giftWraps(), other to giftWraps()), null) + sync(pool, relay) + pool.onIncomingMessage(RecordingRelayClient(relay), ClosedMessage("giftwraps", refusal)) + + assertEquals( + listOf(1059, 21059), + sync(pool, other) + .single() + .filters + .single() + .kinds, + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusalsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusalsTest.kt index bed37f8339..21ca1d14e3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusalsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayReqRefusalsTest.kt @@ -104,4 +104,18 @@ class RelayReqRefusalsTest { assertFalse(refusals.shouldSuppress(relay, plain()), "no single class reached the threshold") } + + @Test + fun parsesTheKindsARelaySaysItDoesNotAllow() { + assertEquals(setOf(21059), RelayReqRefusals.parseDisallowedKinds("ERROR: bad req: filter validation failed: kind not allowed: 21059")) + assertEquals(setOf(7374, 30382), RelayReqRefusals.parseDisallowedKinds("blocked: kinds not allowed: 7374, 30382")) + assertEquals(setOf(4), RelayReqRefusals.parseDisallowedKinds("restricted: kind 4 is not allowed")) + } + + @Test + fun otherRefusalsNameNoKind() { + assertEquals(emptySet(), RelayReqRefusals.parseDisallowedKinds("auth-required: please authenticate")) + assertEquals(emptySet(), RelayReqRefusals.parseDisallowedKinds("error: search filter is required")) + assertEquals(emptySet(), RelayReqRefusals.parseDisallowedKinds("blocked: not allowed to read")) + } } From fa534e09755b2863dec56632f890fe4d70c68cf2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 18:57:15 -0400 Subject: [PATCH 32/32] fix(marmot): apply edits from other clients, live and after restart A White Noise edit (inner kind:1009) decrypted fine and never showed: - A 1009 has no typed event class, so LocalCache's dispatch has no case for it and justConsume dropped it ("Event Not Supported" in logcat). The edit's note was left without an event, and Note.latestMarmotEdit, which matches on the event's kind and author, skipped it. - Because justConsume never called it new, the edit was also never persisted to the group's message log. And the startup restore of that log did not re-link edits to their message at all, so even a persisted edit was lost on restart. AccountMarmotActions.indexMarmotInnerEvent now does the indexing for both live decryption and the startup restore. It caches the inner event and holds it on its note with loadEvent, which also sets the author the overlay checks. An edit, which the cache cannot type, is attached directly instead of being passed to justConsume. It links an edit to the message it replaces. It reports "new" correctly for an edit (its note was empty), so edits are persisted and come back after a restart. Verified on device: a White Noise edit made hours earlier now renders in Amethyst ("hello from WhiteNoise 1 EDITED (edited)"), in the message and in a reply quoting it, after a restart, via the restore path. The live path needs the group's messages to arrive at all; on the White Noise relay that also depends on #4239 (filter-count cap). Co-Authored-By: Claude Opus 5.5 --- .../vitorpamplona/amethyst/model/Account.kt | 8 ++-- .../amethyst/model/AccountMarmotActions.kt | 43 +++++++++++++++++++ .../loggedIn/DecryptAndIndexProcessor.kt | 34 +++------------ 3 files changed, 51 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7f149f0610..29d1baedd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -4017,11 +4017,9 @@ class Account( // via wasVerified=false) would silently drop // kind:7 reactions / kind:5 deletions since // they never carry a Schnorr signature. - val isNew = cache.justConsume(innerEvent, null, true) - val innerNote = cache.getOrCreateNote(innerEvent.id) - if (isNew) { - innerNote.event = innerEvent - } + // Same indexing as live decryption, so a restored + // kind:1009 edit is re-linked to its message too. + val innerNote = marmot.indexMarmotInnerEvent(innerEvent).note marmotGroupList.addMessage(groupId, innerNote) } catch (e: Exception) { Log.w( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index bdb1f9a9e6..15df5f3552 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.appComponents.MarmotWebUrl import com.vitorpamplona.quartz.marmot.appComponents.MessageRetentionV1 +import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent +import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState @@ -247,6 +249,47 @@ class AccountMarmotActions( manager.persistDecryptedMessage(nostrGroupId, innerEvent.toJson()) } + /** + * Index a decrypted Marmot inner event: cache it, hold it on its note, and link an + * edit to the message it replaces. Shared by live decryption and the startup restore + * of the stored message log, so both see the same thing. + * + * A kind:1009 edit has no typed class, so `LocalCache` has no case for it and + * `justConsume` drops it ("Event Not Supported"). The note then had no event and + * `Note.latestMarmotEdit`, which matches on the event's kind, skipped it: an edit + * from White Noise decrypted fine and never showed. An edit needs nothing from the + * cache but its note, so it is attached directly. + */ + fun indexMarmotInnerEvent(innerEvent: Event): IndexedInnerEvent { + val cache = account.cache + val isEdit = innerEvent.kind == MarmotAppEvent.KIND_EDIT + val innerNote = cache.getOrCreateNote(innerEvent.id) + // wasVerified=true: MIP-03 inner events are unsigned rumors; MLS authenticated the sender. + // For an edit, "new" is whether its note was empty — which also decides whether it is + // persisted, so an edit is kept in the local log and survives a restart. + val isNew = if (isEdit) innerNote.event == null else cache.justConsume(innerEvent, null, true) + if (isNew || innerNote.event == null) { + // loadEvent, not a bare `event =`: the overlay also matches the edit's AUTHOR to the + // message's, and only loadEvent sets it. + innerNote.loadEvent(innerEvent, cache.getOrCreateUser(innerEvent.pubKey), emptyList()) + } + + // The overlay's rules (author-only, latest wins) are applied at render time by + // `Note.latestMarmotEdit`: the target's author may not be known yet. + if (isEdit) { + MarmotMessageEdit.fromAppEvent(MarmotAppEvent.fromEvent(innerEvent))?.let { edit -> + cache.getOrCreateNote(edit.targetId).addEdit(innerNote) + } + } + return IndexedInnerEvent(innerNote, isNew) + } + + /** [note] holds the inner event; [isNew] is true the first time this client indexed it. */ + class IndexedInnerEvent( + val note: Note, + val isNew: Boolean, + ) + /** * The Marmot group [note] was received or sent in, or null when it is not a * Marmot message. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index e09c5e6e4a..83d907fb90 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -32,8 +32,6 @@ import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.marmot.GroupEventResult import com.vitorpamplona.quartz.marmot.MarmotInboundProcessor import com.vitorpamplona.quartz.marmot.WelcomeResult -import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotAppEvent -import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -672,15 +670,9 @@ class GroupEventHandler( // needs this path to surface the note (otherwise the // operator saw nothing but a misleading "inner event // already in cache" log and the message never rendered). - val isNew = cache.justConsume(innerEvent, null, true) - val innerNote = cache.getOrCreateNote(innerEvent.id) - if (isNew) { - innerNote.event = innerEvent - } else { - Log.d("MarmotDbg") { - "GroupEventHandler.add: inner event already in cache — surfacing in chatroom anyway" - } - } + val indexed = account.marmot.indexMarmotInnerEvent(innerEvent) + val innerNote = indexed.note + val isNew = indexed.isNew // Link the envelope to its inner note and copy over the // relays that delivered/accepted the kind-445 so far, so @@ -694,24 +686,8 @@ class GroupEventHandler( cache.copyRelaysFromTo(outerNote, innerEvent.id) } - // A kind:1009 edit is anchored to the message it replaces, - // exactly like a Concord edit or a reaction: the bubble reads - // `Note.edits`, and holding the edit as a hard-referenced - // child of its target is what keeps it alive as long as that - // target is. A Marmot inner event is decrypted exactly once — - // the ratchet has moved on by the time anyone could re-fetch - // it — so an edit left orphaned in the soft cache could be - // collected and never come back. - // - // The overlay's own rules (author-only, latest wins) are - // applied at render time by `Note.latestMarmotEdit`, not here: - // the target's author is not necessarily known yet when the - // edit arrives, and a link is not an endorsement. - if (innerEvent.kind == MarmotAppEvent.KIND_EDIT) { - MarmotMessageEdit.fromAppEvent(MarmotAppEvent.fromEvent(innerEvent))?.let { edit -> - cache.getOrCreateNote(edit.targetId).addEdit(innerNote) - } - } + // A kind:1009 edit was linked to the message it replaces by + // indexMarmotInnerEvent, which also holds it on its note. // Push token gossip (kinds 447/448/449) is routing data for // a notification server, addressed to the other members'