4338e5e6c40ee59bcdab9b02f74561c86dc75e59
18 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4338e5e6c4 |
docs(quic+nestsClient): post-implementation status + audio-rooms completion plan
Two new module-local plan docs (per CLAUDE.md's "plans live in the owning
module" rule) and a sweep of stale inline phase references.
quic/plans/2026-04-26-quic-stack-status.md:
Post-mortem of the original docs/plans/2026-04-22 plan. Documents
what shipped vs what was estimated, the actual package layout (~8.5k
LoC, 39 test files, 5 audit rounds), the crypto delegation surface
(Quartz only — no BouncyCastle, no JNI), interop verification status
(aioquic + picoquic; nests not yet), and known deferred items
(STREAM retransmit, Initial-key discard, etc.).
nestsClient/plans/2026-04-26-audio-rooms-completion.md:
Punch list to ship audio rooms end-to-end:
M1 Listener wire-up in Amethyst UI
M2 Multi-speaker audience UX
M3 Foreground service for backgrounded playback
M4 Manual interop pass against nostrnests.com
M5 MoQ publisher path (ANNOUNCE / TrackPublisher)
M6 Capture → encode → publish pipeline
M7 NestsSpeaker API
M8 App polish (reconnect, leave cleanup)
M9 Foreground service for speakers
~6 weeks for full audio rooms; ~2 weeks for listener-only MVP.
Inline doc cleanup:
* Removed "Phase 3a/3c-1/3c-2/3c-3" / "Phase B/C/D-K/L" references
from active code; replaced with "today" or pointers to the
completion plan
* Removed "Kwik-based stub" references; QuicWebTransportFactory and
surrounding docs now describe :quic as the production path
* TlsClient header reflects non-null certificateValidator + the
JdkCertificateValidator / PermissiveCertificateValidator split
* SendBuffer header documents the best-effort no-retransmit mode
explicitly (was hidden behind a "Phase L will fix this" note)
* MoqMessage / MoqObject / MoqSession reflect listener-side as
shipped + publisher-side as Phase M5
CLAUDE.md:
* Module list now includes :quic and :nestsClient (was 5 modules,
now 7)
* Architecture diagram + sharing philosophy explain what each new
module owns
No production behaviour changes; doc + comment-only edits. Tests green.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
2f9a0a0e03 |
fix(quic): live interop against aioquic + round-3 audit fixes
🎉 First successful live-interop handshake against a real reference impl. Drove our pure-Kotlin QUIC client at the aucslab/aioquic-http3-server Docker container; HANDSHAKE COMPLETE with negotiated ALPN=h3 and full transport parameter exchange: max_data=1048576, max_streams_bidi=128, max_streams_uni=128, idle_timeout=60000ms, max_datagram=65536 Every layer worked end-to-end over real UDP: packet codec, header protection, AES-128-GCM AEAD, TLS 1.3 with X25519, CertificateVerify, transport parameters, ALPN negotiation. The PermissiveCertificateValidator + InteropRunner main + Gradle :quic:interop task make this reproducible in one command. Round-3 audit fixes (8 critical bugs caught by 4 parallel reviewers): 1. feedDatagram coalesced-packet skip (RFC 9001 §5.5): `?: break` discarded all subsequent coalesced packets if any one of them failed to decrypt. Now uses peekHeader to advance over a failed packet, only breaking on a totally-unparseable header. 2. Receive-side flow-control enforcement: Parser was inserting STREAM frames without checking against the limit we advertised. A misbehaving peer could blow past initialMaxStreamDataX with no error; now triggers markClosedExternally with FLOW_CONTROL diagnostic. 3. Bounded incomingChannel (audit-2 finding finally addressed): QuicStream.incomingChannel was Channel.UNLIMITED — slow consumer + fast peer = unbounded heap growth. Now Channel(64), bounded by the per-stream receive limit. Combined with #2, memory growth is capped. 4. RetryPacket CID length validation: parse() didn't bounds-check dcidLen/scidLen against the RFC 9000 §17.2 1..20 range. Hostile Retry with cidLen=255 → readBytes throws QuicCodecException to the caller (instead of returning null for silent drop). Added explicit `!in 0..20 → return null`. 5. HelloRetryRequest detection: No HRR check — we treated it as a regular ServerHello, derived an ECDHE on garbage, then failed AEAD downstream with a confusing error. Now checks ServerHello.random against the SHA-256("HelloRetryRequest") magic value and throws cleanly. 6. CancellationException handling in WT factory: Catch-all wrapped CancellationException as HandshakeFailed, breaking structured concurrency. Now rethrows ce after closing the driver. 7. InteropRunner scope leak on timeout: parentScope was never cancelled — orphaned coroutines kept the IO dispatcher alive after main exited. Now scope.cancel() runs unconditionally; small delay gives the driver-launched teardown a moment to flush before exit. 8. RFC 9220 §3.1 SETTINGS-before-CONNECT documented as known limitation: The strict requirement to wait for SETTINGS_ENABLE_WEBTRANSPORT=1 before sending CONNECT requires more refactoring (the demux capturing peerSettings lives inside QuicWebTransportSessionState, which we don't build until after the request bidi opens). Tolerant servers (aioquic, quic-go) accept early CONNECT; documented for future strict-RFC fix. All :quic:jvmTest + :nestsClient:jvmTest pass. Live aioquic interop verified post-fix: handshake completes, transport parameters round-trip cleanly. https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx |
||
|
|
8f5280c9c3 |
fix(quic): WebTransport peer-stream demux + flow-control + perf cleanup
Round-2 audit found that without prefix-stripping on peer-initiated streams,
the server's HTTP/3 control stream would deliver its SETTINGS frame to the
application, breaking MoQ framing. Plus several flow-control + perf items
the audit flagged.
WtPeerStreamDemux:
Spawned per WT session in QuicWebTransportSessionState.init. Routes peer
streams by their leading varint(s):
- HTTP/3 CONTROL stream (0x00) → drains internally, captures peer
SETTINGS frame for inspection (peerSettings property).
- QPACK encoder/decoder streams (0x02/0x03) → drained to /dev/null
(we run with QPACK_MAX_TABLE_CAPACITY=0).
- WebTransport unidirectional (0x54) + matching quarter session id →
surfaces to app as a StrippedWtStream with the prefix stripped.
- WebTransport bidi signal (0x41) + matching quarter session id → same.
- Anything else → dropped per RFC 9114 §9.
pollIncomingPeerStream is now @Deprecated; the recommended path is the
incomingStrippedStreams flow on the session state. The nestsClient adapter
now consumes the stripped flow and emits StrippedWtReadStreamAdapter
through the existing WebTransportSession.incomingUniStreams API.
WT_CLOSE_SESSION decoder + CapsuleReader:
Added stateful capsule reader that yields parsed WtCloseSession
(errorCode + reason) from the CONNECT bidi. Wires into the future close
notification path; not yet bound to status flip but the parser is in
place.
Flow-control fixes from the audit:
- Per-direction receive window in getOrCreatePeerStreamLocked. SERVER_UNI
streams now use initialMaxStreamDataUni instead of bidi-remote.
- appendFlowControlUpdates picks per-direction window matching the
stream's StreamId.kindOf.
- MAX_DATA tracking via QuicConnection.advertisedMaxData high-water mark.
Previously the writer emitted a fresh MAX_DATA on every outbound
packet once the threshold was crossed (spam). Now: only when the new
value strictly exceeds advertisedMaxData.
Stream iteration round-robin:
Writer was iterating streamsLocked() in insertion order, starving streams
created later under MTU pressure. Added streamRoundRobinStart counter on
QuicConnection that advances after each drain.
SendBuffer alias defense:
takeChunk's fast path used to return the head ByteArray reference directly
when consuming the whole chunk — caller-owned byte arrays could be mutated
in-place. Now always copies, since downstream encoders pass the bytes to
AEAD.seal which assumes immutability.
UdpSocket cleanup:
- readBuf shrunk from 65 KiB to 2 KiB. QUIC datagrams cap at MTU; the old
65 KiB allocation was per-connection waste with no benefit.
- Removed pointless synchronized(readBuf) — only the read loop touches it.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
94a3d32a6d |
fix(quic): lifecycle hangs + TLS hardening from second-pass audit
Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.
QuicConnection / awaitHandshake hang fixes:
- QuicConnectionClosedException + markClosedExternally(reason).
- close() now also calls signalHandshakeFailed if !handshakeComplete.
- QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
(was: just flip status, leaving awaiters hanging forever).
- QuicConnectionDriver.readLoop has a finally-block that calls
markClosedExternally + wakeup so when the socket closes mid-handshake
or the server closes uncleanly, awaitHandshake() throws instead of
suspending forever.
QuicConnectionDriver.close() no longer cancels its own caller scope:
Previously close() was suspend, called connection.close (acquires lock)
→ wakeup → scope.cancel → socket.close. If close() is invoked from
inside the driver scope (e.g. by the WT factory's exception cleanup
path), scope.cancel cancels the very coroutine running close, so
socket.close may never run. close() is now non-suspend and dispatches
the teardown onto parentScope.launch so the cancel never reaches its
caller.
QuicWebTransportFactory.connect:
- readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
default 10s). Dead network → HandshakeFailed instead of forever-hang.
- Whole post-handshake setup (open control + request streams + read
response) now in try/catch that calls driver.close() on any unexpected
exception. Previously a thrown SocketException between awaitHandshake()
and the explicit non-2xx branch leaked the driver + UDP socket.
JdkCertificateValidator.validateChain authType:
Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
trust managers (RootTrustManager, NetworkSecurityConfig) gate
algorithm-specific pinning rules on this string and may reject mismatched
combos.
TlsExtension.decodeList bounds:
Inner reads were unbounded against the extension-list end. A malicious
server could claim totalLen=4 but encode an extension whose data length
declared 1000, reading past the supposed extension-list end into
trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
asserts r.position ≤ end after each inner decode.
TlsClient KeyUpdate close-on-receipt:
Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
rotates keys and we keep using the old ones, AEAD opens silently fail
→ connection wedges. Until we implement rotation, KeyUpdate must throw
so the QUIC layer closes cleanly with a fatal error instead of
desynchronizing.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
368b8dd432 |
fix(quic): C3+C4+C9 + Tier-2 robustness from review
C3+C4 — HTTP/3 frame reader + WebTransport response :status check
New Http3FrameReader buffers stream bytes and yields complete frames
(DATA, HEADERS, SETTINGS, GOAWAY, Unknown). QuicWebTransportFactory
drains the request stream after sending the Extended CONNECT request,
feeds bytes through the reader, decodes the first HEADERS frame via
QPACK, and pulls `:status`. Non-2xx → ConnectRejected. Without this,
any 401/404/500 yielded a "connected" session that silently dropped.
Tests: 5 new H3FrameReader tests covering SETTINGS, HEADERS round-trip,
cross-push reassembly, unknown-type passthrough, multi-frame in one push.
C9 — flow-control enforcement + receive-side crediting
- Send: per-stream `sendCredit` is now consulted before each takeChunk;
bytes beyond `sendCredit - sentOffset` are held back. SendBuffer
exposes `sentOffset` for the writer.
- Receive: appendFlowControlUpdates() emits MAX_STREAM_DATA when the
receive cursor crosses half the advertised window, and MAX_DATA at
the connection level. Without this, peer windows close and any
sustained transfer wedges silently.
Tier-2 cleanups (six items in one batch):
- AckTracker.purgeBelow(): drop ranges below peer's largest_acked when
we receive an ACK frame. Range list no longer grows unboundedly on
long connections.
- ReceiveBuffer adjacency edge: pull in the prior chunk when its
endOffset exactly equals the new chunk's start. Previously perfectly-
sequential receives starting at offset > 0 left adjacent chunks
unmerged, growing the chunk list and overcounting bufferedAhead.
- RetryPacket integrity-tag verify: constant-time compare instead of
contentEquals.
- ServerHello legacy_session_id_echo MUST be empty per RFC 8446 §4.1.3
(we send empty); reject non-empty as a downgrade signal.
- TLS state machine handles post-handshake NewSessionTicket and
KeyUpdate at Application level — silently drop instead of throwing
"unexpected post-handshake type" and tearing down the connection.
Tier-3 perf: SendBuffer chunked queue
Replaced the O(N) copyOf-on-every-enqueue with an ArrayDeque<ByteArray>
+ headOffset cursor. Enqueue is now O(1); takeChunk peels at most one
head chunk. Memory is bounded by the sum of outstanding writes instead
of (sum)². For sustained MoQ stream writes of small chunks this drops
from O(N²) memcpy to O(N).
All :quic:jvmTest + :nestsClient:jvmTest pass — every RFC 9001 Appendix A
vector still verifies bit-for-bit.
Remaining items deferred:
Tier-2: incremental transcript hash (low impact: 4-5 calls per handshake
over <10 KB), TLS HelloRetryRequest detection (we never send
incompatible ClientHello today, server won't HRR).
Tier-3: cipher reuse, Huffman lookup tree, UdpSocket selector, packet
codec triple-allocation. None block live interop; revisit if
measured RTT or CPU surfaces them.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
e250b76272 |
fix(quic): six critical correctness + security bugs from review
Synthesizes findings from four parallel layer reviews. Each fix here would
have broken or weakened live interop:
C1 — TlsClient stored cipher suite (was hardcoded)
TlsClient.currentCipherSuite() always returned AES-128-GCM-SHA256, even
when the server picked TLS_CHACHA20_POLY1305_SHA256. The QUIC layer would
then install AES-GCM AEAD + AES-ECB header protection over a ChaCha20-
derived secret → silent 1-RTT decrypt failure. Now stores the negotiated
cipher from ServerHello and returns it.
C2 — AckTracker records the actual packet PN, not the largest received
dispatchFrames() in QuicConnectionParser was passing
state.pnSpace.largestReceived to the ACK tracker. With two coalesced
packets in one datagram, only the larger PN was ever tracked → server
retransmits the smaller forever. Plumb the parsed packet's PN through
dispatchFrames and feed it to receivedPacket(). Also always record (even
for non-ack-eliciting packets) so the peer's loss recovery sees a
contiguous picture.
C5 — bounds-check every readVarint().toInt() length in frame decode
CRYPTO, STREAM (LEN), CONNECTION_CLOSE reason, DATAGRAM_LEN, and ACK
range count all read a 62-bit varint, truncate to Int, and pass straight
to readBytes / repeat. A hostile peer could send length=2^62-1 → crash or
multi-GB allocation. Added boundedLength() + boundedRangeCount() helpers
that reject if value < 0 or > remaining.
C6 — frame type dispatch uses readVarint, not readByte
RFC 9000 §12.4 specifies frame types as varints. We were reading a single
byte, so any extension frame type ≥ 0x40 (e.g. ACK_FREQUENCY 0xAF) would
be mis-dispatched. All current types are < 64 so the 1-byte form matches
the 1-byte varint, but the change is forward-compatible.
C7 — CertificateValidator required (no silent skip)
Both QuicConnection and TlsClient previously had `validator: ... = null`
defaults. A misconfigured caller would silently accept any server's
certificate. Removed the defaults; null is now an explicit opt-in for
in-process loopback tests. Added JdkCertificateValidator backed by the
platform / JDK system trust store with proper SAN-based hostname check
and signature verification for ECDSA / RSA-PSS / RSA-PKCS1 / Ed25519.
QuicWebTransportFactory uses it by default.
C8 — thread-safety on connection state
QuicConnection.streams, pendingDatagrams, nextLocalBidiIndex/UniIndex
were mutated from the driver loops and from app coroutines without
synchronization → ConcurrentModificationException waiting to happen.
Moved the mutex onto QuicConnection itself; the driver wraps feed/drain
with `connection.lock.withLock { ... }`, public mutators became suspend
and acquire the same lock. Internal helpers used by feed/drain are
marked `Locked` to make the precondition explicit.
Also replaced the `delay(2)` send-loop polling with a CONFLATED
`Channel<Unit>` wakeup — app writes (queueDatagram, openBidiStream,
stream write via the WT adapter) call `driver.wakeup()`. Idle CPU
drops to zero between packets.
awaitHandshake() replaces the busy-poll over `conn.status` in
QuicWebTransportFactory.connect — backed by a CompletableDeferred that
the TLS listener completes on onHandshakeComplete() or fails on a torn
down read loop.
Tests: full :quic:jvmTest and :nestsClient:jvmTest suites pass — every
RFC 9001 Appendix A vector still verifies bit-for-bit.
Remaining critical work (in progress, separate commits):
C3+C4 — HTTP/3 frame reader + WebTransport response :status check
C9 — flow-control enforcement + MAX_STREAM_DATA crediting
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
46742636c7 |
feat(quic): Phase L — wire QuicWebTransportFactory into nestsClient
Replace the Kwik stub in :nestsClient with a pure-Kotlin QUIC + WebTransport
adapter built on top of :quic.
- QuicWebTransportSessionState (in :quic) bundles the QuicConnection +
QuicConnectionDriver + the CONNECT bidi stream id and exposes
open-bidi-stream / open-uni-stream / send-datagram / poll-incoming-* /
close primitives. Stream-type prefix bytes (0x41 / 0x54 + quarter session id)
are pushed onto each new stream automatically. close() emits a
WT_CLOSE_SESSION capsule before tearing down the QUIC connection.
- QuicWebTransportFactory (in :nestsClient/jvmAndroid, replacing
KwikWebTransportFactory) drives the full open sequence:
1. UDP connect + QuicConnection.start
2. wait until handshake completes (Status.CONNECTED)
3. open H3 control uni-stream with stream-type 0x00 + the
SETTINGS frame (ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1,
ENABLE_WEBTRANSPORT=1)
4. open the Extended CONNECT bidi: HEADERS frame carrying
:method=CONNECT, :protocol=webtransport, :scheme=https,
:authority, :path, optional Authorization: Bearer
5. wrap the connection + driver + connect stream id in a
WebTransportSession adapter that the existing nestsClient MoQ +
audio pipeline already targets.
- The KwikWebTransportFactory stub + its test are removed; nothing else in
:amethyst, :commons, or the audio pipeline changes — the moment connect()
returns a session, the rest of PR #2494's stack runs end-to-end.
- spotless / ktlint compliance: file rename to match the QuicWebTransportSession
class name, comment-style cleanup in TlsConstants and LongHeaderPacket.
Build: :amethyst:compileFdroidDebugKotlin succeeds; :nestsClient:jvmTest +
:quic:jvmTest both pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
2d541c6fd4 |
feat(quic): Phase A — module foundations
Create the new :quic Gradle module (KMP, api(project(":quartz"))) and migrate
the QUIC varint codec out of :nestsClient where it was incidentally living.
Add the connection-ID, packet-number-space, and UDP socket primitives that
the rest of the QUIC client will build on.
Layer-by-layer plan in docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md.
- New :quic module wired into settings.gradle, with commonMain + jvmAndroid
source sets mirroring :quartz's structure.
- Varint moves from com.vitorpamplona.nestsclient.moq to com.vitorpamplona.quic;
MoqBuffer/MoqCodec updated to import the new path.
- ConnectionId enforces the 0..20 byte length range and ships a randomizer
backed by Quartz's RandomInstance.
- PacketNumberSpaceState tracks per-space outbound allocation + largest-received
tracking, and implements the RFC 9000 §A.3 truncated-PN decode formula plus
the §17.1 minimum encode-length picker.
- UdpSocket is an expect class with a connected DatagramChannel actual on
jvmAndroid using Dispatchers.IO (no Selector — one socket per connection).
All 12 tests pass on jvmTest. RFC 9000 §A.1 varint vectors and §A.3 truncated-PN
vector match bit-for-bit.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
|
||
|
|
999184cced | optimise imports | ||
|
|
c07f7baa14 |
docs(nestsClient): Phase 3b-2 Kwik integration plan in stub
Phase 3b-2 attempt. The honest version: I do not have network access to verify Kwik's current Maven coordinates or test against a live nests server, so committing speculative QUIC + Extended CONNECT code risks breaking the build for everyone else without giving us audible-audio-on-device confidence in return. What I did instead: expanded the docstring on KwikWebTransportFactory into a full integration playbook that the next person picking this up can execute directly. It covers: - Maven coordinates to verify (`tech.kwik:kwik-core` + `tech.kwik:flupke`, with `net.luminis.quic:kwik` as the legacy fallback group). - The exact `gradle/libs.versions.toml` + `nestsClient/build.gradle.kts` edits to drop in once coords are confirmed. - Step-by-step handshake sequence with the right HTTP/3 setting IDs: * SETTINGS_ENABLE_CONNECT_PROTOCOL=1 (RFC 8441, 0x08) * SETTINGS_ENABLE_WEBTRANSPORT=1 (WT-H3 draft, 0x2b603742) * SETTINGS_H3_DATAGRAM=1 (RFC 9297, 0x33) - Extended CONNECT pseudo-header set, including the legacy `sec-webtransport-http3-draft02 = 1` for older server compat. - WebTransport stream-type prefix bytes (0x41 for client bidi, 0x54 for client uni) + WT capsule type for graceful close (0x2843). - Test strategy: validate against local nests-rs first, then the production `nostrnests.com`. - Open questions (Flupke Extended CONNECT maturity, draft churn, dev-only self-signed-cert support, Android API surface). Behavior unchanged: connect() still throws WebTransportException with Kind.NotImplemented and a message pointing at this docstring. When the real implementation lands, no upstream caller needs to change — the AudioRoomConnectionViewModel from Phase 3d-3 will auto-light up the connection chip from "Failed: NotImplemented" to "Connected" and start producing audio through the Phase 3d-1 pipeline that's already wired. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D |
||
|
|
b62e3dd0ec |
feat(nestsClient): MoQ ANNOUNCE family + Opus encoder + AudioRecord capture
Phase 4 (codec + audio capture slice). Adds the publisher-side MoQ
control messages and the Opus encode + microphone capture pieces a
speaker needs. The host-grants-speaker UI flow is deferred — that's
multi-screen UX that should be designed before being implemented.
commonMain (nestsclient.moq):
- 5 new MoqMessageType entries with codec round-trip + tests:
* Announce (0x06): publisher offers a track namespace.
* AnnounceOk (0x07): subscriber acknowledges.
* AnnounceError (0x08): subscriber rejects with error code +
reason phrase.
* Unannounce (0x09): publisher withdraws a previously-announced
namespace.
* SubscribeDone (0x0B): publisher tells the subscriber no more
objects are coming for this subscription, with stream count and
reason.
commonMain (nestsclient.audio):
- New `OpusEncoder` interface — symmetric to `OpusDecoder`, one
instance per outgoing track since Opus state is per-stream.
- New `AudioCapture` interface — `start()`, `readFrame()` returns a
PCM frame or null when stopped, `stop()` releases the mic.
androidMain (nestsclient.audio):
- `MediaCodecOpusEncoder` — wraps `MediaCodec("audio/opus")` encoder
variant (API 29+). 48 kHz mono in, 32 kbit/s VBR Opus out, 20 ms
frames. Drains output queue per encode call.
- `AudioRecordCapture` — wraps `AudioRecord` from
`MediaRecorder.AudioSource.VOICE_COMMUNICATION` so the platform's
echo-cancellation + noise-suppression filters apply when available.
Reads exactly one PCM frame per readFrame() call, retries on
underrun, throws AudioException(DeviceUnavailable) on permission/
resource failures.
commonTest:
- `AnnounceCodecTest` — 7 cases covering each message round-trip,
concatenated decode in sequence, and a guard against accidentally
reordering MoqMessageType enum codes.
Permission already declared:
- `RECORD_AUDIO` is already in amethyst/AndroidManifest.xml — no
manifest change needed.
What this does NOT include:
- A publisher loop class (analogous to AudioRoomPlayer for publish
direction) — can be added when the speak-button wiring lands.
- The host-grants-speaker UI — needs design input.
- STREAM_HEADER_SUBGROUP for stream-based object delivery — datagrams
cover the listener happy-path; streams add reliability that nests
may or may not require.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
|
||
|
|
c1355f1dd8 |
feat(nestsClient): NestsListener facade + connect orchestrator
Phase 3d-2 of the Clubhouse/nests integration. Ties HTTP auth (3a) +
WebTransport (3b-1 stub) + MoQ session (3c) under a single
`connectNestsListener()` entry point with an observable state
machine, so audio-room callers see one resource and one StateFlow
instead of three layered handshakes. Pure commonMain — fully tested
against fakes; no network or Android needed.
commonMain (nestsclient):
- `NestsListener` interface — `state: StateFlow<NestsListenerState>`,
`subscribeSpeaker(pubkeyHex)`, `close()`. nests namespaces each
speaker's track as `["nests", <roomId>]` with the speaker's pubkey
hex as track name; subscribeSpeaker fills that in.
- `NestsListenerState` sealed class:
* Idle
* Connecting(step = ResolvingRoom | OpeningTransport | MoqHandshake)
* Connected(roomInfo, negotiatedMoqVersion)
* Failed(reason, cause?)
* Closed
- `DefaultNestsListener` — straight delegation to MoqSession once
connected.
- `connectNestsListener(httpClient, transport, scope, serviceBase,
roomId, signer, supportedMoqVersions)` — walks the three handshake
steps. Each failure short-circuits to Failed with a clear reason
string and the underlying cause attached; transport is torn down on
partial-handshake failure.
- `parseEndpoint(url)` — hand-rolled URL splitter so commonMain stays
free of a URL-library dependency. Handles default-port stripping,
rejects userinfo, preserves query strings.
commonTest:
- `NestsConnectTest` (5 cases, all using fakes):
* Happy path: walks resolveRoom -> transport.connect -> MoQ SETUP,
asserts state lands on Connected with the right roomInfo +
negotiated version, and verifies the transport saw the parsed
authority/path/bearer.
* resolveRoom failure short-circuits without touching transport.
* Transport handshake failure short-circuits with the right kind in
the reason.
* Malformed endpoint URL short-circuits.
* parseEndpoint covers default-port stripping, explicit port,
pathless URL, query string preservation.
This completes the pure-Kotlin integration. The only seam left
between `connectNestsListener()` and audible audio is the Kwik-backed
WebTransportFactory implementation (Phase 3b-2). When that lands, an
Amethyst AudioRoomViewModel can wire the existing AudioRoomStage to:
state = MutableStateFlow(NestsListenerState.Idle)
listener = connectNestsListener(...)
for each speaker p: AudioRoomPlayer(MediaCodecOpusDecoder(),
AudioTrackPlayer(), scope).play(listener.subscribeSpeaker(p).objects)
…and audio plays.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
|
||
|
|
6e6fc4cabb |
feat(nestsClient): listener audio pipeline (Opus decode + AudioTrack)
Phase 3d-1 of the Clubhouse/nests integration. Adds the listener-side
audio pipeline that turns a SubscribeHandle's Flow<MoqObject> into
audible PCM through Android's MediaCodec + AudioTrack. Encoder /
AudioRecord (speaker publishing) lands in Phase 4.
commonMain (nestsclient.audio):
- `AudioFormat` constants — 48 kHz mono signed-16-bit, 20 ms frames
(960 samples), matching the nests Opus profile.
- `OpusDecoder` interface — stateful per-track decoder.
- `AudioPlayer` interface — start/enqueue/stop, suspend on backpressure.
- `AudioRoomPlayer` — wires a Flow<MoqObject> through OpusDecoder into
AudioPlayer. Decoder errors are surfaced via an `onError` callback
but don't tear down the loop (one bad packet shouldn't kill the
room); player errors are fatal. play()/stop() are single-shot per
instance, with stop() idempotent and double-release-safe.
- `AudioException` with three canonical kinds (DecoderError,
DeviceUnavailable, PlaybackFailed).
androidMain:
- `MediaCodecOpusDecoder` — wraps `MediaCodec("audio/opus")` with the
RFC 7845 §5.1 Opus identification header in csd-0 and zeroed
pre-skip / seek-pre-roll in csd-1 / csd-2. Drains the output queue
per-packet, handles INFO_OUTPUT_FORMAT_CHANGED gracefully.
- `AudioTrackPlayer` — wraps `AudioTrack` in MODE_STREAM with USAGE_
VOICE_COMMUNICATION / CONTENT_TYPE_SPEECH so the OS treats audio-
room playback like a phone call (call-volume rocker, ducks
notifications). Buffer = 4× minimum so the producer can fall behind
~80 ms (roughly the WebTransport datagram jitter on mobile).
Tests (commonTest, fake-based):
- `AudioRoomPlayerTest` — 7 cases covering happy-path decode +
enqueue, decoder failure with continued loop, stop idempotency,
double-play rejection, play-after-stop rejection, empty-PCM
skip-enqueue, and live channel-backed flow.
The real MediaCodec / AudioTrack code is thin glue against Android
APIs and is validated manually on device — no Robolectric here, the
seams are the Fake* doubles.
Next: Phase 3d-2 wires NestsClient end-to-end (HTTP auth → MoqSession
listener subscribe → AudioRoomPlayer) plus the Amethyst-side
AudioRoomViewModel. After that the only remaining pure-MoQ work is
the Kwik handshake (Phase 3b-2).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
|
||
|
|
b091b50fe6 |
feat(nestsClient): MoQ session pump + subscribe API
Phase 3c-3 of the Clubhouse/nests integration. Promotes MoqSession
from a one-shot SETUP runner to a real concurrent session: the
control-stream and datagram pumps run on a caller-supplied scope
after the handshake, and a public subscribe()/unsubscribe() API
delivers per-track OBJECT_DATAGRAMs as a Flow<MoqObject>.
commonMain (nestsclient.moq):
- MoqSession now takes a `pumpScope: CoroutineScope` so callers can
bind the pumps to their lifecycle (test backgroundScope, viewmodel
scope, etc.).
- After setup() succeeds, two background coroutines start:
* Control-stream pump — buffers chunks across multiple writes,
decodes complete frames, dispatches SUBSCRIBE_OK / SUBSCRIBE_ERROR
to the matching CompletableDeferred, drops malformed frames
without killing the session.
* Datagram pump — decodes OBJECT_DATAGRAMs and routes each to the
matching per-track sink keyed by track_alias.
- `subscribe(namespace, trackName, priority, filter)` sends SUBSCRIBE,
awaits SUBSCRIBE_OK (throws MoqProtocolException on SUBSCRIBE_ERROR
with the publisher's reason), and returns a SubscribeHandle whose
`objects` flow emits inbound OBJECT_DATAGRAMs.
- `unsubscribe(subscribeId)` is idempotent; second call is a no-op.
close() cancels pumps + tears down all in-flight subscribes cleanly.
- Per-track sink uses a bounded Channel with DROP_OLDEST overflow
rather than MutableSharedFlow, so objects buffered between
subscribe() returning and the consumer attaching are preserved
(SharedFlow with replay=0 silently drops pre-subscription emissions).
transport (FakeWebTransport):
- Switched from `consumeAsFlow()` to `receiveAsFlow()` so a `.first()`
during setup followed by a long-running pump `.collect{}` works on
the same channel without the first call closing it.
Tests:
- Updated existing setup tests for the new `pumpScope` parameter.
- New `subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order`
end-to-end: client subscribes, raw server peer (no MoqSession to
avoid pump-vs-test contention) replies SUBSCRIBE_OK + 3 datagrams,
client's flow yields all three in order with correct payloads.
- `subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error`.
- `datagrams_for_unknown_track_alias_are_dropped_silently` ensures
the pump doesn't crash on orphan datagrams and the session stays
closeable.
This completes the pure-Kotlin MoQ work. Remaining 3.x phases all
touch real audio (MediaCodec Opus, AudioTrack, AudioRecord) or the
real WebTransport handshake (Phase 3b-2 with Kwik).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
|
||
|
|
d65ab7b616 |
feat(nestsClient): MoQ SUBSCRIBE family + OBJECT_DATAGRAM codecs
Phase 3c-2 of the Clubhouse/nests integration. Extends the MoQ
control-plane codec with the subscribe lifecycle messages (SUBSCRIBE,
SUBSCRIBE_OK, SUBSCRIBE_ERROR, UNSUBSCRIBE) and adds the
OBJECT_DATAGRAM wire format the listener uses to receive low-latency
Opus audio. Pure codec layer — no session-pump or network changes;
the subscribe/object delivery Flow arrives in Phase 3c-3.
commonMain (nestsclient.moq):
- New message types in `MoqMessageType`: Subscribe (0x03),
SubscribeOk (0x04), SubscribeError (0x05), Unsubscribe (0x0A).
- New data classes in MoqMessage.kt:
* `TrackNamespace` — tuple of byte strings, with `of(vararg String)`
helper for the common UTF-8 case.
* `SubscribeFilter` enum. Phase 3c-2 supports LatestGroup /
LatestObject; AbsoluteStart/AbsoluteRange throw at construction
(they need extra wire fields we'll add when nests needs them).
* `Subscribe`, `SubscribeOk`, `SubscribeError`, `Unsubscribe` data
classes. `SubscribeOk` validates contentExists + largest-id
coupling at construction.
- New codecs in `MoqCodec` (namespace tuple + all four messages).
Unknown filter codes on the wire are rejected.
- `MoqObject` data class + `MoqObjectDatagram` encode/decode for the
OBJECT_DATAGRAM wire format (track_alias, group_id, object_id,
publisher_priority, status, payload).
Tests:
- `SubscribeCodecTest` — round-trips for each message with multi-
segment namespaces + parameters, SUBSCRIBE_OK with and without
contentExists, concatenated decode in sequence, unknown-filter
rejection, construction-time validation.
- `MoqObjectDatagramTest` — Opus-sized payload round-trip, zero-
length payload with OBJECT_DOES_NOT_EXIST status, 8-byte-varint
boundary coverage, truncated datagram rejection, out-of-range
priority rejection.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
|
||
|
|
37e24ce4f3 |
feat(nestsClient): MoQ varint + SETUP handshake codec
Phase 3c-1 of the Clubhouse/nests integration. Lands a MoQ-transport control-plane codec (draft-ietf-moq-transport) and a session wrapper that runs the SETUP handshake over any WebTransportSession. Purely commonMain + fully tested end-to-end against FakeWebTransport — no network or Kwik dependency required. commonMain (nestsclient.moq): - `Varint` — QUIC variable-length integer codec per RFC 9000 §16. Encode/decode/size, with typed truncation handling (decode returns null so callers can buffer more and retry). - `MoqWriter` / `MoqReader` — append-only byte writer + bounds-checked reader used by the message codec. - `MoqCodecException` — typed error for malformed frames. - `MoqMessage` sealed class + `MoqMessageType` enum + `SetupParameter`. Phase 3c-1 covers just `ClientSetup` / `ServerSetup` (message types 0x40 / 0x41). - `MoqCodec.encode/decode` — wraps payload with `type (varint) + length (varint)`. Rejects unknown types and trailing bytes inside a declared payload window. - `MoqSession.client/server` — attaches to a WebTransportSession and runs the CLIENT_SETUP / SERVER_SETUP handshake with version negotiation + configurable timeout. - `MoqVersion` constants for draft-11 and draft-17. Tests: - `VarintTest` — all four RFC 9000 §A.1 sample vectors (1/2/4/8 byte), boundary round-trips, negative/overflow rejection, short-buffer returns null, bytesConsumed accuracy. - `MoqCodecTest` — CLIENT_SETUP / SERVER_SETUP round-trip (empty and multi-param), multi-version negotiation, exact wire layout for ServerSetup(1L), truncated-frame returns null, concatenated frames decode with offset, unknown type rejected, trailing bytes rejected, zero-length parameter values allowed. - `MoqSessionTest` — full SETUP exchange over FakeWebTransport (both sides happy path, client falling back to older version, server rejecting when no version overlap + client timing out cleanly). SUBSCRIBE / ANNOUNCE / OBJECT_STREAM / OBJECT_DATAGRAM land in Phase 3c-2 on top of the same MoqSession. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D |
||
|
|
4ead4ccd5c |
feat(nestsClient): WebTransport abstraction + fake + Kwik stub
Phase 3b-1 of the Clubhouse/nests integration. Lands the [WebTransportSession] abstraction the MoQ layer (Phase 3c) will code against, so MoQ framing + tests can develop in parallel with the real Kwik-based transport integration (deferred to Phase 3b-2). commonMain: - `WebTransportSession` — bidi/uni stream access, datagrams, close. - `WebTransportBidiStream` / `WebTransportReadStream` / `WebTransportWriteStream` — minimal read/write surface. - `WebTransportFactory.connect(authority, path, bearerToken)` for opening sessions. - `WebTransportException(kind, ...)` with four canonical failure modes (HandshakeFailed, ConnectRejected, PeerClosed, NotImplemented) so UI code doesn't need to know about library-specific exceptions. - `FakeWebTransport.pair()` — in-memory, fully-wired client/server pair for unit-testing MoQ framing without touching a real QUIC stack. jvmAndroid: - `KwikWebTransportFactory` stub that throws `WebTransportException(NotImplemented)` at `connect()`. Doc spells out the handshake sequence (QUIC dial → H3 SETTINGS → `:method=CONNECT :protocol=webtransport` Extended CONNECT → 2xx) so Phase 3b-2 can drop the real implementation in without touching callers. Tests: - `FakeWebTransportTest` — datagram round-trip both directions, bidi-stream write visible on peer side, close() flips isOpen. - `WebTransportExceptionTest` — kind + message + cause preserved. - `KwikWebTransportFactoryTest` — `connect()` currently fails with the NotImplemented sentinel, so Phase 3b-2 can replace this test when the real handshake lands. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D |
||
|
|
933b522273 |
feat(nestsClient): NIP-98 auth + nests room-info client
Phase 3a of the Clubhouse/nests integration. Adds a new KMP module `nestsClient` (Android + JVM targets) that owns the HTTP control plane for talking to a nests audio-room backend. No transport/audio yet — that arrives in 3b with WebTransport + MoQ. Surface: - `NestsAuth.header(signer, url, method)` signs a kind 27235 event via Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use `Authorization: Nostr <base64>` header value. - `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown fields so newer nests server revisions don't break older clients). - `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls `<serviceBase>/<roomId>` with the signed NIP-98 header and returns the MoQ endpoint + token the audio layer will need. - `OkHttpNestsClient` (jvmAndroid) is the default implementation shared between Android and desktop JVM. Tests: - `NestsRoomInfoTest` (commonTest) covers full/minimal payloads, unknown-field tolerance, missing-endpoint rejection, and URL construction edge cases. - `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the base64 back through Quartz's JacksonMapper, and asserts the signature verifies and the url+method tags bind correctly. https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D |