test(audio-rooms) + fix: round-trip test + audit pass

Adds an end-to-end MoQ round-trip test and lands the highest-severity
findings from a 3-agent audit (protocol / ViewModel / Android lifecycle)
of the M5–M7 + Activity work.

Round-trip test (`:nestsClient` MoqRoundTripTest):
- Two MoqSession.client() instances (publisher + subscriber) talk
  through a hand-rolled in-test relay coroutine that mirrors a real
  MoQ relay's wire behavior (forwards CLIENT_SETUP / SERVER_SETUP /
  ANNOUNCE / SUBSCRIBE / SUBSCRIBE_OK / OBJECT_DATAGRAM).
- 100-Opus-frame test exercises the full publisher → wire →
  subscriber path, asserting payload bytes + monotonic group/object
  ids round-trip correctly. Catches any drift between our publisher
  and our own subscriber's wire format.
- Second test verifies SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) flows
  back as MoqProtocolException when the publisher hasn't openTrack'd.

MoQ protocol fixes (CRITICAL audit findings):
- SubscribeDoneStatus constants were inverted: had UNSUBSCRIBED=0x01
  (peer reads as INTERNAL_ERROR) and TRACK_ENDED=0x00 (peer reads as
  UNSUBSCRIBED). Swapped to draft-stable values: UNSUBSCRIBED=0x00,
  TRACK_ENDED=0x03.
- Pending CompletableDeferreds for in-flight SUBSCRIBE / ANNOUNCE on
  session close were `cancel()`-ed, which propagates as
  CancellationException — caller's entire scope cancels instead of
  catching a domain MoqProtocolException. Switched all sites to
  `completeExceptionally(MoqProtocolException("session closed"))`
  including unsubscribe-while-pending-OK.

ViewModel fixes:
- openSubscription race: re-check `activeSubscriptions[pubkey] === slot
  && !closed` AFTER the suspending `subscribeSpeaker` returns; if the
  user removed the speaker mid-flight, fire-and-forget UNSUBSCRIBE
  rather than attaching a leaked SubscribeHandle + AudioRoomPlayer to
  a discarded slot.
- Server-initiated `Closed` listener state now triggers
  `teardown(targetState=Closed)` and resets the auto-retry counter,
  so a transport-died-mid-handshake doesn't leave stale subscriptions
  in the VM map until the Activity finishes.
- Cleanup-scope split: `disconnect()` (user-driven) routes the close
  through `viewModelScope` (still alive); `onCleared()` routes it
  through a process-lived `cleanupScope` (default GlobalScope, tests
  pass backgroundScope) so MoQ control frames (UNSUBSCRIBE,
  UNANNOUNCE, SUBSCRIBE_DONE) actually land before the QUIC
  transport drops, instead of being eaten by the cancelled
  viewModelScope.

Android lifecycle fixes:
- AudioRoomBridge.clear() wired into AccountViewModel.onCleared next
  to the existing CallSessionBridge.clear() — no more cross-account
  AccountViewModel leak after logout/switch.
- registerReceiver flag now gated on Build.VERSION.SDK_INT
  TIRAMISU+ — RECEIVER_NOT_EXPORTED on pre-33 devices collides with
  RECEIVER_VISIBLE_TO_INSTANT_APPS. PendingIntents stay
  package-scoped via setPackage(packageName).
- onUserLeaveHint no longer enters PIP unconditionally: gated on
  ui.connection == Connected AND PackageManager
  FEATURE_PICTURE_IN_PICTURE present, so PIP-from-lobby /
  PIP-on-incompatible-device doesn't leave a frozen full-screen
  card in Recents.
- Replaced the process-wide singleton AudioRoomPipActions toggle
  Boolean with a per-Activity MutableSharedFlow<Unit> so a stale
  emission from a torn-down Activity can't leak into a new one.

ViewModel test was extended with `cleanupScope = backgroundScope`
plumbing so the post-disconnect `listener.close()` assertion remains
observable in the test scheduler.

Verified: spotlessApply clean; :commons:jvmTest (9 tests),
:nestsClient:jvmTest (80 tests including 2 new round-trip), and
:amethyst:compilePlayDebugKotlin all green.

Audit findings deferred to follow-up commits (none ship-blocking):
- Wire layout pinning vs draft-17 vs draft-11 (currently emits a
  draft-11-style OBJECT_DATAGRAM; we advertise draft-17). Resolve
  via the M4 manual interop pass against nostrnests.
- UNANNOUNCE racing inbound SUBSCRIBE; control-pump cancel half-write
  (audit MoQ #5, #6) — small ordering tweaks.
- Two retry coroutines stacking under fast Failed bursts (audit VM #4).
- AudioRoomForegroundService startForeground always-first contract
  (audit Android #8).
This commit is contained in:
Claude
2026-04-26 09:05:00 +00:00
parent 3816e471b7
commit f0b27654ba
7 changed files with 354 additions and 56 deletions
@@ -0,0 +1,226 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.moq
import com.vitorpamplona.nestsclient.transport.FakeWebTransport
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
/**
* End-to-end round-trip tests: an actual `MoqSession.client()` publisher
* announces, opens a track, and pushes OBJECT_DATAGRAMs which transit a
* minimal in-test relay and arrive at an actual `MoqSession.client()`
* subscriber via its `SubscribeHandle.objects` flow.
*
* The relay role here is a hand-rolled coroutine that mirrors the wire
* behavior of a real MoQ relay (e.g. nostrnests):
* - reads CLIENT_SETUP from each side, writes SERVER_SETUP back
* - matches SUBSCRIBE on the subscriber side to ANNOUNCE on the
* publisher side, forwards SUBSCRIBE to the publisher
* - matches SUBSCRIBE_OK from the publisher and forwards to the
* subscriber
* - forwards every OBJECT_DATAGRAM from publisher → subscriber
*
* It's intentionally not generic — just enough to drive one
* (announce, openTrack, send) ↔ (subscribe, collect) round trip per test.
*
* What this catches that the per-side tests don't: that what our publisher
* emits is byte-for-byte what our subscriber expects. If we ever drift on
* MoQ message layouts between publisher and listener, this fails.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MoqRoundTripTest {
@Test
fun publisher_to_subscriber_round_trip_delivers_objects_in_order() =
runTest(UnconfinedTestDispatcher()) {
val (pubA, pubB) = FakeWebTransport.pair()
val (subA, subB) = FakeWebTransport.pair()
val ns = TrackNamespace.of("nests", "round-trip-room")
val trackName = "alice-pubkey".encodeToByteArray()
val publisherSession = MoqSession.client(pubA, backgroundScope)
val subscriberSession = MoqSession.client(subA, backgroundScope)
val relayJob = launch { runRelay(pubB, subB, ns, trackName) }
publisherSession.setup(listOf(MoqVersion.DRAFT_17))
subscriberSession.setup(listOf(MoqVersion.DRAFT_17))
val announceHandle = publisherSession.announce(ns)
val publisher = announceHandle.openTrack(trackName)
// Subscribe runs on a separate coroutine because it has to stay
// alive until SUBSCRIBE_OK comes back through the relay path,
// which requires the publisher's openTrack to be present (it is,
// by virtue of the line above).
val handleDeferred =
async {
subscriberSession.subscribe(
namespace = ns,
trackName = trackName,
// Big enough that the test's burst of 100 frames
// never trips DROP_OLDEST regardless of how far the
// consumer falls behind under UnconfinedTestDispatcher.
objectBufferCapacity = 256,
)
}
val handle = handleDeferred.await()
// Push N Opus-shaped payloads through the publisher and verify
// every one arrives on the subscriber's flow with intact
// group/object ids and payload bytes. Drives the publisher loop
// on `backgroundScope` so it runs concurrently with the
// consumer's `take().toList()` collect.
val frameCount = 100
backgroundScope.launch {
repeat(frameCount) { i -> publisher.send(opusPayload(i)) }
}
val received = handle.objects.take(frameCount).toList()
assertEquals(frameCount, received.size)
received.forEachIndexed { idx, obj ->
assertEquals(0L, obj.groupId, "frame $idx groupId")
assertEquals(idx.toLong(), obj.objectId, "frame $idx objectId monotonic")
assertContentEquals(opusPayload(idx), obj.payload, "frame $idx payload")
}
handle.unsubscribe()
publisher.close()
announceHandle.unannounce()
publisherSession.close()
subscriberSession.close()
relayJob.cancel()
}
@Test
fun round_trip_unknown_namespace_subscribe_fails_with_protocol_exception() =
runTest {
val (pubA, pubB) = FakeWebTransport.pair()
val (subA, subB) = FakeWebTransport.pair()
val ns = TrackNamespace.of("nests", "real-room")
val trackName = "alice".encodeToByteArray()
val pub = MoqSession.client(pubA, backgroundScope)
val sub = MoqSession.client(subA, backgroundScope)
val relayJob = launch { runRelay(pubB, subB, ns, trackName) }
pub.setup(listOf(MoqVersion.DRAFT_17))
sub.setup(listOf(MoqVersion.DRAFT_17))
// Publisher announces "real-room" but does NOT openTrack — the
// inbound SUBSCRIBE on the publisher side will return
// SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) because no publisher is
// registered for that name.
pub.announce(ns)
val ex =
runCatching {
sub.subscribe(namespace = ns, trackName = trackName)
}.exceptionOrNull()
assertEquals(true, ex is MoqProtocolException, "expected MoqProtocolException, got $ex")
pub.close()
sub.close()
relayJob.cancel()
}
/**
* Minimal MoQ-relay simulator. Mirrors what a nests relay does on the
* wire so two of our [MoqSession]s can talk through it.
*
* Limitations vs a real relay (deliberate, this is a test fixture):
* - One namespace, one track, one subscriber per test.
* - SubscribeId / trackAlias are forwarded 1:1 across the relay (a
* real relay remaps; we don't need to since neither side reuses ids).
* - No SUBSCRIBE_DONE relay — the test calls `unsubscribe()` and
* checks it doesn't blow up; the publisher's UNSUBSCRIBE goes to
* the relay which silently drops it.
*/
private suspend fun runRelay(
pubTransport: FakeWebTransport,
subTransport: FakeWebTransport,
@Suppress("UNUSED_PARAMETER") expectedNs: TrackNamespace,
@Suppress("UNUSED_PARAMETER") expectedTrack: ByteArray,
) {
val pubControl = pubTransport.peerOpenedBidiStreams().first()
val subControl = subTransport.peerOpenedBidiStreams().first()
// SETUP both sides.
val pubCs = MoqCodec.decode(pubControl.incoming().first())!!.message as ClientSetup
pubControl.write(MoqCodec.encode(ServerSetup(pubCs.supportedVersions.first())))
val subCs = MoqCodec.decode(subControl.incoming().first())!!.message as ClientSetup
subControl.write(MoqCodec.encode(ServerSetup(subCs.supportedVersions.first())))
// Read ANNOUNCE from publisher, ack with ANNOUNCE_OK.
val announce = MoqCodec.decode(pubControl.incoming().first())!!.message as Announce
pubControl.write(MoqCodec.encode(AnnounceOk(announce.namespace)))
// Read SUBSCRIBE from subscriber, forward to publisher verbatim.
val sub = MoqCodec.decode(subControl.incoming().first())!!.message as Subscribe
pubControl.write(MoqCodec.encode(sub))
// Wait for the publisher's reply (SUBSCRIBE_OK or SUBSCRIBE_ERROR)
// and forward to the subscriber.
val pubReply = MoqCodec.decode(pubControl.incoming().first())!!.message
when (pubReply) {
is SubscribeOk -> {
subControl.write(MoqCodec.encode(pubReply))
}
is SubscribeError -> {
subControl.write(MoqCodec.encode(pubReply))
return
}
else -> {
error("unexpected publisher reply to SUBSCRIBE: ${pubReply.type}")
}
}
// Forward every OBJECT_DATAGRAM from publisher → subscriber until
// either side cancels the relay coroutine.
pubTransport.incomingDatagrams().collect { datagram ->
subTransport.sendDatagram(datagram)
}
}
private fun opusPayload(seqId: Int): ByteArray {
// Mimic an Opus packet shape (~80 bytes, varying content).
val size = 80
val out = ByteArray(size)
for (i in 0 until size) {
out[i] = ((seqId xor i).toByte())
}
return out
}
}