diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index a006b8382..f49058bee 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -37,6 +37,11 @@
+
+
+
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt
index c79670aff..dc5f06873 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt
@@ -28,13 +28,23 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
+import android.media.AudioDeviceCallback
+import android.media.AudioDeviceInfo
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.net.ConnectivityManager
+import android.net.Network
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus
+import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusState
+import com.vitorpamplona.amethyst.commons.viewmodels.NestNetworkChangeBus
import com.vitorpamplona.amethyst.ui.MainActivity
+import com.vitorpamplona.quartz.utils.Log
/**
* Process-anchor for an active audio-room session. Holds a partial wake-lock
@@ -57,7 +67,42 @@ import com.vitorpamplona.amethyst.ui.MainActivity
class NestForegroundService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var promoted = false
- private var audioFocusRequest: android.media.AudioFocusRequest? = null
+ private var audioFocusRequest: AudioFocusRequest? = null
+
+ /**
+ * Logs route changes (Bluetooth headset attach/detach, wired
+ * headset, USB audio, speakerphone) so field reports of
+ * "audio cut out when I plugged in headphones" can be correlated
+ * with a concrete device-add / device-remove event.
+ *
+ * Doesn't drive playback decisions — Android's AudioTrack +
+ * AudioRecord auto-route to whichever device the OS treats as
+ * active, so the brief silence on a route swap is unavoidable
+ * without going through the (intrusive) `MODE_IN_COMMUNICATION` +
+ * `setCommunicationDevice` flow that this service deliberately
+ * avoids. v1 ships observability only; future work could pause
+ * playback briefly across a route change to mask the swap.
+ */
+ private val deviceCallback =
+ object : AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(addedDevices: Array?) {
+ addedDevices?.forEach { dev ->
+ Log.i("NestAudio") {
+ "audio device added: type=${dev.type} name='${dev.productName}' " +
+ "isSink=${dev.isSink} isSource=${dev.isSource}"
+ }
+ }
+ }
+
+ override fun onAudioDevicesRemoved(removedDevices: Array?) {
+ removedDevices?.forEach { dev ->
+ Log.i("NestAudio") {
+ "audio device removed: type=${dev.type} name='${dev.productName}' " +
+ "isSink=${dev.isSink} isSource=${dev.isSource}"
+ }
+ }
+ }
+ }
override fun onCreate() {
super.onCreate()
@@ -67,13 +112,103 @@ class NestForegroundService : Service() {
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "amethyst:audio-room")
.apply { setReferenceCounted(false) }
requestAudioFocus()
+ registerAudioDeviceCallback()
+ registerNetworkCallback()
+ }
+
+ private fun registerAudioDeviceCallback() {
+ runCatching {
+ val mgr = getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ // null Handler → callback runs on the main looper, which is
+ // the cheapest path for "log a line" handlers like this.
+ mgr.registerAudioDeviceCallback(deviceCallback, null)
+ }
+ }
+
+ private fun unregisterAudioDeviceCallback() {
+ runCatching {
+ val mgr = getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ mgr.unregisterAudioDeviceCallback(deviceCallback)
+ }
}
/**
- * Request transient-may-duck audio focus so an inbound phone
- * call lowers the room volume cleanly instead of mixing two
- * voices on top of each other. Acquired once per service
- * lifetime; released in [onDestroy].
+ * Track the current default network so the NetworkCallback can
+ * distinguish "first onAvailable after register" (no-op) from "the
+ * default network just changed under us" (publish). Volatile because
+ * the callback fires on a binder thread; the publish path is
+ * lock-free via [NestNetworkChangeBus].
+ */
+ @Volatile private var currentDefaultNetwork: Network? = null
+
+ /**
+ * Listens for the device's default-network changing (Wi-Fi ↔
+ * cellular handover, plane mode toggle, hotspot swap) and signals
+ * every active [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel]
+ * to recycle its QUIC session. Without this nudge the QUIC
+ * connection sitting on the now-dead socket would have to wait
+ * for its PTO (~30 s) before the wrapper notices — a long
+ * audible silence on every handover.
+ *
+ * The callback also fires once right after registration with the
+ * current default network — we suppress that emission so the VM
+ * doesn't recycle on every service start.
+ */
+ private val networkCallback =
+ object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ val previous = currentDefaultNetwork
+ currentDefaultNetwork = network
+ if (previous != null && previous != network) {
+ Log.i("NestNet") {
+ "default network changed ($previous → $network), recycling QUIC sessions"
+ }
+ NestNetworkChangeBus.publish()
+ }
+ }
+
+ override fun onLost(network: Network) {
+ if (currentDefaultNetwork == network) {
+ // We've lost the current default. Don't publish here —
+ // the next onAvailable (with the replacement network)
+ // will, and recycling NOW means the wrapper would try
+ // to handshake on no network at all and fail-then-
+ // backoff. Just clear so the next onAvailable is
+ // recognised as a change.
+ currentDefaultNetwork = null
+ }
+ }
+ }
+
+ private fun registerNetworkCallback() {
+ runCatching {
+ val mgr = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ mgr.registerDefaultNetworkCallback(networkCallback)
+ }
+ }
+
+ private fun unregisterNetworkCallback() {
+ runCatching {
+ val mgr = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ mgr.unregisterNetworkCallback(networkCallback)
+ }
+ currentDefaultNetwork = null
+ }
+
+ /**
+ * Request audio focus for the duration of the audio-room session
+ * and route the system's focus-change events into [NestAudioFocusBus]
+ * so every active [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel]
+ * can react.
+ *
+ * Why we actually handle the focus change (vs the previous no-op
+ * listener): the platform only auto-ducks streams that opted into
+ * auto-ducking (CONTENT_TYPE_MUSIC, etc.) — a `CONTENT_TYPE_SPEECH`
+ * stream is left alone, so without a real listener an inbound phone
+ * call would mix on top of the room audio and a Maps voice prompt
+ * would be inaudible against an active speaker. The bus carries
+ * the translated state to the VM, which silences the listener
+ * playback and the broadcast mic for the duration of the loss.
*
* Matches the playback `AudioAttributes` we set on `AudioTrack`
* in `AudioTrackPlayer` (USAGE_MEDIA + CONTENT_TYPE_SPEECH) so
@@ -82,7 +217,7 @@ class NestForegroundService : Service() {
*/
private fun requestAudioFocus() {
if (audioFocusRequest != null) return
- val mgr = getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
+ val mgr = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val attrs =
android.media.AudioAttributes
.Builder()
@@ -90,26 +225,61 @@ class NestForegroundService : Service() {
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val request =
- android.media.AudioFocusRequest
- .Builder(android.media.AudioManager.AUDIOFOCUS_GAIN)
+ AudioFocusRequest
+ .Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setAcceptsDelayedFocusGain(false)
- .setOnAudioFocusChangeListener({ /* no-op — duck-on-loss handled by the OS */ })
- .build()
+ .setOnAudioFocusChangeListener { focusChange ->
+ NestAudioFocusBus.publish(translateFocusChange(focusChange))
+ }.build()
// Best-effort: a refused request just means the OS will
// duck/pause us based on its own policy. Don't fail the
// service start over a focus denial.
- runCatching { mgr.requestAudioFocus(request) }
+ val granted = runCatching { mgr.requestAudioFocus(request) }.getOrNull()
+ // If the OS refused outright (rare — typically only when an
+ // active call is already in progress at the moment we start),
+ // publish TransientLoss immediately so the VM mutes from t=0
+ // rather than playing for ~50 ms before the listener fires.
+ // [AudioManager.AUDIOFOCUS_REQUEST_FAILED] = 0; granted = 1.
+ if (granted == AudioManager.AUDIOFOCUS_REQUEST_FAILED) {
+ NestAudioFocusBus.publish(NestAudioFocusState.TransientLoss)
+ } else {
+ NestAudioFocusBus.publish(NestAudioFocusState.Granted)
+ }
audioFocusRequest = request
}
private fun abandonAudioFocus() {
val request = audioFocusRequest ?: return
- val mgr = getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
+ val mgr = getSystemService(Context.AUDIO_SERVICE) as AudioManager
runCatching { mgr.abandonAudioFocusRequest(request) }
audioFocusRequest = null
+ // Reset to Granted so a future foreground-service start that
+ // happens before the next focus request lands doesn't inherit
+ // a stale "muted because we lost focus" state.
+ NestAudioFocusBus.publish(NestAudioFocusState.Granted)
}
+ private fun translateFocusChange(focusChange: Int): NestAudioFocusState =
+ when (focusChange) {
+ AudioManager.AUDIOFOCUS_GAIN,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK,
+ AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
+ -> NestAudioFocusState.Granted
+
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK,
+ -> NestAudioFocusState.TransientLoss
+
+ AudioManager.AUDIOFOCUS_LOSS -> NestAudioFocusState.Loss
+
+ // Unknown future codes: be defensive and treat as Granted
+ // so a vendor-specific extension can't silently mute the
+ // room forever.
+ else -> NestAudioFocusState.Granted
+ }
+
override fun onStartCommand(
intent: Intent?,
flags: Int,
@@ -217,6 +387,8 @@ class NestForegroundService : Service() {
wakeLock?.takeIf { it.isHeld }?.release()
wakeLock = null
abandonAudioFocus()
+ unregisterAudioDeviceCallback()
+ unregisterNetworkCallback()
super.onDestroy()
}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestAudioFocusBus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestAudioFocusBus.kt
new file mode 100644
index 000000000..4e61fe35e
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestAudioFocusBus.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.commons.viewmodels
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Process-wide audio-focus signal published by the platform-side audio
+ * focus listener (Android: `NestForegroundService`'s
+ * `OnAudioFocusChangeListener`) and consumed by every active
+ * [NestViewModel].
+ *
+ * Decoupled from the platform audio APIs so commons can stay free of
+ * `android.media.AudioManager` references — the platform layer
+ * translates focus-change codes into the small enum below before
+ * publishing.
+ *
+ * Why a singleton: there's at most one foreground audio-room session
+ * per process (the foreground service is unique), and every ViewModel
+ * wants to observe the same focus signal, so keying by some other
+ * dimension would just complicate the wiring. The bus survives
+ * activity / VM rotations naturally.
+ */
+object NestAudioFocusBus {
+ private val _state = MutableStateFlow(NestAudioFocusState.Granted)
+
+ /**
+ * The latest audio-focus state. Defaults to [NestAudioFocusState.Granted]
+ * — i.e. "we own playback" — so consumers don't have to special-case
+ * the boot-up window before the foreground service has a chance to
+ * register its listener.
+ */
+ val state: StateFlow = _state.asStateFlow()
+
+ /**
+ * Publish a new focus state. Only the platform-side listener calls
+ * this; consumers observe via [state].
+ */
+ fun publish(newState: NestAudioFocusState) {
+ _state.value = newState
+ }
+}
+
+/**
+ * Audio-focus state translated from the platform's `AUDIOFOCUS_*` codes.
+ * Maps them onto the three actions an audio-room app actually cares
+ * about: keep playing, pause-because-something-else-is-playing, and
+ * stop-because-something-else-is-now-the-primary-audio-source.
+ */
+enum class NestAudioFocusState {
+ /** We own playback. Normal operation. */
+ Granted,
+
+ /**
+ * We've lost focus temporarily — typical triggers are an inbound
+ * phone call, a maps voice prompt, a system alarm. The
+ * [NestViewModel] reacts by silencing playback + the broadcast
+ * mic (the user-visible "muted" state stays unchanged so it
+ * restores on regain). Audio pipeline keeps running so resume
+ * is sample-accurate.
+ */
+ TransientLoss,
+
+ /**
+ * Permanent focus loss — another app has taken over as the
+ * primary audio source for the foreseeable future. Same effective
+ * action as [TransientLoss] in v1 (silence both directions), but
+ * the distinction is preserved so future enhancements can tear
+ * down the audio device entirely on long-form loss.
+ */
+ Loss,
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestNetworkChangeBus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestNetworkChangeBus.kt
new file mode 100644
index 000000000..3a0a5f458
--- /dev/null
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestNetworkChangeBus.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.commons.viewmodels
+
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+
+/**
+ * Process-wide signal published by the platform-side connectivity
+ * listener (Android: `NestForegroundService`'s
+ * `ConnectivityManager.NetworkCallback`) and consumed by every active
+ * [NestViewModel] to recycle its [com.vitorpamplona.nestsclient.NestsListener]
+ * / [com.vitorpamplona.nestsclient.NestsSpeaker] sessions.
+ *
+ * Why this exists: when a phone hands over from Wi-Fi to cellular (or
+ * the other way), the local socket binding's source IP changes. The
+ * QUIC connection sitting on the previous socket isn't notified of
+ * the change — it'll keep retransmitting into the void until its PTO
+ * fires (`~30 s` for an idle connection). Without an external nudge
+ * the user hears 30 seconds of silence before the wrapper's reconnect
+ * loop notices the failure. With this bus, the wrapper recycles the
+ * QUIC session the moment the platform sees the network change,
+ * shrinking the audible gap to a single re-handshake (≈ 1 s on
+ * typical mobile networks).
+ *
+ * Decoupled from `android.net.ConnectivityManager` so commons stays
+ * platform-free — the platform layer translates `onAvailable` /
+ * `onLost` callbacks into a single `publish()` event before the
+ * VM observes it.
+ */
+object NestNetworkChangeBus {
+ /**
+ * `extraBufferCapacity = 1, DROP_OLDEST` — the consumer only cares
+ * that *a* network change happened, not how many. A burst of
+ * onLost / onAvailable calls during a Wi-Fi flap collapses to a
+ * single recycle event, which is exactly what we want (one
+ * handshake instead of N).
+ */
+ private val _events =
+ MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ /**
+ * Hot flow of network-change events. Consumers ([NestViewModel])
+ * collect this and call `recycleSession()` on their listener +
+ * speaker.
+ */
+ val events: SharedFlow = _events.asSharedFlow()
+
+ /**
+ * Publish a network-change event. Only the platform-side listener
+ * calls this; consumers observe via [events].
+ */
+ fun publish() {
+ _events.tryEmit(Unit)
+ }
+}
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt
index 183bc9d27..0ec19cad5 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt
@@ -282,6 +282,34 @@ class NestViewModel(
private var speakerStateJob: Job? = null
private var speakerConnectJob: Job? = null
+ /**
+ * Mirror of [NestAudioFocusBus.state] flipped to a boolean: `true`
+ * while another app holds audio focus (inbound phone call, Maps
+ * voice prompt, system alarm). Composed with the user-visible
+ * mute states (`NestUiState.isMuted` for listeners,
+ * `BroadcastUiState.Broadcasting.isMuted` for the speaker mic) via
+ * [effectiveListenMuted] / [effectiveMicMuted] so the user-chosen
+ * state is preserved across a focus loss + regain cycle.
+ *
+ * Maintained by [audioFocusObserverJob], started in [launchConnect]
+ * (so a never-connected VM doesn't subscribe needlessly).
+ */
+ @Volatile private var focusMuted: Boolean = false
+ private var audioFocusObserverJob: Job? = null
+
+ /**
+ * Subscribed in [ensureNetworkChangeObserverStarted]; calls
+ * [com.vitorpamplona.nestsclient.NestsListener.recycleSession]
+ * (and the speaker counterpart) when the platform reports a
+ * default-network change. Without this, a Wi-Fi → cellular
+ * handover leaves the QUIC connection sitting on the now-dead
+ * socket until its PTO fires (~30 s) — the wrapper's reconnect
+ * loop only learns about the failure at PTO. With the bus
+ * nudge, recycle happens immediately and the audible gap
+ * shrinks to a single re-handshake.
+ */
+ private var networkChangeObserverJob: Job? = null
+
/**
* Push the latest known speaker set from the room event. The user's
* own pubkey (when broadcasting) is filtered out so we don't subscribe
@@ -317,13 +345,97 @@ class NestViewModel(
teardown(targetState = ConnectionUiState.Idle, finalCleanup = false)
}
+ ensureAudioFocusObserverStarted()
+ ensureNetworkChangeObserverStarted()
launchConnect()
}
+ /**
+ * Subscribe to [NestAudioFocusBus] for the lifetime of this VM (or
+ * until [teardown] cancels). On loss, mute every active listener
+ * + the broadcast mic without touching the user-visible mute
+ * states; on regain, restore the user's choice. Idempotent —
+ * a second call while the job is alive is a no-op.
+ */
+ private fun ensureAudioFocusObserverStarted() {
+ if (audioFocusObserverJob?.isActive == true) return
+ audioFocusObserverJob =
+ viewModelScope.launch {
+ NestAudioFocusBus.state.collect { focusState ->
+ if (closed) return@collect
+ val newFocusMuted = focusState != NestAudioFocusState.Granted
+ if (newFocusMuted == focusMuted) return@collect
+ focusMuted = newFocusMuted
+ // Re-apply both directions. effectiveListenMuted()
+ // and the per-broadcast effective rebuild below
+ // both consume the just-updated [focusMuted] field.
+ applyEffectiveListenMute()
+ applyEffectiveMicMute()
+ }
+ }
+ }
+
+ /**
+ * Subscribe to [NestNetworkChangeBus] for the lifetime of this VM
+ * (or until [teardown]). On a default-network change, ask both
+ * the listener and the speaker wrappers to recycle their
+ * underlying sessions — much faster than waiting for the QUIC
+ * PTO on a dead socket. Idempotent.
+ */
+ private fun ensureNetworkChangeObserverStarted() {
+ if (networkChangeObserverJob?.isActive == true) return
+ networkChangeObserverJob =
+ viewModelScope.launch {
+ NestNetworkChangeBus.events.collect {
+ if (closed) return@collect
+ // Best-effort. The wrapper-level recycleSession is a
+ // no-op on raw / non-reconnecting implementations
+ // (test fakes, IETF reference) so a network change
+ // there is silently ignored — the production paths
+ // wrap with [connectReconnectingNestsListener] /
+ // [connectReconnectingNestsSpeaker] which both
+ // override the hook to close the inner session.
+ listener?.runCatching { recycleSession() }
+ speaker?.runCatching { recycleSession() }
+ }
+ }
+ }
+
+ /**
+ * Push the effective mic mute (`user choice OR focus-loss`) onto
+ * the live broadcast handle, if any. The user-visible
+ * [BroadcastUiState.Broadcasting.isMuted] stays the user's choice
+ * — UI only reflects that, never the focus-driven temporary
+ * silencing — so a focus regain is invisible to the user.
+ */
+ private fun applyEffectiveMicMute() {
+ val handle = broadcastHandle ?: return
+ val ui = _uiState.value.broadcast
+ val userMuted = (ui as? BroadcastUiState.Broadcasting)?.isMuted ?: false
+ val effective = userMuted || focusMuted
+ viewModelScope.launch {
+ runCatching { handle.setMuted(effective) }
+ }
+ }
+
fun setMuted(muted: Boolean) {
if (closed) return
_uiState.update { it.copy(isMuted = muted) }
- activeSubscriptions.values.forEach { it.player?.setMutedSafe(muted) }
+ applyEffectiveListenMute()
+ }
+
+ /**
+ * Effective listener mute = user-chosen mute (from the talk-bar)
+ * OR audio-focus loss (phone call / nav prompt). Applied to every
+ * active player. The user-visible state (`NestUiState.isMuted`)
+ * stays the user's choice, so a focus regain restores it
+ * automatically.
+ */
+ private fun effectiveListenMuted(): Boolean = _uiState.value.isMuted || focusMuted
+
+ private fun applyEffectiveListenMute() {
+ val effective = effectiveListenMuted()
+ activeSubscriptions.values.forEach { it.player?.setMutedSafe(effective) }
}
/**
@@ -523,7 +635,13 @@ class NestViewModel(
if (closed) return
val handle = broadcastHandle ?: return
viewModelScope.launch {
- val result = handle.runCatching { setMuted(muted) }
+ // Apply (user-mute OR audio-focus-loss) to the wire so the
+ // mic stays silent through a phone call without us losing
+ // the user's intent. UI then reflects the user choice (not
+ // the focus-effective state) so a focus-driven mute doesn't
+ // visually flip the talk button.
+ val effective = muted || focusMuted
+ val result = handle.runCatching { setMuted(effective) }
if (closed) return@launch
_uiState.update {
val current = it.broadcast
@@ -866,7 +984,12 @@ class NestViewModel(
throw t
}
try {
- val isMuted = _uiState.value.isMuted
+ // Apply (user-mute OR audio-focus-loss) so a speaker
+ // that comes on stage during a phone call attaches
+ // already silenced. The focus observer re-runs
+ // applyEffectiveListenMute() on regain, restoring the
+ // user's intent.
+ val isMuted = effectiveListenMuted()
val isHushed = pubkey in _uiState.value.locallyHushed
val roomPlayer =
NestPlayer(
@@ -940,6 +1063,20 @@ class NestViewModel(
announcesJob = null
levelEmitterJob?.cancel()
levelEmitterJob = null
+ // Audio-focus observation only ends on the final teardown —
+ // a transient disconnect+reconnect (user retry, room swap)
+ // keeps the bus subscription alive so a focus loss that
+ // happens during the gap is still picked up. The observer
+ // is idempotent under [ensureAudioFocusObserverStarted], so
+ // the next [connect] call is safe whether or not we
+ // cancelled here.
+ if (finalCleanup) {
+ audioFocusObserverJob?.cancel()
+ audioFocusObserverJob = null
+ focusMuted = false
+ networkChangeObserverJob?.cancel()
+ networkChangeObserverJob = null
+ }
rawAudioLevels.clear()
if (_audioLevels.value.isNotEmpty()) {
_audioLevels.value = emptyMap()
diff --git a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt
index 1f5cfa1a1..b5645c85e 100644
--- a/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt
+++ b/nestsClient/src/androidMain/kotlin/com/vitorpamplona/nestsclient/audio/AudioRecordCapture.kt
@@ -22,6 +22,10 @@ package com.vitorpamplona.nestsclient.audio
import android.media.AudioRecord
import android.media.MediaRecorder
+import android.media.audiofx.AcousticEchoCanceler
+import android.media.audiofx.AutomaticGainControl
+import android.media.audiofx.NoiseSuppressor
+import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import android.media.AudioFormat as AndroidAudioFormat
@@ -32,6 +36,19 @@ import android.media.AudioFormat as AndroidAudioFormat
* voice-chat libraries use, so it gets the platform's echo-cancellation and
* noise-suppression filters when available.
*
+ * **Audio effects (AEC / NS / AGC).** On most modern Android devices the
+ * `VOICE_COMMUNICATION` source is enough to engage the platform's echo
+ * canceller automatically. On a small set of older / OEM-customised
+ * devices it isn't — the AEC engine only attaches when the device is in
+ * `MODE_IN_COMMUNICATION`, which an audio-room app deliberately avoids
+ * driving (it reroutes everything through the call audio path and shows a
+ * "phone call" notification icon). To cover those devices without
+ * touching `AudioManager.mode` we explicitly attach the standalone
+ * [AcousticEchoCanceler] / [NoiseSuppressor] / [AutomaticGainControl]
+ * effects to the AudioRecord's session id. On devices where the source
+ * already engages them, attaching a second effect is a no-op (the
+ * platform deduplicates by session id) — so this is purely additive.
+ *
* **Permission:** the caller is responsible for holding `RECORD_AUDIO` before
* calling [start]; this class will throw [AudioException.Kind.DeviceUnavailable]
* if the OS denies the resource.
@@ -40,6 +57,9 @@ class AudioRecordCapture(
private val source: Int = MediaRecorder.AudioSource.VOICE_COMMUNICATION,
) : AudioCapture {
private var record: AudioRecord? = null
+ private var aec: AcousticEchoCanceler? = null
+ private var ns: NoiseSuppressor? = null
+ private var agc: AutomaticGainControl? = null
private var stopped = false
override fun start() {
@@ -97,6 +117,34 @@ class AudioRecordCapture(
)
}
record = rec
+ // Attach standalone audio effects to the session. This covers
+ // devices where the VOICE_COMMUNICATION source alone doesn't
+ // engage AEC (typically because they only attach AEC under
+ // MODE_IN_COMMUNICATION). All three are best-effort — a device
+ // that doesn't support an effect leaves it null without
+ // affecting capture.
+ attachAudioEffects(rec.audioSessionId)
+ }
+
+ private fun attachAudioEffects(sessionId: Int) {
+ if (AcousticEchoCanceler.isAvailable()) {
+ aec =
+ runCatching { AcousticEchoCanceler.create(sessionId)?.apply { enabled = true } }
+ .onFailure { Log.w("NestTx") { "AcousticEchoCanceler.create failed: ${it.message}" } }
+ .getOrNull()
+ }
+ if (NoiseSuppressor.isAvailable()) {
+ ns =
+ runCatching { NoiseSuppressor.create(sessionId)?.apply { enabled = true } }
+ .onFailure { Log.w("NestTx") { "NoiseSuppressor.create failed: ${it.message}" } }
+ .getOrNull()
+ }
+ if (AutomaticGainControl.isAvailable()) {
+ agc =
+ runCatching { AutomaticGainControl.create(sessionId)?.apply { enabled = true } }
+ .onFailure { Log.w("NestTx") { "AutomaticGainControl.create failed: ${it.message}" } }
+ .getOrNull()
+ }
}
override suspend fun readFrame(): ShortArray? {
@@ -128,6 +176,16 @@ class AudioRecordCapture(
override fun stop() {
if (stopped) return
stopped = true
+ // Release the audio effects BEFORE the AudioRecord — they hold
+ // a session-id reference and the platform expects effect
+ // teardown to precede the AudioRecord.release() that frees
+ // the session.
+ runCatching { aec?.release() }
+ runCatching { ns?.release() }
+ runCatching { agc?.release() }
+ aec = null
+ ns = null
+ agc = null
val rec = record ?: return
record = null
runCatching { rec.stop() }
diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt
index 0b9e7fd95..2cc2be72c 100644
--- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt
+++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt
@@ -116,6 +116,27 @@ interface NestsListener {
"announces() is moq-lite-only; IETF listener has no announce-prefix flow.",
)
+ /**
+ * Force the underlying transport / MoQ session to be torn down and
+ * a fresh one opened in its place — without permanently closing
+ * this listener. Used by the platform layer on a network change
+ * (Wi-Fi ↔ cellular handover) to skip the ~30 s QUIC PTO that
+ * would otherwise have to fire before the wrapper notices the
+ * old socket is dead.
+ *
+ * Default no-op for non-reconnecting implementations (raw
+ * [DefaultNestsListener] / [MoqLiteNestsListener]) — there's no
+ * orchestrator to drive a reconnect, so a force recycle would
+ * just close the listener. The reconnecting wrapper
+ * (`ReconnectingHandle` from [connectReconnectingNestsListener])
+ * overrides to close the active inner listener so its
+ * orchestrator opens a fresh session.
+ */
+ suspend fun recycleSession() {
+ // no-op — only the reconnecting wrapper has anywhere to
+ // recycle to.
+ }
+
/** Tear down the MoQ session + underlying transport. Idempotent. */
suspend fun close()
}
diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt
index dd5720610..88f584cac 100644
--- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt
+++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsSpeaker.kt
@@ -66,6 +66,27 @@ interface NestsSpeaker {
*/
suspend fun startBroadcasting(onLevel: (Float) -> Unit = { /* no-op */ }): BroadcastHandle
+ /**
+ * Force the underlying transport / MoQ session to be torn down and
+ * a fresh one opened in its place — without permanently closing
+ * this speaker. Mirror of [NestsListener.recycleSession]; same
+ * use case (network handover) and same default no-op for non-
+ * reconnecting implementations.
+ *
+ * The reconnecting wrapper from
+ * [connectReconnectingNestsSpeaker] overrides to close the
+ * inner speaker so its orchestrator opens a fresh session.
+ * For moq-lite speakers, the long-lived
+ * [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster]
+ * keeps capturing through the recycle and the
+ * [BroadcastHandle.swapPublisher]-based pump retargets onto
+ * the new session's publisher with no audible gap.
+ */
+ suspend fun recycleSession() {
+ // no-op — only the reconnecting wrapper has anywhere to
+ // recycle to.
+ }
+
/** Tear down the MoQ session + transport. Idempotent. */
suspend fun close()
}
diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt
index 1e5072108..6c4e726d9 100644
--- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt
+++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsListener.kt
@@ -420,6 +420,29 @@ private class ReconnectingHandle(
)
}
+ /**
+ * Force-close the active inner listener so the orchestrator's
+ * `terminalAwait` returns Closed and the next loop iteration opens
+ * a fresh session. Used by the platform layer on a network change
+ * (Wi-Fi ↔ cellular) — without this, the wrapper would have to
+ * wait for the QUIC PTO to fire on the now-dead old socket
+ * (~30 s of silence) before noticing.
+ *
+ * The SubscribeHandle re-issuance pump cuts existing subs over to
+ * the new session as soon as it's Connected — same path the
+ * JWT-refresh recycle uses — so the consumer-facing
+ * [SubscribeHandle.objects] flow keeps emitting once the new
+ * session lands.
+ */
+ override suspend fun recycleSession() {
+ val current = activeListener.value ?: return
+ // Best-effort. If the close races a concurrent reconnect path
+ // (extremely rare — we only call this on deliberate user /
+ // platform signals), the orchestrator absorbs both via its
+ // existing terminal-state handling.
+ runCatching { current.close() }
+ }
+
override suspend fun close() {
orchestrator.cancel()
runCatching { activeListener.value?.close() }
diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt
index e22039cd1..a6ed6e68d 100644
--- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt
+++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/ReconnectingNestsSpeaker.kt
@@ -358,6 +358,17 @@ private class ReconnectingSpeakerHandle(
handle
}
+ /**
+ * Force-close the active inner speaker so the orchestrator opens
+ * a fresh session against the (presumably new) network. Used by
+ * the platform layer on a network change. Mirror of the listener
+ * wrapper's [com.vitorpamplona.nestsclient.NestsListener.recycleSession].
+ */
+ override suspend fun recycleSession() {
+ val current = activeSpeaker.value ?: return
+ runCatching { current.close() }
+ }
+
override suspend fun close() {
orchestrator.cancel()
runCatching { activeBroadcast?.close() }