Merge pull request #2773 from vitorpamplona/claude/harden-quic-audio-rooms-kwqnS

feat(quic): retire fully-settled streams to keep tracker bounded under audio-room churn
This commit is contained in:
Vitor Pamplona
2026-05-07 20:19:13 -04:00
committed by GitHub
19 changed files with 3476 additions and 41 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst
import android.app.Application
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
@@ -41,6 +42,17 @@ class Amethyst : Application() {
Log.d("AmethystApp") { "onCreate $this" }
instance = AppModules(this)
// After-background foreground recycle: when the app returns to
// the foreground after spending more than ~5 s in the
// background, publish a network-change event so every active
// NestViewModel recycles its underlying QUIC session. Covers
// the case where Android reclaims our UDP socket FD while
// backgrounded — the connectivity callback in
// `NestForegroundService` doesn't fire there because the
// network itself is still up. See `AppForegroundRecycleHook`'s
// kdoc for the threshold rationale.
registerActivityLifecycleCallbacks(AppForegroundRecycleHook())
if (isDebug) {
Logging.setup()
// Auto-enable the Nests session-trace recorder in debug
@@ -0,0 +1,173 @@
/*
* 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.amethyst.service.nests
import android.app.Activity
import android.app.Application
import android.os.Bundle
import com.vitorpamplona.amethyst.commons.viewmodels.NestNetworkChangeBus
import com.vitorpamplona.quartz.utils.Log
/**
* Pure-state foreground/background tracker, decoupled from
* `android.app.Activity` so the unit tests can drive it without
* Robolectric or Mockito.
*
* See [AppForegroundRecycleHook] for the production motivation /
* threshold-rationale kdoc — this class is just the testable core.
*
* Threading: all state-mutating methods are documented to run on the
* Android main thread (Application lifecycle callbacks fire there);
* tests call them serially, so no synchronisation is needed.
*/
class AppForegroundCounter(
private val backgroundThresholdMs: Long = AppForegroundRecycleHook.DEFAULT_BACKGROUND_THRESHOLD_MS,
private val publishEvent: () -> Unit = { NestNetworkChangeBus.publish() },
private val nowMillis: () -> Long = { System.currentTimeMillis() },
) {
private var startedActivities = 0
private var lastBackgroundedAtMillis: Long = -1L
/**
* Count of recycle events fired since construction. Diagnostic
* surface for tests; production code observes the side-effect via
* [NestNetworkChangeBus] instead.
*/
var recyclesFired: Int = 0
private set
/**
* Increment the started-activity counter and, if this is the
* 0 → 1 transition AND the app spent ≥ [backgroundThresholdMs]
* in the background, fire [publishEvent]. The first
* onActivityStarted after process start is a no-op (no prior
* background timestamp to compare against).
*/
fun onActivityStarted() {
val wasBackgrounded = startedActivities == 0
startedActivities++
if (!wasBackgrounded) return
val backgroundedAt = lastBackgroundedAtMillis
if (backgroundedAt < 0L) return
val backgroundedFor = nowMillis() - backgroundedAt
if (backgroundedFor < backgroundThresholdMs) {
Log.d("AppForegroundCounter") {
"skipping recycle on resume after only ${backgroundedFor}ms background " +
"(threshold=${backgroundThresholdMs}ms)"
}
return
}
Log.d("AppForegroundCounter") {
"publishing recycle event on resume after ${backgroundedFor}ms background"
}
recyclesFired++
publishEvent()
}
/**
* Decrement the counter; on N → 0 transition, record the
* background timestamp.
*/
fun onActivityStopped() {
startedActivities--
if (startedActivities <= 0) {
startedActivities = 0
lastBackgroundedAtMillis = nowMillis()
}
}
}
/**
* Application-wide observer that publishes a
* [NestNetworkChangeBus] event when the app returns to the foreground
* after spending more than [backgroundThresholdMs] in the background.
*
* Production motivation: Android may reclaim a backgrounded app's
* UDP-socket file descriptors as it ages out of the foreground app
* pool (the kernel's watcher trims after roughly 30 s of no foreground
* activity, with quite a bit of variance per OEM). When the user
* resumes the app, the QUIC connection sitting on the now-reclaimed
* socket has dead OS-level state, but the connection-level FSM
* doesn't know that yet — the next `socket.send` throws, and only
* then does the send-loop catch surface CLOSED.
*
* The downstream
* [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel]
* already observes [NestNetworkChangeBus] for the network-handover
* case (Wi-Fi ↔ cellular). We piggy-back on the same bus here: a
* "long enough background" event has the same shape from the QUIC
* driver's perspective as a network change — recycle the underlying
* session and let the [com.vitorpamplona.nestsclient.connectReconnectingNestsListener]
* / `connectReconnectingNestsSpeaker` orchestrators reconnect.
*
* Why a threshold instead of fire-on-every-resume:
* - A short background (notification pulldown, biometric auth, lock-
* screen glance) lasts < 1 s and the socket is still healthy. A
* forced recycle there is a wasted ~1 s re-handshake gap of audio
* silence — annoying to users for no benefit.
* - A long background (call from another app, screen off > 30 s,
* home-button-then-back) commonly leaves the socket dead. Better
* to eat one re-handshake gap than 30 s of silence while the QUIC
* PTO times out.
* 5 seconds is the sweet spot: well over typical UI transitions but
* well under any plausible socket-reclaim window.
*/
class AppForegroundRecycleHook(
backgroundThresholdMs: Long = DEFAULT_BACKGROUND_THRESHOLD_MS,
publishEvent: () -> Unit = { NestNetworkChangeBus.publish() },
nowMillis: () -> Long = { System.currentTimeMillis() },
) : Application.ActivityLifecycleCallbacks {
private val counter = AppForegroundCounter(backgroundThresholdMs, publishEvent, nowMillis)
override fun onActivityStarted(activity: Activity) {
counter.onActivityStarted()
}
override fun onActivityStopped(activity: Activity) {
counter.onActivityStopped()
}
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?,
) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivitySaveInstanceState(
activity: Activity,
outState: Bundle,
) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
companion object {
/**
* Default 5 000 ms — well above the longest plausible UI
* transition (notification pull, biometric prompt) and well
* below the 30 s timing the Android kernel uses to reclaim
* idle UDP sockets.
*/
const val DEFAULT_BACKGROUND_THRESHOLD_MS = 5_000L
}
}
@@ -0,0 +1,180 @@
/*
* 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.amethyst.service.nests
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for [AppForegroundCounter] — the pure-state core of
* [AppForegroundRecycleHook]. Drives the lifecycle transitions
* directly with a controllable clock so the threshold logic doesn't
* need a real wall-clock wait.
*/
class AppForegroundRecycleHookTest {
@Test
fun firstForegroundAfterProcessStartDoesNotPublish() {
// The very first onActivityStarted has no prior background to
// recycle from — recycling here would fire a redundant
// re-handshake on every cold start, which is wasteful.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
fakeNow = 1_000L
counter.onActivityStarted()
assertEquals("first onActivityStarted must not publish — no prior background", 0, publishCount)
assertEquals(0, counter.recyclesFired)
}
@Test
fun shortBackgroundDoesNotTriggerRecycle() {
// 1 s background (notification pull, biometric prompt) is
// typical UI noise and the QUIC socket is still healthy.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
backgroundThresholdMs = 5_000L,
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
fakeNow = 1_000L
counter.onActivityStarted()
fakeNow = 2_000L
counter.onActivityStopped()
fakeNow = 3_000L // backgrounded for 1 s only
counter.onActivityStarted()
assertEquals(
"background < threshold must not publish — short transitions don't reclaim sockets",
0,
publishCount,
)
}
@Test
fun backgroundLongerThanThresholdTriggersRecycle() {
// 6 s background crosses the 5 s default threshold — Android
// may have reclaimed the socket FD by now, so recycle the
// QUIC session on resume.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
backgroundThresholdMs = 5_000L,
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
fakeNow = 1_000L
counter.onActivityStarted()
fakeNow = 2_000L
counter.onActivityStopped()
fakeNow = 8_000L // backgrounded for 6 s
counter.onActivityStarted()
assertEquals(
"background ≥ threshold must publish exactly once on resume",
1,
publishCount,
)
assertEquals(1, counter.recyclesFired)
}
@Test
fun multipleActivitiesTrackTransitionCorrectly() {
// Picture-in-picture mode is implemented as a second activity
// overlaid on the main activity. While both are started the
// app is in foreground; only when both stop does the app
// truly background.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
backgroundThresholdMs = 5_000L,
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
// First activity starts (cold start), no publish.
fakeNow = 1_000L
counter.onActivityStarted()
// Second activity (e.g. PIP / dialog) starts on top. Still
// foreground; no publish — `wasBackgrounded` was false.
fakeNow = 2_000L
counter.onActivityStarted()
// First activity stops (e.g. user backs out of main).
// counter = 1, still foreground.
fakeNow = 3_000L
counter.onActivityStopped()
assertEquals(
"intermediate stop with another activity still started must not background",
0,
publishCount,
)
// Second stops → app truly backgrounds.
fakeNow = 4_000L
counter.onActivityStopped()
// 6 s later, an activity restarts → recycle.
fakeNow = 10_000L
counter.onActivityStarted()
assertEquals(1, publishCount)
}
@Test
fun consecutiveLongBackgroundsEachPublishOnce() {
// Two separate back-and-forth cycles must each fire exactly
// one publish. A regression that misses to refresh the
// last-backgrounded timestamp on second-stop would either
// double-fire on the second resume or skip it.
var fakeNow = 0L
var publishCount = 0
val counter =
AppForegroundCounter(
backgroundThresholdMs = 5_000L,
publishEvent = { publishCount++ },
nowMillis = { fakeNow },
)
// Cycle 1: cold start → 6 s background → resume (publish #1)
fakeNow = 1_000L
counter.onActivityStarted()
fakeNow = 2_000L
counter.onActivityStopped()
fakeNow = 8_000L
counter.onActivityStarted()
assertEquals("first resume after long background must publish", 1, publishCount)
// Cycle 2: 8 s background again → resume (publish #2)
fakeNow = 10_000L
counter.onActivityStopped()
fakeNow = 18_000L
counter.onActivityStarted()
assertEquals("second resume after long background must also publish", 2, publishCount)
}
}