Merge "Add flag to use PlaybackState actions in media controls"
diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml
index 4790412..43254aa 100644
--- a/packages/SystemUI/res/values/strings.xml
+++ b/packages/SystemUI/res/values/strings.xml
@@ -2164,6 +2164,15 @@
     <!-- Content description for media cotnrols progress bar [CHAR_LIMIT=NONE] -->
     <string name="controls_media_seekbar_description"><xliff:g id="elapsed_time" example="1:30">%1$s</xliff:g> of <xliff:g id="total_time" example="3:00">%2$s</xliff:g></string>
 
+    <!-- Description for button in media controls. Pressing button starts playback [CHAR_LIMIT=NONE] -->
+    <string name="controls_media_button_play">Play</string>
+    <!-- Description for button in media controls. Pressing button pauses playback [CHAR_LIMIT=NONE] -->
+    <string name="controls_media_button_pause">Pause</string>
+    <!-- Description for button in media controls. Pressing button goes to previous track [CHAR_LIMIT=NONE] -->
+    <string name="controls_media_button_prev">Previous track</string>
+    <!-- Description for button in media controls. Pressing button goes to next track [CHAR_LIMIT=NONE] -->
+    <string name="controls_media_button_next">Next track</string>
+
     <!-- Title for Smartspace recommendation card within media controls. The "Play" means the action to play a media [CHAR_LIMIT=10] -->
     <string name="controls_media_smartspace_rec_title">Play</string>
     <!-- Description for Smartspace recommendation card within media controls [CHAR_LIMIT=NONE] -->
diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.java b/packages/SystemUI/src/com/android/systemui/flags/Flags.java
index c293493..97533c9 100644
--- a/packages/SystemUI/src/com/android/systemui/flags/Flags.java
+++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.java
@@ -121,6 +121,7 @@
     /***************************************/
     // 900 - media
     public static final BooleanFlag MEDIA_TAP_TO_TRANSFER = new BooleanFlag(900, false);
+    public static final BooleanFlag MEDIA_SESSION_ACTIONS = new BooleanFlag(901, false);
 
     // Pay no attention to the reflection behind the curtain.
     // ========================== Curtain ==========================
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaCarouselController.kt b/packages/SystemUI/src/com/android/systemui/media/MediaCarouselController.kt
index b85f1072..e921ad29c 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaCarouselController.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaCarouselController.kt
@@ -862,8 +862,23 @@
 
 @VisibleForTesting
 internal object MediaPlayerData {
-    private val EMPTY = MediaData(-1, false, 0, null, null, null, null, null,
-            emptyList(), emptyList(), "INVALID", null, null, null, true, null)
+    private val EMPTY = MediaData(
+            userId = -1,
+            initialized = false,
+            backgroundColor = 0,
+            app = null,
+            appIcon = null,
+            artist = null,
+            song = null,
+            artwork = null,
+            actions = emptyList(),
+            actionsToShowInCompact = emptyList(),
+            packageName = "INVALID",
+            token = null,
+            clickIntent = null,
+            device = null,
+            active = true,
+            resumeAction = null)
     // Whether should prioritize Smartspace card.
     internal var shouldPrioritizeSs: Boolean = false
         private set
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
index f66eb5b..63555bb 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
@@ -62,6 +62,7 @@
 import com.android.systemui.util.time.SystemClock;
 
 import java.net.URISyntaxException;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Executor;
 
@@ -122,6 +123,8 @@
     private MediaCarouselController mMediaCarouselController;
     private final MediaOutputDialogFactory mMediaOutputDialogFactory;
     private final FalsingManager mFalsingManager;
+    private final MediaFlags mMediaFlags;
+
     // Used for swipe-to-dismiss logging.
     protected boolean mIsImpressed = false;
     private SystemClock mSystemClock;
@@ -138,7 +141,7 @@
             SeekBarViewModel seekBarViewModel, Lazy<MediaDataManager> lazyMediaDataManager,
             KeyguardDismissUtil keyguardDismissUtil, MediaOutputDialogFactory
             mediaOutputDialogFactory, MediaCarouselController mediaCarouselController,
-            FalsingManager falsingManager, SystemClock systemClock) {
+            FalsingManager falsingManager, MediaFlags mediaFlags, SystemClock systemClock) {
         mContext = context;
         mBackgroundExecutor = backgroundExecutor;
         mActivityStarter = activityStarter;
@@ -149,8 +152,8 @@
         mMediaOutputDialogFactory = mediaOutputDialogFactory;
         mMediaCarouselController = mediaCarouselController;
         mFalsingManager = falsingManager;
+        mMediaFlags = mediaFlags;
         mSystemClock = systemClock;
-
         loadDimens();
 
         mSeekBarViewModel.setLogSmartspaceClick(() -> {
@@ -426,33 +429,61 @@
         deviceName.setText(deviceString);
         seamlessView.setContentDescription(deviceString);
 
-        List<Integer> actionsWhenCollapsed = data.getActionsToShowInCompact();
-        // Media controls
-        int i = 0;
+        // Media action buttons
         List<MediaAction> actionIcons = data.getActions();
+        List<Integer> actionsWhenCollapsed = data.getActionsToShowInCompact();
+
+        if (mMediaFlags.areMediaSessionActionsEnabled() && data.getSemanticActions() != null) {
+            // Use PlaybackState actions instead
+            MediaButton semanticActions = data.getSemanticActions();
+
+            actionIcons = new ArrayList<MediaAction>();
+            actionIcons.add(semanticActions.getStartCustom());
+            actionIcons.add(semanticActions.getPrevOrCustom());
+            actionIcons.add(semanticActions.getPlayOrPause());
+            actionIcons.add(semanticActions.getNextOrCustom());
+            actionIcons.add(semanticActions.getEndCustom());
+
+            actionsWhenCollapsed = new ArrayList<Integer>();
+            actionsWhenCollapsed.add(1);
+            actionsWhenCollapsed.add(2);
+            actionsWhenCollapsed.add(3);
+        }
+
+        int i = 0;
         for (; i < actionIcons.size() && i < ACTION_IDS.length; i++) {
             int actionId = ACTION_IDS[i];
+            boolean visibleInCompat = actionsWhenCollapsed.contains(i);
             final ImageButton button = mPlayerViewHolder.getAction(actionId);
             MediaAction mediaAction = actionIcons.get(i);
-            button.setImageIcon(mediaAction.getIcon());
-            button.setContentDescription(mediaAction.getContentDescription());
-            Runnable action = mediaAction.getAction();
+            if (mediaAction != null) {
+                button.setImageIcon(mediaAction.getIcon());
+                button.setContentDescription(mediaAction.getContentDescription());
+                Runnable action = mediaAction.getAction();
 
-            if (action == null) {
-                button.setEnabled(false);
+                if (action == null) {
+                    button.setEnabled(false);
+                } else {
+                    button.setEnabled(true);
+                    button.setOnClickListener(v -> {
+                        if (!mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) {
+                            logSmartspaceCardReported(760, // SMARTSPACE_CARD_CLICK
+                                    /* isRecommendationCard */ false);
+                            action.run();
+                        }
+                    });
+                }
+                setVisibleAndAlpha(collapsedSet, actionId, visibleInCompat);
+                setVisibleAndAlpha(expandedSet, actionId, true /*visible */);
             } else {
-                button.setEnabled(true);
-                button.setOnClickListener(v -> {
-                    if (!mFalsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) {
-                        logSmartspaceCardReported(760, // SMARTSPACE_CARD_CLICK
-                                /* isRecommendationCard */ false);
-                        action.run();
-                    }
-                });
+                button.setImageIcon(null);
+                button.setContentDescription(null);
+                button.setEnabled(false);
+                setVisibleAndAlpha(collapsedSet, actionId, visibleInCompat);
+                // for expanded layout, set as INVISIBLE so that we still reserve space in the UI
+                expandedSet.setVisibility(actionId, ConstraintSet.INVISIBLE);
+                expandedSet.setAlpha(actionId, 0.0f);
             }
-            boolean visibleInCompat = actionsWhenCollapsed.contains(i);
-            setVisibleAndAlpha(collapsedSet, actionId, visibleInCompat);
-            setVisibleAndAlpha(expandedSet, actionId, true /*visible */);
         }
 
         // Hide any unused buttons
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
index bda07f4..4b8dfde 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt
@@ -47,7 +47,7 @@
      */
     val artwork: Icon?,
     /**
-     * List of actions that can be performed on the player: prev, next, play, pause, etc.
+     * List of generic action buttons for the media player, based on notification actions
      */
     val actions: List<MediaAction>,
     /**
@@ -55,6 +55,11 @@
      */
     val actionsToShowInCompact: List<Int>,
     /**
+     * Semantic actions buttons, based on the PlaybackState of the media session.
+     * If present, these actions will be preferred in the UI over [actions]
+     */
+    val semanticActions: MediaButton? = null,
+    /**
      * Package name of the app that's posting the media.
      */
     val packageName: String,
@@ -125,6 +130,32 @@
     }
 }
 
+/**
+ * Contains [MediaAction] objects which represent specific buttons in the UI
+ */
+data class MediaButton(
+    /**
+     * Play/pause button
+     */
+    var playOrPause: MediaAction? = null,
+    /**
+     * Next button, or custom action
+     */
+    var nextOrCustom: MediaAction? = null,
+    /**
+     * Previous button, or custom action
+     */
+    var prevOrCustom: MediaAction? = null,
+    /**
+     * First custom action space
+     */
+    var startCustom: MediaAction? = null,
+    /**
+     * Last custom action space
+     */
+    var endCustom: MediaAction? = null
+)
+
 /** State of a media action. */
 data class MediaAction(
     val icon: Icon?,
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
index 7c0f7fc..49a63c3 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt
@@ -38,6 +38,7 @@
 import android.media.MediaMetadata
 import android.media.session.MediaController
 import android.media.session.MediaSession
+import android.media.session.PlaybackState
 import android.net.Uri
 import android.os.Parcelable
 import android.os.UserHandle
@@ -45,6 +46,7 @@
 import android.service.notification.StatusBarNotification
 import android.text.TextUtils
 import android.util.Log
+import androidx.media.utils.MediaConstants
 import com.android.internal.annotations.VisibleForTesting
 import com.android.systemui.Dumpable
 import com.android.systemui.R
@@ -80,8 +82,24 @@
 private const val DEBUG = true
 private const val EXTRAS_SMARTSPACE_DISMISS_INTENT_KEY = "dismiss_intent"
 
-private val LOADING = MediaData(-1, false, 0, null, null, null, null, null,
-        emptyList(), emptyList(), "INVALID", null, null, null, true, null)
+private val LOADING = MediaData(
+        userId = -1,
+        initialized = false,
+        backgroundColor = 0,
+        app = null,
+        appIcon = null,
+        artist = null,
+        song = null,
+        artwork = null,
+        actions = emptyList(),
+        actionsToShowInCompact = emptyList(),
+        packageName = "INVALID",
+        token = null,
+        clickIntent = null,
+        device = null,
+        active = true,
+        resumeAction = null)
+
 @VisibleForTesting
 internal val EMPTY_SMARTSPACE_MEDIA_DATA = SmartspaceMediaData("INVALID", false, false,
     "INVALID", null, emptyList(), null, 0, 0)
@@ -112,7 +130,8 @@
     private var useMediaResumption: Boolean,
     private val useQsMediaPlayer: Boolean,
     private val systemClock: SystemClock,
-    private val tunerService: TunerService
+    private val tunerService: TunerService,
+    private val mediaFlags: MediaFlags,
 ) : Dumpable, BcSmartspaceDataPlugin.SmartspaceTargetListener {
 
     companion object {
@@ -127,6 +146,10 @@
         // Maximum number of actions allowed in compact view
         @JvmField
         val MAX_COMPACT_ACTIONS = 3
+
+        /** Maximum number of [PlaybackState.CustomAction] buttons supported */
+        @JvmField
+        val MAX_CUSTOM_ACTIONS = 4
     }
 
     private val themeText = com.android.settingslib.Utils.getColorAttr(context,
@@ -182,12 +205,13 @@
         activityStarter: ActivityStarter,
         smartspaceMediaDataProvider: SmartspaceMediaDataProvider,
         clock: SystemClock,
-        tunerService: TunerService
+        tunerService: TunerService,
+        mediaFlags: MediaFlags
     ) : this(context, backgroundExecutor, foregroundExecutor, mediaControllerFactory,
             broadcastDispatcher, dumpManager, mediaTimeoutListener, mediaResumeListener,
             mediaSessionBasedFilter, mediaDeviceManager, mediaDataCombineLatest, mediaDataFilter,
             activityStarter, smartspaceMediaDataProvider, Utils.useMediaResumption(context),
-            Utils.useQsMediaPlayer(context), clock, tunerService)
+            Utils.useQsMediaPlayer(context), clock, tunerService, mediaFlags)
 
     private val appChangeReceiver = object : BroadcastReceiver() {
         override fun onReceive(context: Context, intent: Intent) {
@@ -522,7 +546,7 @@
         foregroundExecutor.execute {
             onMediaDataLoaded(packageName, null, MediaData(userId, true, bgColor, appName,
                     null, desc.subtitle, desc.title, artworkIcon, listOf(mediaAction), listOf(0),
-                    packageName, token, appIntent, device = null, active = false,
+                    null, packageName, token, appIntent, device = null, active = false,
                     resumeAction = resumeAction, resumption = true, notificationKey = packageName,
                     hasCheckedForResume = true, lastActive = lastActive))
         }
@@ -594,15 +618,55 @@
         }
 
         // Control buttons
+        // If flag is enabled and controller has a PlaybackState, create actions from session info
+        // Otherwise, use the notification actions
+        var actionIcons: List<MediaAction> = emptyList()
+        var actionsToShowCollapsed: List<Int> = emptyList()
+        var semanticActions: MediaButton? = null
+        if (mediaFlags.areMediaSessionActionsEnabled() && mediaController.playbackState != null) {
+            semanticActions = createActionsFromState(sbn.packageName, mediaController)
+        } else {
+            val actions = createActionsFromNotification(sbn)
+            actionIcons = actions.first
+            actionsToShowCollapsed = actions.second
+        }
+
+        val playbackLocation =
+                if (isRemoteCastNotification(sbn)) MediaData.PLAYBACK_CAST_REMOTE
+                else if (mediaController.playbackInfo?.playbackType ==
+                        MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL) MediaData.PLAYBACK_LOCAL
+                else MediaData.PLAYBACK_CAST_LOCAL
+        val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null
+        val lastActive = systemClock.elapsedRealtime()
+        foregroundExecutor.execute {
+            val resumeAction: Runnable? = mediaEntries[key]?.resumeAction
+            val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
+            val active = mediaEntries[key]?.active ?: true
+            onMediaDataLoaded(key, oldKey, MediaData(sbn.normalizedUserId, true, bgColor, app,
+                    smallIcon, artist, song, artWorkIcon, actionIcons, actionsToShowCollapsed,
+                    semanticActions, sbn.packageName, token, notif.contentIntent, null,
+                    active, resumeAction = resumeAction, playbackLocation = playbackLocation,
+                    notificationKey = key, hasCheckedForResume = hasCheckedForResume,
+                    isPlaying = isPlaying, isClearable = sbn.isClearable(),
+                    lastActive = lastActive))
+        }
+    }
+
+    /**
+     * Generate action buttons based on notification actions
+     */
+    private fun createActionsFromNotification(sbn: StatusBarNotification):
+            Pair<List<MediaAction>, List<Int>> {
+        val notif = sbn.notification
         val actionIcons: MutableList<MediaAction> = ArrayList()
         val actions = notif.actions
         var actionsToShowCollapsed = notif.extras.getIntArray(
-                Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList() ?: mutableListOf<Int>()
+            Notification.EXTRA_COMPACT_ACTIONS)?.toMutableList() ?: mutableListOf()
         if (actionsToShowCollapsed.size > MAX_COMPACT_ACTIONS) {
-            Log.e(TAG, "Too many compact actions for $key, limiting to first $MAX_COMPACT_ACTIONS")
+            Log.e(TAG, "Too many compact actions for ${sbn.key}," +
+                "limiting to first $MAX_COMPACT_ACTIONS")
             actionsToShowCollapsed = actionsToShowCollapsed.subList(0, MAX_COMPACT_ACTIONS)
         }
-        // TODO: b/153736623 look into creating actions when this isn't a media style notification
 
         if (actions != null) {
             for ((index, action) in actions.withIndex()) {
@@ -631,32 +695,150 @@
                     action.getIcon()
                 }.setTint(themeText)
                 val mediaAction = MediaAction(
-                        mediaActionIcon,
-                        runnable,
-                        action.title)
+                    mediaActionIcon,
+                    runnable,
+                    action.title)
                 actionIcons.add(mediaAction)
             }
         }
+        return Pair(actionIcons, actionsToShowCollapsed)
+    }
 
-        val playbackLocation =
-                if (isRemoteCastNotification(sbn)) MediaData.PLAYBACK_CAST_REMOTE
-                else if (mediaController.playbackInfo?.playbackType ==
-                    MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL) MediaData.PLAYBACK_LOCAL
-                else MediaData.PLAYBACK_CAST_LOCAL
-        val isPlaying = mediaController.playbackState?.let { isPlayingState(it.state) } ?: null
-        val lastActive = systemClock.elapsedRealtime()
-        foregroundExecutor.execute {
-            val resumeAction: Runnable? = mediaEntries[key]?.resumeAction
-            val hasCheckedForResume = mediaEntries[key]?.hasCheckedForResume == true
-            val active = mediaEntries[key]?.active ?: true
-            onMediaDataLoaded(key, oldKey, MediaData(sbn.normalizedUserId, true, bgColor, app,
-                    smallIcon, artist, song, artWorkIcon, actionIcons,
-                    actionsToShowCollapsed, sbn.packageName, token, notif.contentIntent, null,
-                    active, resumeAction = resumeAction, playbackLocation = playbackLocation,
-                    notificationKey = key, hasCheckedForResume = hasCheckedForResume,
-                    isPlaying = isPlaying, isClearable = sbn.isClearable(),
-                    lastActive = lastActive))
+    /**
+     * Generates action button info for this media session based on the PlaybackState
+     *
+     * @param packageName Package name for the media app
+     * @param controller MediaController for the current session
+     * @return a Pair consisting of a list of media actions, and a list of ints representing which
+     *      of those actions should be shown in the compact player
+     */
+    private fun createActionsFromState(packageName: String, controller: MediaController):
+            MediaButton? {
+        val actions = MediaButton()
+        controller.playbackState?.let { state ->
+            // First, check for standard actions
+            actions.playOrPause = if (isPlayingState(state.state)) {
+                getStandardAction(controller, state.actions, PlaybackState.ACTION_PAUSE)
+            } else {
+                getStandardAction(controller, state.actions, PlaybackState.ACTION_PLAY)
+            }
+            val prevButton = getStandardAction(controller, state.actions,
+                    PlaybackState.ACTION_SKIP_TO_PREVIOUS)
+            val nextButton = getStandardAction(controller, state.actions,
+                    PlaybackState.ACTION_SKIP_TO_NEXT)
+
+            // Then, check for custom actions
+            val customActions = MutableList<MediaAction?>(4) { null }
+            var customCount = 0
+            for (i in 0..MAX_CUSTOM_ACTIONS) {
+                getCustomAction(state, packageName, controller, customCount)?.let {
+                    customActions[customCount++] = it
+                }
+            }
+
+            // Finally, assign the remaining button slots: C A play/pause B 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 = controller.extras?.getBoolean(
+                    MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_PREV) == true
+            val reserveNext = controller.extras?.getBoolean(
+                    MediaConstants.SESSION_EXTRAS_KEY_SLOT_RESERVATION_SKIP_TO_NEXT) == true
+            var customIdx = 0
+
+            actions.prevOrCustom = if (prevButton != null) {
+                prevButton
+            } else if (!reservePrev) {
+                customActions[customIdx++]
+            } else {
+                null
+            }
+
+            actions.nextOrCustom = if (nextButton != null) {
+                nextButton
+            } else if (!reserveNext) {
+                customActions[customIdx++]
+            } else {
+                null
+            }
+
+            actions.startCustom = customActions[customIdx++]
+            actions.endCustom = customActions[customIdx++]
         }
+        return actions
+    }
+
+    /**
+     * Get a [MediaAction] representing one of
+     * - [PlaybackState.ACTION_PLAY]
+     * - [PlaybackState.ACTION_PAUSE]
+     * - [PlaybackState.ACTION_SKIP_TO_PREVIOUS]
+     * - [PlaybackState.ACTION_SKIP_TO_NEXT]
+     */
+    private fun getStandardAction(
+        controller: MediaController,
+        stateActions: Long,
+        action: Long
+    ): MediaAction? {
+        if (stateActions and action == 0L) {
+            return null
+        }
+
+        return when (action) {
+            PlaybackState.ACTION_PLAY -> {
+                MediaAction(
+                    Icon.createWithResource(context, com.android.internal.R.drawable.ic_media_play),
+                    { controller.transportControls.play() },
+                    context.getString(R.string.controls_media_button_play)
+                )
+            }
+            PlaybackState.ACTION_PAUSE -> {
+                MediaAction(
+                    Icon.createWithResource(context,
+                        com.android.internal.R.drawable.ic_media_pause),
+                    { controller.transportControls.pause() },
+                    context.getString(R.string.controls_media_button_pause)
+                )
+            }
+            PlaybackState.ACTION_SKIP_TO_PREVIOUS -> {
+                MediaAction(
+                    Icon.createWithResource(context,
+                        com.android.internal.R.drawable.ic_media_previous),
+                    { controller.transportControls.skipToPrevious() },
+                    context.getString(R.string.controls_media_button_prev)
+                )
+            }
+            PlaybackState.ACTION_SKIP_TO_NEXT -> {
+                MediaAction(
+                    Icon.createWithResource(context, com.android.internal.R.drawable.ic_media_next),
+                    { controller.transportControls.skipToNext() },
+                    context.getString(R.string.controls_media_button_next)
+                )
+            }
+            else -> null
+        }
+    }
+
+    /**
+     * Get a [MediaAction] representing a [PlaybackState.CustomAction]
+     */
+    private fun getCustomAction(
+        state: PlaybackState,
+        packageName: String,
+        controller: MediaController,
+        index: Int
+    ): MediaAction? {
+        if (state.customActions.size <= index || state.customActions[index] == null) {
+            if (DEBUG) { Log.d(TAG, "not enough actions or action was null at $index") }
+            return null
+        }
+
+        val it = state.customActions[index]
+        return MediaAction(
+            Icon.createWithResource(packageName, it.icon),
+            { controller.transportControls.sendCustomAction(it, it.extras) },
+            it.name
+        )
     }
 
     /**
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaFlags.kt b/packages/SystemUI/src/com/android/systemui/media/MediaFlags.kt
new file mode 100644
index 0000000..b4a4b42
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaFlags.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2021 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
+
+import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.flags.FeatureFlags
+import com.android.systemui.flags.Flags
+import javax.inject.Inject
+
+@SysUISingleton
+class MediaFlags @Inject constructor(private val featureFlags: FeatureFlags) {
+    /**
+     * Check whether media control actions should be based on PlaybackState instead of notification
+     */
+    fun areMediaSessionActionsEnabled(): Boolean {
+        return featureFlags.isEnabled(Flags.MEDIA_SESSION_ACTIONS)
+    }
+}
\ No newline at end of file
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
index 41ce941..7cc0172 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt
@@ -19,6 +19,7 @@
 import android.content.Intent
 import android.graphics.Color
 import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.Icon
 import android.graphics.drawable.RippleDrawable
 import android.media.MediaMetadata
 import android.media.session.MediaSession
@@ -97,6 +98,7 @@
     @Mock private lateinit var mediaOutputDialogFactory: MediaOutputDialogFactory
     @Mock private lateinit var mediaCarouselController: MediaCarouselController
     @Mock private lateinit var falsingManager: FalsingManager
+    @Mock private lateinit var mediaFlags: MediaFlags
     private lateinit var appIcon: ImageView
     private lateinit var albumView: ImageView
     private lateinit var titleText: TextView
@@ -123,6 +125,7 @@
     private lateinit var session: MediaSession
     private val device = MediaDeviceData(true, null, DEVICE_NAME)
     private val disabledDevice = MediaDeviceData(false, null, "Disabled Device")
+    private lateinit var mediaData: MediaData
     private val clock = FakeSystemClock()
 
     @JvmField @Rule val mockito = MockitoJUnit.rule()
@@ -135,7 +138,7 @@
 
         player = MediaControlPanel(context, bgExecutor, activityStarter, mediaViewController,
             seekBarViewModel, Lazy { mediaDataManager }, keyguardDismissUtil,
-            mediaOutputDialogFactory, mediaCarouselController, falsingManager, clock)
+            mediaOutputDialogFactory, mediaCarouselController, falsingManager, mediaFlags, clock)
         whenever(seekBarViewModel.progress).thenReturn(seekBarData)
 
         // Mock out a view holder for the player to attach to.
@@ -165,14 +168,19 @@
         whenever(holder.totalTimeView).thenReturn(totalTimeView)
         action0 = ImageButton(context)
         whenever(holder.action0).thenReturn(action0)
+        whenever(holder.getAction(R.id.action0)).thenReturn(action0)
         action1 = ImageButton(context)
         whenever(holder.action1).thenReturn(action1)
+        whenever(holder.getAction(R.id.action1)).thenReturn(action1)
         action2 = ImageButton(context)
         whenever(holder.action2).thenReturn(action2)
+        whenever(holder.getAction(R.id.action2)).thenReturn(action2)
         action3 = ImageButton(context)
         whenever(holder.action3).thenReturn(action3)
+        whenever(holder.getAction(R.id.action3)).thenReturn(action3)
         action4 = ImageButton(context)
         whenever(holder.action4).thenReturn(action4)
+        whenever(holder.getAction(R.id.action4)).thenReturn(action4)
         whenever(holder.longPressText).thenReturn(longPressText)
         whenever(longPressText.handler).thenReturn(handler)
         settings = View(context)
@@ -200,6 +208,26 @@
             setPlaybackState(playbackBuilder.build())
         }
         session.setActive(true)
+
+        mediaData = MediaData(
+                userId = USER_ID,
+                initialized = true,
+                backgroundColor = BG_COLOR,
+                app = APP,
+                appIcon = null,
+                artist = ARTIST,
+                song = TITLE,
+                artwork = null,
+                actions = emptyList(),
+                actionsToShowInCompact = emptyList(),
+                packageName = PACKAGE,
+                token = session.sessionToken,
+                clickIntent = null,
+                device = device,
+                active = true,
+                resumeAction = null)
+
+        whenever(mediaFlags.areMediaSessionActionsEnabled()).thenReturn(false)
     }
 
     @After
@@ -210,18 +238,50 @@
 
     @Test
     fun bindWhenUnattached() {
-        val state = MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-                emptyList(), PACKAGE, null, null, device, true, null)
+        val state = mediaData.copy(token = null)
         player.bindPlayer(state, PACKAGE)
         assertThat(player.isPlaying()).isFalse()
     }
 
     @Test
+    fun bindSemanticActions() {
+        whenever(mediaFlags.areMediaSessionActionsEnabled()).thenReturn(true)
+        val icon = Icon.createWithResource(context, android.R.drawable.ic_media_play)
+        val semanticActions = MediaButton(
+                playOrPause = MediaAction(icon, Runnable {}, "play"),
+                nextOrCustom = MediaAction(icon, Runnable {}, "next"),
+                startCustom = MediaAction(icon, null, "custom 1"),
+                endCustom = MediaAction(icon, null, "custom 2")
+        )
+        val state = mediaData.copy(semanticActions = semanticActions)
+
+        player.attachPlayer(holder)
+        player.bindPlayer(state, PACKAGE)
+
+        verify(expandedSet).setVisibility(R.id.action0, ConstraintSet.VISIBLE)
+        assertThat(action0.contentDescription).isEqualTo("custom 1")
+        assertThat(action0.isEnabled()).isFalse()
+
+        verify(expandedSet).setVisibility(R.id.action1, ConstraintSet.INVISIBLE)
+        assertThat(action1.isEnabled()).isFalse()
+
+        verify(expandedSet).setVisibility(R.id.action2, ConstraintSet.VISIBLE)
+        assertThat(action2.isEnabled()).isTrue()
+        assertThat(action2.contentDescription).isEqualTo("play")
+
+        verify(expandedSet).setVisibility(R.id.action3, ConstraintSet.VISIBLE)
+        assertThat(action3.isEnabled()).isTrue()
+        assertThat(action3.contentDescription).isEqualTo("next")
+
+        verify(expandedSet).setVisibility(R.id.action4, ConstraintSet.VISIBLE)
+        assertThat(action4.contentDescription).isEqualTo("custom 2")
+        assertThat(action4.isEnabled()).isFalse()
+    }
+
+    @Test
     fun bindText() {
         player.attachPlayer(holder)
-        val state = MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-                emptyList(), PACKAGE, session.getSessionToken(), null, device, true, null)
-        player.bindPlayer(state, PACKAGE)
+        player.bindPlayer(mediaData, PACKAGE)
         assertThat(titleText.getText()).isEqualTo(TITLE)
         assertThat(artistText.getText()).isEqualTo(ARTIST)
     }
@@ -229,9 +289,7 @@
     @Test
     fun bindDevice() {
         player.attachPlayer(holder)
-        val state = MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-                emptyList(), PACKAGE, session.getSessionToken(), null, device, true, null)
-        player.bindPlayer(state, PACKAGE)
+        player.bindPlayer(mediaData, PACKAGE)
         assertThat(seamlessText.getText()).isEqualTo(DEVICE_NAME)
         assertThat(seamless.contentDescription).isEqualTo(DEVICE_NAME)
         assertThat(seamless.isEnabled()).isTrue()
@@ -242,8 +300,7 @@
         seamless.id = 1
         val fallbackString = context.getString(R.string.media_seamless_other_device)
         player.attachPlayer(holder)
-        val state = MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-                emptyList(), PACKAGE, session.getSessionToken(), null, disabledDevice, true, null)
+        val state = mediaData.copy(device = disabledDevice)
         player.bindPlayer(state, PACKAGE)
         assertThat(seamless.isEnabled()).isFalse()
         assertThat(seamlessText.getText()).isEqualTo(fallbackString)
@@ -254,8 +311,7 @@
     fun bindNullDevice() {
         val fallbackString = context.getResources().getString(R.string.media_seamless_other_device)
         player.attachPlayer(holder)
-        val state = MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-                emptyList(), PACKAGE, session.getSessionToken(), null, null, true, null)
+        val state = mediaData.copy(device = null)
         player.bindPlayer(state, PACKAGE)
         assertThat(seamless.isEnabled()).isTrue()
         assertThat(seamlessText.getText()).isEqualTo(fallbackString)
@@ -265,9 +321,7 @@
     @Test
     fun bindDeviceResumptionPlayer() {
         player.attachPlayer(holder)
-        val state = MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-                emptyList(), PACKAGE, session.getSessionToken(), null, device, true, null,
-                resumption = true)
+        val state = mediaData.copy(resumption = true)
         player.bindPlayer(state, PACKAGE)
         assertThat(seamlessText.getText()).isEqualTo(DEVICE_NAME)
         assertThat(seamless.isEnabled()).isFalse()
@@ -323,9 +377,7 @@
     fun dismissButtonClick() {
         val mediaKey = "key for dismissal"
         player.attachPlayer(holder)
-        val state = MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-                emptyList(), PACKAGE, session.getSessionToken(), null, null, true, null,
-                notificationKey = KEY)
+        val state = mediaData.copy(notificationKey = KEY)
         player.bindPlayer(state, mediaKey)
 
         assertThat(dismiss.isEnabled).isEqualTo(true)
@@ -337,9 +389,7 @@
     fun dismissButtonDisabled() {
         val mediaKey = "key for dismissal"
         player.attachPlayer(holder)
-        val state = MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-                emptyList(), PACKAGE, session.getSessionToken(), null, null, true, null,
-                isClearable = false, notificationKey = KEY)
+        val state = mediaData.copy(isClearable = false, notificationKey = KEY)
         player.bindPlayer(state, mediaKey)
 
         assertThat(dismiss.isEnabled).isEqualTo(false)
@@ -351,9 +401,7 @@
         whenever(mediaDataManager.dismissMediaData(eq(mediaKey), anyLong())).thenReturn(false)
 
         player.attachPlayer(holder)
-        val state = MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-                emptyList(), PACKAGE, session.getSessionToken(), null, null, true, null,
-                notificationKey = KEY)
+        val state = mediaData.copy(notificationKey = KEY)
         player.bindPlayer(state, mediaKey)
 
         assertThat(dismiss.isEnabled).isEqualTo(true)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java
index 09c83e5..7a487b8 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java
@@ -74,8 +74,9 @@
         mManager = new MediaDataCombineLatest();
         mManager.addListener(mListener);
 
-        mMediaData = new MediaData(USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null,
-                new ArrayList<>(), new ArrayList<>(), PACKAGE, null, null, null, true, null,
+        mMediaData = new MediaData(
+                USER_ID, true, BG_COLOR, APP, null, ARTIST, TITLE, null,
+                new ArrayList<>(), new ArrayList<>(), null, PACKAGE, null, null, null, true, null,
                 MediaData.PLAYBACK_LOCAL, false, KEY, false, false, false, 0L);
         mDeviceData = new MediaDeviceData(true, null, DEVICE_NAME);
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataFilterTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataFilterTest.kt
index 5a3c43c..6b203bc 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataFilterTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataFilterTest.kt
@@ -96,11 +96,24 @@
         setUser(USER_MAIN)
 
         // Set up test media data
-        dataMain = MediaData(USER_MAIN, true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(),
-            emptyList(), PACKAGE, null, null, device, true, null)
-
-        dataGuest = MediaData(USER_GUEST, true, BG_COLOR, APP, null, ARTIST, TITLE, null,
-            emptyList(), emptyList(), PACKAGE, null, null, device, true, null)
+        dataMain = MediaData(
+                userId = USER_MAIN,
+                initialized = true,
+                backgroundColor = BG_COLOR,
+                app = APP,
+                appIcon = null,
+                artist = ARTIST,
+                song = TITLE,
+                artwork = null,
+                actions = emptyList(),
+                actionsToShowInCompact = emptyList(),
+                packageName = PACKAGE,
+                token = null,
+                clickIntent = null,
+                device = device,
+                active = true,
+                resumeAction = null)
+        dataGuest = dataMain.copy(userId = USER_GUEST)
 
         `when`(smartspaceData.targetId).thenReturn(SMARTSPACE_KEY)
         `when`(smartspaceData.isActive).thenReturn(true)
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
index e2019e0..d0b957c 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataManagerTest.kt
@@ -11,12 +11,15 @@
 import android.media.MediaMetadata
 import android.media.session.MediaController
 import android.media.session.MediaSession
+import android.media.session.PlaybackState
 import android.os.Bundle
 import android.provider.Settings
 import android.service.notification.StatusBarNotification
 import android.testing.AndroidTestingRunner
 import android.testing.TestableLooper.RunWithLooper
+import androidx.media.utils.MediaConstants
 import androidx.test.filters.SmallTest
+import com.android.systemui.R
 import com.android.systemui.SysuiTestCase
 import com.android.systemui.broadcast.BroadcastDispatcher
 import com.android.systemui.dump.DumpManager
@@ -67,6 +70,7 @@
     @JvmField @Rule val mockito = MockitoJUnit.rule()
     @Mock lateinit var mediaControllerFactory: MediaControllerFactory
     @Mock lateinit var controller: MediaController
+    @Mock lateinit var transportControls: MediaController.TransportControls
     @Mock lateinit var playbackInfo: MediaController.PlaybackInfo
     lateinit var session: MediaSession
     lateinit var metadataBuilder: MediaMetadata.Builder
@@ -87,6 +91,7 @@
     @Mock lateinit var mediaSmartspaceTarget: SmartspaceTarget
     @Mock private lateinit var mediaRecommendationItem: SmartspaceAction
     @Mock private lateinit var mediaSmartspaceBaseAction: SmartspaceAction
+    @Mock private lateinit var mediaFlags: MediaFlags
     lateinit var mediaDataManager: MediaDataManager
     lateinit var mediaNotification: StatusBarNotification
     @Captor lateinit var mediaDataCaptor: ArgumentCaptor<MediaData>
@@ -122,7 +127,8 @@
             useMediaResumption = true,
             useQsMediaPlayer = true,
             systemClock = clock,
-            tunerService = tunerService
+            tunerService = tunerService,
+            mediaFlags = mediaFlags
         )
         verify(tunerService).addTunable(capture(tunableCaptor),
                 eq(Settings.Secure.MEDIA_CONTROLS_RECOMMENDATION))
@@ -140,6 +146,7 @@
             putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_TITLE)
         }
         whenever(mediaControllerFactory.create(eq(session.sessionToken))).thenReturn(controller)
+        whenever(controller.transportControls).thenReturn(transportControls)
         whenever(controller.playbackInfo).thenReturn(playbackInfo)
         whenever(playbackInfo.playbackType).thenReturn(
                 MediaController.PlaybackInfo.PLAYBACK_TYPE_LOCAL)
@@ -161,6 +168,7 @@
         whenever(mediaSmartspaceTarget.featureType).thenReturn(SmartspaceTarget.FEATURE_MEDIA)
         whenever(mediaSmartspaceTarget.iconGrid).thenReturn(listOf(mediaRecommendationItem))
         whenever(mediaSmartspaceTarget.creationTimeMillis).thenReturn(1234L)
+        whenever(mediaFlags.areMediaSessionActionsEnabled()).thenReturn(false)
     }
 
     @After
@@ -583,4 +591,157 @@
         assertThat(mediaDataCaptor.value.actionsToShowInCompact.size).isEqualTo(
                 MediaDataManager.MAX_COMPACT_ACTIONS)
     }
+
+    @Test
+    fun testPlaybackActions_noState_usesNotification() {
+        val desc = "Notification Action"
+        whenever(mediaFlags.areMediaSessionActionsEnabled()).thenReturn(true)
+        whenever(controller.playbackState).thenReturn(null)
+
+        val notifWithAction = SbnBuilder().run {
+            setPkg(PACKAGE_NAME)
+            modifyNotification(context).also {
+                it.setSmallIcon(android.R.drawable.ic_media_pause)
+                it.setStyle(MediaStyle().apply { setMediaSession(session.sessionToken) })
+                it.addAction(android.R.drawable.ic_media_play, desc, null)
+            }
+            build()
+        }
+        mediaDataManager.onNotificationAdded(KEY, notifWithAction)
+
+        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+        verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+                eq(0))
+
+        assertThat(mediaDataCaptor.value!!.semanticActions).isNull()
+        assertThat(mediaDataCaptor.value!!.actions).hasSize(1)
+        assertThat(mediaDataCaptor.value!!.actions[0]!!.contentDescription).isEqualTo(desc)
+    }
+
+    @Test
+    fun testPlaybackActions_hasPrevNext() {
+        val customDesc = arrayOf("custom 1", "custom 2", "custom 3", "custom 4")
+        whenever(mediaFlags.areMediaSessionActionsEnabled()).thenReturn(true)
+        val stateActions = PlaybackState.ACTION_PLAY or
+                PlaybackState.ACTION_SKIP_TO_PREVIOUS or
+                PlaybackState.ACTION_SKIP_TO_NEXT
+        val stateBuilder = PlaybackState.Builder()
+                .setActions(stateActions)
+        customDesc.forEach {
+            stateBuilder.addCustomAction("action: $it", it, android.R.drawable.ic_media_pause)
+        }
+        whenever(controller.playbackState).thenReturn(stateBuilder.build())
+
+        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+        verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+                eq(0))
+
+        assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
+        val actions = mediaDataCaptor.value!!.semanticActions!!
+
+        assertThat(actions.playOrPause).isNotNull()
+        assertThat(actions.playOrPause!!.contentDescription).isEqualTo(
+                context.getString(R.string.controls_media_button_play))
+        actions.playOrPause!!.action!!.run()
+        verify(transportControls).play()
+
+        assertThat(actions.prevOrCustom).isNotNull()
+        assertThat(actions.prevOrCustom!!.contentDescription).isEqualTo(
+                context.getString(R.string.controls_media_button_prev))
+        actions.prevOrCustom!!.action!!.run()
+        verify(transportControls).skipToPrevious()
+
+        assertThat(actions.nextOrCustom).isNotNull()
+        assertThat(actions.nextOrCustom!!.contentDescription).isEqualTo(
+                context.getString(R.string.controls_media_button_next))
+        actions.nextOrCustom!!.action!!.run()
+        verify(transportControls).skipToNext()
+
+        assertThat(actions.startCustom).isNotNull()
+        assertThat(actions.startCustom!!.contentDescription).isEqualTo(customDesc[0])
+
+        assertThat(actions.endCustom).isNotNull()
+        assertThat(actions.endCustom!!.contentDescription).isEqualTo(customDesc[1])
+    }
+
+    @Test
+    fun testPlaybackActions_noPrevNext_usesCustom() {
+        val customDesc = arrayOf("custom 1", "custom 2", "custom 3", "custom 4")
+        whenever(mediaFlags.areMediaSessionActionsEnabled()).thenReturn(true)
+        val stateActions = PlaybackState.ACTION_PLAY
+        val stateBuilder = PlaybackState.Builder()
+                .setActions(stateActions)
+        customDesc.forEach {
+            stateBuilder.addCustomAction("action: $it", it, android.R.drawable.ic_media_pause)
+        }
+        whenever(controller.playbackState).thenReturn(stateBuilder.build())
+
+        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+        verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+                eq(0))
+
+        assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
+        val actions = mediaDataCaptor.value!!.semanticActions!!
+
+        assertThat(actions.playOrPause).isNotNull()
+        assertThat(actions.playOrPause!!.contentDescription).isEqualTo(
+                context.getString(R.string.controls_media_button_play))
+
+        assertThat(actions.prevOrCustom).isNotNull()
+        assertThat(actions.prevOrCustom!!.contentDescription).isEqualTo(customDesc[0])
+
+        assertThat(actions.nextOrCustom).isNotNull()
+        assertThat(actions.nextOrCustom!!.contentDescription).isEqualTo(customDesc[1])
+
+        assertThat(actions.startCustom).isNotNull()
+        assertThat(actions.startCustom!!.contentDescription).isEqualTo(customDesc[2])
+
+        assertThat(actions.endCustom).isNotNull()
+        assertThat(actions.endCustom!!.contentDescription).isEqualTo(customDesc[3])
+    }
+
+    @Test
+    fun testPlaybackActions_reservedSpace() {
+        val customDesc = arrayOf("custom 1", "custom 2", "custom 3", "custom 4")
+        whenever(mediaFlags.areMediaSessionActionsEnabled()).thenReturn(true)
+        val stateActions = PlaybackState.ACTION_PLAY
+        val stateBuilder = PlaybackState.Builder()
+                .setActions(stateActions)
+        customDesc.forEach {
+            stateBuilder.addCustomAction("action: $it", it, android.R.drawable.ic_media_pause)
+        }
+        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(controller.playbackState).thenReturn(stateBuilder.build())
+        whenever(controller.extras).thenReturn(extras)
+
+        mediaDataManager.onNotificationAdded(KEY, mediaNotification)
+        assertThat(backgroundExecutor.runAllReady()).isEqualTo(1)
+        assertThat(foregroundExecutor.runAllReady()).isEqualTo(1)
+        verify(listener).onMediaDataLoaded(eq(KEY), eq(null), capture(mediaDataCaptor), eq(true),
+                eq(0))
+
+        assertThat(mediaDataCaptor.value!!.semanticActions).isNotNull()
+        val actions = mediaDataCaptor.value!!.semanticActions!!
+
+        assertThat(actions.playOrPause).isNotNull()
+        assertThat(actions.playOrPause!!.contentDescription).isEqualTo(
+                context.getString(R.string.controls_media_button_play))
+
+        assertThat(actions.prevOrCustom).isNull()
+        assertThat(actions.nextOrCustom).isNull()
+
+        assertThat(actions.startCustom).isNotNull()
+        assertThat(actions.startCustom!!.contentDescription).isEqualTo(customDesc[0])
+
+        assertThat(actions.endCustom).isNotNull()
+        assertThat(actions.endCustom!!.contentDescription).isEqualTo(customDesc[1])
+    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt
index 7dadbad..3d59497 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt
@@ -102,9 +102,24 @@
         // Create a media sesssion and notification for testing.
         session = MediaSession(context, SESSION_KEY)
 
-        mediaData = MediaData(USER_ID, true, 0, PACKAGE, null, null, SESSION_TITLE, null,
-            emptyList(), emptyList(), PACKAGE, session.sessionToken, clickIntent = null,
-            device = null, active = true, resumeAction = null)
+        mediaData = MediaData(
+                userId = USER_ID,
+                initialized = true,
+                backgroundColor = 0,
+                app = PACKAGE,
+                appIcon = null,
+                artist = null,
+                song = SESSION_TITLE,
+                artwork = null,
+                actions = emptyList(),
+                actionsToShowInCompact = emptyList(),
+                packageName = PACKAGE,
+                token = session.sessionToken,
+                clickIntent = null,
+                device = null,
+                active = true,
+                resumeAction = null)
+
         whenever(controllerFactory.create(session.sessionToken))
                 .thenReturn(controller)
     }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaPlayerDataTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaPlayerDataTest.kt
index 421f9be..ceeb0db 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaPlayerDataTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaPlayerDataTest.kt
@@ -163,8 +163,27 @@
         isPlaying: Boolean?,
         location: Int,
         resumption: Boolean
-    ) =
-        MediaData(0, false, 0, app, null, null, null, null, emptyList(), emptyList<Int>(),
-            "package:" + app, null, null, null, true, null, location, resumption, "key:" + app,
-            false, isPlaying)
+    ) = MediaData(
+        userId = 0,
+        initialized = false,
+        backgroundColor = 0,
+        app = app,
+        appIcon = null,
+        artist = null,
+        song = null,
+        artwork = null,
+        actions = emptyList(),
+        actionsToShowInCompact = emptyList(),
+        packageName = "package: $app",
+        token = null,
+        clickIntent = null,
+        device = null,
+        active = true,
+        resumeAction = null,
+        playbackLocation = location,
+        resumption = resumption,
+        notificationKey = "key: $app",
+        hasCheckedForResume = false,
+        isPlaying = isPlaying
+    )
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt
index de2235d..8c2fed5 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt
+++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaTimeoutListenerTest.kt
@@ -95,13 +95,26 @@
             setPlaybackState(playbackBuilder.build())
         }
         session.setActive(true)
-        mediaData = MediaData(USER_ID, true, 0, PACKAGE, null, null, SESSION_TITLE, null,
-            emptyList(), emptyList(), PACKAGE, session.sessionToken, clickIntent = null,
-            device = null, active = true, resumeAction = null)
 
-        resumeData = MediaData(USER_ID, true, 0, PACKAGE, null, null, SESSION_TITLE, null,
-                emptyList(), emptyList(), PACKAGE, null, clickIntent = null,
-                device = null, active = false, resumeAction = null, resumption = true)
+        mediaData = MediaData(
+                userId = USER_ID,
+                initialized = true,
+                backgroundColor = 0,
+                app = PACKAGE,
+                appIcon = null,
+                artist = null,
+                song = SESSION_TITLE,
+                artwork = null,
+                actions = emptyList(),
+                actionsToShowInCompact = emptyList(),
+                packageName = PACKAGE,
+                token = session.sessionToken,
+                clickIntent = null,
+                device = null,
+                active = true,
+                resumeAction = null)
+
+        resumeData = mediaData.copy(token = null, active = false, resumption = true)
     }
 
     @Test