Generate media control action buttons from Media3

Instead of using the framework PlaybackState API, use the session token
to get a Media3 controller, and use that to generate the media control action
buttons, following the same scheme as currently

Flag: com.android.systemui.media_controls_button_media3
Bug: 360196209
Test: atest Media3ActionFactoryTest MediaDataLoaderTest
Test: manual
Change-Id: I884ff9f39bec01298af1241200fc98278ee35413
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp
index 5f90b39..331df78 100644
--- a/packages/SystemUI/Android.bp
+++ b/packages/SystemUI/Android.bp
@@ -540,6 +540,8 @@
         "androidx.room_room-runtime",
         "androidx.room_room-ktx",
         "androidx.datastore_datastore-preferences",
+        "androidx.media3.media3-common",
+        "androidx.media3.media3-session",
         "com.google.android.material_material",
         "device_state_flags_lib",
         "kotlinx_coroutines_android",
@@ -707,6 +709,8 @@
         "androidx.room_room-testing",
         "androidx.room_room-ktx",
         "androidx.datastore_datastore-preferences",
+        "androidx.media3.media3-common",
+        "androidx.media3.media3-session",
         "device_state_flags_lib",
         "kotlinx-coroutines-android",
         "kotlinx-coroutines-core",
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/Media3ActionFactoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/Media3ActionFactoryTest.kt
new file mode 100644
index 0000000..9e3fdf3
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/Media3ActionFactoryTest.kt
@@ -0,0 +1,335 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.media.controls.domain.pipeline
+
+import android.media.session.MediaSession
+import android.os.Bundle
+import android.os.Handler
+import android.os.looper
+import android.testing.TestableLooper
+import android.testing.TestableLooper.RunWithLooper
+import androidx.media.utils.MediaConstants
+import androidx.media3.common.Player
+import androidx.media3.session.CommandButton
+import androidx.media3.session.MediaController as Media3Controller
+import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionToken
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.graphics.imageLoader
+import com.android.systemui.kosmos.testScope
+import com.android.systemui.media.controls.shared.mediaLogger
+import com.android.systemui.media.controls.shared.model.MediaButton
+import com.android.systemui.media.controls.util.fakeMediaControllerFactory
+import com.android.systemui.media.controls.util.fakeSessionTokenFactory
+import com.android.systemui.res.R
+import com.android.systemui.testKosmos
+import com.google.common.collect.ImmutableList
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.clearInvocations
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+
+private const val PACKAGE_NAME = "package_name"
+private const val CUSTOM_ACTION_NAME = "Custom Action"
+private const val CUSTOM_ACTION_COMMAND = "custom-action"
+
+@SmallTest
+@RunWithLooper
+@RunWith(AndroidJUnit4::class)
+class Media3ActionFactoryTest : SysuiTestCase() {
+
+    private val kosmos = testKosmos()
+    private val testScope = kosmos.testScope
+    private val controllerFactory = kosmos.fakeMediaControllerFactory
+    private val tokenFactory = kosmos.fakeSessionTokenFactory
+    private lateinit var testableLooper: TestableLooper
+
+    private var commandCaptor = argumentCaptor<SessionCommand>()
+    private var runnableCaptor = argumentCaptor<Runnable>()
+
+    private val legacyToken = MediaSession.Token(1, null)
+    private val token = mock<SessionToken>()
+    private val handler =
+        mock<Handler> {
+            on { post(runnableCaptor.capture()) } doAnswer
+                {
+                    runnableCaptor.lastValue.run()
+                    true
+                }
+        }
+    private val customLayout = ImmutableList.of<CommandButton>()
+    private val media3Controller =
+        mock<Media3Controller> {
+            on { customLayout } doReturn customLayout
+            on { sessionExtras } doReturn Bundle()
+            on { isCommandAvailable(any()) } doReturn true
+            on { isSessionCommandAvailable(any<SessionCommand>()) } doReturn true
+        }
+
+    private lateinit var underTest: Media3ActionFactory
+
+    @Before
+    fun setup() {
+        testableLooper = TestableLooper.get(this)
+
+        underTest =
+            Media3ActionFactory(
+                context,
+                kosmos.imageLoader,
+                controllerFactory,
+                tokenFactory,
+                kosmos.mediaLogger,
+                kosmos.looper,
+                handler,
+                kosmos.testScope,
+            )
+
+        controllerFactory.setMedia3Controller(media3Controller)
+        tokenFactory.setMedia3SessionToken(token)
+    }
+
+    @Test
+    fun media3Actions_playingState_withCustomActions() =
+        testScope.runTest {
+            // Media is playing, all commands available, with custom actions
+            val customLayout = ImmutableList.copyOf((0..1).map { createCustomCommandButton(it) })
+            whenever(media3Controller.customLayout).thenReturn(customLayout)
+            whenever(media3Controller.isPlaying).thenReturn(true)
+            val result = getActions()
+
+            assertThat(result).isNotNull()
+
+            val actions = result!!
+            assertThat(actions.playOrPause!!.contentDescription)
+                .isEqualTo(context.getString(R.string.controls_media_button_pause))
+            actions.playOrPause!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).pause()
+            verify(media3Controller).release()
+            clearInvocations(media3Controller)
+
+            assertThat(actions.prevOrCustom!!.contentDescription)
+                .isEqualTo(context.getString(R.string.controls_media_button_prev))
+            actions.prevOrCustom!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).seekToPrevious()
+            verify(media3Controller).release()
+            clearInvocations(media3Controller)
+
+            assertThat(actions.nextOrCustom!!.contentDescription)
+                .isEqualTo(context.getString(R.string.controls_media_button_next))
+            actions.nextOrCustom!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).seekToNext()
+            verify(media3Controller).release()
+            clearInvocations(media3Controller)
+
+            assertThat(actions.custom0!!.contentDescription).isEqualTo("$CUSTOM_ACTION_NAME 0")
+            actions.custom0!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).sendCustomCommand(commandCaptor.capture(), any<Bundle>())
+            assertThat(commandCaptor.lastValue.customAction).isEqualTo("$CUSTOM_ACTION_COMMAND 0")
+            verify(media3Controller).release()
+            clearInvocations(media3Controller)
+
+            assertThat(actions.custom1!!.contentDescription).isEqualTo("$CUSTOM_ACTION_NAME 1")
+            actions.custom1!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).sendCustomCommand(commandCaptor.capture(), any<Bundle>())
+            assertThat(commandCaptor.lastValue.customAction).isEqualTo("$CUSTOM_ACTION_COMMAND 1")
+            verify(media3Controller).release()
+        }
+
+    @Test
+    fun media3Actions_pausedState_hasPauseAction() =
+        testScope.runTest {
+            whenever(media3Controller.isPlaying).thenReturn(false)
+            val result = getActions()
+
+            assertThat(result).isNotNull()
+            val actions = result!!
+            assertThat(actions.playOrPause!!.contentDescription)
+                .isEqualTo(context.getString(R.string.controls_media_button_play))
+            clearInvocations(media3Controller)
+
+            actions.playOrPause!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).play()
+            verify(media3Controller).release()
+            clearInvocations(media3Controller)
+        }
+
+    @Test
+    fun media3Actions_bufferingState_hasLoadingSpinner() =
+        testScope.runTest {
+            whenever(media3Controller.isPlaying).thenReturn(false)
+            whenever(media3Controller.playbackState).thenReturn(Player.STATE_BUFFERING)
+            val result = getActions()
+
+            assertThat(result).isNotNull()
+            val actions = result!!
+            assertThat(actions.playOrPause!!.contentDescription)
+                .isEqualTo(context.getString(R.string.controls_media_button_connecting))
+            assertThat(actions.playOrPause!!.action).isNull()
+            assertThat(actions.playOrPause!!.rebindId)
+                .isEqualTo(com.android.internal.R.drawable.progress_small_material)
+        }
+
+    @Test
+    fun media3Actions_noPrevNext_usesCustom() =
+        testScope.runTest {
+            val customLayout = ImmutableList.copyOf((0..4).map { createCustomCommandButton(it) })
+            whenever(media3Controller.customLayout).thenReturn(customLayout)
+            whenever(media3Controller.isPlaying).thenReturn(true)
+            whenever(media3Controller.isCommandAvailable(eq(Player.COMMAND_SEEK_TO_PREVIOUS)))
+                .thenReturn(false)
+            whenever(
+                    media3Controller.isCommandAvailable(
+                        eq(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
+                    )
+                )
+                .thenReturn(false)
+            whenever(media3Controller.isCommandAvailable(eq(Player.COMMAND_SEEK_TO_NEXT)))
+                .thenReturn(false)
+            whenever(
+                    media3Controller.isCommandAvailable(eq(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
+                )
+                .thenReturn(false)
+            val result = getActions()
+
+            assertThat(result).isNotNull()
+            val actions = result!!
+
+            assertThat(actions.prevOrCustom!!.contentDescription).isEqualTo("$CUSTOM_ACTION_NAME 0")
+            actions.prevOrCustom!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).sendCustomCommand(commandCaptor.capture(), any<Bundle>())
+            assertThat(commandCaptor.lastValue.customAction).isEqualTo("$CUSTOM_ACTION_COMMAND 0")
+            verify(media3Controller).release()
+            clearInvocations(media3Controller)
+
+            assertThat(actions.nextOrCustom!!.contentDescription).isEqualTo("$CUSTOM_ACTION_NAME 1")
+            actions.nextOrCustom!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).sendCustomCommand(commandCaptor.capture(), any<Bundle>())
+            assertThat(commandCaptor.lastValue.customAction).isEqualTo("$CUSTOM_ACTION_COMMAND 1")
+            verify(media3Controller).release()
+            clearInvocations(media3Controller)
+
+            assertThat(actions.custom0!!.contentDescription).isEqualTo("$CUSTOM_ACTION_NAME 2")
+            actions.custom0!!.action!!.run()
+            runCurrent()
+            testableLooper.processAllMessages()
+            verify(media3Controller).sendCustomCommand(commandCaptor.capture(), any<Bundle>())
+            assertThat(commandCaptor.lastValue.customAction).isEqualTo("$CUSTOM_ACTION_COMMAND 2")
+            verify(media3Controller).release()
+            clearInvocations(media3Controller)
+
+            assertThat(actions.custom1!!.contentDescription).isEqualTo("$CUSTOM_ACTION_NAME 3")
+            actions.custom1!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).sendCustomCommand(commandCaptor.capture(), any<Bundle>())
+            assertThat(commandCaptor.lastValue.customAction).isEqualTo("$CUSTOM_ACTION_COMMAND 3")
+            verify(media3Controller).release()
+        }
+
+    @Test
+    fun media3Actions_noPrevNext_reservedSpace() =
+        testScope.runTest {
+            val customLayout = ImmutableList.copyOf((0..4).map { createCustomCommandButton(it) })
+            whenever(media3Controller.customLayout).thenReturn(customLayout)
+            whenever(media3Controller.isPlaying).thenReturn(true)
+            whenever(media3Controller.isCommandAvailable(eq(Player.COMMAND_SEEK_TO_PREVIOUS)))
+                .thenReturn(false)
+            whenever(
+                    media3Controller.isCommandAvailable(
+                        eq(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
+                    )
+                )
+                .thenReturn(false)
+            whenever(media3Controller.isCommandAvailable(eq(Player.COMMAND_SEEK_TO_NEXT)))
+                .thenReturn(false)
+            whenever(
+                    media3Controller.isCommandAvailable(eq(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
+                )
+                .thenReturn(false)
+            val extras =
+                Bundle().apply {
+                    putBoolean(
+                        MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV,
+                        true,
+                    )
+                    putBoolean(
+                        MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT,
+                        true,
+                    )
+                }
+            whenever(media3Controller.sessionExtras).thenReturn(extras)
+            val result = getActions()
+
+            assertThat(result).isNotNull()
+            val actions = result!!
+
+            assertThat(actions.prevOrCustom).isNull()
+            assertThat(actions.nextOrCustom).isNull()
+
+            assertThat(actions.custom0!!.contentDescription).isEqualTo("$CUSTOM_ACTION_NAME 0")
+            actions.custom0!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).sendCustomCommand(commandCaptor.capture(), any<Bundle>())
+            assertThat(commandCaptor.lastValue.customAction).isEqualTo("$CUSTOM_ACTION_COMMAND 0")
+            verify(media3Controller).release()
+            clearInvocations(media3Controller)
+
+            assertThat(actions.custom1!!.contentDescription).isEqualTo("$CUSTOM_ACTION_NAME 1")
+            actions.custom1!!.action!!.run()
+            runCurrent()
+            verify(media3Controller).sendCustomCommand(commandCaptor.capture(), any<Bundle>())
+            assertThat(commandCaptor.lastValue.customAction).isEqualTo("$CUSTOM_ACTION_COMMAND 1")
+            verify(media3Controller).release()
+        }
+
+    private suspend fun getActions(): MediaButton? {
+        val result = underTest.createActionsFromSession(PACKAGE_NAME, legacyToken)
+        testScope.runCurrent()
+        verify(media3Controller).release()
+
+        // Clear so tests can verify the correct number of release() calls in later operations
+        clearInvocations(media3Controller)
+        return result
+    }
+
+    private fun createCustomCommandButton(id: Int): CommandButton {
+        return CommandButton.Builder()
+            .setDisplayName("$CUSTOM_ACTION_NAME $id")
+            .setSessionCommand(SessionCommand("$CUSTOM_ACTION_COMMAND $id", Bundle()))
+            .build()
+    }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt
index fc9e595..1a7265b 100644
--- a/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderTest.kt
@@ -29,6 +29,7 @@
 import android.media.session.PlaybackState
 import android.os.Bundle
 import android.service.notification.StatusBarNotification
+import android.testing.TestableLooper.RunWithLooper
 import androidx.media.utils.MediaConstants
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SmallTest
@@ -69,6 +70,7 @@
 private const val SESSION_EMPTY_TITLE = ""
 
 @SmallTest
+@RunWithLooper
 @RunWith(AndroidJUnit4::class)
 class MediaDataLoaderTest : SysuiTestCase() {
 
@@ -80,6 +82,7 @@
     private val fakeFeatureFlags = kosmos.fakeFeatureFlagsClassic
     private val mediaFlags = kosmos.mediaFlags
     private val mediaControllerFactory = kosmos.fakeMediaControllerFactory
+    private val media3ActionFactory = kosmos.media3ActionFactory
     private val session = MediaSession(context, "MediaDataLoaderTestSession")
     private val metadataBuilder =
         MediaMetadata.Builder().apply {
@@ -87,21 +90,25 @@
             putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_TITLE)
         }
 
-    private val underTest: MediaDataLoader =
-        MediaDataLoader(
-            context,
-            testDispatcher,
-            testScope,
-            mediaControllerFactory,
-            mediaFlags,
-            kosmos.imageLoader,
-            statusBarManager,
-        )
+    private lateinit var underTest: MediaDataLoader
 
     @Before
     fun setUp() {
         mediaControllerFactory.setControllerForToken(session.sessionToken, mediaController)
+        whenever(mediaController.sessionToken).thenReturn(session.sessionToken)
         whenever(mediaController.metadata).then { metadataBuilder.build() }
+
+        underTest =
+            MediaDataLoader(
+                context,
+                testDispatcher,
+                testScope,
+                mediaControllerFactory,
+                mediaFlags,
+                kosmos.imageLoader,
+                statusBarManager,
+                kosmos.media3ActionFactory,
+            )
     }
 
     @Test
@@ -394,6 +401,7 @@
                     mediaFlags,
                     mockImageLoader,
                     statusBarManager,
+                    media3ActionFactory,
                 )
             metadataBuilder.putString(
                 MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
@@ -422,6 +430,7 @@
                     mediaFlags,
                     mockImageLoader,
                     statusBarManager,
+                    media3ActionFactory,
                 )
             metadataBuilder.putString(
                 MediaMetadata.METADATA_KEY_ALBUM_ART_URI,
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/Media3ActionFactory.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/Media3ActionFactory.kt
new file mode 100644
index 0000000..a33685b
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/Media3ActionFactory.kt
@@ -0,0 +1,341 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.media.controls.domain.pipeline
+
+import android.content.Context
+import android.graphics.drawable.Animatable
+import android.graphics.drawable.Drawable
+import android.media.session.MediaController
+import android.media.session.MediaSession
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.annotation.WorkerThread
+import androidx.media.utils.MediaConstants
+import androidx.media3.common.Player
+import androidx.media3.session.CommandButton
+import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionToken
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.dagger.qualifiers.Background
+import com.android.systemui.graphics.ImageLoader
+import com.android.systemui.media.controls.shared.MediaControlDrawables
+import com.android.systemui.media.controls.shared.MediaLogger
+import com.android.systemui.media.controls.shared.model.MediaAction
+import com.android.systemui.media.controls.shared.model.MediaButton
+import com.android.systemui.media.controls.util.MediaControllerFactory
+import com.android.systemui.media.controls.util.SessionTokenFactory
+import com.android.systemui.res.R
+import com.android.systemui.util.Assert
+import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+private const val TAG = "Media3ActionFactory"
+
+@SysUISingleton
+class Media3ActionFactory
+@Inject
+constructor(
+    @Application val context: Context,
+    private val imageLoader: ImageLoader,
+    private val controllerFactory: MediaControllerFactory,
+    private val tokenFactory: SessionTokenFactory,
+    private val logger: MediaLogger,
+    @Background private val looper: Looper,
+    @Background private val handler: Handler,
+    @Background private val bgScope: CoroutineScope,
+) {
+
+    /**
+     * Generates action button info for this media session based on the Media3 session info
+     *
+     * @param packageName Package name for the media app
+     * @param controller The framework [MediaController] for the session
+     * @return The media action buttons, or null if the session token is null
+     */
+    suspend fun createActionsFromSession(
+        packageName: String,
+        sessionToken: MediaSession.Token,
+    ): MediaButton? {
+        // Get the Media3 controller using the legacy token
+        val token = tokenFactory.createTokenFromLegacy(sessionToken)
+        val m3controller = controllerFactory.create(token, looper)
+
+        // Build button info
+        val buttons = suspendCancellableCoroutine { continuation ->
+            // Media3Controller methods must always be called from a specific looper
+            handler.post {
+                val result = getMedia3Actions(packageName, m3controller, token)
+                m3controller.release()
+                continuation.resumeWith(Result.success(result))
+            }
+        }
+        return buttons
+    }
+
+    /** This method must be called on the Media3 looper! */
+    @WorkerThread
+    private fun getMedia3Actions(
+        packageName: String,
+        m3controller: androidx.media3.session.MediaController,
+        token: SessionToken,
+    ): MediaButton? {
+        Assert.isNotMainThread()
+
+        // First, get standard actions
+        val playOrPause =
+            if (m3controller.playbackState == Player.STATE_BUFFERING) {
+                // Spinner needs to be animating to render anything. Start it here.
+                val drawable =
+                    context.getDrawable(com.android.internal.R.drawable.progress_small_material)
+                (drawable as Animatable).start()
+                MediaAction(
+                    drawable,
+                    null, // no action to perform when clicked
+                    context.getString(R.string.controls_media_button_connecting),
+                    context.getDrawable(R.drawable.ic_media_connecting_container),
+                    // Specify a rebind id to prevent the spinner from restarting on later binds.
+                    com.android.internal.R.drawable.progress_small_material,
+                )
+            } else {
+                getStandardAction(m3controller, token, Player.COMMAND_PLAY_PAUSE)
+            }
+
+        val prevButton =
+            getStandardAction(
+                m3controller,
+                token,
+                Player.COMMAND_SEEK_TO_PREVIOUS,
+                Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
+            )
+        val nextButton =
+            getStandardAction(
+                m3controller,
+                token,
+                Player.COMMAND_SEEK_TO_NEXT,
+                Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
+            )
+
+        // Then, get custom actions
+        var customActions =
+            m3controller.customLayout
+                .asSequence()
+                .filter {
+                    it.isEnabled &&
+                        it.sessionCommand?.commandCode == SessionCommand.COMMAND_CODE_CUSTOM &&
+                        m3controller.isSessionCommandAvailable(it.sessionCommand!!)
+                }
+                .map { getCustomAction(packageName, token, it) }
+                .iterator()
+        fun nextCustomAction() = if (customActions.hasNext()) customActions.next() else null
+
+        // Finally, assign the remaining button slots: play/pause A B C D
+        // A = previous, else custom action (if not reserved)
+        // B = next, else custom action (if not reserved)
+        // C and D are always custom actions
+        val reservePrev =
+            m3controller.sessionExtras.getBoolean(
+                MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV,
+                false,
+            )
+        val reserveNext =
+            m3controller.sessionExtras.getBoolean(
+                MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT,
+                false,
+            )
+
+        val prevOrCustom =
+            prevButton
+                ?: if (reservePrev) {
+                    null
+                } else {
+                    nextCustomAction()
+                }
+
+        val nextOrCustom =
+            nextButton
+                ?: if (reserveNext) {
+                    null
+                } else {
+                    nextCustomAction()
+                }
+
+        return MediaButton(
+            playOrPause = playOrPause,
+            nextOrCustom = nextOrCustom,
+            prevOrCustom = prevOrCustom,
+            custom0 = nextCustomAction(),
+            custom1 = nextCustomAction(),
+            reserveNext = reserveNext,
+            reservePrev = reservePrev,
+        )
+    }
+
+    /**
+     * Create a [MediaAction] for a given command, if supported
+     *
+     * @param controller Media3 controller for the session
+     * @param commands Commands to check, in priority order
+     * @return A [MediaAction] representing the first supported command, or null if not supported
+     */
+    private fun getStandardAction(
+        controller: androidx.media3.session.MediaController,
+        token: SessionToken,
+        vararg commands: @Player.Command Int,
+    ): MediaAction? {
+        for (command in commands) {
+            if (!controller.isCommandAvailable(command)) {
+                continue
+            }
+
+            return when (command) {
+                Player.COMMAND_PLAY_PAUSE -> {
+                    if (!controller.isPlaying) {
+                        MediaAction(
+                            context.getDrawable(R.drawable.ic_media_play),
+                            { executeAction(token, Player.COMMAND_PLAY_PAUSE) },
+                            context.getString(R.string.controls_media_button_play),
+                            context.getDrawable(R.drawable.ic_media_play_container),
+                        )
+                    } else {
+                        MediaAction(
+                            context.getDrawable(R.drawable.ic_media_pause),
+                            { executeAction(token, Player.COMMAND_PLAY_PAUSE) },
+                            context.getString(R.string.controls_media_button_pause),
+                            context.getDrawable(R.drawable.ic_media_pause_container),
+                        )
+                    }
+                }
+                else -> {
+                    MediaAction(
+                        icon = getIconForAction(command),
+                        action = { executeAction(token, command) },
+                        contentDescription = getDescriptionForAction(command),
+                        background = null,
+                    )
+                }
+            }
+        }
+        return null
+    }
+
+    /** Get a [MediaAction] representing a [CommandButton] */
+    private fun getCustomAction(
+        packageName: String,
+        token: SessionToken,
+        customAction: CommandButton,
+    ): MediaAction {
+        return MediaAction(
+            getIconForAction(customAction, packageName),
+            { executeAction(token, Player.COMMAND_INVALID, customAction) },
+            customAction.displayName,
+            null,
+        )
+    }
+
+    private fun getIconForAction(command: @Player.Command Int): Drawable? {
+        return when (command) {
+            Player.COMMAND_SEEK_TO_PREVIOUS -> MediaControlDrawables.getPrevIcon(context)
+            Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> MediaControlDrawables.getPrevIcon(context)
+            Player.COMMAND_SEEK_TO_NEXT -> MediaControlDrawables.getNextIcon(context)
+            Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> MediaControlDrawables.getNextIcon(context)
+            else -> {
+                Log.e(TAG, "Unknown icon for $command")
+                null
+            }
+        }
+    }
+
+    private fun getIconForAction(customAction: CommandButton, packageName: String): Drawable? {
+        val size = context.resources.getDimensionPixelSize(R.dimen.min_clickable_item_size)
+        // TODO(b/360196209): check customAction.icon field to use platform icons
+        if (customAction.iconResId != 0) {
+            val packageContext = context.createPackageContext(packageName, 0)
+            val source = ImageLoader.Res(customAction.iconResId, packageContext)
+            return runBlocking { imageLoader.loadDrawable(source, size, size) }
+        }
+
+        if (customAction.iconUri != null) {
+            val source = ImageLoader.Uri(customAction.iconUri!!)
+            return runBlocking { imageLoader.loadDrawable(source, size, size) }
+        }
+        return null
+    }
+
+    private fun getDescriptionForAction(command: @Player.Command Int): String? {
+        return when (command) {
+            Player.COMMAND_SEEK_TO_PREVIOUS ->
+                context.getString(R.string.controls_media_button_prev)
+            Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM ->
+                context.getString(R.string.controls_media_button_prev)
+            Player.COMMAND_SEEK_TO_NEXT -> context.getString(R.string.controls_media_button_next)
+            Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM ->
+                context.getString(R.string.controls_media_button_next)
+            else -> {
+                Log.e(TAG, "Unknown content description for $command")
+                null
+            }
+        }
+    }
+
+    private fun executeAction(
+        token: SessionToken,
+        command: Int,
+        customAction: CommandButton? = null,
+    ) {
+        bgScope.launch {
+            val controller = controllerFactory.create(token, looper)
+            handler.post {
+                when (command) {
+                    Player.COMMAND_PLAY_PAUSE -> {
+                        if (controller.isPlaying) controller.pause() else controller.play()
+                    }
+
+                    Player.COMMAND_SEEK_TO_PREVIOUS -> controller.seekToPrevious()
+                    Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM ->
+                        controller.seekToPreviousMediaItem()
+
+                    Player.COMMAND_SEEK_TO_NEXT -> controller.seekToNext()
+                    Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> controller.seekToNextMediaItem()
+                    Player.COMMAND_INVALID -> {
+                        if (
+                            customAction != null &&
+                                customAction!!.sessionCommand != null &&
+                                controller.isSessionCommandAvailable(
+                                    customAction!!.sessionCommand!!
+                                )
+                        ) {
+                            controller.sendCustomCommand(
+                                customAction!!.sessionCommand!!,
+                                customAction!!.extras,
+                            )
+                        } else {
+                            logger.logMedia3UnsupportedCommand("$command, action $customAction")
+                        }
+                    }
+
+                    else -> logger.logMedia3UnsupportedCommand(command.toString())
+                }
+                controller.release()
+            }
+        }
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt
index 7b8703d..99dc089 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoader.kt
@@ -84,6 +84,7 @@
     private val mediaFlags: MediaFlags,
     private val imageLoader: ImageLoader,
     private val statusBarManager: StatusBarManager,
+    private val media3ActionFactory: Media3ActionFactory,
 ) {
     private val mediaProcessingJobs = ConcurrentHashMap<String, Job>()
 
@@ -364,7 +365,7 @@
             )
         }
 
-    private fun createActionsFromState(
+    private suspend fun createActionsFromState(
         packageName: String,
         controller: MediaController,
         user: UserHandle,
@@ -373,6 +374,12 @@
             return null
         }
 
+        if (mediaFlags.areMedia3ActionsEnabled(packageName, user)) {
+            return media3ActionFactory.createActionsFromSession(
+                packageName,
+                controller.sessionToken,
+            )
+        }
         return createActionsFromState(context, packageName, controller)
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaProcessingHelper.kt b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaProcessingHelper.kt
index 55d7b1d..a8628f1 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaProcessingHelper.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/domain/pipeline/MediaProcessingHelper.kt
@@ -42,7 +42,7 @@
     context: Context,
     newController: MediaController,
     new: MediaData,
-    old: MediaData?
+    old: MediaData?,
 ): Boolean {
     if (old == null || !mediaControlsPostsOptimization()) return false
 
@@ -71,7 +71,7 @@
 /** Returns whether actions lists are equal. */
 fun areCustomActionListsEqual(
     first: List<PlaybackState.CustomAction>?,
-    second: List<PlaybackState.CustomAction>?
+    second: List<PlaybackState.CustomAction>?,
 ): Boolean {
     // Same object, or both null
     if (first === second) {
@@ -94,7 +94,7 @@
 
 private fun areCustomActionsEqual(
     firstAction: PlaybackState.CustomAction,
-    secondAction: PlaybackState.CustomAction
+    secondAction: PlaybackState.CustomAction,
 ): Boolean {
     if (
         firstAction.action != secondAction.action ||
@@ -139,8 +139,9 @@
     context: Context,
     newController: MediaController,
     new: MediaData,
-    old: MediaData
+    old: MediaData,
 ): Boolean {
+    // TODO(b/360196209): account for actions generated from media3
     val oldState = MediaController(context, old.token!!).playbackState
     return if (
         new.semanticActions == null &&
@@ -164,7 +165,7 @@
         oldState?.actions == newController.playbackState?.actions &&
             areCustomActionListsEqual(
                 oldState?.customActions,
-                newController.playbackState?.customActions
+                newController.playbackState?.customActions,
             )
     } else {
         false
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/shared/MediaLogger.kt b/packages/SystemUI/src/com/android/systemui/media/controls/shared/MediaLogger.kt
index 88c47ba..0b598c1 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/shared/MediaLogger.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/shared/MediaLogger.kt
@@ -140,6 +140,10 @@
         )
     }
 
+    fun logMedia3UnsupportedCommand(command: String) {
+        buffer.log(TAG, LogLevel.DEBUG, { str1 = command }, { "Unsupported media3 command $str1" })
+    }
+
     companion object {
         private const val TAG = "MediaLog"
     }
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaControllerFactory.java b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaControllerFactory.java
deleted file mode 100644
index 6caf5c2..0000000
--- a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaControllerFactory.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *      http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.systemui.media.controls.util;
-
-import android.annotation.NonNull;
-import android.content.Context;
-import android.media.session.MediaController;
-import android.media.session.MediaSession;
-
-import javax.inject.Inject;
-
-/**
- * Testable wrapper around {@link MediaController} constructor.
- */
-public class MediaControllerFactory {
-
-    private final Context mContext;
-
-    @Inject
-    public MediaControllerFactory(Context context) {
-        mContext = context;
-    }
-
-    /**
-     * Creates a new MediaController from a session's token.
-     *
-     * @param token The token for the session. This value must never be null.
-     */
-    public MediaController create(@NonNull MediaSession.Token token) {
-        return new MediaController(mContext, token);
-    }
-}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaControllerFactory.kt b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaControllerFactory.kt
new file mode 100644
index 0000000..741f529
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaControllerFactory.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.systemui.media.controls.util
+
+import android.content.Context
+import android.media.session.MediaController
+import android.media.session.MediaSession
+import android.os.Looper
+import androidx.concurrent.futures.await
+import androidx.media3.session.MediaController as Media3Controller
+import androidx.media3.session.SessionToken
+import javax.inject.Inject
+
+/** Testable wrapper for media controller construction */
+open class MediaControllerFactory @Inject constructor(private val context: Context) {
+    /**
+     * Creates a new [MediaController] from the framework session token.
+     *
+     * @param token The token for the session. This value must never be null.
+     */
+    open fun create(token: MediaSession.Token): MediaController {
+        return MediaController(context, token)
+    }
+
+    /** Creates a new [Media3Controller] from a [SessionToken] */
+    open suspend fun create(token: SessionToken, looper: Looper): Media3Controller {
+        return Media3Controller.Builder(context, token)
+            .setApplicationLooper(looper)
+            .buildAsync()
+            .await()
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt
index d4af1b5..ac60c47 100644
--- a/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/util/MediaFlags.kt
@@ -18,9 +18,10 @@
 
 import android.app.StatusBarManager
 import android.os.UserHandle
+import com.android.systemui.Flags
 import com.android.systemui.dagger.SysUISingleton
 import com.android.systemui.flags.FeatureFlagsClassic
-import com.android.systemui.flags.Flags
+import com.android.systemui.flags.Flags as FlagsClassic
 import javax.inject.Inject
 
 @SysUISingleton
@@ -29,22 +30,29 @@
      * Check whether media control actions should be based on PlaybackState instead of notification
      */
     fun areMediaSessionActionsEnabled(packageName: String, user: UserHandle): Boolean {
-        // Allow global override with flag
         return StatusBarManager.useMediaSessionActionsForApp(packageName, user)
     }
 
+    /** Check whether media control actions should be derived from Media3 controller */
+    fun areMedia3ActionsEnabled(packageName: String, user: UserHandle): Boolean {
+        val compatFlag = StatusBarManager.useMedia3ControllerForApp(packageName, user)
+        val featureFlag = Flags.mediaControlsButtonMedia3()
+        return featureFlag && compatFlag
+    }
+
     /**
      * If true, keep active media controls for the lifetime of the MediaSession, regardless of
      * whether the underlying notification was dismissed
      */
-    fun isRetainingPlayersEnabled() = featureFlags.isEnabled(Flags.MEDIA_RETAIN_SESSIONS)
+    fun isRetainingPlayersEnabled() = featureFlags.isEnabled(FlagsClassic.MEDIA_RETAIN_SESSIONS)
 
     /** Check whether to get progress information for resume players */
-    fun isResumeProgressEnabled() = featureFlags.isEnabled(Flags.MEDIA_RESUME_PROGRESS)
+    fun isResumeProgressEnabled() = featureFlags.isEnabled(FlagsClassic.MEDIA_RESUME_PROGRESS)
 
     /** If true, do not automatically dismiss the recommendation card */
-    fun isPersistentSsCardEnabled() = featureFlags.isEnabled(Flags.MEDIA_RETAIN_RECOMMENDATIONS)
+    fun isPersistentSsCardEnabled() =
+        featureFlags.isEnabled(FlagsClassic.MEDIA_RETAIN_RECOMMENDATIONS)
 
     /** Check whether we allow remote media to generate resume controls */
-    fun isRemoteResumeAllowed() = featureFlags.isEnabled(Flags.MEDIA_REMOTE_RESUME)
+    fun isRemoteResumeAllowed() = featureFlags.isEnabled(FlagsClassic.MEDIA_REMOTE_RESUME)
 }
diff --git a/packages/SystemUI/src/com/android/systemui/media/controls/util/SessionTokenFactory.kt b/packages/SystemUI/src/com/android/systemui/media/controls/util/SessionTokenFactory.kt
new file mode 100644
index 0000000..b289fd4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/controls/util/SessionTokenFactory.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.media.controls.util
+
+import android.content.Context
+import android.media.session.MediaSession
+import androidx.concurrent.futures.await
+import androidx.media3.session.SessionToken
+import javax.inject.Inject
+
+/** Testable wrapper for [SessionToken] creation */
+open class SessionTokenFactory @Inject constructor(private val context: Context) {
+    /** Create a new [SessionToken] from the framework [MediaSession.Token] */
+    open suspend fun createTokenFromLegacy(token: MediaSession.Token): SessionToken {
+        return SessionToken.createSessionToken(context, token).await()
+    }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/Media3ActionFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/Media3ActionFactoryKosmos.kt
new file mode 100644
index 0000000..7e7eea2
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/Media3ActionFactoryKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.media.controls.domain.pipeline
+
+import com.android.systemui.kosmos.Kosmos
+import org.mockito.kotlin.mock
+
+var Kosmos.media3ActionFactory: Media3ActionFactory by Kosmos.Fixture { mock {} }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderKosmos.kt
index cb7750f5..af6a0c5 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderKosmos.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/domain/pipeline/MediaDataLoaderKosmos.kt
@@ -34,6 +34,7 @@
             fakeMediaControllerFactory,
             mediaFlags,
             imageLoader,
-            statusBarManager
+            statusBarManager,
+            media3ActionFactory,
         )
     }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/FakeMediaControllerFactory.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/FakeMediaControllerFactory.kt
index 7f8348e..b833750 100644
--- a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/FakeMediaControllerFactory.kt
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/FakeMediaControllerFactory.kt
@@ -18,21 +18,32 @@
 
 import android.content.Context
 import android.media.session.MediaController
-import android.media.session.MediaSession
 import android.media.session.MediaSession.Token
+import android.os.Looper
+import androidx.media3.session.MediaController as Media3Controller
+import androidx.media3.session.SessionToken
 
 class FakeMediaControllerFactory(context: Context) : MediaControllerFactory(context) {
 
     private val mediaControllersForToken = mutableMapOf<Token, MediaController>()
+    private var media3Controller: Media3Controller? = null
 
-    override fun create(token: MediaSession.Token): android.media.session.MediaController {
+    override fun create(token: Token): MediaController {
         if (token !in mediaControllersForToken) {
             super.create(token)
         }
         return mediaControllersForToken[token]!!
     }
 
+    override suspend fun create(token: SessionToken, looper: Looper): Media3Controller {
+        return media3Controller ?: super.create(token, looper)
+    }
+
     fun setControllerForToken(token: Token, mediaController: MediaController) {
         mediaControllersForToken[token] = mediaController
     }
+
+    fun setMedia3Controller(mediaController: Media3Controller) {
+        media3Controller = mediaController
+    }
 }
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/FakeSessionTokenFactory.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/FakeSessionTokenFactory.kt
new file mode 100644
index 0000000..94e0bca
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/FakeSessionTokenFactory.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.media.controls.util
+
+import android.content.Context
+import android.media.session.MediaSession.Token
+import androidx.media3.session.SessionToken
+
+class FakeSessionTokenFactory(context: Context) : SessionTokenFactory(context) {
+    private var sessionToken: SessionToken? = null
+
+    override suspend fun createTokenFromLegacy(token: Token): SessionToken {
+        return sessionToken ?: super.createTokenFromLegacy(token)
+    }
+
+    fun setMedia3SessionToken(token: SessionToken) {
+        sessionToken = token
+    }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/SessionTokenFactoryKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/SessionTokenFactoryKosmos.kt
new file mode 100644
index 0000000..8e473042
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/media/controls/util/SessionTokenFactoryKosmos.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.media.controls.util
+
+import android.content.applicationContext
+import com.android.systemui.kosmos.Kosmos
+
+val Kosmos.fakeSessionTokenFactory by Kosmos.Fixture { FakeSessionTokenFactory(applicationContext) }