fix: tear down nest session on PIP close + Just Leave

Three more leave paths now fully release the speaker session and
listener (and with them AudioRecord + the system mic indicator)
before the Activity destruction queue eats viewModelScope:

  - PIP overlay's Leave action — pipReceiver invokes a cleanup
    callback set by NestActivityBody, then finish().
  - PIP swipe-dismiss — NestActivity.onStop() invokes the same
    callback when in PIP and isFinishing. Non-PIP onStop stays a
    no-op so backgrounding doesn't tear down.
  - Host's Just leave — calls viewModel.leave() before onLeave().
    Unlike Close the Room, this still leaves the kind-30312
    meeting space open for other users.

The cleanup callback is registered via DisposableEffect in
NestActivityBody so it's auto-cleared when the composable leaves.
This commit is contained in:
Claude
2026-05-01 22:05:23 +00:00
parent 36152243ab
commit 770af8faf9
3 changed files with 59 additions and 0 deletions
@@ -83,12 +83,31 @@ class NestActivity : AppCompatActivity() {
}
ACTION_PIP_LEAVE -> {
// Tear down the speaker session + listener BEFORE
// finish() so AudioRecord (and the system mic
// indicator) releases promptly. onCleared() alone
// runs late in the destroy lifecycle.
pipCleanupAction?.invoke()
finish()
}
}
}
}
/**
* Wired by [NestActivityBody] via a DisposableEffect. Invokes
* `viewModel.leave()` so the broadcast handle + listener close
* route through `cleanupScope`/GlobalScope and survive Activity
* destruction. Called from PIP close paths (the overlay's Leave
* action and a swipe-dismiss while in PIP).
*/
@Volatile
private var pipCleanupAction: (() -> Unit)? = null
fun setPipCleanupAction(action: (() -> Unit)?) {
pipCleanupAction = action
}
private val _toggleMuteSignal =
MutableSharedFlow<Unit>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
@@ -180,6 +199,22 @@ class NestActivity : AppCompatActivity() {
enterPip()
}
/**
* Detects a PIP swipe-dismiss. When the user dismisses the floating
* window the system finishes the activity, so [isFinishing] is true
* by the time onStop runs. Tearing down here (rather than waiting
* for onCleared) keeps AudioRecord + the system mic indicator from
* lingering through the destruction queue. Non-PIP onStop (Home /
* Recents while not in PIP) is a no-op so backgrounding doesn't
* teardown.
*/
override fun onStop() {
if (isInPipMode.value && isFinishing) {
pipCleanupAction?.invoke()
}
super.onStop()
}
/**
* Enter Picture-in-Picture if the audio session is live and the
* system supports PIP. Called both by [onUserLeaveHint] (Home /
@@ -31,6 +31,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -39,6 +40,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -274,6 +276,19 @@ private fun NestActivityBody(
PipBridge(ui, pipMuteSignal, viewModel, onMuteState, onConnectedChange)
NestForegroundServiceLifecycle(ui)
// Tell NestActivity how to tear the session down when the user
// closes PIP. Both close paths in PIP — the overlay's Leave
// action and a swipe-dismiss while in PIP — invoke this so the
// speaker handle close (releases AudioRecord and clears the
// system mic indicator) and the listener close run on
// cleanupScope/GlobalScope before the activity destruction
// queue eats viewModelScope.
val activity = LocalContext.current as? NestActivity
DisposableEffect(activity, viewModel) {
activity?.setPipCleanupAction { viewModel.leave() }
onDispose { activity?.setPipCleanupAction(null) }
}
// Hand-raise is screen-local UI state; presence-publish picks it
// up via the heartbeat and emits onstage / muted / publishing
// alongside.
@@ -394,6 +394,15 @@ internal fun NestFullScreen(
TextButton(
onClick = {
showHostLeaveConfirm = false
// Tear down the speaker session + listener before
// finishing so the AudioRecord (and the system mic
// indicator) releases promptly. Same reasoning as
// the Close the Room path — onCleared() runs late
// in the destroy lifecycle and can leave the mic
// held while the activity is queued for destruction.
// Unlike Close the Room, this path leaves the
// kind-30312 meeting space open for other users.
viewModel.leave()
onLeave()
},
) {