feat(android): LikeMediaCallback for media-session like button
android / Build + lint + test (push) Failing after 1m29s

New MediaSession.Callback that grants CMD_TOGGLE_LIKE in onConnect
(Media3 issue #2679 guard) and routes onCustomCommand through
LikesRepository.toggleLike so notification/lock-screen/Pixel-Watch
taps inherit the offline-resilient MutationQueue path.

Unit tests cover the onConnect grant, current-state inversion in
both directions, no-op when there is no current MediaItem, and
rejection of unknown custom actions.

MinstrelPlayerService wiring lands in a follow-up commit.
This commit is contained in:
2026-06-02 23:48:25 -04:00
parent ad7e57fe66
commit d37ef56bb1
2 changed files with 175 additions and 0 deletions
@@ -0,0 +1,72 @@
package com.fabledsword.minstrel.player
import android.os.Bundle
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* MediaSession callback that adds the like/heart custom command to
* every connecting controller (notification, lock screen, Pixel Watch,
* Android Auto) and routes taps through [LikesRepository.toggleLike]
* so they inherit the offline-resilient MutationQueue path.
*
* The [onConnect] grant is REQUIRED — without it the button is
* silently invisible on some surfaces (Media3 issue #2679). See the
* spec at docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
*
* Suspend work in [onCustomCommand] is launched on [scope] (a
* service-lifetime scope) so the callback returns synchronously and
* the controller does not block on Room + REST. State updates flow
* back through [LikesRepository.observeIsLiked] so the icon refreshes
* via the like-state job in [MinstrelPlayerService].
*/
class LikeMediaCallback(
private val likes: LikesRepository,
private val scope: CoroutineScope,
) : MediaSession.Callback {
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo,
): MediaSession.ConnectionResult {
val grants = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
.buildUpon()
.add(SessionCommand(CMD_TOGGLE_LIKE, Bundle.EMPTY))
.build()
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(grants)
.build()
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle,
): ListenableFuture<SessionResult> {
if (customCommand.customAction != CMD_TOGGLE_LIKE) {
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
}
val mediaId = session.player.currentMediaItem?.mediaId
?: return Futures.immediateFuture(SessionResult(SessionResult.RESULT_INFO_SKIPPED))
scope.launch {
val current = likes.observeIsLiked(ENTITY_TRACK, mediaId).first()
likes.toggleLike(ENTITY_TRACK, mediaId, !current)
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
companion object {
/** Custom-action key for the like/heart button. Namespaced so
* future custom commands (output picker, etc.) don't collide. */
const val CMD_TOGGLE_LIKE: String = "minstrel.toggle_like"
}
}
@@ -0,0 +1,103 @@
package com.fabledsword.minstrel.player
import android.os.Bundle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class LikeMediaCallbackTest {
private val scope = TestScope(UnconfinedTestDispatcher())
private val likes = mockk<LikesRepository>(relaxed = true)
private val session = mockk<MediaSession>()
private val controllerInfo = mockk<MediaSession.ControllerInfo>()
private val callback = LikeMediaCallback(likes, scope)
@Test
fun `onConnect adds CMD_TOGGLE_LIKE to available session commands`() {
val result = callback.onConnect(session, controllerInfo)
assertTrue(
result.availableSessionCommands.contains(
SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY),
),
)
}
@Test
fun `onCustomCommand routes toggle when current track has a mediaId`() {
val item = MediaItem.Builder().setMediaId("track-abc").build()
val player = mockk<Player> { every { currentMediaItem } returns item }
every { session.player } returns player
coEvery { likes.observeIsLiked(ENTITY_TRACK, "track-abc") } returns flowOf(false)
val future = callback.onCustomCommand(
session,
controllerInfo,
SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY),
Bundle.EMPTY,
)
assertEquals(SessionResult.RESULT_SUCCESS, future.get().resultCode)
coVerify(exactly = 1) { likes.toggleLike(ENTITY_TRACK, "track-abc", true) }
}
@Test
fun `onCustomCommand inverts current liked state`() {
val item = MediaItem.Builder().setMediaId("track-xyz").build()
val player = mockk<Player> { every { currentMediaItem } returns item }
every { session.player } returns player
coEvery { likes.observeIsLiked(ENTITY_TRACK, "track-xyz") } returns flowOf(true)
callback.onCustomCommand(
session,
controllerInfo,
SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY),
Bundle.EMPTY,
)
coVerify(exactly = 1) { likes.toggleLike(ENTITY_TRACK, "track-xyz", false) }
}
@Test
fun `onCustomCommand is no-op when no currentMediaItem`() {
val player = mockk<Player> { every { currentMediaItem } returns null }
every { session.player } returns player
val future = callback.onCustomCommand(
session,
controllerInfo,
SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY),
Bundle.EMPTY,
)
assertEquals(SessionResult.RESULT_INFO_SKIPPED, future.get().resultCode)
coVerify(exactly = 0) { likes.toggleLike(any(), any(), any()) }
}
@Test
fun `onCustomCommand rejects unknown actions`() {
val future = callback.onCustomCommand(
session,
controllerInfo,
SessionCommand("garbage", Bundle.EMPTY),
Bundle.EMPTY,
)
assertEquals(SessionResult.RESULT_ERROR_NOT_SUPPORTED, future.get().resultCode)
}
}