fix(audio-rooms): request audio focus + Tier-3 audit notes

Tier-3 background-audio audit (item #4 found the only gap):
AudioTrackPlayer.start() never called requestAudioFocus, so an
inbound phone call mixed on top of the room audio instead of
ducking it. Fixed by acquiring focus at the SERVICE level (one
owner per room session, not per player) in
AudioRoomForegroundService.requestAudioFocus():

  * AUDIOFOCUS_GAIN with USAGE_VOICE_COMMUNICATION +
    CONTENT_TYPE_SPEECH so the OS treats the room like a phone
    call.
  * setAcceptsDelayedFocusGain(false) — immediate focus or
    nothing.
  * On-change listener is a no-op; the OS handles duck-on-loss.
  * abandonAudioFocusRequest in onDestroy.
  * Best-effort acquire — runCatching swallows denials so a
    refused request doesn't fail service start (the OS will
    duck/pause us by its own policy, which is degraded but
    acceptable).

Audit notes file under nestsClient/plans/ documents items 1-5
with commit-time citations:
  1. PARTIAL_WAKE_LOCK — already correct
  2. FG type microphone (14+) — already correct
  3. FG type media-playback for listen-only — already correct
  4. Audio focus — fixed in THIS commit
  5. PIP keep-alive — already correct
This commit is contained in:
Claude
2026-04-26 23:40:34 +00:00
parent cdf930938d
commit cc829f6b0b
2 changed files with 86 additions and 0 deletions
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.MainActivity
class AudioRoomForegroundService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var promoted = false
private var audioFocusRequest: android.media.AudioFocusRequest? = null
override fun onCreate() {
super.onCreate()
@@ -65,6 +66,43 @@ class AudioRoomForegroundService : Service() {
(getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "amethyst:audio-room")
.apply { setReferenceCounted(false) }
requestAudioFocus()
}
/**
* 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].
*/
private fun requestAudioFocus() {
if (audioFocusRequest != null) return
val mgr = getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
val attrs =
android.media.AudioAttributes
.Builder()
.setUsage(android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val request =
android.media.AudioFocusRequest
.Builder(android.media.AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs)
.setAcceptsDelayedFocusGain(false)
.setOnAudioFocusChangeListener({ /* no-op — duck-on-loss handled by the OS */ })
.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) }
audioFocusRequest = request
}
private fun abandonAudioFocus() {
val request = audioFocusRequest ?: return
val mgr = getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
runCatching { mgr.abandonAudioFocusRequest(request) }
audioFocusRequest = null
}
override fun onStartCommand(
@@ -173,6 +211,7 @@ class AudioRoomForegroundService : Service() {
override fun onDestroy() {
wakeLock?.takeIf { it.isHeld }?.release()
wakeLock = null
abandonAudioFocus()
super.onDestroy()
}
@@ -0,0 +1,47 @@
# Background-audio audit (T3 #2)
Audit run for the Tier-3 plan checklist. Items pass-by-default;
deltas captured below.
## 1. `PARTIAL_WAKE_LOCK` during a broadcast — ✅ PASS
`amethyst/src/main/java/com/vitorpamplona/amethyst/service/audiorooms/AudioRoomForegroundService.kt`
- Acquired in `onCreate()` (line 64) via `newWakeLock(PARTIAL_WAKE_LOCK, "amethyst:audio-room")` with `setReferenceCounted(false)`.
- Acquire on every `onStartCommand` if not held (line 99-101) with a 4-hour safety cap (`WAKE_LOCK_TIMEOUT_MS`).
- Released in `onDestroy()` (line 174-175) via `wakeLock?.takeIf { it.isHeld }?.release()`.
## 2. Foreground-service type `microphone` for Android 14+ — ✅ PASS
Same file, `startForegroundWithType(includeMic)`:
- When `includeMic = true` (broadcast mode), uses `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or FOREGROUND_SERVICE_TYPE_MICROPHONE` (lines 111-112). Required by Android 14+ while the mic is open.
- The manifest already declares `android:foregroundServiceType="microphone|mediaPlayback"` (verified separately in `AndroidManifest.xml`).
## 3. Listener-only foreground type — ✅ PASS
Same call, `includeMic = false` path uses just `FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK` (line 114). Play Store policy requires the elevated `microphone` permission only when the mic is actually open; listen-only sessions correctly stick to media-playback.
## 4. Audio focus — ❌ WAS MISSING; FIXED IN THIS COMMIT
Pre-fix: `AudioTrackPlayer.start()` constructed an `AudioTrack` but never called `AudioManager.requestAudioFocus(...)`. A phone call would mix on top of the room audio instead of ducking it.
Fix: focus is now requested at the SERVICE level (one focus owner per room session, not per player) in `AudioRoomForegroundService.requestAudioFocus()`:
- `AUDIOFOCUS_GAIN` with `USAGE_VOICE_COMMUNICATION` + `CONTENT_TYPE_SPEECH` so the OS treats the room like a phone call.
- `setAcceptsDelayedFocusGain(false)` — we want the focus immediately or not at all.
- On-change listener is a no-op; the OS handles duck-on-loss for us.
- `abandonAudioFocusRequest` in `onDestroy()`.
Refused focus requests are caught with `runCatching` — a denial means the OS will duck/pause us based on its own policy, which is a degraded but acceptable experience and shouldn't fail service start.
## 5. PIP keep-alive — ✅ PASS (existing)
`AudioRoomActivity` already survives the PIP transition without dropping the foreground service. The activity's `onPictureInPictureModeChanged` doesn't stop the service; the service tracks its own `promoted` state independent of activity lifecycle.
## Summary
- 4/5 items passed before this commit
- 1 fix in `AudioRoomForegroundService` adds the audio-focus request
No follow-ups remaining for the Tier-3 audit checklist.