The "Updates all dependencies" commit (ece719445) bumped Kotlin to 2.4.0,
which removed the `DisableCacheInKotlinVersion.2_3_21` enum value. Rather
than bumping the guard, the whole `disableUiKitPrebuiltCache()` workaround
was deleted — which reintroduced the iOS test link failure on CI
The :commons:linkDebugTestIosSimulatorArm64 CI step fails under Xcode 16.4
with 'Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion'. The symbol is
referenced by the prebuilt Kotlin/Native cache of Compose Multiplatform's
org.jetbrains.compose.ui:ui-uikit (CMPLayoutRegion), which was built against
a newer simulator SDK (18.5) than the test binary is linked for (14.0).
Disable the native compiler cache for the iOS test binaries so ui-uikit
recompiles against the active SDK, where UIViewLayoutRegion resolves. The
DisableCacheInKotlinVersion guard re-surfaces the workaround once we move
past Kotlin 2.3.21 so it can be removed when the cache is fixed upstream.
Phase 1 part C — the integration that makes the new compression
pipeline actually run on every upload.
- UploadOrchestrator.upload gains an optional quality parameter
(default CompressionQuality.DESKTOP_HIGH). The orchestrator now:
1. Runs ImageReencoder.reencode — branches on Reencoded vs
PassThrough.
2. For PassThrough + stripExif=true + JPEG source, runs
MediaCompressor.stripExif so animated/SVG passes still get
EXIF stripped where applicable.
3. Computes metadata on the bytes that will actually leave
the machine.
4. Eager-cleans up every intermediate in a finally{} block
wrapped in NonCancellable so user-cancelled uploads don't
leak temps.
Throws CompressionException for the fail-loud dialog path.
- MediaCompressor.stripExif drops deleteOnExit (long-running
desktop process was leaking stripped_*.jpg per
docs/temp-file-cleanup-analysis.md). Temp files now land under
AmethystTempDir with the amethyst_stripped_ prefix; caller owns
cleanup.
- AmethystTempDir lazy-initializer now triggers sweepOrphans() on
first access, recovering any amethyst_* files > 24h old left
behind by JVM crashes that skipped shutdown hooks.
- BlossomClient: open class + open upload methods so
UploadOrchestratorTest can substitute a FakeBlossomClient that
captures the uploaded file's bytes.
- jvmTest now pulls secp256k1.kmp.jni.jvm so end-to-end signer-
based tests (Blossom auth event signing) can run.
5 new orchestrator tests cover: JPEG re-encode path (uploaded file
is the AmethystTempDir temp, smaller than source, hash matches
captured bytes), animated GIF pass-through (uploaded == original),
AVIF refused with UnsupportedFormat (client.upload never called),
temp cleanup on success, and temp cleanup on upload failure.
Test totals across commons jvmTest upload package: 44 / 44 green
(4 smoke + 23 sniffer + 12 reencoder + 5 orchestrator).
Phase 0 of the desktop image compression plan
(docs/plans/2026-06-08-feat-desktop-image-compression-plan.md).
- commons jvmMain gains net.coobird:thumbnailator:0.4.21 (pure-Java,
MIT) — to be consumed by the new ImageReencoder in Phase 1.
- amy CLI now sets -Djava.awt.headless=true via three paths so any
transitive ImageIO/AWT touch never spawns a GUI thread:
* applicationDefaultJvmArgs in cli/build.gradle.kts (covers the
installDist startup scripts and any future jpackage launcher),
* the amyImage custom Unix launcher in cli/build.gradle.kts,
* System.setProperty as the first line of cli Main.kt — belt-
and-braces for invocations that bypass the launcher scripts.
- commons:jvmTest forces -Djava.awt.headless=true for the same
reason during test runs.
Smoke tests (CompressionSmokeTest.kt) document Thumbnailator's
upscale-by-default behavior — ImageReencoder must gate the resize
itself in Phase 1.
Third slice of the amethyst→commons migration. UrlPreview (OpenGraph
link-preview fetcher) and HtmlParser already wrapped the extracted
commons preview parsers (MetaTagsParser/OpenGraphParser/HtmlCharsetParser);
this consolidates the whole link-preview concern in commons.
- Move service/previews/{UrlPreview,HtmlParser} into commons jvmAndroid
service preview package. They land in jvmAndroid (not commonMain)
because UrlPreview uses OkHttp and HtmlParser uses java.nio.charset —
both JVM-only. No Android-framework or keystone coupling: the caller
injects the OkHttpClient as a lambda.
- Add explicit okhttp + okhttp-coroutines deps to commons jvmAndroid
(previously only present transitively via coil-okhttp).
- Re-point the single caller (model/UrlCachedPreviewer).
commons JVM compile + verifyKmpPurity pass; amethyst play + fdroid compile.
Address bugs and gaps surfaced by an audit of the prior 14 commits.
JVM tests passed because of typealias / platform-type lenience that
won't hold on Native; these are real iOS compile / behavior issues.
BUG fixes (iOS compile failures):
- commons/.../Note.kt:899 — Iterable.sumOf { -> BigDecimal } is a
JVM-stdlib-only overload. Common stdlib ships sumOf only for
Int/Long/Double/Float/UInt/ULong. Replaced with fold(BigDecimal(0)).
- commons/.../Note.kt:889 — BigDecimal(it.event?.content): the quartz
expect-class constructor takes String non-null; JVM accepted nullable
via platform-type lenience and threw NPE caught downstream. Switched
to ?.let { content -> BigDecimal(content) }.
- commons/.../Note.kt:838 — `catch (e: java.lang.Exception)` -> `Exception`.
- commons/.../feeds/custom/FeedDefinitionBuilder.kt + FeedBuilderState.kt:
inline FQN `java.util.UUID.randomUUID().toString()` -> kotlin.uuid.Uuid.
random().toString() (Kotlin 2.0+, @OptIn ExperimentalUuidApi).
inline `System.currentTimeMillis() / 1000` -> TimeUtils.now() (already
used elsewhere in the codebase).
- commons/.../viewmodels/NestViewModelTest.kt: moved from commonTest to
jvmTest. The test imports NestViewModel + nestsclient, both of which
the prior PR moved to jvmAndroid. commonTest depends on commonMain
only, so the test would fail to compile for iosSimulatorArm64Test.
SUBTLE fixes:
- commons/.../UserRelaysCache.kt: the flow field used double-checked
locking on a non-volatile var. JMM hazard on Native (ARM weak memory
model) — outer fast-path could observe a partially-published
WeakReference. Added @kotlin.concurrent.Volatile.
- commons/.../util/UrlValidation.ios.kt: NSURL.URLWithString("http:")
returns non-null with scheme="http" and no host; JVM's URI.toURL()
rejects with MalformedURLException. Reject scheme-only network URLs
(http/https/ws/wss/ftp without a host) to match JVM behavior.
- commons/.../util/KmpLock.kt commonMain doc: corrected "NSLock" ->
"NSRecursiveLock" to match the actual iOS implementation.
verifyKmpPurity gate extended (commons + quartz):
- Adds patterns: System.currentTimeMillis, Thread.sleep, java.util.UUID,
kotlin.jvm.Synchronized, kotlin.jvm.Volatile.
- Each pattern paired with a hint pointing at the canonical KMP
replacement; the error message surfaces both.
- Skips lines that start with //, *, or /* to avoid false positives on
KDoc / migration notes.
Phase 2 task 10 of the iOS plan — flip on iOS targets for :commons.
Gradle dep resolution is fully green for iOS; actual Kotlin/Native
compilation runs on the macOS CI job (the dev container in which this
was authored can't extract the K/N LLVM toolchain).
Dep reshuffle to match what's actually KMP-available:
- commonMain: kept project(":quartz"), Compose Multiplatform, coil-compose,
androidx-collection, kotlinx-collections-immutable, kotlinx-serialization-json,
compose components-resources, androidx-lifecycle-viewmodel,
androidx-lifecycle-runtime-compose. These all publish iosArm64 +
iosSimulatorArm64 variants per `.module` inspection.
- jvmAndroid (NEW location): project(":nestsClient") (JVM+Android-only),
coil-okhttp (JVM-only), markdown-commonmark / markdown-ui /
markdown-ui-material3 (the RenderMarkdown.kt consumer is already in
jvmAndroid), and androidx-lifecycle-viewmodel-compose (AndroidX publishes
android + jvmStubs + linuxx64Stubs variants — no iOS, so the viewModel()
Composable helper stays JVM-bound until we either swap to the
org.jetbrains.androidx.lifecycle variant or accept a platform-specific
ViewModel access pattern on iOS).
- libs.versions.toml: adds androidx-lifecycle-viewmodel catalog entry.
- New intermediate source set iosMain → both iosArm64Main and
iosSimulatorArm64Main depend on it (clean place for iOS-only actuals
when KmpLock, WeakReference, etc. get their iOS implementations).
- .github/workflows/build.yml: test-quartz-ios job now also runs
:commons:compileKotlinIosArm64 + :commons:compileKotlinIosSimulatorArm64.
Address review feedback: the project already has LargeCache (in quartz,
with jvmAndroid/appleMain/linuxMain actuals) as its KMP concurrent-map
abstraction — it's used pervasively in the model layer. Adding stately
duplicated that capability with an external dep.
- Comparable-key maps switch to LargeCache:
* ChessEventCollector.moves (String key)
* ChessEventCollectorManager.collectors (String key)
* ChessRelayFetchHelper.events (String key)
* ChessRelayFetchHelper.relayEventCounts: LargeCache<NormalizedRelayUrl,
AtomicInt> with getOrCreate { AtomicInt(0) }.addAndFetch(1) — replaces
the stately .block { compute } increment idiom. getOrCreate is atomic
via ConcurrentSkipListMap.putIfAbsent so all threads end up
incrementing the same AtomicInt instance.
* ChessLobbyLogic.recentlyLoadedGames (String key)
- The SubscriptionManager pair (MutableComposeSubscriptionManager,
ComposeSubscriptionManager) keeps a plain mutableMapOf — T :
MutableQueryState is generic and not Comparable, so LargeCache's
ConcurrentSkipListMap backing would ClassCastException at put time.
Concurrency comes from a KmpLock-guarded map.
- Set-shaped uses switch to KmpLock + mutableSetOf:
* ChessEventCollector.processedEventIds
* ChessRelayFetchHelper.eoseReceived
* ChessLobbyLogic.dismissedGameIds + seenEventIds (the bounded LRU
keeps insertion-order eviction; mutableSetOf returns LinkedHashSet
on every KMP target).
- UserRelaysCache.flow's lock: stately Lock -> KmpLock.
Adds expect class KmpLock() with jvmAndroid actual that wraps
ReentrantLock. iOS actual (NSLock) will land with the iOS target.
Mirrors the WeakReference pattern from the previous PR.
Drops stately-concurrent-collections 2.1.0 from libs.versions.toml and
commons/build.gradle.kts (no remaining consumers).
Phase 2 of the iOS plan — clears the ConcurrentHashMap blockers from
commons/commonMain. Five files migrated (the four flagged in the
initial audit + ChessLobbyLogic, which used fully-qualified inline
java.util references that the import-based audit missed).
Adds co.touchlab:stately-concurrent-collections 2.1.0 — a small,
mature KMP library that provides ConcurrentMutableMap /
ConcurrentMutableSet with semantics equivalent to ConcurrentHashMap /
ConcurrentHashMap.newKeySet on every Kotlin target. The .block { }
helper covers the compound-update paths (ChessRelayFetchHelper's
per-relay event-count compute, ChessLobbyLogic's bounded-LRU dedup).
- ComposeSubscriptionManager + MutableComposeSubscriptionManager:
ConcurrentHashMap -> ConcurrentMutableMap
- ChessEventCollector + ChessEventCollectorManager: map and Set
- ChessRelayFetchHelper: in-function event/relay state
- ChessLobbyLogic: replaces dismissedGameIds (synchronizedSet),
recentlyLoadedGames (ConcurrentHashMap), seenEventIds (bounded LRU
using LinkedHashSet via Collections.synchronizedSet + synchronized {}).
seenEventIds keeps insertion-order eviction semantics because
mutableSetOf returns LinkedHashSet on every KMP target.
The one Jackson holdout in commons/commonMain. Migrating it unblocks
the iOS purity gate for :commons (Phase 1 of the iOS plan).
- Rewrites FeedDefinitionSerializer with the kotlinx.serialization JSON
tree API (JsonObject / JsonArray / JsonPrimitive). Wire format is
byte-identical, so users' existing on-disk custom-feed definitions
keep deserializing — covered by a new parsesLegacyJacksonOutput test
that pins a hand-written Jackson-shaped JSON blob.
- Adds :commons:verifyKmpPurity (mirrors the one in :quartz) and wires
it into the CI lint job alongside :quartz:verifyKmpPurity.
- Pulls in kotlinx-serialization-json as a commonMain dep; the
serialization plugin was already applied on :commons.
The catalog subscription API shipped two commits ago but nothing
read it. This wires it up:
* RoomSpeakerCatalog data class (commons) — kotlinx.serialization
parser for moq-lite's `catalog.json` payload (version, audio
track list with codec / sample_rate / channel_count / bitrate).
Forward-compat: ignoreUnknownKeys + nullable fields tolerate
new keys and partial publishers.
* AudioRoomViewModel.speakerCatalogs: StateFlow<Map<String,
RoomSpeakerCatalog>> populated lazily as per-speaker
subscriptions land. Catalog fetch piggybacks on openSubscription
via subscribeCatalog — best-effort, doesn't gate audio.
* Participant context sheet renders a single-line summary
("OPUS · 48kHz · 2ch") below the pubkey when the catalog is
available.
* Enabled kotlinx.serialization plugin on :commons (was already
a transitive dep, just not wired for @Serializable codegen).
Tests:
* RoomSpeakerCatalogTest — 7 cases covering the canonical shape,
describe() formatting (codec uppercased, kHz / channel
short-formed), all-null fallback, unknown-key tolerance,
garbage-bytes fallback, and empty-audio-list.
Wires `connectNestsListener` into the existing NIP-53 audio-room "stage" so
a user can tap Connect, see the live connection state, and hear hosts
speaking. Per the audio-rooms completion plan
(`nestsClient/plans/2026-04-26-audio-rooms-completion.md`) phase M1.
UI (amethyst):
- AudioRoomStage gets a state-chip / Connect / Disconnect / Mute row
above the existing hand-raise. State chip walks ResolvingRoom →
OpeningTransport → MoQ-handshake → Connected; Failed surfaces the
reason inline with a Retry button. The 30312 `service` tag drives
visibility — rooms hosted on non-nests servers show "Audio not
available" and the rest of the stage UI continues to work.
ViewModel (commons):
- New `AudioRoomViewModel` in commons/.../viewmodels/ owns one
`NestsListener` and one `AudioRoomPlayer` per active speaker, exposes
a single `StateFlow<AudioRoomUiState>`, and reconciles subscriptions
whenever the screen pushes a new speaker set via `updateSpeakers`.
Audio-pipeline construction is injected via `decoderFactory` /
`playerFactory` so a future desktop port can reuse the orchestration
once Compose Desktop has WebTransport. Unit-tested with a fake
`NestsListenerConnector` driving state transitions directly.
nestsClient:
- `AudioPlayer.setMuted(Boolean)` joins the interface; `AudioTrackPlayer`
routes it through `AudioTrack.setVolume(0f/1f)`. Mute keeps the
decode + network pipeline running so unmute is sample-accurate.
Build:
- `:commons` commonMain now `implementation(project(":nestsClient"))` so
the shared VM can see `NestsListener` / `AudioRoomPlayer` / interfaces.
Concrete OkHttp / Quic / MediaCodec / AudioTrack actuals stay in
`:nestsClient`'s platform source sets and are bound by an
Android-side `AudioRoomViewModelFactory` co-located with the screen.
Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
Moves five JVM-only upload helpers from desktopApp into commons so
the CLI can reuse the same Blossom upload + AES-GCM encryption path
without depending on :desktopApp:
desktop/service/upload/DesktopBlossomAuth.kt → commons/service/upload/BlossomAuth.kt
desktop/service/upload/DesktopBlossomClient.kt → commons/service/upload/BlossomClient.kt
desktop/service/upload/DesktopMediaCompressor.kt → commons/service/upload/MediaCompressor.kt
desktop/service/upload/DesktopMediaMetadata.kt → commons/service/upload/MediaMetadata.kt
desktop/service/upload/DesktopUploadOrchestrator.kt → commons/service/upload/UploadOrchestrator.kt
API tweaks:
- Drop the `Desktop` prefix on every class.
- Rename the `DesktopMediaMetadata` object to `MediaMetadataReader`
to avoid colliding with the `MediaMetadata` data class in the same
package.
- BlossomClient no longer reaches into desktop's `DesktopHttpClient`.
Callers pass an `OkHttpClient` (or use the default one-shot client);
desktop continues to inject its Tor-aware client.
- Tighten BlossomClient's response handling — the OkHttp version on
commons' classpath has a nullable `Response.body`, so explicitly
null-check it.
Mechanical updates to keep desktop building:
- ComposeNoteDialog / ChatPane / DesktopUploadTracker switch to the
new commons imports.
- DimensionTag-from-MediaMetadata: cross-module smart cast no longer
works on the public `width`/`height` properties — replace with
`?.let { … }` chains.
- commons/jvmMain now declares `commons-imaging` (used only by
MediaCompressor's EXIF stripping).
Tests come along too: file names follow the renamed classes
(spotless ktlint enforces this).
Next commit will add `amy dm send-file --file PATH --server URL`,
which calls `UploadOrchestrator.uploadEncrypted(...)` directly.
Replace the `material-icons-extended` library with Google's Material Symbols
variable font, rendered via a new `commons/icons/symbols/` package. Weight is
set to 100 (Thin) via a single tunable default (MaterialSymbolsDefaults.WEIGHT),
so we can re-tune line thickness globally in one line without regenerating
assets.
Key changes:
- commons/composeResources/font/material_symbols_outlined.ttf: Material Symbols
Outlined variable font (all 3600+ glyphs, 4 design axes: wght/FILL/GRAD/opsz).
- commons/icons/symbols/: MaterialSymbol data class, 219 codepoint constants,
variable-font FontFamily helper, Canvas-backed Icon composable (overload that
accepts MaterialSymbol and supports auto-mirror for RTL), and a Painter
wrapper for use with Image / AsyncImage.
- Bulk-rewrote 250+ call sites across amethyst/, commons/, desktopApp/:
Icons.{Default,Outlined,Filled,Rounded,AutoMirrored.*}.X → MaterialSymbols.X
or MaterialSymbols.AutoMirrored.X. `imageVector =` renamed to `symbol =`
inside Icon() calls; Image(imageVector = MS.X) converted to
Image(painter = rememberMaterialSymbolPainter(MS.X)).
- Dropped material-icons-extended dependency from commons, desktopApp,
amethyst build files.
Hand-drawn custom icons (Bookmark, Like, Reply, Zap, chess pieces, robohash)
remain as ImageVector and are untouched.
https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
Add 81 tests across two test suites covering the full NIP-AC spec:
- quartz/NipACStateMachineTest (31 tests): Protocol compliance test vectors
for event structure, tags, P2P/group flows, ICE serialization, staleness,
renegotiation glare rules, and multi-device support
- commons/CallManagerTest (50 tests): State machine integration tests using
real NostrSignerInternal with actual crypto, covering:
* Full call lifecycle (Idle → Offering/IncomingCall → Connecting → Connected → Ended → Idle)
* Call rejection, busy auto-reject, hangup from any state
* Self-event filtering (ICE, hangup, answer-elsewhere)
* Mid-call renegotiation (voice ↔ video)
* Group calls (mesh discovery, partial disconnect, invite peer)
* Interface-level tests with real signing + gift wrapping pipeline
* Full end-to-end P2P flow with two CallManager instances
Also adds test vector tables to NIP-AC.md spec for other implementers.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Remove Moko Resources dependency that blocks AGP 9.0 migration.
Migrate to built-in Compose Multiplatform Resources which is compatible
with the new KMP Android plugin.
- Remove moko-resources plugin and libraries from commons module
- Move strings.xml from moko-resources/ to composeResources/values/
- Update imports from SharedRes.strings.* to Res.string.*
- Add compose.components.resources dependency to desktopApp
- Configure resource package as com.vitorpamplona.amethyst.commons.resources
Closes#1675
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds androidx.lifecycle.viewmodel.compose and lifecycle.runtime.compose
to commons/commonMain as preparation for extracting ViewModels to shared code.
These dependencies have KMP support since 2.8.0 (currently on 2.10.0).
Preparation for Phase A2: Extract Thread ViewModels to commons.
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Extract reusable components from desktopApp to commons module:
Utilities (commonMain):
- NumberFormatters: countToHumanReadable, countToHumanReadableBytes
- PubKeyFormatter: toShortDisplay, toDisplayHexKey for key formatting
Utilities (jvmAndroid):
- TimeAgoFormatter: Human-readable time formatting for Nostr timestamps
- ZapFormatter: BigDecimal amount formatting with G/M/k suffixes
UI Components (commonMain):
- AppScreen enum for shared navigation
- LoadingState, EmptyState, ErrorState with refresh/retry support
- FeedHeader with relay status indicator
- NoteCard for displaying Nostr notes
- ActionButtons (AddButton, RemoveButton)
- RobohashImage for avatar display
- PlaceholderScreens (Search, Messages, Notifications)
- RelayStatusColors and shared color definitions
UI Components (jvmAndroid):
- LoginCard with key input field
- NewKeyWarningCard for new key backup warnings
- KeyInputField and SelectableKeyText
- ProfileInfoCard for account display
- RelayStatusCard for relay management
Core (jvmAndroid):
- AccountManager with login/logout/key generation
- RelayConnectionManager base class
- RelayStatus and DefaultRelays
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>