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
4.1 KiB
Extract NestSubscriptionManager from NestViewModel
Status: deferred — flagged in the audit pass, not landed yet.
Why
NestViewModel.kt is 2112 lines and growing. ~1000 of those lines are
the per-speaker subscription-lifecycle state machine: open / close /
reconcile / catalog-fetch / decoder-await / level-tap / mute-routing /
hush. The rest is room-level public API (connect, disconnect,
broadcast, presence, reactions, focus / network observers, UI state).
These two concerns are coupled by shared mutable state but do not share a responsibility. The audit-9 finding flagged it as the single biggest SRP breach in the touched code.
We extracted ActiveSubscription into its own file (ActiveSubscription.kt)
in commit <TBD> as a stepping stone — the deferred extraction
proper is the orchestration class.
Target shape
internal class NestSubscriptionManager(
private val viewModelScope: CoroutineScope,
private val signer: NostrSigner,
private val decoderFactory: (channelCount: Int, sampleRate: Int) -> OpusDecoder,
private val playerFactory: (channelCount: Int, sampleRate: Int) -> AudioPlayer,
private val onActiveSpeakersChanged: (Set<String>) -> Unit,
private val onSpeakerActivity: (String) -> Unit,
private val onAudioLevel: (String, Float) -> Unit,
private val onConnectingSpeakerChanged: (pubkey: String, connecting: Boolean) -> Unit,
private val effectiveListenMuted: () -> Boolean,
private val locallyHushed: () -> Set<String>,
) {
val speakerCatalogs: StateFlow<Map<String, RoomSpeakerCatalog>>
val audioLevels: StateFlow<Map<String, Float>>
fun bind(listener: NestsListener)
fun unbind() // cancel everything; release native resources
fun updateSpeakers(requested: Set<String>)
fun applyEffectiveMute()
fun setLocalHushed(pubkey: String, hushed: Boolean)
}
State that moves
From NestViewModel:
activeSubscriptions: MutableMap<String, ActiveSubscription>catalogJobs: MutableMap<String, Job>_speakerCatalogs: MutableStateFlow<Map<String, RoomSpeakerCatalog>>requestedSpeakers: Set<String>speakingExpiryJobs: MutableMap<String, Job>(foronSpeakerActivitydebounce)_audioLevelsif separable
Methods that move
reconcileSubscriptionsopenSubscription(~150 lines)closeSubscriptionfetchSpeakerCatalogawaitAudioPipelineConfigonSpeakerActivitydebounce timeronAudioLevelcoalescingapplyEffectiveListenMute's subscription-touching halfsetLocalHushed's player.setVolume halfpublishActiveSpeakers
What stays in NestViewModel
- Public lifecycle (
connect,disconnect,onCleared) - Connection state machine (
ConnectionUiState) - Broadcast state (
broadcast/_uiState.broadcast) - Presence aggregation (kind 10312 events)
- Reactions
- Focus / network observers
- The
NestUiStatecomposition itself
NestViewModel calls manager.bind(listener) on each fresh
listener and manager.unbind() on disconnect / cleanup.
updateSpeakers and mute / hush propagate through to the manager.
Why deferred
- 1000-line code move across ~10 methods + 5 state fields
- Subtle coupling: catalog readiness affects spinner state, mute
state has effective + per-speaker flavours, expiry jobs need
parent scope,
closedflag is currently a single VM-wide flag - Test surface:
NestViewModelTestexercises subscription paths through the VM today; would need to either keep that surface or add aNestSubscriptionManagerTest.
The extraction has a clean contract (callbacks for the bits that remain VM-side) but landing it without behavioural drift wants a focused review pass plus its own dedicated test rebuild — bigger than fits in the current audit-pass.
When to land
After:
- The catalog-driven decoder reconfig (T3) has run in production
for long enough that the
awaitAudioPipelineConfigpattern is proven against real publishers. - Any pending subscription-lifecycle bug fixes (e.g. the audit's earlier "boundary-rebuild dangling decoder" path) are settled — moving them mid-fix risks introducing regressions.
Track as a follow-up issue rather than a near-term must-do.