12 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Build & development commands
./gradlew assembleDebug # debug build (Android app, :app module)
./gradlew assembleRelease # release build (requires signing keystore)
./gradlew ktlintCheck # lint check (also runs on every git commit via pre-commit hook)
./gradlew ktlintFormat # auto-fix lint issues
./gradlew test --no-daemon # unit tests (also runs on every git push via pre-push hook)
./build.sh # builds both offline and free release variants to ~/release/
./gradlew :desktop:run # run the desktop bunker app locally
./gradlew :desktop:createDistributable # build a runnable app image (no native installer)
./gradlew :desktop:packageDeb # Linux .deb (packageMsi/packageDmg need to run on Windows/macOS hosts)
./gradlew :shared:desktopTest # JVM unit tests for the shared bunker signing engine
Git hooks are auto-installed via the root build.gradle.kts preBuild task — no manual setup needed.
Modules
| Module | Purpose |
|---|---|
:app |
The Android app — all three request-ingestion paths, full UI, Room-backed persistence. Unaffected by the :shared/:desktop split; still self-contained. |
:shared |
Kotlin Multiplatform module (androidTarget() + jvm("desktop")) holding the portable NIP-46 bunker signing engine used by :desktop. See "Desktop bunker app" below. |
:desktop |
Compose Multiplatform Desktop app — a focused bunker-only signer for Linux/Windows/Mac, not a port of the full Android UI. |
Build flavors
| Flavor | Purpose |
|---|---|
free (default) |
Online variant with full networking (OkHttp, Coil, relay connectivity) |
offline |
No network stack; use BuildFlavorChecker.isOfflineFlavor() to guard network code |
:desktop always has networking (a bunker signer is meaningless offline) and does not have flavors.
Architecture
Request ingestion — three paths
External apps and relays reach the signer through three distinct paths:
nostrsigner://Intent →SignerActivity→ parsed byIntentUtils→ shown as bottom sheet- ContentProvider IPC →
SignerProvider→ synchronous signing viarunBlocking - NIP-46 relay events (kind 24133) →
NotificationSubscription→EventNotificationConsumer→BunkerRequestUtils
All three paths converge on Account.sign() / encrypt/decrypt methods backed by NostrSignerInternal. This is all :app (Android); the desktop app is a separate, fourth entry point — see below.
Desktop bunker app (:desktop + :shared)
The desktop app only implements the NIP-46/bunker path (paths 1–2 above are Android-only concepts — no intent scheme or ContentProvider on desktop) and is intentionally not a port of the full Android app: no Tor/proxy support yet. It supports multiple local accounts (switch/add/logout, see below) and has its own multi-screen UI (home, connect, connected apps + per-app permission editor, activity log, settings) mirroring Android's look and key bunker-relevant flows — see below.
shared/src/commonMain/.../BunkerSigningEngine.kt— decrypts an incoming kind-24133 event, resolves the requesting app's display name (from NIP-46connectclient metadata viaBunkerClientMetadata, or an optionalappNameLookupfallback for later requests), checks aBunkerPermissionStorefor an auto-accept/reject rule (falling back to aBunkerApprovalPortprompt), performs the sign/nip04/nip44 operation viaBunkerSigner(wraps Quartz'sNostrSignerInternal), and returns the signed response event. This is new code written for the desktop use case — it does not replace or get called by:app'sBunkerRequestUtils/EventNotificationConsumer, which keep using their existing Room/Context-coupled implementation directly (rewiring the shipping Android signing path onto shared code was judged higher regression risk than the desktop use case warranted).shared/.../SecureCryptoHelper.ktisexpect/actual: the desktopactual(desktopMain) stores an AES-256 master key in the OS keychain viajava-keyring(Windows Credential Manager / macOS Keychain / Linux Secret Service — requires a running Secret Service provider, e.g. gnome-keyring, on Linux) and AES-GCM-encrypts secrets at rest with it, mirroring the shape of the Androidactual(Keystore-backed, itself a from-scratch mirror of:app's ownSecureCryptoHelper.kt— not wired in, kept for parity/future adoption). One master key is reused to encrypt every stored account's key.desktop/src/main/kotlin/.../data/—AccountStoremanages multiple local accounts under~/.amber-bunker/accounts/<pubkeyHex>/{account.key,bunker.db}, each key persisted encrypted, plus a top-levelactive_accountpointer file (setActive/activeAccountuse an atomic temp-file-then-rename).migrateLegacyLayoutIfNeeded()is called once at startup to move a pre-multi-account flat~/.amber-bunker/{account.key,bunker.db}layout into the new per-account form — idempotent, and aborts without touching any file if the legacy key can't be decrypted.SqliteBunkerPermissionStore/SqliteBunkerHistoryLogger/RelayStore/SettingsStoreare plain JDBC againstorg.xerial:sqlite-jdbc(not Room), opened per-account viaBunkerDatabase.open(pubKeyHex), schema created on first run. Permissions and history are queryable per-app (permissionsFor/deletePermission,recentHistoryFor) for the app-detail screen, andSqliteBunkerHistoryLogger.removeAppdrops a connected app's record (its history is kept for the activity log's audit trail).desktop/src/main/kotlin/.../relay/BunkerRelayConnection.kt— subscribes to kind-24133 events addressed to the account pubkey using Quartz's ownNostrClient+BasicOkHttpWebSocket(both resolve from the multiplatformcom.vitorpamplona.quartz:quartzcoordinate'squartz-jvmvariant — no vendored crypto or websocket code was needed), publishes engine responses back viapublishAndConfirm, and exposesconnectedRelays: StateFlow<Set<NormalizedRelayUrl>>for the Home screen's connection status.desktop/src/main/kotlin/.../ui/BunkerApp.ktowns the account list/active pointer (running the legacy-layout migration once at startup) and passes the active account's pubkey intoAppShell, which loads that account's key and (re)builds its signing/relay/DB stack keyed on the pubkey — switching accounts tears down and rebuilds that whole stack.ui/AppShell.ktis the app's shell: aNavigationRail+ hand-rolled navigation (ui/nav/Screen.ktsealed class; no KMP navigation-compose library exists yet) acrossHomeScreen/ConnectScreen/ConnectedAppsScreen/AppDetailScreen/ActivityScreen/SettingsScreen, plus theApprovalDialogoverlay fed byDesktopApprovalPort's pending-request queue and anAccountSwitcherDialog(opened from the Home screen's avatar) for switching/adding/logging out of accounts, backed by a sharedui/components/ConfirmDialog.ktfor destructive confirmations (logout, remove app).ui/theme/DesktopTheme.ktports Amber's exact warm color scheme/shapes fromapp/.../ui/theme/Theme.kt(Compose Desktop has no reliableisSystemInDarkTheme(), so theme mode is a manual Light/Dark/System toggle persisted viaSettingsStore, System defaulting to Light).
Global state — Amber.kt
Amber is the Application class and acts as the DI container. Key singletons it owns:
applicationIOScope—CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler), used for all background workclient: NostrClient— the Quartz Nostr relay clientnotificationSubscription— keeps the NIP-46 filter alive in the backgroundprofileSubscription— per-account, throttled one-shot metadata (kind 0) fetch; started/stopped by the composables that display each account viaProfileSubscriptionEffect(not app-wide)isStartingAppState: MutableStateFlow<Boolean>— set totrueduringrunMigrations(); code that must wait for startup callsisStartingAppState.first { !it }settings.killSwitch— when true, all relays are disconnected; checked before every relay operation
Per-account isolation
Every npub gets its own:
SharedPreferencesfile (prefs_${npub})AppDatabase(amber_db_${npub}) — apps + permissionsLogDatabase— operation logsHistoryDatabase— request history
All databases are lazy-loaded and cached in ConcurrentHashMaps in Amber. Account data (including decrypted keys) is loaded via LocalPreferences.loadFromEncryptedStorage() and cached in LargeCache.
Permission system
Permissions are stored in ApplicationEntity + ApplicationPermissionsEntity (Room). Each permission entry has:
rememberType— auto-accept, auto-reject, or always-askacceptUntil/rejectUntil— time-bound grantskind— event-kind-specific rules
Before showing the approval UI, all three ingestion paths query the database; if a matching auto-accept rule exists, signing proceeds silently.
Biometric / PIN lock is UI-only
The biometric/PIN prompt (useAuth / usePin in AmberSettings, configured in SecurityScreen) is only an app-launch UI gate, not a signing gate. It is rendered by BiometricAuthScreen, which is invoked exclusively from the two UI entry points — MainActivity and SignerActivity — to unlock the app's screens before any approval bottom sheet is shown.
It does not protect the signing operations themselves:
- ContentProvider IPC (
SignerProvider) and NIP-46 relay events (EventNotificationConsumer→BunkerRequestUtils) never touchBiometricAuthScreen,Biometrics,useAuth, orusePin. They sign in the background based purely on the permission database. - When an auto-accept rule matches, all three paths sign silently without triggering the biometric/PIN prompt — including
nostrsigner://intents, which auto-finish before the UI is interacted with.
In other words, the lock controls who can open and navigate the app UI; it does not stand between a request and Account.sign(). Authorization for automatic signing is governed solely by the permission system (rememberType / acceptUntil / kind).
Key files
| File | Purpose |
|---|---|
Amber.kt |
Application singleton, Nostr client, relay connectivity |
LocalPreferences.kt |
Account/settings persistence, encrypted key storage |
IntentUtils.kt |
Parses nostrsigner:// URIs, creates IntentData objects |
SignerProvider.kt |
ContentProvider IPC signing |
BunkerRequestUtils.kt |
NIP-46 protocol handling, relay responses |
AccountStateViewModel.kt |
Auth state, account switching, toast notifications |
ConnectivityService.kt |
Foreground service, network monitoring, relay reconnection |
BiometricAuthScreen.kt |
UI-only app-launch lock (biometric/PIN); not a signing gate |
Biometrics.kt |
Wraps BiometricPrompt / keyguard credential prompt |
SecurityScreen.kt |
Toggles useAuth / usePin and the re-prompt interval |
shared/.../BunkerSigningEngine.kt |
Desktop's NIP-46 request handler (decrypt → permission check → sign/encrypt → respond) |
shared/.../SecureCryptoHelper.kt |
expect/actual at-rest encryption: Android Keystore vs. desktop OS keychain |
desktop/.../relay/BunkerRelayConnection.kt |
Desktop's kind-24133 relay subscription + response publishing |
desktop/.../data/AccountStore.kt |
Desktop's multi-account key storage, active-account pointer, legacy-layout migration |
desktop/.../ui/BunkerApp.kt |
Desktop's account-list/active-account owner; runs the startup migration |
desktop/.../ui/AppShell.kt |
Desktop's nav rail + screen dispatch + approval dialog overlay, keyed on the active account |
desktop/.../ui/theme/DesktopTheme.kt |
Desktop theme, ported from app/.../ui/theme/Theme.kt |