Files
amethyst/commons/plans/2026-08-30-commons-migration-sweep.md
T
Claude 3d03c82970 fix: audit follow-ups for the commons move - Compose string whitespace, backup labels, store edge cases
The one real regression predates this branch: Compose resources draw a value's
XML whitespace verbatim, where aapt collapsed it. Values wrapped over indented
lines rendered with a leading line break and eight spaces, which turns the
account-backup tips' markdown heading into a code block, and Crowdin's stray
translator edge spaces (" miejsca zniknęły", Hindi's " दि॰" suffix) showed up.
fix_escapes.py now applies aapt's rule to line-break/tab runs and trims an
edge space a translation has but its source lacks. The Crowdin workflow
already runs it after each sync, and compose_escaping_check.py now fails on raw
line breaks. 598 values across 54 files repaired.

Also:
- rememberPresentation resolves only the labels its diff type writes, instead
  of all 16 on every recomposition. A test pins the list against presentationOf.
- ScheduledPostStore: a failed stat is "no file" (okio posix throws on EACCES
  where File.exists() did not), symlinked stores still count, no chmod against
  an injected non-system FileSystem, the cleanup log keeps its throwable.
- ClickableEmail percent-encodes '%' in the mailto: URI.
- Dropped a misleading WatchScrollToTop import, a stray blank line, and a stale
  doc path; noted the Base64 pad-bit difference beside the decoder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY
2026-09-26 22:03:54 +00:00

88 KiB
Raw Blame History

Full sweep: amethyst/ → :commons migration candidates

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 → commons/service/http, 37 model/service singles, the IFeedTopNavFilter → ICacheProvider signature fix, TopFilter extracted out of AccountSettings.kt, and 34 topNavFeeds files reunited with their commons half. All app/desktop/cli targets compile. Corrections found while executing are folded into the sections below; the biggest one: import-graph analysis under-counts blockers — same-package files use Account/LocalCache/each other without imports (extension receivers included), so several "clean" files (AccountMarmotActions, EventBroadcaster, ParticipantListBuilder, UnexpectedCrashSaver, the Blossom fetchers) are actually Account/LocalCache-coupled and stayed. The ui/theme + ui/layouts batch also does NOT move mechanically: Theme.kt is Android-coupled (Activity, UiModeManager, app font/theme prefs enums) and the layouts sit on app-side theme constants + stringRes — that whole cluster belongs to the strings/theme wave.

Refined LocalCache recipe (next big step, needs maintainer input): the move-group is LocalCache + AntiSpamFilter (android LruCache → androidx.collection) + CachePruner + CacheSearch + MiniFhir + OnchainZapResolver, into commons jvmAndroid (which legalizes its java.io.File NIP-95 spill as-is). Seams to cut: Amethyst.instance (2 sites → injected scope), app isDebug (2 → settable flag), checkNotInMainThread (→ settable hook or expect), ui.note.dateFormatter (1 log line). The open design question: CachePruner/CacheSearch call Account.isFollowing(...) and read account.hiddenUsers.flow.value. hiddenWordsCase — IAccount already has isHidden/hiddenWordsCase but lacks isFollowing, so either IAccount grows it (DesktopIAccount must implement) or the pruner/search take a narrower ISP interface.

Date: 2026-08-30 Scope: every Kotlin source file in the amethyst Android module (amethyst/src/main, 2,347 files), audited for "can and should move to :commons", cross-checked against what commons/ and desktopApp/ already contain.

Method

  1. Static classification of all 2,347 files by imports:
    • Android-dirty = imports android.*, com.google.*, or androidx.* other than the KMP-safe set (compose, lifecycle, annotation, paging, collection), or uses androidx.navigation.
    • R-dirty = imports com.vitorpamplona.amethyst.R or references R.string/R.drawable/R.raw (incl. via stringRes).
  2. Transitive closure over the module-internal import graph: a file is Tier A iff it and everything it references inside the module are clean of both. Tier B = clean once its strings move to commons Compose resources.
  3. Six deep area audits (model, service, service/relayClient+okhttp, ui shared components, ui/screen, napplet/connectedApps/misc) verifying verdicts file-by-file and hunting for desktopApp reimplementations.

Headline numbers

Metric Count
Files in amethyst/src/main 2,347
Directly Android/R-clean 1,324
Tier A — transitively clean, movable now 509
Tier B — movable once their strings migrate +21
Files that genuinely touch an Android API ~14% of ui/
ViewModels app-side vs migrated to commons 138 vs 11
Strings migrated to commons Compose resources 152 / 4,417

The dominant blockers are not Android APIs. They are five hub types that are themselves Android-import-free but still live in the app module, plus the string-resource bridge:

Blocker Files it transitively blocks Nature
model.Account 254 Android-clean god-object (184 vals, 218 funs) — decompose, don't move
ui.screen.loggedIn.AccountViewModel 180 genuinely Android-coupled (9 android imports, 36 R refs) — shrink, don't move
ui.navigation.navs.INav / routes.Route 173 platform nav — correctly stays
model.LocalCache 115 Android-clean; only 3 trivial dirty deps
ui.stringRes (StringResourceCache) ~210 direct users needs a Compose-resources twin
model.TopFilter (declared inside AccountSettings.kt!) 39 clean sealed class in the wrong file

The five highest-leverage edits

Each of these is small and unlocks a large batch:

  1. service/relays/EOSE.kt → finish the move. 4 of its 6 declarations are already typealiases into commons/relays; SincePerRelayMap (androidx.collection.LruCache — KMP-safe) and EOSEAccountKey remain. Moving them unblocks ~145 subassemblies/ filter files at once (97% of that layer is already Tier A).
  2. IFeedTopNavFilter signature fix. model/topNavFeeds/IFeedTopNavFilter.kt:34,36 hard-codes cache: LocalCache in toPerRelayFlow/startValue, poisoning all 23 *TopNavFilter implementors. Change to the existing commons ICacheProvider port → the app half of topNavFeeds reunites with the 25-file half already in commons/model/topNavFeeds/.
  3. Move LocalCache (3,980 lines). It's object LocalCache : ILocalCache, ICacheProvider, Dao with zero Android imports. Its entire dirty-dep set: MainThreadChecker (Looper → expect no-op on JVM), a dateFormatter log line, Amethyst.instance scope (inject), BundledInsert (commons BundledUpdate already exists), NIP-95 java.io.File blob spill (expect sink), and hoisting the inline ILocalCache interface. This deletes DesktopLocalCache.kt (1,173 lines) — the single largest duplication in the repo.
  4. Extract TopFilter + a ListBackupStore port out of AccountSettings.kt. AccountSettings (1,900 lines) mixes the backupXList/updateXList persistence port with nav-shape types (NavBarItem, DrawerItemVisibility) and the TopFilter sealed class (line 102). The port + ICacheProvider unlock all of model/nip51Lists (31 files) and ~18 one-file model/nipNN packages — the commons copies of Nip65RelayListState etc. prove the recipe is purely mechanical (LocalCache → ICacheProvider, drop AccountSettings).
  5. A Compose-resources stringRes twin. ui/StringResourceCache.kt defines the six stringRes() overloads + painterRes on android.util.LruCache + R ids. commons already has the translated composeResources pipeline (152 strings so far). This is the gate on ~210 otherwise-clean composables/ViewModels.

MOVE-NOW batches (no prerequisites)

These are Tier A today; grouped as reviewable PRs. Verified per-file by the area audits.

Batch 1 — relay client (highest confidence, zero risk)

  • service/relayClient/eoseManagers/: PerUserEoseManager, PerUserAndFollowListEoseManager, PerUniqueIdEoseManager, AccountScopedSingleSubNoEoseCacheEoseManager → commons/relayClient/eoseManagers (they extend commons BaseEoseManager already; deps User, SincePerRelayMap are commons or move with Batch 1).
  • reqCommand/account/ pure filterXxx() functions (~9 files: FilterDraftsAndReportsFromKey, FilterBasicAccountInfoFromKeys, FilterBookmarksAndReportsFromKey, FilterFollowsAndMutesFromKey, FilterLastPostsFromKey, FilterAccountInfoAndListsFromKey, FilterNotificationsToPubkey, FilterCashuHistoryToPubkey) + the account EOSE managers that consume them → commons/relayClient/account.
  • reqCommand/channel/ filters + watchers (FilterChannelMetadata*, FilterLiveStreamUpdatesByAddress, watcher sub-assemblers) → commons/relayClient/channel.
  • reqCommand/nwc/ (FilterNWCPaymentsFromRequests, NWCPaymentFilterAssembler, NWCPaymentWatcherSubAssembler) → commons/relayClient/nip47WalletConnect.
  • searchCommand/subassemblies/SearchPeopleByName, SearchPostsByText → commons/relayClient/search. Desktop's SearchFilterFactory.kt:56 literally comments "ported from Android SearchPostsByText".
  • chatDelivery/, speedLogger/ → commonMain; diagnostics/ → jvmAndroid.
  • TorCircuitHealthTracker → commons/relays/health.
  • authCommand/model (9 of 11 files) → merge into the existing commons/relayClient auth types (AuthApprovalPolicy/AuthApprovalRequests are a thinner second implementation of the same concern — reconcile, don't copy). DataStoreRelayAuthPermissionStore stays as the Android actual.

Batch 2 — the ~145 ui/screen/**/subassemblies/ filter files

Pure fun filterXByY(relay, …): List<RelayBasedFilter> functions, 97% Tier A, already importing commons.relayClient.subscriptions.ExplainedFilter. Sole prerequisite: SincePerRelayMap (Batch 1). Desktop reimplements 34 of them by hand in desktop/subscriptions/FilterBuilders.kt (742 lines).

Batch 3 — okhttp stack → commons/service/http (jvmAndroid)

16 of 20 files in service/okhttp/ move as-is: IHttpClientManager, DualHttpClientManager(+ForRelays), OkHttpClientFactory(+ForRelays), all 8 interceptors, both event listeners, OnionLocationCache, OkHttpDebugLogging. Plus model/privacyOptions/ (4 of 5: role-based client builders, ProxiedSocketFactory). okhttp3 is an established commons jvmAndroid dep (BlossomClient, lnurl, UrlPreview) with no shared client manager today.

  • EncryptionKeyCache needs android.util.LruCache → KMP cache first.
  • IsEmulator stays (androidMain actual).
  • OkHttpWebSocket is production-dead (only androidTest usages) and duplicates quartz's BasicOkHttpWebSocket — migrate the tests, delete.
  • Closes a real desktop gap: desktop/network/DesktopHttpClient.kt re-implements the dual direct/SOCKS client but is missing every interceptor (onion-location, Blossom auth, encrypted blobs).

Batch 4 — theme, layouts, feed shell (UI quick wins, no string work)

  • ui/theme/ (Theme.kt 780 lines, Shape.kt, Type.kt, Color.kt) → commons/ui/theme. commons' theme package is only a fragment (no ColorScheme/Typography/Shapes); desktop re-declares the entire palette in desktop/platform/PlatformColorScheme.kt — two divergent Amethyst palettes ship today.
  • ui/layouts/: NoteComposeLayout (374), RepostLayout, ScreenLayout are 100% clean; LeftPictureLayout, ChatHeaderLayout, SlimListItemLayout need only content-description strings. DisappearingScaffold → SPLIT (drop the AccountViewModel param for a slot; its nested-scroll half is already in commons).
  • ui/feeds/ (12 of 14): FeedStates, RefresheableBox, WatchScrollToTop, RememberForeverStates, ChannelFeedContentState, WatchLifecycleAndUpdateModel have zero blockers; FeedEmpty/Error/Loading/UserBlockedFeed need 2-3 strings each. Desktop hand-rolls its own empty/error/loading states.
  • ui/components/ 20 zero-blocker files: the Clickable* family, M3ActionDialog, OutlinedThinPaddingTextField, SlidingCarousel, AudioWaveformReadOnly, LatexEquation, FileAttachmentCard, pdf/PdfFetcher, and toasts/ (6 of 9 — despite the name it's a Compose snackbar queue, not Android Toast).
  • navigation/topbars/ chrome (ShorterTopAppBar, TopBarWithBackButton, ActionTopBar, AmethystClickableIcon) → commons/ui/layouts.

Batch 5 — model + misc clean files

  • model/ root, 14 files: HomeFeedType, VideoPostKind, HashtagIcon, ConcordInviteResult, NoteEditOverlays, PrivateChatroomReadState, RelayGroupContentRouting, MutedPublicChats, ParticipantListBuilder, LargeSoftCacheAddressExt, Dao, EventBroadcaster → commons/model; AccountMarmotActions, AccountRelayGroupActions → commons/actions.
  • model/nip64Chess/ChessAction → commons/nip64Chess.
  • model/nip03Timestamp (4 of 6: OTS settings, explorer endpoints, verification, resolver builder) → commons/model/nip03Timestamp.
  • model/marmot/InMemoryMlsGroupStateStore → commons/marmot.
  • model/nip47WalletConnect/NwcInfoCache → commons/model/nip47WalletConnect.
  • ui/dal/AdditiveComplexFeedFilter (6-line abstract class, zero imports) → commons/ui/feeds; DefaultFeedOrderEvent likewise.
  • napplet clean half: NappletRelayCleartext (pure NIP-04/44 → quartz or commons/napplet/protocol), NappletLaunchRegistry, NappletNotificationStore, NappletIdentityWatch → commons/napplet (jvmAndroid).
  • service/namecoin/NamecoinNameService → commons/service/namecoin. Desktop's DesktopNamecoinNameService.kt says "Same functionality as the Android NamecoinNameService… mirrors AppModules#buildNamecoinBackend exactly" and is the superset — merge into it, not alongside it.
  • service/images/ fetchers (7 of 11: Base64Fetcher, BlurHashFetcher, ThumbHashFetcher, ProfilePictureFetcher, BlossomFetcher, BlossomReadAuthFetcher, DeferredDeleteFileSystem) → commons/service/image (jvmAndroid). Desktop clones four of them file-for-file; commons/{blurhash,thumbhash,base64Image} actuals already exist to sit under them.
  • service/resourceusage/ 13 of 18 files (UsageKeys, UsageSummary, ResourceUsageStore, ResourceUsageAccountant, ResourceUsageAlerts, RefCountedSession, MeteringNostrSigner, BatteryDrainSampler, ResourceUsageReportAssembler, time integrators, RelayUsageListener) → commons/service/resourceusage (only SystemClock → TimeUtils swaps).
  • service/cashu/ → commons/cashu (rest of the wallet is already there); CachedCashuParser/MeltProcessor need the KMP LRU.
  • Small singles: PodcastRemoteContent → commons/podcasts; BuzzInviteMinter → commons/actions or commons/buzz; WritingAssistant (pure interface) → commonMain; DetectedWorkout + WorkoutMerger (197 lines of merge logic) → commons/workouts; ConnectivityStatus + ConnectivityManager flow plumbing → commonMain with the Android ConnectivityFlow as actual; crashreports logic (UnexpectedCrashSaver, CrashReportCache, DevReportContact) → jvmAndroid (desktop currently has no crash reporting); ScheduledPostWorkGate → commons/scheduledposts (desktop duplicates the drain gating); PowJobStore/PowJobRestorer → commons/service/pow (desktop lacks PoW persistence entirely); service/uploads/ clean half (ImageDownloader, MediaUploadResult, MediaMimeTypes, SuspendableConfirmation, blossom/*, hls/* builders, nip96/ServerInfoRetriever) → commons/service/upload (jvmAndroid).
  • ui/tor/ non-service half (8 files: TorSettings(+Flow), TorServiceStatus, TorBackend, ArtiGuardState, TorPreferencesPort, TorManager, TorDialogViewModel) → fold into existing commons/tor.
  • ui/screen root strays: AccountState, UserFeedState, DebouncedPublisher, relays/common/RelaySuggestionState, embed/SelectionUiState.
  • ui/screen/loggedIn/discover/ is 67% Tier A — near-wholesale move of its datasource + subassemblies layers.

Delete-only (shims whose move already happened)

model/Note.kt, model/User.kt, model/HashtagIcon.kt, model/torState/TorRelaySettings.kt, model/torState/TorRelayEvaluation.kt, ui/dal/FeedFilters.kt, ui/dal/ChangesFlowFilter.kt, ui/feeds/FeedStates.kt (partly), service/BundledUpdates.kt, service/relays/EOSE.kt (after Batch 1), ui/screen/FeedViewModel.kt, chats/privateDM/dal/ChatroomFeedViewModel.kt + ChatroomFeedFilter.kt, threadview/dal/LevelFeedViewModel.kt, reqCommand/user/UserFinderShims.kt, reqCommand/event/EventFinderShims.kt — 13+ typealias re-export files. Rewrite importers to the commons FQNs and delete.

Also: adopt commons/util/toTimeAgo and delete ui/note/TimeAgoFormatter.kt (377 lines on android.text.format.DateUtils — the app and desktop currently render time-ago with two divergent formatters).

MOVE-AFTER (blocked, with the named blocker)

Area Files Blocker(s) Target
ui/screen/**/dal/ feed filters + thin VMs ~114 Account (106 of 109 block on it alone), LocalCache commons/ui/feeds + commons/viewmodels
ui/screen/**/datasource/ assemblers ~168 AccountViewModel, TopFilter, app-side eoseManagers commons/relayClient
model/nip51Lists/ state classes 18 ICacheProvider swap + ListBackupStore port commons/model/nip51Lists
model/topNavFeeds/ 37 the IFeedTopNavFilter signature edit commons/model/topNavFeeds
~18 one-file model/nipNN state pkgs (nip17Dms, nip65RelayList, nipB7Blossom, edits, localRelays, zap, buzz, algoFeeds, trustedAssertions, …) ~25 same two ports; four already have commons twins — reconcile-and-delete, don't copy (nip65RelayList, nipB7Blossom, nip30CustomEmojis, nip72Communities) matching commons/model/nipNN
model/serverList, model/nip01UserMetadata, model/nip02FollowLists 17 LocalCache commons/model/…; replaces desktop's hand-rolled DesktopAccountRelays (250 lines)
AntiSpamFilter, MediaAspectRatioCache, UrlCachedPreviewer, Nip11CachedRetriever 5 KMP LruCache expect (or quartz LargeCache) commons/model, commons/util
relays/ list-editing VMs (17 *RelayListViewModel) ~20 2-3 R.string toasts each commons/viewmodels; desktop hand-rolls all seven relay editors today
wallet/WalletViewModel (750 lines, zero android imports) 1 19 R.string refs + Amethyst singleton commons/viewmodels
SearchBarViewModel (435) 1 Account + Route (make route-building a callback) commons/viewmodels (its SearchBarState is already there)
chess/ChessViewModelNew 3 trivial — desktop's DesktopChessViewModelNew.kt is line-for-line the same class commons/nip64Chess
ui/note/types/ kind renderers ~89 AccountViewModel threading (81 files) + strings; introduce a NoteRenderContext/callback bundle commons/ui/note (22 files there prove the pattern)
ReactionsRow (2,602 lines) + note/buttons/ ~10 30 strings, 1 stray Context import commons/ui/note — desktop has zero reactions/zaps UI today
UsernameDisplay, NIP05VerificationDisplay, UserProfilePicture 3 strings; merge avatar stack into commons UserAvatar commons/ui/note
RichTextViewer family ~8 strings; commons ui/richtext exists and desktop already uses it — reconcile the app's fork commons/ui
notifications state (CardFeedContentState, CardFeedState, NotificationSummaryState, OpenPollsState) 6 Account; commons ui/notifications/CardFeedState is the other half of a half-done move commons/ui/notifications
6 structurally-identical *MetadataViewModels (lists, followPacks, bookmarkgroups, emojipacks, interestSets, nip28 channel) 6 one shared error-enum extraction (8 R.string + Context each) commons/viewmodels
uploads core (UploadOrchestrator 525, MediaCompressor, MetadataStripper) ~8 Uri/ContentResolver → stream core; Int string-res errors → typed enum; promote commons' jvmMain orchestrator to jvmAndroid commons/service/upload — today two independent orchestrators exist
ZapPaymentHandler (580) / V4VPaymentHandler (295) 2 split zap-split math + NWC/LNURL sequencing (shareable) from Context/string error surface commons/model/nip57Zaps / commons/onchain
playback policy leaves (SimultaneousPlaybackCalculator, VideoViewedPositionCache, AutoReplayLimiter, HlsLivenessCache, LowLatencyHlsStripper, websocket/Wss*) ~7 extract from the 71-file media3 package commons/service/playback
push logic (PushWrapDecryptor, RegisterAccounts) 2 none real — just entangled with FCM glue commons/service/push
napplet/NappletLiveSubscriptions, gateways/AccountIdentityReader, NappletManifestLookup 3 Account / inject cache commons/napplet
ui/broadcast/ banners 3 strings + theme + AccountViewModel; logic already in commons/service/broadcast commons/service/broadcast/ui
post composers (ShortNotePostViewModel 2,046, *NewMessageViewModel, HLS/music authoring) ~20 deep: uploads core + location + StringProvider + Amethyst singleton last wave

STAY (correctly platform-native)

  • 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).
  • Process/DI roots: Amethyst.kt, AppModules.kt, EncryptedStorage, LocalPreferences, DebugUtils, model/accountsCache, model/preferences/ (the one genuinely-Android model package: DataStore/ Keystore actuals — but define their ports in commons).
  • Media & capture: service/playback/ (media3/MediaSession/PiP — desktop's player is a genuinely different implementation, not a port), ui/actions/uploads pickers/camera/voice, creators/ capture files, Zoomable/PDF viewers, GifVideoView.
  • Android services: notifications (channels/FCM/TileService), calendar (WorkManager), location (LocationManager — LocationGeoHash caches could move), tts, cast (SDK), nests foreground service, foreground/eventCache/ priority/logging (Choreographer/StrictMode instrumentation), workouts' Health Connect half.
  • napplet broker & sandbox (Messenger IPC, WebView, consent activities), connectedApps/ (the shareable signer/nip46 halves are already in commons; what remains is DataStore actuals + activities), favorites/ registries (models already extracted to commons/favorites).
  • Flavor sources src/play/ + src/fdroid/ — they exist precisely to encode the Play-vs-FOSS split; only interfaces they implement may move.
  • StringResourceCache, SafeImeInsets, NwcResponseMessages — the Android halves of i18n/IME bridges.

Desktop duplication catalog (the "should" evidence)

Desktop file Hand-rolled copy of
cache/DesktopLocalCache.kt (1,173) model/LocalCache.kt (3,980)
model/DesktopIAccount.kt (539) the ~14% of Account.kt a second front end needs
subscriptions/FilterBuilders.kt (742) ~34 ui/screen/**/subassemblies/ filter fns
subscriptions/SearchFilterFactory.kt searchCommand/subassemblies/SearchPostsByText (self-documented port)
subscriptions/{FeedSubscription,ProfileSubscription,FilterDMs,ChessSubscription}.kt reqCommand watchers / gift-wrap / profile filters
feeds/DesktopFeedFilters.kt (404) + DesktopFeedViewModel.kt ui/screen/**/dal/ feed filters
model/DesktopAccountRelays.kt (250) model/serverList/ + model/nip01UserMetadata/ relay states
model/{DesktopHiddenUsersState,DesktopDmRelayState,BlossomServers}.kt nip51Lists/HiddenUsersState, nip17Dms/*, nipB7Blossom/*
chess/DesktopChessViewModelNew.kt (203) chess/ChessViewModelNew.kt (215) — line-for-line
service/namecoin/DesktopNamecoinNameService.kt service/namecoin/NamecoinNameService.kt (says so in its KDoc)
service/images/Desktop{Base64,BlurHash,ThumbHash}Fetcher.kt service/images/ fetchers
network/DesktopHttpClient.kt DualHttpClientManagerForRelays (minus all interceptors — a live behavior gap)
platform/PlatformColorScheme.kt ui/theme/Theme.kt palette
ui/relay/*Editor.kt (7 files) relays/*RelayListViewModel edit logic
ui/notifications/NotificationGroup.kt notifications/ MultiSetCard grouping
ui/thread/ (784) threadview dal + UI
ui/search/ (1,495) + search/DesktopRelayUserSearchDelegate.kt SearchBarViewModel
network/RelayConnectionManager.kt::RelayMetrics speedLogger/ telemetry subset
Desktop has no reactions/zaps row, no crash reporting, no PoW persistence gaps that sharing closes for free
  1. Wave 0 (mechanical, this branch's follow-ups): delete the 13+ typealias shims; land Batches 1-5 above (~350 files) — no refactoring required.
  2. Wave 1 (three small edits, huge fan-out): finish EOSE.kt; IFeedTopNavFilter → ICacheProvider; add KMP LruCache expect. Unlocks subassemblies (145 — landed 2026-09-05, see the handoff below), topNavFeeds (23), caches (5).
  3. Wave 2 (ports): extract TopFilter + ListBackupStore from AccountSettings; move LocalCache behind its 3 tiny expects. Unlocks nip51Lists, the nipNN state packages, serverList/userMetadata, ui/screen/**/dal/ — and deletes DesktopLocalCache.
  4. Wave 3 (strings): build the Compose-resources stringRes twin, then migrate strings feature-by-feature with their composables (theme, layouts, feed shell first — they need no strings at all and can go in Wave 0).
  5. Wave 4 (decompose the god-objects): grow IAccount and migrate Account's six concern groups (relay-set derivation, nip51 state, the 30-feed-type × 2 field explosion → a Map<FeedType, FollowListPair>, relay-auth policy, buzz state, the 218 action methods → commons/actions) until app-side Account is only composition wiring. Shrink AccountViewModel the same way. This is what unblocks the remaining ~400 datasource/ + ui/note/types/ files.
  6. Wave 5 (deep platform seams): uploads core (Uri→stream + typed errors), zap payment handlers, playback policy extraction, post composers.

jvmAndroid audit: what the migration parked there, and what can promote to commonMain

Everything below landed in commons/src/jvmAndroid (or androidMain) during Waves 0-1. For each file: the exact JVM-only API pinning it there, and whether the repo already has a KMP replacement. Replacements referenced: KmpLock/withLock (commons/util), TimeUtils.nowMillis() (quartz), kotlin.concurrent.atomics (stdlib KMP, already used in quartz BLE), quartz/utils/concurrent/ConcurrentMap + ConcurrentSet (expect/actual), RandomInstance (quartz secure random), kotlin.time.TimeSource.Monotonic (used in quartz RelayProber), okio (KMP), kotlinx-serialization + commons/util/JsonTreeUtils, and the PlatformImage expect (android + jvm + ios actuals in commons/blurhash).

Tier 1 — promotable to commonMain by moving the file (no code change)

Executed on this branch: the 7 auth files, EncryptionKeyCache and HttpClientEnvironment now live in commonMain (verified against JVM, iOS and verifyKmpPurity). NWCPaymentWatcherSubAssembler turned out to use NWCPaymentQueryState from its pinned same-package sibling — reclassified to Tier 2.

File JVM pin Note
relayClient/auth/ RelayAuthPermissionCache, RelayAuthPermissionLedger, RelayAuthSessionGrants, InMemoryRelayAuthPermissionStore, RelayAuthVenues, RelayAuthPurposeDeriver, RelayAuthFirstParty (7 files) none Zero JVM imports; no deps on the two pinned siblings. Wave 0b parked the whole group conservatively.
relayClient/nip47WalletConnect/NWCPaymentWatcherSubAssembler none directly — but uses NWCPaymentQueryState, declared same-package in the pinned assembler Reclassified to Tier 2: moves with NWCPaymentFilterAssembler.
service/http/EncryptionKeyCache none androidx.collection LruCache is commonMain-safe.
service/http/HttpClientEnvironment none Plain object; conceptually pairs with the factories but nothing pins it.

Tier 2 — promotable with mechanical one-line swaps (replacement exists in-repo)

Executed on this branch, with a concurrency review per file rather than blind swaps. Locks that guarded nothing were removed instead of ported: RelayAuthPromptBus is now lock-free (ConcurrentMap.getOrPut + identity-check ownership), and NappletLaunchRegistry dropped all three @Synchronized by replacing its JVM-only access-ordered LinkedHashMap with androidx.collection.LruCache (internally synchronized, access-ordered cap — same semantics). Locks that protect real multi-field invariants stayed as KmpLock deliberately: ChatDeliveryTracker (its hot OK path was already lock-free via volatile immutable maps; CAS-ing the three coordinated structures would copy maps per write — GC churn for zero contention win), NWCPaymentFilterAssembler (debounce set + job swapped atomically), NappletNotificationStore (per-coordinate ordered buckets), DeferredDeleteFileSystem (pending-set membership must decide deletion atomically). java.util.concurrent atomics/CHM became stdlib kotlin.concurrent.atomics + quartz ConcurrentMap (which grew putIfAbsent/remove(key,value)/clear for the leader-follower caches). Extra pins found while executing: speedLogger used kotlin.concurrent.timer and ::class.java (the tick now runs on a cancellable coroutine scope — the old daemon timer outlived destroy()); OnionLocationCache, BlossomReadAuthTokenProvider, DmRelayDiagnosticsLogger, NappletNotificationStore swapped to TimeUtils.nowMillis(). Bonus promote: service/upload/BlossomAuth (quartz-only imports). Two reclassified to Tier 4: NappletIdentityWatch (depends on NappletProtocolJson, pinned by java.util.Base64) and NamecoinNameService (quartz ElectrumXClient is jvmAndroid).

File JVM pin KMP replacement
relayClient/auth/RelayAuthPromptBus synchronized() KmpLock.withLock
relayClient/auth/ListWithUniqueSetCache AtomicReference kotlin.concurrent.atomics.AtomicReference
relayClient/chatDelivery/ChatDeliveryTracker @Volatile + synchronized() ×9 KmpLock + stdlib atomics
relayClient/speedLogger/FrameStat, KindGroup AtomicInteger kotlin.concurrent.atomics.AtomicInt
relayClient/speedLogger/RelaySpeedLogger none (blocked by the two above) moves with them
relayClient/diagnostics/DmRelayDiagnosticsLogger System.currentTimeMillis TimeUtils.nowMillis()
relayClient/nip47WalletConnect/NWCPaymentFilterAssembler @Volatile + synchronized() KmpLock + atomics
model/nip47WalletConnect/NwcInfoCache ConcurrentHashMap quartz ConcurrentMap
marmot/InMemoryMlsGroupStateStore ConcurrentHashMap quartz ConcurrentMap
napplet/NappletIdentityWatch ConcurrentHashMap quartz ConcurrentMap
napplet/NappletLaunchRegistry SecureRandom, @Synchronized RandomInstance.bytes(), KmpLock
napplet/NappletNotificationStore ConcurrentHashMap, AtomicLong, synchronized(), millis ConcurrentMap + atomics + KmpLock + TimeUtils
service/namecoin/NamecoinNameService @Volatile ×1 atomics (verify the injected resolver ifaces are commonMain)
service/http/BlossomReadAuthTokenProvider ConcurrentHashMap, millis ConcurrentMap + TimeUtils.nowMillis()
service/http/OnionLocationCache ConcurrentHashMap, TimeUnit, millis ConcurrentMap + plain-ms TTL + TimeUtils
service/image/DeferredDeleteFileSystem java.io.IOException, synchronized() already okio-based → okio.IOException + KmpLock

Tier 3 — promotable with a small refactor

File JVM pin Path
relayClient/diagnostics/BootRelayDiagnostics kotlin.concurrent.thread, ConcurrentHashMap, atomics, millis swap thread → coroutine launch; rest is Tier-2 swaps
actions/BuzzInviteMinter Jackson ObjectMapper, OkHttp call Jackson → kotlinx-serialization (JsonTreeUtils landed in commonMain); inject a suspend (url, body) -> String fetch or Ktor client
podcasts/PodcastRemoteContent OkHttp call inject a fetch function or Ktor client
[androidMain] Base64Fetcher, BlurHashFetcher, ThumbHashFetcher android Bitmap bridge (toAndroidBitmap) coil3 core is KMP and PlatformImage has android/jvm/ios actuals — add an expect PlatformImage → coil3.Image bridge; deletes desktop's three clone fetchers

Tier 4 — blocked by a dependency that must move first

File Blocker
scheduledposts/ScheduledPostWorkGate ScheduledPostStore (pre-existing jvmAndroid: Jackson + java.io.File) — store needs kotlinx-serialization + okio first
model/cache/LargeSoftCacheAddressExt LargeSoftCache (WeakReference, ConcurrentSkipListMap) — soft/weak-reference caching has no KMP equivalent; a native actual would change eviction semantics. Long-term expect/actual candidate.
model/nip03Timestamp/BitcoinExplorerEndpoint, OtsSettings, TorAwareOkHttpOtsResolverBuilder quartz's own OTS explorer/calendar clients are OkHttp-only (quartz…nip03Timestamp.okhttp.*) — quartz needs a KMP (Ktor) OTS transport before these can follow

Tier 5 — stays jvmAndroid by design (typed against OkHttp / java.net)

service/http/: IHttpClientManager, IRoleBasedHttpClientBuilder, DualHttpClientManager(+ForRelays), OkHttpClientFactory(+ForRelays), Empty/SingleRoleBasedHttpClientBuilder, ProxiedSocketFactory, and the interceptor/listener set (BlossomReadAuth, EncryptedBlob, LocalBlossomCacheRedirect, OnionLocation, OnionUrlRewrite, DefaultContentType, Logging, DnsInvalidatingEventListener, MediaCallEventListener, OkHttpDebugLogging), plus service/image/BlossomReadAuthFetcher (coil-network-okhttp).

These are the OkHttp engine itself: okhttp3.Interceptor/EventListener types, java.net.Proxy/Socket. The KMP path is a Ktor-client rewrite (Ktor 3.5.2 is already in the catalog, server-side only today), but quartz's relay websockets are equally OkHttp-bound on jvmAndroid, so an iOS transport story has to land in quartz first; rewriting commons alone buys nothing. Revisit when quartz grows a non-JVM socket/HTTP engine.

Score: of the 59 files parked, 11 move with zero code change, 17 with one-line in-repo swaps, 6 with small refactors, 5 wait on a dependency, and 20 are the OkHttp engine that should stay until quartz has a KMP transport.

Corrections to commons/ARCHITECTURE.md found during the sweep

  • §2 is stale: commons/service also holds pow/, georelay/, broadcast/; several service-ish concerns live as top-level packages (scheduledposts, cashu, podcasts, audio, connectedApps, favorites, napplet, browser) — the service/ vs top-level split deserves a stated rule.
  • commons/model/nip02FollowList (singular) vs app model/nip02FollowLists (plural) — reconcile on the quartz name when merging.

Session handoff — state as of 2026-09-01 (PR #4025)

Everything below is the live state for whoever picks this up next; the sections above are the original audit and stay as reference.

What is DONE and pushed (branch claude/amethyst-commons-migration-hm8vgm)

  • Waves 0 + 1 (~145 files moved to :commons, 12 typealias shims deleted, importers rewritten repo-wide). See the batch tables above.
  • jvmAndroid promotion Tiers 1–3 (the 5-tier table above, annotated per-tier): ~28 more files promoted to commonMain, incl. the diagnostics stack (BootRelayDiagnostics, RelaySpeedLogger, DmRelayDiagnosticsLogger), the image fetchers (Base64/BlurHash/ThumbHash via the new CoilImageBridge expect/actual, three Desktop clones deleted), and the Tier-2 concurrency review (2 locks removed, 4 kept as KmpLock with documented invariants; quartz ConcurrentMap gained putIfAbsent/remove(k,v)/clear).
  • Merged main twice; second merge brought the #4026 Shorts rename, and commit 5cf47f6f dropped the 30 orphaned route_video/new_short translation entries (15 locales × 2 keys) that were failing :amethyst:lintFdroidBenchmark on main and every branch. Main is still red until that cleanup lands there — cherry-picking 5cf47f6f fixes it.
  • CI fully green on head 5cf47f6f; PR #4025 mergeable_state clean.

Audit findings (2026-09-01 review) — 5 of 6 FIXED on the branch

Findings 1, 2, 3, 5 and 6 below are fixed and covered where testable (TopFilterSerialNameTest in commons commonTest pins the pre-move serial names and legacy-JSON decoding on JVM and iOS). The one still open:

  • Baseline profile stale (finding 4) — baseline-prof.txt has ~251 rules naming pre-move classes; regenerating requires the :baselineprofile macrobenchmark on a device, which this environment cannot run. Regenerate before or shortly after release.

Original findings, for reference:

  1. [ship-blocker] TopFilter serial names changed — the move to commons.model.topNavFeeds changed every subclass's kotlinx default serial name; persisted per-tab feed-filter prefs (written by JsonMapper.toJson, read via parseTopFilterOrDefault which swallows decode errors) silently reset for every user on upgrade. Fix: add @SerialName("com.vitorpamplona.amethyst.model.TopFilter.…") (old FQNs) to each subclass.
  2. HttpClientEnvironment.isEmulator set too late — Amethyst.onCreate builds AppModules (which eagerly constructs both OkHttp factories and their dispatchers) before setting the flag; the emulator branch is dead. Set the flag before AppModules(this), or read it lazily.
  3. @Contextual Address in TopFilter has no iOS serializer — the nativeMain Address actual is not @Serializable and JsonMapper registers no contextual serializer; serializing address-carrying filters throws on iOS. Annotate the native actual or register a serializer.
  4. Baseline profile stale — baseline-prof.txt has ~251 rules naming pre-move classes; regenerate via :baselineprofile (cold-start wins regress until then).
  5. NappletLaunchRegistry in commons breaches the documented napplet sandbox boundary (CLAUDE.md says the broker-side registry stays in :amethyst so :nappletHost cannot import it). Move it back or update the boundary doc + add a guard.
  6. PlatformImage.toSkiaBitmap() duplicated verbatim in CoilImageBridge.jvm.kt and .ios.kt — hoist to a shared Skiko source set.

Batch 2 landed — subassemblies are in commons/relayClient (2026-09-05)

The audit's Batch 2 (the ui/screen/**/subassemblies/ filter functions) was never executed by the earlier waves; it is done now on branch claude/amethyst-commons-migration-ua8ma8. What moved and where:

  • All 161 subassembly files → commons/relayClient/<feature>/…, with the subassemblies path segment dropped to match how Batch 1 landed (relayClient/account/metadata/FilterBasicAccountInfoFromKeys.kt). Mapping: searchCommand/subassemblies → relayClient/search, chats/publicChannels/datasource/subassemblies → relayClient/channel, …/relayGroup/datasource/subassemblies → relayClient/channel/relayGroup, discover/<nip>/subassemblies → relayClient/discover/<nip>, and <feature>/datasource/subassemblies[/<sub>] → relayClient/<feature>[/<sub>]. The misnamed shorts/…/FilterPollsByAllCommunities.kt became FilterShortsByAllCommunities.kt on the way.
  • Pure companions the 161 needed, moved alongside: video/datasource/FeedBasis.kt (the picture/video kind lists) → relayClient/video, and relayGroup/datasource/RelayGroupFilterBuilders.kt (+ its test) → relayClient/channel/relayGroup. The two *_PAGE_LIMIT consts went to relayClient/nsites / relayClient/napplets.
  • The three query-state keys the sub-assemblers are typed on now live in commons on IAccount: ChannelQueryState (unchanged shape), SearchQueryState (carries the search / indexer / follow-plus-mine relay sets as StateFlows instead of reaching into Account), and VideoQueryState (carries listName, followsPerRelay and lastNoteCreatedAtWhenFullyLoaded flows instead of Account + AccountFeedContentStates). The app constructs them in SearchBarViewModel, UserSuggestionState and VideoFilterAssemblerSubscription; the *FilterAssembler classes stayed in the app (they are now import-clean and can follow with the datasource batch).
  • ICacheProvider grew the seams the six LocalCache-coupled files needed: consume(nip19: Entity) (hint + placeholder seeding for a search query), allRelayGroupChannels() and getRelayGroupChannelsOnRelay(relay). DesktopLocalCache implements them (relay groups: empty, desktop has no NIP-29 cache yet). filterByAuthor/Event/Address and the set-level filterRelayGroupsBy{Authors,MutedAuthors,Follows} + filterRelayGroupsDiscovery take cache: ICacheProvider as their first parameter; the per-relay filterRelayGroupsByAuthors lost its LocalCache default for cachedChannels (the test always passed it explicitly).
  • The four tests moved to commons/src/commonTest on kotlin.test (JUnit's message-first asserts were reordered).

Not done on purpose: the SubAssemblyHelper.kt / *Filter.kt dispatchers and the *FilterAssembler / *Subscription classes in each datasource package — they are the datasource batch (333 files, 102 of them Account/ AccountViewModel-coupled) and belong with Wave 4. Desktop's subscriptions/FilterBuilders.kt (742 lines of hand-rolled copies of these filters) is now deletable in favour of the commons functions — a desktop follow-up, not part of this batch.

Top-nav feed datasource layer landed in commons/relayClient (2026-09-05)

The family that dispatches to the Batch-2 filters — 26 XFilterAssembler + XSubAssembler + XQueryState triples, one per top-nav feed (polls, pictures, longs, badges, nests, podcasts ×2, music ×2, discover, …) — was 26 copies of the same ~90 lines differing only in the makeXFilter dispatcher and which FeedContentState floor bounds since. Moving the copies would have relocated the duplication, so they collapsed onto three shared classes in commons/relayClient/topNavFeeds/:

  • TopNavFeedQueryState(account: IAccount, listName, followsPerRelay, scope, feeds) — the one key type for all of them (open; DiscoveryQueryState adds its seven named tabs, SoftwareAppsQueryState adds the blocked-relay set).
  • TopNavFeedSubAssembler<K> — the abstract EOSE manager carrying the shared job wiring (list-name watcher, per-relay follows sampled at 500 ms, feed floors combined and sampled at 5 s, plus onListChanged / extraInvalidators hooks). SingleTopNavFeedSubAssembler(client, keys, ::makeXFilter, resetEoseOnListChange) is the concrete one every single-dispatcher feed uses; Discovery's three and SoftwareApps' one are subclasses.
  • TopNavFeedFilterAssembler<K> — the ComposeSubscriptionManager that owns the sub-assemblers. Each feature keeps a one-line commons/relayClient/<feature>/XFilterAssembler.kt naming its dispatcher, so RelaySubscriptionsCoordinator and the *Subscription composables kept their types and names.

The pure makeXFilter dispatchers (SubAssemblyHelper.kt, XFilter.kt, FilterBadges.kt, the seven discover/<nip>/SubAssemblyHelper.kt) moved next to their filters. What stays in the app is exactly the Compose glue: each XFilterAssemblerSubscription.kt now builds the key with AccountViewModel.topNavFeedQueryState(listName, followsPerRelay, feed…) (ui/screen/loggedIn/TopNavFeedQueryStates.kt), reading the Account / AccountSettings / AccountFeedContentStates fields there.

Deliberate behaviour deltas, all small: every watcher now runs on the key's (screen) scope — the floor watcher used to run on account.scope; the per-relay-follows sampler is 500 ms everywhere (Discovery tab 2 and Video had 1000 ms); Video gained the list-name watcher the others always had, and its inline when became makePictureAndVideoFilter. VideoQueryState from the previous batch is gone in favour of the shared key.

Not in this batch — the datasource files with other key shapes: RelayGroupsDiscovery* (reads LocalCache.live.newEventBundles and the relay-group server lists), home/ (its filters are still app-side and HomeOutboxEventsEoseManager is a different animal), and the non-top-nav families (profile, thread, chatroom(s), hashtag, geohash, url, relay feed, community, gitRepo, chess, one/my podcast, connectedApps, profile badges, app recommendations, nest room, follow-pack feed, onchain zaps, NIP-66 relay info). Many are import-clean and can follow mechanically; the Account-typed ones wait on Wave 4.

Import-clean non-top-nav datasource families landed (2026-09-05)

The families whose keys are a User, an AddressableNote, a string, or an IAccount-compatible account moved wholesale into commons/relayClient/: chess, relay feed, NIP-66 relay info, url, geohash, the channel assembler, communities, git repo, profile (all 14 files), one/my podcast, onchain zaps, poll responses, plus the pure filterEventsInThreadForRoot / filterMissingEventsForThread functions and FilterPostsByScopes.kt (CommentKinds), which those filters and the home feed share. The URL filter test came along onto kotlin.test. Three Subscription composables that never touched AccountViewModel (community, repository, profile) moved too.

The only seam was LocalCache, read for relayHints and checkGetOrCreateUser — both already on ICacheProvider — so the affected assemblers take cache: ICacheProvider as their first constructor parameter, thread it into their sub-assemblers, and the filter functions that need it take it as their first parameter. RelaySubscriptionsCoordinator passes the cache it already holds.

Still in the app, with the reason: hashtag/ (FilterHashtagLabels reads Account.followsPerRelay, a per-outbox follow resolution IAccount lacks); threadview/datasources/ assembler + sub-assemblers and apps/recommendations, badges/profile, napplets/ConnectedApps* (each reads one relay-list state off Account — followPlusAllMineWithSearch, outboxRelays / defaultGlobalRelays, notificationRelays, homeRelays; the SearchQueryState recipe of carrying the flow on the key applies, one small change each); followPacks/feed (dispatches to the app-side home filters); chats/* (DM plumbing: DmRelayLog, GeohashRelays, launchChatFeedToggleObserver); nests/NestRoom*, relayGroup/*, concord/* and home/ (Account + LocalCache live streams + AccountViewModel).

Single-relay-read families and the home feed landed (2026-09-05, after #4059)

The four families that read one relay-set state off Account now carry that set as a StateFlow on their key, the way SearchQueryState does: ProfileAppRecommendationsQueryState(outboxRelays, defaultGlobalRelays), ProfileBadgesQueryState(notificationRelays), ConnectedAppsQueryState(homeRelays) and ThreadQueryState(defaultRelays). Thread also takes the cache (ThreadFilterAssembler(cache, client); its two sub-assemblers run ThreadAssembler(cache)). All live under commons/relayClient/{apps/recommendations,badges/profile,napplets,thread}.

The home feed moved whole: the FilterHomePosts* dispatchers under commons/relayClient/home/<nip>/, and HomeOutboxEventsEoseManager is now a TopNavFeedSubAssembler<HomeQueryState> whose key carries the new-threads and replies floors plus enabledHomeFeedTypes; the Settings › Home toggle is an extraInvalidators entry (still drop(1)), and the disabled-kinds stripping is unchanged. Deltas as for the other feeds: watchers on the screen scope, follows sampler 500 ms (was 1000). The commented-out alternative sub-assemblers in HomeFilterAssembler were dropped.

What remains in ui/screen/**/datasource*/ (92 files) is the Compose glue — one *Subscription.kt per feature, which builds the key from AccountViewModel — plus the families that need Wave 4: hashtag/ (Account.followsPerRelay), followPacks/feed (Account.proxyRelayList, blockedRelayList, cache), chats/*, nests/NestRoom*, relayGroup/*, concord/*, and wallet's and polls/results' composables.

Desktop subscriptions/FilterBuilders.kt — investigated, not migrated (2026-09-05)

The audit listed this 742-line file as a hand-rolled copy of ~34 subassemblies that the Batch-2 move would make deletable. That premise is wrong, and the two sides do not share a model:

  • Desktop builds plain quartz Filters and broadcasts the same list to a whole relay set (RelayConnectionManager.subscribe does relays.associateWith { filters }; relay choice is "all connected/configured relays"). No per-relay since, no EOSE bookkeeping, no SubPurpose.
  • Commons builds List<RelayBasedFilter> — one ExplainedFilter per relay — from an IFeedTopNavPerRelayFilterSet the outbox model derives from follow lists + each author's NIP-65 write relays, with since per relay from the EOSE managers, driven by ComposeSubscriptionManager.
  • Both end in the same quartz call, NostrClient.subscribe(subId, Map<relay, List<Filter>>); the mismatch is one layer up (who picks the relay per filter, where since lives).

What the file actually is: 27 functions, 6 with no callers at all (nip04DmsToUser/FromUser, giftWrapsToUser, dmRelayList, chessChallengesToUser, chessAllEvents); a 90-line buildFilter DSL used only by its own 525-line test; most of the rest are one-line named-argument wrappers over the Filter constructor with hard-coded kind integers (listOf(0), 9735, 30023, 30064…). The genuine overlaps with commons are the search kind groups (SearchFilterFactory says "ported from Android SearchPostsByText"; commons now exports SearchPostsByTextKinds1..3), the chess filters (commons nip64Chess/subscription/ChessFilterBuilder has challengesFilter/userGamesFilter/userTaggedFilter), and notificationsForUser, which already delegates to commons.

Options, for the maintainer to pick:

  1. Adapter, partial match — teach SubscriptionConfig / RelayConnectionManager to accept List<RelayBasedFilter> (group by relay into the map) so desktop can call commons functions with a uniform set, e.g. GlobalTopNavPerRelayFilterSet(relays.associateWith { GlobalTopNavPerRelayFilter }) or an AuthorsTopNavPerRelayFilterSet from the follow list. Reuses the shared filter shapes and deletes most of FilterBuilders; keeps desktop's broadcast relay policy and since = null. Prerequisite for (2). Recommended next step.
  2. Adopt the outbox model, full match — desktop builds filter sets via commons OutboxLoaderState (needs the users' NIP-65 lists in its cache) and drives subscriptions through the commons EOSE + subscription managers. This is the Wave-2/DesktopLocalCache direction; a project, not a cleanup.
  3. No model change — inline the trivial wrappers as Filter(...) with quartz kind constants, delete the dead functions and the test-only DSL, switch search/chess to the commons functions. Removes the file, gains no routing parity.
  • Tier 4 unlocks — done 2026-09-26, see the last section. (Was: small, mechanical: NappletProtocolJson java.util.Base64 → kotlin.io.encoding.Base64 (frees NappletIdentityWatch); ScheduledPostStore Jackson+java.io.File → kotlinx-serialization+okio (frees ScheduledPostWorkGate). LargeSoftCache stays parked (needs a WeakReference expect/actual).)
  • Wave 2: LocalCache move-group — part A landed (2026-09-19), see the section below. Part B (repoint desktopApp, delete DesktopLocalCache.kt) is the remaining half.
  • Wave 3: strings bridge — BUILT (2026-09-01). The pieces:
    • commons/ui/StringRes.kt (commonMain): stringRes(StringResource), formatted + plural variants, loadStringRes/loadPluralStringRes for non-composable scopes. Thin delegates over compose-resources — no extra cache (the library caches parsed locale files process-wide).
    • App-side ui/StringResourceCache.kt gained stringRes(StringResource) overloads delegating to the bridge, so ONE stringRes import serves mixed files: migrating a key is just R.string.x → Res.string.x (+ the two commons.resources imports). No aliasing, no churn.
    • tools/strings-migrate/migrate.py <keys…>: moves keys from app res to commons composeResources across all 57 locales byte-for-byte (Crowdin covers both trees with the same mapping — see crowdin.yml). It refuses keys with bare %s/%d (compose-resources needs positional %1$s).
    • Exemplar shipped: profile_banner (the ui/layouts cluster's only string blocker) migrated + all 7 call-site files repointed.
    • Bulk migration executed (2026-09-01, commit 7c6a18ee): all 2,565 mechanically-safe keys (composable-only call sites, no XML refs, only %N$s/%N$d args, no markup) moved to commons; 568 app files repointed. The app keeps 1,866 keys that are genuinely Android-bound: ctx-based call sites (adapt to loadStringRes to free them), Int-typed id storage (maps/whens over resource ids), @string/ XML references, and exotic format specifiers.
    • Remaining Wave-3 work: free the ctx-based keys by adapting call sites, then move the now-unblocked composables. Note the layouts cluster ALSO needs theme constants (DividerThickness, Size55Modifier, …) hoisted from app 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.

Environment notes for the next session (hard-won)

  • Run gradle tests and pushes with LC_ALL=C.UTF-8 LANG=C.UTF-8 (a POSIX locale breaks an em-dash test-report filename).
  • One gradle invocation at a time; concurrent runs die on the project lock.
  • Pre-push hook runs :quartz:jvmTest :commons:jvmTest :nestsClient:jvmTest :quic:jvmTest :amethyst:testPlayDebugUnitTest :cli:test (not geode); a PreToolUse hook blocks pushes when spotlessApply reformatted files — commit first, then push.
  • :commons:compileKotlinIosArm64 is the cheap local gate for iOS breakage (CI's test-quartz-ios compiles commons for iosSimulatorArm64). If the Kotlin/Native toolchain corrupts (dangling liblto_plugin.so symlink), delete the extracted dirs under /root/.konan/dependencies and let gradle re-extract.
  • stdlib atomics have no incrementAndFetch() here — use addAndFetch(1); withLock {} can't assign outer vals — restructure to lambda-return.

Wave 2 part A landed — LocalCache is in commons (2026-09-19)

LocalCache (4,021 lines) and its move-group now live in commons/src/jvmAndroid/…/commons/model/. desktopApp is untouched and still runs DesktopLocalCache; repointing it and deleting that 1,177-line fork is part B.

Where things went

From amethyst/…/model/ To commons/…/commons/model/
LocalCache.kt cache/LocalCache.kt
AntiSpamFilter.kt cache/AntiSpamFilter.kt
CachePruner.kt cache/CachePruner.kt
CacheSearch.kt cache/CacheSearch.kt
DvmHeartbeatRegistry.kt nip90DVMs/DvmHeartbeatRegistry.kt
nipBCOnchainZaps/OnchainZapResolver.kt nipBCOnchainZaps/OnchainZapResolver.kt

All in jvmAndroid: LocalCache spills NIP-95 blobs through java.io.File, and the other five either name LocalCache or use java.util.concurrent.

The seam: LocalCacheHost

A new port in commons/…/model/cache/LocalCacheHost.kt collects everything the cache needed from Amethyst.instance and friends — scope, isDebug, nip95BlobDir, relayStats, relaySelfPubKey(relay), assertNotMainThread(). Every member has a default that is the honest answer for a host that supplies none of them, so the cache runs shell-less (Desktop, tests). LocalCache.appHost is the install point; Android installs AmethystLocalCacheHost from AppModules next to val cache, before any event is consumed. Named appHost, not host, because host is already a domain word in that file (NIP-29 host relay, gift-wrap host).

The whole seam cut is 101 changed lines in a 4,021-line file:

Was Now
Amethyst.instance.nip11Cache.getFromCache(relay).self appHost.relaySelfPubKey(relay)
Amethyst.instance.applicationIOScope.launch (×2) appHost.scope.launch
Amethyst.instance.nip95cache appHost.nip95BlobDir
isDebug (×2) appHost.isDebug
checkNotInMainThread() appHost.assertNotMainThread()
ui.note.dateFormatter commons.util.dateFormatter (the twin already existed)

nip95BlobDir is nullable, so the FileStorageEvent path had to grow one branch: with nowhere to spill to, the note keeps the event whole instead of loading a content-stripped copy whose bytes are on no disk. Android always supplies the directory, so its behaviour is unchanged.

The design question the plan flagged, resolved

CachePruner/CacheSearch took Account. It turned out IAccount did not need to grow anything and Desktop needed no change:

  • CacheSearch.findUsersStartingWith(…, forAccount: IAccount?) — isHidden and hiddenWordsCase are already on IAccount, and isFollowing(user) is by definition user.pubkeyHex in followingKeySet(), which is too. Hoisting the set out of the per-candidate lambda also stops rebuilding it per result.
  • CachePruner.pruneHiddenMessages(account: IAccount) — Channel.pruneHiddenMessages already took IAccount.
  • CachePruner.pruneHiddenEvents(hidden: LiveHiddenUsers) — takes the mute list by value, the idiom ICacheProvider.findNotesMatching already documents. IAccount exposes hidden users only as hash codes, which would prune authors the reader never muted.

Other pieces

  • ILocalCache was declared inline in LocalCache.kt and used nowhere else; hoisted to commons/…/model/cache/ILocalCache.kt (commonMain) as the write-side port next to the read-side ICacheProvider.
  • AntiSpamFilter: android.util.LruCache → androidx.collection.LruCache, already the commons idiom. Its get is Kotlin-nullable where the platform class returned a platform type, so the two duplicate checks now read each entry once into a local and smart-cast. It takes its host as () -> LocalCacheHost rather than a value, because the cache that owns it is built before the shell installs one.
  • njumpLink moved from ui/note/NoteQuickActionMenu.kt to commons/…/util/ExternalLinks.kt (AntiSpamFilter logs one); the app's four call sites import it from there.
  • OnchainZapResolver.onchainTipHeightFlow dropped its runCatching { Amethyst.instance } fallback — cache.appHost.scope always answers.

Left in amethyst on purpose: MiniFhir.kt, which the original move-group listed. It is clean and movable, but LocalCache never referenced it (the hit was the phrase "Resource-usage ledger" in a comment), so it unlocks nothing here and would only widen the diff. OnchainWalletState likewise stays — Account and RailCapability use it, OnchainZapResolver does not.

Verified: :commons:compileKotlinJvm, :amethyst:compileFdroidDebugKotlin, :desktopApp:compileKotlin, :cli:compileKotlin, :commons:verifyKmpPurity, :commons:jvmTest, :cli:test, :amethyst:testPlayDebugUnitTest, spotlessApply.

Wave 2 part B — compatibility analysis and the road to deleting DesktopLocalCache (2026-09-19)

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.

What is already compatible

  • consume() coverage is a strict superset. All 22 kinds Desktop routes are handled; the 9 without a dedicated EventCache overload (TextNoteEvent, ContactListEvent, CommentEvent, AdvertisedRelayListEvent, BlossomServersEvent, BookmarkListEvent, OldBookmarkListEvent, ChatMessageRelayListEvent, FollowListEvent) fall into the generic replaceable/addressable group.
  • 13 of 13 core read methods match by name and signature.
  • Feed retention is already aligned — both platforms hold feed content strongly in the shared FeedContentState.
  • The event stream is separable. Desktop's newEventBundles is driven by DesktopRelaySubscriptionsCoordinator, not by the cache, so Desktop can keep owning DesktopCacheEventStream and does not inherit LocalCacheFlow's 1 s BundledInsert window.

Resolved during part B

  • notesByAuthor — a strong ConcurrentHashMap<HexKey, MutableSet<Note>> of every note ever consumed, which defeated the LargeSoftCache (WeakReference, despite the name) and made Desktop retain every note for the life of the process. It also drove kind-0 metadata invalidation, which Android has no counterpart for. Removed: every display-data site now observes the author's User metadata flow through Event.rememberDisplayData (which already existed, documented for this, with zero call sites).
  • object vs class — EventCache is now the class, LocalCache the process-wide object over it. Android's ~350 static call sites were untouched.
  • findUsersStartingWith(prefix, limit) — EventCache now overrides the port method instead of inheriting its emptyList() default.

Still to do, and the shape it should take

DesktopLocalCache should become a facade over an owned EventCache, not a deletion: ~500 lines of genuinely Desktop-specific state survive, and the ~700 lines of consumeXxx routing go.

  1. Storage unification first (safe, mechanical): drop Desktop's users / notes / addressableNotes / liveChatChannels and delegate to the owned EventCache. Desktop's own consumeXxx methods keep working because they go through getOrCreateNote / getOrCreateAddressableNote. Note the key type widens (addressableNotes<String, _> → addressables<Address, _>); the one external reader (DesktopRelaySubscriptionsCoordinator:599) ignores the key.
  2. Then swap routing kind by kind, each step green against the 62 tests in the 8 desktop/cache test classes, ending at cache.checkDeletionAndConsume(event, relay, true).
  3. Desktop-only state stays, moved behind the facade: localRelayStore write-through, followedUsers, accountPubkey, contactListEvents, metadataVersion, followPackVersion / liveActivityVersion, the snapshots, cachedAdvertisedRelayList, follower/following counts, onProfileMetadataConsumed, appScope, eventStream. The mutations are concentrated in five places (metadata, contact list, follow pack, live activity, clear) and re-derive cleanly from the event after consume.
  4. Two Desktop-shaped consume overloads do not generalize — the NIP-47 LnZapPaymentRequestEvent one takes a zappedNote and an onResponse callback, and the response one drives paymentTracker + appScope. Android reaches the same tracker through account state. Keep them Desktop-side.
  5. clear() has only 2 production call sites (Main.kt, logout/account switch) plus 2 in tests. EventCache deliberately has no clear(): DeletionIndex, FilterIndex, HintIndexer and NwcPaymentTracker have no way to reset, so one would be a half-truth. Construct a fresh EventCache instead — which is what making it a class bought.

Known behaviour changes to watch for when this lands: kind-0 currently returns false from Desktop's route() and is therefore never written through to LocalRelayStore; EventCache returns true when the metadata updated, so profiles would start persisting. And notes outside a loaded feed become GC-eligible on Desktop, as they already are on Android.

The test suite is not sufficient for this step. 62 tests cover the consume path well, but nothing covers a stale render. Run the desktop app against a real relay before calling part B done.

Can EventCache go to commonMain? — audit (2026-09-21)

Short answer: yes in principle, but it is a port, not a move, and the earlier note in this file ("needs an okio sink … nothing else in the move-group blocks iOS") was wrong. The file sink is the visible blocker; the binding one is that the cache's own storage is jvmAndroid.

commons really does build iosArm64/iosSimulatorArm64, so this is a real constraint rather than a hypothetical one.

What must move with it (EventCache cannot go alone):

Dependency Why it is jvmAndroid
LargeSoftCache — users, notes, addressables java.lang.ref.WeakReference, ConcurrentSkipListMap, BiConsumer
EventListMatchingFilter, NoteListMatchingFilter SortedSet, ConcurrentHashMap, ConcurrentSkipListSet
MintDirectoryIndex ConcurrentHashMap
NwcPaymentTracker ConcurrentHashMap, AtomicInteger
DvmHeartbeatRegistry, OnchainZapResolver ConcurrentHashMap
LocalCacheHost java.io.File

Most of it already has an answer in the tree:

  • commons.util.WeakReference is already an expect/actual, and its KDoc already anticipates kotlin.native.ref.WeakReference for iOS.
  • quartz commonMain already ships ConcurrentMap, ConcurrentSet, ConcurrentHashCache and LargeCache — so every ConcurrentHashMap / ConcurrentSkipListSet above is a swap, not a design problem.
  • LargeSoftCache's skip-list ordering is not load-bearing — wrong, corrected 2026-09-21. It is load-bearing, and it is the thing that stops this migration. LargeSoftCache implements CacheOperations (which is itself quartz/jvmAndroid, not commonMain) for its ranged forEach(from, to, …), backed by ConcurrentSkipListMap.subMap. The whole of LargeSoftCacheAddressExt is built on it: filter(kindStart(kind), kindEnd(kind), …) walks only one kind's slice of the Address key space, and filterIntoSet is called 28 times. On a hash map every one of those becomes a full scan of all addressables.
  • AtomicInteger → kotlin.concurrent.atomics; BiConsumer → a function type; the dateFormatter call is one log line and can go.
  • androidx.collection.LruCache in AntiSpamFilter is already KMP — commonMain uses it in blurhash/CosineCache and relays/EOSE.

The two pieces of genuinely new work:

  1. NIP-95 blob sink. EventCache.consume(FileStorageEvent) writes through java.io.File/FileOutputStream. Needs okio or an expect sink behind LocalCacheHost.nip95BlobDir.
  2. java.util.SortedSet in the public API. EventCache.filter(Filter) returns one, and both *ListMatchingFilter observables take (Filter) -> SortedSet<Note>. Kotlin has no common SortedSet, so this is a signature change rippling into CacheSearch, both observables and LocalCacheSearchParityTest.

The invariant this would break. quartz/linuxTest/LargeCacheRangeFallbackTest pins the native LargeCache range overloads to a deliberate full-scan fallback, and says why that is acceptable: "the range overloads have no callers outside the JVM-only LargeSoftCache". Promoting LargeSoftCache to commonMain makes that statement false. Any move has to either give iOS a genuinely sorted store or accept — explicitly, not silently — that addressable lookups there are full scans.

Revised cost. This is not the swap described above. It needs either an expect/actual LargeSoftCache with a hand-written sorted iOS actual (the LargeCache apple actual, for comparison, is 432 lines), or a KMP sorted-concurrent map that does not exist in the tree or the stdlib — which would want a Comparable bound on Address and would benefit quartz too. Neither is a step; both are projects.

Recommended order (bottom-up; each step is independently shippable), if the ordering question above is answered first:

  1. LargeSoftCache → commonMain on the existing WeakReference expect plus quartz's ConcurrentMap. This is the keystone — everything else is behind it.
  2. The ConcurrentHashMap/ConcurrentSkipListSet holders, mechanically.
  3. SortedSet out of EventCache.filter's signature.
  4. The NIP-95 sink.
  5. EventCache + LocalCacheHost themselves.

Not on part B's critical path. Desktop is JVM, so retiring DesktopLocalCache needs none of this. The payoff here is an iOS front end later, not anything queued now.

Step 1 shipped: LargeSoftCache is now expect/actual (iOS unimplemented)

Decision on the ordering question above: expect/actual, with the iOS actual deliberately missing. Not a hash-map actual, and not a full sorted concurrent map yet — the third option, which keeps the invariant honest at the cost of iOS not being able to construct the cache.

  • model/cache/LargeSoftCache.kt → commonMain, as expect class LargeSoftCache<K : Any, V : Any>() : ICacheOperations<K, V>. Like quartz's LargeCache, the expect has to redeclare every member it inherits from the interface — an expect class is not abstract, so the inherited abstracts are its own to declare.
  • LargeSoftCache.jvmAndroid.kt is today's implementation unchanged in behaviour: ConcurrentSkipListMap<K, WeakReference<V>> behind CacheOperations. The collector defaults and both forEach(BiConsumer) overloads stay JVM-side; only size() needed an actual override, the rest actualize through inheritance. java.lang.ref.WeakReference became the commons WeakReference expect — the same type on JVM, it is an actual typealias.
  • LargeSoftCache.ios.kt throws on every member. Kotlin/Native has no sorted concurrent map and no weak-valued one; a HashMap actual would compile and then be wrong in exactly the way LargeCacheRangeFallbackTest warns about — every bounded scan silently walking every entry. The stub keeps :commons compiling for iOS and fails loudly if anything there tries to construct a cache. Replacing it is now a self-contained task with a written-down contract (key-sorted, weak values) rather than a blocker on the rest of the migration.
  • LargeSoftCacheAddressExt.kt moved to commonMain with it — its ranged filter/filterIntoSet/mapNotNullIntoSet calls are all ICacheOperations members, so they need no platform code.

Verified: :commons:compileCommonMainKotlinMetadata, :commons:compileIosMainKotlinMetadata (this runs on Linux and does enforce expect/actual matching — checked by breaking a member name on purpose and watching it fail), :commons:verifyKmpPurity, :commons:jvmTest, :desktopApp:test, :cli:test, :amethyst:compileFdroidDebugKotlin, LargeCacheAddressableFilterTest, spotlessCheck.

Steps 2–5 are unchanged and still ahead; the iOS actual is now their peer, not their gate.

Step 2 shipped: the ConcurrentHashMap holders

Three of the six holders in the audit table above are now commonMain, each a straight swap onto a KMP primitive that already existed:

  • MintDirectoryIndex — ConcurrentHashMap<String, Int> → quartz ConcurrentMap. counts.merge(key, 1, Int::plus) becomes merge(key, 1) { old, new -> old + new }, which is atomic on every target, so a count still cannot be lost to a racing add. suggest() now ranks over snapshot() — a copy the comparator cannot see shift underneath it.
  • DvmHeartbeatRegistry — ConcurrentMap.getOrPut is the same atomic get-or-create the ConcurrentHashMap version relied on, so two threads racing the first beat for an address still agree on one MutableStateFlow.
  • NwcPaymentTracker — ConcurrentMap plus kotlin.concurrent.atomics.AtomicInt for spoofAttempts (incrementAndGet() → fetchAndIncrement(), get() → load(), under @OptIn(ExperimentalAtomicApi::class) as quartz already does in BleChunkAssembler and BanStore). containsKey has no ConcurrentMap equivalent and became a null check on get, which is the same question for a map that never stores nulls. Its test moved to commonTest, so the tracker's 4 race cases now also run on iOS rather than JVM only.

OnchainZapResolver cannot move yet — it reads EventCache — but its two ConcurrentHashMap.newKeySet() in-flight gates are now the commons ConcurrentSet, which is exactly newKeySet() on JVM/Android. That leaves it with no java.* import, so it travels with EventCache in step 5 for free.

MintDirectoryIndexTest stayed in jvmTest: it is written against org.junit.Assert, and converting it to kotlin.test is unrelated churn.

Remaining in the table: EventListMatchingFilter / NoteListMatchingFilter, which also carry SortedSet — they belong to step 3, not this one.

Verified: :commons:compileCommonMainKotlinMetadata, :commons:compileIosMainKotlinMetadata, :commons:jvmTest (incl. the moved NwcPaymentTrackerTest, 4 tests), :desktopApp:test, :cli:test, :amethyst:compileFdroidDebugKotlin, DvmHeartbeatTest, spotlessCheck.

Steps 3–5 shipped: the cache group is commonMain

commons/src/jvmAndroid/…/model/cache/ now holds exactly one file, the LargeSoftCache actual. Everything else — EventCache (4k lines), LocalCache, LocalCacheHost, AntiSpamFilter, CachePruner, CacheSearch, OnchainZapResolver — is shared.

The NIP-95 sink. nip95BlobDir: File? became nip95Blobs: Nip95BlobStore?, because a directory is not what the cache needs — somewhere to put bytes and a way to ask whether they are there already is. FileSystemNip95BlobStore is the okio-backed one, and okio was already a commons dependency. Its constructor takes a plain path string so Android needs no okio of its own, and platformFileSystem is a two-line expect/actual because okio declares FileSystem.SYSTEM per platform.

Two bugs surfaced while rewriting that block. decode() returns ByteArray? and went straight into FileOutputStream.write — a platform type, so a null would have thrown past the IOException catch. And the note's copy dropped its content whenever a directory existed, including right after a failed write, losing the only copy of those bytes; content is now dropped only when the blob really is stored.

SortedSet. EventCache.filter returns a List<Note> in the comparator's order, newest first. That order is load-bearing (the napplet gateway answers REQs from it). The comparator also defines uniqueness by reference, so the sorted set was collapsing the duplicate references a filter with a repeated kind produces; toSet() before sorting keeps exactly that, since Note declares no equals().

The observables. NoteListMatchingFilter / EventListMatchingFilter are now expect/actual with today's implementation untouched as the jvmAndroid actual and a throwing iOS stub. Their concurrency — every sorted-set write inside that key's ConcurrentHashMap.compute critical section — is not reassemblable from quartz's KMP primitives: getOrPut covers new(), but remove() needs the sorted-set removal inside the section, or a concurrent re-add inserts a comparator-equal entry that the later removal takes out instead, dropping the note for good. A copy-on-write compute cannot stand in either, since its CAS retry may run a side-effecting lambda twice.

What the move itself turned up, none of it visible to an import grep: @Synchronized / @Volatile / synchronized {} (→ KmpLock, kotlin.concurrent.Volatile), System.nanoTime (→ TimeSource.Monotonic), HashMap.merge (→ a local mergeMax), Dispatchers.IO (just needs import kotlinx.coroutines.IO), the one dateFormatter log line (now logs the raw created_at), and LnurlEndpointCache — a quartz jvmAndroid singleton, reached through a new LocalCacheHost.lnurlEndpoint port whose default null means "cold cache", which is what a miss already meant.

Still true, and worth repeating: commonMain is not iOS support. iOS now compiles the cache, and the cache's store and its two observables throw there. The remaining gap is three named files, not a module boundary.

Verified: :commons:compileCommonMainKotlinMetadata, :commons:compileIosMainKotlinMetadata, :commons:verifyKmpPurity, :commons:jvmTest, :desktopApp:test, :cli:test, :amethyst:compileFdroidDebugKotlin, :amethyst:testPlayDebugUnitTest, spotlessCheck.

Step 8 — measuring the copy-on-write observables (the "is it actually faster?" question)

The rewrite of NoteListMatchingFilter / EventListMatchingFilter from ConcurrentSkipListSet + ConcurrentHashMap to copy-on-write over an AtomicReference was made for portability — java.util.concurrent has no KMP equivalent, and that is what kept these two in jvmAndroid with an iOS stub. That argument says nothing about speed, and these sit on a hot path: every consumed event is offered to each observer whose filter could match it, from each relay's socket coroutine. A few hundred events a second across a dozen relays reaches this code thousands of times a second.

So it was measured rather than argued. ObserverListBenchmark (commons/src/jvmTest/.../prodbench/) keeps the skip-list implementation verbatim as the baseline and as a differential oracle: bothImplementations Agree asserts the two emit identical lists for identical input at limits null / 50 / 400, which is the property the rewrite had to preserve. It runs in ~7s and asserts only on correctness, never on wall time.

What the first run found: the copy-on-write version was slower on the limited-filter insert path and 4.9x slower on 8 concurrent threads, with zero thread scaling. The cause was not the design but one line — State.plus published ids + added and, over the limit, ids + added - dropped. Each operator allocates a full copy of the set, so the eviction path rebuilt the membership set twice per insert. Replacing both with a single HashSet copy (sized up front, mutated, then published) is the whole of the fix.

After that fix, against the implementation it replaced:

shape result
re-deliver an already listed note (steady state once a screen is warm) 3.3–4.1x faster
insert into a populated unlimited list, n = 100 / 1000 1.3–1.5x faster, widening with n
cold fill, n = 5000 1.8x faster
concurrent inserts into the same observer, 1 thread parity
concurrent inserts into the same observer, 4 / 8 threads 2.1–2.7x slower

The last row is the real cost and is documented on both classes rather than buried here: threads serialize on one reference and a lost CAS discards its copy, where the skip list striped across keys and scaled with thread count. Two things bound it. The benchmark's threads do nothing but insert, while real ingest spends most of its per-event budget on signature verification and parsing before reaching an observer, so the contention window is a fraction of the measured one. And nearly every production observer registers with no limit — grep the observeNotes / observeEvents call sites — which is the shape copy-on-write wins.

Worth revisiting if a profile ever disagrees. The lever would be the emit, not the lock: both implementations already materialize the whole list on every write, so an observer that only ever appends is paying O(n) to tell the UI about one new row.

Also worth recording: "lock-free" was never the differentiator between the two. The skip-list version was lock-free too — ConcurrentHashMap stripes per key, so compute holds one bin, not a monitor. The choice was portability and speed, not locking.

Verified: :commons:jvmTest, :commons:compileIosMainKotlinMetadata, :commons:spotlessApply.

Step 9 — the weak note cache has no strong referent in tests

compose-ui-test went red on DesktopCachePipelineTest: FollowingFeedFilter only includes notes from followed users, expected:<1> but was:<0>, and it would not reproduce on a dev machine.

The cause is this branch, indirectly. DesktopLocalCache kept a notesByAuthor: ConcurrentHashMap<HexKey, MutableSet<Note>> index for metadata invalidation — an unbounded strong map holding every note the Desktop app ever saw, which quietly defeated the point of storing them in a LargeSoftCache. Removing it was right (the Android cache never had one). But it was also the only thing keeping the test fixtures alive: every test consumes events and then queries the cache for the notes they produced, with nothing in between holding a reference. A GC landing in that window empties the cache.

It reproduces deterministically with two System.gc() calls before the query, and it is not specific to that one test — all 46 consume sites have the shape, so CI's tighter heap just picked the victim. The fix routes every consume through an ingest helper that pins what the cache built for the lifetime of the test instance, the way a screen holds the notes it is showing in the app, and keeps the forced GC in the test that failed so the contract is asserted rather than left to the heap.

2026-09-26 — Tier 4 unlocks, the strings drift, and the UI stragglers

Tier 4: two jvmAndroid groups promoted to commonMain

  • Napplet wire protocol. NappletProtocolJson, NappletIdentityWatch and NappletRequestRouter moved to commons/commonMain/…/napplet. The only pins were java.util.Base64 and one System.currentTimeMillis(). Base64 is now kotlin.io.encoding.Base64, decoding with PaddingOption.PRESENT_OPTIONAL because java.util.Base64.getDecoder() accepted unpadded input and applets may send it. The clock is TimeUtils.now().
  • Scheduled posts. ScheduledPostStore had already dropped Jackson for kotlinx-serialization; what pinned it was java.io.File and a POSIX chmod. It now takes an okio Path plus a FileSystem (default platformFileSystem), and a String constructor so callers need no okio, the same shape as FileSystemNip95BlobStore. The owner-only permission is a new expect fun restrictFileToOwner(path, tag): Files.setPosixFilePermissions on jvmAndroid, POSIX chmod(0600) on iOS. The temp-file swap is FileSystem.atomicMove, which replaces an existing target on every platform, so the old "rename, else delete and rename again" fallback went with it. ScheduledPostWorkGate, ScheduledPostPublisher and ScheduledPostNotifier came along; none had a JVM pin of its own.

The strings drift, fixed

Between the 2026-09-22 move and today, the cordn UI and the backup-conflict review added 447 keys to amethyst/src/main/res (cordn_*, backup_*), taking it from 190 keys back to 597. All 447 moved with tools/strings-migrate/migrate.py (1,131 elements over 57 locales) and the 18 files that read them were repointed. The non-mechanical sites:

  • stringRes(context, …) inside suspend senders (sendAttachment, sendVoiceNote) → loadStringRes(…). adminFailureText became suspend; its only caller is already in scope.launch.
  • The backup review kept its labels as @StringRes Int (DiffGroup.label, ReviewRow.*Res, eventTypeName(), the countTiles/moneyHero/laneItems params) → StringResource / PluralStringResource.
  • rememberPresentation resolved labels inside remember { } through LocalResources. Compose resources have no non-suspend accessor, so the 16 labels presentationOf writes into item text are resolved in composition into a map, and the map is the remember key.
  • androidx.compose.ui.res.pluralStringResource → the compose-resources one.

Six moved keys have no reader today (cordn_backup_restore_title, cordn_coordinators_relays, cordn_create_admin_only_me, cordn_create_coordinator, cordn_send, cordn_voice_play). They moved rather than being deleted; dropping them is the feature owner's call. pow_notification_sending stays in Android res/: the PoW foreground service reads it synchronously.

The rule is now written down in .claude/CLAUDE.md ("Strings"), in commonsUI/ARCHITECTURE.md, and in the android-expert and find-missing-translations skills: new strings go in commonsUI composeResources, even for Android-only screens.

UI stragglers moved to commonsUI

A survey against main found that most of "Batch 4" had already landed in other PRs (dde228d2, 1a563cf2, 8dd409cb, 1f1b6dab): the theme constants (in commonsUI/…/theme/Shape.kt, not the Sizes.kt this plan named), the feed shell, the top bars, M3ActionDialog, NoteComposeLayout, RepostLayout and the toast queue. This round moved what was left that had no real blocker:

Moved To Note
NewItemsBubble (from ChatroomHeaderCompose.kt) commonsUI/…/ui/components
ChatHeaderLayout, LeftPictureLayout commonsUI/…/ui/layouts Their @Previews use app drawables, TimeAgo and TextCount, so the previews stay app-side in *Preview.kt files
listItem/SlimListItemLayout commonsUI/…/ui/layouts/listItem Same preview split; @VisibleForTesting dropped (no androidx.annotation in commonMain)
WatchScrollToTop (list/grid/pager overloads) commonsUI/…/ui/feeds The CardFeedContentState overload stays in the app
app StickToTopOnPrepend deleted It was a copy of the commons one. The commons copy now collects with collectAsStateWithLifecycle, as the app copy did
ChannelFeedState, ChannelFeedContentState commons/commonMain/…/feeds checkNotInMainThread() → LocalCache.appHost.assertNotMainThread(), which Android wires to the same check
ClickableBuzzInviteLink, ClickableConcordInviteLink, ClickableRelayGroupLink, ClickableRelayUrl, OutlinedThinPaddingTextField commonsUI/…/ui/components
app ClickableEmail, ClickablePhone deleted Android now uses the commons ones (mailto: / tel: through LocalUriHandler) instead of ACTION_SEND / ACTION_DIAL intents. The commons ClickablePhone is new. Desktop still renders phone numbers as plain text, on purpose

Still app-side, with the reason:

  • AmethystClickableIcon: 9 lines around an Android debug action; moving it only pushes LocalContext into its two callers.
  • ScreenLayout: needs LocalConfiguration → LocalWindowInfo, and the window-size-class breakpoints inlined.
  • SlidingCarousel: needs animatedViewerChromeInset hoisted out of the viewer-chrome file.
  • AudioWaveformReadOnly: needs two enums from the Android-only waveform library replaced.
  • FileAttachmentCard: needs extractFilename hoisted.
  • PdfFetcher: needs the disk cache injected.
  • ClickablePhone / ClickableEmail on Desktop: deliberately unchanged.
  • AmethystTheme split (the pure scheme/typography half to commonsUI; the Amethyst.instance overload and the system-bar SideEffect stay).
  • TimeAgo split: the labels, NowProvider and the style enum are pure; the absolute and short formatters need expect/actual over android.text.format.
  • Palette unification: Desktop's PlatformColorScheme and the app palette differ in primary, secondary, surfaces and outlines, and every shared token (placeholderText, chatBubbleThem, …) is frozen from the app palette. This is a design decision, not a move.

Audit of the 2026-09-26 round

The four commits were reviewed file by file against the originals. They had no functional bugs. The follow-ups:

  • Compose resources keep XML whitespace; Android didn't. This is older than this round (it dates back to the first string moves), and it is the one real regression. aapt collapses every whitespace run in an unquoted value and trims the ends; Compose draws the XML text verbatim. So the values wrapped over indented lines (account_backup_tips2_md, push_server_install_app_description, couldnt_find_nwc_wallets_description, the chat explainers, and 349 translated values) rendered with a leading line break and eight spaces. In the _md strings CommonMark turns that into a code block. Crowdin also exports a translator's stray edge space, which aapt used to drop, for example miejsca zniknęły and Hindi's दि॰ time suffix. fix_escapes.py now applies aapt's rule to runs holding a line break or tab. In a translation it also trims an edge space its source string lacks, which keeps deliberate ones like " and ". The Crowdin workflow already runs it after every sync, and compose_escaping_check.py now fails on raw line breaks, so a sync can't bring it back. 598 values across 54 files were repaired.
  • rememberPresentation read all 16 backup labels on every recomposition, even for diff types that write none. It now reads only the ones its diff type uses, and BackupPresentationLabelsTest fails if presentationOf asks for a label that list lacks.
  • ScheduledPostStore: a failed stat now counts as "no file" rather than throwing (okio's posix metadata throws on EACCES where File.exists() returned false). Symlinked store files still count as present. The chmod is skipped for an injected non-system FileSystem, and the cleanup log keeps its throwable.
  • ClickableEmail percent-encodes % in the mailto: URI (RFC 6068).
  • Left as-is, by design: Kotlin's Base64 rejects non-zero pad bits ("SGl=") that Java ignored, which no browser encoder produces. %1$d arguments now render ASCII digits in every locale, the same as every other migrated string. ChannelFeedContentState reaches LocalCache.appHost for its main-thread check.