diff options
17 files changed, 918 insertions, 66 deletions
diff --git a/core/java/android/app/StatusBarManager.java b/core/java/android/app/StatusBarManager.java index 63e039143917..b7285c38290c 100644 --- a/core/java/android/app/StatusBarManager.java +++ b/core/java/android/app/StatusBarManager.java @@ -602,6 +602,15 @@ public class StatusBarManager {      @LoggingOnly      private static final long MEDIA_CONTROL_BLANK_TITLE = 274775190L; +    /** +     * Media controls based on {@link android.app.Notification.MediaStyle} notifications will have +     * actions from the associated {@link androidx.media3.MediaController}, if available. +     */ +    @ChangeId +    @EnabledSince(targetSdkVersion = Build.VERSION_CODES.CUR_DEVELOPMENT) +    // TODO(b/360196209): Set target SDK to Baklava once available +    private static final long MEDIA_CONTROL_MEDIA3_ACTIONS = 360196209L; +      @UnsupportedAppUsage      private Context mContext;      private IStatusBarService mService; @@ -1270,6 +1279,21 @@ public class StatusBarManager {      }      /** +     * Checks whether the media controls for a given package should use a Media3 controller +     * +     * @param packageName App posting media controls +     * @param user Current user handle +     * @return true if Media3 should be used +     * +     * @hide +     */ +    @RequiresPermission(allOf = {android.Manifest.permission.READ_COMPAT_CHANGE_CONFIG, +            android.Manifest.permission.LOG_COMPAT_CHANGE}) +    public static boolean useMedia3ControllerForApp(String packageName, UserHandle user) { +        return CompatChanges.isChangeEnabled(MEDIA_CONTROL_MEDIA3_ACTIONS, packageName, user); +    } + +    /**       * Checks whether the supplied activity can {@link Activity#startActivityForResult(Intent, int)}       * a system activity that captures content on the screen to take a screenshot.       * diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp index a18b6c1b301a..bffda8bcae65 100644 --- a/packages/SystemUI/Android.bp +++ b/packages/SystemUI/Android.bp @@ -536,6 +536,8 @@ android_library {          "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", @@ -703,6 +705,8 @@ android_library {          "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 000000000000..9e3fdf377b83 --- /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 fc9e595945dd..1a7265b09aae 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.MediaSession  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_TITLE = "title"  private const val SESSION_EMPTY_TITLE = ""  @SmallTest +@RunWithLooper  @RunWith(AndroidJUnit4::class)  class MediaDataLoaderTest : SysuiTestCase() { @@ -80,6 +82,7 @@ class MediaDataLoaderTest : SysuiTestCase() {      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 @@ class MediaDataLoaderTest : SysuiTestCase() {              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 @@ class MediaDataLoaderTest : SysuiTestCase() {                      mediaFlags,                      mockImageLoader,                      statusBarManager, +                    media3ActionFactory,                  )              metadataBuilder.putString(                  MediaMetadata.METADATA_KEY_ALBUM_ART_URI, @@ -422,6 +430,7 @@ class MediaDataLoaderTest : SysuiTestCase() {                      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 000000000000..a33685b61237 --- /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 591a9cccdadd..a176e0c1c2a6 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 @@ constructor(      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 @@ constructor(              )          } -    private fun createActionsFromState( +    private suspend fun createActionsFromState(          packageName: String,          controller: MediaController,          user: UserHandle, @@ -373,6 +374,12 @@ constructor(              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 2bdee67dd57a..beb4d4103b11 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 @@ -141,6 +141,7 @@ private fun areActionsEqual(      new: 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 && 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 88c47ba4d243..0b598c13311f 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 @@ class MediaLogger @Inject constructor(@MediaLog private val buffer: LogBuffer) {          )      } +    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 6caf5c20b81c..000000000000 --- 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 000000000000..741f52998782 --- /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 d4af1b546369..ac60c47ee6ab 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 @@ package com.android.systemui.media.controls.util  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 @@ class MediaFlags @Inject constructor(private val featureFlags: FeatureFlagsClass       * 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 000000000000..b289fd40a3a2 --- /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 000000000000..7e7eea216584 --- /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 cb7750f55647..af6a0c505535 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 @@ val Kosmos.mediaDataLoader by              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 7f8348e2ca6f..b833750a2c4a 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 @@ package com.android.systemui.media.controls.util  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 000000000000..94e0bca5675b --- /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 000000000000..8e473042c5d4 --- /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) }  |