Commit Graph

12799 Commits

Author SHA1 Message Date
Claude 0ced269b27 feat(quic): step 3 of RFC 9002 retransmit — drain SentPacket on ACK
QuicConnectionParser's AckFrame handler now drains
state.sentPackets of every entry whose packet number is covered by
the ACK's ranges. The drained SentPackets are returned but
discarded for now; step 5 will route them to loss detection / RTT.

New helper file `connection/recovery/AckedPackets.kt`:

  - forEachAckedPacketNumber(ack, block): inline iterator over an
    AckFrame's ranges in RFC 9000 §19.3.1 order. Walks first range
    [largestAcked - firstAckRange, largestAcked] then each
    additional range with `nextLargest = previousSmallest - gap - 2`,
    `nextSmallest = nextLargest - ackRangeLength`. Defensive clamp
    at PN 0 against malformed peer ACKs.
  - drainAckedSentPackets(sentPackets, ack): walks via
    forEachAckedPacketNumber and removes each matching entry from
    the map. Returns the drained list.

Wired into QuicConnectionParser.kt:165 alongside the existing
ackTracker.purgeBelow call.

Tests added (9, all pass):
  - simpleRange / multipleRanges / singlePacketAck: range walking
    for typical ACK shapes
  - ackForUnsentPn_isNoOp / emptyMap_returnsEmptyDrain: defensive
    paths
  - ackBoundary_pn0Inclusive: PN 0 is correctly included, no
    underflow
  - forEachAckedPacketNumber_iteratesDescending /
    forEachAckedPacketNumber_acrossMultipleRanges_descending:
    iterator semantics
  - returnedDrain_preservesTokens: drained SentPacket retains its
    full tokens list — step 5+ will dispatch these to RTT / loss

Mirror of neqo's `recovery/mod.rs::remove_acked` (one of the 20
recovery tests we owe per
`quic/plans/2026-05-04-control-frame-retransmit.md`). The remaining
loss-detection tests land in step 5.

Full :quic test suite + nestsClient moq-lite tests pass.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:45:10 +00:00
Claude ea15a9afa1 feat(quic): step 2 of RFC 9002 retransmit — record SentPacket per outbound
Plumbs sent-packet retention into the writer. From now on every
Application packet emission stores a SentPacket in
`LevelState.sentPackets`, keyed by packet number, carrying:

  - the packet number (from `pnSpace.allocateOutbound()`)
  - the writer's `nowMillis` send time
  - whether the packet is ack-eliciting (RFC 9000 §13.2.1)
  - the encrypted on-wire size (or 0 if encrypt threw)
  - a list of RecoveryTokens — one per retransmittable frame in the
    packet (Ack token for ACK frames; MaxStreamsUni / MaxStreamsBidi
    / MaxData / MaxStreamData for the corresponding flow-control
    extensions)

`appendFlowControlUpdates` now takes a parallel `tokens: MutableList`
and writes lock-step with `frames`. The writer's existing semantics
are unchanged — same frames go on the wire, same advertised-cap
bookkeeping. Step 2 only adds the retention; nothing reads
`sentPackets` yet (steps 3–6 do that).

Order of operations on packet emission:
  1. Allocate packet number
  2. runCatching the encrypt step
  3. Record SentPacket regardless of encrypt outcome (sizeBytes=0 if
     it threw — the bookkeeping survives so loss detection can later
     declare the gap lost on the time threshold)
  4. Re-throw the encrypt exception so the driver loop sees the same
     error it did before this change

Tests added (3, all pass):
  - writer_records_sent_packet_with_max_streams_uni_token: cross
    half-window, drain, observe a SentPacket whose tokens contain
    MaxStreamsUni with maxStreams matching advertisedMaxStreamsUni
  - ack_only_outbound_records_sent_packet_with_ack_token_and_not_ack_eliciting:
    pending ACK only, observe a SentPacket with single Ack token and
    ackEliciting=false
  - successive_drains_record_distinct_packet_numbers: two drains
    record disjoint PN sets

Full :quic test suite passes (no regressions). nestsClient moq-lite
tests pass.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:42:15 +00:00
Claude 9e6fa3d3f0 feat(quic): step 1 of RFC 9002 retransmit — RecoveryToken + SentPacket types
First step of `quic/plans/2026-05-04-control-frame-retransmit.md`.
Pure type definitions, no behavior change yet — sets up the data
shape the next steps will populate from the writer (step 2) and
drain from ACK / loss-detection paths (steps 3–6).

RecoveryToken: sealed class mirroring neqo's
neqo-transport/src/recovery/token.rs:21 StreamRecoveryToken.

  - Ack (singleton object): tracked but never retransmitted, so the
    sent-packet map invariant ("every retained entry has at least
    one token") holds for ACK-only packets too
  - MaxStreamsUni / MaxStreamsBidi: receive-side stream-id cap
    extension (RFC 9000 §19.11) — the frame whose loss tripped
    the moq-rs cliff
  - MaxData: connection-level data cap extension (§19.9)
  - MaxStreamData: per-stream data cap extension (§19.10)

SentPacket: data class mirroring neqo's
neqo-transport/src/recovery/sent.rs::Packet. Held in a per-pn-space
map on QuicConnection (step 2 wires this up). Carries packet number,
send time, ack-eliciting flag, on-wire size, and the token list to
dispatch on loss.

Tests: 6 token tests (data-class equality, sealed-hierarchy
exhaustiveness, Ack singleton-ness) + 6 SentPacket tests (equality
across fields, copy semantics, ACK-only-with-Ack-token convention,
multi-token packet shape). All pass; full :quic test suite still
passes — types are purely additive.

Out of scope as documented in the plan: STREAM data retransmit,
CRYPTO retransmit, RESET_STREAM/STOP_SENDING/etc, congestion control,
0-RTT. Those are separate follow-ups.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:31:07 +00:00
Claude c24630513f docs(quic): plan control-frame retransmit subsystem mirroring neqo
The shipping fix raises initialMaxStreamsUni to 1M to dodge the
moq-rs cliff that fired when our :quic emitted its first
MAX_STREAMS_UNI extension. That sidesteps the symptom but leaves
every ack-eliciting control frame one wire-loss away from a silent
stall (MAX_DATA, MAX_STREAM_DATA, RESET_STREAM, etc.). Browser
QUIC stacks (Firefox neqo and Chrome quiche, both verified by
reading source) implement RFC 9002 §6 loss detection + per-frame
retransmit; :quic does not.

Plan documents:
  - the architecture, mirroring neqo's typed-token shape (chosen
    over quiche's monotonic-control-frame-id deque on code-fit
    grounds — better match for :quic's existing sealed-class
    Frame / MoqLiteControl idioms)
  - file-by-file implementation order in 9 steps that each compile
    and test independently
  - inventory of ~50 tests to port from neqo (9 from fc.rs, ~20
    from recovery/mod.rs, ~10 connection-level, plus codec round
    trips). Each row links to the upstream neqo test by file:line
    and the equivalent Kotlin test name we'll add
  - explicit out-of-scope list (STREAM data retransmit, CRYPTO
    retransmit, congestion control, 0-RTT, multipath) — separate
    follow-ups
  - effort estimate (4–7 days) and acceptance criteria

References upstream code at /tmp/quic-refs/neqo (mozilla/neqo) and
/tmp/quic-refs/quiche (google/quiche), sparse-cloned for source
verification.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 22:25:34 +00:00
Claude c3d6cadffb fix(quic): raise stream-id cap to 1M to support multi-hour Nests; strip diagnostic logs
Two changes once the cliff is confirmed sidestepped:

1. Raise initialMaxStreamsUni from 10 000 → 1 000 000.

   At framesPerGroup=5 with 20 ms Opus frames the relay opens ~10
   uni streams/sec to a listener. The half-window threshold check
   (`count + initialMaxStreamsUni/2 >= advertisedMaxStreamsUni`)
   now trips at count=500 000 ≈ 13.9 hours of continuous audio.
   For any realistic Nest the rolling MAX_STREAMS_UNI extension
   path — which is what tripped the moq-rs cliff — is dormant.

   Memory cost: QuicConnection.streams grows for the connection's
   lifetime (no removal in current model), so 2 hours costs ~72k
   stream entries. Per-stream overhead is small enough that this
   is tolerable for an audio-room workload; bounded growth is a
   known follow-up.

2. Strip the high-frequency diagnostic logs that were added during
   investigation. Production keeps:
     - SUBSCRIBE_DROP (rare error)
     - MAX_STREAMS_UNI / MAX_STREAMS_BIDI emit (should never fire
       in normal operation now; if it does, we want to know)
     - pumpUniStreams / pumpInboundBidis ended (pump death)
     - announce / subscribe bidi.incoming() exception path
     - ReconnectingHandle.opener throw + retry

   Stripped:
     - per-stream "transport delivered uni stream #N"
     - per-group "uni grpHdr id=N seq=M"
     - per-group "openGroup seq=N keyedOnSubId=…"
     - per-25-stream peerInitiatedUniCount milestone
     - per-chunk "subscribe id=N: bidi chunk #M"
     - per-update RoomAnnouncement
     - 5-second QuicWebTransportSession flow-control snapshot ticker
     - "VM.openSubscription ->/<- subscribeSpeaker"
     - "VM.onSpeakerActivity FIRST frame"
     - "broadcaster: send accepted (subscriber attached)"
     - "broadcaster: publisher.send returned false (no inbound subscriber)"
     - "first inbound subscriber attached"
     - "ignoring inbound SUBSCRIBE id=N track=catalog.json"
     - "publish suffix=…"
     - "transport delivered inbound bidi #N"
     - "inbound AnnouncePlease prefix=…"
     - "inbound SUBSCRIBE id=… track=…"
     - "inbound SUBSCRIBE FIN'd: removing id=…"

   The diagnostic logs can be re-added behind a debug flag later if
   we need to chase a different regression.

Confirmed durable for at least 15 s of continuous audio against
nostrnests.com production with the prior 10 000-cap fix; bumping to
1 000 000 expands the headroom to multi-hour broadcasts without any
new code path firing.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 21:55:49 +00:00
Claude f32131d073 fix(quic): raise initialMaxStreamsUni from 100 → 10000 to dodge MAX_STREAMS_UNI cliff
Production trace pinned the audio-cliff to the exact moment our
writer emits its first MAX_STREAMS_UNI extension (frame 0x13).
With the default cap of 100 and the half-window threshold check at
count=50, the listener emits MAX_STREAMS_UNI(150) at count=50, then:

    16:47:18.092 MAX_STREAMS_UNI emit oldCap=100 → newCap=150
    16:47:18.210 transport delivered uni stream #50 ← LAST stream
    16:47:20.204 udpRecvDatagrams=431
    16:47:25.209 udpRecvDatagrams=443  (+12)
    16:47:30.213 udpRecvDatagrams=443  ← FROZEN forever

`udpRecvDatagrams` (kernel-level UDP receive counter) freezes:
the relay's UDP packets stop reaching the OS. Our QUIC state still
believes the connection is alive (sendCredit=2^62-1, pendingBytes=0,
peerInitMaxStreamsUni=10000) but `peerInitiatedUniCount` is stuck at
51 forever. The relay, meanwhile, FINs both inbound SUBSCRIBE bidis
on the publisher side at +10s, telling the publisher "this listener
went away" — split-brain.

The cliff is repeatable but not at a fixed stream count: prior runs
saw cliffs at 124 (where the *second* bump at count=100 would have
fired) and 61 (where some intermediate condition tripped). The
common thread is the rolling-extension path, not the absolute count.

Without a packet capture from the relay we can't pin whether our
MAX_STREAMS_UNI frame is malformed, mis-sequenced, ill-timed against
moq-rs's flow-control state machine, or something subtler. But moq-rs
itself advertises `max_concurrent_uni_streams = 10000` for exactly
the audio-rooms workload — every Opus group is a fresh peer-initiated
uni stream — and matching that takes the listener to ~5 000 groups
(at framesPerGroup=5, Opus 20ms, that's ~8 minutes of audio) before
the half-window threshold trips at all. For any realistic Nest
duration we never need to extend.

The extension code path stays in QuicConnectionWriter.kt:395 — it's
correct per RFC 9000 §19.11, the bug is in moq-rs (or its Quinn
config) and we're working around it for now.

Side effect: we also bump the bidi cap implicitly — but that's
controlled by initialMaxStreamsBidi (still 100), unaffected by this
change. Bidi streams are control-stream-only in moq-lite (Subscribe
+ Announce), well below 100.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 20:53:58 +00:00
Claude 36c707f98a debug(quic): periodic 5s flow-control snapshot from QuicWebTransportSession
Listener cliffs at uni stream #61 even though MAX_STREAMS_UNI(150)
was emitted at count=50 — and the cliff number is variable across
runs (124 in one trace, 61 in another), which strongly suggests the
limiter isn't the stream-id cap any more. Need visibility into what
QUIC flow-control state looks like at the moment streams stop.

QuicWebTransportSession now optionally takes a parentScope and, when
provided, launches a daemon coroutine that calls
QuicConnection.flowControlSnapshot() every 5 s. The snapshot dumps
the fields the cliff-investigation plan
(nestsClient/plans/2026-05-01-quic-stream-cliff-investigation.md)
identified as smoking-gun candidates:

  - peerInitiatedUniCount + advertisedMaxStreamsUni (stream-id cap)
  - peerInitMaxStreamsUni (peer's view of our cap, from handshake)
  - sendCredit + consumed (connection-level data flow control)
  - pendingBytes / pendingStreams (anything stuck in send buffers)
  - udpRecvDatagrams + udpRecvBytes (raw socket-level reception)

The factory passes its parentScope into the session so the snapshot
job dies cleanly with the same supervisor that owns the driver.
QuicWebTransportFactory tests and SendTraceScenario don't pass a
scope and won't see the periodic logger.

Filter `adb logcat -s NestQuic:D` and look for "snapshot peerInitiatedUni=…".
At cliff time the snapshot will print whichever counter is wedged.

https://claude.ai/code/session_01PYYez8a6sjiakyjAxsfCEQ
2026-05-04 20:28:41 +00:00
Claude 1555023ea2 Merge remote-tracking branch 'origin/main' into claude/fix-nest-audio-display-3chAG 2026-05-04 20:07:25 +00:00
Vitor Pamplona 8cea0fa869 Merge pull request #2725 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-04 16:06:37 -04:00
Vitor Pamplona b053a08173 Merge pull request #2728 from vitorpamplona/claude/trace-nostrclient-lifecycle-JXoP8
Replace KeyDataSourceSubscription with LifecycleAwareKeyDataSourceSubscription
2026-05-04 16:06:30 -04:00
Claude 6bcb94a658 feat(subscriptions): split foreground-only account loaders into AccountForegroundFilterAssembler
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
2026-05-04 19:43:09 +00:00
Claude 98c0438526 feat(subscriptions): make screen subscriptions lifecycle-aware
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
2026-05-04 19:43:09 +00:00
Crowdin Bot 3e9b05b2fe New Crowdin translations by GitHub Action 2026-05-04 18:35:45 +00:00
Vitor Pamplona 846b25dcd4 Merge pull request #2727 from vitorpamplona/claude/fix-foreground-service-U8P5d
Handle Android 12+ ForegroundServiceStartNotAllowedException gracefully
2026-05-04 14:34:08 -04:00
Claude 9f542a6ff4 fix(nests): retry SUBSCRIBE on relay-FIN instead of permanently giving up
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
2026-05-04 18:28:42 +00:00
Claude e693a954d1 fix(notifications): handle FGS-not-allowed exception from background
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.
2026-05-04 18:22:31 +00:00
Vitor Pamplona aaa35b1a06 Merge pull request #2726 from vitorpamplona/claude/delayed-unsubscribe-lifecycle-93ICJ
Add grace period to lifecycle-aware subscriptions
2026-05-04 13:59:27 -04:00
Claude e6eca9362a feat(commons): delay lifecycle-aware unsubscribe by 30s
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
2026-05-04 17:56:38 +00:00
Claude 283e7760bd debug(quic): log MAX_STREAMS_UNI emission and per-25-stream count milestones
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
2026-05-04 17:48:03 +00:00
Vitor Pamplona eb73069459 Merge pull request #2724 from vitorpamplona/claude/enhance-speaker-animation-8AlyR
Add animated outer ring indicator for speaking participants
2026-05-04 13:34:30 -04:00
Claude 43720dd14d fix(nests): scope moq-lite publisher to a single track to stop catalog hijack
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
2026-05-04 17:28:18 +00:00
Claude 9b6e09838e debug(nests): expose silent failures inside subscribe / announce / reissue
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
2026-05-04 17:16:28 +00:00
Claude 62bedf1372 debug(nests): log QUIC↔moq seam in pumpUniStreams + pumpInboundBidis
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
2026-05-04 14:05:03 +00:00
Claude 520949af20 feat(nests): add detached outer ring to speaker avatar
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.
2026-05-04 14:04:50 +00:00
Claude 726894362f debug(nests): wire NestRx/NestTx logs across listener and speaker paths
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
2026-05-04 13:10:24 +00:00
Vitor Pamplona 0210d608b5 Merge pull request #2719 from nrobi144/fix/bunker-timeout-and-decrypt
fix(nip46): fix bunker decrypt/encrypt parsing and increase timeout
2026-05-04 08:09:33 -04:00
Vitor Pamplona b1fb13cddc Merge pull request #2720 from davotoula/feat/log-lambda-overloads
chore: convert interpolated Log calls to lambda overload + restore throwables in Marmot catch blocks
2026-05-04 08:08:19 -04:00
Vitor Pamplona 85402c0d27 Merge pull request #2721 from greenart7c3/main
Add bulk unblock/unmute functionality to Security Filters
2026-05-04 08:04:26 -04:00
Claude 76b69cd440 feat: bulk-remove for blocked users and hidden words
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).
2026-05-04 08:55:29 -03:00
David Kaspar 4a789efa63 Merge pull request #2723 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-04 13:51:17 +02:00
Crowdin Bot 39ea8a6aae New Crowdin translations by GitHub Action 2026-05-04 11:49:25 +00:00
Vitor Pamplona cee3e28e97 Merge pull request #2656 from nrobi144/feat/desktop-multi-account
feat(desktop): Multi-Account Support + Feed Metadata Optimization
2026-05-04 07:47:35 -04:00
davotoula 72fcdfb6d1 docs(skill): extend find-non-lambda-logs to flag dropped throwables
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>
2026-05-04 12:47:22 +02:00
davotoula 31dee7fe38 fix(log): pass throwable to Log.w in Marmot catch blocks
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>
2026-05-04 10:52:39 +02:00
davotoula a22f63e42c refactor(log): convert interpolated Log calls to lambda overload
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>
2026-05-04 10:15:22 +02:00
nrobi144 bc2267d79a fix(nip46): fix bunker decrypt/encrypt response parsing and increase timeout
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>
2026-05-04 10:24:05 +03:00
nrobi144 fd34105439 merge: resolve conflicts with upstream/main
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>
2026-05-04 07:29:20 +03:00
Vitor Pamplona 42d2a50436 Merge pull request #2714 from vitorpamplona/claude/dns-resolver-okhtp-XCJWi
Add SurgeDns: burst-tolerant DNS resolver for OkHttp
2026-05-03 17:28:52 -04:00
Claude b9a260f3ce refactor(okhttp): rename AmethystDns -> SurgeDns
"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.
2026-05-03 21:26:46 +00:00
Claude e173daf3c9 refactor(okhttp): clearer names in DNS resolver
- 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.
2026-05-03 21:14:12 +00:00
Claude e86871a868 fix(okhttp): close save-race window and reject empty positive results
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
2026-05-03 21:01:03 +00:00
Claude 6240e771f8 refactor(okhttp): inject AmethystDns instead of using a singleton
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.
2026-05-03 20:48:12 +00:00
Claude eeb5f00732 refactor(okhttp): tidy AmethystDns internals
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.
2026-05-03 20:44:12 +00:00
David Kaspar 99ecab8ba6 Merge pull request #2718 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-03 22:44:03 +02:00
Crowdin Bot 0dce23198f New Crowdin translations by GitHub Action 2026-05-03 20:39:55 +00:00
Vitor Pamplona b20a7901d8 Merge pull request #2716 from davotoula/fix-hls-gallery-icons
Generate poster JPEGs and fix HLS video rendering in profile gallery
2026-05-03 16:38:38 -04:00
David Kaspar e4a7df4a06 Merge pull request #2717 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-05-03 21:55:43 +02:00
Crowdin Bot 3e62211bca New Crowdin translations by GitHub Action 2026-05-03 19:54:06 +00:00
davotoula e7022fb9dc Add missing translations for cs-rCZ, pt-rBR, sv-rSE, de-rDE
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>
2026-05-03 21:52:10 +02:00
Claude e4897126a1 fix(okhttp): canonicalize DNS hosts and invalidate on call failure
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.
2026-05-03 19:22:04 +00:00