refactor: split Compose UI out of commons into a new commonsUI module

`:commons` is on the CLI classpath, yet it declared Compose UI, Coil, Compose
resources, markdown and desktop Compose as dependencies, dragging ~40 MB of
UI/Skiko jars into every `amy` distribution. This moves every
Compose-dependent file into a new KMP module, `:commonsUI`, that
`api`-depends on `:commons`; `:commons` keeps only the Compose runtime
(stability annotations + snapshot state) and lifecycle-viewmodel.

Files keep their `com.vitorpamplona.amethyst.commons.*` packages, so the split
is a build-graph boundary and no consumer import changed. 236 files were
`git mv`'d (composables, icons, robohash, theme, Coil fetchers, the
`@Composable` relay-client entry points, `composeResources`, and the tests
that exercise them). Two headless files needed surgery instead of a move:
`GalleryParser` lost a vestigial foundation `@OptIn`, and the
`LocalPrivacyLockState`/`lockStateFor` CompositionLocal accessor moved out of
`PrivacyLockState` into its own commonsUI file. The feed DAL under `ui/feeds`
and `ui/note/ParentNote`+`ReplyContext` stay in `commons` because ViewModels
depend on them.

`amethyst`, `desktopApp`, `nappletHost` (NappletWebContract serves the shell
from composeResources) and `benchmark` now depend on `:commonsUI`; `cli`,
`geode` and `marmotBench` do not. commons' androidMain gains an explicit
androidx.core KTX dep it previously got transitively through Compose UI.

CI, crowdin, the icon-font tools and the escaping hook point at the new
composeResources location; CLAUDE.md, commons/ARCHITECTURE.md, a new
commonsUI/ARCHITECTURE.md, CONTRIBUTING, BUILDING and the affected skills
document the boundary. A plan doc under commons/plans records the
classification method and follow-ups.

Verified: JVM compiles for commons, commonsUI, cli, desktopApp; Android debug
compiles for nappletHost and amethyst; commons/commonsUI/cli JVM test suites;
both verifyKmpPurity gates; the cli runtime classpath no longer resolves
Compose UI, material3, Skiko or Coil.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N56KzPSYiN5edMRamvKEgD
This commit is contained in:
Claude
2026-09-12 16:26:59 +00:00
parent 08a3bab605
commit a87222c51f
332 changed files with 752 additions and 267 deletions
+43 -25
View File
@@ -3,7 +3,7 @@
## Project Overview ## Project Overview
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching 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`, `amethyst`, 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 `desktopApp`, `cli`, plus the audio-rooms transport stack `quic` + `nestsClient`. Quartz should
contain implementations of Nostr specifications and utilities to help implement them. Commons stores contain implementations of Nostr specifications and utilities to help implement them. Commons stores
shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop
@@ -48,11 +48,18 @@ amethyst/
│ ├── androidMain/ # Android-specific (crypto, storage) │ ├── androidMain/ # Android-specific (crypto, storage)
│ ├── jvmMain/ # Desktop JVM-specific │ ├── jvmMain/ # Desktop JVM-specific
│ └── iosMain/ # iOS-specific │ └── iosMain/ # iOS-specific
├── commons/ # Shared UI components (convert to KMP) ├── commons/ # Shared HEADLESS layer (models, state, ViewModels, relay client) — CLI-safe
│ └── src/ │ └── src/
│ ├── commonMain/ # Shared composables, icons, state │ ├── commonMain/ # Domain models, state holders, ViewModels, services
│ ├── androidMain/ # Android-specific UI utilities │ ├── jvmAndroid/ # JVM-bound services shared by Android + Desktop
│ └── jvmMain/ # Desktop-specific UI utilities │ ├── 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) ├── quic/ # Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport (audio-rooms transport)
│ └── src/ │ └── src/
│ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine │ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine
@@ -70,12 +77,20 @@ amethyst/
**Sharing Philosophy:** **Sharing Philosophy:**
- `quartz/` = Nostr business logic, protocol, data (no UI) - `quartz/` = Nostr business logic, protocol, data (no UI)
- `commons/` = Shared code for every front end (Android, Desktop, iOS, and the - `commons/` = Shared **headless** code for every front end (Android, Desktop,
headless `cli`): domain models, state holders, ViewModels, the relay client, iOS, and the headless `cli`): domain models, state holders, ViewModels, the
shared services, **and** the Compose UI that ≥1 GUI front end renders. The relay client, shared services. It may use the Compose *runtime*
package taxonomy, the CLI-safe / UI boundary, and a "where does my code go?" (`@Stable`/`@Immutable`, snapshot state) but never Compose UI, Coil or
guide are documented in **`commons/ARCHITECTURE.md`** — read it before adding Compose resources — the build enforces this: `commons` has no such deps.
a new package or dropping code into `commons`. - `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 - `quic/` = Transport library (QUIC + HTTP/3 + WebTransport); reusable for any
KMP project that needs MoQ. Has no Android-framework dependencies. KMP project that needs MoQ. Has no Android-framework dependencies.
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport, - `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
@@ -88,7 +103,7 @@ amethyst/
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation - `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic - `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
allowed). May also depend on `:geode` (for `amy serve`, which embeds the allowed). May also depend on `:geode` (for `amy serve`, which embeds the
standalone relay); never on `:amethyst` or `:desktopApp`. standalone relay); never on `:commonsUI`, `:amethyst` or `:desktopApp`.
**Plans per module:** design docs for new subsystems live in the owning **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/`). module's `plans/YYYY-MM-DD-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
@@ -180,16 +195,19 @@ etc. instead of re-implementing them.
**Share vs keep platform-native:** **Share vs keep platform-native:**
- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and - **Share** → `quartz/commonMain/` (business logic, data models, protocol),
`commons/commonMain/` (major UI components, **ViewModels** under `commons/commonMain/` (**ViewModels** under `viewmodels/`, state holders,
`viewmodels/`, icons). ViewModels are platform-agnostic state + logic relay client, services — headless) and `commonsUI/commonMain/` (major UI
(StateFlow/SharedFlow), so they belong in `commons`. 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`.
- **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android - **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android
`Activity`), navigation (sidebar vs bottom nav), platform interactions `Activity`), navigation (sidebar vs bottom nav), platform interactions
(gestures, keyboard shortcuts), system integrations (notifications, file (gestures, keyboard shortcuts), system integrations (notifications, file
pickers). pickers).
When extracting a composable: move it to `commons/commonMain/` (see When extracting a composable: move it to `commonsUI/commonMain/` (see
`/compose-expert`), add expect/actual for any platform behavior (see `/compose-expert`), add expect/actual for any platform behavior (see
`/kotlin-multiplatform`), then point both Android and Desktop at the shared `/kotlin-multiplatform`), then point both Android and Desktop at the shared
version. `quartz/` is protocol-only — no composables. version. `quartz/` is protocol-only — no composables.
@@ -216,7 +234,7 @@ version. `quartz/` is protocol-only — no composables.
## Dependency Licensing ## Dependency Licensing
**MANDATORY whenever you introduce a new third-party dependency** — in *any* **MANDATORY whenever you introduce a new third-party dependency** — in *any*
module (`quartz`, `commons`, `amethyst`, `desktopApp`, `cli`, `quic`, module (`quartz`, `commons`, `commonsUI`, `amethyst`, `desktopApp`, `cli`, `quic`,
`nestsClient`, …), whether you add it to `gradle/libs.versions.toml` or to a `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. 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 Amethyst ships under the **MIT** license, so a copyleft dependency linked into a
@@ -253,9 +271,9 @@ JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns.
## Icons ## Icons
The Material Symbols font bundled at The Material Symbols font bundled at
`commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` `commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf`
is a **subset** that only contains the glyphs referenced from is a **subset** that only contains the glyphs referenced from
`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`. `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt`.
**MANDATORY:** Whenever you add a new icon — i.e. introduce a **MANDATORY:** Whenever you add a new icon — i.e. introduce a
`MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in `MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in
@@ -274,21 +292,21 @@ regenerating.
### Amethyst's own icons are also a font ### Amethyst's own icons are also a font
The icons in `commons/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are The icons in `commonsUI/.../commons/icons/*.kt` (Like, Reply, Reposted, Zap, …) are
**also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn **also** compiled into a font, `composeResources/font/amethyst_icons.ttf`, and drawn
as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into 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 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%**, a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**,
overrun P90 **-17.4%** on the feed scroll benchmark. overrun P90 **-17.4%** on the feed scroll benchmark.
**MANDATORY:** whenever you add or change an icon under `commons/.../commons/icons/`, **MANDATORY:** whenever you add or change an icon under `commonsUI/.../commons/icons/`,
regenerate the font *and* its codepoint table together: regenerate the font *and* its codepoint table together:
```bash ```bash
python3 tools/icon-font/build_icon_font.py \ python3 tools/icon-font/build_icon_font.py \
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \ commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
commons/src/commonMain/composeResources/font/amethyst_icons.ttf \ commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf \
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
``` ```
Both outputs must be committed together: codepoints are assigned in filename order, Both outputs must be committed together: codepoints are assigned in filename order,
+2 -2
View File
@@ -21,7 +21,7 @@ that has to catch it.
Repair with: Repair with:
python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\ python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\
commons/src/commonMain/composeResources commonsUI/src/commonMain/composeResources
`--no-unwrap-quotes` is mandatory on already-migrated files: escape conversion is `--no-unwrap-quotes` is mandatory on already-migrated files: escape conversion is
idempotent, quote-unwrapping is not, and a second unwrap strips the real display idempotent, quote-unwrapping is not, and a second unwrap strips the real display
@@ -77,7 +77,7 @@ def main() -> int:
print( print(
"\nRepair:\n" "\nRepair:\n"
" python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\\n" " python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\\n"
" commons/src/commonMain/composeResources\n" " commonsUI/src/commonMain/composeResources\n"
"(--no-unwrap-quotes is mandatory on already-migrated files.)", "(--no-unwrap-quotes is mandatory on already-migrated files.)",
file=out, file=out,
) )
@@ -47,7 +47,7 @@ Walk the imports. The usual offenders:
| `android.util.Log` | Replace with `quartz` `PlatformLog` (already multiplatform). | | `android.util.Log` | Replace with `quartz` `PlatformLog` (already multiplatform). |
| `android.graphics.Bitmap` | Almost never needed by Amy. Keep in Android and split the function. | | `android.graphics.Bitmap` | Almost never needed by Amy. Keep in Android and split the function. |
| `android.net.Uri` | Replace with `kotlinx.io` path types or a plain `String`. | | `android.net.Uri` | Replace with `kotlinx.io` path types or a plain `String`. |
| `androidx.compose.*` | Must stay out of `commons/commonMain` unless you're in a Compose-Multiplatform module. Amy doesn't depend on Compose. | | `androidx.compose.*` | Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` must stay out of `commons` entirely — they belong in `:commonsUI`, which Amy never depends on. Only the Compose *runtime* (`@Stable`, snapshot state) is allowed in `commons`. |
### Step 3 — Pick a migration strategy per dependency ### Step 3 — Pick a migration strategy per dependency
@@ -66,7 +66,7 @@ Walk the imports. The usual offenders:
# Target location depends on what it is: # Target location depends on what it is:
# - Protocol → quartz/src/commonMain/kotlin/… # - Protocol → quartz/src/commonMain/kotlin/…
# - Business logic → commons/src/commonMain/kotlin/… # - Business logic → commons/src/commonMain/kotlin/…
# - UI → commons/src/commonMain/… (needs Compose Multiplatform) # - UI → commonsUI/src/commonMain/… (needs Compose Multiplatform; never used by amy)
git mv amethyst/src/main/java/com/.../FollowListManager.kt \ git mv amethyst/src/main/java/com/.../FollowListManager.kt \
commons/src/commonMain/kotlin/com/.../FollowListManager.kt commons/src/commonMain/kotlin/com/.../FollowListManager.kt
``` ```
+7 -7
View File
@@ -24,7 +24,7 @@ Visual UI patterns for sharing composables across Android and Desktop.
## Philosophy: Share by Default ## Philosophy: Share by Default
**Default to `commons/commonMain`** unless platform experts indicate otherwise. **Default to `commonsUI/commonMain`** (shared composables live in `:commonsUI`, the Compose half of the shared layer; headless state/ViewModels stay in `:commons`) unless platform experts indicate otherwise.
### Always Share ### Always Share
@@ -416,7 +416,7 @@ fun DataScreen(uiState: UiState) {
} }
``` ```
**Components** (all in `commons/commonMain`): **Components** (all in `commonsUI/commonMain`):
- `LoadingState` - Progress indicator + message - `LoadingState` - Progress indicator + message
- `EmptyState` - Empty message + optional refresh button - `EmptyState` - Empty message + optional refresh button
- `ErrorState` - Error message + optional retry button - `ErrorState` - Error message + optional retry button
@@ -527,12 +527,12 @@ fun FeedList(items: List<Item>) {
| Task | Pattern | Location | | Task | Pattern | Location |
|------|---------|----------| |------|---------|----------|
| Reusable UI | State hoisting | commons/commonMain | | Reusable UI | State hoisting | commonsUI/commonMain |
| Simple state | remember { mutableStateOf() } | Composable scope | | Simple state | remember { mutableStateOf() } | Composable scope |
| Derived state | derivedStateOf { } | remember block | | Derived state | derivedStateOf { } | remember block |
| Async → state | produceState { } | Composable function | | Async → state | produceState { } | Composable function |
| Custom icons | roboBuilder + PathData | commons/icons | | Custom icons | roboBuilder + PathData | commonsUI/icons |
| Loading/Error | LoadingState, ErrorState | commons/ui/components | | Loading/Error | LoadingState, ErrorState | commonsUI/ui/components |
| Theme colors | MaterialTheme.colorScheme | Any @Composable | | Theme colors | MaterialTheme.colorScheme | Any @Composable |
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ | | Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
@@ -540,7 +540,7 @@ fun FeedList(items: List<Item>) {
### Creating a Shared Component ### Creating a Shared Component
1. Start in `commons/src/commonMain/kotlin/.../ui/components/` 1. Start in `commonsUI/src/commonMain/kotlin/.../ui/components/`
2. Use Material3 primitives only 2. Use Material3 primitives only
3. Hoist state (parameters for data, callbacks for events) 3. Hoist state (parameters for data, callbacks for events)
4. Add modifier parameter 4. Add modifier parameter
@@ -551,7 +551,7 @@ fun FeedList(items: List<Item>) {
1. Read current implementation in `amethyst/` or `desktopApp/` 1. Read current implementation in `amethyst/` or `desktopApp/`
2. Identify pure visual logic (no platform APIs) 2. Identify pure visual logic (no platform APIs)
3. Create in `commons/commonMain` with hoisted state 3. Create in `commonsUI/commonMain` with hoisted state
4. Replace platform implementations with shared component 4. Replace platform implementations with shared component
5. Keep platform-specific wrappers if needed 5. Keep platform-specific wrappers if needed
@@ -24,9 +24,9 @@ There are two separate `strings.xml` trees, each with its own default `values/`
| Tree | Default file | Per-locale file | | Tree | Default file | Per-locale file |
|------|--------------|-----------------| |------|--------------|-----------------|
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` | | **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
| **commons** (KMP Compose resources, shared by Android + Desktop) | `commons/src/commonMain/composeResources/values/strings.xml` | `commons/src/commonMain/composeResources/values-<locale>/strings.xml` | | **commonsUI** (KMP Compose resources, shared by Android + Desktop) | `commonsUI/src/commonMain/composeResources/values/strings.xml` | `commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml` |
The `commons` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers). The `commonsUI` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` — now `commonsUI/` since the UI split (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
**Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match. **Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
@@ -37,7 +37,7 @@ The `commons` tree appeared when shared event-renderer composables were extracte
Detect name-overlap **and flag value mismatches** in one pass: Detect name-overlap **and flag value mismatches** in one pass:
```bash ```bash
cdef=commons/src/commonMain/composeResources/values/strings.xml cdef=commonsUI/src/commonMain/composeResources/values/strings.xml
adef=amethyst/src/main/res/values/strings.xml adef=amethyst/src/main/res/values/strings.xml
comm -12 \ comm -12 \
<(grep '<string name=' "$cdef" | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \ <(grep '<string name=' "$cdef" | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
@@ -54,7 +54,7 @@ Only `SAFE-COPY` keys may be copied verbatim. For `VALUE-DIFFERS`, translate the
**Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced: **Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced:
```bash ```bash
grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values-*/strings.xml grep -nE '<string name="[^"]*">"' commonsUI/src/commonMain/composeResources/values-*/strings.xml
# The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst. # The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst.
``` ```
@@ -132,14 +132,14 @@ Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml Target: amethyst/src/main/res/values-<locale>/strings.xml
# commons tree # commons tree
Default: commons/src/commonMain/composeResources/values/strings.xml Default: commonsUI/src/commonMain/composeResources/values/strings.xml
Target: commons/src/commonMain/composeResources/values-<locale>/strings.xml Target: commonsUI/src/commonMain/composeResources/values-<locale>/strings.xml
``` ```
A convenient way to run the whole technique twice is to loop over the two base dirs: A convenient way to run the whole technique twice is to loop over the two base dirs:
```bash ```bash
for base in amethyst/src/main/res commons/src/commonMain/composeResources; do for base in amethyst/src/main/res commonsUI/src/commonMain/composeResources; do
echo "########## tree: $base ##########" echo "########## tree: $base ##########"
# ... run the diff/count/value-extraction commands with $base/values[...] ... # ... run the diff/count/value-extraction commands with $base/values[...] ...
done done
@@ -258,8 +258,8 @@ Flag and offer to fix:
# hardcode "1" (or other literal digits) instead of using a placeholder. # hardcode "1" (or other literal digits) instead of using a placeholder.
# Looks at default + all values-* locales, in BOTH resource trees. # Looks at default + all values-* locales, in BOTH resource trees.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \ for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \ commonsUI/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
awk -v file="$f" ' awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) } /<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="one"/ { in_plurals && /quantity="one"/ {
@@ -279,8 +279,8 @@ Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-
```bash ```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \ for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \ commonsUI/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
# Skip Arabic, Latvian and Welsh — they natively use the zero category. # Skip Arabic, Latvian and Welsh — they natively use the zero category.
# (Latvian's zero covers 0, 10, 11-19, 20, 30, … — stripping it breaks most counts.) # (Latvian's zero covers 0, 10, 11-19, 20, 30, … — stripping it breaks most counts.)
case "$f" in case "$f" in
@@ -313,7 +313,7 @@ itre = re.compile(r'<item quantity="([^"]+)"[^>]*>(.*?)</item>', re.S)
# (?<!\\) is REQUIRED: \%2$d is an escaped literal, not a placeholder. # (?<!\\) is REQUIRED: \%2$d is an escaped literal, not a placeholder.
phre = re.compile(r'(?<!\\)%(?:(\d+)\$)?([sdf])') phre = re.compile(r'(?<!\\)%(?:(\d+)\$)?([sdf])')
sig = lambda t: sorted(m.group(0) for m in phre.finditer(t)) sig = lambda t: sorted(m.group(0) for m in phre.finditer(t))
for base in ['amethyst/src/main/res', 'commons/src/commonMain/composeResources']: for base in ['amethyst/src/main/res', 'commonsUI/src/commonMain/composeResources']:
d = io.open(f'{base}/values/strings.xml', encoding='utf-8').read() d = io.open(f'{base}/values/strings.xml', encoding='utf-8').read()
dstr = {m.group(1): sig(m.group(2)) for m in keyre.finditer(d)} dstr = {m.group(1): sig(m.group(2)) for m in keyre.finditer(d)}
dpl = {} dpl = {}
@@ -339,7 +339,7 @@ PY
# Empty plural items render as nothing at runtime — always a bug. # Empty plural items render as nothing at runtime — always a bug.
grep -rn '<item quantity="[a-z]*"></item>' \ grep -rn '<item quantity="[a-z]*"></item>' \
amethyst/src/main/res/values*/strings.xml \ amethyst/src/main/res/values*/strings.xml \
commons/src/commonMain/composeResources/values*/strings.xml commonsUI/src/commonMain/composeResources/values*/strings.xml
``` ```
Three things this scan taught us, all of which it now encodes: Three things this scan taught us, all of which it now encodes:
@@ -528,7 +528,7 @@ When adding translated strings to locale files:
## Common Mistakes ## Common Mistakes
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately. - **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commonsUI/src/commonMain/composeResources`). A key extracted into `commonsUI/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees). - **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees).
- **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format. - **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format.
- **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass. - **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass.
+1 -1
View File
@@ -383,7 +383,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns | | State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns |
| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** | | **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** |
| UI formatters (pure) | commons/commonMain | Reusable, no dependencies | | UI formatters (pure) | commons/commonMain | Reusable, no dependencies |
| UI components (simple) | commons/commonMain | Cards, buttons, dialogs | | UI components (simple) | commonsUI/commonMain | Cards, buttons, dialogs (Compose UI never goes in `commons`) |
| **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** | | **Screen layouts** | **Platform-specific** | **Window vs Activity, sidebar vs bottom nav** |
| Navigation | Platform-specific only | Activity vs Window too different | | Navigation | Platform-specific only | Activity vs Window too different |
| Permissions | Platform-specific only | APIs incompatible | | Permissions | Platform-specific only | APIs incompatible |
+1 -1
View File
@@ -17,7 +17,7 @@ The layer between `LocalCache`/`Account` and the raw relay connection. Ensures c
## Layout ## Layout
All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/`: All under `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/` (the `@Composable` entry points — `observeUser*`, `*FilterAssemblerSubscription`, `KeyDataSourceSubscription` — sit in the same package but in `commonsUI/src/commonMain/…`, the Compose half of the shared layer):
``` ```
relayClient/ relayClient/
+9 -3
View File
@@ -45,7 +45,7 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/main' }} cache-read-only: ${{ github.ref != 'refs/heads/main' }}
- name: Linter (gradle) - name: Linter (gradle)
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity :commonsUI:verifyKmpPurity
build-desktop: build-desktop:
needs: lint needs: lint
@@ -93,7 +93,7 @@ jobs:
- name: Test + Build Desktop (gradle) - name: Test + Build Desktop (gradle)
run: | run: |
CMD="./gradlew :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}" CMD="./gradlew :quartz:jvmTest :commons:jvmTest :commonsUI:jvmTest :nestsClient:jvmTest :cli:test :desktopApp:test :desktopApp:${{ matrix.desktop-task }}"
if [ "${{ runner.os }}" = "Linux" ]; then if [ "${{ runner.os }}" = "Linux" ]; then
xvfb-run --auto-servernum $CMD xvfb-run --auto-servernum $CMD
else else
@@ -129,6 +129,7 @@ jobs:
path: | path: |
quartz/build/reports/tests quartz/build/reports/tests
commons/build/reports/tests commons/build/reports/tests
commonsUI/build/reports/tests
nestsClient/build/reports/tests nestsClient/build/reports/tests
cli/build/reports/tests cli/build/reports/tests
desktopApp/build/reports/tests desktopApp/build/reports/tests
@@ -307,11 +308,15 @@ jobs:
# :commons:jvmTest stays green — this is the job that catches it. # :commons:jvmTest stays green — this is the job that catches it.
# - compileTestKotlinIosArm64 catches device-only compile drift # - compileTestKotlinIosArm64 catches device-only compile drift
# (iosArm64 = aarch64-apple-ios) without needing a physical device. # (iosArm64 = aarch64-apple-ios) without needing a physical device.
# :commonsUI (the Compose half split out of :commons) gets the same
# treatment so the shared composables keep compiling on Apple targets.
- name: Test Commons on iOS - name: Test Commons on iOS
run: | run: |
./gradlew \ ./gradlew \
:commons:iosSimulatorArm64Test \ :commons:iosSimulatorArm64Test \
:commons:compileTestKotlinIosArm64 :commons:compileTestKotlinIosArm64 \
:commonsUI:iosSimulatorArm64Test \
:commonsUI:compileTestKotlinIosArm64
- name: Upload iOS Test Reports - name: Upload iOS Test Reports
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -363,6 +368,7 @@ jobs:
:amethyst:lintPlayBenchmark \ :amethyst:lintPlayBenchmark \
:quartz:jvmTest \ :quartz:jvmTest \
:commons:jvmTest \ :commons:jvmTest \
:commonsUI:jvmTest \
:nestsClient:jvmTest \ :nestsClient:jvmTest \
:amethyst:testFdroidDebugUnitTest \ :amethyst:testFdroidDebugUnitTest \
:amethyst:testPlayDebugUnitTest \ :amethyst:testPlayDebugUnitTest \
+6 -5
View File
@@ -572,11 +572,12 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
# The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget # The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget
# targets < 80 MB, but :commons currently leaks Compose + Skiko as # targets < 80 MB. Compose UI + Skiko now live in :commonsUI, which
# transitive deps (~40 MB of unused UI jars). Budget is set to # :cli does not depend on, so the headless :commons no longer drags
# 200 MB until commons is split into core + ui modules — track that # ~40 MB of UI jars into the CLI. Budget stays at 200 MB for now
# as a follow-up. Until then, this gate just catches pathological # (tighten once a release confirms the new size); this gate catches
# regressions (e.g. accidental :amethyst dep pulling Android libs). # pathological regressions (e.g. an accidental :commonsUI/:amethyst
# dep pulling UI or Android libs back in).
fail=0 fail=0
for f in dist/*; do for f in dist/*; do
if [[ -f "$f" ]]; then if [[ -f "$f" ]]; then
+4 -4
View File
@@ -53,7 +53,7 @@ jobs:
# Both files in crowdin.yml are declared `type: android`, so Crowdin's Android # Both files in crowdin.yml are declared `type: android`, so Crowdin's Android
# serializer escapes apostrophes on the way down: `l'URL` comes back as `l\'URL`. # serializer escapes apostrophes on the way down: `l'URL` comes back as `l\'URL`.
# That is correct for amethyst/src/main/res/, which aapt un-escapes at build time, # That is correct for amethyst/src/main/res/, which aapt un-escapes at build time,
# and WRONG for commons/.../composeResources/, where Compose resolves only \uXXXX, # and WRONG for commonsUI/.../composeResources/, where Compose resolves only \uXXXX,
# \n and \t and leaves \' \" \? \@ alone -- so the backslash reaches the screen. # \n and \t and leaves \' \" \? \@ alone -- so the backslash reaches the screen.
# #
# Without this step every sync reopens the same regression and CI's # Without this step every sync reopens the same regression and CI's
@@ -68,14 +68,14 @@ jobs:
- name: Convert Android escaping to Compose escaping in the shared catalog - name: Convert Android escaping to Compose escaping in the shared catalog
run: | run: |
python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \ python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \
commons/src/commonMain/composeResources commonsUI/src/commonMain/composeResources
# Assert the conversion actually satisfied the check that guards main, so a case # Assert the conversion actually satisfied the check that guards main, so a case
# the converter cannot repair fails the sync loudly here instead of opening a red # the converter cannot repair fails the sync loudly here instead of opening a red
# PR. Known gap if this ever trips: fix_escapes.py only rewrites text inside # PR. Known gap if this ever trips: fix_escapes.py only rewrites text inside
# <string>/<item> elements, while the check scans the whole file -- an escape in an # <string>/<item> elements, while the check scans the whole file -- an escape in an
# XML comment (comments do propagate into the locale files) has to be fixed at the # XML comment (comments do propagate into the locale files) has to be fixed at the
# source string in commons/.../composeResources/values/strings.xml by hand. # source string in commonsUI/.../composeResources/values/strings.xml by hand.
- name: Verify the shared catalog is free of Android-only escaping - name: Verify the shared catalog is free of Android-only escaping
run: .claude/hooks/compose_escaping_check.py run: .claude/hooks/compose_escaping_check.py
@@ -99,7 +99,7 @@ jobs:
branch: l10n_crowdin_translations branch: l10n_crowdin_translations
add-paths: | add-paths: |
amethyst/src/main/res/**/strings.xml amethyst/src/main/res/**/strings.xml
commons/src/commonMain/composeResources/**/strings.xml commonsUI/src/commonMain/composeResources/**/strings.xml
docs/changelog/translators.json docs/changelog/translators.json
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders' commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
title: 'New Crowdin Translations' title: 'New Crowdin Translations'
+7 -6
View File
@@ -96,7 +96,7 @@ and each has its own guide:
| Artifact | Committed at | Regenerate when | Guide | | Artifact | Committed at | Regenerate when | Guide |
|---|---|---|---| |---|---|---|---|
| **Material Symbols subset font** | `commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` | | **Material Symbols subset font** | `commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf` | You add/remove a `MaterialSymbol("\uXXXX")` codepoint in `MaterialSymbols.kt`, or bump the upstream font | [`tools/material-symbols-subset/README.md`](tools/material-symbols-subset/README.md) — run `./tools/material-symbols-subset/subset.sh` |
| **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) | | **Arti (Tor) native libs** | `amethyst/src/main/jniLibs/*.so` | You update the pinned Arti version, change the JNI wrapper, or want to reproduce the binaries | [`tools/arti-build/README.md`](tools/arti-build/README.md) |
> **Material Symbols is mandatory after icon changes.** The bundled font is a > **Material Symbols is mandatory after icon changes.** The bundled font is a
@@ -466,8 +466,8 @@ Homebrew removes the quarantine attribute on its own downloads.
> with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and > with `dry_run=true` — the sign+notarize step runs regardless of `dry_run` and
> now prints the per-file notary log on a non-`Accepted` verdict. If it comes > now prints the per-file notary log on a non-`Accepted` verdict. If it comes
> back `Invalid`, the fix is to codesign the dylibs *inside* those jars before > back `Invalid`, the fix is to codesign the dylibs *inside* those jars before
> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the > zipping (the unused `skiko`/Compose jars left the CLI image with the
> `:commons` core/ui split the size budget already flags). The **desktop** app > `:commons` / `:commonsUI` split). The **desktop** app
> bundles the same jars through Compose/jpackage notarization, so run a desktop > bundles the same jars through Compose/jpackage notarization, so run a desktop
> dry-run too; its in-jar handling differs and is likewise unverified. > dry-run too; its in-jar handling differs and is likewise unverified.
@@ -686,9 +686,10 @@ Caveats that the maintainer must weigh before submitting:
- **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading - **Pre-built-jar scrutiny.** homebrew-core prefers source builds; downloading
a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to
justify it (sandboxed Gradle can't fetch Maven deps). justify it (sandboxed Gradle can't fetch Maven deps).
- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks - **Bundle size.** The bundle used to be ~70 MB because `:commons` leaked
Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons` Compose/Skiko jars onto the CLI classpath. Compose UI now lives in
core/ui split) would shrink it and smooth review — tracked as a follow-up. `:commonsUI`, which `:cli` does not depend on, so the bundle no longer
carries those jars — re-measure at the next release.
After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot After the formula merges, the `livecheck` block lets homebrew-core's BrewTestBot
auto-open version-bump PRs on each stable release — no token or workflow on our auto-open version-bump PRs on each stable release — no token or workflow on our
+7 -3
View File
@@ -175,9 +175,13 @@ device. PRs that introduce any of them will be sent back.
### KMP source-set discipline ### KMP source-set discipline
- **Android-only imports don't belong in `commons/commonMain` or - **Android-only imports don't belong in `commons/commonMain`,
`quartz/commonMain`.** Use `expect`/`actual` for platform-specific `commonsUI/commonMain` or `quartz/commonMain`.** Use `expect`/`actual`
bits, or move the Android-specific code to `androidMain`. for platform-specific bits, or move the Android-specific code to
`androidMain`.
- **Compose UI (`ui`/`foundation`/`material3`), Coil and `Res` don't belong
in `commons` at all** — that module is on the CLI classpath. Put the file
in `commonsUI` (same package) instead.
### Logging ### Logging
+8 -5
View File
@@ -3,7 +3,7 @@
Thanks for your interest in improving Amethyst. This document captures the Thanks for your interest in improving Amethyst. This document captures the
expectations, conventions, and review rules for code, documentation, and expectations, conventions, and review rules for code, documentation, and
translation contributions across all modules in this repository (`amethyst/`, translation contributions across all modules in this repository (`amethyst/`,
`desktopApp/`, `quartz/`, `commons/`, `cli/`, `quic/`, `nestsClient/`). `desktopApp/`, `quartz/`, `commons/`, `commonsUI/`, `cli/`, `quic/`, `nestsClient/`).
By contributing, you agree to license your work under the MIT license. Any By contributing, you agree to license your work under the MIT license. Any
work contributed where you are not the original author must contain its work contributed where you are not the original author must contain its
@@ -157,7 +157,8 @@ Common Gradle entry points:
Modules: Modules:
- `quartz/` — Nostr KMP library (protocol, crypto, models). **No UI.** - `quartz/` — Nostr KMP library (protocol, crypto, models). **No UI.**
- `commons/` — Shared Compose Multiplatform UI, icons, ViewModels, flows. - `commons/` — Shared headless layer: models, ViewModels, flows, relay client. **No Compose UI** (the CLI depends on it).
- `commonsUI/` — Shared Compose Multiplatform UI, icons, theme, Compose resources, on top of `commons`.
- `quic/` — Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport. - `quic/` — Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport.
- `nestsClient/` — Audio-rooms client (NIP-53) built on `:quic` and - `nestsClient/` — Audio-rooms client (NIP-53) built on `:quic` and
`:quartz`. `:quartz`.
@@ -175,7 +176,8 @@ of PR churn. Place new code by purpose:
| What you're adding | Goes in | | What you're adding | Goes in |
|---|---| |---|---|
| Nostr event types, NIPs, tags, signing, crypto, Bech32 | `quartz/commonMain/` | | Nostr event types, NIPs, tags, signing, crypto, Bech32 | `quartz/commonMain/` |
| Shared Composables, icons, ViewModels, StateFlows | `commons/commonMain/viewmodels/` or `commons/commonMain/` | | Shared ViewModels, StateFlows, relay subscriptions | `commons/commonMain/viewmodels/` or `commons/commonMain/` |
| Shared Composables, icons, theme | `commonsUI/commonMain/` (same packages as `commons`) |
| Android-only screen, navigation, system integration | `amethyst/` | | Android-only screen, navigation, system integration | `amethyst/` |
| Desktop-only window, sidebar, menu bar, shortcut | `desktopApp/` | | Desktop-only window, sidebar, menu bar, shortcut | `desktopApp/` |
| `amy <verb>` subcommand (thin assembly only) | `cli/src/main/kotlin/.../cli/` | | `amy <verb>` subcommand (thin assembly only) | `cli/src/main/kotlin/.../cli/` |
@@ -188,8 +190,9 @@ Hard rules:
- `cli/` has **no Nostr protocol or business logic** — it's a thin assembly - `cli/` has **no Nostr protocol or business logic** — it's a thin assembly
layer over `quartz` + `commons`. If your CLI command needs new behavior, layer over `quartz` + `commons`. If your CLI command needs new behavior,
extract it into `commons/` first. extract it into `commons/` first.
- ViewModels belong in `commons/commonMain/`. Only screens (the Composable - ViewModels belong in `commons/commonMain/`; shared composables in
that wires layout + navigation) stay in the platform module. `commonsUI/commonMain/`. Only screens (the Composable that wires layout +
navigation) stay in the platform module.
- For platform-specific behavior in a shared file, use `expect`/`actual`. - For platform-specific behavior in a shared file, use `expect`/`actual`.
## Workflow ## Workflow
+1
View File
@@ -405,6 +405,7 @@ dependencies {
implementation(project(":quartz")) implementation(project(":quartz"))
implementation(project(":commons")) implementation(project(":commons"))
implementation(project(":commonsUI"))
implementation(project(":nestsClient")) implementation(project(":nestsClient"))
// Agent text stream previews: the raw-QUIC binding plus the QUIC // Agent text stream previews: the raw-QUIC binding plus the QUIC
// stack under it (for the certificate validator it requires). // stack under it (for the certificate validator it requires).
+1
View File
@@ -67,6 +67,7 @@ dependencies {
androidTestImplementation(libs.androidx.benchmark.junit4) androidTestImplementation(libs.androidx.benchmark.junit4)
androidTestImplementation(project(":quartz")) androidTestImplementation(project(":quartz"))
androidTestImplementation(project(":commons")) androidTestImplementation(project(":commons"))
androidTestImplementation(project(":commonsUI"))
// Custom C secp256k1 (libschnorr256k1) for the 3-way Android benchmark // Custom C secp256k1 (libschnorr256k1) for the 3-way Android benchmark
androidTestImplementation(libs.schnorr256k1.kmp) androidTestImplementation(libs.schnorr256k1.kmp)
+72 -49
View File
@@ -4,10 +4,11 @@
| Consumer | Kind | Uses from `commons` | | Consumer | Kind | Uses from `commons` |
|----------------|------------------------------|----------------------------------------------| |----------------|------------------------------|----------------------------------------------|
| `amethyst` | Android app (touch-first) | everything (models, state, ViewModels, UI) | | `amethyst` | Android app (touch-first) | everything (models, state, ViewModels) + `commonsUI` |
| `desktopApp` | Desktop JVM app (mouse-first)| everything (models, state, ViewModels, UI) | | `desktopApp` | Desktop JVM app (mouse-first)| everything (models, state, ViewModels) + `commonsUI` |
| `cli` (`amy`) | Headless JVM CLI (no UI) | **non-UI only** — models, actions, relay, services | | `cli` (`amy`) | Headless JVM CLI (no UI) | everything — `commons` is headless by construction; it never sees `commonsUI` |
| iOS (future) | iOS app | everything; expected to share most UI with Android | | `nappletHost` | Android WebView sandbox | napplet contract + `commonsUI` (for the shell/shim Compose resources) |
| iOS (future) | iOS app | everything + `commonsUI`; expected to share most UI with Android |
`commons` sits **above** `quartz` (the protocol-only Nostr KMP library) and `commons` sits **above** `quartz` (the protocol-only Nostr KMP library) and
**below** the apps. The split between the three is: **below** the apps. The split between the three is:
@@ -15,10 +16,13 @@
- **`quartz/`** — Nostr protocol: events, NIPs, crypto, relay framing. No app - **`quartz/`** — Nostr protocol: events, NIPs, crypto, relay framing. No app
state, no UI, no caches of "what this user follows." state, no UI, no caches of "what this user follows."
- **`commons/`** — everything an Amethyst *client* needs that isn't a - **`commons/`** — everything an Amethyst *client* needs that isn't a
platform-native screen or navigation shell: domain models (`Note`, `User`), platform-native screen, navigation shell, **or Compose UI**: domain models
in-memory state holders, ViewModels, the relay-subscription client, shared (`Note`, `User`), in-memory state holders, ViewModels, the relay-subscription
business services, **and** the Compose UI components that more than one front client, shared business services.
end renders. - **`commonsUI/`** — the Compose UI components that more than one front end
renders, plus everything only they need (icons, theme, Coil fetchers,
markdown, the `composeResources` strings/fonts and the generated `Res`).
Depends on `commons` as `api`. See `commonsUI/ARCHITECTURE.md`.
- **`amethyst/` & `desktopApp/`** — platform-native screens, navigation - **`amethyst/` & `desktopApp/`** — platform-native screens, navigation
(bottom-nav vs sidebar), gestures, system integration. They assemble (bottom-nav vs sidebar), gestures, system integration. They assemble
`commons` pieces; they should not re-implement them. `commons` pieces; they should not re-implement them.
@@ -31,29 +35,32 @@
## 1. The one rule that shapes the package tree: the **UI / non-UI boundary** ## 1. The one rule that shapes the package tree: the **UI / non-UI boundary**
`commons` is a single module that contains **both** Compose UI and headless The shared layer is **two modules** with one package tree:
logic. That is deliberate (it keeps a feature's model, state, and UI together —
see §3), but it creates one hard constraint, because **`cli` and any headless
consumer cannot use Compose**:
> **CLI-safe code** = does not depend on Compose UI. It may use the > **`commons` = CLI-safe code.** It does not depend on Compose UI. It may use
> `androidx.compose.runtime` *annotations* `@Stable` / `@Immutable` (they are > the `androidx.compose.runtime` *annotations* `@Stable` / `@Immutable` (they
> just stability tags) and snapshot state, but it must **not** import > are just stability tags) and snapshot state (`mutableStateOf`, `State`), but
> `androidx.compose.ui`, `androidx.compose.foundation`, > it must **not** import `androidx.compose.ui`, `androidx.compose.foundation`,
> `androidx.compose.material3`, declare `@Composable` functions, or build > `androidx.compose.material3`, Coil, the generated `Res`, declare
> `ImageVector`s. > `@Composable` functions, or build `ImageVector`s. Its `build.gradle.kts`
> simply has none of those dependencies, so a violation fails to compile.
> >
> **UI code** = anything that does. It is only usable by the GUI front ends > **`commonsUI` = UI code.** Anything that does the above. It is only usable
> (Android, Desktop, iOS), never by `cli`. > by the GUI front ends (Android, Desktop, iOS), never by `cli`.
Compose is an `implementation` dependency of `commonMain`, so `cli` pulling in Both modules share the **same `com.vitorpamplona.amethyst.commons.*` package
`commons` does **not** force it to render anything — but a `cli` command must tree** — the split is a module boundary, not a package rename, so a file moves
only reach for CLI-safe packages. When you add code, know which side of this between `commons/src/…` and `commonsUI/src/…` without changing its package or
line it is on, and put it in a package that matches (§2). any consumer's imports. Kotlin resolves same-package declarations across
modules without imports; the only thing that stops working across the boundary
is `internal` visibility (a UI file cannot see an `internal` declaration in
`commons` — make it public or move it).
This boundary is **not** a top-level `ui/` vs `logic/` partition of the whole This boundary is **not** a top-level `ui/` vs `logic/` partition of the package
module (we chose to stay feature-oriented, §3). It is a property of each file tree (we chose to stay feature-oriented, §3). It is a property of each file:
that you keep track of via package placement and the table in §2. a feature keeps its logic in `commons/…/<feature>/` and its composables in
`commonsUI/…/<feature>/ui/` (or `commonsUI/…/<feature>/` for the historical
flat packages), and the table in §2 says which module each package lives in.
--- ---
@@ -61,7 +68,9 @@ that you keep track of via package placement and the table in §2.
Top-level packages under Top-level packages under
`commonMain/.../commons/`, grouped by concern. **UI?** marks whether the `commonMain/.../commons/`, grouped by concern. **UI?** marks whether the
package contains Compose UI (and is therefore *not* CLI-safe). package contains Compose UI (and therefore lives in **`commonsUI`**, not
here). "mixed" means the feature's logic is in `commons` and its composables
in `commonsUI`, under the same package.
### Domain models & data ### Domain models & data
| Package | UI? | Purpose | | Package | UI? | Purpose |
@@ -91,17 +100,19 @@ package contains Compose UI (and is therefore *not* CLI-safe).
| Package | UI? | Purpose | | Package | UI? | Purpose |
|----------------|-----|---------| |----------------|-----|---------|
| `state` | no² | Small feature `StateFlow` machines (`FollowState`, `UserMetadataState`, `LoadingState`). | | `state` | no² | Small feature `StateFlow` machines (`FollowState`, `UserMetadataState`, `LoadingState`). |
| `viewmodels` | no² | Larger list/feed-backed ViewModels (`androidx.lifecycle.ViewModel`). Shared by all GUI front ends; `cli` usually drives the layers below instead. | | `viewmodels` | no² | Larger list/feed-backed ViewModels (`androidx.lifecycle.ViewModel`). Shared by all GUI front ends; `cli` usually drives the layers below instead. The few that hold Compose UI state (`ChatNewMessageState` — `TextFieldValue`; `thread/LevelFeedViewModel` — `LazyListState`) live in `commonsUI` under the same package. |
| `feeds` | no | `FeedDefinitionRepository` — custom-feed definitions & ordering. | | `feeds` | no | `FeedDefinitionRepository` — custom-feed definitions & ordering. |
| `profile` | mixed | `ProfileBroadcastStatus` (state) + `EditProfileFields` at the root; the `ProfileBroadcastBanner` composable lives in `profile/ui`. | | `profile` | mixed | `ProfileBroadcastStatus` (state) + `EditProfileFields` at the root; the `ProfileBroadcastBanner` composable lives in `commonsUI` `profile/ui`. |
| `privacylock` | mixed | Lock state machine + settings here; `LocalPrivacyLockState`/`lockStateFor` (CompositionLocal accessor) in `commonsUI`. |
² may touch `compose.runtime`/`foundation` state types (e.g. `LazyListState`); ² may touch `compose.runtime` state types (snapshot state, `@Stable`); they
they are shared across the GUI apps. Treat as GUI-shared, not strictly headless. are shared across the GUI apps. A state holder that needs a `foundation`/`ui`
type (`LazyListState`, `TextFieldValue`, `TextFieldState`) goes to `commonsUI`.
### Relay client ### Relay client
| Package | UI? | Purpose | | Package | UI? | Purpose |
|----------------|-----|---------| |----------------|-----|---------|
| `relayClient` | no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) The canonical **per-visible loading** entry points live here: `relayClient/user/` (`observeUser*` — kind-0 metadata) and `relayClient/event/` (`EventFinderFilterAssemblerSubscription`/`observeNote*` — reactions/zaps/reposts). See the `relay-client` skill. | | `relayClient` | mixed | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a `composeSubscriptionManagers` subpackage name, this is subscription-lifecycle logic, not UI.) The `@Composable` entry points — `relayClient/user/` (`observeUser*` — kind-0 metadata), `relayClient/event/` (`EventFinderFilterAssemblerSubscription`/`observeNote*`), the other `*FilterAssemblerSubscription`s, `KeyDataSourceSubscription`, `auth/AuthApprovalBanner` — are in `commonsUI` under the same packages. See the `relay-client` skill. |
| `relays` | no | Low-level EOSE/relay-timing bookkeeping (`EOSECache`, `EOSERelayList`). | | `relays` | no | Low-level EOSE/relay-timing bookkeeping (`EOSECache`, `EOSERelayList`). |
### Platform abstractions (`expect`/`actual`) ### Platform abstractions (`expect`/`actual`)
@@ -112,19 +123,23 @@ they are shared across the GUI apps. Treat as GUI-shared, not strictly headless.
| `tor` | no | Tor manager interface + settings. | | `tor` | no | Tor manager interface + settings. |
| `service` | no | Cross-cutting services: `BundledUpdate` batching (common); `service/upload` (JVM), `service/nwc`, `service/lnurl` (jvmAndroid). **Singular `service`** — there is no `services`. | | `service` | no | Cross-cutting services: `BundledUpdate` batching (common); `service/upload` (JVM), `service/nwc`, `service/lnurl` (jvmAndroid). **Singular `service`** — there is no `services`. |
### UI (Compose — **not** CLI-safe) ### UI (Compose — lives in **`commonsUI`**)
| Package | UI? | Purpose | | Package | UI? | Purpose |
|----------------|-----|---------| |----------------|-----|---------|
| `ui` | yes | **Cross-cutting** shared composables only, organized by area: `ui/components`, `ui/theme`, `ui/signing`, `ui/thread`, `ui/feeds` (feed DAL + filters — see debt §4), `ui/notifications`, `ui/screens`, `ui/elements`, `ui/layouts`, `ui/markdown`, plus Compose helpers in `ui/state` (cached-state) and `ui/text` (TextField extensions). Feature-specific UI lives in `<feature>/ui`, **not** here. | | `ui` | yes | **Cross-cutting** shared composables only, organized by area: `ui/components`, `ui/theme`, `ui/signing`, `ui/thread`, `ui/note`, `ui/richtext`, `ui/search`, `ui/notifications`, `ui/screens`, `ui/layouts`, `ui/markdown`, `ui/privacylock`, plus Compose helpers in `ui/state` (cached-state) and `ui/text` (TextField extensions). Feature-specific UI lives in `<feature>/ui`, **not** here. **Exception:** `ui/feeds` in *this* module holds the headless feed DAL (`FeedFilter`, `AdditiveFeedFilter`, `ChangesFlowFilter`, `FeedContentState`, `RepostRenderability`…) — see debt §4; the `ui/feeds` composables (`NewPostsChip`, `RelayReachMarker`…) are in `commonsUI`. Likewise `ui/note/ParentNote`+`ReplyContext` (pure thread logic) stay here. |
| `nip23LongContent` | yes | Long-form (NIP-23) article UI: `nip23LongContent/ui/article` (reader) + `…/ui/editor` (authoring). The model lives in `model/nip23LongContent`. | | `nip23LongContent` | yes | Long-form (NIP-23) article UI: `nip23LongContent/ui/article` (reader) + `…/ui/editor` (authoring). The model lives in `model/nip23LongContent` (here). |
| `icons` | yes | `ImageVector` icon definitions + builders. | | `icons` | yes | `ImageVector` icon definitions + builders, Material Symbols codepoints, the icon-font glyph tables. |
| `hashtags` | yes | Custom hashtag `ImageVector`s. | | `hashtags` | yes | Custom hashtag `ImageVector`s. |
| `robohash` | yes | Procedural robohash avatar `ImageVector` assembly. | | `robohash` | yes | Procedural robohash avatar `ImageVector` assembly. |
| `audio` | mixed | Spectrum/visualizer *data* (`AudioSpectrum`, `SpectrumAnalyzer`…) here; the `VisualizerRenderer`s, `VisualizerRegistry` and the canvas composables in `commonsUI`. |
| `service/image` | mixed | `CoilImageBridge` + the BlurHash/ThumbHash/Base64/Blossom Coil fetchers are `commonsUI` (they are Coil); the headless image helpers stay here. |
| `napplet` | mixed | Protocol/permission logic here; `NappletWebContract` (serves the shell/shim from `composeResources`) in `commonsUI`. |
| `favorites`, `nip30CustomEmojis`, `nip34Git`, `nip85TrustedAssertions`, `nip53LiveActivities` | mixed | Logic here; each feature's `ui/` (or the flat `FavoriteAppIcon`, `EmojiSuggestionState`) in `commonsUI`. |
### Mixed (documented debt — see §4) ### Mixed (documented debt — see §4)
| Package | UI? | Purpose | | Package | UI? | Purpose |
|----------------|-----|---------| |----------------|-----|---------|
| `nip64Chess` | mixed | Live-chess feature: game/lobby/subscription logic **and** board/lobby composables in one flat package. Needs a `nip64Chess/ui` split. (Mirrors `quartz/.../nip64Chess`.) | | `nip64Chess` | mixed | Live-chess feature: game/lobby/subscription logic (here) **and** board/lobby composables (`commonsUI`) in one flat package. Still wants a `nip64Chess/ui` sub-package rename. (Mirrors `quartz/.../nip64Chess`.) |
| `domain` | no | Currently only `domain/nip46` (Nostr Connect signer flows). Sparse; candidate to fold into a clearer home. | | `domain` | no | Currently only `domain/nip46` (Nostr Connect signer flows). Sparse; candidate to fold into a clearer home. |
--- ---
@@ -185,11 +200,15 @@ Instead, **layer is the primary axis, NIP is the secondary axis**:
| Source set | For | | Source set | For |
|---------------|-----| |---------------|-----|
| `commonMain` | KMP code for **all** targets (Android, JVM, iOS). Gated by `verifyKmpPurity` — no Jackson/OkHttp/`System.currentTimeMillis`/`java.util.UUID`/JVM `@Synchronized`/`@Volatile`. Use the KMP replacements. | | `commonMain` | KMP code for **all** targets (Android, JVM, iOS). Gated by `verifyKmpPurity` — no Jackson/OkHttp/`System.currentTimeMillis`/`java.util.UUID`/JVM `@Synchronized`/`@Volatile`. Use the KMP replacements. |
| `jvmAndroid` | Shared by Android + Desktop, **not** iOS. Where JVM-bound deps live (`nestsClient`, Coil-OkHttp, markdown, `viewModel()` helper, NWC/LNURL). | | `jvmAndroid` | Shared by Android + Desktop, **not** iOS. Where JVM-bound deps live (`nestsClient`, OkHttp, NWC/LNURL). |
| `jvmMain` | Desktop-only (keyring, EXIF, `service/upload`). `dependsOn(jvmAndroid)`. | | `jvmMain` | Desktop-only (keyring, EXIF, `service/upload`, OS notifications). `dependsOn(jvmAndroid)`. |
| `androidMain` | Android-only (Keystore, DataStore). `dependsOn(jvmAndroid)`. | | `androidMain` | Android-only (Keystore, DataStore, the Android `R` string resources used by the napplet host). `dependsOn(jvmAndroid)`. |
| `iosMain` | iOS `actual`s. Compile-only spike today. | | `iosMain` | iOS `actual`s. Compile-only spike today. |
`commonsUI` mirrors the same source-set layout (plus `skikoMain`, shared by
desktop JVM + iOS for `org.jetbrains.skia` pixel helpers); Coil-OkHttp,
markdown and the `viewModel()` helper live in its `jvmAndroid`.
When adding platform code, prefer the **most common** source set that still When adding platform code, prefer the **most common** source set that still
compiles: `commonMain` → `jvmAndroid` → platform-specific. See compiles: `commonMain` → `jvmAndroid` → platform-specific. See
`/kotlin-multiplatform`. `/kotlin-multiplatform`.
@@ -197,7 +216,8 @@ compiles: `commonMain` → `jvmAndroid` → platform-specific. See
### Where does my code go? (quick guide) ### Where does my code go? (quick guide)
1. **Pure Nostr protocol** (events/NIPs/crypto)? → not here, it's `quartz`. 1. **Pure Nostr protocol** (events/NIPs/crypto)? → not here, it's `quartz`.
2. **A composable** rendered by ≥2 front ends, or that you want iOS to share? → 2. **A composable** rendered by ≥2 front ends, or that you want iOS to share? →
`ui/<area>` or `<feature>/ui`. Never in `cli`. `commonsUI`, in `ui/<area>` or `<feature>/ui` (same package tree as here).
Also anything that imports Coil, `Res`, or a `foundation`/`ui` state type.
3. **A ViewModel / `StateFlow` state holder**? → `viewmodels` or `state` (or 3. **A ViewModel / `StateFlow` state holder**? → `viewmodels` or `state` (or
`<feature>` if feature-scoped). Keep it CLI-safe where practical. `<feature>` if feature-scoped). Keep it CLI-safe where practical.
4. **Relay subscription / filter assembly**? → `relayClient`. 4. **Relay subscription / filter assembly**? → `relayClient`.
@@ -212,14 +232,17 @@ compiles: `commonMain` → `jvmAndroid` → platform-specific. See
These are intentionally *documented*, not silently tolerated. Fix opportunistically. These are intentionally *documented*, not silently tolerated. Fix opportunistically.
- **`nip64Chess` is UI+logic in one flat package.** `LiveChessGame.kt` mixes a - **`nip64Chess` is UI+logic in one flat package.** The composables now sit in
state class with composables. Split into `nip64Chess/` (logic) + `commonsUI` (module split), but they keep the flat `nip64Chess` package;
`nip64Chess/ui/` (composables); this needs file-level surgery (extracting renaming them into `nip64Chess/ui/` is the remaining step.
composables out of logic files), not just moves, so it is deferred. - **`ui/feeds` (in `commons`) holds the feed data-access layer** (`FeedFilter`,
- **`ui/feeds` holds the feed data-access layer** (`FeedFilter`,
`ChangesFlowFilter`, `FeedContentState`), which is logic, not UI, and overlaps `ChangesFlowFilter`, `FeedContentState`), which is logic, not UI, and overlaps
conceptually with the top-level `feeds` (custom-feed definitions). Consider conceptually with the top-level `feeds` (custom-feed definitions). Since the
moving the DAL out of `ui/`. module split it is the one `ui.*` package that is *also* in `commons`. Move
the DAL out of `ui/` (a package rename touching app imports) when convenient.
- **Same package tree in two modules.** Intentional (zero-import-churn split),
but it means a package's module is not visible from its name. Rule of
thumb: if it imports Compose UI it is in `commonsUI`; check §2 when unsure.
- **`domain` is sparse** (only `nip46`). Either grow it as the home for - **`domain` is sparse** (only `nip46`). Either grow it as the home for
use-case/flow types or rename it to the matching `nip46RemoteSigner` per the use-case/flow types or rename it to the matching `nip46RemoteSigner` per the
NIP-second-axis rule. NIP-second-axis rule.
+21 -98
View File
@@ -1,29 +1,20 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.DisableCacheInKotlinVersion
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeCacheApi
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable
// Disables the Kotlin/Native compiler cache for an iOS test binary so the
// Compose ui-uikit klib recompiles fresh instead of linking the broken prebuilt
// cache (see the call site in the `kotlin {}` block). The version guard makes
// Kotlin re-surface this workaround once we move past 2.4.20, so it can be
// dropped when a newer Compose/Kotlin pairing fixes the cache. Wrapped in a
// helper because @OptIn only applies to declarations, not bare statements.
@OptIn(KotlinNativeCacheApi::class)
fun TestExecutable.disableUiKitPrebuiltCache() =
disableNativeCache(
DisableCacheInKotlinVersion.`2_4_20`,
"Compose ui-uikit prebuilt cache references UIViewLayoutRegion (iOS 17+); " +
"linking the iOS test binary fails under Xcode 16.4.",
)
// `:commons` is the HEADLESS half of the shared layer: domain models, state
// holders, ViewModels, the relay client, services. It is consumed by every
// front end including the headless `:cli`, so it must never depend on Compose
// UI (ui / foundation / material3), Coil, Compose resources or Skiko — those
// live in `:commonsUI`, which sits on top of this module. Only the Compose
// *runtime* (@Stable/@Immutable + snapshot state) is allowed here.
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.androidKotlinMultiplatformLibrary)
// Kept on purpose even though no @Composable lives here anymore: the
// Compose compiler stamps @StabilityInferred on every class it compiles,
// which is what lets the apps' composables treat commons models (Note,
// User, states) as stable/skippable. Dropping it would silently make all
// of them "unstable" from the UI's point of view.
alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.serialization) alias(libs.plugins.serialization)
} }
@@ -70,45 +61,20 @@ kotlin {
iosArm64() iosArm64()
iosSimulatorArm64() iosSimulatorArm64()
// Compose Multiplatform 1.11.x ships an `org.jetbrains.compose.ui:ui-uikit`
// prebuilt Kotlin/Native cache whose CMPLayoutRegion object hard-references
// the UIKit class `UIViewLayoutRegion` (introduced in iOS 17). Linking the
// iOS *test* executable against that cache under Xcode 16.4 fails with
// ld: Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion
// because the cached object was built for a newer simulator SDK (18.5) than
// the test binary is being linked for (14.0). Disabling the native cache for
// the iOS test binaries makes ui-uikit recompile against the active SDK,
// where the symbol resolves. See disableUiKitPrebuiltCache() above and
// https://kotl.in/disable-native-cache
targets.withType<KotlinNativeTarget>().configureEach {
binaries.withType<TestExecutable>().configureEach {
disableUiKitPrebuiltCache()
}
}
sourceSets { sourceSets {
commonMain { commonMain {
dependencies { dependencies {
implementation(project(":quartz")) implementation(project(":quartz"))
// Compose Multiplatform // Compose *runtime* only — @Stable/@Immutable annotations and
implementation(libs.jetbrains.compose.ui) // snapshot state (mutableStateOf, State) used by state holders.
implementation(libs.jetbrains.compose.foundation) // No ui / foundation / material3 here: that is :commonsUI.
implementation(libs.jetbrains.compose.runtime) implementation(libs.jetbrains.compose.runtime)
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui.tooling.preview)
// Lifecycle (KMP since 2.8.0). lifecycle-viewmodel and // Lifecycle ViewModel (KMP since 2.8.0, ships iOS variants).
// lifecycle-runtime-compose ship iOS variants; // The Compose-side helpers (lifecycle-runtime-compose,
// lifecycle-viewmodel-compose (the viewModel() Composable // viewModel()) live in :commonsUI.
// helper) is Android-only and lives in jvmAndroid below.
implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
// Image loading (Coil 3 - KMP). The okhttp network fetcher is
// JVM-only and lives in jvmAndroid; iOS will pull coil-ktor
// when that target wires its actual.
implementation(libs.coil.compose)
// LruCache (KMP-ready) // LruCache (KMP-ready)
implementation(libs.androidx.collection) implementation(libs.androidx.collection)
@@ -119,12 +85,6 @@ kotlin {
// JSON for custom-feed definitions (KMP — replaces Jackson // JSON for custom-feed definitions (KMP — replaces Jackson
// for the one commonMain serializer that was blocking iOS). // for the one commonMain serializer that was blocking iOS).
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// Compose Multiplatform Resources
implementation(libs.jetbrains.compose.components.resources)
// KMP syntax highlighter (Apache-2.0) for the git code browser.
implementation(libs.highlights)
} }
} }
@@ -149,39 +109,17 @@ kotlin {
// Phase 5 lands. // Phase 5 lands.
implementation(project(":nestsClient")) implementation(project(":nestsClient"))
// Coil's OkHttp network fetcher (JVM-only). iOS will use
// coil-ktor when the iOS Compose UI ships.
implementation(libs.coil.okhttp)
// OkHttp (+ coroutines bridge) for the link-preview fetcher // OkHttp (+ coroutines bridge) for the link-preview fetcher
// (service/preview/UrlPreview). JVM-only; iOS will swap to // (service/preview/UrlPreview). JVM-only; iOS will swap to
// Ktor when its UI ships. // Ktor when its UI ships.
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.okhttpCoroutines) implementation(libs.okhttpCoroutines)
// Markdown rendering (richtext-commonmark). The single
// consumer (RenderMarkdown.kt) already lives in jvmAndroid.
// iOS support pending Phase 3 markdown decision.
implementation(libs.markdown.commonmark)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
// viewModel() Compose helper. AndroidX publishes this
// artifact for android/jvmStubs/linuxx64Stubs but not iOS,
// so it stays in jvmAndroid until we either swap to the
// org.jetbrains.androidx.lifecycle variant or accept a
// platform-specific ViewModel access pattern on iOS.
implementation(libs.androidx.lifecycle.viewmodel.compose)
} }
} }
jvmMain { jvmMain {
dependsOn(jvmAndroid) dependsOn(jvmAndroid)
dependencies { dependencies {
// Desktop-specific Compose
implementation(compose.desktop.currentOs)
implementation(libs.jetbrains.compose.ui.tooling)
// Secure key storage via OS keychain (macOS/Windows/Linux) // Secure key storage via OS keychain (macOS/Windows/Linux)
implementation(libs.java.keyring) implementation(libs.java.keyring)
@@ -205,8 +143,10 @@ kotlin {
androidMain { androidMain {
dependsOn(jvmAndroid) dependsOn(jvmAndroid)
dependencies { dependencies {
// Android-specific Compose tooling // androidx.core KTX (Bitmap.scale, prefs.edit {}) used by the
implementation(libs.androidx.ui.tooling.preview) // Android actuals. Was reaching us transitively through the
// Compose UI artifacts before the :commonsUI split.
implementation(libs.androidx.core.ktx)
// Secure key storage via Android Keystore // Secure key storage via Android Keystore
implementation(libs.androidx.security.crypto.ktx) implementation(libs.androidx.security.crypto.ktx)
@@ -222,16 +162,6 @@ kotlin {
getByName("iosArm64Main").dependsOn(iosMain) getByName("iosArm64Main").dependsOn(iosMain)
getByName("iosSimulatorArm64Main").dependsOn(iosMain) getByName("iosSimulatorArm64Main").dependsOn(iosMain)
// Skiko-backed targets (desktop JVM + iOS) share pixel-format helpers
// (org.jetbrains.skia.* resolves on both through Compose). Android is
// deliberately NOT in this set — it renders through android.graphics.
val skikoMain =
create("skikoMain") {
dependsOn(commonMain.get())
}
getByName("jvmMain").dependsOn(skikoMain)
iosMain.dependsOn(skikoMain)
getByName("androidHostTest") { getByName("androidHostTest") {
dependencies { dependencies {
implementation(libs.junit) implementation(libs.junit)
@@ -259,12 +189,6 @@ kotlin {
} }
} }
compose.resources {
publicResClass = true
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
generateResClass = always
}
// JVM tests run AWT-backed code (ImageIO, Thumbnailator, BufferedImage) — pin // JVM tests run AWT-backed code (ImageIO, Thumbnailator, BufferedImage) — pin
// headless mode so a stray Toolkit.getDefaultToolkit() in a transitive dep // headless mode so a stray Toolkit.getDefaultToolkit() in a transitive dep
// never bounces the macOS Dock during CI/local test runs. // never bounces the macOS Dock during CI/local test runs.
@@ -287,7 +211,6 @@ val verifyKmpPurity by tasks.registering {
"src/appleMain", "src/appleTest", "src/appleMain", "src/appleTest",
"src/nativeMain", "src/nativeTest", "src/nativeMain", "src/nativeTest",
"src/iosMain", "src/iosTest", "src/iosMain", "src/iosTest",
"src/skikoMain", "src/skikoTest",
"src/iosArm64Main", "src/iosArm64Test", "src/iosArm64Main", "src/iosArm64Test",
"src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test", "src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test",
"src/linuxMain", "src/linuxTest", "src/linuxMain", "src/linuxTest",
@@ -0,0 +1,89 @@
# Split `commons` into `commons` (headless) + `commonsUI` (Compose)
**Date:** 2026-09-12
**Status:** shipped on this branch
## Why
`cli` (`amy`) depends on `:commons`. Until now `:commons` declared Compose
UI, Coil, Compose resources, markdown and (on JVM) `compose.desktop.currentOs`
as dependencies, so every CLI distribution dragged ~40 MB of Compose + Skiko
jars it never loads (`create-release.yml` had a 200 MB budget "until commons
is split into core + ui modules"; `BUILDING.md` flagged the same for the
Homebrew bundle). The UI / non-UI boundary documented in
`commons/ARCHITECTURE.md` §1 was a convention enforced by nothing.
## What
A new KMP module **`:commonsUI`** (same targets and source-set layout as
`:commons`, plus `skikoMain`) that `api`-depends on `:commons` and owns every
Compose-dependent file. `:commons` keeps only the Compose *runtime*
(`@Stable`/`@Immutable`, snapshot state) and `lifecycle-viewmodel`; it no
longer applies the `org.jetbrains.compose` plugin, has no `composeResources`,
no Coil, no markdown, no `highlights`, no desktop Compose.
**Files keep their packages.** The split is a build-graph boundary, not a
package rename: `231` files were `git mv`'d from `commons/src/…` to
`commonsUI/src/…` and not a single import changed in `amethyst`,
`desktopApp`, `nappletHost` or `cli`. The generated `Res` class stays at
`com.vitorpamplona.amethyst.commons.resources` for the same reason.
Consumers: `amethyst`, `desktopApp`, `nappletHost` (for
`NappletWebContract`, which reads the shell/shim from `composeResources`) and
`benchmark` (robohash) gained `project(":commonsUI")`. `cli`, `geode`,
`marmotBench` are untouched and must stay that way.
## Method (reproducible)
1. Classify every `.kt` under `commons/src` as **UI** if it imports
`androidx.compose.{ui,foundation,material3,animation}`,
`org.jetbrains.compose.*`, `coil3`, `org.jetbrains.skia`,
`androidx.lifecycle.compose`, `…commons.resources`, or declares
`@Composable`.
2. Build the intra-module reference graph (imports + same-package simple-name
hits) and verify **no headless file references a UI file**. Two real hits
surfaced and were fixed by surgery rather than by moving logic into the UI
module:
- `richtext/GalleryParser` carried a vestigial
`@OptIn(ExperimentalLayoutApi::class)` — dropped; it is pure logic.
- `privacylock/PrivacyLockState` declared the `LocalPrivacyLockState`
CompositionLocal + `lockStateFor()` next to the state machine — the two
Compose members moved to `commonsUI/…/privacylock/LocalPrivacyLockState.kt`
(same package).
3. Additionally move the rest of the UI-only packages (`ui/**`, `icons`,
`hashtags`, `robohash`, `<feature>/ui`) **unless a headless file depends
on the file**. That rule keeps the feed DAL (`ui/feeds/FeedFilter`,
`AdditiveFeedFilter`, `ChangesFlowFilter`, `FeedContentState`,
`RepostRenderability`, `AdditiveComplexFeedFilter`…) and
`ui/note/ParentNote`+`ReplyContext` in `commons` (ViewModels use them).
4. Tests follow their subject; test fixtures shared with headless tests
(`ui/note/StubCache`) stay in `commons`.
5. Check `expect`/`actual` pairs never straddle the boundary (none did) and
that no `internal` declaration is used across it (none was).
## Verified
- `:commons:compileKotlinJvm`, `:commonsUI:compileKotlinJvm`,
`:cli:compileKotlin`, `:desktopApp:compileKotlin`,
`:nappletHost:compileDebugKotlin`, `:amethyst:compileFdroidDebugKotlin`.
- `:commons:jvmTest`, `:commonsUI:jvmTest`, `:cli:test`,
`:commons:verifyKmpPurity`, `:commonsUI:verifyKmpPurity`.
- `:cli` runtime classpath no longer contains `org.jetbrains.compose.ui`,
`foundation`, `material3`, `skiko` or `coil`.
iOS targets could not be linked in the Linux CI container; the workflow runs
`:commonsUI:iosSimulatorArm64Test` + `:commonsUI:compileTestKotlinIosArm64`
next to the `:commons` ones on macOS.
## Follow-ups
- **Tighten the CLI size budget** in `create-release.yml` once a release
confirms the new `amy` tarball size (target < 80 MB per
`cli/plans/2026-04-21-cli-distribution.md`).
- **Rename the feed DAL out of `ui.feeds`** (it is the one `ui.*` package
that still lives in `commons`); a package rename that touches app imports.
- **`nip64Chess` → `nip64Chess/ui`** for the composables now in `commonsUI`
(module split done, package rename pending).
- `commons` still applies the Compose *compiler* plugin on purpose (stability
inference for its model classes as seen from the apps' composables).
Revisit if a `runtime-annotation`-only setup proves sufficient.
+6 -1
View File
@@ -1,6 +1,6 @@
# commons plans # commons plans
_Audited 2026-06-30. 6 plans: 2 shipped (archived), 2 in-progress, 2 queued, 0 abandoned._ _Audited 2026-06-30 (+ 2026-09-12 split entry). 7 plans: 3 shipped, 2 in-progress, 2 queued, 0 abandoned._
## In progress ## In progress
| Plan | Summary | | Plan | Summary |
@@ -15,6 +15,11 @@ _Audited 2026-06-30. 6 plans: 2 shipped (archived), 2 in-progress, 2 queued, 0 a
| [2026-05-30-amethyst-to-commons-migration.md](2026-05-30-amethyst-to-commons-migration.md) | Roadmap to move shared `amethyst` Android code into `commons`; keystone `Account`/`LocalCache` extraction not begun. | | [2026-05-30-amethyst-to-commons-migration.md](2026-05-30-amethyst-to-commons-migration.md) | Roadmap to move shared `amethyst` Android code into `commons`; keystone `Account`/`LocalCache` extraction not begun. |
| [2026-08-03-poll-results-page.md](2026-08-03-poll-results-page.md) | Extended NIP-88 poll results page (per-option counts + who voted for what) for Android and Desktop; also specifies four tally-correctness fixes and the missing poll-relay subscription. Proposed, not started. | | [2026-08-03-poll-results-page.md](2026-08-03-poll-results-page.md) | Extended NIP-88 poll results page (per-option counts + who voted for what) for Android and Desktop; also specifies four tally-correctness fixes and the missing poll-relay subscription. Proposed, not started. |
## Shipped
| Plan | Summary |
| ---- | ------- |
| [2026-09-12-commons-ui-split.md](2026-09-12-commons-ui-split.md) | Split the Compose half of `commons` into the new `:commonsUI` module (same packages, `api(:commons)`), so `cli` no longer carries Compose/Skiko; records the classification method and follow-ups. |
## Archived (shipped) ## Archived (shipped)
| Plan | Summary | | Plan | Summary |
| ---- | ------- | | ---- | ------- |
@@ -20,9 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.commons.privacylock package com.vitorpamplona.amethyst.commons.privacylock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.compositionLocalOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -174,23 +171,3 @@ class PrivacyLockState(
idleTimerJob = null idleTimerJob = null
} }
} }
/**
* Provided once at the App composition root. Map keyed by [LockScope]; every
* scope must have an entry (see [lockStateFor] which throws when missing).
*/
val LocalPrivacyLockState =
compositionLocalOf<Map<LockScope, PrivacyLockState>> {
error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider")
}
/**
* Convenience accessor used inside gate composables. Reads the map from the
* ambient [LocalPrivacyLockState] and returns the state holder for [scope].
* Throws if the scope was not registered at the App root.
*/
@Composable
@ReadOnlyComposable
fun lockStateFor(scope: LockScope): PrivacyLockState =
LocalPrivacyLockState.current[scope]
?: error("PrivacyLockState for $scope not registered at App root")
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.commons.richtext package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
data class ParagraphImageAnalysis( data class ParagraphImageAnalysis(
@@ -106,7 +105,6 @@ class GalleryParser {
return imageParagraphs to j return imageParagraphs to j
} }
@OptIn(ExperimentalLayoutApi::class)
fun processParagraphs(paragraphs: List<ParagraphState>): List<ParagraphState> { fun processParagraphs(paragraphs: List<ParagraphState>): List<ParagraphState> {
val result = mutableListOf<ParagraphState>() val result = mutableListOf<ParagraphState>()
+1
View File
@@ -0,0 +1 @@
/build
+64
View File
@@ -0,0 +1,64 @@
# `commonsUI` — the Compose half of the shared layer
`commonsUI` holds every piece of shared code that needs **Compose UI** and is
therefore useless to the headless `cli`:
- composables (`ui/<area>`, `<feature>/ui`, and the historical flat feature
packages like `nip64Chess`, `audio`),
- `ImageVector` icons (`icons`, `hashtags`, `robohash`) and the icon fonts,
- theme, layouts, markdown rendering (`ui/markdown`, jvmAndroid),
- Coil (`service/image`: `CoilImageBridge` + BlurHash/ThumbHash/Base64/Blossom
fetchers),
- the `composeResources` tree (translated strings, fonts, the napplet shell +
shim files) and the generated `com.vitorpamplona.amethyst.commons.resources.Res`,
- the `@Composable` relay-client entry points (`observeUser*`,
`*FilterAssemblerSubscription`, `KeyDataSourceSubscription`),
- state holders that carry a `foundation`/`ui` type (`LevelFeedViewModel` with
its `LazyListState`, `ChatNewMessageState` with `TextFieldValue`,
`EmojiSuggestionState` with `TextFieldState`).
It depends on `:commons` (and `:quartz`) as **`api`**, so a consumer that adds
`:commonsUI` sees the headless layer transitively. `amethyst`, `desktopApp`,
`nappletHost` and `benchmark` depend on it; `cli`, `geode`, `marmotBench`
must never.
## Same packages as `commons`
Every file keeps its `com.vitorpamplona.amethyst.commons.*` package. The split
is a **build-graph boundary, not a package rename**: nothing in the apps had to
change an import, and a file can move between the two modules with a `git mv`.
The rule for which module a file lives in is mechanical:
> imports `androidx.compose.ui` / `foundation` / `material3` / `animation`,
> Coil, `org.jetbrains.compose.resources`, `Res`, `org.jetbrains.skia`, or
> declares a `@Composable` → **`commonsUI`**. Otherwise → **`commons`**.
The only cross-module gotcha is `internal`: a `commonsUI` file cannot see an
`internal` declaration in `commons`. Make it public (or move the caller).
The full package taxonomy — including which packages are "mixed" (logic in
`commons`, composables here, same package name) — is the table in
`commons/ARCHITECTURE.md` §2. Read that first; this file only documents what
is specific to the UI module.
## Source sets
| Source set | For |
|---------------|-----|
| `commonMain` | Composables, icons, theme, Coil fetchers, `composeResources`. Gated by `verifyKmpPurity` like `commons`. |
| `jvmAndroid` | Markdown renderer (`ui/markdown`), Coil-OkHttp + Blossom read-auth fetcher, the `viewModel()` helper. |
| `jvmMain` | Desktop Coil bridge (`CoilImageBridge.jvm.kt`), `compose.desktop.currentOs`. `dependsOn(jvmAndroid)` + `skikoMain`. |
| `androidMain` | Android Coil bridge. `dependsOn(jvmAndroid)`. |
| `skikoMain` | `org.jetbrains.skia` pixel helpers shared by desktop JVM + iOS (`SkiaBitmapConverter`). |
| `iosMain` | iOS Coil bridge. Compile-only spike today. |
## Tooling that points here
- Icon fonts: `tools/material-symbols-subset/subset.sh` and
`tools/icon-font/build_icon_font.py` read `icons/` and write
`composeResources/font/` in this module (see root `.claude/CLAUDE.md`, "Icons").
- Translations: `crowdin.yml` and `tools/strings-migrate/` target
`commonsUI/src/commonMain/composeResources/`.
- CI: `.github/workflows/build.yml` runs `:commonsUI:jvmTest`,
`:commonsUI:verifyKmpPurity` and the iOS compile/test tasks next to the
`:commons` ones.
+293
View File
@@ -0,0 +1,293 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.DisableCacheInKotlinVersion
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeCacheApi
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable
// `:commonsUI` is the Compose half of the shared layer: every composable,
// icon, theme, Coil fetcher and Compose-resource (strings/fonts/files) that the
// GUI front ends (Android, Desktop, iOS) render. It sits on top of `:commons`
// (headless models, state, ViewModels, relay client) and is never a dependency
// of `:cli`, which keeps Compose UI + Skiko off the CLI classpath.
// Source files keep their `com.vitorpamplona.amethyst.commons.*` packages so
// the module boundary is purely a build-graph constraint — consumers did not
// have to change a single import when the split happened.
// Disables the Kotlin/Native compiler cache for an iOS test binary so the
// Compose ui-uikit klib recompiles fresh instead of linking the broken prebuilt
// cache (see the call site in the `kotlin {}` block). The version guard makes
// Kotlin re-surface this workaround once we move past 2.4.20, so it can be
// dropped when a newer Compose/Kotlin pairing fixes the cache. Wrapped in a
// helper because @OptIn only applies to declarations, not bare statements.
@OptIn(KotlinNativeCacheApi::class)
fun TestExecutable.disableUiKitPrebuiltCache() =
disableNativeCache(
DisableCacheInKotlinVersion.`2_4_20`,
"Compose ui-uikit prebuilt cache references UIViewLayoutRegion (iOS 17+); " +
"linking the iOS test binary fails under Xcode 16.4.",
)
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidKotlinMultiplatformLibrary)
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.composeMultiplatform)
}
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
jvm {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
android {
namespace = "com.vitorpamplona.amethyst.commons.ui"
compileSdk =
libs.versions.android.compileSdk
.get()
.toInt()
minSdk =
libs.versions.android.minSdk
.get()
.toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
androidResources.enable = true
withHostTest {
isReturnDefaultValues = true
}
withDeviceTest {
instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
}
// iOS targets — same compile-only spike as :commons.
iosArm64()
iosSimulatorArm64()
// Compose Multiplatform 1.11.x ships an `org.jetbrains.compose.ui:ui-uikit`
// prebuilt Kotlin/Native cache whose CMPLayoutRegion object hard-references
// the UIKit class `UIViewLayoutRegion` (introduced in iOS 17). Linking the
// iOS *test* executable against that cache under Xcode 16.4 fails with
// ld: Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion
// because the cached object was built for a newer simulator SDK (18.5) than
// the test binary is being linked for (14.0). Disabling the native cache for
// the iOS test binaries makes ui-uikit recompile against the active SDK,
// where the symbol resolves. See disableUiKitPrebuiltCache() above and
// https://kotl.in/disable-native-cache
targets.withType<KotlinNativeTarget>().configureEach {
binaries.withType<TestExecutable>().configureEach {
disableUiKitPrebuiltCache()
}
}
sourceSets {
commonMain {
dependencies {
// The headless half. `api` because every composable here takes
// or returns commons types (Note, User, ViewModels, states).
api(project(":commons"))
api(project(":quartz"))
// Compose Multiplatform
implementation(libs.jetbrains.compose.ui)
implementation(libs.jetbrains.compose.foundation)
implementation(libs.jetbrains.compose.runtime)
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui.tooling.preview)
// Lifecycle (KMP since 2.8.0). lifecycle-runtime-compose ships
// iOS variants; lifecycle-viewmodel-compose (the viewModel()
// Composable helper) is Android-only and lives in jvmAndroid.
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
// Image loading (Coil 3 - KMP). The okhttp network fetcher is
// JVM-only and lives in jvmAndroid; iOS will pull coil-ktor
// when that target wires its actual.
implementation(libs.coil.compose)
// LruCache (KMP-ready)
implementation(libs.androidx.collection)
// Immutable collections
api(libs.kotlinx.collections.immutable)
// Compose Multiplatform Resources (strings, fonts, napplet shell files)
implementation(libs.jetbrains.compose.components.resources)
// KMP syntax highlighter (Apache-2.0) for the git code browser.
implementation(libs.highlights)
}
}
commonTest {
dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
}
// Shared JVM code for both Android and Desktop
val jvmAndroid =
create("jvmAndroid") {
dependsOn(commonMain.get())
dependencies {
// Coil's OkHttp network fetcher (JVM-only). iOS will use
// coil-ktor when the iOS Compose UI ships.
implementation(libs.coil.okhttp)
// OkHttp for the Blossom read-auth Coil fetcher.
implementation(libs.okhttp)
// Markdown rendering (richtext-commonmark). The single
// consumer (RenderMarkdown.kt) lives in jvmAndroid.
// iOS support pending Phase 3 markdown decision.
implementation(libs.markdown.commonmark)
implementation(libs.markdown.ui)
implementation(libs.markdown.ui.material3)
// viewModel() Compose helper. AndroidX publishes this
// artifact for android/jvmStubs/linuxx64Stubs but not iOS,
// so it stays in jvmAndroid until we either swap to the
// org.jetbrains.androidx.lifecycle variant or accept a
// platform-specific ViewModel access pattern on iOS.
implementation(libs.androidx.lifecycle.viewmodel.compose)
}
}
jvmMain {
dependsOn(jvmAndroid)
dependencies {
// Desktop-specific Compose
implementation(compose.desktop.currentOs)
implementation(libs.jetbrains.compose.ui.tooling)
}
}
androidMain {
dependsOn(jvmAndroid)
dependencies {
// Android-specific Compose tooling
implementation(libs.androidx.ui.tooling.preview)
}
}
// iOS intermediate so iosArm64Main and iosSimulatorArm64Main share code.
val iosMain =
create("iosMain") {
dependsOn(commonMain.get())
}
getByName("iosArm64Main").dependsOn(iosMain)
getByName("iosSimulatorArm64Main").dependsOn(iosMain)
// Skiko-backed targets (desktop JVM + iOS) share pixel-format helpers
// (org.jetbrains.skia.* resolves on both through Compose). Android is
// deliberately NOT in this set — it renders through android.graphics.
val skikoMain =
create("skikoMain") {
dependsOn(commonMain.get())
}
getByName("jvmMain").dependsOn(skikoMain)
iosMain.dependsOn(skikoMain)
getByName("androidHostTest") {
dependencies {
implementation(libs.junit)
}
}
getByName("androidDeviceTest") {
dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.espresso.core)
}
}
}
}
compose.resources {
publicResClass = true
// Kept on the pre-split package so `Res` imports in every consumer
// (amethyst, desktopApp, nappletHost) keep resolving unchanged.
packageOfResClass = "com.vitorpamplona.amethyst.commons.resources"
generateResClass = always
}
// iOS purity gate — same shape as :quartz / :commons verifyKmpPurity.
// commonMain here must stay free of JVM-only JSON / HTTP deps.
val verifyKmpPurity by tasks.registering {
group = "verification"
description = "Fails if iOS-targeted source sets import JVM-only deps."
val checkedDirs =
listOf(
"src/commonMain", "src/commonTest",
"src/appleMain", "src/appleTest",
"src/nativeMain", "src/nativeTest",
"src/iosMain", "src/iosTest",
"src/skikoMain", "src/skikoTest",
"src/iosArm64Main", "src/iosArm64Test",
"src/iosSimulatorArm64Main", "src/iosSimulatorArm64Test",
"src/linuxMain", "src/linuxTest",
"src/linuxX64Main", "src/linuxX64Test",
"src/macosMain", "src/macosTest",
"src/macosArm64Main", "src/macosArm64Test",
).map { layout.projectDirectory.dir(it).asFile }
.filter { it.exists() }
inputs.files(checkedDirs)
doLast {
// Each pattern is paired with a short hint so the failure message
// points at the canonical KMP replacement.
val forbidden =
listOf(
"com.fasterxml.jackson" to "Jackson is JVM-only — use kotlinx.serialization",
"okhttp3" to "OkHttp is JVM-only — wrap behind expect/actual or use Ktor on iOS",
"System.currentTimeMillis" to "use TimeUtils.now()",
"Thread.sleep" to "use kotlinx.coroutines.delay or platform-specific actual",
"java.util.UUID" to "use kotlin.uuid.Uuid",
"kotlin.jvm.Synchronized" to "use KmpLock.withLock {}",
// The bare call, not just the annotation: `synchronized(lock) {}` resolves
// from kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and
// only fails at the iOS compile step. Catch it here instead.
"synchronized(" to "`synchronized` is JVM-only — use KmpLock.withLock {}",
"kotlin.jvm.Volatile" to "use kotlin.concurrent.Volatile",
)
val offenders =
checkedDirs.flatMap { dir ->
dir.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.flatMap { file ->
file.readLines().withIndex().mapNotNull { (idx, line) ->
val trimmed = line.trimStart()
// Skip KDoc / line-comment lines — those legitimately
// mention forbidden names (migration notes, doc refs).
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
return@mapNotNull null
}
forbidden.firstOrNull { (pattern, _) -> line.contains(pattern) }?.let { (hit, hint) ->
"${file.relativeTo(rootDir)}:${idx + 1}: '$hit' — $hint"
}
}
}
}
if (offenders.isNotEmpty()) {
throw GradleException(
"iOS-targeted source sets must not reference JVM-only APIs. " +
"Move the offending code to jvmAndroid/ or behind an expect/actual:\n " +
offenders.joinToString("\n "),
)
}
}
}
tasks.named("check").configure { dependsOn(verifyKmpPurity) }
View File
+24
View File
@@ -0,0 +1,24 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-keep class com.vitorpamplona.quartz.** { *; }
-keep class com.vitorpamplona.amethyst.** { *; }
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>

Some files were not shown because too many files have changed in this diff Show More