fix(media): Move audio playback callback off the main thread

MediaControl registered its AudioPlaybackCallback with a null Handler,
binding delivery to the main looper. Both the callback body and the
constructor's seed read call AudioManager.isMusicActive, a binder
transaction into AudioService, producing two ANR clusters: one in
onPlaybackConfigChanged and one in <init>, the latter on the cold-start
critical path since MediaControl is constructed during App.onCreate.

Registration and seeding now run on a dedicated, injected Handler backed
by a "CAPod-MediaControl" HandlerThread, and the callback is delivered on
that same looper. Registration happens before seeding so a transition
during registration is queued behind the seed instead of being lost.

The handler is constructor-injected via a new @AudioCallbackHandler
qualifier so unit tests can drive it without Robolectric.
This commit is contained in:
darken
2026-07-28 19:07:54 +02:00
committed by Matthias Urhahn
parent 80ea6fbd68
commit a95377cf7b
3 changed files with 117 additions and 5 deletions
@@ -1,6 +1,7 @@
package eu.darken.capod.common
import android.media.AudioManager
import android.os.Handler
import io.mockk.CapturingSlot
import io.mockk.Runs
import io.mockk.clearMocks
@@ -9,6 +10,7 @@ import io.mockk.just
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import io.mockk.verifyOrder
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@@ -22,7 +24,9 @@ class MediaControlTest : BaseTest() {
private lateinit var audioManager: AudioManager
private lateinit var mediaControl: MediaControl
private lateinit var timeSource: TestTimeSource
private lateinit var handler: Handler
private lateinit var playbackCallbackSlot: CapturingSlot<AudioManager.AudioPlaybackCallback>
private lateinit var initRunnableSlot: CapturingSlot<Runnable>
@BeforeEach
fun setup() {
@@ -35,7 +39,15 @@ class MediaControlTest : BaseTest() {
every { audioManager.isMusicActive } returns false
playbackCallbackSlot = slot()
every { audioManager.registerAudioPlaybackCallback(capture(playbackCallbackSlot), any()) } just Runs
mediaControl = MediaControl(audioManager, timeSource)
// The handler is captured, not inlined: registration and seeding now happen on a posted
// runnable, and an unconditionally-inline post would hide the window that exists between
// construction and that runnable draining.
handler = mockk()
initRunnableSlot = slot()
every { handler.post(capture(initRunnableSlot)) } returns true
mediaControl = MediaControl(audioManager, timeSource, handler)
// Drain the init runnable so `playbackCallbackSlot` is populated for `fireCallback()`.
initRunnableSlot.captured.run()
}
private fun fireCallback() {
@@ -228,4 +240,72 @@ class MediaControlTest : BaseTest() {
// we don't want to claim our pause "stuck" when audio is playing.
assertFalse(mediaControl.wasRecentlyPausedByCap)
}
@Test
fun `playback callback is registered on the injected background handler, not the main looper`() {
// Regression for the ANR cluster in onPlaybackConfigChanged: passing `null` here binds
// callback delivery to the main looper, and the callback body does a binder call.
verify { audioManager.registerAudioPlaybackCallback(any(), handler) }
}
@Test
fun `construction does no binder work on the calling thread`() {
// Regression for the ANR cluster in MediaControl's constructor: it runs during
// App.onCreate via Hilt, so nothing here may touch AudioService inline.
val freshAudioManager = mockk<AudioManager>(relaxed = true)
val freshHandler = mockk<Handler>()
every { freshHandler.post(any()) } returns true
MediaControl(freshAudioManager, timeSource, freshHandler)
verify(exactly = 0) { freshAudioManager.isMusicActive }
verify(exactly = 0) { freshAudioManager.registerAudioPlaybackCallback(any(), any()) }
verify(exactly = 1) { freshHandler.post(any()) }
}
@Test
fun `init registers the callback before seeding the active flag`() {
// Seeding first would leave a gap in which a transition is neither delivered (not yet
// registered) nor reflected in the seed.
val freshAudioManager = mockk<AudioManager>(relaxed = true)
every { freshAudioManager.isMusicActive } returns false
every { freshAudioManager.registerAudioPlaybackCallback(any(), any()) } just Runs
val freshHandler = mockk<Handler>()
val runnableSlot = slot<Runnable>()
every { freshHandler.post(capture(runnableSlot)) } returns true
MediaControl(freshAudioManager, timeSource, freshHandler)
runnableSlot.captured.run()
verifyOrder {
freshAudioManager.registerAudioPlaybackCallback(any(), freshHandler)
freshAudioManager.isMusicActive
}
}
@Test
fun `media control still works before the init runnable has drained`() = runTest {
// The window between construction and the posted runnable running: no callback is live
// yet, but the key-dispatch paths must behave normally.
val freshAudioManager = mockk<AudioManager>(relaxed = true)
every { freshAudioManager.isMusicActive } returns true
every { freshAudioManager.dispatchMediaKeyEvent(any()) } just Runs
every { freshAudioManager.registerAudioPlaybackCallback(any(), any()) } just Runs
val freshHandler = mockk<Handler>()
every { freshHandler.post(any()) } returns true
val undrained = MediaControl(freshAudioManager, timeSource, freshHandler)
assertFalse(undrained.wasRecentlyPausedByCap)
assertTrue(undrained.sendPause(rememberForResume = true))
assertTrue(undrained.wasRecentlyPausedByCap)
verify(exactly = 2) { freshAudioManager.dispatchMediaKeyEvent(any()) }
clearMocks(freshAudioManager, answers = false, recordedCalls = true)
undrained.sendPlay()
assertFalse(undrained.wasRecentlyPausedByCap)
verify(exactly = 2) { freshAudioManager.dispatchMediaKeyEvent(any()) }
}
}