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) }