Drop the AmethystDns.shared lazy companion. Construct one
amethystDns in AppModules and thread it through:
- DualHttpClientManager / OkHttpClientFactory
- DualHttpClientManagerForRelays / OkHttpClientFactoryForRelays
- MediaCallEventListenerFactory / MediaCallEventListener
- DnsInvalidatingEventListener.Factory
- AmethystDnsStore
This matches the dependency-injection style the rest of AppModules
uses and lets tests inject a mock Dns where needed. Behavior is
unchanged — every consumer still shares the same single instance,
just by construction rather than by a static singleton.
Mechanical cleanups from a final review pass. No behavior change.
- Drop the dead `private val xxxMillis = xxxMs` aliasing — just use
the constructor params directly.
- Extract positiveExpiry()/negativeExpiry() and evictIfOverCap() so
putPositive and putNegative read as one line each.
- Extract lookupAndCache() from resolveAsLeader so the leader's
bookkeeping (re-check, complete future, remove inflight) is
separate from the upstream/cache write logic.
- Tighten awaitFollower's cause-handling.
- Use runCatching in the refresh executor task.
- Drop DnsCacheRecord's no-arg constructor — jacksonObjectMapper()
registers the Kotlin module, which uses the primary constructor
reflectively.
- Rename `hostname` -> `host` in private methods that receive the
already-normalized lowercase key.
Two follow-ups from the resolver audit:
1. Lowercase the hostname at the public boundary (lookup, invalidate,
restore). DNS is case-insensitive; OkHttp normally hands us
lowercase but custom callers and persisted records can mix case.
This prevents "Example.com" and "example.com" from creating
separate cache entries.
2. Drop the cache entry when an OkHttp call to a host fails outright.
Without this, a stale-cached IP that no longer works would be
served for up to 24h before the soft TTL ran out. Hooking
callFailed (final-stage signal after OkHttp tried every address)
instead of per-attempt connectFailed avoids over-invalidating
multi-A-record hosts where one IP is dead and OkHttp recovers via
the next.
- Media path: invalidation folded into MediaCallEventListener.finish
alongside its existing timing logging.
- Relay path: new DnsInvalidatingEventListener wired into
OkHttpClientFactoryForRelays via eventListenerFactory.
Cold starts were the resolver's worst case: every host paid a sync
getaddrinfo. With a persisted snapshot, every previously-seen host
falls into the stale-while-revalidate path on first lookup — the
cached IP is served immediately and a background refresh updates it.
~700 blocking system calls at app start become zero.
Switch entry expiries from System.nanoTime to System.currentTimeMillis
so timestamps survive process death (nanoTime is monotonic per
process, undefined across restarts). Add snapshot()/restore() that
serialize only fresh positive entries — negative entries are skipped
and re-resolved synchronously, and restore() uses putIfAbsent so a
fresh in-memory entry is never clobbered by a stale on-disk one.
Add AmethystDnsStore as a thin SharedPreferences + Jackson wrapper.
The blob is plain (not encrypted): hostnames are already exposed in
the user's signed relay list, Coil's image cache, and the system
resolver's own state.
Wire load + save into AppModules:
- load() runs once at app start on the IO scope.
- save() runs every 5 minutes (skipped when nothing has changed via
the dirty flag), on trim() when the app backgrounds, and once on
terminate() as a best-effort flush.
After the first lookup of a host, recurring connections never block on
getaddrinfo again. Soft-expired positive entries are returned
immediately while a background refresh updates the cache through a
small fixed thread pool (8 daemon threads). The refresh is coalesced
through the same inflight map, so a fan-out of stale reads to the same
host still triggers exactly one upstream call.
Negative entries deliberately do NOT serve stale — a transient DNS
failure must recover quickly, not keep returning UnknownHostException.
A failed refresh demotes the entry to negative so the next caller sees
a fresh failure rather than forever-stale wrong IPs.
Add TTL jitter on positive writes so a burst of co-written entries
(e.g. ~700 relay reconnects at app start) doesn't all expire at the
same instant 24h later. Default jitter equals the base TTL, so entries
spread their expiries uniformly across [24h, 48h]. Combined with the
bounded refresh executor, even a daily-app-open user with 1000 stale
hosts generates a few seconds of background work and zero blocking
waits in the foreground.
The previous cache was a synchronizedMap(LinkedHashMap(access-order=true)).
Access-order LRU rewrites the linked list on get(), so every cache hit
took the global monitor — at 700 concurrent relay reconnects, the lock
serialized every dispatcher worker through a single critical section.
Switch to ConcurrentHashMap so reads are lock-free. Amethyst's steady
state is well under maxEntries (~750 distinct hosts vs cap of 2000), so
strict LRU was never going to evict anything and the access-order
machinery was pure contention.
Bump positive TTL from 5min to 24h: relay and CDN IPs change on the
order of days, and we are not a recursive resolver — there's no
correctness reason to honor authoritative TTLs. A 5min cycle made us
re-resolve all 700 relays every 5 minutes.
Also add a leader-side cache re-check after acquiring inflight
ownership. If a peer leader refreshed the entry while we were claiming
the slot, we skip getaddrinfo entirely.
Eviction is now a cheap on-demand sweep of expired entries when the
map exceeds maxEntries; in normal use it never runs.
A Nostr feed can touch hundreds of distinct hosts (Blossom servers,
relays, NIP-05 domains, image proxies). The 256-entry cap was small
enough that active users would churn the LRU and pay extra getaddrinfo
calls. 2000 still costs <100KB of heap.
Avoids paying the getaddrinfo tax on every HTTP call to the same
host. Adds a single-flight, LRU+TTL DNS resolver wired into both the
media and relay OkHttp clients via a process-wide shared instance, so
resolutions cross-cut images, relays, and NIP-05 lookups.
- Per-host coalescing: N concurrent lookups for the same host share one
upstream call.
- Different hosts proceed in parallel — no global lock around the
upstream resolver.
- Negative cache (10s) prevents hammering on typos / dead hosts.
- Positive cache (5m) survives a feed scroll.
Casting LocalContext.current to NestActivity tripped the Jetpack
Activity Compose lint rule ContextCastToActivity, failing
:amethyst:lintPlayBenchmark in CI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the local user's avatar into the existing speaking-ring animation so
the host/speaker sees the same green ring + amplitude glow on their own
cell that remote speakers already get.
Ground truth is the broadcaster's `publisher.send(opus)` success: the new
`onLevel` callback fires only after a frame actually leaves on the wire,
computed from the raw PCM peak (shared `peakAmplitude` util used by both
the player decode loop and the broadcaster capture loop). This means the
animation reflects what the relay sees, not any UI-side mute / role
state that could be stale or buggy — if frames are flowing, the ring
plays.
Plumbing: `NestsSpeaker.startBroadcasting` gains an optional `onLevel`
that the moq-lite + IETF broadcasters both honour, the reconnecting
wrapper replays it on every session reissue (alongside the existing
mute-intent replay), and `NestViewModel.startBroadcast` hands it a
lambda that calls the existing `onSpeakerActivity` / `onAudioLevel`
plumbing keyed on the local pubkey. The 250 ms speaking-timeout fades
the ring naturally when the user mutes or stops broadcasting.
Three more leave paths now fully release the speaker session and
listener (and with them AudioRecord + the system mic indicator)
before the Activity destruction queue eats viewModelScope:
- PIP overlay's Leave action — pipReceiver invokes a cleanup
callback set by NestActivityBody, then finish().
- PIP swipe-dismiss — NestActivity.onStop() invokes the same
callback when in PIP and isFinishing. Non-PIP onStop stays a
no-op so backgrounding doesn't tear down.
- Host's Just leave — calls viewModel.leave() before onLeave().
Unlike Close the Room, this still leaves the kind-30312
meeting space open for other users.
The cleanup callback is registered via DisposableEffect in
NestActivityBody so it's auto-cleared when the composable leaves.
The host-leave confirmation dialog's Close the Room button only called
finish(), relying on VM.onCleared() to release the AudioRecord. That
runs late in the destroy lifecycle, so the system mic-in-use indicator
stayed lit while the activity was queued for destruction.
Adds NestViewModel.leave(), which mirrors onCleared() (sets closed,
runs both teardowns with finalCleanup=true so closes route through
cleanupScope/GlobalScope and survive Activity destruction). Wires it
into the Close the Room callback only — the Just Leave and non-host
Leave paths are unchanged per request.
After tapping Leave the Stage, the bar lingered because it was gated
solely on isOnStageMe — a value derived from the presence aggregator.
That aggregator only updates after a presence event signs, broadcasts
to relays, and loops back through LocalCache. With the recent
broadcast / mute traffic contending on the signer plus the 500 ms
mute debounce in NestPresencePublisher, the round-trip ran long
enough that the user perceived Leave the Stage as not firing.
AND the visibility gate with ui.onStageNow (the local intent flag,
which flips synchronously on setOnStage(false)) so the tap hides the
bar on the next frame. Host demotion still hides it via the existing
isOnStageMe path.
The kebab on a NoteCompose card opens the comprehensive
NoteDropDownMenu (Follow/Unfollow, Copy text/pubkey/note id, Share,
Edit, Broadcast, Timestamp, Pin, Bookmark, Delete/Report) — not the
smaller LongPressToQuickAction popup. The previous nests commit
wired the kebab to the wrong menu.
Replace the manual ClickableBox + VerticalDotsIcon + showQuickAction
plumbing with the existing MoreOptionsButton composable, which
already wraps the icon and triggers NoteDropDownMenu for both card
variants. Long-press still opens NoteQuickActionMenu via the outer
LongPressToQuickAction wrapper, matching NoteCompose's two-tier
gesture model exactly.
Drops the onMore parameter threading on ObserveAndRenderSpace,
RenderLiveSpacesThumb, and SpaceHostAndReactions — MoreOptionsButton
is self-contained, so the trailing optional parameter is no longer
needed.
Long-press on the cards already opened the standard
NoteQuickActionMenu, but the affordance was hidden — especially on
the compact ENDED card which has no visible interactive elements.
Add an explicit MoreVert (VerticalDotsIcon) tap target that opens
the same menu:
- ENDED compact card: 40dp icon on the trailing edge of the row.
- LIVE/SCHEDULED full card: 35dp icon in the existing
SpaceHostAndReactions row, after Like and Zap.
Both wire through the showQuickAction lambda from
LongPressToQuickAction, so the icon and the long-press gesture
trigger the exact same menu (share, copy, broadcast, report,
block, delete) — no second popup-state plumbing.
ObserveAndRenderSpace, RenderLiveSpacesThumb, and
SpaceHostAndReactions gain an optional `onMore: (() -> Unit)?`
parameter so other callers (e.g. existing live-stream surfaces
that reuse SpaceHostAndReactions) keep their current rendering
unchanged when they don't pass one.
The previous commit placed the controls strip as a sibling row right
after StageGrid. Visually it read as a separate band, not as part of
the stage card.
Adds a bottomBar slot to StageGrid that renders inside the same
Surface, below the grid, sharing the card's tonal background and
horizontal padding. StageControlsBar drops its own horizontal padding
since the card supplies it, and keeps a top inset so it has breathing
room from the avatars.
Replace the flat NestsScreen list with three explicit sections so the
audio-room surface separates actionable rooms from historic ones:
- LIVE: ordered by follows-participating DESC, total-participants DESC,
createdAt DESC. PRIVATE rooms continue to live in the LIVE bucket
since they're audible once a join token is granted.
- SCHEDULED: ordered by `starts` ASC (soonest first). PLANNED rooms
now have their own bucket instead of collapsing into ENDED.
- ENDED: ordered by createdAt DESC (proxy for "ended at"), capped to
the last 7 d, and rendered with a compact one-line card variant
(square thumb + name + host + "Ended Xh ago"). Tapping still
routes through the read-only lobby so recordings remain reachable.
The DAL exposes `NestsFeedFilter.bucketOf()` so the screen can walk
the pre-sorted feed once and inject sticky-header sections without
re-sorting on each recomposition.
Cards now wrap in `LongPressToQuickAction`, giving share / copy /
delete / report / block / broadcast on long press — matching the
NoteCompose interaction model and giving hosts a path to delete
(kind:5) ended rooms without entering the room first.
NestActionBar held the speaker controls (Talk / MicMute / Leave the
Stage) alongside the audience controls (hand-raise / react / leave
room), making the bar uncomfortably wide on phones once a user was
promoted.
Splits responsibilities:
- NestActionBar now only handles connection state in the start
cluster (Connect / Connecting chip / Reconnecting chip / empty)
plus the existing end cluster.
- StageControlsBar is a new strip rendered under the Stage card,
gated only on isOnStage, holding the full broadcast state machine.
isOnStage is the single visibility signal — a stable derived value
from the kind-30312 role + presence onstage flag — so connection
blips, broadcast state churn, mute toggles, and permission flows
never make the new bar appear or disappear. It only flips on a real
promote/demote or the user tapping Leave the Stage.
`AudioTrackPlayer` was constructing its `AudioTrack` with
`USAGE_VOICE_COMMUNICATION` + `CONTENT_TYPE_SPEECH` so the room would
behave like a phone call (volume rocker controls call volume, ducks
notifications). The `AudioTrack` then renders on `STREAM_VOICE_CALL`,
which Android only services audibly while the device is in
`MODE_IN_COMMUNICATION`. Nests never drives `AudioManager.mode` (only
NIP-100's `CallAudioManager` does), so on most devices the playback
either dropped to the earpiece at near-zero volume or produced no audio
at all — making rooms appear silent on both phones.
Fix: route through `USAGE_MEDIA` + `CONTENT_TYPE_SPEECH` (=
`STREAM_MUSIC`) so audio-room playback comes out of the loudspeaker by
default and the volume rocker controls it any time. Same approach
Twitter/X Spaces and Clubhouse take for hands-free audio rooms. Echo
cancellation still works on the capture side via
`MediaRecorder.AudioSource.VOICE_COMMUNICATION` regardless of the
playback usage.
Also update `NestForegroundService.requestAudioFocus` to request focus
under the matching `USAGE_MEDIA` attributes so the focus claim and the
playback attributes line up.
MicMuteToggle already covers 'stop sending audio while staying on stage'
with sample-accurate resume, and LeaveStageButton covers full teardown.
StopBroadcastButton drew a Mic icon next to MicMuteToggle's Mic icon,
reading visually as two adjacent mic buttons.
ScriptedSpeaker publishes startCount > 0 from inside startBroadcasting,
so polling startCount alone races the wrapper pump's subsequent
`if (desiredMuted) handle.setMuted(true)` step. Under load (full suite
run on CI) the assertion fired before the pump replayed mute intent,
producing setMutedCalls=0. Wait for the post-condition the assertion
checks instead.
Audit-driven UI fixes that don't fit the perf bucket:
* NestActionBar — wire StopBroadcastButton between MicMuteToggle and
LeaveStageButton while broadcasting. The doc table at the top of
the file already advertised [Mute] [Stop] [Leave the Stage] but the
button was defined and never used; speakers had to leave the stage
to stop sending audio.
* NestActivityContent — render an explicit loading spinner while the
kind-30312 resolves and a clear "this room can't be opened" page
with a Back button when the event is missing service/endpoint.
Replaces the previous black-screen dead end.
* HandRaiseQueueSection — wrap filter+sort in remember so a heartbeat
doesn't rebuild the queue on every recompose, swap to LazyColumn
so a long queue doesn't render every row every frame, and show
UsernameDisplay instead of the raw 8-char hex prefix.
* CreateNestSheet schedule picker — compute the timezone offset at
the picked instant rather than at Instant.now(); the previous
variant was off by one hour for rooms scheduled across a DST
transition.
* NestViewModelFactory — pass OkHttpNestsClient's httpClient via
named parameter so the (String) -> OkHttpClient function reference
doesn't get bound to the Long callTimeoutMs slot. Fixes the
pre-existing :amethyst:compilePlayDebugKotlin failure on the
branch base.
Adds three strings (loading, unjoinable title/body, back).
https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
NestBridge holds a process-singleton AccountViewModel reference so
NestActivity (separately tasked) can read the active account without
serialising a NostrSigner through an Intent extra. The bridge's doc
promised it'd be cleared on logout / account switch but no call site
existed — so the audio-room activity could pick up a stale
AccountViewModel from the previous session and sign with the old
key after an account swap.
Wire NestBridge.clear() into both transition points in
AccountSessionManager: switchUserSync (always) and logOff (only when
the current account is being torn down).
https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
Feed thumbnails were reusing NestRoomFilterAssemblerSubscription to
flip the LIVE/ENDED badge based on the freshest kind-10312 presence.
That assembler subscribes to chat + presence + reactions (kinds 1311,
10312, 7) — so a feed of N rooms paid each popular room's full
chat-history pull per outbox relay just to render a status pill.
Add NestRoomLivenessAssembler, scoped to a single kinds=[10312],
#a=[room], limit=1 filter per relay, and switch the feed thumbnail
to it. The full chat / reaction / admin command REQ stays gated
behind the join flow.
https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4