Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package au.com.shiftyjelly.pocketcasts.repositories.playback

import android.view.KeyEvent
import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield

/**
* Serializes media-button key events before dispatching their resolved actions.
*
* Event registration starts synchronously to preserve framework callback order. [onImmediatePlay] may therefore run
* on the caller's stack and must stay fast. [onMediaEvent] runs only after a suspension boundary, outside that
* synchronous registration section.
*/
internal class MediaButtonEventHandler(
private val scopeProvider: () -> CoroutineScope,
private val onImmediatePlay: () -> Unit,
private val onMediaEvent: (MediaEvent) -> Unit,
private val onError: (Exception) -> Unit = {
LogBuffer.e(LogBuffer.TAG_PLAYBACK, it, "Media button event handling failed")
},
) {
private val mediaEventQueue = MediaEventQueue(scopeProvider)

private val scope: CoroutineScope get() = scopeProvider()

fun handle(keyEvent: KeyEvent): Boolean {
if (keyEvent.action != KeyEvent.ACTION_DOWN) {
return false
}
Comment on lines +31 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning false for non-ACTION_DOWN events diverges from both callbacks this handler is meant to replace:

  • Media3SessionCallback.kt:200-202 returns true for a media key with action != ACTION_DOWN.
  • MediaSessionManager.kt:1262 falls through to super.onMediaButtonEvent(...).

Returning false tells Media3 the event was not consumed, so it applies its own default handling to the ACTION_UP half of every media-button press. Since every ACTION_DOWN is followed by an ACTION_UP, that risks a second action per press once #5645 wires this in (e.g. player.play()/pause() on top of the resolved tap action).

Two other gaps to keep in mind for #5645, since the handler is presented as owning "key-code mapping":

  • KEYCODE_MEDIA_PAUSE, KEYCODE_MEDIA_SKIP_FORWARD and KEYCODE_MEDIA_SKIP_BACKWARD (Media3SessionCallback.kt:210-245, including the PiP skip buttons) are not mapped here, so the callback must still intercept them before delegating.
  • The LogBuffer.i(..., "media button event: keyCode=...") breadcrumb present in both callbacks has no equivalent here; it's load-bearing for diagnosing headphone bug reports.

Worth adding a test that asserts the intended return value for ACTION_UP so the contract is pinned before the integration PR depends on it.

Fix this →


val inputEvent = when (keyEvent.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY,
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE,
KeyEvent.KEYCODE_HEADSETHOOK,
-> MediaEvent.SingleTap

KeyEvent.KEYCODE_MEDIA_NEXT -> MediaEvent.DoubleTap

KeyEvent.KEYCODE_MEDIA_PREVIOUS -> MediaEvent.TripleTap

else -> null
} ?: return false

val immediateSingleTapHandler = if (keyEvent.keyCode == KeyEvent.KEYCODE_MEDIA_PLAY) {
::handleImmediatePlay
} else {
null
}

// Register the event before returning to the framework callback. This preserves
// delivery order while the queue's timeout still resumes on the provided scope.
scope.launch(start = CoroutineStart.UNDISPATCHED) {
Comment on lines +55 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering guarantee this comment claims is best-effort, not absolute — worth softening the wording so the next reader doesn't rely on it.

UNDISPATCHED runs the body on the caller's thread only until the first real suspension. consumeEvent immediately hits stateMutex.withLock, which takes the non-suspending fast path only when the mutex is uncontended. If two media-button events genuinely overlap (the case this PR is hardening against), the second one suspends on lock() and resumes on the scope's dispatcher — so registration happens after handle() has already returned true, which is exactly the ordering the comment says is preserved.

Second consideration: UNDISPATCHED also means onImmediatePlay executes synchronously on whatever thread onMediaButtonEvent was delivered on. For Media3 that's the app's main looper, so playbackManager.playPause(...) would now run inline on the main thread instead of on the callback scope's dispatcher as it does today. The KDoc says "must stay fast", but it would be good to confirm in #5645 that the wired-up onImmediatePlay is genuinely main-safe.

try {
coroutineContext.ensureActive()
val outputEvent = mediaEventQueue.consumeEvent(
event = inputEvent,
onImmediateSingleTap = immediateSingleTapHandler,
)
if (outputEvent != null) {
// Output actions historically ran asynchronously on the callback scope.
yield()
onMediaEvent(outputEvent)
}
Comment on lines +64 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yield() is a fragile way to express "run this asynchronously": it is a no-op on a dispatcher whose isDispatchNeeded returns false (e.g. Dispatchers.Unconfined). With such a scope, and since multi-tap events return from consumeEvent without suspending, onMediaEvent would run inline on the framework callback stack — silently breaking the invariant in the KDoc.

Both current call sites use real dispatchers so this is latent rather than live, but scope.launch { onMediaEvent(outputEvent) } (or withContext(dispatcher)) states the intent directly and can't be defeated by the scope's dispatcher choice.

} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
onError(e)
}
}
return true
}

private fun handleImmediatePlay() {
try {
onImmediatePlay()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
onError(e)
}
}
Comment on lines +78 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error ownership is duplicated here, and it makes the rollback in MediaEventQueue dead code.

Because handleImmediatePlay catches Exception itself, no exception ever reaches MediaEventQueue.handleSingleTapEvent, so the rollback block at MediaEventQueue.kt:47-55 is unreachable from the only production caller. (CancellationException is rethrown, but the queue's catch (e: Exception) would catch it anyway since java.util.concurrent.CancellationException extends IllegalStateException.)

The two test files now assert opposite behaviour for the same scenario:

  • MediaEventQueueTest.kt:125 — callback throws ⇒ window is discarded, the next tap resolves as a fresh SingleTap.
  • MediaButtonEventHandlerTest.kt:58 — callback throws ⇒ window survives, the second tap resolves as DoubleTap.

Both are defensible, but only one is reachable in the app, and there is no test pinning which layer owns the decision. If someone later removes this try/catch (it looks redundant — the caller already catches), the user-visible behaviour silently flips from "double tap still works" to "double tap is swallowed".

Suggest picking one owner: either drop this try/catch and keep the queue's rollback (accepting that a failed immediate play discards a concurrent double tap), or drop the rollback in the queue and document in consumeEvent's KDoc that onImmediateSingleTap must not throw. Either way, a comment explaining the choice would help.

Fix this →

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,49 +4,70 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

internal class MediaEventQueue(
private val scopeProvider: () -> CoroutineScope,
) {
private var singleTapJob: SingleTapJob? = null
private var multiTapJob: Job? = null
private val stateMutex = Mutex()

private val scope: CoroutineScope get() = scopeProvider()

suspend fun consumeEvent(event: MediaEvent) = when (event) {
MediaEvent.SingleTap -> handleSingleTapEvent()
suspend fun consumeEvent(
event: MediaEvent,
onImmediateSingleTap: (() -> Unit)? = null,
) = when (event) {
MediaEvent.SingleTap -> handleSingleTapEvent(onImmediateSingleTap)
MediaEvent.DoubleTap, MediaEvent.TripleTap -> handleMultiTapEvent(event)
}

private suspend fun handleSingleTapEvent(): MediaEvent? {
val currentSingleTapJob = singleTapJob
return when {
// Pixel Buds (and possibly other headphones) trigger KEYCODE_MEDIA_PLAY
// after KEYCODE_MEDIA_NEXT or KEYCODE_MEDIA_PREVIOUS.
// We need to ignore it so the single tap action isn't triggered in such cases.
multiTapJob?.isActive == true -> {
null
}
private suspend fun handleSingleTapEvent(onImmediateSingleTap: (() -> Unit)?): MediaEvent? {
val newSingleTapJob = stateMutex.withLock {
val currentSingleTapJob = singleTapJob
when {
// Pixel Buds (and possibly other headphones) trigger KEYCODE_MEDIA_PLAY
// after KEYCODE_MEDIA_NEXT or KEYCODE_MEDIA_PREVIOUS.
// We need to ignore it so the single tap action isn't triggered in such cases.
multiTapJob?.isActive == true -> null

currentSingleTapJob?.isActive == true -> {
currentSingleTapJob.incrementTaps()
null
}

currentSingleTapJob?.isActive == true -> {
currentSingleTapJob.incrementTaps()
null
else -> SingleTapJob(scope).also { singleTapJob = it }
}
} ?: return null

else -> {
val newSingleTapJob = SingleTapJob(scope)
singleTapJob = newSingleTapJob
newSingleTapJob.await()
newSingleTapJob.event()
try {
onImmediateSingleTap?.invoke()
} catch (e: Exception) {
stateMutex.withLock {
if (singleTapJob === newSingleTapJob) {
singleTapJob = null
newSingleTapJob.cancel()
}
}
throw e
}
Comment on lines +45 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two problems with this rollback block:

1. The cleanup is skipped exactly when it is most likely to be needed. stateMutex.withLock is a suspend call inside a catch (e: Exception), and CancellationException is an Exception (java.util.concurrent.CancellationExceptionIllegalStateException). If the coroutine has been cancelled, this withLock throws CancellationException immediately, so singleTapJob is never cleared, newSingleTapJob.cancel() never runs, and the original exception is replaced by the cancellation. Wrap the cleanup in withContext(NonCancellable), or catch a narrower type and let cancellation flow past.

2. A concurrent double tap is silently lost. If a second tap already ran incrementTaps() (counter = 2) while the first coroutine was inside onImmediateSingleTap, the second tap returned null and is relying on this coroutine to emit DoubleTap. Rolling back throws instead, so the user's double tap disappears. Rethrowing after cleanup means nobody emits the resolved event.

} catch (e: Exception) {
    withContext(NonCancellable) {
        stateMutex.withLock {
            if (singleTapJob === newSingleTapJob) {
                singleTapJob = null
                newSingleTapJob.cancel()
            }
        }
    }
    throw e
}

Fix this →

newSingleTapJob.await()
return stateMutex.withLock {
// The immediate callback owns a resolved SingleTap. Follow-up taps still
// return their DoubleTap or TripleTap action after the window closes.
newSingleTapJob.event().takeUnless {
it == MediaEvent.SingleTap && onImmediateSingleTap != null
}
}
}

private fun handleMultiTapEvent(event: MediaEvent): MediaEvent {
private suspend fun handleMultiTapEvent(event: MediaEvent): MediaEvent = stateMutex.withLock {
val currentJob = multiTapJob
multiTapJob = scope.launch { delay(250) }
currentJob?.cancel()
return event
event
}

private class SingleTapJob(
Expand All @@ -60,6 +81,8 @@ internal class MediaEventQueue(

suspend fun await() = job.join()

fun cancel() = job.cancel()

fun incrementTaps() {
counter++
}
Comment on lines 73 to 88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: counter is a plain Int mutated from arbitrary threads, so its cross-thread visibility depends entirely on callers happening to hold stateMutex. That's true today, but it's an invariant enforced nowhere in this class — and it's the reason the final read has to be wrapped in stateMutex.withLock { newSingleTapJob.event() } at line 57 even though nothing there mutates shared state.

An AtomicInteger makes the thread-safety self-evident, lets event() be called without the lock, and removes one lock acquisition from the hot path:

Suggested change
private class SingleTapJob(
scope: CoroutineScope,
) {
private val counter = AtomicInteger(1)
private val job = scope.launch { delay(600) }
val isActive get() = job.isActive
suspend fun await() = job.join()
fun cancel() = job.cancel()
fun incrementTaps() {
counter.incrementAndGet()
}
fun event() = when (counter.get()) {
1 -> MediaEvent.SingleTap
2 -> MediaEvent.DoubleTap
else -> MediaEvent.TripleTap
}
}

(Requires import java.util.concurrent.atomic.AtomicInteger.) Alternatively, a // guarded by stateMutex comment on counter, singleTapJob and multiTapJob would document the invariant cheaply.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package au.com.shiftyjelly.pocketcasts.repositories.playback

import android.view.KeyEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
@OptIn(ExperimentalCoroutinesApi::class)
class MediaButtonEventHandlerTest {
@Test
fun `KEYCODE_MEDIA_PLAY runs the immediate action without a delayed single tap`() = runTest {
var immediatePlayCount = 0
val events = mutableListOf<MediaEvent>()
val handler = MediaButtonEventHandler(
scopeProvider = { this },
onImmediatePlay = { immediatePlayCount++ },
onMediaEvent = events::add,
)

assertTrue(handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_PLAY)))
assertEquals(1, immediatePlayCount)
assertEquals(emptyList<MediaEvent>(), events)

advanceUntilIdle()
assertEquals(emptyList<MediaEvent>(), events)
}

@Test
fun `rapid KEYCODE_MEDIA_PLAY events run the immediate action once and emit a double tap`() = runTest {
var immediatePlayCount = 0
val events = mutableListOf<MediaEvent>()
val handler = MediaButtonEventHandler(
scopeProvider = { this },
onImmediatePlay = { immediatePlayCount++ },
onMediaEvent = events::add,
)

handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_PLAY))
handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_PLAY))

assertEquals(1, immediatePlayCount)

advanceUntilIdle()
assertEquals(listOf(MediaEvent.DoubleTap), events)
}

@Test
fun `immediate play failure is reported without losing the resolved double tap`() = runTest {
val failure = IllegalStateException("Immediate action failed")
val errors = mutableListOf<Exception>()
val events = mutableListOf<MediaEvent>()
val handler = MediaButtonEventHandler(
scopeProvider = { this },
onImmediatePlay = { throw failure },
onMediaEvent = events::add,
onError = errors::add,
)

assertTrue(handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_PLAY)))
assertTrue(handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_PLAY)))

advanceUntilIdle()
assertEquals(listOf(failure), errors)
assertEquals(listOf(MediaEvent.DoubleTap), events)
}

@Test
fun `KEYCODE_MEDIA_NEXT suppresses a following KEYCODE_MEDIA_PLAY`() = runTest {
var immediatePlayCount = 0
val events = mutableListOf<MediaEvent>()
val handler = MediaButtonEventHandler(
scopeProvider = { this },
onImmediatePlay = { immediatePlayCount++ },
onMediaEvent = events::add,
)

handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_NEXT))
handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_PLAY))

assertEquals(0, immediatePlayCount)

advanceUntilIdle()
assertEquals(listOf(MediaEvent.DoubleTap), events)
}

@Test
fun `resolved multi tap actions are deferred beyond event registration`() = runTest {
val events = mutableListOf<MediaEvent>()
val handler = MediaButtonEventHandler(
scopeProvider = { this },
onImmediatePlay = {},
onMediaEvent = events::add,
)

assertTrue(handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_NEXT)))
assertEquals(emptyList<MediaEvent>(), events)

runCurrent()
assertEquals(listOf(MediaEvent.DoubleTap), events)
}

@Test
fun `cancelled scope does not handle events`() = runTest {
val cancelledJob = Job().apply { cancel() }
val cancelledScope = CoroutineScope(coroutineContext + cancelledJob)
var immediatePlayCount = 0
val events = mutableListOf<MediaEvent>()
val handler = MediaButtonEventHandler(
scopeProvider = { cancelledScope },
onImmediatePlay = { immediatePlayCount++ },
onMediaEvent = events::add,
)

assertTrue(handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_PLAY)))
assertEquals(0, immediatePlayCount)
assertEquals(emptyList<MediaEvent>(), events)
}

@Test
fun `unhandled key events return false`() = runTest {
val handler = MediaButtonEventHandler(
scopeProvider = { this },
onImmediatePlay = {},
onMediaEvent = {},
)

assertFalse(handler.handle(keyEvent(KeyEvent.KEYCODE_VOLUME_UP)))
assertFalse(handler.handle(keyEvent(KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.ACTION_UP)))
}

private fun keyEvent(
keyCode: Int,
action: Int = KeyEvent.ACTION_DOWN,
) = KeyEvent(action, keyCode)
}
Loading
Loading