diff options
| author | 2024-11-04 15:47:09 +0000 | |
|---|---|---|
| committer | 2024-11-04 15:47:09 +0000 | |
| commit | 4df17359e7d52c754080aadcee76232ceabc47bf (patch) | |
| tree | 231681137412af51cb636cf239ac18bc9a5529a4 | |
| parent | 65629114e96793410a99c2b9b5ba3c2f374f5ef0 (diff) | |
| parent | 43e8087825ffa6ca7b5e1e8c3395a9caca397cdf (diff) | |
Merge "Generate media control action buttons from Media3" into main
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) } |