mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
+43
-25
@@ -3,7 +3,7 @@
|
||||
## Project Overview
|
||||
|
||||
Amethyst is a Nostr Client for Android that was made for Android-only and has been slowly switching
|
||||
over to a Kotlin Multiplatform project. The main modules are: `quartz`, `commons`, `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
|
||||
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
|
||||
@@ -48,11 +48,18 @@ amethyst/
|
||||
│ ├── androidMain/ # Android-specific (crypto, storage)
|
||||
│ ├── jvmMain/ # Desktop JVM-specific
|
||||
│ └── iosMain/ # iOS-specific
|
||||
├── commons/ # Shared UI components (convert to KMP)
|
||||
├── commons/ # Shared HEADLESS layer (models, state, ViewModels, relay client) — CLI-safe
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # Shared composables, icons, state
|
||||
│ ├── androidMain/ # Android-specific UI utilities
|
||||
│ └── jvmMain/ # Desktop-specific UI utilities
|
||||
│ ├── commonMain/ # Domain models, state holders, ViewModels, services
|
||||
│ ├── jvmAndroid/ # JVM-bound services shared by Android + Desktop
|
||||
│ ├── androidMain/ # Android-specific actuals (Keystore, DataStore)
|
||||
│ └── jvmMain/ # Desktop-specific actuals (keyring, upload pipeline)
|
||||
├── commonsUI/ # Shared Compose UI on top of commons (composables, icons, theme, Coil, resources)
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # Shared composables, icons, theme, composeResources (strings/fonts)
|
||||
│ ├── jvmAndroid/ # Markdown renderer, Coil OkHttp fetchers
|
||||
│ ├── androidMain/ # Android Coil bridge
|
||||
│ └── jvmMain/ # Desktop Coil bridge (+ skikoMain shared with iOS)
|
||||
├── quic/ # Pure-Kotlin QUIC v1 + HTTP/3 + WebTransport (audio-rooms transport)
|
||||
│ └── src/
|
||||
│ ├── commonMain/ # Protocol, frame/packet codecs, TLS state machine
|
||||
@@ -70,12 +77,20 @@ amethyst/
|
||||
|
||||
**Sharing Philosophy:**
|
||||
- `quartz/` = Nostr business logic, protocol, data (no UI)
|
||||
- `commons/` = Shared code for every front end (Android, Desktop, iOS, and the
|
||||
headless `cli`): domain models, state holders, ViewModels, the relay client,
|
||||
shared services, **and** the Compose UI that ≥1 GUI front end renders. The
|
||||
package taxonomy, the CLI-safe / UI boundary, and a "where does my code go?"
|
||||
guide are documented in **`commons/ARCHITECTURE.md`** — read it before adding
|
||||
a new package or dropping code into `commons`.
|
||||
- `commons/` = Shared **headless** code for every front end (Android, Desktop,
|
||||
iOS, and the headless `cli`): domain models, state holders, ViewModels, the
|
||||
relay client, shared services. It may use the Compose *runtime*
|
||||
(`@Stable`/`@Immutable`, snapshot state) but never Compose UI, Coil or
|
||||
Compose resources — the build enforces this: `commons` has no such deps.
|
||||
- `commonsUI/` = Shared **Compose UI** that ≥1 GUI front end renders
|
||||
(composables, `ui/theme`, icons, robohash, Coil fetchers, markdown, the
|
||||
`composeResources` strings/fonts and the generated `Res` class). Depends on
|
||||
`commons` (as `api`); `cli` never depends on it. Files keep their
|
||||
`com.vitorpamplona.amethyst.commons.*` packages — the split is a module
|
||||
boundary, not a package rename. The package taxonomy, the CLI-safe / UI
|
||||
boundary, and a "where does my code go?" guide are documented in
|
||||
**`commons/ARCHITECTURE.md`** (+ `commonsUI/ARCHITECTURE.md`) — read them
|
||||
before adding a new package or dropping code into either module.
|
||||
- `quic/` = Transport library (QUIC + HTTP/3 + WebTransport); reusable for any
|
||||
KMP project that needs MoQ. Has no Android-framework dependencies.
|
||||
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
|
||||
@@ -88,7 +103,7 @@ amethyst/
|
||||
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
|
||||
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
|
||||
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
|
||||
standalone relay); never on `:amethyst` or `:desktopApp`.
|
||||
standalone relay); never on `:commonsUI`, `:amethyst` or `:desktopApp`.
|
||||
|
||||
**Plans per module:** design docs for new subsystems live in the owning
|
||||
module's `plans/YYYY-MM-DD-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
|
||||
@@ -180,16 +195,19 @@ etc. instead of re-implementing them.
|
||||
|
||||
**Share vs keep platform-native:**
|
||||
|
||||
- **Share** → `quartz/commonMain/` (business logic, data models, protocol) and
|
||||
`commons/commonMain/` (major UI components, **ViewModels** under
|
||||
`viewmodels/`, icons). ViewModels are platform-agnostic state + logic
|
||||
(StateFlow/SharedFlow), so they belong in `commons`.
|
||||
- **Share** → `quartz/commonMain/` (business logic, data models, protocol),
|
||||
`commons/commonMain/` (**ViewModels** under `viewmodels/`, state holders,
|
||||
relay client, services — headless) and `commonsUI/commonMain/` (major UI
|
||||
components, icons, theme). ViewModels are platform-agnostic state + logic
|
||||
(StateFlow/SharedFlow), so they belong in `commons`; anything that imports
|
||||
`androidx.compose.ui`/`foundation`/`material3`, Coil, or `Res` belongs in
|
||||
`commonsUI`.
|
||||
- **Keep native** → screen composables/scaffolding (Desktop `Window` vs Android
|
||||
`Activity`), navigation (sidebar vs bottom nav), platform interactions
|
||||
(gestures, keyboard shortcuts), system integrations (notifications, file
|
||||
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
|
||||
`/kotlin-multiplatform`), then point both Android and Desktop at the shared
|
||||
version. `quartz/` is protocol-only — no composables.
|
||||
@@ -216,7 +234,7 @@ version. `quartz/` is protocol-only — no composables.
|
||||
## Dependency Licensing
|
||||
|
||||
**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
|
||||
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
|
||||
@@ -253,9 +271,9 @@ JVM). See `/kotlin-multiplatform` for the expect/actual and source-set patterns.
|
||||
## Icons
|
||||
|
||||
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
|
||||
`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
|
||||
`MaterialSymbol("\uXXXX")` codepoint that wasn't already referenced anywhere in
|
||||
@@ -274,21 +292,21 @@ regenerating.
|
||||
|
||||
### 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
|
||||
as glyphs via `AmethystIconGlyph`. Drawing an `ImageVector` rasterises its paths into
|
||||
a per-instance cached layer, so a feed re-rasterised the same glyph once per card;
|
||||
a glyph is a blit from the shared text atlas. Measured: frame P90 **-10.7%**,
|
||||
overrun P90 **-17.4%** on the feed scroll benchmark.
|
||||
|
||||
**MANDATORY:** whenever you add or change an icon under `commons/.../commons/icons/`,
|
||||
**MANDATORY:** whenever you add or change an icon under `commonsUI/.../commons/icons/`,
|
||||
regenerate the font *and* its codepoint table together:
|
||||
|
||||
```bash
|
||||
python3 tools/icon-font/build_icon_font.py \
|
||||
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons \
|
||||
commons/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 \
|
||||
commonsUI/src/commonMain/composeResources/font/amethyst_icons.ttf \
|
||||
commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/AmethystIcons.kt
|
||||
```
|
||||
|
||||
Both outputs must be committed together: codepoints are assigned in filename order,
|
||||
|
||||
@@ -21,7 +21,7 @@ that has to catch it.
|
||||
Repair with:
|
||||
|
||||
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
|
||||
idempotent, quote-unwrapping is not, and a second unwrap strips the real display
|
||||
@@ -77,7 +77,7 @@ def main() -> int:
|
||||
print(
|
||||
"\nRepair:\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.)",
|
||||
file=out,
|
||||
)
|
||||
|
||||
@@ -47,7 +47,7 @@ Walk the imports. The usual offenders:
|
||||
| `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.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
|
||||
|
||||
@@ -66,7 +66,7 @@ Walk the imports. The usual offenders:
|
||||
# Target location depends on what it is:
|
||||
# - Protocol → quartz/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 \
|
||||
commons/src/commonMain/kotlin/com/.../FollowListManager.kt
|
||||
```
|
||||
|
||||
@@ -24,7 +24,7 @@ Visual UI patterns for sharing composables across Android and Desktop.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -416,7 +416,7 @@ fun DataScreen(uiState: UiState) {
|
||||
}
|
||||
```
|
||||
|
||||
**Components** (all in `commons/commonMain`):
|
||||
**Components** (all in `commonsUI/commonMain`):
|
||||
- `LoadingState` - Progress indicator + message
|
||||
- `EmptyState` - Empty message + optional refresh button
|
||||
- `ErrorState` - Error message + optional retry button
|
||||
@@ -527,12 +527,12 @@ fun FeedList(items: List<Item>) {
|
||||
|
||||
| Task | Pattern | Location |
|
||||
|------|---------|----------|
|
||||
| Reusable UI | State hoisting | commons/commonMain |
|
||||
| Reusable UI | State hoisting | commonsUI/commonMain |
|
||||
| Simple state | remember { mutableStateOf() } | Composable scope |
|
||||
| Derived state | derivedStateOf { } | remember block |
|
||||
| Async → state | produceState { } | Composable function |
|
||||
| Custom icons | roboBuilder + PathData | commons/icons |
|
||||
| Loading/Error | LoadingState, ErrorState | commons/ui/components |
|
||||
| Custom icons | roboBuilder + PathData | commonsUI/icons |
|
||||
| Loading/Error | LoadingState, ErrorState | commonsUI/ui/components |
|
||||
| Theme colors | MaterialTheme.colorScheme | Any @Composable |
|
||||
| Navigation | Delegate to platform expert | amethyst/, desktopApp/ |
|
||||
|
||||
@@ -540,7 +540,7 @@ fun FeedList(items: List<Item>) {
|
||||
|
||||
### 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
|
||||
3. Hoist state (parameters for data, callbacks for events)
|
||||
4. Add modifier parameter
|
||||
@@ -551,7 +551,7 @@ fun FeedList(items: List<Item>) {
|
||||
|
||||
1. Read current implementation in `amethyst/` or `desktopApp/`
|
||||
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
|
||||
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 |
|
||||
|------|--------------|-----------------|
|
||||
| **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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
```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
|
||||
comm -12 \
|
||||
<(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:
|
||||
|
||||
```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.
|
||||
```
|
||||
|
||||
@@ -132,14 +132,14 @@ Default: amethyst/src/main/res/values/strings.xml
|
||||
Target: amethyst/src/main/res/values-<locale>/strings.xml
|
||||
|
||||
# commons tree
|
||||
Default: commons/src/commonMain/composeResources/values/strings.xml
|
||||
Target: commons/src/commonMain/composeResources/values-<locale>/strings.xml
|
||||
Default: commonsUI/src/commonMain/composeResources/values/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:
|
||||
|
||||
```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 ##########"
|
||||
# ... run the diff/count/value-extraction commands with $base/values[...] ...
|
||||
done
|
||||
@@ -258,8 +258,8 @@ Flag and offer to fix:
|
||||
# hardcode "1" (or other literal digits) instead of using a placeholder.
|
||||
# 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 \
|
||||
commons/src/commonMain/composeResources/values/strings.xml \
|
||||
commons/src/commonMain/composeResources/values-*/strings.xml; do
|
||||
commonsUI/src/commonMain/composeResources/values/strings.xml \
|
||||
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
|
||||
awk -v file="$f" '
|
||||
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
|
||||
in_plurals && /quantity="one"/ {
|
||||
@@ -279,8 +279,8 @@ Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-
|
||||
|
||||
```bash
|
||||
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
|
||||
commons/src/commonMain/composeResources/values/strings.xml \
|
||||
commons/src/commonMain/composeResources/values-*/strings.xml; do
|
||||
commonsUI/src/commonMain/composeResources/values/strings.xml \
|
||||
commonsUI/src/commonMain/composeResources/values-*/strings.xml; do
|
||||
# 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.)
|
||||
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.
|
||||
phre = re.compile(r'(?<!\\)%(?:(\d+)\$)?([sdf])')
|
||||
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()
|
||||
dstr = {m.group(1): sig(m.group(2)) for m in keyre.finditer(d)}
|
||||
dpl = {}
|
||||
@@ -339,7 +339,7 @@ PY
|
||||
# Empty plural items render as nothing at runtime — always a bug.
|
||||
grep -rn '<item quantity="[a-z]*"></item>' \
|
||||
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:
|
||||
@@ -528,7 +528,7 @@ When adding translated strings to locale files:
|
||||
|
||||
## 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).
|
||||
- **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.
|
||||
|
||||
@@ -383,7 +383,7 @@ import com.fasterxml.jackson.databind.ObjectMapper
|
||||
| State (business logic) | commonMain or commons/jvmAndroid | Reusable StateFlow patterns |
|
||||
| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** |
|
||||
| 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** |
|
||||
| Navigation | Platform-specific only | Activity vs Window too different |
|
||||
| Permissions | Platform-specific only | APIs incompatible |
|
||||
|
||||
@@ -17,7 +17,7 @@ The layer between `LocalCache`/`Account` and the raw relay connection. Ensures c
|
||||
|
||||
## 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/
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Linter (gradle)
|
||||
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity
|
||||
run: ./gradlew spotlessCheck :quartz:verifyKmpPurity :commons:verifyKmpPurity :commonsUI:verifyKmpPurity
|
||||
|
||||
build-desktop:
|
||||
needs: lint
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
|
||||
- name: Test + Build Desktop (gradle)
|
||||
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
|
||||
xvfb-run --auto-servernum $CMD
|
||||
else
|
||||
@@ -129,6 +129,7 @@ jobs:
|
||||
path: |
|
||||
quartz/build/reports/tests
|
||||
commons/build/reports/tests
|
||||
commonsUI/build/reports/tests
|
||||
nestsClient/build/reports/tests
|
||||
cli/build/reports/tests
|
||||
desktopApp/build/reports/tests
|
||||
@@ -307,11 +308,15 @@ jobs:
|
||||
# :commons:jvmTest stays green — this is the job that catches it.
|
||||
# - compileTestKotlinIosArm64 catches device-only compile drift
|
||||
# (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
|
||||
run: |
|
||||
./gradlew \
|
||||
:commons:iosSimulatorArm64Test \
|
||||
:commons:compileTestKotlinIosArm64
|
||||
:commons:compileTestKotlinIosArm64 \
|
||||
:commonsUI:iosSimulatorArm64Test \
|
||||
:commonsUI:compileTestKotlinIosArm64
|
||||
|
||||
- name: Upload iOS Test Reports
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -363,6 +368,7 @@ jobs:
|
||||
:amethyst:lintPlayBenchmark \
|
||||
:quartz:jvmTest \
|
||||
:commons:jvmTest \
|
||||
:commonsUI:jvmTest \
|
||||
:nestsClient:jvmTest \
|
||||
:amethyst:testFdroidDebugUnitTest \
|
||||
:amethyst:testPlayDebugUnitTest \
|
||||
|
||||
@@ -572,11 +572,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The plan at cli/plans/2026-04-21-cli-distribution.md §size-budget
|
||||
# targets < 80 MB, but :commons currently leaks Compose + Skiko as
|
||||
# transitive deps (~40 MB of unused UI jars). Budget is set to
|
||||
# 200 MB until commons is split into core + ui modules — track that
|
||||
# as a follow-up. Until then, this gate just catches pathological
|
||||
# regressions (e.g. accidental :amethyst dep pulling Android libs).
|
||||
# targets < 80 MB. Compose UI + Skiko now live in :commonsUI, which
|
||||
# :cli does not depend on, so the headless :commons no longer drags
|
||||
# ~40 MB of UI jars into the CLI. Budget stays at 200 MB for now
|
||||
# (tighten once a release confirms the new size); this gate catches
|
||||
# pathological regressions (e.g. an accidental :commonsUI/:amethyst
|
||||
# dep pulling UI or Android libs back in).
|
||||
fail=0
|
||||
for f in dist/*; do
|
||||
if [[ -f "$f" ]]; then
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# 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`.
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
run: |
|
||||
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
|
||||
# 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
|
||||
# <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
|
||||
# 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
|
||||
run: .claude/hooks/compose_escaping_check.py
|
||||
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
branch: l10n_crowdin_translations
|
||||
add-paths: |
|
||||
amethyst/src/main/res/**/strings.xml
|
||||
commons/src/commonMain/composeResources/**/strings.xml
|
||||
commonsUI/src/commonMain/composeResources/**/strings.xml
|
||||
docs/changelog/translators.json
|
||||
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
|
||||
title: 'New Crowdin Translations'
|
||||
|
||||
+7
-6
@@ -96,7 +96,7 @@ and each has its own 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) |
|
||||
|
||||
> **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
|
||||
> 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
|
||||
> zipping (and/or strip the unused `skiko`/Compose jars from the CLI image — the
|
||||
> `:commons` core/ui split the size budget already flags). The **desktop** app
|
||||
> zipping (the unused `skiko`/Compose jars left the CLI image with the
|
||||
> `:commons` / `:commonsUI` split). The **desktop** app
|
||||
> bundles the same jars through Compose/jpackage notarization, so run a desktop
|
||||
> 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
|
||||
a jar bundle is an accepted-but-reviewed pattern for JVM tools. Be ready to
|
||||
justify it (sandboxed Gradle can't fetch Maven deps).
|
||||
- **Bundle size.** The bundle is ~70 MB today because `:commons` leaks
|
||||
Compose/Skiko jars onto the CLI classpath. Trimming that (a `:commons`
|
||||
core/ui split) would shrink it and smooth review — tracked as a follow-up.
|
||||
- **Bundle size.** The bundle used to be ~70 MB because `:commons` leaked
|
||||
Compose/Skiko jars onto the CLI classpath. Compose UI now lives in
|
||||
`: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
|
||||
auto-open version-bump PRs on each stable release — no token or workflow on our
|
||||
|
||||
@@ -175,9 +175,13 @@ device. PRs that introduce any of them will be sent back.
|
||||
|
||||
### KMP source-set discipline
|
||||
|
||||
- **Android-only imports don't belong in `commons/commonMain` or
|
||||
`quartz/commonMain`.** Use `expect`/`actual` for platform-specific
|
||||
bits, or move the Android-specific code to `androidMain`.
|
||||
- **Android-only imports don't belong in `commons/commonMain`,
|
||||
`commonsUI/commonMain` or `quartz/commonMain`.** Use `expect`/`actual`
|
||||
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
|
||||
|
||||
|
||||
+8
-5
@@ -3,7 +3,7 @@
|
||||
Thanks for your interest in improving Amethyst. This document captures the
|
||||
expectations, conventions, and review rules for code, documentation, and
|
||||
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
|
||||
work contributed where you are not the original author must contain its
|
||||
@@ -157,7 +157,8 @@ Common Gradle entry points:
|
||||
Modules:
|
||||
|
||||
- `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.
|
||||
- `nestsClient/` — Audio-rooms client (NIP-53) built on `:quic` and
|
||||
`:quartz`.
|
||||
@@ -175,7 +176,8 @@ of PR churn. Place new code by purpose:
|
||||
| What you're adding | Goes in |
|
||||
|---|---|
|
||||
| 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/` |
|
||||
| Desktop-only window, sidebar, menu bar, shortcut | `desktopApp/` |
|
||||
| `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
|
||||
layer over `quartz` + `commons`. If your CLI command needs new behavior,
|
||||
extract it into `commons/` first.
|
||||
- ViewModels belong in `commons/commonMain/`. Only screens (the Composable
|
||||
that wires layout + navigation) stay in the platform module.
|
||||
- ViewModels belong in `commons/commonMain/`; shared composables in
|
||||
`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`.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -405,6 +405,7 @@ dependencies {
|
||||
|
||||
implementation(project(":quartz"))
|
||||
implementation(project(":commons"))
|
||||
implementation(project(":commonsUI"))
|
||||
implementation(project(":nestsClient"))
|
||||
// Agent text stream previews: the raw-QUIC binding plus the QUIC
|
||||
// stack under it (for the certificate validator it requires).
|
||||
|
||||
@@ -67,6 +67,7 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.benchmark.junit4)
|
||||
androidTestImplementation(project(":quartz"))
|
||||
androidTestImplementation(project(":commons"))
|
||||
androidTestImplementation(project(":commonsUI"))
|
||||
|
||||
// Custom C secp256k1 (libschnorr256k1) for the 3-way Android benchmark
|
||||
androidTestImplementation(libs.schnorr256k1.kmp)
|
||||
|
||||
+72
-49
@@ -4,10 +4,11 @@
|
||||
|
||||
| Consumer | Kind | Uses from `commons` |
|
||||
|----------------|------------------------------|----------------------------------------------|
|
||||
| `amethyst` | Android app (touch-first) | everything (models, state, ViewModels, UI) |
|
||||
| `desktopApp` | Desktop JVM app (mouse-first)| everything (models, state, ViewModels, UI) |
|
||||
| `cli` (`amy`) | Headless JVM CLI (no UI) | **non-UI only** — models, actions, relay, services |
|
||||
| iOS (future) | iOS app | everything; expected to share most UI with Android |
|
||||
| `amethyst` | Android app (touch-first) | everything (models, state, ViewModels) + `commonsUI` |
|
||||
| `desktopApp` | Desktop JVM app (mouse-first)| everything (models, state, ViewModels) + `commonsUI` |
|
||||
| `cli` (`amy`) | Headless JVM CLI (no UI) | everything — `commons` is headless by construction; it never sees `commonsUI` |
|
||||
| `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
|
||||
**below** the apps. The split between the three is:
|
||||
@@ -15,10 +16,13 @@
|
||||
- **`quartz/`** — Nostr protocol: events, NIPs, crypto, relay framing. No app
|
||||
state, no UI, no caches of "what this user follows."
|
||||
- **`commons/`** — everything an Amethyst *client* needs that isn't a
|
||||
platform-native screen or navigation shell: domain models (`Note`, `User`),
|
||||
in-memory state holders, ViewModels, the relay-subscription client, shared
|
||||
business services, **and** the Compose UI components that more than one front
|
||||
end renders.
|
||||
platform-native screen, navigation shell, **or Compose UI**: domain models
|
||||
(`Note`, `User`), in-memory state holders, ViewModels, the relay-subscription
|
||||
client, shared business services.
|
||||
- **`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
|
||||
(bottom-nav vs sidebar), gestures, system integration. They assemble
|
||||
`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**
|
||||
|
||||
`commons` is a single module that contains **both** Compose UI and headless
|
||||
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**:
|
||||
The shared layer is **two modules** with one package tree:
|
||||
|
||||
> **CLI-safe code** = does not depend on Compose UI. It may use the
|
||||
> `androidx.compose.runtime` *annotations* `@Stable` / `@Immutable` (they are
|
||||
> just stability tags) and snapshot state, but it must **not** import
|
||||
> `androidx.compose.ui`, `androidx.compose.foundation`,
|
||||
> `androidx.compose.material3`, declare `@Composable` functions, or build
|
||||
> `ImageVector`s.
|
||||
> **`commons` = CLI-safe code.** It does not depend on Compose UI. It may use
|
||||
> the `androidx.compose.runtime` *annotations* `@Stable` / `@Immutable` (they
|
||||
> are just stability tags) and snapshot state (`mutableStateOf`, `State`), but
|
||||
> it must **not** import `androidx.compose.ui`, `androidx.compose.foundation`,
|
||||
> `androidx.compose.material3`, Coil, the generated `Res`, declare
|
||||
> `@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
|
||||
> (Android, Desktop, iOS), never by `cli`.
|
||||
> **`commonsUI` = UI code.** Anything that does the above. It is only usable
|
||||
> by the GUI front ends (Android, Desktop, iOS), never by `cli`.
|
||||
|
||||
Compose is an `implementation` dependency of `commonMain`, so `cli` pulling in
|
||||
`commons` does **not** force it to render anything — but a `cli` command must
|
||||
only reach for CLI-safe packages. When you add code, know which side of this
|
||||
line it is on, and put it in a package that matches (§2).
|
||||
Both modules share the **same `com.vitorpamplona.amethyst.commons.*` package
|
||||
tree** — the split is a module boundary, not a package rename, so a file moves
|
||||
between `commons/src/…` and `commonsUI/src/…` without changing its package or
|
||||
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
|
||||
module (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.
|
||||
This boundary is **not** a top-level `ui/` vs `logic/` partition of the package
|
||||
tree (we chose to stay feature-oriented, §3). It is a property of each file:
|
||||
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
|
||||
`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
|
||||
| Package | UI? | Purpose |
|
||||
@@ -91,17 +100,19 @@ package contains Compose UI (and is therefore *not* CLI-safe).
|
||||
| Package | UI? | Purpose |
|
||||
|----------------|-----|---------|
|
||||
| `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. |
|
||||
| `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`);
|
||||
they are shared across the GUI apps. Treat as GUI-shared, not strictly headless.
|
||||
² may touch `compose.runtime` state types (snapshot state, `@Stable`); they
|
||||
are shared across the GUI apps. A state holder that needs a `foundation`/`ui`
|
||||
type (`LazyListState`, `TextFieldValue`, `TextFieldState`) goes to `commonsUI`.
|
||||
|
||||
### Relay client
|
||||
| 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`). |
|
||||
|
||||
### 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. |
|
||||
| `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 |
|
||||
|----------------|-----|---------|
|
||||
| `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. |
|
||||
| `nip23LongContent` | yes | Long-form (NIP-23) article UI: `nip23LongContent/ui/article` (reader) + `…/ui/editor` (authoring). The model lives in `model/nip23LongContent`. |
|
||||
| `icons` | yes | `ImageVector` icon definitions + builders. |
|
||||
| `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` (here). |
|
||||
| `icons` | yes | `ImageVector` icon definitions + builders, Material Symbols codepoints, the icon-font glyph tables. |
|
||||
| `hashtags` | yes | Custom hashtag `ImageVector`s. |
|
||||
| `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)
|
||||
| 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. |
|
||||
|
||||
---
|
||||
@@ -185,11 +200,15 @@ Instead, **layer is the primary axis, NIP is the secondary axis**:
|
||||
| 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. |
|
||||
| `jvmAndroid` | Shared by Android + Desktop, **not** iOS. Where JVM-bound deps live (`nestsClient`, Coil-OkHttp, markdown, `viewModel()` helper, NWC/LNURL). |
|
||||
| `jvmMain` | Desktop-only (keyring, EXIF, `service/upload`). `dependsOn(jvmAndroid)`. |
|
||||
| `androidMain` | Android-only (Keystore, DataStore). `dependsOn(jvmAndroid)`. |
|
||||
| `jvmAndroid` | Shared by Android + Desktop, **not** iOS. Where JVM-bound deps live (`nestsClient`, OkHttp, NWC/LNURL). |
|
||||
| `jvmMain` | Desktop-only (keyring, EXIF, `service/upload`, OS notifications). `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. |
|
||||
|
||||
`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
|
||||
compiles: `commonMain` → `jvmAndroid` → platform-specific. See
|
||||
`/kotlin-multiplatform`.
|
||||
@@ -197,7 +216,8 @@ compiles: `commonMain` → `jvmAndroid` → platform-specific. See
|
||||
### Where does my code go? (quick guide)
|
||||
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? →
|
||||
`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
|
||||
`<feature>` if feature-scoped). Keep it CLI-safe where practical.
|
||||
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.
|
||||
|
||||
- **`nip64Chess` is UI+logic in one flat package.** `LiveChessGame.kt` mixes a
|
||||
state class with composables. Split into `nip64Chess/` (logic) +
|
||||
`nip64Chess/ui/` (composables); this needs file-level surgery (extracting
|
||||
composables out of logic files), not just moves, so it is deferred.
|
||||
- **`ui/feeds` holds the feed data-access layer** (`FeedFilter`,
|
||||
- **`nip64Chess` is UI+logic in one flat package.** The composables now sit in
|
||||
`commonsUI` (module split), but they keep the flat `nip64Chess` package;
|
||||
renaming them into `nip64Chess/ui/` is the remaining step.
|
||||
- **`ui/feeds` (in `commons`) holds the feed data-access layer** (`FeedFilter`,
|
||||
`ChangesFlowFilter`, `FeedContentState`), which is logic, not UI, and overlaps
|
||||
conceptually with the top-level `feeds` (custom-feed definitions). Consider
|
||||
moving the DAL out of `ui/`.
|
||||
conceptually with the top-level `feeds` (custom-feed definitions). Since the
|
||||
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
|
||||
use-case/flow types or rename it to the matching `nip46RemoteSigner` per the
|
||||
NIP-second-axis rule.
|
||||
|
||||
+21
-98
@@ -1,29 +1,20 @@
|
||||
|
||||
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 {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
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.composeMultiplatform)
|
||||
alias(libs.plugins.serialization)
|
||||
}
|
||||
|
||||
@@ -70,45 +61,20 @@ kotlin {
|
||||
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 {
|
||||
implementation(project(":quartz"))
|
||||
|
||||
// Compose Multiplatform
|
||||
implementation(libs.jetbrains.compose.ui)
|
||||
implementation(libs.jetbrains.compose.foundation)
|
||||
// Compose *runtime* only — @Stable/@Immutable annotations and
|
||||
// snapshot state (mutableStateOf, State) used by state holders.
|
||||
// No ui / foundation / material3 here: that is :commonsUI.
|
||||
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-runtime-compose ship iOS variants;
|
||||
// lifecycle-viewmodel-compose (the viewModel() Composable
|
||||
// helper) is Android-only and lives in jvmAndroid below.
|
||||
// Lifecycle ViewModel (KMP since 2.8.0, ships iOS variants).
|
||||
// The Compose-side helpers (lifecycle-runtime-compose,
|
||||
// viewModel()) live in :commonsUI.
|
||||
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)
|
||||
@@ -119,12 +85,6 @@ kotlin {
|
||||
// JSON for custom-feed definitions (KMP — replaces Jackson
|
||||
// for the one commonMain serializer that was blocking iOS).
|
||||
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.
|
||||
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
|
||||
// (service/preview/UrlPreview). JVM-only; iOS will swap to
|
||||
// Ktor when its UI ships.
|
||||
implementation(libs.okhttp)
|
||||
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 {
|
||||
dependsOn(jvmAndroid)
|
||||
dependencies {
|
||||
// Desktop-specific Compose
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(libs.jetbrains.compose.ui.tooling)
|
||||
|
||||
// Secure key storage via OS keychain (macOS/Windows/Linux)
|
||||
implementation(libs.java.keyring)
|
||||
|
||||
@@ -205,8 +143,10 @@ kotlin {
|
||||
androidMain {
|
||||
dependsOn(jvmAndroid)
|
||||
dependencies {
|
||||
// Android-specific Compose tooling
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
// androidx.core KTX (Bitmap.scale, prefs.edit {}) used by the
|
||||
// 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
|
||||
implementation(libs.androidx.security.crypto.ktx)
|
||||
@@ -222,16 +162,6 @@ kotlin {
|
||||
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)
|
||||
@@ -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
|
||||
// headless mode so a stray Toolkit.getDefaultToolkit() in a transitive dep
|
||||
// never bounces the macOS Dock during CI/local test runs.
|
||||
@@ -287,7 +211,6 @@ val verifyKmpPurity by tasks.registering {
|
||||
"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",
|
||||
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
| 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-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)
|
||||
| Plan | Summary |
|
||||
| ---- | ------- |
|
||||
|
||||
-23
@@ -20,9 +20,6 @@
|
||||
*/
|
||||
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.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -174,23 +171,3 @@ class PrivacyLockState(
|
||||
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")
|
||||
|
||||
-2
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.richtext
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
data class ParagraphImageAnalysis(
|
||||
@@ -106,7 +105,6 @@ class GalleryParser {
|
||||
return imageParagraphs to j
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun processParagraphs(paragraphs: List<ParagraphState>): List<ParagraphState> {
|
||||
val result = mutableListOf<ParagraphState>()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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.
|
||||
@@ -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) }
|
||||
Vendored
+24
@@ -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
Reference in New Issue
Block a user