FileCordnStores (CordnStorageLayout and the group, key-package, coordinator and handoff stores), FileBackedCordnScopeFactory, CordnMigrationStores and EncryptedAppendLog move from jvmAndroid to commonMain. They now take an okio Path, plus a FileSystem that defaults to platformFileSystem, where they used to take a java.io.File. The on-disk format does not change. These files hold encrypted MLS group state that cannot be re-derived, so CordnStorageFormatGoldenTest pinned the exact tree of paths and the bytes of every file against the java.io implementation first. It passes against this one with its constants untouched; only its construction glue moved from File to Path. - Cursor framing: ByteBuffer's default big-endian putLong/getLong is now okio Buffer.writeLong/readLong, which is also big-endian. - Migration base64: java.util.Base64 is now kotlin.io.encoding.Base64.Default with PRESENT_OPTIONAL padding, the same alphabet and output. A new test checks it against java.util.Base64 in both directions, padded and unpadded. - Failure handling: File.delete(), deleteRecursively() and renameTo-else-copy returned false where okio throws, so the File behaviour is kept by deleteQuietly, deleteRecursivelyQuietly and moveOrCopy in commons/util/FileSystemExt.kt. - Durability: the log's fsyncs are FileHandle.flush(), which is FileDescriptor.sync() on the JVM and Android. Callers updated: the CLI's CordnContext, CordnRuntime, Account.cordnFilesDir (now Path?), AccountCacheState, and EncryptedMarmotMessageStore, which stays in jvmAndroid and passes toOkioPath() to the log. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
112 KiB
Full sweep: amethyst/ → :commons migration candidates
Direction change (2026-09-27): one UI. Android now ships on laptops, so every screen and the navigation shell move to
commonsUI,amethystbecomes an Android shim, and a new JVMdesktopAppreplaces the current one with the same UI. See 2026-09-27-one-ui-android-desktop.md. That supersedes, below: the STAY list's screens/navigation entries, "decomposeAccount, shrinkAccountViewModel" (both now move), Wave 2 part B (dropped), and the "Desktop phase" merges of Desktop's own forks (dropped). This file stays the log of what moved; Wave 4 is planned and measured in the new plan.
Execution status (updated 2026-08-30, same branch): Waves 0-1 are DONE on this branch — the 12 shim deletions, the 38-file relayClient batch (with
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)
Revised 2026-09-27. The navigation shell and the screens no longer stay: they move to
commonsUI(see the one-UI plan). What is listed below is what remains platform-native. The media, capture and service entries stay as implementations; the screens that render them reach them through ports or slots.
Navigation shell & screens:Moving to*Screen.kt,*TopBar.kt,New*Button.kt,INav/Route/RouteMaker/AppNavigation, drawer/bottom-bar,AccountScreen/AccountSessionManager/LoggedInPage,loggedOff/,settings/screens.commonsUI(RouteMakertocommons).- 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 — re-scoped 2026-09-27 from
"decomposition" to moving both to
commons. Measured and sequenced in 2026-09-27-one-ui-android-desktop.md: theAccountgroup is 77 files / ~23.9k lines insidemodel/, with 5 hard-blocked files and 14 exit edges to cut.
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)
Dropped 2026-09-27. The new desktop app uses
LocalCacheandAccountdirectly; the currentdesktopAppkeeps its fork until it is retired. The analysis below is kept for the record.
Part A moved the cache. Part B is retiring the Desktop fork. The naive framing ("repoint ~70 consumers and delete 1,177 lines") is wrong; what follows is the measured picture.
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,DmRelayListEvent,StarterPackEvent) 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-47NwcRequestEventone 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.
2026-09-27 — the rest of the single-blocker UI, TimeAgo, and the theme
Moved
| What | To | The blocker, and how it went |
|---|---|---|
ScreenLayout tier logic, CappedScreenContent, the pane widths |
commonsUI/…/ui/layouts/ScreenLayout.kt |
material3-window-size-class (app-only): its width breakpoints (Medium ≥ 600dp, Expanded ≥ 840dp) are inlined, and ScreenLayoutTest (18 cases, now in commonsUI jvmTest) passes against them. LocalConfiguration stays in the app, which asks the shared rememberScreenLayoutSpec(widthDp, heightDp), so Desktop can supply its own window size. |
animatedViewerChromeInset, rememberViewerControlsVisibility |
commonsUI/…/ui/components/ViewerChrome.kt |
They sat in a file full of Android window code. Only ImmersiveSystemBarsEffect (Window/insets controller) stays. |
SlidingCarousel |
commonsUI/…/ui/components |
needed the inset above |
AudioWaveformReadOnly |
commonsUI/…/ui/components |
two enums from the Android-only audiowaveform library, now local enums with the same values |
FileAttachmentCard (+ FileAttachmentRow, now public) |
commonsUI/…/ui/components |
extractFilename hoisted to commons/…/util/MimeTypeLabels.kt |
PdfFetcher |
commonsUI/src/jvmAndroid/…/service/pdf |
Amethyst.instance.diskCache → a diskCache parameter. commonsUI jvmAndroid gained okhttp-coroutines (already on every module that ships OkHttp). |
TimeAgoFormatter (timeAgo*, timeAbsolute*, dateFormatter, lastSeenSentence, timeAgoShort, TimeAgoLabels) |
commonsUI/…/ui/note/TimeAgoFormatter.kt |
android.text.format — see below |
ToggleableTimeAgoText, TimeAgo, NormalTimeAgo, TimeAgoStyle, NowProvider, LocalNowSeconds |
commonsUI/…/ui/note/elements |
followed the formatter |
AmethystTheme's scheme, typography and providers |
commonsUI/…/ui/theme/AmethystTheme.kt (AmethystMaterialTheme, amethystDark/LightColors, isDarkTheme, previewColor, toFontFamily) |
The app's AmethystTheme resolves the prefs, calls it, then tints the system bars. The Vico chart colours stay app-side (Vico is Android-only here). |
The date-format seam. PlatformDateFormat.kt (commonsUI) has four expects:
DateSkeletonFormatter(skeleton):- Android:
getBestDateTimePatternin aThreadLocalSimpleDateFormat, exactly the old code. - JVM:
DateTimeFormatter.ofLocalizedPattern(JDK 19+, the same CLDR skeleton lookup), cached per locale and zone. - iOS:
NSDateFormatter.setLocalizedDateFormatFromTemplate.
- Android:
calendarYearAndDay:Calendaron jvmAndroid,NSCalendaron iOS.rememberTimeOfDayFormatter:- Android:
DateFormat.getTimeFormat(context)per call, as before, so the system 12/24-hour setting is still followed. - JVM: the locale's SHORT time.
- iOS:
NSDateFormatterShortStyle.
- Android:
relativeTimeSpanShort:- Android:
DateUtils, as before. - Elsewhere: the compact "5m" form.
- Android:
DateSkeletonFormatterTest pins the JVM side: en-US vs en-GB order from the same instance after a locale switch, the three skeletons, day/year boundaries, and the same-day branch of timeAbsoluteWith.
Desktop still has its own ToggleableTimeAgoText and the older commons/…/util/TimeAgoFormatter.kt (hard-coded English units, DateFormat.MEDIUM). Merging those onto this one is the Desktop phase.
What is left in amethyst/ui, measured (2026-09-27)
A transitive-blocker sweep of the 1,385 files under amethyst/…/ui/, after this round:
-
80 files have no blocker left.
- Many are headless and belong in
commons, notcommonsUI: the filter assemblers and*LastRead,NewMessageTagger,SplitConversor,PubKeyFormatter,SettingsCatalog, and the Tor status/dialog VM. - The Compose ones include the chat bubble set (
ChatBubbleLayout,ChatGroupPosition,JumboEmoji,NewDateOrSubjectDivisor,AutoScrollToNewest). It moved tocommonsUIin the follow-up below. Desktop'sui/chats/ChatBubbleLayout.ktis an older fork of it.
- Many are headless and belong in
-
AccountViewModelis the wall:- 892 files touch it, and 310 touch nothing else app-side. Yet swapping it for an interface frees only 76 files by itself, because the rest call hub composables that are blocked themselves.
- The ui files use 210 distinct members of it. The note renderers use 28.
- Only ~10 note renderers become movable with a context interface alone.
-
The real levers are about ten hub composables, each blocking the ui files that call it:
Hub Files blocked UserProfilePicture123 RouteMaker89 UsernameDisplay77 Loaders66 DisappearingScaffold64 (AVM only, 53 lines) RichTextViewer62 NoteCompose58 FeedContentStateView/FeedViewAVM only Also on the list:
- the
reqCommandobserve*helpers (176 files; they takeaccountViewModelthemselves); - the flavour-only
TranslatableRichTextViewer(54 files), which wants a slot or a CompositionLocal.
- the
-
Two corrections to the MOVE-AFTER table above:
INav/Routeare no longer blockers: every ui file imports the commons ones.ui/note/typesis 111 files, not ~89, and "AVM threading" understates it: the hubs matter more than the parameter.
Follow-up in the same round: the chat bubble group, and the audit
- Moved to
commonsUI/…/commons/chats/ui, besideChatDivisorandUserDisplayNameLayout:ChatBubbleLayout,ChatGroupPosition,JumboEmoji,NewDateOrSubjectDivisorandAutoScrollToNewest.AutoScrollToNewestandCHAT_GROUP_WINDOW_SECONDSwent frominternalto public so the app can reach them. Desktop'sui/chats/ChatBubbleLayout.ktis an older fork of this one (no group position, jumbo emoji, swipe-to-reply or reaction row). Replacing it is the Desktop phase. - Audit follow-ups:
PdfFetchertakes the disk cache as a provider, read on the IO dispatcher, so a PDF card composing on a cold start doesn't build the app's lazy cache on the main thread.- The two DM lists resolve
TimeAgoLabelsonce per list instead of once per row. - The iOS
DateSkeletonFormatterrebuilds on a locale change, as Android's does. (The JVM one also keys on the time zone; iOS caches its system zone until reset, so keying on it there would cost lookups and still not notice.) ScreenLayoutTestpins the inclusive 600dp boundary.- Two stale KDoc links and a same-package import are fixed.
- The generated baseline profile has its stable-name entries repointed:
NowProviderKt, and the five theme functions now inAmethystThemeKt. Its R8 lambda entries were already stale before this round (it still listsMarkDownStyleOnDarkunderThemeKt), and the new theme root (AmethystThemeKt;->AmethystMaterialTheme,isDarkTheme) has no entries at all, so regenerate the profile after these moves.
Second audit, and one Desktop decision
- Fixed:
PdfPreviewCardclosed its cache snapshot on the main thread, afterwithContext(IO)returned. That was older than this branch. Closing takes the same global DiskLruCache lock as opening, soPdfFetcher.useSnapshotnow fetches, runs the block and closes, all in one IO block, leaving no suspension point for cancellation to leak through.- The PDF viewer's
onDisposenow closes its handle on the app IO scope, under the render mutex. - JVM and iOS built a time-of-day formatter per feed item; they now share one cached
instance.
NSDateFormatteris costly to build. timeAgoShortno longer allocates an unused fallback lambda per tick on Android.- The iOS skeleton formatter keys on the locale only, like Android. iOS caches its system zone until reset, so keying on the zone cost lookups and still never saw a change.
- New tests:
JumboEmojiTest(counts, ZWJ/skin tone/flag/keycap sequences, bubble shapes).ScreenLayoutTestmoved to commonsUI jvmTest, beside the code it tests.
- Desktop needs
jdk.localedatabefore it uses these formatters.desktopApp/build.gradle.ktsnativeDistributions.modules(...)doesn't include it, so the packaged runtime carries only en/root CLDR data.DateTimeFormatter.ofLocalizedPatternthen gives en-GBJan 5, 2024and de-DE2024 Jan 5, and the JDK tests (full runtime) can't see it.- Desktop's existing
java.timeformatting (DesktopScheduleAtPicker) already has the same gap. - Adding the module costs about +28 MB to the unpacked runtime. That is a packaging call, left for the Desktop phase.
2026-09-27 (later) — the unblocked ui/ files, and one chess view model
How the list was rebuilt
The morning's "80 files have no blocker" count was not saved, so it was recomputed with a script (not committed; it is a one-off) that:
- indexes every public top-level declaration in
amethyst/src/{main,play,fdroid}; - follows each file's imports and its same-package references (the under-count this plan's header warns about);
- marks a file blocked by
android.*, a non-KMPandroidx.*,R./BuildConfig, or a library the shared modules lack; - takes the fixpoint, then assigns each survivor a module and source set from what it
(transitively) uses: Compose UI,
Resor acommonsUIsymbol meanscommonsUI;java.*/JVM tokens or a jvmAndroid-onlycommonssymbol meansjvmAndroid.
It found 96 such files under ui/ (142 app-wide). 68 of the ui/ ones moved
(plus service/uploads/SuspendableConfirmation, which one of them needs), 3 were app
copies of existing commons code and were deleted, and 25 stay, for the reasons below.
Moved
To commons
| Package | Files |
|---|---|
model |
ConcordLastRead, MarmotGroupLastRead, RelayGroupLastRead (beside privateChatLastReadRoute) |
model/chats |
ConcordServerRoomNote, RelayGroupServerRoomNote, HistoryDateFormat (jvmAndroid) |
model/composer |
NewMessageTagger, DraftTagState, IExpiration, SplitConversor, PreviewState (jvmAndroid) |
model/navigation |
RouteTextArgs, ShareToDMRouteRewriter |
model/buzz |
AgentWork |
model/nip29RelayGroups |
GroupDiscoveryConstraintResolver (was dal/GroupDiscoveryConstraint.kt; renamed because the matcher it resolves to already owns that file name here) |
model/nip47WalletConnect |
TransactionRowLabels |
model/nip72Communities |
CommunityRulesLookup |
model/nip92IMeta |
ImetaContent |
relayClient/chatrooms (new) |
the five Filter* functions of the chatroom-list assembler |
relayClient/channel/relayGroup |
RelayGroupOpenThreadsFilterAssembler, RelayGroupsDiscoveryFilter |
relayClient/hashtag (new), relayClient/geocaches, relayClient/polls/results |
FilterPostsByHashtags, GeocacheListingKinds, RelayPollResponseLoader |
feeds |
FilterByListParams, UserFeedState, StringFeedState, SupportedContent |
viewmodels |
RelayFeedViewModel, UserExternalIdentitiesViewModel |
service/upload |
MediaUploadTracker, HlsPublishState, SuspendableConfirmation |
audio, music, qrcode, cashu, mediaServers, tor |
PitchShifter; MusicFormatting; ScanResult + StructuredAppendAccumulator; CashuWalletDiscovery (jvmAndroid); MediaServerHealth (jvmAndroid); TorDialogViewModel |
util |
DebouncedPublisher, TimeFormatUtils + ZapFormatterNoDecimals (jvmAndroid) |
To commonsUI
| Package | Files |
|---|---|
ui/components |
InformationDialog, UrlPreviewCard (both jvmAndroid) |
ui/text |
MentionPreservingInputTransformation (its MENTION_REGEX is now public; the app's UrlUserTagOutputTransformation shares it) |
ui/navigation/navs |
ObservableNav, TwoPaneNav, ShareToDMNav |
ui/navigation/bottombars, ui/insets, ui/theme, ui/settings (new) |
TabReselectCoordinator; KeyboardState; ColorSchemePreview (jvmAndroid: it formats with String.format); SettingsCatalog |
feature ui/ packages |
account/ui/login/LoginErrorManager, clink/ui/ClinkBudgetDialog, cordn/ui/{BusyLabel,SettingsFormBlock}, nip46RemoteSigner/ui/Nip46ActivityUi, nip52Calendar/ui/CalendarsViewMode, nip53LiveActivities/ui/StreamingStatusFlags, nip72ModCommunities/ui/CommunityRulesViolationBanner, relays/ui/SubPurposeLabels, service/upload/ui/StrippingFailureDialog |
audio |
VoicePreset, RecordingIndicators (jvmAndroid) |
Most files moved unchanged apart from the package line. The exceptions, all for
commonMain:
formatTrackDurationwent frominternalto public so the app's music renderers can still call it. Its"%d:%02d".format(...)is now apadStart, becauseString.formatis JVM-only.RouteTextArgs:codePointAt/Character.charCount→ the commonscodePointAtKmp/codePointCharCount, which mirror the JDK ones.TorDialogViewModel:Integer.parseInt→toInt(). Both throwNumberFormatExceptionon the JVM.UserExternalIdentitiesViewModel.Factory:create(Class<T>)→ the multiplatformcreate(KClass<T>, CreationExtras), asPollResultsViewModelalready does.Dispatchers.IOneedsimport kotlinx.coroutines.IOin common code, andRelayFeedViewModel'sjavaClass.simpleNamelog is nowthis::class.simpleName.
A tooling note for the next sweep: stripping comments with a regex before scanning
identifiers breaks on a /* inside a string ("image/*"), which swallows code up to the
next */. Tokenize strings and comments together. And scan bodies, not only imports,
for inline android./java. names and javaClass.
Eleven pure unit tests moved with their code to commons jvmTest (NewMessageTagger key
parsing, PitchShifter, RouteTextArgs, ShareToDMRouteRewriter, DebouncedPublisher,
RelayGroupDiscoveryConstraint, StructuredAppendAccumulator, SupportedContent,
TransactionRowLabels, CommunityRulesLookup) and commonsUI jvmTest
(SettingsCatalogFilter).
Deleted app copies (callers repointed at the commons original):
ui/note/PubKeyFormatter.kt (the commons toShortDisplay is the same with an optional
prefixSize), ui/note/ZapFormatter.kt (same functions; the commons TenGiga…OneKilo
constants became public for ZapFormatterNoDecimals), and the
EqualImmutableLists.kt re-export shim.
ChessViewModelNew and DesktopChessViewModelNew → one ChessViewModel
The two classes forwarded the same ~40 members to ChessLobbyLogic; only the
adapters and the scope differed. commons/nip64Chess/ChessViewModel now takes the
adapters, the polling config, the dismissed-games store and an optional scope:
- Android:
ChessViewModelFactorybuilds it with the Android adapters and no scope, so it runs onviewModelScopeand stops polling inonCleared, as before. The factory lost its unusedApplicationparameter. TheviewModel(key = …)key changed fromChessViewModelNew-<pubkey>toChessViewModel-<pubkey>, in all four call sites together, so the lobby, game screen, note card and home button still share one instance. - Desktop:
ChessScreenbuilds it with the Desktop adapters and itsrememberCoroutineScope(), which is whatDesktopChessViewModelNewdid. TheUserMetadataCachethat class owned is now remembered next to it inChessScreen. The debuginstanceId(System.identityHashCode) went, since it isn't KMP.
Left in the app on purpose (unblocked, but not shared code)
ui/screen/AndroidFeedViewModel,threadview/dal/AndroidLevelFeedViewModel: they exist to bind Android's process-wideLocalCache. Desktop still runsDesktopLocalCache(Wave 2 part B).ui/navigation/ShareIntentRouting: names the manifest's<activity-alias>es.ui/screen/loggedIn/embed/*(9 files) andbrowser/EmbeddedPageRequests: bridges for the Android WebView host. No other front end embeds a WebView.workouts/health/HealthConnectRationaleScreen: Health Connect is Android-only.ui/tor/{TorManager,TorBackend,ArtiNative,ArtiGuardState,TorConnectionFailureDialog}: the in-process Arti client. Desktop drives an external Tor.ui/tor/TorServiceStatus:commons/torhas its own copy that differs on purpose (Android hasBootstrapping, Desktop hasError). Merging them changes everywhenover it on both platforms; that's a design step, not a move.ui/actions/UrlUserTagOutputTransformation: callsandroid.util.Patterns.WEB_URLby its fully-qualified name, which an import-based scan cannot see. It needs a KMP URL matcher (commonsrichtexthas one) before it can move.nests/room/{lifecycle/NestRoomEventCollectors, participants/RoomParticipantActions, stage/SpeakerReactionOverlay, stage/SpeakerZapOverlay}:internalpieces of the Android audio-room screen, which Desktop doesn't have.
Unblocked outside ui/ (not moved in this round)
The same sweep finds 46 more movable files outside ui/. The notable groups are
service/resourceusage (9, the resource-usage ledger), service/uploads/blossom
(InFlightInvoices, PaymentPromptLedger, the bud10 resolver trio),
service/playback leaves (HlsLivenessCache, SessionRegistry,
WssDataStreamCollector, the player control buttons), and model/MediaAspectRatioCache.
Audit of this round
Three reviewers read the 69 moved files. I read the chess merge and the hand edits. The
edits kept behaviour: codePointAtKmp/codePointCharCount match the JDK calls,
padStart matches %02d for every Int, toInt() throws the same exception as
parseInt, and lifecycle's ViewModelProvider calls the KClass factory overload
first. None of the findings below came from the move; all are on main.
Fixed:
- Zap amounts rounded half-even (
showAmount,showAmountInteger). Kotlin'sBigDecimal.divalready rounds to the dividend's scale with HALF_EVEN, so thesetScale(0, HALF_UP)after it never ran: 12,500 read "12k" and 2,500,000 read "2M". The unit was also picked before rounding, so 999,500 read "1000k". Both now usedivide(_, 0, HALF_UP)and pick the unit after rounding. TheBigDecimal(0.01)built on every call is now a constant. TheshowAmountKDoc examples were wrong and now match what it returns.ZapFormatterTestcovers this. AgentWorkBoard.mergesorted every band by upvotes. Its KDoc says upvotes order only the queue. An old upvoted job sat above a fresh one in Shipped, and every job sat above every workflow run in Working. Upvotes now count only in the queue.SuspendableConfirmationcould resume its continuation twice: a button tap plus an outside-tap dismiss before the dialog recomposed away threw "Already resumed". A cancelled upload also left the dialog up with dead buttons. It now resumes only while active, and clears the dialog on cancellation, but only if the dialog is still its own.SuspendableConfirmationTestcovers this.TorDialogViewModel.saveparsed the SOCKS port even with the field hidden. Junk typed while External was selected blocked saving Internal or Off. It also took 0 or 70000. Now only External requires a port in 1..65535; otherwise the last saved port is kept.TorDialogViewModelTestcovers this.ExpandingCirclesAnimationbuilt its infinite transition before checkingisRecording. The composer toolbar always shows the record button, so it asked for a frame on every vsync while the composer was open. The transition now exists only while recording. The circles and the stop dot read their animated values ingraphicsLayer, so a frame redraws a layer instead of recomposing.DebouncedPublisher.flushtreated a job whosepublish()was already running as pending. Leaving the nav picker while a publish waited on a remote signer cancelled it and started a second signing request.flushnow acts only during the delay. A new case inDebouncedPublisherTestcovers this.RelayFeedViewModel:- The DM list's first emission read
nip65RelayListNoteas aDmRelayListEvent, which is always null. It now readsdmRelayListNote. subscribeTo/unsubscribeTocompared theMutableStateFlowitself to aUser, sounsubscribeTonever cleared it. They now compare.value.
- The DM list's first emission read
ScheduledFlagbuilt aDateFormaton every composition of a feed card. It is now remembered per start time.CashuWalletDiscovery: up to 50 relays finishing at once could write the crawl progress out of order, so it stepped backwards. The write is now monotonic.- Desktop
ChessScreen: polling runs on the screen's scope. Whenrememberreplaced the view model on an account switch, the old one kept polling. ADisposableEffectnow stops it.
Reported, not changed (need an owner's call):
FilterLastMessageFollowingPublicChatsasks each relay for kind 41 withlimit = 1across all followed channels, so only one channel's metadata comes back per relay. One busy channel can also fill the kind-42limit = 100. The fix (a limit per channel, or a filter per channel) changes relay load.PitchShifter's brute-force WSOLA search costs about 500 multiply-adds per output sample, and the loop never checks for cancellation. Decimating the correlation changes the audio, and a cancellation check changes its API.RelayFeedViewModel.invalidateDatare-emits the same user into aStateFlow, which drops it, so a refresh only spins for a second.ScheduledFlagstill readsTimeUtils.now()once, so a card composed before the start time keeps the date after it passes.
2026-09-28 — cordn file stores and EncryptedAppendLog to commonMain
cordn/FileCordnStores.kt (CordnStorageLayout and the group, key-package,
coordinator and handoff stores), cordn/FileBackedCordnScopeFactory.kt,
cordn/CordnMigrationStores.kt and storage/EncryptedAppendLog.kt moved from
jvmAndroid to commonMain. They take an okio Path plus a FileSystem
(default platformFileSystem), the ScheduledPostStore shape.
These files hold encrypted MLS group state that cannot be re-derived, so the
format is pinned first: CordnStorageFormatGoldenTest was committed against the
java.io implementation, asserting the exact tree of paths and the exact bytes
of every file, and passes unchanged on the okio one. Notes on the port:
ByteBufferframing (the cursor file) is okioBuffer.writeLong/readLong; both are big-endian.java.util.Base64in the migration snapshot iskotlin.io.encoding.Base64.DefaultwithPaddingOption.PRESENT_OPTIONAL, the same shape as the napplet move.File.delete()/deleteRecursively()returned false instead of throwing, andrenameTofell back to copy-and-delete. okio throws; the File behaviour is kept bydeleteQuietly,deleteRecursivelyQuietlyandmoveOrCopyincommons/util/FileSystemExt.kt. One deliberate difference: the recursive delete removes symlinks instead of following them.- The log's
fsyncs areFileHandle.flush(), which isFileDescriptor.sync()on the JVM and Android. EncryptedMarmotMessageStorestays in jvmAndroid and passestoOkioPath()to the log.