The join/leave-request buttons launched a raw scope.launch(Dispatchers.IO)
and called account.signer.sign() directly. When the signer threw —
TimedOutException from a Samsung-A53 / Android-16 Amber prompt the user
didn't respond to in 30s, or any other SignerException — the failure
escaped the composable's scope and was reported as an uncaught crash
instead of being toasted/logged like every other signing operation.
Route both buttons through accountViewModel.launchSigner so the same
SignerExceptions handling that every other signing entry point uses
applies here too.
ChatroomListKnownFeedFilter.feed() iterated account.publicChatList.flow,
a Set<ChannelTag>. ChannelTag uses identity equality (it's not a data
class) and the set is built from raw tag parsing, so a channel list
that names the same channel id twice (e.g., the same channel appearing
in the public section and the encrypted private section, or repeated
with different relay hints) produces multiple ChannelTag entries for
the same channel. Each one resolved to the same newest Note via
getOrCreatePublicChatChannel(it.eventId), so the feed contained the
same Note twice and the chat list LazyColumn crashed with "Key
PublicChannelLazyKey(channelId=...) was already used".
Use the sibling flowSet (Set<HexKey>, already deduped by event id),
which is the same source filterRelevantPublicMessages reads from.
rememberForeverPagerState persists currentPage across tab-count changes.
When the user toggles off one of the conditional Home tabs (New Threads,
Conversations, Everything), tabs.size shrinks but pagerState.currentPage
is still pointing at the removed slot, so Material3's
TabIndicatorOffsetNode reads tabPositions[currentPage] out of bounds and
crashes with IndexOutOfBoundsException.
Clamp the index for the TabRow and use getOrNull for the bottom-bar
re-tap callback.
ChatroomListKnownFeedFilter.updateListWith only recognized
ChannelMessageEvent when checking the old list for an existing row to
replace. When a public channel's row was first populated from a
ChannelCreateEvent or ChannelMetadataEvent (no message had arrived
yet), the next incoming ChannelMessageEvent failed to match, so the
filter appended a second note for the same channel. Both rows then
produced the same PublicChannelLazyKey, crashing the LazyColumn with
"Key was already used".
Resolve the channel id from any IsInPublicChatChannel event (covers
ChannelMessageEvent and ChannelMetadataEvent) and fall back to the
event id for ChannelCreateEvent, mirroring how the lazy key is built
in ChatroomListFeedView.
Re-introduces the lifecycle observer that was dropped in the Feb 24
flow refactor of GetVideoController (f2410a69 "removing most of the
little hacks to get the controller to work in the lifecycle"). Without
it, the scroll-position mutex is the only thing that pauses a video —
and the window's visible rect doesn't change on ON_PAUSE, so the
currently-playing video kept playing forever behind other apps.
Pause on ON_PAUSE, resume on ON_RESUME if the video is still the
visible/active one. The explicit BackgroundMedia (PiP) instance is
preserved as the opt-in to keep playing.
Compose Multiplatform 1.11.0 wired ProGuard 7.7.0 into the release
build for the first time. With optimize + shrink + obfuscate all on,
the desktop v1.09.1 DMG hit four distinct runtime failures:
A. Jackson's SerializationFeature.values()/valueOf() stripped, so
Class.getEnumConstants() returned null and ObjectMapper failed
to initialise (app didn't launch). Fixed in #2921.
B. fr.acinq.secp256k1.* JNI bridge classes renamed by obfuscate;
bundled libsecp256k1-jni.dylib could not FindClass the original
names, so KeyPair / sign / verify crashed on first use.
C. androidx.sqlite-bundled native declarations (nativeThreadSafeMode,
nativeOpen) shrunk away because no Kotlin caller referenced them
directly; libsqliteJni.dylib raised NoSuchMethodError on the first
EventStore query.
D. ProGuard's method/specialization/returntype optimize sub-pass
generated a synthetic okio bridge (Okio__OkioKt.buffer$<hash>)
whose declared return type was RealBufferedSource but whose body
returned the BufferedSource super-interface. The JVM verifier
rejected the bridge, killing every OkHttp connection.
The Android (mobile) module solved the same class of problems in
amethyst/proguard-rules.pro with a single global strategy:
-dontobfuscate
-keepnames class ** { *; }
-keep enum ** { *; }
+ per-library -keep rules for JNA / libsodium / libscrypt
+ first-party -keep com.vitorpamplona.**
This commit replays that strategy in desktopApp/compose-rules.pro
and drops the previous optimize.set(false)/obfuscate.set(false)
escape hatch in desktopApp/build.gradle.kts. Shrink and optimize
both stay on; only the one optimize sub-pass that produced invalid
okio bytecode (method/specialization/returntype) is disabled.
Additional desktop-only keep rules (Jackson, full JNA, VLCj,
SLF4J, OkHttp / Conscrypt / BouncyCastle / OpenJSSE / Graal
dontwarns) stay in place. Two libraries shipped only on desktop
also get explicit -keep rules so their native callbacks survive
shrink:
- androidx.sqlite.** + native <methods>; (nativeThreadSafeMode
was previously stripped even with -keepnames, because shrink
removes members the global rule doesn't cover).
- pt.davidafsilva.apple.** + native <methods>; for the macOS
Keychain JNI bridge.
Verified on macOS arm64 packageReleaseDmg:
- javap on the post-ProGuard jars confirms:
* fr.acinq.secp256k1.NativeSecp256k1 keeps its FQCN.
* SerializationFeature.values() / valueOf() present.
* androidx.sqlite.driver.bundled.BundledSQLiteDriverKt
retains native nativeThreadSafeMode() + nativeOpen().
* Okio__OkioKt only exposes buffer(Source): BufferedSource
and buffer(Sink): BufferedSink — no specialized
buffer$<hash> bridge.
- Bundled app launches and reaches VLC MediaPlayerFactory init
with zero VerifyError / NoSuchMethodError / UnsatisfiedLinkError
in the log.
The ImportFollowListDialog composable was already implemented but never
rendered. The File menu item set a boolean state that no observer
consumed.
Render the dialog from MainContent inside the CompositionLocalProvider
that supplies LocalNamecoinService, so Namecoin (.bit, d/, id/)
identifier resolution works in addition to npub/hex/NIP-05.
Also add a left-side launcher in both layouts so the feature is
discoverable without using the File menu:
- single-pane: NavigationRailItem (PersonAdd icon, 'Import' label)
- deck: IconButton in the DeckSidebar next to 'Add Column'
The desktop client already ships DesktopNamecoinPreferences, the
DesktopNamecoinNameService that consumes them, and the NamecoinSettingsSection
composable that's a port of the Android UI. The section just wasn't surfaced
in the desktop Settings screen.
This wires NamecoinSettingsSection into RelaySettingsScreen, between the Tor
section and the Developer / Relay sections. Preferences come from the
namecoinPreferences parameter that the deck container already passes in, and
fall back to LocalNamecoinPreferences when called from another caller.
User-visible behaviour: the .bit / d/ / id/ ElectrumX server settings (master
toggle, custom servers, defaults indicator, add/remove, reset) are now
reachable from desktop Settings and persist via java.util.prefs.Preferences.
The v1.09.1 desktop release (DMG/MSI/DEB/RPM) fails to launch on every
platform with:
Exception in thread "main" java.lang.ExceptionInInitializerError
at com.fasterxml.jackson.databind.ObjectMapper.<init>(ObjectMapper.java:700)
at com.vitorpamplona.amethyst.desktop.ui.deck.DeckState.<clinit>
Caused by: java.lang.NullPointerException:
Cannot read the array length because the return value of
"java.lang.Class.getEnumConstants()" is null
at com.fasterxml.jackson.databind.cfg.MapperConfig
.collectFeatureDefaults(MapperConfig.java:107)
Failed to launch JVM
Root cause: PR #2914 wired Compose 1.11.0's new ProGuard pass into the
release build but added only `-dontwarn` rules. ProGuard then optimized
Jackson's enum classes (`SerializationFeature`, `MapperFeature`, etc.) and
stripped their synthetic `values()` / `valueOf()` methods because nothing
in Amethyst's own bytecode calls them. Jackson does call them — but
reflectively, at `ObjectMapper` construction time, long after ProGuard
finished. The result is `Class.getEnumConstants()` returning null and the
JVM giving up.
The same regression silently broke VLC media playback (VLCj's
`ServiceLoader` lookup of `DiscoveryDirectoryProvider` impls printed
`ConfigDirConfigFileDiscoveryDirectoryProvider not found` because
ProGuard stripped both the META-INF/services file and the impl class).
Fix: add explicit `-keep` rules for the four reflection-heavy libraries
on the desktop classpath:
- Jackson (databind, core, annotations, module-kotlin): keep classes
intact and keep `values()`/`valueOf()` on every Jackson enum.
- JNA (com.sun.jna.\*\*): used by VLCj and kmp-tor — its `Structure`
field-order reflection requires field metadata to survive.
- VLCj (uk.co.caprica.vlcj.\*\*): `ServiceLoader`-based discovery providers
and reflective binding classes throughout.
- SLF4J: detected by Jackson via `Class.forName`.
Verified by building `:desktopApp:createReleaseDistributable` and
launching the resulting bundle on macOS arm64:
- No `ExceptionInInitializerError`
- `VLC: bundled discovery succeeded`
- `VLC: MediaPlayerFactory created successfully`
The same ProGuard pass runs on all four target formats (Dmg, Msi, Deb,
Rpm) so this single change fixes the regression on every desktop OS.
URLs like https://nostr.build/i/nostr.build_<sha>.jpg embed a 64-char
hex in the filename but aren't BUD-01 Blossom blobs — the last path
segment must be exactly <sha256> or <sha256>.<ext>. The previous regex
matched the embedded hash and rewrote the request to the local cache
with xs=https://nostr.build/i, which 404s on miss because the real blob
lives at /i/nostr.build_<sha>.jpg, not /i/<sha>.
Switch both extraction sites (MediaUrlContentExt and the OkHttp
interceptor) to a matchEntire regex that anchors the sha at the start
of the segment with at most a .<ext> suffix.
LocalCache.consume's type switch only had branches for kinds 1617
(patch), 1621 (issue), 1622 (reply) and 30617 (repository). Every
other NIP-34 event kind fell through to the else branch and was
silently dropped with a "Event Not Supported" warning, even though
Quartz's EventFactory parses them all.
This affected:
- kinds 1630-1633 (status open/applied/closed/draft) — the status
pills on the new Git Repository screen and on issue/patch cards
could never populate, since the status events themselves never
reached LocalCache.
- kind 1618 (pull request) — the Patches/PRs tab on the repository
screen would never list PR events.
- kind 1619 (PR update) — couldn't be observed, even though Quartz
models the parent-PR relationship.
- kind 30618 (repository state) — branch/tag pointers were dropped.
- kind 10317 (user grasp server list) — analog to NIP-65, dropped.
Wires each into the existing consumeRegularEvent /
consumeBaseReplaceable helpers so they land in the cache where the
screen filters, GitStatusIndex, and any future UI surfaces can see
them.
Replace `remember(pinned)` with a single `remember`, so the local
`items` MutableState isn't recreated each time the StateFlow emits.
Previously, when the user toggled an item ON, the resulting
recomposition swapped the local items state to a new MutableState
backed by `initialRows(newPinned)`. Drag-gesture callbacks captured
in earlier compositions kept writing to the old MutableState, and on
drag end they re-emitted a `pinned` list computed from that stale
state — dropping the just-pinned item.
Initialize once from `bottomBarItems.value` (already correct at first
composition) and let toggle / drag / restore-default be the only
writers. This makes the screen self-contained and removes the
position-vs-state race entirely.
When a Git Repository screen is opened via deep-link (naddr) the
addressable note exists but its event payload arrives later through
EventFinder. RepositoryContentSubAssembler.updateFilter checks
note.event and returns an empty filter when it isn't a
GitRepositoryEvent yet — and since ComposeSubscriptionManager only
re-invalidates on subscribe/unsubscribe, the now-stale empty filter
persists indefinitely, leaving the Issues/Patches tabs permanently
blank for cold-started repos.
Gate the subscription composable on event presence so the underlying
DisposableEffect fires fresh (running updateFilter again, this time
with the populated event) only after the repo event has loaded.
Inside a DisappearingScaffold the content fills the full screen height so
feeds can scroll behind the top bar. PullToRefreshBox anchors its indicator
at the top of the box, which placed it behind the top bar.
Read LocalDisappearingScaffoldPadding inside RefresheableBox and pass a
custom indicator with that top padding applied, so the spinner surfaces
just below the bar. Screens not inside a DisappearingScaffold see the
default 0dp padding and behave as before.
https://claude.ai/code/session_01MgnMaw8U83rXPSnoGF4KBY
- Drop two unused strings (git_repo_no_issues, git_repo_no_patches);
the default FeedEmpty composable already provides a generic "Feed
is empty" + refresh button which is sufficient.
- Import MaterialSymbol in GitRepositoryOverview so the LinkLine
parameter type isn't a fully-qualified name, per CLAUDE.md style.
- Switch two vertical Spacer(Modifier.size(N.dp)) to
Modifier.height(N.dp) — functionally identical but conveys intent.
Two issues from a follow-up audit on the Git Repository screen.
- Move the initial cache scan into flow `onStart` so the
newEventBundles collector is attached before scanning. Closes a small
race window where status events emitted between scan completion and
collect attachment would have been dropped.
- Distinguish "index warming up" from "no status exists" by typing the
StateFlow as `Map?` (null until first scan completes). The pill now
hides itself entirely while warming, instead of briefly rendering
the `defaultIfMissing` fallback and then flipping to the real status.
Also drops the dead public `rememberLatestStatus` helper and the
unnecessary derivedStateOf scaffolding — at the scale this screen
operates (tens of pills), the straightforward map read is fine.
Audit follow-up on the Git Repository screen.
- Add GitStatusIndex, a process-level Map<targetId, latest GitStatusEvent>
fed by an initial scan on Dispatchers.IO and incrementally maintained
from LocalCache.live.newEventBundles. GitStatusPill now does an O(1)
map lookup instead of scanning the entire LocalCache on every issue/
patch row at composition time on the main thread.
- Observe the GitRepositoryEvent reactively in the screen via
observeNoteEvent so the title and Overview tab refresh when the event
arrives after composition or when the maintainer publishes a new
version.
Compose Multiplatform 1.11.0 tightened its default ProGuard ruleset so
`proguardReleaseJars` now fails on unresolved references that 1.10.3
tolerated silently. The v1.09.0 release tag-build hit this on every
desktop leg (macOS DMG, Windows MSI, Linux DEB/RPM, Linux AppImage),
which is why only Android APKs and the macOS-arm64 / Linux Amy CLI
bundles made it onto the GitHub release.
Three groups of references were flagged:
- Jackson kotlin-module 2.21.x value-class converters
(`IntValueClassBoxConverter`, `ValueClassKeyDeserializer$*`, …)
invoke `MethodHandle.invokeExact` with polymorphic signatures
ProGuard cannot resolve against the abstract MethodHandle.
- okhttp's optional TLS adapters (`okhttp3.internal.graal.*`,
`BouncyCastle/Conscrypt/OpenJSSEPlatform`) reference classes from
runtime backends that aren't on the desktop classpath; okhttp falls
back to the JDK default in their absence.
- JNA's `com.sun.jna.internal.Cleaner` inner classes touch synthetic
`access$N` accessors that ProGuard's class-member analysis cannot
follow.
Add `desktopApp/compose-rules.pro` with `-dontwarn` rules for each
bucket and wire it through `compose.desktop.application.buildTypes
.release.proguard.configurationFiles`. Verified locally by re-running
`:desktopApp:proguardReleaseJars` (now BUILD SUCCESSFUL) and
`:desktopApp:packageReleaseDeb` (gets past ProGuard; only fails on the
sandbox missing `fakeroot`, which CI installs in the existing
"Install RPM tooling" step).
Clicking a Git Repository note now opens a tabbed screen modeled after
the Community screen: Overview surfaces the description, links, topics,
maintainers, and personal-fork badge; Issues and Patches/PRs are
relay-backed feeds scoped to the repository address.
Adds NIP-34 status pills (Open/Merged/Closed/Draft) sourced from
kind 1630-1633 events, rendered on issue and patch cards both in the
feed and in the new screen. The short repository pill inside issue and
patch cards is now clickable and navigates to the new screen.
The `onRefresh: () -> Unit` parameter in the second `RefresheableBox`
overload was shadowed by a local `val onRefresh` of the same name. The
recursive `onRefresh()` call inside the launched coroutine therefore
re-invoked the local wrapper instead of the caller's callback, so
pulling down on the home feed never actually triggered
`feedState.invalidateData()` or the DVM refresh.
Rename the local wrapper to `onRefreshWrapped` so the parameter remains
visible inside the lambda.
The pre-push hook runs the full `./gradlew test` suite, which doesn't
pass `-DnestsHangInterop=true`. Without that flag the sidecars don't
exist, so every test in this class blew up on `NativeMoqRelayHarness.shared()`
with `IllegalStateException`. Adds a `@BeforeTest` that calls
`assumeHangInterop()` and skips the case via JUnit's Assume — same
pattern `HangInteropTest`, `HangInteropReverseTest`, and the rest of
the `interop/native/` suite already use.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`AudioLatencyComparisonTest`:
- Three new scenarios: 10 %, 20 %, 30 % loss in addition to the
existing clean and 5 %. The whole benchmark now sweeps a useful
arc for finding the breakdown point.
- Frame-count floor now scales with loss instead of a fixed 60 %
threshold. QUIC streams are reliable so loss surfaces mainly as
latency, but at high rates the retransmit + congestion-control
feedback can eat into the 10 s window, so the floor relaxes to
`max(50, (1 - 3*lossRate) * EXPECTED_FRAMES)`. The 50-frame
absolute floor (= 1 s of audio) catches a catastrophic publisher
failure without flapping on the loaded loss-path runs.
Linux benchmark harness (`nestsClient/tests/hang-interop/linux-bench/`):
- `Dockerfile` for an arm64/amd64 image with JDK 21 + rustc 1.95 +
cmake + the *-sys-crate build deps (libssl-dev, build-essential,
pkg-config). Pins rustc 1.95 explicitly so moq-relay v0.10.25's
Cargo.lock-requested `constant_time_eq@0.4.3` builds cleanly.
- `run.sh` wrapper that:
* builds the image,
* unpacks a filtered tarball of the host repo into the container
(mac host `nestsClient/build/` and `cargo target/` stay
untouched),
* symlinks `nestsClient/build` -> `/persistent-gradle-build` and
`nestsClient/tests/hang-interop/target` ->
`/persistent-cargo-target` so successive runs reuse compiled
artefacts (~3-5 min cached vs ~7-15 min cold),
* runs the gradle test invocation and surfaces the JUnit XML
stats to `linux-bench/out/`.
Linux numbers observed (arm64 Docker on a MacBook M2, 10 s window):
| Loss | Stack | p50 | p95 | p99 | max | Frames |
|------|--------|-------|-------|-------|-------|--------|
| 0 % | Kotlin | 61.1 | 63.1 | 65.5 | 79.1 | 573 |
| 0 % | Rust | 100.7 | 102.7 | 104.9 | 140.2 | 495 |
| 5 % | Kotlin | 380.9 | 383.4 | 385.7 | 390.5 | 556 |
| 5 % | Rust | 0.7 | 7.3 | 23.2 | 84.2 | 500 |
| 10 % | Kotlin | 120.5 | 122.4 | 124.0 | 132.6 | 568 |
| 10 % | Rust | 0.5 | 21.9 | 60.4 | 84.4 | 500 |
| 20 % | Kotlin | 159.7 | 164.3 | 170.3 | 183.1 | 566 |
| 20 % | Rust | 1.6 | 61.3 | 101.9 | 144.9 | 499 |
| 30 % | Kotlin | 181.0 | 184.2 | 188.1 | 194.0 | 566 |
| 30 % | Rust | 1.2 | 80.7 | 105.3 | 140.8 | 491 |
Notes for future readers: Rust's p50 collapsing to ~1 ms under loss
is the SUBSCRIBE-late-arrival signal, not "Rust is 100x faster" — by
the time the SUBSCRIBE lands at the publisher, the in-flight
encoder has moved past those early frames, so the listener starts
receiving in near-real-time. Read p95/p99/max for the per-frame
story (Rust expands 7 -> 22 -> 61 -> 80 ms p95 across loss rates).
Frame delivery is stable for both stacks even at 30 % loss on Linux,
unlike the macOS run which hits a 3.4 s Rust catastrophe at 30 %.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expands `AudioLatencyComparisonTest` from pure inter-arrival jitter
into a real one-way latency measurement against the same
`hang-publish` Rust reference, AND adds a loss-path variant gated
through `udp-loss-shim`.
`hang-publish` (Rust sidecar):
- new `--log-send-times` flag. When set, the publisher prints
`SEND frame=<N> send_t_us=<UNIX_EPOCH_MICROS>` to stdout for
every frame, captured with `SystemTime::now()` immediately
before `frame.encode(group)`. Lock + flush per line because a
piped Rust stdout is fully-buffered by default and the JVM-side
parser needs a live stream. Default off so existing scenarios
(the I7 reconnect test in particular) keep their stderr-only
RUST_LOG output untouched.
Kotlin test:
- `TimestampingOpusEncoder` wraps `JvmOpusEncoder` and stamps an
`Instant.now()` epoch-micros value per `encode(...)` call into a
shared map keyed by a monotonic frame counter. encode runs
immediately before `publisher.send` inside
`NestMoqLiteBroadcaster`, so the captured timestamp is the
closest cross-stack anchor we have to "moment the frame entered
the publisher's outbound buffer" — same anchor the Rust side
uses.
- One-way latency = arrival epoch-micros − send epoch-micros per
matched frame. Both sides read `CLOCK_REALTIME` on linux/macOS,
so values can be compared directly without a sync handshake when
the two processes share a host.
- Frame matching uses `MoqObject.objectId` directly. The listener
layer already synthesises objectId as a per-SUBSCRIPTION
monotonic counter, so it equals the publisher's absolute frame
index; a previous draft of this test did `groupId * 5 + objectId`
and silently aligned against the wrong frames, producing
negative latencies.
- New `under_5pct_packet_loss_pacing_and_one_way_latency` scenario.
Spawns TWO `udp-loss-shim` instances (one per publisher: the
shim latches a single client on first datagram, so a shared shim
would silently swallow the second publisher's traffic), each
forwarding 1:1 to the moq-relay's UDP port modulo a 5 %
bidirectional drop rate. The listener stays on the direct path
so any latency growth comes from publisher-side retransmit /
ack feedback, not listener-side loss.
- Both Kotlin and Rust pinned to `FRAMES_PER_GROUP = 5` (Rust's
default; Kotlin's default is 50). Matched so the per-group
uni-stream open/close cost is the same on both sides — the
comparison is about implementation, not about which stack picked
which group size.
Observed (10 s window, localhost, MacBook Pro M2):
===== Audio publisher comparison: clean (loss=0%) =====
Kotlin speaker ttf=137 ms inter-arrival p50/95/99/max=20.17/20.67/20.88/21.16 ms
one-way p50/95/99/max=80.60/81.31/81.74/83.91 ms
Rust hang-publish ttf=325 ms inter-arrival p50/95/99/max=20.00/21.18/21.49/21.95 ms
one-way p50/95/99/max=100.32/101.45/102.04/168.58 ms
===== Audio publisher comparison: loss-5pct (loss=5%) =====
Kotlin speaker one-way p50/95/99/max=80.50/81.55/82.31/86.43 ms
Rust hang-publish one-way p50/95/99/max=100.44/119.88/122.16/158.98 ms
Reading: Kotlin matches Rust on the clean path within ~20 ms
baseline (and actually sits below it), and absorbs 5 % loss with
only +0.6 ms p99 growth vs Rust's +20 ms. The 80-100 ms baseline
is moq-lite's group-buffer floor with 5-frames/group — protocol,
not stack.
Asserts: each side delivers >= 80 % of expected frames on the clean
path, >= 60 % under loss, and clean-path median inter-arrival sits
in [15, 35] ms. Tail percentiles are printed, not gated.
Gated by `-DnestsHangInterop=true`; both sidecars (hang-publish,
udp-loss-shim) come from `interopBuildSidecars`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>