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:
+39
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.MainActivity
|
|||||||
class AudioRoomForegroundService : Service() {
|
class AudioRoomForegroundService : Service() {
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var promoted = false
|
private var promoted = false
|
||||||
|
private var audioFocusRequest: android.media.AudioFocusRequest? = null
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
@@ -65,6 +66,43 @@ class AudioRoomForegroundService : Service() {
|
|||||||
(getSystemService(Context.POWER_SERVICE) as PowerManager)
|
(getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "amethyst:audio-room")
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "amethyst:audio-room")
|
||||||
.apply { setReferenceCounted(false) }
|
.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(
|
override fun onStartCommand(
|
||||||
@@ -173,6 +211,7 @@ class AudioRoomForegroundService : Service() {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
wakeLock?.takeIf { it.isHeld }?.release()
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
wakeLock = null
|
wakeLock = null
|
||||||
|
abandonAudioFocus()
|
||||||
super.onDestroy()
|
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.
|
||||||
Reference in New Issue
Block a user