mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
466 lines
27 KiB
Markdown
466 lines
27 KiB
Markdown
# 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 headless `cli`): 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: `commons` has no such deps.
|
||
- `commonsUI/` = Shared **Compose UI** that ≥1 GUI front end renders
|
||
(composables, `ui/theme`, icons, robohash, Coil fetchers, markdown, the
|
||
`composeResources` strings/fonts and the generated `Res` class). Depends on
|
||
`commons` (as `api`); `cli` never depends on it. Files keep their
|
||
`com.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 in
|
||
**`commons/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 `:quic` as transport,
|
||
Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio.
|
||
- `marmotQuic/` = Marmot's raw-QUIC binding for agent text stream previews.
|
||
Takes `:quic` for the connection and `:quartz` for the record/envelope
|
||
codecs. Not WebTransport — the binding has its own ALPNs and writes frames
|
||
straight onto QUIC streams, so it deliberately does not reuse
|
||
`nestsClient`'s `WebTransportSession`.
|
||
- `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 to `commonsUI` (today's `desktopApp` screens are legacy, to be replaced).
|
||
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
|
||
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
|
||
standalone relay); never on `:commonsUI`, `:amethyst` or `: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`, declared `android:process=":napplet"`). It holds **no**
|
||
account or keys; `Amethyst.onCreate()` early-returns here so `Amethyst.instance`
|
||
is **left unset** (touching it throws `UninitializedPropertyAccessException`).
|
||
The sandbox runtime lives in its own module **`:nappletHost`** (depends only on
|
||
`:commons` + `:quartz`, **never** `:amethyst`) so it *cannot* import
|
||
`Amethyst`/`LocalCache`/`Account` — the broker-side (signer, gateways, registry)
|
||
stays in `:amethyst` and the two halves talk over Messenger IPC.
|
||
|
||
Consequences — don't get caught assuming one process:
|
||
|
||
- **Processes don't share memory.** Every `object`/companion/`static` is a
|
||
*separate copy per process*: `LocalCache` (an `object`), `NappletLaunchRegistry`,
|
||
etc. The populated `LocalCache` lives 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.instance` exists.** Any code reachable from `:napplet`
|
||
(the host activity, content server, or an Application lifecycle callback like
|
||
`onTrimMemory`) must guard on the process and never reach for `instance`.
|
||
- **Cross-process state goes over Messenger IPC**, never a shared singleton — this
|
||
is why the broker (main) owns `NappletLaunchRegistry` and the sandbox only relays
|
||
an opaque token. See `amethyst/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-expert` tells you where shared composables live;
|
||
`compose-slot-api-pattern` tells 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** under `viewmodels/`, state holders,
|
||
relay client, services — headless) and `commonsUI/commonMain/` (major UI
|
||
components, icons, theme). ViewModels are platform-agnostic state + logic
|
||
(StateFlow/SharedFlow), so they belong in `commons`; anything that imports
|
||
`androidx.compose.ui`/`foundation`/`material3`, Coil, or `Res` belongs in
|
||
`commonsUI`.
|
||
**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 in `commonsUI`, because Android runs on laptops and the new
|
||
Desktop app renders the same UI. Adapt to the window with `ScreenLayoutSpec`
|
||
/ `LocalScreenLayout`, not with a per-platform screen.
|
||
- **Keep native** → only what is the platform itself: the process/window entry
|
||
point (Android `Activity`/`Service`, Desktop `Window`/tray/menu bar),
|
||
system integrations (notifications, file pickers, share sheets, camera,
|
||
media3, WebView, keyring/Keystore), and the platform `actual`s or port
|
||
implementations the shared UI calls. A new screen goes in `commonsUI` when
|
||
its dependencies allow — `AccountViewModel` itself is in `commonsUI`
|
||
(`commons.viewmodels`), reaching Android through `AccountViewModelHost`. A
|
||
screen that still needs an app-only helper may live in `amethyst/`, but keep
|
||
Android APIs out of it (behind a port or slot) so it can move later. Don't add screens to today's `desktopApp`
|
||
that 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
|
||
|
||
```bash
|
||
# 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`, importing
|
||
`com.vitorpamplona.amethyst.commons.resources.Res` **and** the per-key accessor
|
||
`com.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-side `ui.stringRes` overloads 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 function `suspend`), or resolve the label in
|
||
composition and pass it down as a `String`. A `runBlocking` bridge 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.py`
|
||
checks 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 `RemoteAction`s, 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:
|
||
|
||
```bash
|
||
./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:
|
||
|
||
```bash
|
||
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
|
||
`import` for the class and reference it by its simple name. Write
|
||
`Event` (with `import com.vitorpamplona.quartz...Event`), not
|
||
`com.vitorpamplona.quartz...Event` in the middle of code.
|
||
- The only acceptable inline fully-qualified names are: a genuine name
|
||
collision (prefer `import ... as Alias` instead), or where the language
|
||
requires it. Comments, KDoc, and string literals are exempt.
|
||
- **Prefer the `androidx.core` KTX extension over the raw platform Java call**
|
||
when one exists — this is what Android Lint's `UseKtx` flags. 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()`, and `prefs.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_8888`
|
||
`Bitmap.Config`, `scale(filter = false)`), and leave calls the KTX has no
|
||
equivalent for (e.g. the `createBitmap` pixels/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 inline `fast*` operators (`fastForEach`,
|
||
`fastAny`, `fastFirstOrNull`, `fastFirstNotNullOfOrNull`, … in
|
||
`nip01Core/core/TagArray.kt`) instead of Kotlin `List` + stdlib
|
||
`forEach`/`map`/`filter`/`any` — the `fast*` 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 `\uXXXX` escapes 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-managed `strings.xml`
|
||
translations (legitimate RTL typography; don't touch those files by hand
|
||
anyway). Note the tooling trap: the Edit tool may normalise a typed
|
||
`\uXXXX` back 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 **canonical** `main`; moves constantly. Standard `gh` PR flow.
|
||
- a **git-over-nostr** remote (`nostr://…/relay.ngit.dev/amethyst`, via `ngit`) — pushing fans out to GitHub **and** the GRASP git servers; **PRs here are nostr proposals** (the `pr/feat/*` branches), reviewed on **gitworkshop.dev** — *not* GitHub PRs. (In the maintainer's checkout these happen to be named `upstream` and `origin` respectively, 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.
|