From c3cecf1e9f336cba2ddefe20ec752e6a11c0f7ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 19:23:22 +0000 Subject: [PATCH] fix(qr): correct the bunker message, and pin what the scanner sheet has to explain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While looking into routing more scanned payloads I found the reverse: the scanner's explanation sheet told users to add a bunker:// address "from the NIP-46 signer screen", which cannot accept one. Amethyst *publishes* bunker addresses — it is the remote signer — and the signer screen pairs nostrconnect:// offers only; remote-signer login exists on Desktop and even there it generates a nostrconnect:// rather than consuming a bunker URI. Split the string so bunker and nostrconnect each say something true. The investigation also showed raw nostr+walletconnect:// URIs already route, via the fallback at the end of uriToRoute rather than the dlnwc?value= branch above it. Both behaviours are now pinned by tests, because what uriToRoute returns null for is exactly what that sheet has to explain. Also set LANG/LC_ALL to C.UTF-8 for agent sessions. Without a UTF-8 locale sun.jnu.encoding is ASCII, and `./gradlew test` fails while *writing its HTML report* — after every test has passed — because eleven test names across four modules contain an em dash. It cost a full 12-minute run this session and would cost one in every future session. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0134jvyriixNTHST4WRbbqbX --- .claude/settings.json | 4 ++ .../qrcode/scanner/ScanOutcomeSheet.kt | 7 ++- .../amethyst/ui/UriToRouteTest.kt | 44 +++++++++++++++++++ .../composeResources/values/strings.xml | 3 +- 4 files changed, 56 insertions(+), 2 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index c0238b52b5..e7a86ad229 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,8 @@ { + "env": { + "LANG": "C.UTF-8", + "LC_ALL": "C.UTF-8" + }, "hooks": { "PreToolUse": [ { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanOutcomeSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanOutcomeSheet.kt index 1352a805b6..e6e06d6ea2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanOutcomeSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/scanner/ScanOutcomeSheet.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.qr_scanner_copy +import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_bunker import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_cashu import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_lightning import com.vitorpamplona.amethyst.commons.resources.qr_scanner_kind_nostr_unsupported @@ -137,7 +138,11 @@ private fun explain(payload: ScannedPayload): String = } is ScannedPayload.WalletConnect -> stringRes(Res.string.qr_scanner_kind_wallet) - is ScannedPayload.Bunker, is ScannedPayload.NostrConnect -> stringRes(Res.string.qr_scanner_kind_signer) + // Split deliberately: Amethyst PUBLISHES bunker:// addresses (it is the signer) and has no + // screen that consumes one, so pointing the user at the signer screen -- as this used to -- + // sends them somewhere that cannot accept it. + is ScannedPayload.Bunker -> stringRes(Res.string.qr_scanner_kind_bunker) + is ScannedPayload.NostrConnect -> stringRes(Res.string.qr_scanner_kind_signer) is ScannedPayload.Lightning -> stringRes(Res.string.qr_scanner_kind_lightning) is ScannedPayload.Cashu -> stringRes(Res.string.qr_scanner_kind_cashu) is ScannedPayload.Web -> stringRes(Res.string.qr_scanner_kind_web) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt index 309e8f338f..bef8fe833e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt @@ -58,6 +58,50 @@ class UriToRouteTest { assertEquals(Route.Hashtag("foo"), uriToRoute("nostr:hashtag?id=foo", account)) } + // The QR scanner shows a "can't open this" sheet for anything uriToRoute returns null for, so + // what does and does not route here decides what that sheet ever has to explain. + + @Test + fun rawWalletConnectUrisRouteWithoutTheDlnwcWrapper() { + // Not just the `dlnwc?value=` deep-link form: a wallet's QR code holds the bare URI, and + // the fallback at the end of uriToRoute is what catches it. + assertEquals( + Route.WalletAddNwc(NWC_URI), + uriToRoute(NWC_URI, account), + ) + } + + @Test + fun everyWalletConnectSchemeSpellingRoutes() { + assertEquals( + Route.WalletAddNwc(NWC_URI_NO_PLUS), + uriToRoute(NWC_URI_NO_PLUS, account), + ) + } + + @Test + fun walletConnectDeepLinksStillUnwrapTheValueParameter() { + assertEquals( + Route.WalletAddNwc(NWC_URI), + uriToRoute("dlnwc?value=$NWC_URI", account), + ) + } + + @Test + fun bunkerUrisDoNotRouteAnywhere() { + // Amethyst *publishes* bunker:// addresses (it is the remote signer); it has no screen that + // consumes one. The NIP-46 signer screen pairs nostrconnect:// offers only. Until that + // changes, a scanned bunker:// belongs in the scanner's explanation sheet, and the sheet + // must not tell the user to take it somewhere that cannot accept it. + assertNull(uriToRoute("bunker://$PUBKEY_HEX?relay=wss%3A%2F%2Frelay.example&secret=abc", account)) + } + + companion object { + private const val PUBKEY_HEX = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + private const val NWC_URI = "nostr+walletconnect://$PUBKEY_HEX?relay=wss%3A%2F%2Frelay.example&secret=$PUBKEY_HEX" + private const val NWC_URI_NO_PLUS = "nostrwalletconnect://$PUBKEY_HEX?relay=wss%3A%2F%2Frelay.example&secret=$PUBKEY_HEX" + } + @Test fun nostrConnectOfferRoutesToTheSignerScreenCarryingTheUri() { val offer = "nostrconnect://" + "b".repeat(64) + "?relay=wss%3A%2F%2Frelay.example.com&secret=abc123" diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 259a1a8869..4b80850cb4 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -218,7 +218,8 @@ This code carries a private key or a pairing secret, so its contents are not shown here. Use the screen that expects it. This is a private key. Paste it on the login screen instead. This is a wallet connection. Add it from Wallet settings. - This is a remote signer. Add it from the NIP-46 signer screen. + This is a remote-signer address. Amethyst hands these out so other apps can ask it to sign — it cannot sign in with one. + This is an app asking to connect to your signer. Open it from the NIP-46 signer screen. This is a Lightning invoice. This is a Cashu token. This is a web link.