`AudioTrackPlayer` sized the AudioTrack ring at `max(minBuffer * 16,
250 ms)`. The intent (per the kdoc) was ~250 ms of jitter slack with
the device's `getMinBufferSize` as a floor — but the `* 16` multiplier
silently dominated on common devices. A Pixel reports `minBuffer ≈
40 ms` for 48 kHz mono; `× 16 = 640 ms`. Add the 200 ms preroll in
`NestPlayer` and decoded PCM sat in the ring for ~800 ms – 1 s before
the speaker played it.
Two-phone testing confirmed the symptom: the speaking-now ring lit up
~1 s before the listener's audio. The ring fires on
`onLevel(peakAmplitude(pcm))` in `NestPlayer.play()` immediately after
`decoder.decode()` succeeds — before `player.enqueue(pcm)` — so the
ring is real-time and the delay was entirely between `enqueue` and
the speaker. The web (NostrNests) listener uses an `AudioWorklet`
with no equivalent ring buffer, so its audio aligned with the wire.
Drop the `* 16` so the formula matches the intent: `max(minBuffer,
250 ms)`. On devices whose floor is below 250 ms (the common case)
the 250 ms target wins; on devices whose floor is above 250 ms (rare)
we still respect the device-reported minimum. Steady-state ear-to-
speaker delay drops from ~1 s to ~450 ms (250 ms ring + 200 ms preroll).
Removes the four trace points + debug-build auto-enable hook added in
the prior commit on this branch. They served their purpose locally
(confirmed the ~1s startup lag lives between pcm_decoded and
pcm_enqueued, i.e. AudioTrack ring backpressure), and don't need to
land in main.
Adds four trace points on the listener pipeline so an end-to-end JSONL
capture (`adb logcat -s NestsTraceJsonl:D -v raw > nest-trace.jsonl`)
shows wall-clock latency at every stage:
frame_received (MoqLiteSession.drainOneGroup, after trySend)
frame_object_mapped (MoqLiteNestsListener.wrapSubscription map)
pcm_decoded (NestPlayer.play, after decoder.decode)
pcm_enqueued (NestPlayer.play, after player.enqueue)
Each event carries (sub_id, group_seq, ...) or (track_alias, obj_id, ...)
so the same frame can be followed through all four stages. Deltas
isolate which segment owns the observed startup-only ~1s lag:
subscribe_send → first frame_received : relay forward latency
frame_received → pcm_decoded : Opus decode wall-time
pcm_decoded → pcm_enqueued : AudioTrack ring backpressure
pcm_enqueued → next pcm_decoded : decode-loop scheduling
Tracing auto-enables on debug builds when `NestActivity` opens and
disables on `onDestroy` so release apps pay only the field-load
guard inside `NestsTrace.emit`. Capture instructions are inline at
the call site in `NestActivity.onCreate`.
viewedTab was a MutableState read inside RenderScreen at
visibleTabs.indexOf(viewedTab), so every pager swipe — which writes to
viewedTab from snapshotFlow — invalidated the enclosing restart group
even though rememberPagerState's initialPage is never re-applied after
the first composition.
Move the tracker to a plain holder class (ViewedTabRef). Reads inside
the key(visibleTabs) block no longer subscribe to snapshot changes, so
swipes only recompose the tab row / pager content as intended.
- Gate WatchLifecycleAndUpdateModel(appRecommendations) on the
showProfileAppRecommendations toggle so the viewmodel no longer
refreshes when the section is hidden.
- Thread loadFollowers / loadZapsReceived through UserProfileQueryState
and the matching sub-assemblers. When the user hides those tabs,
UserProfileFilterAssembler stops emitting their relay filters and
the live REQs drop.
- Re-pin the profile pager to the tab the user was viewing whenever
the visible-tabs list changes, using key(visibleTabs) +
rememberPagerState(initialPage = ...) and a snapshotFlow that tracks
the currently-viewed ProfileTab. Prevents the pager from silently
shifting to a different tab when one is toggled off in settings.
New "Profile UI" settings page lets users toggle which profile-screen
sections are loaded and displayed. All toggles default to on.
Toggles:
- Profile Badges (NIP-58 badges row)
- App Recommendations (apps row in profile header)
- Zap Received Feed (received zaps tab)
- Followers Feed (followers tab)
Tabs are filtered from the profile pager so disabled feeds no longer
allocate a page or fire their subscriptions.
Three related bugs when a host removes a speaker from the stage on
nostrnests by editing the kind-30312 to drop their p-tag:
1. NestRoomFilterAssembler didn't subscribe to the kind-30312 itself
while in-room — only chat/presence/reactions (#a) and admin
commands. Once joined, the room event was frozen on whatever
version loaded the screen, so demotions never propagated. Added
a per-relay filter on `kinds=[30312], authors=[host], #d=[dTag]`.
2. Even if the demotion did propagate, `BroadcastHandle` kept
running. Only the manual Leave Stage button called
`stopBroadcast()`. Added a LaunchedEffect that tears down the
broadcast when the local user falls off `participantGrid.onStage`
while still publishing — covers both demote-by-host and
leave-stage-on-another-client.
3. The auto-stop reliably exercised a pre-existing
NestForegroundService bug: when `startListening` was called to
demote from mic+media to media-only, `intent.action == null`
fell through to `else -> promoted`, keeping mic=true. The service
then asked startForeground for FOREGROUND_SERVICE_TYPE_MICROPHONE
after the mic had been released, threw SecurityException on
Android 14+, runCatching swallowed it, stopSelf ran without
startForeground → ForegroundServiceDidNotStartInTimeException.
The explicit-demote branch now returns false; only intent==null
(OS sticky-restart) preserves the prior promoted state.
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.
Each binary setting (auto-create drafts, AI writing help, tracked
broadcasts) becomes a single-tap Switch instead of a two-tap
Always/Never spinner, and a shared BooleanSwitchRow helper removes the
per-toggle boilerplate.
https://claude.ai/code/session_019b6cF7Ukkv7GL9A3m6bXym
Audit follow-up — the toggle behaviour now lives in a single
`ToggleableTimeAgoText` core in `ui/note/elements/TimeAgo.kt`. `TimeAgo`,
`NormalTimeAgo`, `ChatTimeAgo`, and `ChatroomHeaderCompose.TimeAgo` are
thin wrappers that pick a `TimeAgoStyle` (Dotted / Short) and pass
colour/font params; no per-site duplication of state + clickable +
derivedStateOf.
Performance fixes that matter for a feed with hundreds of timestamps:
- `rememberSaveable` → `remember`. Persisting a transient peek-toggle to
the SavedStateRegistry for every visible+scrolled-past note was pure
memory bloat. Recycling now resets to relative, which is the expected
behaviour for a transient inspect action.
- The relative-mode `derivedStateOf` lambda reads `nowState.value` only
when displaying a relative time. An item the user has frozen to its
absolute date no longer re-evaluates every 30-second tick.
- Core composable takes only stable primitive params (Long, Color,
TextUnit, TextOverflow, enum) so Compose can skip it entirely when
inputs don't change.
- Desktop wrapper dropped the redundant `derivedStateOf`: its formatter
reads no State, so derivedStateOf had nothing to observe.
https://claude.ai/code/session_01AuPon9VQeRfKV1BTVQuKGC
- sendDraftSync no longer short-circuits the delete-on-blank path when
automatic draft creation is disabled. Clearing a composer now always
cleans up any existing draft; the setting only suppresses creation.
- Physically move AiWritingHelpChoice and TrackedBroadcastsChoice from
AppSettingsScreen.kt into ComposeSettingsScreen.kt so the composable
definitions live alongside the screen that hosts them.
https://claude.ai/code/session_019b6cF7Ukkv7GL9A3m6bXym
P1 (header protection — eliminate remaining HP allocations):
Add HeaderProtection.maskInto(hpKey, src, srcOffset, scratch16, dstMask)
that writes the 5-byte mask into a caller-owned buffer using a
caller-owned 16-byte AES scratch. Add `hpScratch` (16 bytes) and
`hpMask` (5 bytes) to PacketProtection — same per-direction
single-threaded analysis as nonceScratch from the prior commit. Thread
both buffers through Short/LongHeaderPacket build / parseAndDecrypt /
peekKeyPhase as optional parameters paired with nonceScratch; production
call sites in QuicConnectionWriter / QuicConnectionParser pass
`proto.hpScratch` + `proto.hpMask`. With both this commit and 09b28b8d
the AES-128-GCM hot path now allocates ZERO bytes for HP + nonce per
packet (down from 16-byte sample slice + 16-byte AES output + 5-byte
mask + 12-byte nonce = 49 bytes/pkt).
ChaCha20HeaderProtection.maskInto routes through the same shape but
still allocates a fresh 5-byte ciphertext + 12-byte nonce slice
internally — Quartz's `ChaCha20Core.chaCha20Xor` SPI takes a
standalone nonce ByteArray. Documented as a future cleanup; AES is
the dominant path in production.
A2 (TlsRunningSha256 — lazy fallback accumulator):
Probe `digest.clone()` ONCE at construction. Conscrypt's clone
support is a build-time property (native bridge presence), not state-
dependent, so a single probe is a reliable signal. Devices where the
probe succeeds skip the byte accumulator entirely (zero overhead);
devices where it fails get the byte-accumulator path from the very
first update so the first snapshot already has the complete
transcript. Replaces the previous "always accumulate" shape that
wasted a few KB per handshake on every device, including the
overwhelming majority that support clone.
All quic JVM unit tests pass; spotless applied.
https://claude.ai/code/session_01EBHtGLy5o7FUR5qfcpHUUx
Addresses three issues found in the self-audit of commit fbacef1f:
S2 clock bug (CRITICAL): the previous fix compared TlsResumptionState.
issuedAtMillis (wallclock — stored via System.currentTimeMillis() in
TlsClient) against QuicConnection.nowMillis() (monotonic — anchored at
construction). On any new connection the monotonic clock starts near
zero, so `(nowMillis() - issuedAtMillis)` produced a large negative
value, the coerceAtLeast(0L) clamped it to 0, and the expiry check
never fired. Add a dedicated `epochMillis: () -> Long` parameter on
QuicConnection (default System.currentTimeMillis()) and use that for
ticket-age comparisons — matches the source TlsClient stamps the
ticket with.
S3 null-ALPN filter: the effectiveResumption filter rejected ANY
ticket whose cached negotiatedAlpn was null. That breaks resumption
for servers that don't negotiate ALPN at all (legitimate cold-handshake
case), as well as for any persisted ticket predating the
negotiatedAlpn cache field. Treat absent cached ALPN as "no binding to
honour" and allow resumption — the RFC 9001 §4.6.1 restriction is
about ALPN MISMATCH, not absence. The post-EE rejected0Rtt check
mirrors the same null-tolerant shape.
P2 scratch threading: the previous commit added aeadNonceInto but
didn't thread a persistent scratch through the call sites, so the
hot path still allocated a fresh 12-byte nonce per packet. Add
PacketProtection.nonceScratch (sized to iv.size, single-direction so
single-threaded), thread it through Short/LongHeaderPacket build +
parseAndDecrypt as an optional `nonceScratch: ByteArray? = null`
parameter, and pass `proto.nonceScratch` from the four production call
sites in QuicConnectionWriter / QuicConnectionParser. Tests that
construct packets directly are unchanged (default null = allocate
fresh).
All quic JVM unit tests pass; spotless applied.
https://claude.ai/code/session_01EBHtGLy5o7FUR5qfcpHUUx
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
Introduces a new Compose Settings screen that groups composer-related
preferences. Adds a new "Automatically create drafts" toggle that gates
the automatic draft creation triggered on back-press / cancel across
every composer (short notes, public messages, long-form, classifieds,
public channels, private DMs, nests).
Moves "Propose text improvements" and "Tracked broadcasts" out of
Application Preferences and into the new Compose Settings screen.
https://claude.ai/code/session_019b6cF7Ukkv7GL9A3m6bXym
Implements the 10 actionable items from the QUIC code review in issue #2861;
the lone P4 item (encrypt-under-streamsLock) is already tracked as deferred
phase 2 work in quic/plans/2026-05-08-lock-split-design.md and is unchanged.
Android-only correctness (would silently break on API 26–32):
- A1 JdkCertificateValidator: wrap Signature.getInstance("Ed25519") in
try/catch and surface NoSuchAlgorithmException as a clean
QuicCodecException so an Ed25519 leaf cert no longer crashes the
TLS read loop on pre-API-33 Android.
- A2 TlsRunningSha256 (jvmAndroid): keep a parallel byte accumulator and
fall back to one-shot SHA-256 on the rare API-26–28 Conscrypt builds
whose OpenSSLMessageDigestJDK throws CloneNotSupportedException from
MessageDigest.clone(). Latches the fallback after first failure so we
don't pay the JCA throw per snapshot.
Hot-path performance:
- P1 HeaderProtection: add maskAt(hpKey, src, srcOffset) + extend
AesOneBlockEncrypt with encryptInto(key, src, srcOffset, dst, dstOffset)
so the per-packet HP path no longer allocates a 16-byte sample slice
AND no longer allocates a 16-byte JCA Cipher output (the jvmAndroid
impl uses Cipher.doFinal's range overload). Updated 5 call sites in
ShortHeaderPacket and LongHeaderPacket.
- P2 aeadNonce: add aeadNonceInto(staticIv, packetNumber, dst) so call
sites with a persistent 12-byte scratch can build the nonce without
per-packet allocation; aeadNonce keeps its existing shape via the new
helper. Threading the scratch through Short/LongHeaderPacket and the
writer is deferred (similar shape to the documented P4 phase 2 work).
- P3 QuicConnectionParser: decode the frame list once per inbound packet,
feed both qlog (frameNamesFor) and dispatch from the single decode.
Pre-fix every qlog-attached packet ran decodeFrames twice.
- P5 QuicConnectionWriter: iterate pendingMaxStreamData /
pendingNewConnectionId directly instead of allocating
entries.toList() per drain.
Protocol / security:
- S1 QuicConnectionParser: cap MAX_STREAMS at 2^60 (RFC 9000 §19.11);
a peer sending a larger value now triggers STREAM_LIMIT_ERROR close
rather than overflowing the local nextLocalBidi/UniIndex counters.
- S2 QuicConnection.effectiveResumption: drop the cached session ticket
when (now - issuedAt) ≥ min(ticketLifetimeSec, 7 days) per RFC 8446
§4.6.1; expired tickets would otherwise silently fail server-side and
lose any 0-RTT bytes.
- S3 QuicConnection: refuse to offer 0-RTT when our current alpnList
doesn't include the resumed session's negotiated ALPN, and treat
0-RTT as rejected on EE if the new ALPN differs from the cached one
(RFC 9001 §4.6.1).
- S4 TlsExtension.encodeSignatureAlgorithms: drop rsa_pkcs1_sha256.
The validator already rejects it in CertificateVerify per RFC 8446
§4.2.3 — advertising it lied about what we accept.
All quic JVM unit tests pass; spotless applied.
https://claude.ai/code/session_01EBHtGLy5o7FUR5qfcpHUUx
Adds Czech, German, Brazilian Portuguese, and Swedish translations
for the 6 new muted-threads keys introduced with the mute-thread
feature (action_unmute, quick_action_mute_thread,
quick_action_unmute_thread, settings_muted_threads_empty,
settings_muted_threads_title, settings_muted_threads_unknown).
- tighten placeholder dictionary after review
- Skip the placeholder scan when the source has no `{` (avoids Matcher
allocation on the common path).
- Clamp `firstFreeIndex` to PLACEHOLDER_LIMIT so adversarial user text
like `{99999}` can't push our counter past the table limit and make
placeholder() throw for the rest of the note. New JVM test covers it.
- split clamp and max-update in firstFreeIndex
PUA codepoint placeholders (+) were being dropped by ML Kit's
Japanese/Chinese/Korean models, eliding image URLs from translated text
(issue #1180). On-device probing across 8 source languages and 12
placeholder styles shows ICU MessageFormat tokens `{0}`, `{1}`, … are
the only style preserved intact by every model we tested.
build() now scans the source for any user-supplied `{N}` and starts the
dictionary counter above the max, so a note containing `printf("%s", arg{0})`
can't be clobbered by encode/decode. Adds an on-device regression test
with the exact post from the bounty issue and JVM tests for the new
collision-avoidance behaviour.
The previous attempt lit the dot whenever *any* enabled home tab had unread
items, so a user with all three tabs enabled who only scrolled through the
Everything tab still saw the dot — HomeFollows / HomeFollowsReplies stayed at
their old lastRead even though the same content had been read via Everything.
Switch to "every enabled tab still has unread" — equivalently, reaching the
top of any single enabled tab clears the dot. This matches the pre-bug
behavior (where only the New Threads tab gated the dot) while still respecting
users who only enable the Everything tab.
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.
Pulled from a retrospective on a feature PR that exercised every rule
already in the doc, and surfaced eight gaps where the doc was silent.
- Protocol-introducing changes (new section): require an explicit
cross-client compatibility section, on-relay wire-format verification
via nak, and a NIP spec citation when claiming "NIP-X allows this".
These three together prevent the "syncs across devices" handwave
from masking real interop questions.
- Release-minified build subsection in "Build and install both
flavours": call out the existing benchmark build type
(com.vitorpamplona.amethyst.benchmark, profileable=true, R8-active)
as the cheap rig for catching ViewModel-factory / expect-actual /
serialization R8-stripping bugs before the reviewer does.
- Strip diagnostic Log.d before commit: explicit policy. Lambda Log
is required for committed logs; temporary debug Log.d gets removed.
- Regression-test-plan worked example: shows the required
### Feature test plan / ### Regression test plan structure with a
real-shaped example, so contributors don't ship the wrong subheadings.
- Multi-device sync touch-point: added to the regression sweep
checklist for features that publish per-account state to relays.
- R8-minified-build touch-point: added to the same checklist for
reflection-heavy code paths.
- "When on-device QA finds bugs in your own diff" subsection in
"Code review pass": promote the pattern of landing manual-QA
fixes as a separate commit with root-cause prose, not folding
silently into the feature commit.
- Working-notes convention in "Everything else": clarify
docs/plans/ is tracked, docs/brainstorms/ + docs/superpowers/
are gitignored. Saves AI tools from trying to commit scratch work.
Sonar — convert 'if (A) {…} else if (B) {…} else {…}' chains into
'when { A -> …; B -> …; else -> … }' across 33 files (~44 sites).
The change is mechanically equivalent: same conditions, same branch
order, same short-circuit evaluation, no public-signature impact.
The 70-branch event-type dispatch in ThreadFeedView.kt:594 is intentionally
left as if-else for now — the conversion would balloon to a 280-line diff
with no behavioural change, making review impractical.
Sonar — collapse 'if (X) { ...; return A } else { ...; return B }' to
'return if (X) { ...; A } else { ...; B }' across 5 files. Mechanically equivalent.
PlaybackService.lazyPool keeps its in-branch non-local returns from .let{} blocks
(those are early-out cache hits, not tail returns).