Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
27 KiB
Amethyst
Project Overview
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
over to a Kotlin Multiplatform project. The main modules are: quartz, commons, commonsUI, amethyst,
desktopApp, cli, plus the audio-rooms transport stack quic + nestsClient. Quartz should
contain implementations of Nostr specifications and utilities to help implement them — NIPs under
nipXX packages, and whole non-NIP protocol families beside them: marmot/ (MLS over Nostr,
mipXX), cordn/ (MLS over an MCP coordinator, specXX), contextvm/ (MCP over Nostr, cepXX),
concord/ (cordXX), buzz/, plus the binding-agnostic RFC 9420 engine in mls/. A new protocol
over Nostr belongs here as a package, not as a Gradle module; quic/nestsClient/marmotQuic are
modules because they are transports with no Nostr in them. Commons stores
shared code between Amethyst Android (amethyst) and Amethyst Desktop (desktopApp). Android now
also ships on laptops, so the direction is one UI: every screen and the navigation shell at every
window size move to commonsUI, amethyst shrinks to an Android shim, and a new desktopApp
becomes a JVM shim that renders the same UI (see commons/plans/2026-09-27-one-ui-android-desktop.md).
Until it lands, today's desktopApp still has its own mouse-first screens and navigation. cli ships amy,
a non-interactive JVM command-line client that drives the same quartz + commons code — used by
humans, agents, and interop tests. quic is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
exists. geode is a standalone JVM Nostr relay (Ktor) built on quartz's
relay-server code; smaller modules are benchmark (Android macrobenchmarks),
relayBench (head-to-head relay benchmark — boots geode, strfry and other
relay binaries, replays a shared deterministic corpus, measures ingest/query/
NIP-77 sync; ./relayBench/run.sh, see relayBench/README.md) and
quic-interop (QUIC interop runner, lives at quic/interop). marmotQuic is the Marmot raw-QUIC transport
binding for agent text stream previews (transports/quic.md) on top of
:quic — its own ALPNs and framing, not WebTransport. nestsClient runs
the audio-room protocol on top of :quic for the NIP-53 audio-rooms feature. It implements both IETF draft-ietf-moq-transport-17 (under
moq/) and moq-lite Lite-03 (kixelated's variant, under moq/lite/); the
production listener AND speaker paths both run on moq-lite to interop with the
nostrnests reference relay. The IETF code is kept as a reference + unit-test
implementation for any future IETF target; see
nestsClient/plans/2026-04-26-moq-lite-gap.md.
Canonical NIP specs live at https://github.com/nostr-protocol/nips — use
/nip <number> to pull a specific one (it fetches the spec file directly).
Verify, Don't Guess
Don't assert a diagnosis you haven't reproduced. This repo gives you cheap
verification tools: ./gradlew test, per-module test suites, and the amy
CLI (built partly to drive quartz/commons for interop checks). If a
claim is checkable in under a minute, check it before stating it — write
the failing case first, watch it fail, then explain.
Architecture
amethyst/
├── quartz/ # Nostr KMP library (protocol only, no UI)
│ └── src/
│ ├── commonMain/ # Shared Nostr protocol, data models
│ ├── androidMain/ # Android-specific (crypto, storage)
│ ├── jvmMain/ # Desktop JVM-specific
│ └── iosMain/ # iOS-specific
├── commons/ # Shared HEADLESS layer (models, state, ViewModels, relay client) — CLI-safe
│ └── src/
│ ├── commonMain/ # Domain models, state holders, ViewModels, services
│ ├── jvmAndroid/ # JVM-bound services shared by Android + Desktop
│ ├── androidMain/ # Android-specific actuals (Keystore, DataStore)
│ └── jvmMain/ # Desktop-specific actuals (keyring, upload pipeline)
├── commonsUI/ # Shared Compose UI on top of commons (composables, icons, theme, Coil, resources)
│ └── src/
│ ├── commonMain/ # Shared composables, icons, theme, composeResources (strings/fonts)
│ ├── jvmAndroid/ # Markdown renderer, Coil OkHttp fetchers
│ ├── androidMain/ # Android Coil bridge
│ └── jvmMain/ # Desktop Coil bridge (+ skikoMain shared with iOS)
├── quic/ # Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport (audio-rooms transport)
│ └── src/
│ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine
│ ├── jvmAndroid/ # JCA-backed AEAD + UDP socket actuals
│ └── commonTest/ # RFC vector + adversarial tests
├── nestsClient/ # Audio-room client (production runs on moq-lite; IETF MoQ kept as reference)
│ └── src/
│ ├── commonMain/ # MoQ session, NestsListener, audio glue
│ └── jvmAndroid/ # Opus encode/decode, AudioRecord/AudioTrack
├── geode/ # Standalone JVM Nostr relay (Ktor) on quartz's relay-server code
├── desktopApp/ # Desktop JVM application (layouts, navigation)
├── amethyst/ # Android app (layouts, navigation)
└── cli/ # Amy — non-interactive CLI (JVM only, no Compose)
Sharing Philosophy:
quartz/= Nostr business logic, protocol, data (no UI)commons/= Shared headless code for every front end (Android, Desktop, iOS, and the headlesscli): domain models, state holders, ViewModels, the relay client, shared services. It may use the Compose runtime (@Stable/@Immutable, snapshot state) but never Compose UI, Coil or Compose resources — the build enforces this:commonshas no such deps.commonsUI/= Shared Compose UI that ≥1 GUI front end renders (composables,ui/theme, icons, robohash, Coil fetchers, markdown, thecomposeResourcesstrings/fonts and the generatedResclass). Depends oncommons(asapi);clinever depends on it. Files keep theircom.vitorpamplona.amethyst.commons.*packages — the split is a module boundary, not a package rename. The package taxonomy, the CLI-safe / UI boundary, and a "where does my code go?" guide are documented incommons/ARCHITECTURE.md(+commonsUI/ARCHITECTURE.md) — read them before adding a new package or dropping code into either module.quic/= Transport library (QUIC + HTTP/3 + WebTransport); reusable for any KMP project that needs MoQ. Has no Android-framework dependencies.nestsClient/= MoQ + audio-rooms client; takes:quicas transport, Quartz for crypto,MediaCodec/AudioRecord/AudioTrackfor audio.marmotQuic/= Marmot's raw-QUIC binding for agent text stream previews. Takes:quicfor the connection and:quartzfor the record/envelope codecs. Not WebTransport — the binding has its own ALPNs and writes frames straight onto QUIC streams, so it deliberately does not reusenestsClient'sWebTransportSession.amethyst/&desktopApp/= Platform shims: process/window entry points, services, system integrations and the actuals of shared ports. Screens and navigation are shared UI and are moving tocommonsUI(today'sdesktopAppscreens are legacy, to be replaced).cli/= Thin assembly layer overquartz/+commons/(no new logic allowed). May also depend on:geode(foramy serve, which embeds the standalone relay); never on:commonsUI,:amethystor:desktopApp.
Plans per module: design docs for new subsystems live in the owning
module's plans/YYYY-MM-DD-<slug>.md (e.g. cli/plans/, commons/plans/).
The global docs/plans/ folder is frozen — don't add new plans there.
Android Runtime Processes (IMPORTANT — the app runs in TWO processes)
The Android app is not single-process. Android instantiates the one Amethyst
Application class (there is no per-process Application in the manifest) in both:
- main — the normal app: UI, account, signer,
LocalCache, relay client.Amethyst.instance(AppModules) is built here. :napplet— the sandboxed WebView host for NIP-5D napplets / NIP-5A nSites (NappletHostActivity, declaredandroid:process=":napplet"). It holds no account or keys;Amethyst.onCreate()early-returns here soAmethyst.instanceis left unset (touching it throwsUninitializedPropertyAccessException). The sandbox runtime lives in its own module:nappletHost(depends only on:commons+:quartz, never:amethyst) so it cannot importAmethyst/LocalCache/Account— the broker-side (signer, gateways, registry) stays in:amethystand the two halves talk over Messenger IPC.
Consequences — don't get caught assuming one process:
- Processes don't share memory. Every
object/companion/staticis a separate copy per process:LocalCache(anobject),NappletLaunchRegistry, etc. The populatedLocalCachelives only in main; the sandbox neither builds nor should reference it (a stray reference would lazily create a second, empty cache there). - Don't assume
Amethyst.instanceexists. Any code reachable from:napplet(the host activity, content server, or an Application lifecycle callback likeonTrimMemory) must guard on the process and never reach forinstance. - Cross-process state goes over Messenger IPC, never a shared singleton — this
is why the broker (main) owns
NappletLaunchRegistryand the sandbox only relays an opaque token. Seeamethyst/plans/2026-06-22-napplet-nsite-security.md.
Tech Stack
Exact versions live in gradle/libs.versions.toml (the source of truth — check
there rather than trusting a number copied here).
| Layer | Technology |
|---|---|
| Core | Quartz (Nostr KMP) |
| UI | Compose Multiplatform |
| Async | kotlinx.coroutines + Flow |
| Network | OkHttp (JVM) |
| Serialization | Jackson |
| DI | Manual / Koin |
| Build | Gradle + Kotlin Multiplatform |
Skills
The full list of available skills (with descriptions and triggers) is injected into every session, so it isn't duplicated here. Two kinds exist and are meant to be used together:
- Codebase-oriented skills (
nostr-expert,compose-expert,feed-patterns,account-state,amy-expert, …) answer "where is X in Amethyst, what pattern do we use here." - Technique-oriented skills (vendored from
chrisbanes/skills, e.g.compose-slot-api-pattern,kotlin-flow-state-event-modeling) answer "what is the correct Compose/Kotlin design." They complement, not replace, the codebase skills:compose-experttells you where shared composables live;compose-slot-api-patterntells you how to shape their public API.
Feature Workflow
CRITICAL: Check existing implementations first — most logic already exists.
Before writing code, survey all modules (use Grep/Explore) for managers, caches,
state systems, filters, ViewModels, and composables that already do the job. Your
job is usually to reuse (quartz protocol/business logic), extract
(Android UI/ViewModels → commons), and add platform-specific layouts/nav —
not to duplicate existing managers, caches, or state.
Summarize the survey in your plan: for each component, note whether it's
reused as-is, extracted from amethyst/ to commons/, genuinely new
(platform-specific only), or a duplicate of an existing pattern to avoid.
Relay client ops already exist — don't hand-roll subscribe/REQ/publish loops.
One-shot and high-level relay operations (fetch a set, fetch one, page past the
relay cap, publish-and-confirm, NIP-45 count, NIP-77 sync/reconcile) are
INostrClient extension functions in
quartz/…/nip01Core/relay/client/accessories/ (+ …/reqs/ for the flow/subscribe
helpers). Because they're extensions, they don't surface under "usages of
NostrClient" or in completion — grep that package (or read its README.md, which
catalogs them) before writing a new subscription/collect loop. Reuse fetchAll,
fetchFirst, fetchAllPages, publishAndConfirm, count, negentropyReconcile,
etc. instead of re-implementing them.
Share vs keep platform-native:
- Share →
quartz/commonMain/(business logic, data models, protocol),commons/commonMain/(ViewModels underviewmodels/, state holders, relay client, services — headless) andcommonsUI/commonMain/(major UI components, icons, theme). ViewModels are platform-agnostic state + logic (StateFlow/SharedFlow), so they belong incommons; anything that importsandroidx.compose.ui/foundation/material3, Coil, orResbelongs incommonsUI. Screens and navigation are shared too: screen composables, the nav host, and the navigation chrome for every window size (bottom bar, rail, permanent drawer) belong incommonsUI, because Android runs on laptops and the new Desktop app renders the same UI. Adapt to the window withScreenLayoutSpec/LocalScreenLayout, not with a per-platform screen. - Keep native → only what is the platform itself: the process/window entry
point (Android
Activity/Service, DesktopWindow/tray/menu bar), system integrations (notifications, file pickers, share sheets, camera, media3, WebView, keyring/Keystore), and the platformactuals or port implementations the shared UI calls. A new screen goes incommonsUIwhen its dependencies allow —AccountViewModelitself is incommonsUI(commons.viewmodels), reaching Android throughAccountViewModelHost. A screen that still needs an app-only helper may live inamethyst/, but keep Android APIs out of it (behind a port or slot) so it can move later. Don't add screens to today'sdesktopAppthat the shared UI will have to re-create.
When extracting a composable: move it to commonsUI/commonMain/ (see
/compose-expert), add expect/actual for any platform behavior (see
/kotlin-multiplatform), then point both Android and Desktop at the shared
version. quartz/ is protocol-only — no composables.
Build Commands
# Run desktop app
./gradlew :desktopApp:run
# Run Android app
./gradlew :amethyst:installDebug
# Build Quartz for all targets
./gradlew :quartz:build
# Run the JVM tests of every module, KMP ones included. KMP modules register no
# `test` task of their own; the root build aliases it onto their jvmTest.
./gradlew test
# A single module. For a KMP module name jvmTest directly:
./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip52Calendar.*"
# NOT covered by `test`: each KMP module's OTHER targets, notably
# :quartz:testAndroidHostTest (~3.9k tests, its own androidHostTest source set).
# Nothing runs it today - not `test`, not pre-push, not CI - and it has 4 known
# failures on main (NostrServerTest x3, LiveNegentropyIndexStoreTest x1), which
# is why the alias maps to jvmTest rather than allTests. Run it explicitly when
# touching relay-server or store code:
./gradlew :quartz:testAndroidHostTest
# Format code
./gradlew spotlessApply
Dependency Licensing
MANDATORY whenever you introduce a new third-party dependency — in any
module (quartz, commons, commonsUI, amethyst, desktopApp, cli, quic,
nestsClient, …), whether you add it to gradle/libs.versions.toml or to a
module's build.gradle.kts: determine its license before wiring it in.
Amethyst ships under the MIT license, so a copyleft dependency linked into a
distributed artifact (APK, desktop binary) can force that artifact's
combined-work terms onto the whole project.
Verify against the dependency's actual LICENSE/COPYING file or its published
POM — not from memory. Then classify and act:
- Permissive (MIT, Apache-2.0, BSD, ISC, MPL-2.0, zlib, …) → OK, proceed.
- LGPL, or GPL/EPL with a linking / Classpath exception → WARN. Acceptable to link (the exception keeps our own code MIT), but call it out in your summary so the human knows. Confirm the exception actually exists in the LICENSE text — don't assume it does.
- Stricter than LGPL — GPL/AGPL without a linking exception, SSPL, proprietary/commercial-only, or anything where the linking-exception check is "no" → STRONGLY WARN and STOP. Do not add it silently. Surface it prominently and require an explicit call-out in the PR description so a maintainer makes the decision. Prefer a permissive alternative, a clean-room implementation, or dropping the feature.
For any GPL-family hit the decisive question is always "is there a linking
(LGPL/Classpath) exception?" — that is what separates a WARN from a STOP.
(Example: TarsosDSP, GPLv3 with no exception, was removed from amethyst and
replaced with an in-house pitch shifter for exactly this reason.)
Quartz KMP Structure
Quartz uses expect/actual for platform-specific implementations (e.g. crypto
backed by secp256k1-kmp-jni-android on Android and secp256k1-kmp-jni-jvm on
JVM). See /kotlin-multiplatform for the expect/actual and source-set patterns.
Strings
MANDATORY: every new user-visible string goes in commonsUI Compose resources,
commonsUI/src/commonMain/composeResources/values/strings.xml — never in
amethyst/src/main/res/values/strings.xml, even for an Android-only screen. A
screen cannot move to commonsUI (or be rendered by Desktop) while its labels
live in Android res/, and every key added there has to be migrated back out
later. (The 2026-09-22 move emptied Android res/ down to ~190 keys; the next two
features put 447 back.)
- Reference it as
Res.string.my_key/Res.plurals.my_key, importingcom.vitorpamplona.amethyst.commons.resources.Resand the per-key accessorcom.vitorpamplona.amethyst.commons.resources.my_key(Compose resources generates each key as an extension property). - Read it with
stringRes(Res.string.x)/pluralStringRes(Res.plurals.x, n, …)in composition — the app-sideui.stringResoverloads delegate to the commonsUI bridge (commons/ui/StringRes.kt), so one import serves both kinds. - Outside composition there is no blocking accessor: use
loadStringRes(…)from a coroutine (or make the functionsuspend), or resolve the label in composition and pass it down as aString. ArunBlockingbridge is ruled out. - Hold a string reference as
StringResource/PluralStringResource, not a@StringRes Int. - Format arguments must be positional (
%1$s,%2$d) — compose-resources does not format bare%s/%d. - Escaping is not Android's: write
'and"bare (no\'), and never wrap a value in quotes to keep its whitespace..claude/hooks/compose_escaping_check.pychecks this before a push.
The only strings that stay in Android res/ are the ones a platform API
reads synchronously under a deadline, where a suspend read cannot run: a
foreground service's startForeground notification, a notification channel
created during service startup, PiP RemoteActions, napplet capability labels
read in the sandbox process, and AndroidManifest.xml / res/xml references.
Most of those are copies of a key that also lives in commonsUI. See
commons/plans/2026-09-22-strings-to-compose-resources.md ("the
synchronous-platform tier"). If you think a new string belongs in that tier,
say why in the PR.
To move keys that already landed in Android res/, use
tools/strings-migrate/migrate.py <keys…>. It moves every locale byte-for-byte,
so Crowdin sees a pure move. Then repoint R.string.x → Res.string.x.
Icons
The Material Symbols font bundled at
commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf
is a subset that only contains the glyphs referenced from
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt.
MANDATORY: Whenever you add a new icon — i.e. introduce a
MaterialSymbol("\uXXXX") codepoint that wasn't already referenced anywhere in
MaterialSymbols.kt — you MUST regenerate the subset font by running:
./tools/material-symbols-subset/subset.sh
Commit the regenerated material_symbols_outlined.ttf alongside your
MaterialSymbols.kt change. Without this step the new icon renders as tofu (□)
at runtime because the glyph is not in the bundled font.
Reusing a codepoint already present in MaterialSymbols.kt does NOT require
regenerating.
Amethyst's own icons are also a font
The icons in commonsUI/.../commons/icons/*.kt (Like, Reply, Reposted, Zap, …) are
also compiled into a font, composeResources/font/amethyst_icons.ttf, and drawn
as glyphs via AmethystIconGlyph. Drawing an ImageVector rasterises its paths into
a per-instance cached layer, so a feed re-rasterised the same glyph once per card;
a glyph is a blit from the shared text atlas. Measured: frame P90 -10.7%,
overrun P90 -17.4% on the feed scroll benchmark.
MANDATORY: whenever you add or change an icon under commonsUI/.../commons/icons/,
regenerate the font and its codepoint table together:
python3 tools/icon-font/build_icon_font.py \
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf \
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
Both outputs must be committed together: codepoints are assigned in filename order,
so adding an icon renumbers the ones after it, and a stale AmethystIcons.kt then
points at the wrong glyph. Needs fonttools (pip install fonttools). The script
prints any icon it could not convert — an icon that is skipped must keep using its
ImageVector.
See tools/material-symbols-subset/README.md for details and
prerequisites (pip install fonttools brotli).
Code Formatting
After completing any task that modifies Kotlin files, always run:
./gradlew spotlessApply
Do this before considering the task complete.
Kotlin Style
- Never write fully-qualified class names inline in function bodies. Add an
importfor the class and reference it by its simple name. WriteEvent(withimport com.vitorpamplona.quartz...Event), notcom.vitorpamplona.quartz...Eventin the middle of code. - The only acceptable inline fully-qualified names are: a genuine name
collision (prefer
import ... as Aliasinstead), or where the language requires it. Comments, KDoc, and string literals are exempt. - Prefer the
androidx.coreKTX extension over the raw platform Java call when one exists — this is what Android Lint'sUseKtxflags. Common swaps:Bitmap.createBitmap(w, h, cfg)→createBitmap(w, h),Bitmap.createScaledBitmap(src, w, h, f)→src.scale(w, h, f),Uri.parse(s)→s.toUri(), andprefs.edit()…apply()→prefs.edit { }. Only adopt the KTX form when it's behaviour-preserving: keep any explicit argument that differs from the extension's default (a non-ARGB_8888Bitmap.Config,scale(filter = false)), and leave calls the KTX has no equivalent for (e.g. thecreateBitmappixels/matrix overloads, or a conditional-apply()editor loop) untouched. - This "prefer the KTX sugar" rule does NOT extend to collection operators.
The KTX preference is about platform wrappers (
Bitmap/Uri/SharedPreferences), which compile to the identical call. Collections are the opposite: in hot event/parse paths Quartz deliberately uses raw JVM arrays (TagArray = Array<Array<String>>) and the inlinefast*operators (fastForEach,fastAny,fastFirstOrNull,fastFirstNotNullOfOrNull, … innip01Core/core/TagArray.kt) instead of KotlinList+ stdlibforEach/map/filter/any— thefast*variants allocate no iterator, no intermediate list, and no lambda object. Don't "modernize" those into stdlib collection calls; match the surrounding hot-path style. - Never put raw invisible/bidirectional Unicode characters in source files
— write them as
\uXXXXescapes instead ('\u202E',Regex("[\u200B-\u200D\uFEFF]")). This covers the bidi family Sonar's Trojan-Source rule (CVE-2021-42574) flags — U+202A–U+202E, the isolates U+2066–U+2069, U+200E/U+200F, U+061C — plus zero-width characters (U+200B–U+200D, U+FEFF, U+2060). The escape compiles to the identical codepoint, so behaviour is unchanged; the point is that the file on disk stays visually unambiguous. Applies even when the character is intentional (sanitizer strip-lists, adversarial test payloads) — that's data, and escapes express it just as well. Exceptions: U+200D as part of a real emoji ZWJ sequence in test data (👩👧 — functional, not a bidi control), and LRM/RLM inside Crowdin-managedstrings.xmltranslations (legitimate RTL typography; don't touch those files by hand anyway). Note the tooling trap: the Edit tool may normalise a typed\uXXXXback into the raw character — if that happens, do the replacement at byte level (perl -CSD -pe 's/\x{202E}/\\u202E/g').
Navigation Shell
One shell, picked by window size rather than by platform: ScreenLayoutSpec
chooses the bottom bar (compact), the rail, or the permanent drawer (wide,
landscape, tall enough), and docks the notification panel on very wide windows.
It is moving to commonsUI with AppNavigation. Today's desktopApp still has
its own sidebar shell, which will be replaced.
Git Workflow
- Commits: Conventional commits (
feat:,fix:, etc.) - Never use
--no-verify
Remotes & pull requests
A PR can be published two ways, via two kinds of remote — identify them by URL (git remote -v), because the names vary per clone and a collaborator may have only one:
- a GitHub remote (
github.com/vitorpamplona/amethyst) — the canonicalmain; moves constantly. StandardghPR flow. - a git-over-nostr remote (
nostr://…/relay.ngit.dev/amethyst, viangit) — pushing fans out to GitHub and the GRASP git servers; PRs here are nostr proposals (thepr/feat/*branches), reviewed on gitworkshop.dev — not GitHub PRs. (In the maintainer's checkout these happen to be namedupstreamandoriginrespectively, but don't rely on that.)
Before opening, revising, or merging a PR by either path, use the ngit-pr skill. It covers when to use which, identifying your remotes by URL, the gh and ngit commands, and — critically for the nostr path — the three-mains alignment gate (GitHub main vs the lagging nostr main vs local main) that its create/revise/merge flows depend on. Skipping it leads to rejected pushes and PRs that don't show up as revisions.