The big one: the source package was named `nipBCOnchainZaps/build/`, which
the repo .gitignore (`build/`) silently matched — so OnchainZapBuilder.kt
(the main source, not just its test) was NEVER committed. The pushed branch
did not compile; the pre-commit hook only checks the working tree, so this
went unnoticed. Renamed the package `build` -> `builder` (a source package
must never be named `build`) and updated all references; the recovered files
are now actually tracked.
Re-audit findings on the previous fix commit:
- CachingOnchainBackend.txCache was unbounded -> memory leak in a long
session. Added a bounded cache (maxCachedTxs, oldest-entry eviction).
- OnchainZapSender still trusted the signer's returned PSBT for witness
UTXOs and tap internal keys (used to compute the sighash + the verified
output keys). Now it copies ONLY the PSBT_IN_TAP_KEY_SIG records back onto
the PSBT we built, so a signer can contribute signatures and nothing else;
verification and finalization run entirely on our own PSBT.
Test coverage added:
- OnchainZapBuilderTest: confirmed-UTXO filter — unconfirmed UTXOs excluded
by default, spendable only with allowUnconfirmed, confirmed preferred.
- CachingOnchainBackendTest: confirmed-tx cached forever, unconfirmed
re-fetched after TTL, not-found never cached, tip/fee TTL, bounded
eviction.
All quartz / commons jvmTest suites pass; quartz android + amethyst compile.
CRITICAL
- PsbtSignatureVerifier: independently verifies every key-path signature in a
PSBT (BIP-340 sig over the BIP-341 sighash, against the tweaked output key).
- OnchainZapSender now rejects a signer that returns a different transaction
than it was asked to sign (byte-compares the unsigned tx) and verifies all
signatures before broadcasting — closes a substitution attack where a
malicious/buggy external signer could redirect funds.
HIGH — break test circularity
- Pin the full BIP-341 keyPathSpending input-0 witness: signing the vector
sighash with the vector tweaked key reproduces the vector signature
byte-for-byte (also pins BIP-340 nonce determinism).
- Pin TaprootAddress.fromPubKey + SegwitAddress.encodeP2TR against all seven
BIP-341 wallet-test-vector P2TR mainnet addresses.
- EsploraBackendTest: JSON-parsing coverage for /tx, /address/{}/utxo, the
mempool.space and blockstream fee formats, and schema-fallback.
MEDIUM
- OnchainZapBuilder filters to confirmed UTXOs by default (allowUnconfirmed
opt-in) and signals BIP-125 RBF (nSequence 0xFFFFFFFD).
- CachingOnchainBackend: TTL-caching decorator (confirmed tx forever,
unconfirmed/tip/fees short TTL) so a feed of onchain zaps doesn't fan out
into one HTTP request per event. Wired in AppModules.
- OnchainZapVerifier computes real confirmation depth from the chain tip and
asserts the backend echoed the requested txid.
- EsploraBackend falls back to the standard Esplora /fee-estimates endpoint
(blockstream.info) when /v1/fees/recommended 404s.
LOW
- BitcoinTransaction.parse caps input/output/witness-item counts to stop a
hostile varint from triggering a giant pre-allocation.
- OnchainZapEventTest: asserts kind:8333 on-the-wire tag structure against the
NIP-BC spec.
- alt tag now includes the amount ("Onchain zap: N sats"), matching the spec
example.
All quartz / commons / androidHostTest suites pass; amethyst compiles.
Wires the Phase A.2 quartz send-side foundation into a working end-to-end
send: build → sign → broadcast → publish kind:8333.
commons/onchain/OnchainZapSender — stateless orchestrator. Loads the
sender's UTXOs, builds the unsigned PSBT via OnchainZapBuilder, signs it
through NostrSigner.signPsbt, finalizes + broadcasts via OnchainBackend,
then publishes the kind:8333 receipt through an injected publish callback.
Per-stage failures surface as OnchainZapSendResult.Failure (with the
broadcast txid preserved when only the receipt publish failed) instead of
throwing; CancellationException always propagates.
Account.sendOnchainZap — thin wrapper binding the account's signer, the
LocalCache.onchainBackend, and signAndComputeBroadcast into the
orchestrator.
amethyst wallet UI:
- OnchainZapSendDialog — collects recipient npub (or a fixed recipient when
launched from a note's zap menu), amount, fee tier (slow/normal/fast from
the backend's fee estimates), and an optional comment; runs the send and
shows progress + success/failure.
- OnchainSection — the Bitcoin card on the wallet screen gains a "Send"
button next to "Copy address" that opens the dialog for a profile zap.
Tests: OnchainZapSenderTest covers the happy path (receipt references the
broadcast txid, recipient, and amount), insufficient-funds failure at the
build stage, broadcast failure (no txid leaked), and publish failure (txid
preserved for retry).
Pending: Phase B (NIP-55 sign_psbt Intent) — external/remote signers still
throw UnsupportedMethodException; note-zap-menu entry point can reuse
OnchainZapSendDialog's recipientPubKey/zappedEvent parameters once wired.
Implements the send half of the onchain zaps plan: a minimal, fund-safe
BIP-174 PSBT pipeline plus the OnchainZapBuilder and `NostrSigner.signPsbt`.
Every cryptographic step is validated against the BIP-341 wallet test
vectors.
nipBCOnchainZaps/psbt/:
- BitcoinIO: little-endian Bitcoin consensus byte reader/writer (u16/u32/u64,
compact-size varint, var-bytes).
- BitcoinTransaction: OutPoint / TxIn / TxOut / transaction model with legacy
and BIP-144 segwit serialization, segwit-aware parsing, and witness-stripped
txid. Validated against the genesis coinbase tx and the BIP-341 9-input
unsigned tx.
- TaprootSigHash: BIP-341 SigMsg + TapSighash tagged hash for key-path spends.
All six base sighash types plus both ANYONECANPAY variants verified against
the seven BIP-341 keyPathSpending vectors.
- Psbt: BIP-174 container subset (global unsigned-tx, per-input witness-utxo /
tap-internal-key / tap-key-sig / sighash-type, per-output tap-internal-key).
Unknown records round-trip verbatim. Typed accessor extensions.
- PsbtSigner: signs the key-path P2TR inputs a given private key controls —
derives the BIP-341 tweaked key, computes the all-inputs sighash, produces a
BIP-340 Schnorr signature. Leaves inputs the key does not control untouched.
- PsbtFinalizer: moves tap-key-sigs into the witness stack, yields a
broadcastable transaction.
nipBCOnchainZaps/build/:
- OnchainZapBuilder: largest-first coin selection + unsigned-PSBT assembly for
a NIP-BC zap. Sender spends from / changes back to the single Taproot address
derived from their Nostr pubkey; recipient output pays the recipient's
derived address. Dust-aware change handling, InsufficientFundsException,
self-zap and dust-amount guards.
nipBCOnchainZaps/taproot/:
- TaprootAddress.tweakSecretKey: BIP-341 taproot_tweak_seckey (key-path-only),
including the odd-y internal-key negation. Validated against the BIP-341
vector's tweakedPrivkey.
Secp256k1Instance: new `privKeyNegate` primitive across commonMain expect +
jvm / android / native actuals.
Signer hierarchy — new `NostrSigner.signPsbt(psbtHex): String`:
- NostrSignerSync / NostrSignerInternal: real implementation.
- NostrSignerWithClientTag: delegates to the wrapped signer.
- NostrSignerRemote (NIP-46) and NostrSignerExternal (NIP-55): throw
SignerExceptions.UnsupportedMethodException — the bunker command and the
Android Intent contract are Phase B, and depend on signer-app support.
Tests: 31 new tests across the psbt/build/signers packages, anchored on the
BIP-341 wallet-test-vectors so the tweak, sighash, signature, and finalized
transaction are all checked against an authoritative source.
Still pending: Phase B (NIP-55 sign_psbt Intent) and Phase D (send UI +
broadcast + publish kind 8333).
Phase C of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md).
Plumbs the Quartz receive foundation from Phase A.1 into the Android app so
incoming kind 8333 zaps show up in note totals and the wallet screen.
Subscription / fetch path (kind 8333 rides existing zap filters, no new
assemblers per the plan):
- FilterRepliesAndReactionsToNotes: OnchainZapEvent.KIND in
RepliesAndReactionsKinds (#e on notes)
- FilterUserProfileZapReceived: OnchainZapEvent.KIND in
UserProfileZapReceiverKinds (#p on profiles)
- UserProfileZapsViewModel: kinds = LnZap + Onchain inline
- FilterNotificationsToPubkey: OnchainZapEvent.KIND in SummaryKinds
- NotificationFeedFilter / NotificationDispatcher: kind 8333 in
NOTIFICATION_KINDS
- FilterMessagesToLiveStream / FilterGoalForLiveActivity: kind 8333
alongside 9735
Display path (Note.zapsAmount fold-in — no UI changes needed downstream):
- commons/model/Note.kt: new `onchainZaps: Map<String, OnchainZapAmount>`
(keyed by txid for `(txid, target)` dedup); `addOnchainZap(txid, sats,
confirmed)` mutator; `updateZapTotal` now also sums confirmed onchain
sats into `zapsAmount`. Pending entries are tracked but excluded from
the aggregate total per the NIP-BC spec.
- commons/model/OnchainZapAmount.kt: new data class for per-tx state.
- LocalCache: new `onchainBackend: OnchainBackend?` slot; new
`consume(OnchainZapEvent)` handler that runs the same sig-verify and
computeReplyTo pipeline as `consume(LnZapEvent)`, then kicks off async
verification on `applicationIOScope`. Self-zaps are rejected
synchronously per the spec. Verification failures, mempool-only txs,
and confirmed txs all route to `Note.addOnchainZap` with the correct
flag. Without a backend wired, events are still cached (so subscriptions
see them) but skip the total contribution.
- LocalCache.computeReplyTo: new `is OnchainZapEvent ->` branch that
enumerates `e`/`a` targets — profile-only zaps return an empty list and
flow through profile zap queries instead.
Wallet UI:
- Wallet screen now renders a "Bitcoin" Card above the existing NIP-47
NWC wallet list. Single card (one onchain wallet per Nostr identity,
derived from the pubkey). Shows the bc1p taproot address + Copy button.
Balance, recent incoming zaps, and tap-to-detail come in later phases.
Settings + wiring:
- AccountSettings: new `onchainEsploraEndpoint: MutableStateFlow<String>`,
default mempool.space, plumbed for a future settings UI.
- AppModules: builds a single shared `EsploraBackend` at app init using
the role-based money-flavored OkHttp client (Tor-aware), installs it on
`LocalCache.onchainBackend` so verification can run.
Secp256k1Instance: new `pubKeyTweakAdd` was added in Phase A.1.
No PSBT, no signing, no broadcasting — send-side (Phase A.2 + D) is still
pending.
The Stepper introduced in this branch references MaterialSymbols.Remove
(U+E15B). Regenerated via tools/material-symbols-subset/subset.sh so the
shipped TTF actually contains the glyph.
Replaces the cramped single-screen layout (header settings + four tabs)
with a hub that uses the same grouped-card pattern as AllSettingsScreen,
plus a dedicated screen per blocked-content category. The tile control
mix is also modernized:
- "Show sensitive content" is now a 3-way SegmentedButton instead of a
free-floating spinner sitting awkwardly next to switches.
- Numeric prefs (report threshold, max hashtags) use a stepper (- / + )
instead of free OutlinedTextFields with no commit affordance.
- The report-threshold tile renders inline under its toggle and dims
when the toggle is off, instead of appearing/disappearing and shifting
layout.
- Each tile is a row with leading icon, title, description, trailing
control - so the spinner/switch/stepper visual rhythm stays consistent.
The hub's "Blocked content" section shows a count badge per category and
routes to its own screen (Routes.BlockedUsers, SpammingUsers, HiddenWords,
MutedThreads). The hidden-words screen now docks the "Add word" field as
a bottom bar so it doesn't compete with the list for vertical space, and
selection mode is scoped per-screen instead of shared across tabs.
nostrnests publishes kind-30312 without a self-`p`-tag for the room
author — they're the implicit host. The lobby card already handles
this (NestJoinCard) but the in-room code did not: the author fell
into the pure-audience branch with role=null AND was missing from
the audio subscription set, so the host appeared as a regular
listener and no sound played.
buildParticipantGrid now takes hostPubkey and synthesizes a virtual
`["p", host, "", "host"]` when absent, so the existing presence-aware
onstage/audience rules apply uniformly — including "host leaves
stage" (onstage=0 → drops to audience, role stays HOST).
NestActivityContent owns the grid now and derives onStageKeys from
it, so the StageGrid render and the MoQ subscription set stay in
lockstep when anyone (host or speaker) steps off stage.
Every TimeAgo composable (Android NoteCompose timestamp, NormalTimeAgo,
ChatTimeAgo, ChatroomHeaderCompose last-message time) and every Desktop
timestamp Text (NoteCard, NotificationsScreen, ChatPane, ConversationListPane)
is now clickable and toggles to a scale-adjusted absolute date/time:
- same day → time only (e.g. "14:32"), locale-aware
- same year → "MMM dd, HH:mm"
- older → "MMM dd, yyyy"
State is hoisted per-call site via rememberSaveable so the toggle survives
scroll-induced disposal in lazy lists. Desktop call sites share a small
ToggleableTimeAgoText wrapper; Android keeps its existing composable shapes
and just gains a clickable modifier + state.
https://claude.ai/code/session_01AuPon9VQeRfKV1BTVQuKGC
BUD-01 defines a Blossom URL as `<server>/<sha256>[.<ext>]` — the blob hash is
always the last path segment. Walking the path right-to-left for any hex
match was too permissive: a non-Blossom URL like `https://example.com/<sha>/avatar.jpg`
(sha appears in an intermediate segment) would get incorrectly bridged.
Both parsers now look at the last path segment only and skip the URL entirely
when it isn't a sha256. The earlier hex-prefix case (share.yabu.me's
`<cache-prefix>/<blob>.ext`) still works because the blob is still the last
segment; the prefix flows into `xs` via the existing `buildServerBase` /
`extractServerBase` logic. Adds negative tests covering the
sha-in-non-last-segment case in both modules.
Building the URL from extracted prefix/blob constants could mask a parser bug
that splits the path the same way the test constructs it. Use the full URL
from the bug report as a single literal so the test only agrees with the
parser if the parser actually parses the URL correctly.
CDNs like share.yabu.me serve blobs under `<cache-prefix-sha>/<blob-sha>.<ext>`
where both path segments are 64-char hex. The bridge previously locked onto the
first match (the cache prefix), dropping the blob segment from `xs` and asking
the local cache for a non-existent blob. Walking the path right-to-left makes
the rightmost sha — the one that carries the file extension — win, while the
prefix segments stay in `xs` so the cache can fetch upstream on miss.
Behaviour is unchanged when the path has a single sha; tests cover the new
two-hash layout in both the Coil-model path and the OkHttp interceptor path.
fix(cast): handle every Chromecast session terminal state and HLS mime hint
fix(cast): repair DLNA Res ctor and add play-only protocol toggle
fix(cast): backfill Cast button for accounts with pre-existing video settings
fix(cast): include cast glyphs in symbol font subset and add diagnostic logs
fix(cast): wire jUPnP's required Jetty deps and survive dialog dismissal
fix(cast): add jetty-client so jUPnP can issue UPnP control requests
Cleaner conceptual model: the parsed state object is the place where
all derived facts about the content live, so `isMarkdown` becomes a
field on `RichTextViewerState` (populated by `RichTextParser.parseText`
using the same cheap heuristic).
`RichTextViewer` now calls `CachedRichTextParser.parseText` once at the
top and dispatches on `state.isMarkdown` instead of running a separate
scan. `CachedRichTextParser.isMarkdown(content)` and its dedicated
`isMarkdownCache` go away — the single `richTextCache` carries the
decision alongside the parsed segments.
Inner callers (`DisplaySecretEmoji`, `MultiSetCompose` reaction
preview, `DisplayUncitedHashtags`) are unaffected: they continue to
receive a fully-parsed state with all segments populated.
Three targeted fixes for the jitter that shows up as soon as a feed
contains nested NoteCompose (reposts, quotes, BechLink previews):
* `calculateBackgroundColor`: only schedule the 5s "new item" fade
LaunchedEffect when the item is actually new and tracks read state.
Inner notes pass `routeForLastRead = null` and were parking a
coroutine for 5s per item, every scroll. Also drop a per-call
`Color.copy(alpha = 0f)` allocation in favor of `Color.Transparent`.
* `EventObservers` + `WatchBlockAndReport`: wrap the `StateFlow`
lookups in `remember(note)` so each recomposition of the same note
doesn't re-resolve `note.flow().…stateFlow` (and, in
`WatchBlockAndReport`, hit the synchronized LRU lookup) on every
pass. Affects observeNote / Replies / Reactions / Zaps / Reposts /
Ots / Edits and the per-item hidden-flow check.
* `produceCachedState` / `produceCachedStateAsync`: short-circuit on
cache hits. The previous `produceState` body always launched a
coroutine even when the LRU already had the value; this is the
common path for BechLink previews and draft notes during scroll.
Now we read the cache synchronously inside `remember`, and only
launch a `LaunchedEffect` for the actual miss.
No behavior change.
Adapt cherry-picked PR commits to current main where:
- ElectrumxServer.trustAllCerts was renamed to usePinnedTrustStore
- IRequestListener was renamed to SubscriptionListener
Eliminates code duplication between Android and Desktop:
- Move NamecoinSettings to commons/model/nip05DnsIdentifiers/namecoin/
(with @Serializable and @Stable annotations)
- Extract NamecoinResolveState sealed class to its own file in commons
- Move NamecoinSettingsTest to commons (shared by both platforms)
- Replace Android NamecoinSettings.kt with a typealias to commons
- Update all imports in Desktop and Android modules
- Remove debug println statements from ImportFollowListDialog and Main
The bridge was emitting `xs=https://cdn.nostr.build` for URLs like
`https://cdn.nostr.build/i/<sha>.jpg`, dropping the `/i/` prefix. The
local cache appends `/<sha>` to the xs server hint per spec, so it would
try to fetch `https://cdn.nostr.build/<sha>` — a 404 on nostr.build's
non-Blossom CDN scheme.
Anchor the server-base extraction on the slash immediately preceding the
sha-bearing path segment so we emit `xs=https://cdn.nostr.build/i`.
The cache then appends `/<sha>` and reaches the original blob.
Affects all three bridge entry points:
- MediaUrlContent.toCoilModel (note media)
- bridgeProfilePictureUrl (profile pictures)
- LocalBlossomCacheRedirectInterceptor (everything else via OkHttp)
Flat Blossom paths (`<host>/<sha>`) keep emitting `xs=<host>` unchanged.
bridgeProfilePictureUrl previously returned a `blossom:` URI, but
profile pictures go through ProfilePictureFetcher (Coil routes by type
on `ProfilePictureUrl`), which feeds the URL straight to NetworkFetcher
and never sees BlossomFetcher. NetworkFetcher then tries to issue an
HTTP request against the `blossom:` scheme and fails — silently — so
profile pictures from Blossom servers stopped loading.
Return a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pub>`
URL instead. The OkHttp request goes to the local cache normally; the
new redirect interceptor sees it's already on localhost and passes
through unchanged.
- `as=<authorPubKey>`: when converting plain http(s) imeta URLs into
`blossom:` URIs we now include the note author's pubkey, letting the
local cache consult their kind:10063 BUD-03 server list on miss.
Plumbed via RichTextParser/CachedRichTextParser/RichTextViewer/
ExpandableRichTextViewer/TranslatableRichTextViewer.
- Profile-pictures-only mode: a new per-account toggle that restricts
the bridge to profile pictures (RobohashFallbackAsyncImage). When on,
feed images and videos skip the bridge entirely. Profile pictures
recover their sha256 from the URL path when present (covers
Blossom-hosted avatars like https://nostr.build/i/<sha>.jpg).
Several user-visible error messages on Desktop are rendered with plain
`Text` composables, which means they can't be selected or copied. That
makes it awkward to share an error in a bug report or paste a hex error
string into a search.
Wrap the error text in `SelectionContainer` at the canonical sites:
- `commons.ui.components.LoadingState`: wrap the description in
`EmptyState` and the message in `ErrorState`. `EmptyState` is reused
as the in-feed error renderer (e.g. 'Error loading feed' with the
underlying error in `description`), so this covers feed/loading
errors across screens that use these helpers.
- `ComposeNoteDialog`: wrap the validation error and the upload error
in the compose-note dialog.
- `auth/LoginCard` (Nostr Connect): wrap the connection error.
- `auth/KeyInputField`: wrap the supporting-text error so the inline
message under the nsec input field can be copied.
No visual changes \u2014 `SelectionContainer` does not affect layout or
styling. Selection works on Compose Desktop (mouse drag) and on Android
(long-press) without further changes.
Adds support for the local-blossom-cache spec
(https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md):
when http://127.0.0.1:24242 responds 2xx to HEAD /, image and video
fetches are routed through it with xs= upstream hints so it can proxy
on miss. Toggle defaults ON per account; disable from Media Servers
settings.
Covers both blossom:// URIs and plain http(s) URLs that carry an imeta
sha256, by rewriting the latter to a synthetic blossom:<sha>?xs=<host>
URI before handing it to Coil/ExoPlayer.
Split the previously global `AudioFormat.CHANNELS = 1` into a
`DEFAULT_CHANNELS` constant + per-call-site `channelCount` parameters
so a single broadcast can advertise stereo Opus without forcing every
mono call site to grow a new argument. Generalises the catalog factory
to `MoqLiteHangCatalog.opus48k(name, channels)` with memoised JSON
bytes per shape, threads a new `AudioBroadcastConfig(channelCount)`
through `connectNestsSpeaker` / `connectReconnectingNestsSpeaker` /
`MoqLiteNestsSpeaker`, and adds a `channelCount` parameter to
`MediaCodecOpusEncoder`. Production behaviour is unchanged for
mono callers (the new config defaults to mono); the listener side
already discovers the channel count from the catalog via
`NestViewModel.awaitAudioPipelineConfig`. No test or wire changes.
Phase 1 of `nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md`.
The hang-interop test scaffolding (`HangInteropTest`, `runSpeakerToHangListen`,
Rust `hang-listen` / `hang-publish`, `JvmOpusEncoder`) doesn't exist on
this branch yet, so the I4 forward + reverse scenarios are deferred
until the parent T16 plan lands.
https://claude.ai/code/session_01EqJEADzH9yjSuoP5L9js8i
NestViewModel.connect() launches an infinite cliff-detector loop in
viewModelScope (`while(true) { delay(...) }`). The existing tests in
NestViewModelTest call connect() but never disconnect/onCleared, so
that loop stays alive. Under runTest's virtual scheduler each delay
returns instantly, the loop spins millions of iterations per second of
real time, and runTest never reaches the idle state — every test
wedges until the per-test deadline (60 s × 17 tests => :commons:jvmTest
hangs for ~17 minutes).
Wrap each test body with runVmTest, which runs the body inside a
try/finally that calls disconnect() on every VM created by
newViewModel before runTest tries to drain. teardown() cancels
cliffDetectorJob, the scheduler becomes idle, and the test returns.
A 10 s runTest timeout is the safety net — healthy tests now finish
in milliseconds, and tripping the timeout signals a new viewModelScope
coroutine that needs its own teardown call.
Verified: :commons:jvmTest --rerun-tasks now completes in 52 s
(409 tests, 0 failures); NestViewModelTest's 17 tests run in 0.083 s
total versus the previous indefinite hang.
https://claude.ai/code/session_01R9wUxnRJrr299W8TzRyFs7
The full-screen Nest stage's green speaking indicator had two issues:
1. **Cropping at the top.** The glow halo (drawBehind, up to MAX_GLOW_RADIUS
past the avatar) and detached outer ring rendered onto the avatar's own
draw layer, which only had the avatar's bounds (75dp circle). The
surrounding stage Surface (RoundedCornerShape(20dp)) clipped the topmost
pixels of the first row's glow. Wrap the avatar+badges in an outer Box
that reserves [ringPadding] (max of MAX_GLOW_RADIUS / OUTER_RING_GAP +
OUTER_RING_MAX_WIDTH) and move the drawBehind there, drawing relative to
the inner avatar circle. Badge corner-alignment stays on the inner Box
so role/hand/mic badges still anchor to the avatar.
2. **Lit up on mic-on, not on actual speech.** `onSpeakerActivity` was
called once per MoQ object received — i.e. every ~20 ms while the mic
was open, regardless of whether the frame contained voice, silence, or
room noise. That meant the green ring was effectively a "mic is on"
indicator. Split the heartbeat (kept in `onSpeakerActivity`, which
still drives the cliff detector and clears the connecting overlay)
from the speaking detector (now in `onAudioLevel`, gated on the
decoded peak amplitude clearing SPEAKING_LEVEL_THRESHOLD = 0.06,
~-24 dBFS). The existing 250 ms expiry job gives natural hysteresis
between syllables.
Production trace showed a single failed recycle (relay accepted SUBSCRIBE
but never opened uni-streams) leaving the user with ~22s of dead air —
the 30s cooldown blocked any retry while audio never recovered, then
they'd give up. The cooldown couldn't tell "recovered then re-stalled"
(safe to wait) from "recycle did nothing" (need to retry sooner).
Replace with a per-attempt backoff: 0 → 5s → 12s → 24s → 30s cap. Reset
to attempt 0 on any real frame after the recycle, so a recover-then-
restall always re-fires immediately. Cumulative wall-clock to 4 recycles
goes from "4 in 30s" (the moq-rs wedge case) to "4 in 41s", protecting
the relay while letting the typical single-failure case retry inside 5s.
Also stop overwriting `lastFrameAt[pubkey]` on recycle: keeping the real
last-frame timestamp is what lets the predicate distinguish the two
cases via `lastFrameAt > lastRecycleAt`.
Hardening on the leave path:
- mark `closed` @Volatile so the cliff-detector finally block reliably
observes the value set by `leave()` / `onCleared()` (visible in the
trace as `cliff-detector EXITED ... closed=false` after a Leave press).
- replace `runCatching { recycleSession() }` with explicit
`catch (CancellationException)` re-throw so a Leave during recycle
exits promptly rather than running one extra loop iteration.
https://claude.ai/code/session_015euGg6AERTRGj7HYysKnLV
Audit-9: NestViewModel.kt is 2112 lines and growing, ~1000 of which
are subscription-lifecycle state machine concerns intertwined with
the room-level public API. Pulling out the full
`NestSubscriptionManager` is a multi-week refactor with subtle
coupling (catalog readiness affects spinner state, mute has effective
+ per-speaker flavours, expiry jobs need the parent scope) and
warrants its own focused review pass.
For now, take the small tractable subset:
- Extract `ActiveSubscription` from a `private inner class` in
NestViewModel to its own file as `internal class
ActiveSubscription` in the same package. The class is purely
state-holding (handle, roomPlayer, player, isPlaying); zero VM
coupling beyond the slot map's value type. Same visibility for
NestViewModel callers; one less private helper class buried
1500 lines into NestViewModel.kt.
- File `commons/plans/2026-05-06-nest-subscription-manager-extraction.md`
documents the deferred full extraction: target shape, state
that moves, methods that move, what stays in VM, why deferred,
when to land. Picks up the next person who opens NestViewModel.kt
rather than leaving them to re-derive the rationale.
Audit-14: T11.3 (stream priority for moq-lite group uni streams) was
deferred from the T11 commit (drop bestEffort=true) because the
:quic-side change touches the writer's hot path and warrants its own
review pass. New file `nestsClient/plans/2026-05-06-stream-priority-followup.md`
spells out:
- Why: bestEffort=true was incidentally biasing drain order toward
newer groups; without it, the writer's round-robin order can
serve a stale group when a fresh one is more useful.
- Target shape: `QuicStream.priority` field, sortedByDescending
in the writer's send-frame loop, `WebTransportWriteStream.setPriority`
pass-through, `MoqLiteSession.openGroupStream` calls
setPriority(sequence). With code sketches.
- Test: pin iteration order via the writer's emitted-frames tape.
- Risk profile: starvation, perf cost of per-pass sort, compat.
- When to land: after interop verification stabilises.
No code changes in this commit beyond the ActiveSubscription move.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
G4: Audit-9 (catalog-driven decoder reconfig) plumbed numberOfChannels
end-to-end but left sampleRate hardcoded at AudioFormat.SAMPLE_RATE_HZ
in MediaCodecOpusDecoder.buildOpusIdHeader / buildFormat and in
AudioTrackPlayer's MinBufferSize / 250 ms target / setSampleRate
calls. For Opus this is benign in practice — Codec2 always emits
48 kHz PCM regardless of OpusHead inputSampleRate — but hardcoding
the constant means a future codec or container variant whose
decoder DOES respect input sample rate would mis-clock playback.
And the OpusHead identification header should match what the
catalog declares either way.
Thread sampleRate alongside channelCount through every layer:
- MediaCodecOpusDecoder constructor takes
`sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ`. buildFormat
and buildOpusIdHeader both take it as a parameter.
- AudioTrackPlayer constructor takes the same. Used in
AudioTrack.getMinBufferSize, the 250 ms-equivalent target
bytes calculation (`(sampleRate / 4) * BYTES_PER_SAMPLE *
channelCount`), AndroidAudioFormat.Builder.setSampleRate, and
the diagnostic log.
- decoderFactory and playerFactory in NestViewModel become
`(channelCount: Int, sampleRate: Int) -> ...`.
- awaitDecoderChannelCount → awaitAudioPipelineConfig, returning
a private `AudioPipelineConfig(channelCount, sampleRate)`
struct so openSubscription handles both fields uniformly.
`sampleRate` falls back to AudioFormat.SAMPLE_RATE_HZ on
timeout / non-positive declaration with a warning log.
- NestViewModelFactory + NestViewModelTest updated to the
two-arg factory shape.
G5: documented the SUBSCRIBE_BUFFER safety budget on
CATALOG_AWAIT_TIMEOUT_MS's kdoc. With the current 500 ms timeout +
SUBSCRIBE_BUFFER = 64-frame DROP_OLDEST flow + 50 fps Opus, at
most 25 frames buffer during the wait — leaving ≥ 39 frames of
margin (≈ 780 ms) before the oldest frame would be evicted. Even
at the production framesPerGroup = 50 (1 group/sec) cadence this
never trips during normal startup. No code change; just pinning
the rationale so a future timeout bump is checked against the
buffer size.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
Two audit-pass gaps:
G1 — `MoqLiteNestsListener.Companion.stripLegacyTimestampPrefix`
is the entire receive-side wire-format converter for audio frames
(every web speaker's Opus packet flows through it) and had zero
unit tests. A regression here is invisible to users — silent decode
failure of every web speaker — and to interop tests, because both
sides agree on the same bug. New StripLegacyTimestampPrefixTest pins:
- all four QUIC varint-length tiers (1 / 2 / 4 / 8 bytes), one
test per tier with a representative timestamp value plus a
tag-byte assertion so the test fails loudly if Varint.encode's
tier-selection changes.
- empty-payload and malformed-payload fast paths (returns
payload unchanged, same instance — no allocation).
- round-trip against `NestMoqLiteBroadcaster`'s exact wire
shape (`varint(timestamp_us) + raw_opus_packet`) across five
timestamp values spanning all four tiers.
G6 — `RoomSpeakerCatalog.primaryAudio()` previously returned
`audio.renditions.values.firstOrNull()` with no container filter.
A future publisher that emits CMAF before legacy in iteration
order would surface the CMAF rendition, and our decoder pipeline
would then feed CMAF MOOF/MDAT bytes to its legacy
`varint(ts)+opus` parser and decode garbage. Filter to
`container.kind == Container.LEGACY_KIND` so:
- mixed CMAF+legacy publishers always surface the legacy entry
regardless of map iteration order
- CMAF-only publishers correctly return null (caller falls
back to "unknown codec / no audio")
- publishers that omit `container` entirely also return null —
treating "no container declared" as "legacy by default" would
silently mis-decode a CMAF-only publisher that just forgot
to spell out `container.kind`
LEGACY_KIND const lives on `Container.Companion` so call sites
share one canonical spelling. Three new test cases pin the new
filter behaviour (mixed iteration order, CMAF-only, missing
container). The pre-existing `toleratesUnknownKeys` test is
updated to declare a legacy container — the unknown-key
tolerance we're checking is about unrecognised siblings, not
container-presence.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
The reissuing-subscribe wrapper splices fresh-publisher frames into the
same `SharedFlow` that NestPlayer consumes, so across a JWT-refresh
hot-swap (speaker side) or a cliff-detector recycle (listener side)
the decoder receives a discontinuous frame stream while keeping its
Opus predictor state. Result: an audible warble at every publisher
cycle. Single-stream tests don't catch it because they never present
two distinct publishers.
Detect the boundary via `MoqObject.trackAlias` — the underlying
`MoqLiteSession.subscribe` assigns a fresh subscribeId per SUBSCRIBE,
which the listener wrapper surfaces verbatim as `trackAlias` on every
emitted object. A change between consecutive objects = new publisher.
Add an optional `decoderFactory: (() -> OpusDecoder)?` to NestPlayer.
When non-null, NestPlayer tracks `lastTrackAlias` and on a change
releases the current decoder and rebuilds via the factory. The
default `null` preserves the legacy single-decoder behaviour so
existing tests / callers that don't care about boundaries stand
unchanged.
NestViewModel.openSubscription wires the factory: a closure capturing
the catalog-derived `channelCount` so a rebuild reuses the SAME
channel layout — without that capture, a rebuild after a stereo-
publisher cycle would default to mono and silently downmix.
Two new NestPlayerTest cases pin the behaviour:
- `publisher_boundary_rebuilds_decoder_when_factory_provided`:
factory invoked twice (initial + boundary), first decoder
released on boundary, second released on stop.
- `publisher_boundary_no_op_when_factory_is_null`: legacy path
holds onto the same decoder across trackAlias changes (unchanged
semantics).
NestPlayer's first constructor parameter is now `initialDecoder`
(was `decoder`); positional-arg call sites are unchanged but the
named-arg call sites in the test suite are updated accordingly.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
Web speakers (kixelated/hang reference) emit stereo Opus when the
user's AudioContext picks a stereo input — and our catalog now lets
us know that ahead of decoder construction. Previously the decoder
+ AudioTrack were both hardcoded to mono via AudioFormat.CHANNELS, so
a stereo web publisher's frames either threw on the Opus TOC byte
mismatch, decoded with downmix artifacts, or output left-channel-only
depending on the device's MediaCodec implementation.
Wire the catalog-discovered numberOfChannels through:
- MediaCodecOpusDecoder accepts `channelCount: Int = 1`. Drives
MediaFormat.createAudioFormat's channel count, the OpusHead CSD-0
channel byte, and the per-frame output buffer size (stereo frame =
2× shorts vs mono). Validates 1..2 — RFC 7845 mapping family 0
covers both mono and stereo without an explicit channel mapping
table; multichannel needs family 1 which we don't support.
- AudioTrackPlayer accepts `channelCount: Int = 1`. Drives the
AudioTrack channel mask (CHANNEL_OUT_MONO vs CHANNEL_OUT_STEREO)
and the 250 ms wall-clock buffer-size target (stereo doubles bytes
per sample).
- NestViewModel.openSubscription now starts the catalog fetch
BEFORE waiting for it, then awaits `_speakerCatalogs[pubkey]` for
CATALOG_AWAIT_TIMEOUT_MS (500 ms) before constructing the decoder
+ player. With the speaker-side emit-on-subscribe hook in place
(3417e44), the catalog frame is on the wire within one round-trip
of the SUBSCRIBE_OK, so the wait typically resolves in tens of ms.
Falls back to mono if the catalog never arrives — covers legacy
publishers that don't emit a catalog and the failure-mode where
the relay never forwards the catalog group.
- decoderFactory + playerFactory signatures change from
`() -> X` to `(channelCount: Int) -> X`. Two call sites updated:
NestViewModelFactory (production) and NestViewModelTest's
newViewModel helper.
Speaker side stays mono-only — our encoder is fixed at
AudioFormat.CHANNELS=1 and the catalog we publish declares
numberOfChannels=1. This change is exclusively about handling
incoming audio from publishers that don't share that constraint.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
Two audit follow-ups for receive-path interop with kixelated/moq web
publishers (NestsUI v2's reference encoder):
1. ROOM_PLAYER_PREROLL_FRAMES 5 → 10 (≈100 ms → ≈200 ms). The web
reference encoder uses `groupDuration: 100ms` (5 Opus frames per
group) and stacks another 100–200 ms of jitter on top via the
relay's outbound forward queue under residential network
conditions. 100 ms of preroll reliably underran on web speakers,
producing audible clicks between adjacent groups; 200 ms covers
the observed envelope while keeping perceived join latency well
below the wire latency a listener already sees.
2. Per-subscription consecutive Opus decode-error counter in
NestViewModel.openSubscription. Single-frame decode errors are
normal noise — Opus PLC papers over a single bad frame and the
prior code silently swallowed them — but a structural mismatch
(wrong wire format, missing legacy-container varint strip,
codec/channel-count mismatch, corrupted Opus) fails *every* frame
in a row. The previous behaviour made exactly that class of bug
invisible: no audio, no log, no UI signal.
New logic increments a per-subscription AtomicLong on each
DecoderError / EncoderError, resets on every successful onLevel
tick. When the streak hits DECODE_ERROR_STREAK_LOG_THRESHOLD
(50 frames ≈ 1 s of solid failures) we log ONE conspicuous warning
tagged `NestRx` with the underlying throwable's stacktrace.
Logged once per crossing (exact-equality, not modulus) so a stuck
stream produces a single attention-grabbing line instead of a
50 Hz flood.
Picks the non-lambda Log.w overload deliberately so the throwable
stacktrace survives — fires exactly once per stuck-stream streak,
so the unconditional message-string build is fine.
Both changes are additive — no callers changed, no public API
touched. The stale `ROOM_PLAYER_PREROLL_FRAMES = 5` reference in
NestMoqLiteBroadcaster's framesPerGroup kdoc is updated to match.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
hang.js's encoder publishes a `jitter` field (ms) in every audio
rendition; the watcher uses it to size its jitter buffer. Without it
the watcher falls back to a built-in default — works in practice today
but means our publishes deviate from the canonical hang shape and the
deviation could surface as audible buffer-sizing differences on a
future watcher revision that takes the field as required.
Add `jitter: 20` to MoqLiteHangCatalog.AudioRendition (matching the
Opus frame cadence: 960 samples at 48 kHz = 20 ms; same value hang.js
hard-codes at `js/publish/src/audio/encoder.ts:#runConfig`). Updates
the byte-exact MoqLiteHangCatalogTest assertion and the parallel
inline-literal in RoomSpeakerCatalogTest so both sides stay pinned.
The commons-side `RoomSpeakerCatalog` parser doesn't surface the new
field — `JsonMapper`'s `ignoreUnknownKeys = true` lets the watcher
ingest it silently. Adding a typed `jitter` field there is a
forward-compat task; not needed for interop with hang today.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
The two SPEAKER_CATALOG_JSON call sites (MoqLiteNestsSpeaker for the
non-reconnecting path, ReconnectingNestsSpeaker for the JWT-refresh hot-
swap path) both built the manifest as a `\\` + `\"` + `+`-concatenated
string literal. One missed escape and the watcher's parser fails
silently — the broadcast becomes invisible to hang.js with no
indication of why. The two literals had also drifted independently in
review (same content today; nothing prevents drift tomorrow).
Replace both with `MoqLiteHangCatalog.opusMono48k(track).encodeJsonBytes()`,
a Serializable data class that encodes via kotlinx.serialization with
`encodeDefaults = false` + `explicitNulls = false` pinned (so a future
global default-flip can't surface `"bitrate": null` on hang.js's
parser).
The new type lives in :nestsClient because :nestsClient does not depend
on :commons and the parser-side `RoomSpeakerCatalog` (in :commons)
isn't reachable from the publish path. The two classes target the same
wire shape independently; a byte-exact assertion in the new
MoqLiteHangCatalogTest plus the existing RoomSpeakerCatalogTest's
inline-literal round-trip together pin both sides — desync on either
end fails one of the two tests immediately.
https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
- UserSearchEngine in commons (reusable, platform-agnostic) with RelayUserSearchDelegate interface
- DesktopRelayUserSearchDelegate using direct relay manager subscribe
- LocalDesktopCache + LocalRelayManager composition locals
- Provide cache/relay at App() level so AppDrawer and dialogs can access them
- FeedsDrawerTab reads LocalDesktopCache for author display names
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>