docs: one UI for Android and Desktop, and the measured Wave 4 plan

Android now ships on laptops, so the whole UI (screens and the navigation
shell) moves to commonsUI, amethyst becomes an Android shim, and a new JVM
desktopApp replaces the current one with the same UI.

- commons/plans/2026-09-27-one-ui-android-desktop.md: the decision, what it
  supersedes in the sweep tracker, the prerequisites (androidx
  navigation-compose publishes only jvmStubs for JVM; the Amethyst.instance
  app root is read by 178 files; Android-only libraries inside screens), and
  Wave 4 measured: Account's move-group is 77 files / ~23.9k lines inside
  model/, with 5 hard-blocked files and 14 exit edges, each with a proposed
  cut; AccountViewModel's Android imports and 29 outside dependencies.
- CLAUDE.md, commons/ARCHITECTURE.md, commonsUI/ARCHITECTURE.md and the
  kotlin-multiplatform, compose-expert and desktop-expert skills no longer say
  screens and navigation stay platform-native.
- The sweep tracker marks its STAY list, Wave 2 part B and the Desktop-phase
  merges as superseded, and re-scopes Wave 4 from decomposition to moving
  Account and AccountViewModel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-27 23:00:45 +00:00
parent c13e496ce2
commit 09825950c7
9 changed files with 337 additions and 51 deletions
+27 -10
View File
@@ -11,9 +11,11 @@ contain implementations of Nostr specifications and utilities to help implement
`concord/` (`cordXX`), `buzz/`, plus the binding-agnostic RFC 9420 engine in `mls/`. A new protocol
over Nostr belongs here as a package, not as a Gradle module; `quic`/`nestsClient`/`marmotQuic` are
modules because they are transports with no Nostr in them. Commons stores
shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). The Desktop
App is designed to be mouse first and so uses a completely different screen and navigation
architecture while sharing the back end components with the android counterpart. `cli` ships `amy`,
shared code between Amethyst Android (`amethyst`) and Amethyst Desktop (`desktopApp`). Android now
also ships on laptops, so the direction is **one UI**: every screen and the navigation shell at every
window size move to `commonsUI`, `amethyst` shrinks to an Android shim, and a new `desktopApp`
becomes a JVM shim that renders the same UI (see `commons/plans/2026-09-27-one-ui-android-desktop.md`).
Until it lands, today's `desktopApp` still has its own mouse-first screens and navigation. `cli` ships `amy`,
a non-interactive JVM command-line client that drives the same `quartz` + `commons` code — used by
humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
@@ -105,7 +107,9 @@ amethyst/
codecs. Not WebTransport — the binding has its own ALPNs and writes frames
straight onto QUIC streams, so it deliberately does not reuse
`nestsClient`'s `WebTransportSession`.
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
- `amethyst/` & `desktopApp/` = Platform shims: process/window entry points, services, system
integrations and the actuals of shared ports. Screens and navigation are shared UI and are
moving to `commonsUI` (today's `desktopApp` screens are legacy, to be replaced).
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
standalone relay); never on `:commonsUI`, `:amethyst` or `:desktopApp`.
@@ -207,10 +211,20 @@ etc. instead of re-implementing them.
(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).
**Screens and navigation are shared too**: screen composables, the nav host,
and the navigation chrome for every window size (bottom bar, rail, permanent
drawer) belong in `commonsUI`, because Android runs on laptops and the new
Desktop app renders the same UI. Adapt to the window with `ScreenLayoutSpec`
/ `LocalScreenLayout`, not with a per-platform screen.
- **Keep native** → only what is the platform itself: the process/window entry
point (Android `Activity`/`Service`, Desktop `Window`/tray/menu bar),
system integrations (notifications, file pickers, share sheets, camera,
media3, WebView, keyring/Keystore), and the platform `actual`s or port
implementations the shared UI calls. A new screen goes in `commonsUI` when
its dependencies allow. While `AccountViewModel` is still app-side, one that
needs it may live in `amethyst/`, but keep Android APIs out of it (behind a
port or slot) so it can move later. Don't add screens to today's `desktopApp`
that the shared UI will have to re-create.
When extracting a composable: move it to `commonsUI/commonMain/` (see
`/compose-expert`), add expect/actual for any platform behavior (see
@@ -429,8 +443,11 @@ Do this before considering the task complete.
at byte level (`perl -CSD -pe 's/\x{202E}/\\u202E/g'`).
### Navigation Shell
- **Desktop**: Sidebar + main content area
- **Android**: Bottom navigation
One shell, picked by window size rather than by platform: `ScreenLayoutSpec`
chooses the bottom bar (compact), the rail, or the permanent drawer (wide,
landscape, tall enough), and docks the notification panel on very wide windows.
It is moving to `commonsUI` with `AppNavigation`. Today's `desktopApp` still has
its own sidebar shell, which will be replaced.
## Git Workflow
+17 -10
View File
@@ -1,6 +1,6 @@
---
name: compose-expert
description: Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector icons, or determining whether to share UI in commonMain vs keep platform-specific. Delegates navigation to android-expert/desktop-expert. Complements kotlin-expert (handles Kotlin language aspects of state/annotations).
description: Advanced Compose Multiplatform UI patterns for shared composables. Use when working with visual UI components, state management patterns (remember, derivedStateOf, produceState), recomposition optimization (@Stable/@Immutable visual usage), Material3 theming, custom ImageVector icons, or determining whether to share UI in commonMain vs keep platform-specific. Delegates platform entry points (Activity, Window, tray) to android-expert/desktop-expert; the navigation shell itself is shared UI. Complements kotlin-expert (handles Kotlin language aspects of state/annotations).
---
# Compose Multiplatform Expert
@@ -34,12 +34,17 @@ Visual UI patterns for sharing composables across Android and Desktop.
- **Theme utilities**: Color calculations, style helpers
- **Material3 components**: Any UI using Material primitives
**Screens and navigation are shared too.** Android also ships on laptops and the new
Desktop app renders the same UI, so screens, the nav host and the bottom bar / rail /
permanent drawer all belong in `commonsUI`. They adapt to the window with
`ScreenLayoutSpec` / `LocalScreenLayout`, not per platform. See
`commons/plans/2026-09-27-one-ui-android-desktop.md`.
### Keep Platform-Specific
- **Navigation structure**: Bottom nav (Android) vs Sidebar (Desktop)
- **Screen layouts**: Platform-specific scaffolding
- **System integrations**: File pickers, notifications, share sheets
- **Platform UX**: Gestures, keyboard shortcuts, window management
- **Entry points**: Android `Activity`/`Service`, Desktop `Window`, tray, menu bar
- **System integrations**: File pickers, notifications, share sheets, camera, media3, WebView
- **Platform leaves inside shared UI**: behind expect/actual or a slot the shim fills
### Decision Framework
@@ -563,12 +568,14 @@ fun FeedList(items: List<Item>) {
4. Add caching if generated dynamically
5. Wrap in @Composable for easy use
### Navigation (Delegate)
### Navigation
For navigation patterns:
- Android bottom nav → `android-expert`
- Desktop sidebar → `desktop-expert`
- Multi-window → `desktop-expert`
The navigation shell is shared (`ScreenLayoutSpec` picks bottom bar, rail or permanent
drawer by window size) and is moving to `commonsUI` with `AppNavigation`. Delegate only
the platform leaves:
- Activity / intent plumbing → `android-expert`
- Window, tray, menu bar, multi-window → `desktop-expert`
- Today's legacy `desktopApp` sidebar shell → `desktop-expert` (to be replaced)
## Related Skills
+9 -2
View File
@@ -248,9 +248,16 @@ application {
## 5. Desktop Navigation Patterns
### NavigationRail (Current Pattern)
### NavigationRail (Current Pattern — legacy)
Desktop uses **NavigationRail** (vertical sidebar) instead of Android's bottom navigation.
> **Being replaced.** Android now also ships on laptops, and the plan is one UI: the
> shared navigation shell (bottom bar / rail / permanent drawer picked by window size
> via `ScreenLayoutSpec`) moves to `commonsUI`, and a new `desktopApp` becomes a JVM
> shim that renders it. Don't grow this sidebar shell or add Desktop-only screens; see
> `commons/plans/2026-09-27-one-ui-android-desktop.md`. What stays Desktop-specific is
> the window, tray, menu bar, keyboard shortcuts and file system integration.
Today's desktop app uses **NavigationRail** (vertical sidebar) instead of Android's bottom navigation.
```kotlin
Row(Modifier.fillMaxSize()) {
+19 -14
View File
@@ -52,8 +52,9 @@ Q: Does it vary by platform or by JVM vs non-JVM?
│ Example: Jackson JSON parsing (JVM library)
│
└─ Complex/UI-related
→ Keep platform-specific
Example: Navigation (Activity vs Window too different)
→ Share it in commonsUI; expect/actual or a slot only for the platform leaf
Example: Navigation. One shell in commonsUI, picked by window size
(ScreenLayoutSpec); only the Activity / Window entry point is native
Final check:
Q: Maintenance cost of abstraction < duplication cost?
@@ -85,10 +86,13 @@ val jvmAndroid = create("jvmAndroid") {
```
**Why:** Jackson is JVM-only, works on Android + Desktop, not iOS/web.
**Navigation → platform-specific:**
- Android: `MainActivity` (Activity + Compose Navigation)
- Desktop: `Window` + sidebar + MenuBar
**Why:** UI paradigms fundamentally different.
**Navigation → shared, entry points → platform-specific:**
- Shared (`commonsUI`, moving there): the nav host, bottom bar / rail /
permanent drawer, chosen by window size via `ScreenLayoutSpec`
- Android: `MainActivity` hosts it; Desktop: a `Window` (+ MenuBar, tray) hosts it
**Why:** Android runs on laptops too, and the new Desktop app renders the same
UI. See `commons/plans/2026-09-27-one-ui-android-desktop.md`. Today's
`desktopApp` sidebar shell is legacy.
## Mental Model: Source Sets as Dependency Graph
@@ -183,15 +187,15 @@ Quick decision guidelines based on codebase patterns:
### Sometimes Abstract
- **Business logic:** YES - state machines, data processing
- **ViewModels:** YES - state + business logic shareable (StateFlow/SharedFlow)
- **Screen layouts:** NO - platform-native (Window vs Activity)
- **Why:** ViewModels contain platform-agnostic state; Screens render differently per platform
- **Screen layouts:** YES - shared in `commonsUI`; they adapt to window size, not platform
- **Why:** One UI for Android (phones to laptops) and Desktop; only entry points differ
### Rarely Abstract
- **Complex UI components** (composables with heavy platform dependencies)
- **Why:** Platform paradigms can differ significantly
### Never Abstract
- **Navigation** (Activity vs Window fundamentally different)
- **Process / window entry points** (Activity, Service, Window, tray)
- **Permissions** (Android vs iOS APIs incompatible)
- **Platform UX patterns**
- **Why:** Too platform-specific, abstraction creates leaky APIs
@@ -203,7 +207,7 @@ Quick decision guidelines based on codebase patterns:
| PubKeyFormatter, ZapFormatter | ✅ YES | Pure Kotlin, no platform APIs |
| TimeAgoFormatter | ⚠️ ABSTRACTED | Needs StringProvider for localized strings |
| ViewModels (state + logic) | ✅ YES | StateFlow/SharedFlow platform-agnostic, Compose Multiplatform lifecycle compatible |
| Screen layouts (Scaffold, nav) | ❌ NO | Window vs Activity, sidebar vs bottom nav fundamentally different |
| Screen layouts (Scaffold, nav) | ✅ YES (moving) | One UI; `ScreenLayoutSpec` picks bottom bar / rail / drawer by window size |
| Image loading (Coil) | ⚠️ ABSTRACTED | Coil 3.x supports KMP, needs expect/actual wrapper |
## expect/actual Mechanics
@@ -333,8 +337,9 @@ fun validateSignature(...) { ... } // Duplicated!
// ❌ BAD
expect fun NavigationComponent(...)
```
**Why:** Navigation paradigms too different (Activity vs Window)
**Fix:** Keep platform-specific, accept duplication
**Why:** It forks the whole navigation UI per platform.
**Fix:** Write the navigation once in `commonsUI`; put only the platform leaf
(an Activity result, a file picker) behind expect/actual or a slot
### 2. Under-Sharing
**Problem:** Duplicating business logic across platforms
@@ -384,8 +389,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
| **ViewModels** | **commons/commonMain/viewmodels/** | **StateFlow/SharedFlow + logic shareable, Compose MP lifecycle compatible** |
| UI formatters (pure) | commons/commonMain | Reusable, no dependencies |
| 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 |
| **Screen layouts** | **commonsUI** | **One UI; adapt to window size with `ScreenLayoutSpec`** |
| Navigation | commonsUI (entry point native) | Nav host + chrome shared; Activity / Window hosts it |
| Permissions | Platform-specific only | APIs incompatible |
| Platform UX (menus, etc.) | Platform-specific only | Native feel required |
+15 -10
View File
@@ -4,8 +4,8 @@
| Consumer | Kind | Uses from `commons` |
|----------------|------------------------------|----------------------------------------------|
| `amethyst` | Android app (touch-first) | everything (models, state, ViewModels) + `commonsUI` |
| `desktopApp` | Desktop JVM app (mouse-first)| everything (models, state, ViewModels) + `commonsUI` |
| `amethyst` | Android app (phones, tablets, laptops) | everything (models, state, ViewModels) + `commonsUI` |
| `desktopApp` | Desktop JVM app | everything (models, state, ViewModels) + `commonsUI`; today's mouse-first screens are to be replaced by the shared UI |
| `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 |
@@ -15,17 +15,21 @@
- **`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, navigation shell, **or Compose UI**: domain models
- **`commons/`** — everything an Amethyst *client* needs that isn't
platform-specific **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.
- **`amethyst/` & `desktopApp/`** — platform shims: the Activity / Window
entry points, services, system integration, and the platform implementations
of shared ports. They assemble `commons` pieces; they should not
re-implement them. **Screens and navigation are being moved into
`commonsUI`** so Android (which now also runs on laptops) and a new JVM
Desktop app render one UI; see
[`plans/2026-09-27-one-ui-android-desktop.md`](plans/2026-09-27-one-ui-android-desktop.md).
> The goal is **"write it once in `commons`"**. Before adding a manager, cache,
> filter, ViewModel, or composable to an app module, check whether it already
@@ -223,9 +227,10 @@ 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? →
`commonsUI`, in `ui/<area>` or `<feature>/ui` (same package tree as here).
Also anything that imports Coil, `Res`, or a `foundation`/`ui` state type.
2. **A composable** (screens and navigation chrome included)? → `commonsUI`, in
`ui/<area>` or `<feature>/ui` (same package tree as here). Also anything that
imports Coil, `Res`, or a `foundation`/`ui` state type. Only the platform
entry points and system integrations stay in `amethyst/`/`desktopApp/`.
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`.
@@ -1,5 +1,14 @@
# Full sweep: `amethyst/` → `:commons` migration candidates
> **Direction change (2026-09-27): one UI.** Android now ships on laptops, so every
> screen and the navigation shell move to `commonsUI`, `amethyst` becomes an Android
> shim, and a new JVM `desktopApp` replaces the current one with the same UI. See
> [2026-09-27-one-ui-android-desktop.md](2026-09-27-one-ui-android-desktop.md). That
> supersedes, below: the STAY list's screens/navigation entries, "decompose `Account`,
> shrink `AccountViewModel`" (both now **move**), Wave 2 part B (dropped), and the
> "Desktop phase" merges of Desktop's own forks (dropped). This file stays the log of
> what moved; Wave 4 is planned and measured in the new plan.
> **Execution status (updated 2026-08-30, same branch):** Waves 0-1 are DONE
> on this branch — the 12 shim deletions, the 38-file relayClient batch
> (with `AccountScopedQuery` generalized to `IAccount`), the okhttp stack →
@@ -297,10 +306,15 @@ formatters).
## STAY (correctly platform-native)
- **Navigation shell & screens**: `*Screen.kt`, `*TopBar.kt`, `New*Button.kt`,
> **Revised 2026-09-27.** The navigation shell and the screens no longer stay: they move
> to `commonsUI` (see the one-UI plan). What is listed below is what remains
> platform-native. The media, capture and service entries stay as *implementations*;
> the screens that render them reach them through ports or slots.
- ~~**Navigation shell & screens**: `*Screen.kt`, `*TopBar.kt`, `New*Button.kt`,
`INav`/`Route`/`RouteMaker`/`AppNavigation`, drawer/bottom-bar,
`AccountScreen`/`AccountSessionManager`/`LoggedInPage`, `loggedOff/`,
`settings/` screens (~480 files import INav/Route — by design).
`settings/` screens.~~ Moving to `commonsUI` (`RouteMaker` to `commons`).
- **Process/DI roots**: `Amethyst.kt`, `AppModules.kt`, `EncryptedStorage`,
`LocalPreferences`, `DebugUtils`, `model/accountsCache`,
`model/preferences/` (the one genuinely-Android model package: DataStore/
@@ -807,7 +821,11 @@ Options, for the maintainer to pick:
`ui/theme/Shape.kt` into `commons/ui/theme/Sizes.kt`, plus
`painterRes`/`TimeAgo`/`NewItemsBubble` decisions — the audit's
"strings-only" tally under-counted transitive deps.
- **Wave 4: Account/AccountViewModel decomposition** — long-tail.
- **Wave 4: Account/AccountViewModel** — **re-scoped 2026-09-27** from
"decomposition" to **moving both to `commons`**. Measured and sequenced in
[2026-09-27-one-ui-android-desktop.md](2026-09-27-one-ui-android-desktop.md): the
`Account` group is 77 files / ~23.9k lines inside `model/`, with 5 hard-blocked files
and 14 exit edges to cut.
### Environment notes for the next session (hard-won)
@@ -924,6 +942,10 @@ and `RailCapability` use it, `OnchainZapResolver` does not.
### Wave 2 part B — compatibility analysis and the road to deleting `DesktopLocalCache` (2026-09-19)
> **Dropped 2026-09-27.** The new desktop app uses `LocalCache` and `Account` directly;
> the current `desktopApp` keeps its fork until it is retired. The analysis below is
> kept for the record.
Part A moved the cache. Part B is retiring the Desktop fork. The naive framing
("repoint ~70 consumers and delete 1,177 lines") is wrong; what follows is the
measured picture.
@@ -0,0 +1,216 @@
---
title: "One UI: the whole app moves to commonsUI, Android and Desktop become shims"
type: refactor
status: in-progress
date: 2026-09-27
owner: commons
consumers: amethyst, desktopApp, commonsUI
---
# One UI for Android and Desktop
## The decision (maintainer, 2026-09-27)
Android now ships on laptops, so Amethyst Android needs a desktop-class UI. Rather than
maintain two desktop UIs, there will be one:
- **`commonsUI` holds the whole app UI.** Every screen, the navigation host, and the
navigation chrome at every size (bottom bar, rail, permanent drawer, docked panels).
- **`amethyst/` becomes an Android shim**: Activities, services, notifications, media3,
camera, WebView, Health Connect, Keystore/DataStore actuals, flavours. Nothing that is not
Android itself.
- **A new `desktopApp` becomes a JVM shim** that runs the same `commonsUI` app, so Desktop
looks exactly like Android on a laptop: Window, tray, menu bar, keyring, file pickers.
It **replaces** the current `desktopApp`, which keeps shipping until the new one reaches
parity, then goes.
This supersedes the "screens and navigation stay platform-native" rule that
`.claude/CLAUDE.md`, `commons/ARCHITECTURE.md`, the `kotlin-multiplatform` /
`compose-expert` / `desktop-expert` skills and the sweep tracker's STAY list used to state.
Those were updated alongside this plan.
## What this changes in the running migration
The tracker ([2026-08-30-commons-migration-sweep.md](2026-08-30-commons-migration-sweep.md))
stays the log of what moved. These of its conclusions no longer hold:
| Old conclusion | Now |
|---|---|
| `Account` is a god object: "decompose, don't move". `AccountViewModel`: "shrink, don't move". | **Move both** to `commons`. They were only to be decomposed because Desktop was going to keep its own; the shared screens now need the same `Account` on both platforms. Narrow interfaces can still be carved out later, for tests and the CLI, but they are not a prerequisite. |
| STAY: `*Screen.kt`, `*TopBar.kt`, `AppNavigation`, `INav`/`Route`/`RouteMaker`, drawer/bottom bar, `LoggedInPage`, `loggedOff/`, `settings/` screens | **All move** to `commonsUI` (`RouteMaker` to `commons`, it has no Compose). |
| Wave 2 part B: turn `DesktopLocalCache` into a facade over `EventCache` | **Dropped.** The new desktop app uses `LocalCache` and `Account` directly. The old `desktopApp` keeps its fork until it is retired. |
| "Desktop phase": merge Desktop's `ToggleableTimeAgoText`, `TimeAgoFormatter`, `ChatBubbleLayout` forks onto the shared ones | **Dropped** for the same reason. |
| `jdk.localedata` in Desktop's packaged runtime (+~28 MB) is "a packaging call" | **Required** by the new desktop app: the shared date formatters need the CLDR data. |
## Where we start from
The Android app already contains the laptop UI. `ScreenLayoutSpec` (now in
`commonsUI/…/ui/layouts/ScreenLayout.kt`) picks one of three navigation tiers from the window
size alone (bottom bar below 600dp, rail, permanent drawer when wide, landscape and at least
600dp tall), docks the notification panel from 1200dp, and caps every destination to a 600dp
reading column (`CappedScreenContent`). A JVM window can feed it `widthDp`/`heightDp` and
get the same answer. Still app-side and part of the move: `AppNavigation.kt` (1,370 lines),
`AppNavigationRail.kt`, the permanent drawer in `AccountSwitcherAndLeftDrawerLayout.kt`,
`MessagesTwoPane.kt`, and `MainActivity.kt` (493 lines, the part that is not Activity
plumbing).
## Prerequisites the move surfaced
- **Navigation library.** The app uses `androidx.navigation:navigation-compose` 2.10.1. Its
Gradle module metadata publishes an `androidJvm` variant and only **`jvmStubs`** for `jvm`
(checked 2026-09-27), so it cannot run the nav host on Desktop. The multiplatform path is
JetBrains' `org.jetbrains.androidx.navigation:navigation-compose`, which resolves to the
androidx artifact on Android. That swap (and its licence check, per CLAUDE.md) comes before
`AppNavigation` can move.
- **The app root.** `Amethyst.instance` (the `AppModules` graph) is read by 178 files, 115 of
them under `ui/`. Shared screens cannot reach an Android `Application`. The root needs a
commons-side interface for what screens read from it, provided once at the composition root
(as `LocalUserFinderAccount` / `LocalEventFinder` already are), with an Android and a JVM
implementation.
- **Android-only libraries rendered inside screens.** Each needs an expect/actual or a slot the
shim fills: media3 (46 files), Vico charts (8), WebView (5), CameraX (4), Health Connect (4),
ML Kit (3, `play` flavour only).
- **Current-Desktop-only features.** The current `desktopApp` (260 files, ~70k lines) has
features Android lacks: deck columns, the article editor, highlights, scheduled-post
screens, keyboard shortcuts, menu bar, tray. Each needs a call before the old app is
retired: bring it into `commonsUI` for both platforms, or keep it in the new JVM shim. That
inventory is not done yet.
## Wave 4, measured: what moves with `Account`
Measured 2026-09-27 on `main` @ `c13e496c` with a closure script (not committed; the method
is below) over `amethyst/src/{main,play,fdroid}`.
**An unconstrained closure is useless.** Following every edge from `Account.kt` reaches
1,724 of the app's 1,762 files, because a few edges leave `model/` for the app root
(`Amethyst.kt`), `ui/navigation` and `ui/screen`, and those reach everything. The useful
measure is the group reachable **inside `model/`**, plus the list of edges that leave it.
Each leaving edge is a seam to cut before the group can move.
### The group
- **75 files, 22,753 lines**: `Account.kt`, `AccountSettings.kt`, the `Account*Actions` files,
`EventBroadcaster`, and 60-odd per-feature state holders (`nip51Lists/*`, `nip65RelayList`,
`serverList/*`, `topNavFeeds/*`, `nip46Signer/*`, `cordn/*`, …). That is 75 of the 89 files
in `model/`.
- **Adding `EventProcessor`** (`ui/screen/loggedIn/DecryptAndIndexProcessor.kt`, which
`Account` constructs) brings it to 77 files, 23,850 lines, and adds no new exit edge except
two `Amethyst.instance.notificationDispatcher` calls.
- **11 files use `java.*`** (`BigDecimal`, `ConcurrentHashMap`, `UUID`, `Base64`, `File`,
`Locale`, and `OkHttpClient` in `CashuWalletState`). So the group lands in
**`commons/jvmAndroid`** first and promotes to `commonMain` later, the same route
`LocalCache` took.
- **No Compose UI** in any of them.
### Hard blockers inside the group (5 files)
| File | Blocker | Proposed cut |
|---|---|---|
| `Account.kt` | `BuildConfig.VERSION_NAME` (donation prompt, 3 sites) | constructor parameter `appVersion: String` |
| `AccountSyncedSettingsInternal.kt` | `Resources.getSystem()` + `ConfigurationCompat` for the system language list; `DefaultBottomBarEntries` from `ui/navigation/bottombars/NavBarItem.kt` | languages: an injected `() -> List<String>` or a small expect/actual; defaults: move the default entry list to `commons/model/navigation`, beside `BottomBarEntry` |
| `GeohashChatIdentityState.kt` | `androidx.core.content.edit`, `LegacySharedPreferences`, `LocalPreferences.LEGACY_WRITES_RETIRED`, `Amethyst.instance.encryptedStorage` | the legacy-prefs read/write is a migration path; put it behind a `GeohashIdentityLegacyStore` port, implemented in the app |
| `AccountZapActions.kt` | `onError: (StringResource, String?)` with `Res.string.bolt12_*` (compose resources, which `commons` cannot see) | a typed error (sealed class) that the UI maps to a string |
| `nip46Signer/Nip46ConsentBridge.kt` | `Res` + `loadStringRes`; `Amethyst.instance.appContext`; the app's `SignerConnectCoordinator` / `SignerConsentCoordinator` / napplet op labels | it is the Android consent-dialog bridge: leave it in the app and inject it into `Account` through an interface |
### Edges that leave the group (14 targets)
| Target | Used for | Proposed cut |
|---|---|---|
| `Amethyst.kt` | `keyCache` (Account), `encryptedStorage` (Geohash), `appContext` (Nip46 bridge), `notificationDispatcher` (EventProcessor) | constructor parameters / ports; the notification dispatcher gets an interface |
| `LocalPreferences.kt` | `saveToEncryptedStorage(accountSettings)` on settings change | a `AccountSettingsPersister` port, implemented in the app |
| `DebugUtils.kt` | `logTime` (2 sites) | move `logTime` to `commons/util` (it is timing + `Log`) |
| `service/MainThreadChecker.kt` | `checkNotInMainThread` (HiddenUsersState) | the same settable hook `LocalCache` already needed |
| `service/location/LocationState.kt` | the `LocationResult` type in `geolocationFlow` and the around-me feed | move the result type to `commons`; the `LocationManager` half stays |
| `service/uploads/FileHeader.kt` | the `FileHeader` data type in three send methods | split: the data class to `commons`, the `MediaMetadataRetriever` reader stays |
| `service/relayClient/…/BuzzMembershipEoseManager.kt` | the `MembershipNotificationKinds` constant | move the constant to `commons/model/buzz` |
| `ui/navigation/bottombars/NavBarItem.kt` | `DefaultBottomBarEntries` | see the table above |
| `ui/screen/loggedIn/DecryptAndIndexProcessor.kt` | `EventProcessor`, built by `Account` | moves with the group (see above) |
| `AccountSecretsStore.kt`, `LegacySharedPreferences.kt` | Geohash identity storage | behind the Geohash port above |
| `connectedApps/consent/*Coordinator.kt`, `napplet/NostrSignerOpLabels.kt` | Nip46 consent bridge | stay in the app with the bridge |
About twenty small cuts, most of them "pass it in" or "move one declaration". None is a
redesign.
## Wave 4, measured: `AccountViewModel`
`AccountViewModel.kt` is 3,303 lines.
- **Android imports.** Its Android imports are `Context` (3 methods take one), `Toast`,
`Uri`, `Handler`/`Looper`, `android.util.LruCache` (5 sites), `NotificationManager` and
`ContextCompat`. It has no `R` references left.
- **`Amethyst.instance`.** It reads the app root in six places: `websocketBuilder`,
`relayStats`, `powPublishQueue`, `localBlossomCacheProbe`, `blossomResolver` and
`appContext`.
- **Other app files.** Outside the `Account` group it depends on 29 app files. They fall into
three kinds:
- **Headless, should move with it:** `RelaySubscriptionsCoordinator`, `ClinkDebitPayer`,
`CallSessionBridge`, `NestBridge`, `MarkChatRoomsAsRead`, `RowUnread`,
`ReloadMintViewModel`, `EventSync`, `CardFeedContentState`, `RoleBasedHttpClientBuilder`.
- **Payment and intent handlers:** `ZapPaymentHandler`, `V4VPaymentHandler`,
`LightningAddressResolver`, `MeltProcessor`, `ZapCustomDialog.payViaIntent`. They take an
Android `Context` to fire payment intents; that needs a `PaymentLauncher` port.
- **Composable files it borrows a type or constant from:** `ZapAmountCommentNotification`
(`MultiSetCompose`), `ZapraiserStatus` (`ReactionsRow`), `NOTIFICATION_LAST_READ_KEY`
(`NotificationScreen`). The declarations move to `commons`; the composables don't have to.
- **Genuinely Android:** `MediaSaverToDisk`, `MarmotGroupIconUploader`,
`dismissNotificationForEvent` (`NotificationUtils`). These go behind ports.
`AccountViewModel` moves after the `Account` group, into `commons/viewmodels` (jvmAndroid
first).
## Sequence
1. **Docs** (this plan, and the rule changes in CLAUDE.md, both ARCHITECTURE files, three
skills and the tracker). Done 2026-09-27.
2. **Cut the `Account` group's seams**, one small PR each, in the app, with no move yet. Every
cut is a behaviour-preserving refactor that compiles and tests on its own:
- the `BuildConfig` parameter;
- `DefaultBottomBarEntries`;
- `MembershipNotificationKinds`;
- `logTime`;
- `checkNotInMainThread`;
- the `LocationResult` and `FileHeader` types;
- the settings-persister, Geohash legacy-store, Nip46-bridge and notification-dispatcher
ports;
- the typed zap error.
3. **Move the group** (77 files) to `commons/jvmAndroid` in one PR. Desktop keeps its
`DesktopIAccount` until the old app is retired; `IAccount` stays as the port it already is.
4. **`AccountViewModel`**: the same recipe, using the dependency list above.
5. **The shared composables and their helpers** (sized in the tracker's 2026-09-27 section):
- `RouteMaker`;
- drop the `accountViewModel` overloads of the `observe*` helpers;
- `DisappearingScaffold`'s immersive-scrolling read goes onto `DisplaySettings`;
- then `UserProfilePicture`, `UsernameDisplay`, `Loaders`, `RichTextViewer`,
`NoteCompose`.
Once `AccountViewModel` is in `commons`, these move without retyping.
6. **Screens**, feature by feature, into `commonsUI`.
7. **Navigation**: the library swap, then `AppNavigation` + rail + drawer + bottom bar.
8. **The app root port** and the new JVM shim. Then the Desktop feature inventory, and
retiring the old `desktopApp`.
Steps 2–5 can interleave. Step 5's helpers can start before 3–4 if they take `Account` /
`AccountViewModel` unchanged and only move later.
## Method (so the numbers can be re-run)
The closure script indexes every public top-level declaration in
`amethyst/src/{main,play,fdroid}`. For each file it follows edges of four kinds:
- explicit imports;
- wildcard imports;
- inline fully-qualified names;
- **same-package references**, which need no import. This is the undercount the tracker's
header warns about.
A same-package lowercase name reached through a `.` counts only when it is an extension. The
lexer blanks strings but keeps `${…}` templates. A file is marked blocked by any of:
- `android.*`, `com.google.*`, or a non-KMP `androidx.*`;
- `R`/`BuildConfig`;
- a library `commons` lacks.
Symbols from `commons.*` that actually live in `commonsUI` (`Res`, `loadStringRes`) are
checked separately. The first pass missed them. Known over-count: same-package token matching
can link a file to a same-named declaration it does not use; the edges above were checked by
hand.
+3 -2
View File
@@ -1,13 +1,14 @@
# commons plans
_Audited 2026-06-30 (+ 2026-09-12 split entry, 2026-09-27 migration entries)._
_Audited 2026-06-30 (+ 2026-09-12 split entry, 2026-09-27 migration and one-UI entries)._
## In progress
| Plan | Summary |
| ---- | ------- |
| [2026-05-04-custom-feeds-plan.md](2026-05-04-custom-feeds-plan.md) | Custom feed creation/discovery/management for Desktop; core model + builder + kind 31890 + desktop UI shipped, but relay-filter layer, DVM marketplace, kind 10090 sync, and list resolution still pending. |
| [2026-05-06-nest-subscription-manager-extraction.md](2026-05-06-nest-subscription-manager-extraction.md) | Split the per-speaker subscription state machine out of `NestViewModel`; only the `ActiveSubscription` stepping-stone is extracted so far. |
| [2026-08-30-commons-migration-sweep.md](2026-08-30-commons-migration-sweep.md) | The running tracker for moving `amethyst/` code into `commons`/`commonsUI`: waves, what moved each round, what stays and why. `LocalCache` has moved; retiring `DesktopLocalCache` (Wave 2 part B) and the `AccountViewModel` hub composables are next. |
| [2026-09-27-one-ui-android-desktop.md](2026-09-27-one-ui-android-desktop.md) | **The target.** Android ships on laptops, so the whole UI (screens + navigation shell) moves to `commonsUI`, `amethyst` becomes an Android shim, and a new JVM `desktopApp` renders the same UI. Measures Wave 4: `Account`'s 77-file move-group, its 5 blocked files and 14 seams, and `AccountViewModel`'s dependencies; sequences the rest. |
| [2026-08-30-commons-migration-sweep.md](2026-08-30-commons-migration-sweep.md) | The running log of moving `amethyst/` code into `commons`/`commonsUI`: waves, what moved each round. `LocalCache` has moved. Its STAY list and Wave 2 part B are superseded by the one-UI plan above. |
| [2026-05-30-amethyst-to-commons-migration.md](2026-05-30-amethyst-to-commons-migration.md) | The original roadmap for the same move; superseded in practice by the sweep tracker above. |
## Queued
+6
View File
@@ -17,6 +17,12 @@ therefore useless to the headless `cli`:
its `LazyListState`, `ChatNewMessageState` with `TextFieldValue`,
`EmojiSuggestionState` with `TextFieldState`).
Its end state is **the whole app UI**: every screen, the navigation host and the
navigation chrome for every window size (bottom bar, rail, permanent drawer), with
`amethyst` and a new JVM `desktopApp` as thin shims around it. Screens still in
`amethyst/` are waiting on `AccountViewModel` and the app root, not staying there
by design. See `commons/plans/2026-09-27-one-ui-android-desktop.md`.
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`