feat(nip46): scan nostrconnect:// from anywhere and un-gate the connect flow

Two UX gaps made "scan an app's code to connect it to my signer" hard to
reach:

- The signer screen's Connect section (Scan a code / paste) lived behind
  the enabled gate, so a first-time user saw only the "Turn on signer"
  hero and couldn't scan until they had enabled. Since a successful
  nostrconnect pairing already calls setEnabled(true), the gate was
  pointless. The Connect section now shows in both states; the success
  toast spells out the consequence ("Amethyst now signs for it in the
  background").
- The app's general QR scanner (uriToRoute) only understood NIP-19
  entities, so scanning a nostrconnect:// code did nothing. It now routes
  a nostrconnect:// offer to the signer screen, which pairs the app on
  open. Route.Nip46Signer carries an optional connectUri for this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:28 +00:00
parent cceac4478a
commit 3ed8779fff
7 changed files with 64 additions and 37 deletions
@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -224,6 +225,12 @@ fun uriToRoute(
return connectedAppRoute(uri)
}
// A scanned/opened `nostrconnect://` offer is an app asking to connect to our signer: open the
// NIP-46 signer screen and let it run the pairing (it enables the signer as part of connecting).
if (uri.startsWith(NostrConnectURI.NOSTRCONNECT_SCHEME)) {
return Route.Nip46Signer(connectUri = uri)
}
relayGroupInviteRoute(uri)?.let { return it }
concordInviteRoute(uri)?.let { return it }
@@ -424,7 +424,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.NostrApp>(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.Nip46Signer> { Nip46SignerScreen(accountViewModel, nav) }
composableFromEndArgs<Route.Nip46Signer> { Nip46SignerScreen(accountViewModel, nav, it.connectUri) }
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
@@ -107,7 +107,10 @@ sealed class Route {
@Serializable object ConnectedApps : Route()
@Serializable object Nip46Signer : Route()
@Serializable data class Nip46Signer(
/** When set (from a scanned/opened `nostrconnect://` offer), the screen connects that app on open. */
val connectUri: String? = null,
) : Route()
@Serializable object RelayAuthSettings : Route()
@@ -75,7 +75,7 @@ fun buildSettingsCatalog(
symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges),
symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets),
symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps),
symEntry(R.string.nip46_signer_title, MaterialSymbols.Key, R.string.nip46_signer_search_keywords, Route.Nip46Signer),
symEntry(R.string.nip46_signer_title, MaterialSymbols.Key, R.string.nip46_signer_search_keywords, Route.Nip46Signer()),
symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings),
symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters),
symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings),
@@ -101,6 +101,7 @@ private val LiveGreen = Color(0xFF3DDC84)
fun Nip46SignerScreen(
accountViewModel: AccountViewModel,
nav: INav,
connectUri: String? = null,
) {
val account = accountViewModel.account
val signer = account.nip46Signer
@@ -138,6 +139,11 @@ fun Nip46SignerScreen(
}
}
// Opened from a scanned/shared nostrconnect:// offer — pair that app on open (this also enables the signer).
LaunchedEffect(connectUri) {
if (!connectUri.isNullOrBlank()) onConnect(connectUri)
}
if (scanning) {
SimpleQrCodeScanner { contents ->
scanning = false
@@ -162,47 +168,52 @@ fun Nip46SignerScreen(
return@Column
}
if (!enabled) {
DisabledHero(onEnable = { signer.setEnabled(true) })
return@Column
}
LiveStatusCard(
relayCount = relays.size,
connectedCount = connectedCount,
onToggleOff = { signer.setEnabled(false) },
)
if (relays.isEmpty()) {
WarningCard(stringResource(R.string.nip46_signer_status_no_relays))
}
bunkerUri?.let { uri ->
QrHeroCard(
uri = uri,
onCopy = {
clipboard.setText(AnnotatedString(uri))
Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show()
},
onRegenerate = { confirmRotate = true },
if (enabled) {
LiveStatusCard(
relayCount = relays.size,
connectedCount = connectedCount,
onToggleOff = { signer.setEnabled(false) },
)
if (relays.isEmpty()) {
WarningCard(stringResource(R.string.nip46_signer_status_no_relays))
}
bunkerUri?.let { uri ->
QrHeroCard(
uri = uri,
onCopy = {
clipboard.setText(AnnotatedString(uri))
Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show()
},
onRegenerate = { confirmRotate = true },
)
}
} else {
DisabledHero(onEnable = { signer.setEnabled(true) })
}
// The Connect section stays reachable even while off: scanning an app's nostrconnect://
// code pairs it and enables the signer as part of connecting (no separate "enable" step).
ConnectSection(
onScan = { scanning = true },
onPaste = { onConnect(it) },
)
ConnectedAppsRow(
count = connectedCount,
onClick = { nav.nav(Route.ConnectedApps) },
)
if (enabled || connectedCount > 0) {
ConnectedAppsRow(
count = connectedCount,
onClick = { nav.nav(Route.ConnectedApps) },
)
}
Text(
stringResource(R.string.nip46_signer_background_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (enabled) {
Text(
stringResource(R.string.nip46_signer_background_hint),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
+2 -2
View File
@@ -923,8 +923,8 @@
<string name="nip46_signer_connect_hint">Paste a nostrconnect:// link</string>
<string name="nip46_signer_connect_button">Connect</string>
<string name="nip46_signer_manage_apps">Manage connected apps</string>
<string name="nip46_signer_connected_ok">App connected</string>
<string name="nip46_signer_connected_named">Connected %1$s</string>
<string name="nip46_signer_connected_ok">App connected. Amethyst now signs for it in the background.</string>
<string name="nip46_signer_connected_named">Connected %1$s. Amethyst now signs for it in the background.</string>
<string name="nip46_signer_connect_invalid">Not a valid nostrconnect:// link</string>
<string name="nip46_signer_connect_no_relays">That link carries no relay to connect on</string>
<string name="nip46_signer_connect_failed">Could not connect: %1$s</string>
@@ -58,6 +58,12 @@ class UriToRouteTest {
assertEquals(Route.Hashtag("foo"), uriToRoute("nostr:hashtag?id=foo", account))
}
@Test
fun nostrConnectOfferRoutesToTheSignerScreenCarryingTheUri() {
val offer = "nostrconnect://" + "b".repeat(64) + "?relay=wss%3A%2F%2Frelay.example.com&secret=abc123"
assertEquals(Route.Nip46Signer(connectUri = offer), uriToRoute(offer, account))
}
@Test
fun fragmentHashtagOrNullExtractsTheTag() {
assertEquals("NostrMultiplayerGames", fragmentHashtagOrNull("#NostrMultiplayerGames"))