`: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
5.6 KiB
Extract-from-Android recipe
The single most common reason an Amy feature request stalls: the
logic it needs lives in amethyst/ with Android-only imports. You
cannot call it from cli/. You have to move it first.
This recipe is how.
When to extract
Before writing a new command, ask:
-
Does the piece of logic I need exist in
quartz/orcommons/?- Yes → use it.
- No, but it's in
amethyst/→ extract. This file. - No, it doesn't exist anywhere → design it in
commons/directly. Write a plan doc undercommons/plans/if it's a new subsystem.
-
Never duplicate
amethyst/logic intocli/. That's a debt you will pay later when the Android caller drifts.
Recipe
Land this as its own commit, before the commit that adds the CLI command.
Step 1 — Find the class
grep -rn "fun followUser\|class FollowListManager" amethyst/src/main/java/
Identify the minimum unit to move. Sometimes it's a whole file, sometimes one function. Prefer the smallest unit that makes the command possible.
Step 2 — List Android-only dependencies
Walk the imports. The usual offenders:
| Dependency | Treatment |
|---|---|
android.content.Context |
Often accidental — inline if only used for logging or preferences. Otherwise, invert as constructor arg. |
android.content.SharedPreferences |
Abstract behind an interface in commons/; Android actual uses SharedPreferences, JVM actual uses a JSON file. |
androidx.work.WorkManager |
Rarely shareable — if the CLI needs it, simplify the flow to not require background scheduling. |
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.* |
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
- Inline-able. One call, trivial. Delete it.
- Platform-abstractable. Add
expectincommons/commonMain/+actualincommons/androidMain/+actualincommons/jvmMain/. Seekotlin-multiplatformskill for the mechanics and for thejvmAndroidsource-set pattern used throughout this repo. - Inversion-of-control. Take the Android dependency as a constructor arg with an interface type. Amy supplies a JVM flavour; Android supplies the Context-backed one.
Step 4 — Move the code
# Target location depends on what it is:
# - Protocol → quartz/src/commonMain/kotlin/…
# - Business logic → commons/src/commonMain/kotlin/…
# - 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
Update package declarations. Run ./gradlew spotlessApply.
Step 5 — Update the Android caller
The amethyst/ caller now imports from the new location. Often this is the only code change visible in the Android app.
If the Android caller was using a concrete Android-backed dependency, it now supplies that concrete dependency explicitly.
Step 6 — Add a JVM test
In commons/src/commonTest/kotlin/… or commons/src/jvmTest/kotlin/…
(depending on what the code exercises), add a test that runs on JVM.
This guards against Android-only imports sneaking back in, and it's
the only way to be sure Amy can now call the code.
Step 7 — Commit
Single commit, descriptive:
refactor(follow): extract FollowListManager to commons for CLI reuse
Move FollowListManager from amethyst/model/nip02FollowLists/ to commons/commonMain/.../followLists/. Android's SharedPreferences dependency is inverted behind FollowListStore (interface); Android keeps the SharedPreferences-backed actual, new JvmFollowListStore writes JSON to disk. No behaviour change on Android.
Step 8 — Now add the CLI command
Separate commit. Follows the pattern in command-template.md.
Cautionary notes
- Don't extract speculatively. Only extract what the current command needs. A feature-complete port can happen later; right now the goal is to unblock one command without adding surface area you don't have a second caller for.
- Android is allowed to keep side-effects. Notifications,
background services, Intents, camera, permissions dialogs — those
stay in
amethyst/. Amy's job isn't to replicate UX, it's to exercise the protocol underneath. - Check the consumers. Sometimes the "logic" you want is already
partially in
commons/, and theamethyst/class is just a thin wrapper. In that case, re-use thecommons/class directly and delete the wrapper or keep it if Android genuinely needs it. - Tests first if you're nervous. Copy the existing
amethyst/-side test (if any), make it JVM-only by removing Android imports, and watch it pass after the move.
Red flags during extraction
Stop and reconsider if:
- The Android class is 1000+ lines. Extract only the piece the CLI needs; leave the rest for a follow-up.
- You need
Contextin 30 places. It's probably being used as a grab-bag; sort by actual use (strings, preferences, services, …) and abstract those individually. - You find yourself writing
expect classwith a dozen methods. A fine-grained interface is usually clearer than a monolithic expect-actual. - You're about to add a Compose import to
cli/. Stop.