- Updates CLAUDE.md tech stack to current versions (Compose 1.10.3, Kotlin 2.3.20). - Reframes kotlin-multiplatform iOS as mature; adds secp256k1-kmp 0.23.0 references. - Updates desktop-expert Main.kt references (code grew from ~270 to 1341 lines and NavigationRail moved to ui/deck/SinglePaneLayout.kt); replaces obsolete "hardcoded ctrl = true" anti-pattern note with accurate isMacOS branching. - Removes compose-desktop.md (superseded by desktop-expert/). - Adds nostr-expert references: nip19-bech32, event-factory, crypto-and-encryption, large-cache. Adds kotlin-expert/common-utilities, compose-expert/rich-text-parsing, android-expert/image-loading. - New skills: account-state (Account + LocalCache), relay-client (subscriptions, filter assemblers, preloaders), feed-patterns (FeedFilter + FeedViewModel family), auth-signers (NostrSigner across internal / NIP-46 / NIP-55).
5.0 KiB
NIP-55 Android External Signer
NIP-55 lets the user delegate signing to another Android app (Amber, nos2x-fox, etc.) that holds the private key. Communication is via Intents and a ContentProvider, not Nostr itself.
Layout
Android-only, under quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/:
nip55AndroidSigner/
├── JsonMapperNip55.kt ── JSON layer for intent extras
├── SignString.kt ── canonical string to sign for login challenges
├── api/
│ ├── CommandType.kt ── sign_event / nip04_encrypt / nip44_encrypt / …
│ ├── SignerResult.kt ── sealed result sent back by launcher callback
│ ├── background/ ── "background" signer path via ContentProvider (no UI)
│ ├── foreground/ ── "foreground" signer path via Activity + Intent
│ └── permission/ ── permission grant / revoke helpers
└── client/
├── ExternalSignerLogin.kt ── one-shot login / bootstrap intent
├── IActivityLauncher.kt ── abstraction over Activity + ActivityResultLauncher
├── IsExternalSignerInstalled.kt ── query PM for compatible signers
├── NostrSignerExternal.kt ── the NostrSigner impl
└── handlers/ ── per-command result handlers
Amethyst's Android app uses ExternalSignerButton (amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt) as the sign-up entry point.
Two Transport Modes
Foreground (Activity + Intent)
- Launches an
IntentwithACTION_VIEWand data URInostrsigner:<payload>. - The signer app opens, shows a UI prompt, returns via
onActivityResult. - Always works, but requires user interaction each call unless the user has "always allow" granted.
- Lives in
api/foreground/andclient/handlers/.
Background (ContentProvider)
- Queries the signer's
ContentProviderwithcontent://<signer-auth>/sign_event?.... - No UI interaction — signer either silently approves (if pre-authorized) or denies.
- Requires the user to have granted "always allow" permission beforehand via the foreground flow.
- Lives in
api/background/andapi/permission/. - Falls back to foreground when background denies.
NostrSignerExternal picks the path automatically: try background if pre-authorized, else foreground. See client/handlers/ for the per-command dispatch.
Command Types
api/CommandType.kt enumerates what the external signer supports:
sign_event— sign a Nostr event.nip04_encrypt/nip04_decrypt— legacy DMs.nip44_encrypt/nip44_decrypt— NIP-44 payloads (gift-wrap).get_public_key— identity check.decrypt_zap_event— LN zap request decoding.connect— bootstrap / permissions.
Commands map 1:1 to NostrSigner abstract methods.
Installation Check
Before showing the "Use external signer" button, IsExternalSignerInstalled.kt queries the Android PackageManager for intent filters matching nostrsigner: URIs. If no compatible app is installed, hide the button (the UI already does this).
Permission Flow
- User taps "Use external signer" →
ExternalSignerLogin.launch(activityLauncher). - Amethyst fires an intent asking the signer for the user's pubkey.
- Signer app opens, user approves, returns via
onActivityResult. NostrSignerExternalis created with that pubkey and the package name of the approved signer.- On subsequent sign requests, Amethyst tries background (via
ContentProvider); if not granted, falls back to foreground intent.
api/permission/ has helpers to pre-grant / revoke permissions through the signer's dedicated permission URI.
Gotchas
- Activity context required. All launch paths need an
Activity, not just aContext. Design flows so the launcher is available when sign is called — if a background service needs to sign, it must defer until the app is foregrounded, or show a notification. - Foreground loop. Without "always allow", every single event sign = one Activity round-trip. That's bad UX for reactions / zaps. Push users toward granting always-allow.
- Multiple signer apps installed.
IActivityLauncherhonors the one the user first approved, persisted inAccountSyncedSettings. Changing signers requires explicit re-login. - KMP boundary.
NostrSignerExternalis strictly Android; Desktop usesNostrSignerInternalorNostrSignerRemote(NIP-46 bunker). Don't pretend there's a portable external-signer layer. - Test coverage. Signer Android flows have instrumented tests in
quartz/src/androidDeviceTest/kotlin/.../nip55AndroidSigner/— device or emulator only.
Related
nip46-remote-signer.md— the other delegated-signing path (works on all platforms).android-expert/references/android-permissions.md— Android permission mechanics.android-expert/SKILL.md— intent / activity-result launcher patterns.