25 KiB
Repository instructions for coding agents
These instructions apply to every coding agent working in this repository (Codex, OpenCode, Claude Code, …). CLAUDE.md only points here.
Amber is a Nostr event signer (NIP-46 / NIP-55), package com.greenart7c3.nostrsigner. settings.gradle.kts includes three Gradle modules — :app (Android), :desktop and :common, the logic both share (see Modules).
Toolchain
- JDK 21 required (source/target compat 21). CI and
.codex/setup.shuse Temurin 21. compileSdk = 37,minSdk = 26, R8 full mode enabled, Gradle parallel + configuration-cache.- Room schemas are exported to
app/schemasvia KSP (room.schemaLocation). - Dependency verification is on:
gradle/verification-metadata.xmlpins the SHA-256 of every dependency and plugin artifact, and the build fails on any artifact that is missing or doesn't match. After adding or bumping a dependency or plugin, regenerate it with./gradlew --write-verification-metadata sha256 help <the tasks that use it>(e.g.assembleDebug test lintor:desktop:test :desktop:createDistributable) and review the diff. The per-OS Compose Desktop runtimes (desktop-jvm-*/skiko-awt-runtime-*for windows-x64, macos-arm64, macos-x64) andaapt2osx/windowsbuilds only resolve on their own OS, so bump their entries too, or desktop CI on Windows/macOS breaks.
Build and validation commands
./gradlew ktlintCheck— Kotlin style check (pre-commit hook runs this)../gradlew ktlintFormat— auto-fix formatting issues../gradlew detektAll— unused imports, parameters, variables and private declarations in the app (every flavor, unit and instrumented tests), desktop and common, with detekt's type resolution (pre-commit hook runs this). Only those rules are on (config/detekt/detekt.yml); suppress a deliberate one at its site with@Suppress("UnusedParameter")etc. It also runs./gradlew unusedCode(tools/unused-code), which detekt cannot do because it sees one file at a time: across every module's sources it fails on a non-private function, property, class or typealias whose name nothing references (Kotlin sources including tests, XML, ProGuard rules, Gradle scripts andtools/arti/srcfor JNI callbacks), and on a catch parameter the block never reads (name it_). Overrides, operators,main, enum entries, constructor properties,@…Previewand test functions, and Room/serializer-read properties are never reported; mark code that only something outside the sources uses (reflection, the NDK) with@Suppress("unused").- Kotlin compiler warnings are errors (
allWarningsAsErrorsin the rootbuild.gradle.kts): deprecated APIs, needless?./!!/?:, unused expressions, unchecked casts, missing opt-ins. Fix them; when one is deliberate (e.g. the OkHttpbodynull checks the offline flavor's OkHttp 4 needs), suppress it at the statement. ./gradlew lint— Android Lint on the default variant;warningsAsErrorsis on, so any new warning fails the build (pre-commit and pre-push hooks run this). Version-freshness checks (GradleDependencyetc.) are disabled inapp/build.gradle.kts; per-path exemptions live inapp/lint.xml../gradlew test --no-daemon— JVM unit tests for all variants (pre-push hook runs./gradlew test lint).- Run one test:
./gradlew :app:testFreeDebugUnitTest --tests "com.greenart7c3.nostrsigner.SomeTest"(or--tests "*SomeTest.method"). ./gradlew assembleDebug --no-daemon— debug APK for both product flavors../gradlew assembleRelease --no-daemon— release APK; signing only activates when envSIGN_RELEASEis set andkeystore.propertiesexists. CI otherwisetouches an empty one../build.sh <version> <appName>— builds free + offline release APKs/AABs into~/release/and callsgenerate_manifest.sh.- Desktop:
./gradlew :desktop:run(debug build),:desktop:test,:desktop:packageDistributionForCurrentOs.
Required order when pushing: ktlintCheck → detektAll → lint → test → build (mirrored by the hooks, but run them directly; do not rely on hooks).
Releases / version bumps
When asked to "bump the version" / cut a release, do all the following in a single change — never omit the verification block:
- Bump
amberVersionCode(+1) andamberVersionName(newX.Y.Z) ingradle/libs.versions.toml. These are the only places the version lives: the AndroiddefaultConfigand the desktop app (:desktop, installers and the runtime-Damber.version) both read them, anddesktop-release.ymlfails if the tag does not matchamberVersionName. Never hard-code a version inapp/build.gradle.ktsor give the desktop its own. - Prepend a new
## Amber X.Y.Zblock at the top ofCHANGELOG.md, summarizing the non-merge commits since the previousvX.Y.Ztag (git log vX.Y.Z..HEAD --no-merges) as user-facing bullets, and build the bullets exactly the way previous "Bump version to …" commits did — read one first (git log --oneline -- CHANGELOG.md, thengit show <commit> -- CHANGELOG.md):- One bullet per user-facing change, ordered features → behavior changes → fixes → security hardening → dependency updates. Never merge unrelated changes into one bullet, and never squeeze several changes into a single mega-bullet; only join closely related aspects of the same change with ", and".
- Start each bullet with a present-tense verb, name the concrete screen, setting or feature, and state the effect ("Show which account each batched request signs as", "Fix '+' in app names that clients form-encode in bunker URIs"). Prefix new features with
New:. - Leave out internal-only work (CI, refactors, tests, lint, build tooling) unless users can observe it; collapse dependency bumps into one closing line ("Update Quartz to 2.3.0, OkHttp and the rest of the dependency stack").
End the block with the standard "Download it with …" line (update the
releases/tag/vX.Y.ZURL) and the "If you like my work …" donation line.CHANGELOG.mdmust contain only the latest release: delete the previous release's block from it (the notes already live indocs/changelogs/) and keep the## Older versionsfooter at the bottom. Also add the full notes asdocs/changelogs/<X.Y.Z>.md— same bullets and download/donation/verifying text, but under an# Amber X.Y.ZH1 and without the footer — and insert a row| X.Y.Z | [<X.Y.Z>.md](<X.Y.Z>.md) |at the top of the table indocs/changelogs/README.md(most recent first, padded to the existing columns; pre-releases get an entry too).
- Always include the
## Verifying the releaseblock immediately after the donation line (before the next## Amberheading). Copy it verbatim from the previous release entry and only change themanifest-vX.Y.Z.txt/manifest-vX.Y.Z.txt.sigfilenames to the new version. Do not change thegpg --recv-keyskey id, thegpg: Signature made Fri 13 Sep 2024 …block, or the surrounding prose — those are key-specific, not release-specific. This block must be part of the version-bump commit, not a follow-up. - Do not commit unless explicitly asked (per the global rule). Do not edit
build.sh— it takes the version as an argument.
Product flavors (dimension version)
| Flavor | Notes |
|---|---|
free (default) |
Online: OkHttp, Coil, built-in Tor (Arti, src/tor), relay connectivity. Owns INTERNET/network permissions. |
offline |
No network stack. app/src/offline/AndroidManifest.xml removes INTERNET/CHANGE_NETWORK_STATE/ACCESS_NETWORK_STATE with tools:node="remove". |
benchmark |
Mirrors free network deps; applicationIdSuffix=.benchmark, versionNameSuffix=-BENCHMARK; CI builds a signed release per push for side-by-side install. |
- Guard any network-only code with
BuildFlavorChecker.isOfflineFlavor(). There is alsoBuildConfig.IS_FDROID_BUILD(false by default; the F-Droid release workflow flips it true and stripsREQUEST_INSTALL_PACKAGESviased) — read it throughBuildFlavorChecker.isFdroidBuild()to disable self-update / Zapstore, never as the constant, which the IDE would simplify away. - Offline permissions gate:
check-offline-permissions.ymlruns./gradlew processOfflineDebugManifestand greps the merged manifest for the three network permissions. Any new dependency that leaksINTERNETetc. will fail this check — verify the offline merged manifest when adding network-capable deps.
Modules
:app— the Android app (everything in Architecture refers to it).:common— plain Kotlin/JVM library both apps depend on (packagecom.greenart7c3.nostrsigner.common) for logic that must behave the same on Android and desktop:SignerType/EncryptionType, the request policy (connectsThroughOtherKey,hasAmbiguousRelayTags,sanitizeAppName), the remembered-permission decision (activeDecision,rememberedDecision), per-connection keys, crash-report redaction (CrashReports),Nip44v3,PsbtDecoder/PsbtSighashPolicy,RelayHealthTrackerandTorSocksAuthenticator. Put shared logic here instead of copying it into both apps; each app keeps only its storage, UI and platform glue around it. It has no Android or desktop APIs and compiles against Quartz withcompileOnly(quartz-jvm): each app ships its own Quartz build (quartz-android/quartz-jvm), so use only Quartz API both builds have. Its tests run with./gradlew :common:test(also part of./gradlew test).:desktop— Compose for Desktop (JVM) NIP-46 signer for Windows/macOS/Linux; standalone port that mirrors the Android permission model againstquartz-jvm(no NIP-55)../gradlew :desktop:runlaunches a debug build (core/BuildVariant.kt: separate data dir, keyring entry, socket and "Amber Debug" name, so it never touches a release install).- Bunker engine: its core mirrors
NotificationSubscription/EventNotificationConsumer/BunkerRequestUtilsindesktop/.../core/BunkerEngine.kt— behavior changes to the Android bunker flow should be ported there too. - Keys: AES-encrypted via a PKCS12 Java KeyStore (
DesktopKeyStore) whose password lives in the OS credential store (Keychain / Credential Manager / Secret Service viajava-keyring, file fallback — seeKeystorePassword.resolve). That keystore only exists before first run: the passphrase is mandatory (PassphraseLock, setup screen before anything loads), and setting it wraps the master key with Argon2id — combined via HKDF with a random secret in the OS credential store when one works (osSecretStore, a second factor) — and deletes the keystore. It adds a startup/auto-lock gate that evicts key material and gates the bunker engine, and encrypts the per-account database (apps/permissions/history/logs) at rest viawriteSecure/readSecure. Every master-key ciphertext is context-bound (GCM AAD,DesktopKeyStore.encryptString(…, context)), and while a passphrase is set unbound or plaintext data is rejected.applications.jsonis also rollback-protected: its header carries a generation (AMBERENC3:<n>:, bound into the AAD) whose latest value is kept in the OS credential store (StoreGenerations), and an older copy is refused. The desktop app has no released predecessor, so there are no migrations: only the current formats are read, and anything else is treated as tampered. Write files withAppDirs.writeAtomically; unreadable files are quarantined, never overwritten. Lock wipes key material (WipeableAesKey,DesktopAccount.wipe, Quartz NIP-04/44 caches): keep secrets inByteArrays (decryptBytes), not Strings, and don't hand out an account outsideAmberDesktop.account(only cached accounts get wiped). State is JSON files per account (no Room). - Local socket signer: an always-on NIP-5F local socket signer (
core/LocalSigner.kt+LocalSignerProtocol.kt,~/.local/share/nostr/signer.sock) feeds CLI tools through the sameBunkerEnginepermission/approval pipeline as NIP-46: socket apps areAppRecords withtransport = "socket"and no relays, and their pending requests carry aresponderthat answers over the socket instead of a kind-24133 event. Protocol and internals:desktop/LOCAL_SIGNER.md. - Notifications: they go through the OS-native channel (
core/Notifier.kt: freedesktopnotify-send/gdbuson Linux incl. Wayland/Hyprland; AWT tray notification on Windows and macOS — neverosascripton macOS, it posts as Script Editor, which is never authorized, and is silently dropped), decoupled from the tray. Request notifications carry Approve / Reject (Reject only for connect): on Linux daemons that draw action buttons they are posted over the session D-Bus with dbus-java (core/DbusNotifications.kt— the daemon sendsActionInvokedonly to the posting connection, and notify-send 0.8.8 drops the buttons on GNOME 50); on Windows, macOS and button-less Linux daemons (quickshell, mako, dunst) Amber shows its own native-looking popup (ui/RequestNotificationPopup.kt). Request notifications are skipped while the main window is focused (the card is already in view), and focusing it closes a showing popup. On Linux the popup is a POPUPJWindow(override-redirect on X11), not a ComposeWindow/JFrame; AWT's peer still types it NORMAL on XWayland, which Hyprland focuses on map, somarkAsX11Popupsets the peer'swindowTypeby reflection (needs the--add-opens=java.desktop/sun.awt.X11JVM arg) — never let the popup take keyboard focus. The packaged runtime needs thejdk.security.authmodule for dbus-java's bus login, which also enables the Secret Service keyring path, andjdk.netfor the local socket signer's peer check. jlink only bundles the modules listed innativeDistributions { modules(...) }plus what the plugin detects, and:desktop:runuses the full JDK, so a missing module only fails in the packaged app: check new JDK-module imports withjdepsonlib/app/*.jarofcreateDistributable. Focusing the main window closes Amber's own popup, except while the pointer is on it: on macOS clicking the popup activates the app and focuses the main window before the click lands, which used to swallow Approve. - Crash reports:
core/CrashReporter.ktmirrors the AndroidUnexpectedCrashSaver/ReportAssembler/ReportSender(redactedcrash.trace, NIP-17 DM toDEVELOPER_HEX_KEYon user consent,ui/CrashReportDialog.ktafter unlock); the version comes from the launcher's-Damber.version(BuildVariant.version), i.e.amberVersionName. - Tray: the dorkbox SystemTray library on Linux (
NativeTray.kt) publishes a StatusNotifierItem/AppIndicator that shows on Wayland compositors; Windows/macOS use the AWT/ComposeTray.AMBER_DISABLE_TRAY=1skips the native tray. - See
desktop/README.md.
- Bunker engine: its core mirrors
Architecture (Android app)
Request ingestion — three paths
External apps and relays reach the signer through three distinct paths:
nostrsigner:///nostrconnect://Intent →SignerActivity→ parsed byIntentUtils→ shown as an approval 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.
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 fetch; first fetches the user's NIP-65 relay list (kind 10002) and saves it locally, then fetches the metadata (kind 0) from the default profile relays plus the saved user relays; 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
NIP-44 v3 grants are keyed by the context kind only (a kind-K rule, or a kind-less "all kinds" rule); the context scope string (params[2]) is shown in the approval prompt but is not stored, so a remembered kind-K grant covers every scope. Desktop (BunkerEngine, LocalSigner) follows the same model; keep them in step if that ever changes.
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). Do not wire signing through the biometric prompt.
The requireUnlockedDevice opt-in is a signing gate
Separate from the UI-only lock above, the user can enable requireUnlockedDevice (SecurityScreen). When enabled, every Account private-key operation (sign, signSync, nip04*/nip44*, decrypt, signPsbt, getNsec, seedWords, nip49Encrypt, and the backup derived signer) calls DeviceUnlockPolicy.requireAccess first and throws SecurityException while the device is locked. The flag is read synchronously from SharedPreferences (LocalPreferences.getRequireUnlockedDevice), so the gate is correct even before the async settings load and in the offline flavor.
Consequences to keep in mind when touching these paths:
- Background consumers of gated operations must handle the refusal explicitly: the relay AUTH coordinator isolates per-account failures,
BackupApplicationsWorkerreturnsResult.retry()on the lock refusal, and lock/settings refusals are rethrown (not swallowed) byvalidateNip44v3RequestandApplicationBackup.decryptContentso clients and callers see the real condition instead of a decrypt/corruption error. - NIP-46 requests are checked before they are opened: while locked,
EventNotificationConsumeranswers clients that have their own connection key with a "locked" error (using only that key), and leaves requests from legacy clients, which are encrypted to the account key, unopened and unrecorded so a re-delivery after unlock is processed. - This gate is independent of the biometric/PIN UI lock: with
requireUnlockedDevice=false(the default), background signing behaves exactly as described above.
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 |
Conventions
- ktlint
android_studiocode style (.editorconfig); star imports effectively disabled; trailing commas allowed;@Composablefunctions exempt from the function-naming rule. RunktlintFormatrather than hand-formatting. - Translations live in
app/src/main/res/values-<locale>/strings.xml;MissingTranslationlint is intentionally disabled and the shipped locales are pinned byandroidResources.localeFiltersinapp/build.gradle.kts. - Strings must be translated in the same change. Whenever you add or change a string in
app/src/main/res/values/strings.xml— includingevent_kind_*labels for new Nostr kinds — add the real translation to everyapp/src/main/res/values-*/strings.xmllocale file (ls app/src/main/res | grep '^values-', ignoringvalues-night) before reporting the work as done. Never stop at English and never copy English into a locale file — write the actual translation, matching the existing tone/register of that file. BecauseMissingTranslationlint is disabled, a passing build does not mean the translations are present. Escape apostrophes as\'in languages that use them (French, Italian, Turkish, …), exactly like neighboring entries. - Git hooks (
git-hooks/pre-commit,pre-push) are auto-installed by the rootbuild.gradle.ktsinstallGitHooktask wired into:apppreBuild— no manual setup needed, but do not rely on them as a substitute for running checks directly.
Codex Web / cloud setup
Use the committed scripts for cloud environments:
- Setup:
bash .codex/setup.sh— installs/verifies Java 21, bootstraps Android cmdline SDK, installs API 36 / build-tools 36.0.0, accepts licenses, and prewarms Gradle forfreedebug unit-test sources. - Maintenance:
bash .codex/maintenance.sh— refreshes Gradle metadata in cached containers.
Reproducibility
Dockerfile + apkdiff.py verify reproducible builds: docker build -t amber-repro --build-arg VERSION=vX.Y.Z --build-arg APK_TYPE=free-arm64-v8a . then docker run --rm amber-repro (expect APKs match!).
Desktop: every download is reproducible by content, checked with desktopdiff.py (files plus installer scripts/tables; timestamps and build dates ignored; --jdk prints the bundled JDK). Dockerfile.desktop rebuilds the Linux .tar.xz, AppImage, .deb and .rpm of a tag and compares them with the release; Windows (.msi/.exe) and macOS (.dmg) are rebuilt on that OS (README has the steps). What keeps it that way: desktop.yml makes setup-java download the official Temurin archive (an empty RUNNER_TOOL_CACHE), since the app bundles that JDK's runtime and the runner's preinstalled copy differs; SkikoNatives in desktop/build.gradle.kts ships skiko's native library as plain files instead of the skiko-awt-runtime jar, which Compose 1.12 rewrites with the build time (Compose 1.13 fixes that), and ZipTimestamps fixes the entry times of the jars the plugin re-zips on macOS; .gitattributes turns off line-ending conversion, which made Windows checkouts bundle CRLF resources. Run python -m unittest discover -s tests after changing desktopdiff.py.
Collaboration
For repository collaboration, use ngit and follow .agents/skills/ngit/SKILL.md (mirrored at .claude/skills/ngit/SKILL.md).