fix(qr): correct the bunker message, and pin what the scanner sheet has to explain

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0134jvyriixNTHST4WRbbqbX
This commit is contained in:
Claude
2026-09-15 19:23:22 +00:00
parent 1f3929d74a
commit c3cecf1e9f
4 changed files with 56 additions and 2 deletions
+4
View File
@@ -1,4 +1,8 @@
{
"env": {
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8"
},
"hooks": {
"PreToolUse": [
{
@@ -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)
@@ -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"
@@ -218,7 +218,8 @@
<string name="qr_scanner_unsupported_secret">This code carries a private key or a pairing secret, so its contents are not shown here. Use the screen that expects it.</string>
<string name="qr_scanner_kind_nsec">This is a private key. Paste it on the login screen instead.</string>
<string name="qr_scanner_kind_wallet">This is a wallet connection. Add it from Wallet settings.</string>
<string name="qr_scanner_kind_signer">This is a remote signer. Add it from the NIP-46 signer screen.</string>
<string name="qr_scanner_kind_bunker">This is a remote-signer address. Amethyst hands these out so other apps can ask it to sign — it cannot sign in with one.</string>
<string name="qr_scanner_kind_signer">This is an app asking to connect to your signer. Open it from the NIP-46 signer screen.</string>
<string name="qr_scanner_kind_lightning">This is a Lightning invoice.</string>
<string name="qr_scanner_kind_cashu">This is a Cashu token.</string>
<string name="qr_scanner_kind_web">This is a web link.</string>