Merge "Add flag to use PlaybackState actions in media controls"

This commit is contained in:
Beth Thibodeau
2021-12-03 17:56:31 +00:00
committed by Android (Google) Code Review
14 changed files with 671 additions and 100 deletions

View File

@@ -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] -->

View File

@@ -121,6 +121,7 @@ public class Flags {
/***************************************/
// 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 ==========================

View File

@@ -862,8 +862,23 @@ class MediaCarouselController @Inject constructor(
@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

View File

@@ -62,6 +62,7 @@ import com.android.systemui.util.animation.TransitionLayout;
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 @@ public class MediaControlPanel {
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 @@ public class MediaControlPanel {
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 @@ public class MediaControlPanel {
mMediaOutputDialogFactory = mediaOutputDialogFactory;
mMediaCarouselController = mediaCarouselController;
mFalsingManager = falsingManager;
mMediaFlags = mediaFlags;
mSystemClock = systemClock;
loadDimens();
mSeekBarViewModel.setLogSmartspaceClick(() -> {
@@ -426,33 +429,61 @@ public class MediaControlPanel {
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

View File

@@ -47,13 +47,18 @@ data class MediaData(
*/
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>,
/**
* Same as above, but shown on smaller versions of the player, like in QQS or keyguard.
*/
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.
*/
@@ -125,6 +130,32 @@ data class MediaData(
}
}
/**
* 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?,

View File

@@ -38,6 +38,7 @@ import android.media.MediaDescription
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.provider.Settings
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 TAG = "MediaDataManager"
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 @@ class MediaDataManager(
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 @@ class MediaDataManager(
// 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 @@ class MediaDataManager(
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 @@ class MediaDataManager(
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 @@ class MediaDataManager(
}
// 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 @@ class MediaDataManager(
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
)
}
/**

View File

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

View File

@@ -19,6 +19,7 @@ package com.android.systemui.media
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 @@ public class MediaControlPanelTest : SysuiTestCase() {
@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 @@ public class MediaControlPanelTest : SysuiTestCase() {
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 @@ public class MediaControlPanelTest : SysuiTestCase() {
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 @@ public class MediaControlPanelTest : SysuiTestCase() {
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 @@ public class MediaControlPanelTest : SysuiTestCase() {
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 @@ public class MediaControlPanelTest : SysuiTestCase() {
@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 @@ public class MediaControlPanelTest : SysuiTestCase() {
@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 @@ public class MediaControlPanelTest : SysuiTestCase() {
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 @@ public class MediaControlPanelTest : SysuiTestCase() {
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 @@ public class MediaControlPanelTest : SysuiTestCase() {
@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 @@ public class MediaControlPanelTest : SysuiTestCase() {
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 @@ public class MediaControlPanelTest : SysuiTestCase() {
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 @@ public class MediaControlPanelTest : SysuiTestCase() {
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)

View File

@@ -74,8 +74,9 @@ public class MediaDataCombineLatestTest extends SysuiTestCase {
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);
}

View File

@@ -96,11 +96,24 @@ class MediaDataFilterTest : SysuiTestCase() {
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)

View File

@@ -11,12 +11,15 @@ import android.media.MediaDescription
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 @@ class MediaDataManagerTest : SysuiTestCase() {
@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 @@ class MediaDataManagerTest : SysuiTestCase() {
@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 @@ class MediaDataManagerTest : SysuiTestCase() {
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 @@ class MediaDataManagerTest : SysuiTestCase() {
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 @@ class MediaDataManagerTest : SysuiTestCase() {
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 @@ class MediaDataManagerTest : SysuiTestCase() {
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])
}
}

View File

@@ -102,9 +102,24 @@ public class MediaDeviceManagerTest : SysuiTestCase() {
// 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)
}

View File

@@ -163,8 +163,27 @@ public class MediaPlayerDataTest : SysuiTestCase() {
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
)
}

View File

@@ -95,13 +95,26 @@ class MediaTimeoutListenerTest : SysuiTestCase() {
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