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
88 KiB
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
AccountScopedQuerygeneralized toIAccount), the okhttp stack →commons/service/http, 37 model/service singles, theIFeedTopNavFilter→ICacheProvidersignature fix,TopFilterextracted out ofAccountSettings.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 useAccount/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. Theui/theme+ui/layoutsbatch also does NOT move mechanically:Theme.ktis 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
LocalCacherecipe (next big step, needs maintainer input): the move-group isLocalCache+AntiSpamFilter(android LruCache → androidx.collection) +CachePruner+CacheSearch+MiniFhir+OnchainZapResolver, into commons jvmAndroid (which legalizes itsjava.io.FileNIP-95 spill as-is). Seams to cut:Amethyst.instance(2 sites → injected scope), appisDebug(2 → settable flag),checkNotInMainThread(→ settable hook or expect),ui.note.dateFormatter(1 log line). The open design question:CachePruner/CacheSearchcallAccount.isFollowing(...)and readaccount.hiddenUsers.flow.value. hiddenWordsCase—IAccountalready hasisHidden/hiddenWordsCasebut lacksisFollowing, so eitherIAccountgrows 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
- Static classification of all 2,347 files by imports:
- Android-dirty = imports
android.*,com.google.*, orandroidx.*other than the KMP-safe set (compose,lifecycle,annotation,paging,collection), or usesandroidx.navigation. - R-dirty = imports
com.vitorpamplona.amethyst.Ror referencesR.string/R.drawable/R.raw(incl. viastringRes).
- Android-dirty = imports
- 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.
- 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:
service/relays/EOSE.kt→ finish the move. 4 of its 6 declarations are already typealiases intocommons/relays;SincePerRelayMap(androidx.collection.LruCache— KMP-safe) andEOSEAccountKeyremain. Moving them unblocks ~145subassemblies/filter files at once (97% of that layer is already Tier A).IFeedTopNavFiltersignature fix.model/topNavFeeds/IFeedTopNavFilter.kt:34,36hard-codescache: LocalCacheintoPerRelayFlow/startValue, poisoning all 23*TopNavFilterimplementors. Change to the existing commonsICacheProviderport → the app half oftopNavFeedsreunites with the 25-file half already incommons/model/topNavFeeds/.- Move
LocalCache(3,980 lines). It'sobject LocalCache : ILocalCache, ICacheProvider, Daowith zero Android imports. Its entire dirty-dep set:MainThreadChecker(Looper →expectno-op on JVM), adateFormatterlog line,Amethyst.instancescope (inject),BundledInsert(commonsBundledUpdatealready exists), NIP-95java.io.Fileblob spill (expectsink), and hoisting the inlineILocalCacheinterface. This deletesDesktopLocalCache.kt(1,173 lines) — the single largest duplication in the repo. - Extract
TopFilter+ aListBackupStoreport out ofAccountSettings.kt.AccountSettings(1,900 lines) mixes thebackupXList/updateXListpersistence port with nav-shape types (NavBarItem,DrawerItemVisibility) and theTopFiltersealed class (line 102). The port +ICacheProviderunlock all ofmodel/nip51Lists(31 files) and ~18 one-filemodel/nipNNpackages — the commons copies ofNip65RelayListStateetc. prove the recipe is purely mechanical (LocalCache→ICacheProvider, dropAccountSettings). - A Compose-resources
stringRestwin.ui/StringResourceCache.ktdefines the sixstringRes()overloads +painterResonandroid.util.LruCache+Rids. commons already has the translatedcomposeResourcespipeline (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 commonsBaseEoseManageralready; depsUser,SincePerRelayMapare commons or move with Batch 1).reqCommand/account/purefilterXxx()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'sSearchFilterFactory.kt:56literally comments "ported from Android SearchPostsByText".chatDelivery/,speedLogger/→ commonMain;diagnostics/→ jvmAndroid.TorCircuitHealthTracker→commons/relays/health.authCommand/model(9 of 11 files) → merge into the existingcommons/relayClientauth types (AuthApprovalPolicy/AuthApprovalRequestsare a thinner second implementation of the same concern — reconcile, don't copy).DataStoreRelayAuthPermissionStorestays 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.
EncryptionKeyCacheneedsandroid.util.LruCache→ KMP cache first.IsEmulatorstays (androidMain actual).OkHttpWebSocketis production-dead (only androidTest usages) and duplicates quartz'sBasicOkHttpWebSocket— migrate the tests, delete.- Closes a real desktop gap:
desktop/network/DesktopHttpClient.ktre-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 indesktop/platform/PlatformColorScheme.kt— two divergent Amethyst palettes ship today.ui/layouts/:NoteComposeLayout(374),RepostLayout,ScreenLayoutare 100% clean;LeftPictureLayout,ChatHeaderLayout,SlimListItemLayoutneed only content-description strings.DisappearingScaffold→ SPLIT (drop theAccountViewModelparam for a slot; its nested-scroll half is already in commons).ui/feeds/(12 of 14):FeedStates,RefresheableBox,WatchScrollToTop,RememberForeverStates,ChannelFeedContentState,WatchLifecycleAndUpdateModelhave zero blockers;FeedEmpty/Error/Loading/UserBlockedFeedneed 2-3 strings each. Desktop hand-rolls its own empty/error/loading states.ui/components/20 zero-blocker files: theClickable*family,M3ActionDialog,OutlinedThinPaddingTextField,SlidingCarousel,AudioWaveformReadOnly,LatexEquation,FileAttachmentCard,pdf/PdfFetcher, andtoasts/(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;DefaultFeedOrderEventlikewise.- napplet clean half:
NappletRelayCleartext(pure NIP-04/44 → quartz orcommons/napplet/protocol),NappletLaunchRegistry,NappletNotificationStore,NappletIdentityWatch→commons/napplet(jvmAndroid). service/namecoin/NamecoinNameService→commons/service/namecoin. Desktop'sDesktopNamecoinNameService.ktsays "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(onlySystemClock→TimeUtilsswaps).service/cashu/→commons/cashu(rest of the wallet is already there);CachedCashuParser/MeltProcessorneed the KMP LRU.- Small singles:
PodcastRemoteContent→commons/podcasts;BuzzInviteMinter→commons/actionsorcommons/buzz;WritingAssistant(pure interface) → commonMain;DetectedWorkout+WorkoutMerger(197 lines of merge logic) →commons/workouts;ConnectivityStatus+ConnectivityManagerflow plumbing → commonMain with the AndroidConnectivityFlowas 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 existingcommons/tor.ui/screenroot 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/uploadspickers/camera/voice,creators/capture files, Zoomable/PDF viewers,GifVideoView. - Android services: notifications (channels/FCM/TileService), calendar
(WorkManager), location (
LocationManager—LocationGeoHashcaches 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 tocommons/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 |
Recommended sequence
- Wave 0 (mechanical, this branch's follow-ups): delete the 13+ typealias shims; land Batches 1-5 above (~350 files) — no refactoring required.
- Wave 1 (three small edits, huge fan-out): finish
EOSE.kt;IFeedTopNavFilter→ICacheProvider; add KMPLruCacheexpect. Unlocks subassemblies (145 — landed 2026-09-05, see the handoff below), topNavFeeds (23), caches (5). - Wave 2 (ports): extract
TopFilter+ListBackupStorefromAccountSettings; moveLocalCachebehind its 3 tiny expects. Unlocks nip51Lists, the nipNN state packages, serverList/userMetadata,ui/screen/**/dal/— and deletesDesktopLocalCache. - Wave 3 (strings): build the Compose-resources
stringRestwin, 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). - Wave 4 (decompose the god-objects): grow
IAccountand migrateAccount's six concern groups (relay-set derivation, nip51 state, the 30-feed-type × 2 field explosion → aMap<FeedType, FollowListPair>, relay-auth policy, buzz state, the 218 action methods →commons/actions) until app-sideAccountis only composition wiring. ShrinkAccountViewModelthe same way. This is what unblocks the remaining ~400datasource/+ui/note/types/files. - 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,
EncryptionKeyCacheandHttpClientEnvironmentnow live in commonMain (verified against JVM, iOS andverifyKmpPurity).NWCPaymentWatcherSubAssemblerturned out to useNWCPaymentQueryStatefrom 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:
RelayAuthPromptBusis now lock-free (ConcurrentMap.getOrPut+ identity-check ownership), andNappletLaunchRegistrydropped all three@Synchronizedby replacing its JVM-only access-ordered LinkedHashMap withandroidx.collection.LruCache(internally synchronized, access-ordered cap — same semantics). Locks that protect real multi-field invariants stayed asKmpLockdeliberately: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.concurrentatomics/CHM became stdlibkotlin.concurrent.atomics+ quartzConcurrentMap(which grewputIfAbsent/remove(key,value)/clearfor the leader-follower caches). Extra pins found while executing: speedLogger usedkotlin.concurrent.timerand::class.java(the tick now runs on a cancellable coroutine scope — the old daemon timer outliveddestroy());OnionLocationCache,BlossomReadAuthTokenProvider,DmRelayDiagnosticsLogger,NappletNotificationStoreswapped toTimeUtils.nowMillis(). Bonus promote:service/upload/BlossomAuth(quartz-only imports). Two reclassified to Tier 4:NappletIdentityWatch(depends onNappletProtocolJson, pinned byjava.util.Base64) andNamecoinNameService(quartzElectrumXClientis 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/servicealso holdspow/,georelay/,broadcast/; several service-ish concerns live as top-level packages (scheduledposts,cashu,podcasts,audio,connectedApps,favorites,napplet,browser) — theservice/vs top-level split deserves a stated rule. commons/model/nip02FollowList(singular) vs appmodel/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
CoilImageBridgeexpect/actual, three Desktop clones deleted), and the Tier-2 concurrency review (2 locks removed, 4 kept asKmpLockwith documented invariants; quartzConcurrentMapgainedputIfAbsent/remove(k,v)/clear). - Merged main twice; second merge brought the #4026 Shorts rename, and
commit
5cf47f6fdropped the 30 orphanedroute_video/new_shorttranslation entries (15 locales × 2 keys) that were failing:amethyst:lintFdroidBenchmarkon main and every branch. Main is still red until that cleanup lands there — cherry-picking5cf47f6ffixes 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.txthas ~251 rules naming pre-move classes; regenerating requires the:baselineprofilemacrobenchmark on a device, which this environment cannot run. Regenerate before or shortly after release.
Original findings, for reference:
- [ship-blocker]
TopFilterserial names changed — the move tocommons.model.topNavFeedschanged every subclass's kotlinx default serial name; persisted per-tab feed-filter prefs (written byJsonMapper.toJson, read viaparseTopFilterOrDefaultwhich swallows decode errors) silently reset for every user on upgrade. Fix: add@SerialName("com.vitorpamplona.amethyst.model.TopFilter.…")(old FQNs) to each subclass. HttpClientEnvironment.isEmulatorset too late —Amethyst.onCreatebuildsAppModules(which eagerly constructs both OkHttp factories and their dispatchers) before setting the flag; the emulator branch is dead. Set the flag beforeAppModules(this), or read it lazily.@Contextual AddressinTopFilterhas no iOS serializer — the nativeMainAddressactual is not@Serializableand JsonMapper registers no contextual serializer; serializing address-carrying filters throws on iOS. Annotate the native actual or register a serializer.- Baseline profile stale —
baseline-prof.txthas ~251 rules naming pre-move classes; regenerate via:baselineprofile(cold-start wins regress until then). NappletLaunchRegistryin commons breaches the documented napplet sandbox boundary (CLAUDE.md says the broker-side registry stays in:amethystso:nappletHostcannot import it). Move it back or update the boundary doc + add a guard.PlatformImage.toSkiaBitmap()duplicated verbatim inCoilImageBridge.jvm.ktand.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 thesubassembliespath 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 misnamedshorts/…/FilterPollsByAllCommunities.ktbecameFilterShortsByAllCommunities.kton the way. - Pure companions the 161 needed, moved alongside:
video/datasource/FeedBasis.kt(the picture/video kind lists) →relayClient/video, andrelayGroup/datasource/RelayGroupFilterBuilders.kt(+ its test) →relayClient/channel/relayGroup. The two*_PAGE_LIMITconsts went torelayClient/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 asStateFlows instead of reaching intoAccount), andVideoQueryState(carrieslistName,followsPerRelayandlastNoteCreatedAtWhenFullyLoadedflows instead ofAccount+AccountFeedContentStates). The app constructs them inSearchBarViewModel,UserSuggestionStateandVideoFilterAssemblerSubscription; the*FilterAssemblerclasses stayed in the app (they are now import-clean and can follow with the datasource batch). ICacheProvidergrew the seams the sixLocalCache-coupled files needed:consume(nip19: Entity)(hint + placeholder seeding for a search query),allRelayGroupChannels()andgetRelayGroupChannelsOnRelay(relay).DesktopLocalCacheimplements them (relay groups: empty, desktop has no NIP-29 cache yet).filterByAuthor/Event/Addressand the set-levelfilterRelayGroupsBy{Authors,MutedAuthors,Follows}+filterRelayGroupsDiscoverytakecache: ICacheProvideras their first parameter; the per-relayfilterRelayGroupsByAuthorslost itsLocalCachedefault forcachedChannels(the test always passed it explicitly).- The four tests moved to
commons/src/commonTestonkotlin.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;DiscoveryQueryStateadds its seven named tabs,SoftwareAppsQueryStateadds 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, plusonListChanged/extraInvalidatorshooks).SingleTopNavFeedSubAssembler(client, keys, ::makeXFilter, resetEoseOnListChange)is the concrete one every single-dispatcher feed uses; Discovery's three and SoftwareApps' one are subclasses.TopNavFeedFilterAssembler<K>— theComposeSubscriptionManagerthat owns the sub-assemblers. Each feature keeps a one-linecommons/relayClient/<feature>/XFilterAssembler.ktnaming its dispatcher, soRelaySubscriptionsCoordinatorand the*Subscriptioncomposables 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.subscribedoesrelays.associateWith { filters }; relay choice is "all connected/configured relays"). No per-relaysince, no EOSE bookkeeping, noSubPurpose. - Commons builds
List<RelayBasedFilter>— oneExplainedFilterper relay — from anIFeedTopNavPerRelayFilterSetthe outbox model derives from follow lists + each author's NIP-65 write relays, withsinceper relay from the EOSE managers, driven byComposeSubscriptionManager. - 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, wheresincelives).
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:
- Adapter, partial match — teach
SubscriptionConfig/RelayConnectionManagerto acceptList<RelayBasedFilter>(group by relay into the map) so desktop can call commons functions with a uniform set, e.g.GlobalTopNavPerRelayFilterSet(relays.associateWith { GlobalTopNavPerRelayFilter })or anAuthorsTopNavPerRelayFilterSetfrom the follow list. Reuses the shared filter shapes and deletes most ofFilterBuilders; keeps desktop's broadcast relay policy andsince = null. Prerequisite for (2). Recommended next step. - 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/DesktopLocalCachedirection; a project, not a cleanup. - 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.
Next work, in recommended order (needs maintainer go-ahead per item)
- Tier 4 unlocks — done 2026-09-26, see the last section. (Was: small, mechanical:
NappletProtocolJsonjava.util.Base64→kotlin.io.encoding.Base64(freesNappletIdentityWatch);ScheduledPostStoreJackson+java.io.File→ kotlinx-serialization+okio (freesScheduledPostWorkGate).LargeSoftCachestays 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, deleteDesktopLocalCache.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/loadPluralStringResfor non-composable scopes. Thin delegates over compose-resources — no extra cache (the library caches parsed locale files process-wide).- App-side
ui/StringResourceCache.ktgainedstringRes(StringResource)overloads delegating to the bridge, so ONEstringResimport serves mixed files: migrating a key is justR.string.x→Res.string.x(+ the twocommons.resourcesimports). 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$dargs, 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 toloadStringResto 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 appui/theme/Shape.ktintocommons/ui/theme/Sizes.kt, pluspainterRes/TimeAgo/NewItemsBubbledecisions — 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:compileKotlinIosArm64is the cheap local gate for iOS breakage (CI's test-quartz-ios compiles commons for iosSimulatorArm64). If the Kotlin/Native toolchain corrupts (danglingliblto_plugin.sosymlink), delete the extracted dirs under/root/.konan/dependenciesand let gradle re-extract.- stdlib atomics have no
incrementAndFetch()here — useaddAndFetch(1);withLock {}can't assign outervals — 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?)—isHiddenandhiddenWordsCaseare already onIAccount, andisFollowing(user)is by definitionuser.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.pruneHiddenMessagesalready tookIAccount.CachePruner.pruneHiddenEvents(hidden: LiveHiddenUsers)— takes the mute list by value, the idiomICacheProvider.findNotesMatchingalready documents.IAccountexposes hidden users only as hash codes, which would prune authors the reader never muted.
Other pieces
ILocalCachewas declared inline inLocalCache.ktand used nowhere else; hoisted tocommons/…/model/cache/ILocalCache.kt(commonMain) as the write-side port next to the read-sideICacheProvider.AntiSpamFilter:android.util.LruCache→androidx.collection.LruCache, already the commons idiom. Itsgetis 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() -> LocalCacheHostrather than a value, because the cache that owns it is built before the shell installs one.njumpLinkmoved fromui/note/NoteQuickActionMenu.kttocommons/…/util/ExternalLinks.kt(AntiSpamFilter logs one); the app's four call sites import it from there.OnchainZapResolver.onchainTipHeightFlowdropped itsrunCatching { Amethyst.instance }fallback —cache.appHost.scopealways 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 dedicatedEventCacheoverload (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
newEventBundlesis driven byDesktopRelaySubscriptionsCoordinator, not by the cache, so Desktop can keep owningDesktopCacheEventStreamand does not inheritLocalCacheFlow's 1 sBundledInsertwindow.
Resolved during part B
notesByAuthor— a strongConcurrentHashMap<HexKey, MutableSet<Note>>of every note ever consumed, which defeated theLargeSoftCache(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'sUsermetadata flow throughEvent.rememberDisplayData(which already existed, documented for this, with zero call sites).objectvsclass—EventCacheis now the class,LocalCachethe process-wideobjectover it. Android's ~350 static call sites were untouched.findUsersStartingWith(prefix, limit)—EventCachenow overrides the port method instead of inheriting itsemptyList()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.
- Storage unification first (safe, mechanical): drop Desktop's
users/notes/addressableNotes/liveChatChannelsand delegate to the ownedEventCache. Desktop's ownconsumeXxxmethods keep working because they go throughgetOrCreateNote/getOrCreateAddressableNote. Note the key type widens (addressableNotes<String, _>→addressables<Address, _>); the one external reader (DesktopRelaySubscriptionsCoordinator:599) ignores the key. - Then swap routing kind by kind, each step green against the 62 tests in
the 8
desktop/cachetest classes, ending atcache.checkDeletionAndConsume(event, relay, true). - Desktop-only state stays, moved behind the facade:
localRelayStorewrite-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. - Two Desktop-shaped
consumeoverloads do not generalize — the NIP-47LnZapPaymentRequestEventone takes azappedNoteand anonResponsecallback, and the response one drivespaymentTracker+appScope. Android reaches the same tracker through account state. Keep them Desktop-side. clear()has only 2 production call sites (Main.kt, logout/account switch) plus 2 in tests.EventCachedeliberately has noclear():DeletionIndex,FilterIndex,HintIndexerandNwcPaymentTrackerhave no way to reset, so one would be a half-truth. Construct a freshEventCacheinstead — 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.WeakReferenceis already anexpect/actual, and its KDoc already anticipateskotlin.native.ref.WeakReferencefor iOS.- quartz
commonMainalready shipsConcurrentMap,ConcurrentSet,ConcurrentHashCacheandLargeCache— so everyConcurrentHashMap/ConcurrentSkipListSetabove is a swap, not a design problem. — wrong, corrected 2026-09-21. It is load-bearing, and it is the thing that stops this migration.LargeSoftCache's skip-list ordering is not load-bearingLargeSoftCacheimplementsCacheOperations(which is itselfquartz/jvmAndroid, not commonMain) for its rangedforEach(from, to, …), backed byConcurrentSkipListMap.subMap. The whole ofLargeSoftCacheAddressExtis built on it:filter(kindStart(kind), kindEnd(kind), …)walks only one kind's slice of theAddresskey space, andfilterIntoSetis 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; thedateFormattercall is one log line and can go.androidx.collection.LruCacheinAntiSpamFilteris already KMP —commonMainuses it inblurhash/CosineCacheandrelays/EOSE.
The two pieces of genuinely new work:
- NIP-95 blob sink.
EventCache.consume(FileStorageEvent)writes throughjava.io.File/FileOutputStream. Needs okio or anexpectsink behindLocalCacheHost.nip95BlobDir. java.util.SortedSetin the public API.EventCache.filter(Filter)returns one, and both*ListMatchingFilterobservables take(Filter) -> SortedSet<Note>. Kotlin has no commonSortedSet, so this is a signature change rippling intoCacheSearch, both observables andLocalCacheSearchParityTest.
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:
LargeSoftCache→commonMainon the existingWeakReferenceexpect plus quartz'sConcurrentMap. This is the keystone — everything else is behind it.- The
ConcurrentHashMap/ConcurrentSkipListSetholders, mechanically. SortedSetout ofEventCache.filter's signature.- The NIP-95 sink.
EventCache+LocalCacheHostthemselves.
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, asexpect class LargeSoftCache<K : Any, V : Any>() : ICacheOperations<K, V>. Like quartz'sLargeCache, 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.ktis today's implementation unchanged in behaviour:ConcurrentSkipListMap<K, WeakReference<V>>behindCacheOperations. The collector defaults and bothforEach(BiConsumer)overloads stay JVM-side; onlysize()needed anactual override, the rest actualize through inheritance.java.lang.ref.WeakReferencebecame the commonsWeakReferenceexpect — the same type on JVM, it is anactual typealias.LargeSoftCache.ios.ktthrows on every member. Kotlin/Native has no sorted concurrent map and no weak-valued one; aHashMapactual would compile and then be wrong in exactly the wayLargeCacheRangeFallbackTestwarns about — every bounded scan silently walking every entry. The stub keeps:commonscompiling 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.ktmoved tocommonMainwith it — its rangedfilter/filterIntoSet/mapNotNullIntoSetcalls are allICacheOperationsmembers, 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>→ quartzConcurrentMap.counts.merge(key, 1, Int::plus)becomesmerge(key, 1) { old, new -> old + new }, which is atomic on every target, so a count still cannot be lost to a racingadd.suggest()now ranks oversnapshot()— a copy the comparator cannot see shift underneath it.DvmHeartbeatRegistry—ConcurrentMap.getOrPutis the same atomic get-or-create theConcurrentHashMapversion relied on, so two threads racing the first beat for an address still agree on oneMutableStateFlow.NwcPaymentTracker—ConcurrentMappluskotlin.concurrent.atomics.AtomicIntforspoofAttempts(incrementAndGet()→fetchAndIncrement(),get()→load(), under@OptIn(ExperimentalAtomicApi::class)as quartz already does inBleChunkAssemblerandBanStore).containsKeyhas noConcurrentMapequivalent and became a null check onget, which is the same question for a map that never stores nulls. Its test moved tocommonTest, 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,NappletIdentityWatchandNappletRequestRoutermoved tocommons/commonMain/…/napplet. The only pins werejava.util.Base64and oneSystem.currentTimeMillis(). Base64 is nowkotlin.io.encoding.Base64, decoding withPaddingOption.PRESENT_OPTIONALbecausejava.util.Base64.getDecoder()accepted unpadded input and applets may send it. The clock isTimeUtils.now(). - Scheduled posts.
ScheduledPostStorehad already dropped Jackson for kotlinx-serialization; what pinned it wasjava.io.Fileand a POSIXchmod. It now takes an okioPathplus aFileSystem(defaultplatformFileSystem), and aStringconstructor so callers need no okio, the same shape asFileSystemNip95BlobStore. The owner-only permission is a newexpect fun restrictFileToOwner(path, tag):Files.setPosixFilePermissionson jvmAndroid, POSIXchmod(0600)on iOS. The temp-file swap isFileSystem.atomicMove, which replaces an existing target on every platform, so the old "rename, else delete and rename again" fallback went with it.ScheduledPostWorkGate,ScheduledPostPublisherandScheduledPostNotifiercame 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, …)insidesuspendsenders (sendAttachment,sendVoiceNote) →loadStringRes(…).adminFailureTextbecamesuspend; its only caller is already inscope.launch.- The backup review kept its labels as
@StringRes Int(DiffGroup.label,ReviewRow.*Res,eventTypeName(), thecountTiles/moneyHero/laneItemsparams) →StringResource/PluralStringResource. rememberPresentationresolved labels insideremember { }throughLocalResources. Compose resources have no non-suspend accessor, so the 16 labelspresentationOfwrites into item text are resolved in composition into a map, and the map is therememberkey.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 pushesLocalContextinto its two callers.ScreenLayout: needsLocalConfiguration→LocalWindowInfo, and the window-size-class breakpoints inlined.SlidingCarousel: needsanimatedViewerChromeInsethoisted out of the viewer-chrome file.AudioWaveformReadOnly: needs two enums from the Android-only waveform library replaced.FileAttachmentCard: needsextractFilenamehoisted.PdfFetcher: needs the disk cache injected.ClickablePhone/ClickableEmailon Desktop: deliberately unchanged.AmethystThemesplit (the pure scheme/typography half to commonsUI; theAmethyst.instanceoverload and the system-barSideEffectstay).TimeAgosplit: the labels,NowProviderand the style enum are pure; the absolute and short formatters need expect/actual overandroid.text.format.- Palette unification: Desktop's
PlatformColorSchemeand 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_mdstrings CommonMark turns that into a code block. Crowdin also exports a translator's stray edge space, which aapt used to drop, for examplemiejsca zniknęłyand Hindi'sदि॰time suffix.fix_escapes.pynow 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, andcompose_escaping_check.pynow fails on raw line breaks, so a sync can't bring it back. 598 values across 54 files were repaired. rememberPresentationread all 16 backup labels on every recomposition, even for diff types that write none. It now reads only the ones its diff type uses, andBackupPresentationLabelsTestfails ifpresentationOfasks 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 whereFile.exists()returned false). Symlinked store files still count as present. The chmod is skipped for an injected non-systemFileSystem, and the cleanup log keeps its throwable.ClickableEmailpercent-encodes%in themailto: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$darguments now render ASCII digits in every locale, the same as every other migrated string.ChannelFeedContentStatereachesLocalCache.appHostfor its main-thread check.