From d14cf2c1fdfd54fd3abd6676870252fb1475bca4 Mon Sep 17 00:00:00 2001 From: joashrajin Date: Sat, 25 Jul 2026 21:22:07 +0200 Subject: [PATCH 1/2] Resume explicit media play immediately --- CHANGELOG.md | 2 + .../playback/Media3SessionCallback.kt | 51 +++------ .../playback/MediaSessionManager.kt | 65 ++++++----- .../repositories/playback/PlaybackManager.kt | 18 +++ .../playback/Media3SessionCallbackTest.kt | 82 +++++++++++--- .../MediaSessionManagerCallbackTest.kt | 107 ++++++++++++++++++ 6 files changed, 244 insertions(+), 81 deletions(-) create mode 100644 modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManagerCallbackTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b0203208f2..fca87241100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * Add option to switch to the HLS video stream when playing a downloaded episode ([#5620](https://github.com/Automattic/pocket-casts-android/pull/5620)) * Bug Fixes + * Fix Bluetooth play controls resuming slowly and playback repeatedly pausing on wireless Android Auto + ([#5645](https://github.com/Automattic/pocket-casts-android/pull/5645)) * Allow copying description and show notes links with a long press ([#5628](https://github.com/Automattic/pocket-casts-android/pull/5628)) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallback.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallback.kt index 3c800bfb014..1ab26e49c73 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallback.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallback.kt @@ -67,7 +67,17 @@ internal class Media3SessionCallback( private val scope: CoroutineScope get() = scopeProvider() - private val mediaEventQueue = MediaEventQueue(scopeProvider = scopeProvider) + private val mediaButtonEventHandler = MediaButtonEventHandler( + scopeProvider = scopeProvider, + onImmediatePlay = { playbackManager.playIfNotPlaying(sourceView = source) }, + onMediaEvent = { event -> + when (event) { + MediaEvent.SingleTap -> handleMediaButtonSingleTap() + MediaEvent.DoubleTap -> handleMediaButtonDoubleTap() + MediaEvent.TripleTap -> handleMediaButtonTripleTap() + } + }, + ) override fun onConnect( session: MediaSession, @@ -204,9 +214,10 @@ internal class Media3SessionCallback( LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media3 media button event: keyCode=${keyEvent.keyCode}") // Dedicated pause key has explicit semantics — handle it directly. - // KEYCODE_MEDIA_PLAY is routed through the multi-tap system because some - // Bluetooth headphones (e.g. Pixel Buds) send it spuriously after multi-tap - // sequences, and the MediaEventQueue suppresses those duplicates. + // KEYCODE_MEDIA_PLAY stays in the multi-tap system because some Bluetooth + // headphones send it spuriously after multi-tap sequences. Its play-only + // action runs as soon as the queue accepts the first tap, without waiting + // for the multi-tap window to expire. when (keyEvent.keyCode) { KeyEvent.KEYCODE_MEDIA_PAUSE -> { scope.launch { playbackManager.pauseSuspend(sourceView = source) } @@ -244,37 +255,7 @@ internal class Media3SessionCallback( } } - 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 - } - - if (inputEvent != null) { - scope.launch { - try { - val outputEvent = mediaEventQueue.consumeEvent(inputEvent) - when (outputEvent) { - MediaEvent.SingleTap -> handleMediaButtonSingleTap() - MediaEvent.DoubleTap -> handleMediaButtonDoubleTap() - MediaEvent.TripleTap -> handleMediaButtonTripleTap() - null -> Unit - } - } catch (e: Exception) { - Timber.e(e, "Media button event handling failed") - } - } - return true - } - - return false + return mediaButtonEventHandler.handle(keyEvent) } private fun handleMediaButtonSingleTap() { diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt index 3b7495d37b9..2f4c56fac1d 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManager.kt @@ -252,6 +252,7 @@ class MediaSessionManager( LogBuffer.e(LogBuffer.TAG_PLAYBACK, "Failed to add command to queue: $tag") } }, + scopeProvider = { scope }, ), ) } @@ -1218,48 +1219,50 @@ class MediaSessionManager( stateBuilder.addCustomAction(skipBackBuilder.build()) } - inner class MediaSessionCallback( + internal inner class MediaSessionCallback( val playbackManager: PlaybackManager, val episodeManager: EpisodeManager, val enqueueCommand: (String, suspend () -> Unit) -> Unit, + scopeProvider: () -> CoroutineScope, ) : MediaSessionCompat.Callback() { private var playFromSearchDisposable: Disposable? = null - private val mediaEventQueue = MediaEventQueue(scopeProvider = { this@MediaSessionManager.scope }) + private val mediaButtonEventHandler = MediaButtonEventHandler( + scopeProvider = scopeProvider, + onImmediatePlay = { + playbackManager.playIfNotPlaying(sourceView = source) + }, + onMediaEvent = { event -> + LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button output event: $event") + when (event) { + MediaEvent.SingleTap -> handleMediaButtonSingleTap() + MediaEvent.DoubleTap -> handleMediaButtonDoubleTap() + MediaEvent.TripleTap -> handleMediaButtonTripleTap() + } + }, + ) override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { - if (Intent.ACTION_MEDIA_BUTTON == mediaButtonEvent.action) { - val keyEvent = IntentCompat.getParcelableExtra(mediaButtonEvent, Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) ?: return false - logEvent(keyEvent.toString()) - if (keyEvent.action == KeyEvent.ACTION_DOWN) { - LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button Android event: ${keyEvent.action}") - 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 - } - LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button input event: ${keyEvent.action}") - - if (inputEvent != null) { - scope.launch { - val outputEvent = mediaEventQueue.consumeEvent(inputEvent) - LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button output event: ${keyEvent.action}") - when (outputEvent) { - MediaEvent.SingleTap -> handleMediaButtonSingleTap() - MediaEvent.DoubleTap -> handleMediaButtonDoubleTap() - MediaEvent.TripleTap -> handleMediaButtonTripleTap() - null -> Unit - } - } - return true - } - } - } else { + if (Intent.ACTION_MEDIA_BUTTON != mediaButtonEvent.action) { logEvent("onMediaButtonEvent(${mediaButtonEvent.action ?: "unknown action"})") + return super.onMediaButtonEvent(mediaButtonEvent) } - return super.onMediaButtonEvent(mediaButtonEvent) + val keyEvent = IntentCompat.getParcelableExtra( + mediaButtonEvent, + Intent.EXTRA_KEY_EVENT, + KeyEvent::class.java, + ) ?: return false + logEvent(keyEvent.toString()) + if (keyEvent.action == KeyEvent.ACTION_DOWN) { + LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Media button Android event: keyCode=${keyEvent.keyCode}") + } + + return if (mediaButtonEventHandler.handle(keyEvent)) { + true + } else { + super.onMediaButtonEvent(mediaButtonEvent) + } } private fun onAddBookmark() { diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt index f17df8a3a34..01d36272f93 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt @@ -621,6 +621,24 @@ open class PlaybackManager @Inject constructor( } } + /** + * Plays the queue only if playback isn't already running, unlike the [playPause] toggle. + * Used for KEYCODE_MEDIA_PLAY, which has explicit play semantics: some head units + * (wireless Android Auto in particular) send it redundantly while playback is already + * running, and toggling would pause playback. + */ + fun playIfNotPlaying(sourceView: SourceView = SourceView.UNKNOWN) { + if (isPlaying()) { + LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Ignoring play request because playback is already playing") + } else { + LogBuffer.i( + LogBuffer.TAG_PLAYBACK, + "Starting playback for explicit play request from source=$sourceView", + ) + playQueue(sourceView) + } + } + fun playQueue( sourceView: SourceView = SourceView.UNKNOWN, showedStreamWarning: Boolean = false, diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt index 3abfc92915b..4de966e7970 100644 --- a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt +++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt @@ -10,6 +10,7 @@ import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import androidx.media3.session.SessionError import androidx.media3.session.SessionResult +import au.com.shiftyjelly.pocketcasts.analytics.SourceView import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.models.entity.UserEpisode import au.com.shiftyjelly.pocketcasts.preferences.Settings @@ -31,6 +32,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.doCallRealMethod import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -340,12 +342,74 @@ class Media3SessionCallbackTest { // --- Headphone action handler tests --- @Test - fun `KEYCODE_MEDIA_PLAY routes through multi-tap as single tap`() = runTest { + fun `KEYCODE_MEDIA_PLAY while paused starts playback before the multi-tap window expires`() = runTest { + doCallRealMethod().whenever(playbackManager).playIfNotPlaying(any()) + whenever(playbackManager.isPlaying()).thenReturn(false) + sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY) + + // A dedicated play key must not wait 600 ms for tap disambiguation. + verify(playbackManager).playQueue( + sourceView = eq(SourceView.MEDIA_BUTTON_BROADCAST_ACTION), + showedStreamWarning = any(), + ) + verify(playbackManager, never()).playPause(sourceView = any()) + testScope.advanceUntilIdle() - // Routed through MediaEventQueue — single tap resolves as play/pause - verify(playbackManager).playPause(sourceView = any()) + // A lone dedicated play key has already been handled, so the delayed + // single-tap result must not toggle playback. + verify(playbackManager, never()).playPause(sourceView = any()) + } + + @Test + fun `KEYCODE_MEDIA_PLAY while paused resumes before the double-tap action`() = runTest { + mockHeadphoneNextAction(HeadphoneAction.ADD_BOOKMARK) + doCallRealMethod().whenever(playbackManager).playIfNotPlaying(any()) + whenever(playbackManager.isPlaying()).thenReturn(false) + + sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY) + verify(playbackManager).playQueue( + sourceView = eq(SourceView.MEDIA_BUTTON_BROADCAST_ACTION), + showedStreamWarning = any(), + ) + + sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE) + testScope.advanceUntilIdle() + shadowOf(android.os.Looper.getMainLooper()).idle() + + verify(bookmarkHelper).handleAddBookmarkAction(any(), any()) + verify(playbackManager, never()).playPause(sourceView = any()) + } + + @Test + fun `KEYCODE_MEDIA_PLAY stays suppressed after KEYCODE_MEDIA_NEXT`() = runTest { + mockHeadphoneNextAction(HeadphoneAction.SKIP_FORWARD) + mockSkipSettings() + whenever(playbackManager.isPlaying()).thenReturn(true) + + sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_NEXT) + sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY) + testScope.advanceUntilIdle() + + verify(playbackManager, never()).playIfNotPlaying(sourceView = any()) + verify(playbackManager).skipForwardSuspend( + sourceView = any(), + jumpAmountSeconds = eq(30), + ) + } + + @Test + fun `KEYCODE_MEDIA_PLAY while already playing does not toggle`() = runTest { + doCallRealMethod().whenever(playbackManager).playIfNotPlaying(any()) + whenever(playbackManager.isPlaying()).thenReturn(true) + + sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_PLAY) + testScope.advanceUntilIdle() + + verify(playbackManager, never()).playQueue(any(), any()) + verify(playbackManager, never()).pause(any(), any()) + verify(playbackManager, never()).playPause(any()) } @Test @@ -382,18 +446,6 @@ class Media3SessionCallbackTest { ) } - @Test - fun `double tap with ADD_BOOKMARK setting calls bookmarkHelper`() = runTest { - mockHeadphoneNextAction(HeadphoneAction.ADD_BOOKMARK) - - sendMediaButtonEvent(KeyEvent.KEYCODE_MEDIA_NEXT) - testScope.advanceUntilIdle() - // The bookmark action dispatches on Dispatchers.Main, so idle the main looper - shadowOf(android.os.Looper.getMainLooper()).idle() - - verify(bookmarkHelper).handleAddBookmarkAction(any(), any()) - } - @Test fun `triple tap with SKIP_BACK setting calls skipBackwardSuspend`() = runTest { mockHeadphonePreviousAction(HeadphoneAction.SKIP_BACK) diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManagerCallbackTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManagerCallbackTest.kt new file mode 100644 index 00000000000..9a39b967373 --- /dev/null +++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManagerCallbackTest.kt @@ -0,0 +1,107 @@ +package au.com.shiftyjelly.pocketcasts.repositories.playback + +import android.content.Intent +import android.view.KeyEvent +import au.com.shiftyjelly.pocketcasts.analytics.SourceView +import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.preferences.UserSetting +import au.com.shiftyjelly.pocketcasts.preferences.model.HeadphoneAction +import au.com.shiftyjelly.pocketcasts.repositories.bookmark.BookmarkManager +import au.com.shiftyjelly.pocketcasts.repositories.playlist.PlaylistManager +import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager +import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager +import com.automattic.eventhorizon.EventHorizon +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class MediaSessionManagerCallbackTest { + @Test + fun `legacy KEYCODE_MEDIA_PLAY runs play-only action before the multi-tap window expires`() = runTest { + val playbackManager = mock() + val episodeManager = mock() + val manager = createManager(this, playbackManager, episodeManager) + val callback = manager.createCallback(this, playbackManager, episodeManager) + + callback.onMediaButtonEvent(mediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PLAY)) + + verify(playbackManager).playIfNotPlaying(SourceView.MEDIA_BUTTON_BROADCAST_ACTION) + verify(playbackManager, never()).playPause(any()) + + advanceUntilIdle() + verify(playbackManager, never()).playPause(any()) + } + + @Test + fun `legacy KEYCODE_MEDIA_NEXT suppresses a following KEYCODE_MEDIA_PLAY`() = runTest { + val playbackManager = mock() + val episodeManager = mock() + val settings = mock() + val nextAction = mock>() + whenever(settings.headphoneControlsNextAction).thenReturn(nextAction) + whenever(nextAction.value).thenReturn(HeadphoneAction.SKIP_FORWARD) + whenever(playbackManager.isPlaying()).thenReturn(true) + val manager = createManager(this, playbackManager, episodeManager, settings) + val queuedCommands = mutableListOf() + val callback = manager.createCallback( + scope = this, + playbackManager = playbackManager, + episodeManager = episodeManager, + enqueueCommand = { tag, _ -> queuedCommands += tag }, + ) + + callback.onMediaButtonEvent(mediaButtonIntent(KeyEvent.KEYCODE_MEDIA_NEXT)) + callback.onMediaButtonEvent(mediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PLAY)) + advanceUntilIdle() + + verify(playbackManager, never()).playIfNotPlaying(any()) + assertEquals(listOf("skip forwards"), queuedCommands) + } + + private fun createManager( + scope: CoroutineScope, + playbackManager: PlaybackManager, + episodeManager: EpisodeManager, + settings: Settings = mock(), + ) = MediaSessionManager( + playbackManager = playbackManager, + podcastManager = mock(), + episodeManager = episodeManager, + playlistManager = mock(), + settings = settings, + context = RuntimeEnvironment.getApplication(), + eventHorizon = mock(), + bookmarkManager = mock(), + browseTreeProvider = mock(), + applicationScope = scope, + ) + + private fun MediaSessionManager.createCallback( + scope: CoroutineScope, + playbackManager: PlaybackManager, + episodeManager: EpisodeManager, + enqueueCommand: (String, suspend () -> Unit) -> Unit = { _, _ -> }, + ) = MediaSessionCallback( + playbackManager = playbackManager, + episodeManager = episodeManager, + enqueueCommand = enqueueCommand, + scopeProvider = { scope }, + ) + + private fun mediaButtonIntent(keyCode: Int) = Intent(Intent.ACTION_MEDIA_BUTTON).apply { + putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(KeyEvent.ACTION_DOWN, keyCode)) + } +} From 361b8f64e5f0052d36751cf5d96ddfda00afb43f Mon Sep 17 00:00:00 2001 From: joashrajin Date: Sat, 25 Jul 2026 22:19:56 +0200 Subject: [PATCH 2/2] Address stacked media play review feedback --- .../repositories/playback/PlaybackManager.kt | 5 ++- .../playback/Media3SessionCallbackTest.kt | 4 +- .../MediaSessionManagerCallbackTest.kt | 43 +++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt index 01d36272f93..86b1fc36850 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt @@ -625,7 +625,8 @@ open class PlaybackManager @Inject constructor( * Plays the queue only if playback isn't already running, unlike the [playPause] toggle. * Used for KEYCODE_MEDIA_PLAY, which has explicit play semantics: some head units * (wireless Android Auto in particular) send it redundantly while playback is already - * running, and toggling would pause playback. + * running, and toggling would pause playback. Media-button callbacks invoke it immediately + * instead of waiting for multi-tap disambiguation. */ fun playIfNotPlaying(sourceView: SourceView = SourceView.UNKNOWN) { if (isPlaying()) { @@ -633,7 +634,7 @@ open class PlaybackManager @Inject constructor( } else { LogBuffer.i( LogBuffer.TAG_PLAYBACK, - "Starting playback for explicit play request from source=$sourceView", + "Explicit play request from source=$sourceView", ) playQueue(sourceView) } diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt index 4de966e7970..82fc3b7afe5 100644 --- a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt +++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt @@ -20,8 +20,8 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import java.util.Date import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -67,7 +67,7 @@ class Media3SessionCallbackTest { bookmarkHelper = mock() mockSession = mock() mockController = mock() - testScope = TestScope(UnconfinedTestDispatcher()) + testScope = TestScope(StandardTestDispatcher()) callback = Media3SessionCallback( playbackManager = playbackManager, diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManagerCallbackTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManagerCallbackTest.kt index 9a39b967373..7014b524728 100644 --- a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManagerCallbackTest.kt +++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/MediaSessionManagerCallbackTest.kt @@ -71,6 +71,49 @@ class MediaSessionManagerCallbackTest { assertEquals(listOf("skip forwards"), queuedCommands) } + @Test + fun `legacy KEYCODE_HEADSETHOOK resolves to play pause`() = runTest { + val playbackManager = mock() + val episodeManager = mock() + val manager = createManager(this, playbackManager, episodeManager) + val callback = manager.createCallback(this, playbackManager, episodeManager) + + callback.onMediaButtonEvent(mediaButtonIntent(KeyEvent.KEYCODE_HEADSETHOOK)) + advanceUntilIdle() + + verify(playbackManager).playPause(SourceView.MEDIA_BUTTON_BROADCAST_ACTION) + verify(playbackManager, never()).playIfNotPlaying(any()) + } + + @Test + fun `legacy rapid KEYCODE_MEDIA_PLAY resolves previous action after immediate play`() = runTest { + val playbackManager = mock() + val episodeManager = mock() + val settings = mock() + val previousAction = mock>() + whenever(settings.headphoneControlsPreviousAction).thenReturn(previousAction) + whenever(previousAction.value).thenReturn(HeadphoneAction.SKIP_BACK) + val manager = createManager(this, playbackManager, episodeManager, settings) + val queuedCommands = mutableListOf() + val callback = manager.createCallback( + scope = this, + playbackManager = playbackManager, + episodeManager = episodeManager, + enqueueCommand = { tag, _ -> queuedCommands += tag }, + ) + + repeat(3) { + callback.onMediaButtonEvent(mediaButtonIntent(KeyEvent.KEYCODE_MEDIA_PLAY)) + } + + verify(playbackManager).playIfNotPlaying(SourceView.MEDIA_BUTTON_BROADCAST_ACTION) + + advanceUntilIdle() + + assertEquals(listOf("skip backwards"), queuedCommands) + verify(playbackManager, never()).playPause(any()) + } + private fun createManager( scope: CoroutineScope, playbackManager: PlaybackManager,