Move AccountFollowsLoaderSubAssembler and AccountNotificationsEoseFromRandomRelaysManager out
of the always-on AccountFilterAssembler and into a new AccountForegroundFilterAssembler that
is mounted via LifecycleAwareKeyDataSourceSubscription. These two scan-heavy loaders now pause
on ON_STOP and resume on ON_START, while the lightweight always-on account work (metadata,
gift wraps, drafts, inbox-relay notifications, marmot groups) keeps running in background.
https://claude.ai/code/session_01RUmRxmzUAcVXezF9PEETGz
Migrates 32 call sites from KeyDataSourceSubscription to
LifecycleAwareKeyDataSourceSubscription so feed/screen REQs are
paused when the app goes to background and resumed on foreground,
saving relay bandwidth while the always-on notification service
keeps the socket open.
Adds a MutableComposeSubscriptionManager overload to
LifecycleAwareKeyDataSourceSubscription so the search bars (which
bind to a flow-driven query) can also opt in.
Skips AccountFilterAssemblerSubscription (always-on account state)
and NWCFinderFilterAssemblerSubscription (in-flight zap payments
that must complete in the background).
https://claude.ai/code/session_01RUmRxmzUAcVXezF9PEETGz
When a listener joins a Nest before the speaker starts publishing,
moq-rs FINs the SUBSCRIBE bidi immediately without sending
SubscribeOk or SubscribeDrop. MoqLiteSession.subscribe surfaces that
as a MoqLiteSubscribeException("subscribe stream FIN before reply"),
which the listener wrapper translated to MoqProtocolException via
MoqLiteNestsListener.wrapSubscription.
The reconnecting wrapper's inner re-issue loop then did:
val handle = try { opener(listener) } catch (...) { null } ?: break
— breaking out of the loop forever. The outer collectLatest only
re-runs on listener swap (session reconnect), so once the first
SUBSCRIBE failed the audio path stayed dead until the user manually
disconnected and reconnected. With the typical join-order being
"listener taps Join before speaker taps Start", this hit nearly
every nest.
The kdoc on pumpAnnounceWatch claims "moq-lite supports subscribe-
before-announce, so a subscribe issued during the gap … attaches
cleanly when the new publisher comes up" — true for some publisher-
cycle gaps mid-broadcast, but verifiably false for the cold-start
case where the publisher hasn't existed yet on the relay's view of
the namespace. Production trace (commit 283e776):
14:23:29.556 subscribe id=0 track='audio/data': SUBSCRIBE bytes flushed
14:23:29.597 subscribe id=0: bidi closed BEFORE any response parsed
14:23:29.617 ReconnectingHandle.opener threw MoqProtocolException — pump breaks
14:23:33.604 announce update status=Active suffix=… (4 s later)
14:23:34.218 publish suffix=… (speaker starts publishing)
Fix: instead of `break`, treat opener-throw as a transient failure
and retry after SUBSCRIBE_RETRY_BACKOFF_MS (1 s). The outer
collectLatest still cancels the inner loop on listener swap, and
unsubscribeAction.cancel() still tears the pump down on consumer
release, so the retry loop is bounded by both the session and the
caller. 1 s is well over moq-rs's typical announce-propagation
window (< 200 ms in traces) and short enough that the listener
attaches within a second of the speaker actually publishing.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
AlwaysOnNotificationServiceManager calls NotificationRelayService.start()
from a flow collector that can fire during cold-start (before the activity
reaches foreground), where Android 12+ blocks startForegroundService() with
ForegroundServiceStartNotAllowedException. The exception was already caught
but logged as an error.
- Distinguish ForegroundServiceStartNotAllowedException from real failures
and log it at WARN — the other layers (boot receiver, watchdog alarm,
catch-up worker) retry from contexts that have FGS exemption.
- Retry the start in MainActivity.onResume() so the service comes up as
soon as the user opens the app.
Tearing down a relay REQ on every ON_STOP and rebuilding it on ON_START
churns EOSE state and triggers a refetch when the user briefly switches
to another app. Schedule the unsubscribe 30s after ON_STOP and cancel it
on ON_START so short app switches keep the subscription alive.
https://claude.ai/code/session_01W3RY9Rf4gc4eEkL4v1v8Bg
Listener cliff at uni stream #124 with the publisher continuing to
push for ~55 s past that point — the prior MAX_STREAMS_UNI extension
fix is either firing too late or not firing at all on the live
device. The trace can't tell us which because the bump itself is
silent.
- QuicConnectionWriter: log every MAX_STREAMS_UNI / MAX_STREAMS_BIDI
emission with old→new cap and the current peerInitiated count, so
we can see whether the writer is bumping the cap at all and how
far behind reception it falls.
- QuicConnection: log peerInitiatedUniCount every 25 streams along
with the currently advertised cap and headroom. Cheap and lets us
correlate "stream count growth" against "cap bumps" without
printing per-stream noise.
Tag is `NestQuic` so listeners can scope `adb logcat -s NestRx:D
NestTx:D NestQuic:D` to capture the full picture.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
Listeners stuck on a spinning speaker avatar with no audio (against
both Amethyst-hosted and nostrnests.com-hosted Nests). The trace
captured on a working speaker phone showed every group keyed to
subId=1 / track='catalog.json' even though the broadcaster was
pumping Opus frames:
13:21:27.570 inbound SUBSCRIBE id=1 track='catalog.json'
13:21:27.574 inbound SUBSCRIBE id=0 track='audio/data'
13:21:27.609 openGroup seq=0 keyedOnSubId=1 track='catalog.json'
13:21:27.610 broadcaster: send accepted (subscriber attached)
... every subsequent group keyed=1, every audio frame on the
catalog stream
Root cause: `PublisherStateImpl.openNextGroupLocked` keyed each uni
stream off `inboundSubs.first()` with no track filter. The kdoc
asserted "Inbound subscription set is expected to be small (1 in
nests's listener-per-room model)" — that was true when listeners
only opened the audio sub. Once the listener side started opening a
catalog sub alongside (commits ~Apr 2026), whichever SUBSCRIBE
arrived first (typically the catalog one, by ~4 ms in this trace)
became the routing target for all audio frames. The relay forwarded
those frames to the listener with subscribeId=catalog, the listener
routed them to its catalog handler, RoomSpeakerCatalog.parseOrNull
returned null (it's not JSON), and the audio subscription's frames
channel never received anything — so onSpeakerActivity never fired
and the spinner stayed forever.
Fix: per moq-lite Lite-03, a publisher is responsible for one
(broadcast, track) tuple. Thread `track` through `MoqLiteSession.publish`
and have `PublisherStateImpl.registerInboundSubscription` reject
inbound SUBSCRIBEs whose track doesn't match — those still receive
SUBSCRIBE_OK from the bidi handler (best-effort per Lite-03's
optional-content semantics for catalog) but don't influence the
audio routing. `MoqLiteNestsSpeaker.startBroadcasting` passes
`track = MoqLiteNestsListener.AUDIO_TRACK` (= "audio/data").
Test callsites (MoqLiteSessionTest, NostrnestsProdAudioTransmissionTest,
NostrNestsSustainedSendOutcomesInteropTest) updated to pass the new
required parameter; all use track="audio/data" matching what they
exercise.
The diagnostic logs from the prior commits stay in — they're how we
caught this and they'll catch the next one. They can be stripped in
a follow-up once the fix is confirmed in the field.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
Round-2 instrumentation after phone-B trace showed silence between
SUBSCRIBE_OK-expected and the first audio frame. Three additions:
- MoqLiteSession.subscribe: log the SUBSCRIBE-bytes-flushed transition,
every chunk arriving on the response bidi (chunks=N), and any
exception or natural close on bidi.incoming() that previously
swallowed the cause.
- MoqLiteSession.announce: same treatment for the AnnouncePlease bidi
— chunks=N, natural-close, and exception path now visible.
- ReconnectingNestsListener.reissuingSubscribe: previously had a
`catch (_: Throwable) { null }` black hole on the re-issue pump
(line 354); now logs the throwable's class + message before the
pump breaks. Also logs natural-close of handle.objects so a
publisher cycle vs a real failure are distinguishable in the trace.
These narrow the listener-stuck-on-spinner diagnosis from
"something downstream of subscribe never fires" to which exact step
inside `MoqLiteSession.subscribe` is silent: the write, the response
read, or the framing.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
Splits the diagnosis into "QUIC delivered streams" vs "moq routed
them". Without this seam log, a silent NestRx trace after SUBSCRIBE_OK
gives no signal whether the relay is forwarding (and :quic isn't
delivering streams to the moq pump) or moq is dropping frames.
Also surfaces the prior `_: Throwable` swallow in both pumps — those
hid transport-close exceptions which are exactly the kind of clue
we want when the listener silently stalls.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
The existing speaker indicator was easy to miss — only a thin border ring
plus a soft halo, and on busy stages neither stood out. Adds a second,
solid green ring drawn 5dp away from the profile picture so the
"is talking" signal reads at a glance. Stroke width still pulses with
the live audio level for liveness; ring fades out when speech stops.
Drawn via drawBehind so it doesn't affect cell layout — the gap + ring
fit inside the existing GRID_SPACING.
Diagnostic instrumentation to localise the listener-stuck-on-spinner bug.
None of these are intended for merge — pair logs with a logcat capture,
diagnose, then revert.
Receiver (tag NestRx):
- MoqLiteNestsListener: log subscribeSpeaker / subscribeCatalog entry
and per-update RoomAnnouncement emission to the VM
- MoqLiteSession.subscribe: log SUBSCRIBE_OK / SUBSCRIBE_DROP outcomes
- MoqLiteSession.drainOneGroup: log every uni group header decode
and warn on subscription-lookup miss (frame dropped)
- MoqLiteSession.pumpAnnounceWatch: log every announce update and
flag the Ended branch that closes the subscription's frames
- NestViewModel: log VM.openSubscription wiring and the first-frame
trigger that clears the connectingSpeakers spinner
Speaker (tag NestTx):
- MoqLiteSession.publish: log entry suffix
- MoqLiteSession.handleInboundBidi: log inbound AnnouncePlease and
SUBSCRIBE dispatches plus FIN cleanup
- MoqLiteSession PublisherStateImpl: log first inbound subscriber
attach and every group-stream open
- NestMoqLiteBroadcaster: log when publisher.send returns false
(no inbound subscriber on relay) and the subscriber-attached
transition, plus surface throws that runCatching previously
swallowed only into onError
Capture with `adb logcat -s NestRx:D NestTx:D`.
https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
Long-press a row in Settings -> Security Filters to enter selection mode,
pick more rows, tap Unblock. The mute list (kind 10000) and block list
are rebuilt and re-broadcast once for all selected entries instead of
once per item.
- Quartz: add `MuteListEvent.removeAll` and `PeopleListEvent.removeAll`
bulk overloads using the existing `TagArray.removeAny` primitive.
- Account / state classes: add `showUsers(List)` and `showWords(List)`
that produce a single resigned event per list.
- AccountViewModel: thin wrappers `showUsers` / `showWords`.
- SecurityFiltersScreen: hoist per-tab selection sets, swap top bar
in selection mode, add a local selectable user list (the shared
`UserCompose` row was kept untouched).
Add Step 3 to the audit: catch-block Log.w/e calls that interpolate
${e.message} but don't pass `e` lose the stack trace and log "...: null"
when the exception's message is null. Document the throwable-overload
fix alongside the existing lambda-overload conversion.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the catch-block warnings interpolated ${e.message} but
dropped the throwable, losing the stack trace and showing nothing for
exceptions whose message is null. Switch to the (tag, msg, throwable)
overload so the cause and stack trace are logged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Avoid eager string interpolation when the log level is filtered out at
runtime. Covers Log.d/i calls and Log.w/e calls that did not pass a
throwable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues fixed:
1. BunkerResponseDeserializer produces generic BunkerResponse for decrypt/encrypt
results (plain strings that aren't pubkeys or JSON). The response parsers only
matched typed subtypes (BunkerResponseDecrypt/Encrypt), causing all decrypt and
encrypt operations through NIP-46 bunker to fail with ReceivedButCouldNotPerform.
Fix: parsers now fall back to extracting response.result directly.
2. RemoteSignerManager timeout was 30s, shorter than Amber's 60s approval window.
Fix: increased to 65s with safe retry (republishes same request, preserving UUID
so bunker responses always match).
3. Desktop ChatPane showed raw ciphertext on decrypt failure instead of an error
message. Fix: shows "Could not decrypt the message" in italic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge upstream main into feat/desktop-multi-account, resolving:
- Main.kt: Surface wrapper + CompositionLocalProvider + nip11Fetcher from upstream, multi-account DeckSidebar/LoginScreen from HEAD
- FeedScreen.kt: viewport-aware scroll from HEAD + contentPadding from upstream
- DeckSidebar.kt: merged imports (multi-account + titleBarInsetTop + MaterialSymbols)
- Migrated AccountSwitcherDropdown + AddAccountDialog from material.icons to MaterialSymbols
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
"AmethystDns" identified the owner; "SurgeDns" identifies the
behavior. The class is built to absorb sudden bursts of concurrent
DNS work — 700 relays reconnecting, a feed scrolling through dozens
of media hosts, a profile screen requesting a NIP-05 — without
falling over: lock-free reads, single-flight coalescing, and
stale-while-revalidate so recurring hosts never block.
File renames:
- AmethystDns.kt -> SurgeDns.kt
- AmethystDnsStore.kt -> SurgeDnsStore.kt
- AmethystDnsTest.kt -> SurgeDnsTest.kt
Symbol renames in every touchpoint (AppModules, factories,
managers, event listeners): AmethystDns -> SurgeDns,
amethystDns -> surgeDns.
- evictIfOverCap -> purgeExpiredIfOverCap. The method only purges
expired entries; "evict" implied LRU behavior.
- triggerBackgroundRefresh -> scheduleBackgroundRefresh. Better
reflects that we hand the work to an executor that may queue it.
- fresh -> freshEntry in resolveAsLeader. The bare adjective read as
a boolean.
- KEY_CACHE -> KEY_SNAPSHOT in AmethystDnsStore. The constant
identifier now matches what we actually store via dns.snapshot();
the string value is unchanged.
Two correctness fixes from the audit:
1. Save race. The previous order (snapshot -> write -> clearDirty)
could lose a putPositive that landed between the write and the
clearDirty: clearDirty unconditionally wiped the flag, so the
just-written entry would never be persisted. Replace clearDirty
with tryClearDirty (compareAndSet), called BEFORE the snapshot —
any concurrent put then re-marks dirty and is captured by the next
save. Add markDirty so the store can re-flag on write failure.
Also drop the buggy clearDirty in load(): if puts happened between
AmethystDns construction and load completion, we used to silently
discard their dirty signal. restore() never marks dirty, so the
manual clear was both unnecessary and incorrect.
2. Empty positive results. A misbehaving Dns delegate returning
emptyList() would land in the cache as a positive entry — harmless
in lookup semantics (unwrap throws on empty) but wrongly persisted
to disk and dirty-flagged. Treat empty as UnknownHostException so
it goes through putNegative.
Add three tests:
- failed refresh demotes a stale positive entry to negative
- failed lookup does not mark cache dirty
- refresh executor rejection cleans up the inflight slot
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.
Translate Nests, PDF, and duration strings/plurals that were missing
from the four target locales.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
The isAllHls guard already forces imetas.isNotEmpty(), so the
?: imetas.first() fallback could never fire. Switch to the throwing
minBy variant — same runtime semantics, no elvis noise, no nullable
intermediate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use the existing HlsContentTypes.HLS_PLAYLIST constant for the
canonical HLS playlist mime in GalleryThumb's isHlsMimeType so the
read-side stays in sync with the publish path (HlsVideoEventBuilder,
HlsPublishOrchestrator). Trim two block comments down to the
non-obvious WHY only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Add Media to Gallery" action in the zoomable view passed only
blurhash, dim, hash, and mime type — losing the poster URL that the
underlying NIP-71 imeta carries on its `image` field. The resulting
ProfileGalleryEntryEvent therefore had no `image` tag, and the
gallery card fell back to the play-icon placeholder for any HLS URL
because the .m3u8 playlist itself can't be decoded as an image.
Thread the poster through Account.addToGallery and
AccountViewModel.addMediaToGallery as an optional `image` parameter,
and pull it from the MediaUrlVideo's artworkUri at the call site.
Non-video content (MediaUrlImage, etc.) still passes null.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
A NIP-71 HLS publish writes one imeta tag per rendition (master + each
variant), all on the same event. The gallery card was expanding that
into one sub-tile per imeta inside AutoNonlazyGrid, producing a 3x6
sub-grid of black play-icon placeholders for a single video — the
.m3u8 playlist is a text manifest Coil can't decode.
When every imeta on a VideoEvent is an HLS playlist, pick a single
imeta to render: prefer one carrying a poster image, breaking ties
by smallest dimensions to favor the lowest-resolution variant. Fall
back to the first imeta if none carry a poster. Non-HLS multi-imeta
videos keep their existing per-imeta layout to avoid regressions for
any author publishing alternate-format renditions side by side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
HLS publishes built NIP-71 video events with imetas pointing only at
.m3u8 playlists, leaving HLS-unaware UI surfaces (gallery thumbnails,
previews) with nothing to render. This change extracts a still frame
from the picked source video, encodes it as JPEG, uploads it
alongside the segments, and threads the URL onto every imeta's
`image` property — exactly where the gallery already reads it.
- HlsVideoPublishInput gains a posterUrl field; HlsVideoEventBuilder
applies it to every VideoMeta.image (master + renditions).
- HlsPublishOrchestrator gains an injected uploadPoster closure
(default no-op for tests). Failures here log + continue rather
than aborting — the user has already paid the cost of the long
segment uploads, so a missing poster shouldn't block publish.
- HlsPublishOrchestratorFactory wires the production closure:
MediaMetadataRetriever frame extraction (reusing
service.uploads.getThumbnail), JPEG encode at quality 85,
upload via the same HlsBlobUploader, temp-file cleanup.
- New tests: poster URL propagation (builder), poster URL flow
through orchestrator, and graceful failure when the poster
closure throws.
https://claude.ai/code/session_011e5CNmGU5Sbzqb9w9hhd66