diff options
| author | 2020-05-14 16:47:02 -0400 | |
|---|---|---|
| committer | 2020-05-19 20:01:03 -0400 | |
| commit | 70d0d6bd99955f154a5f7c60dace82b4cbccbec6 (patch) | |
| tree | 34b379831d4a0a9b844a40f2b9688890632cb29e | |
| parent | a9c6632f543fa0b171bc436a2a530375ee165c9d (diff) | |
towards MR2: update to new backend
Splitting MediaDeviceManager out of MediaControlPanel. This will make it
easier to integrate
MediaRouter2Manager#getRoutingSessionForMediaController.
Bug: 155266917
Test: atest tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt
Test: atest tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java
Test: manual - switch route between phone, headphones and cast and check
output switcher chip in the player.
Change-Id: Ibe6a7e41bd8fb0f0976d1c1c7f2f94cfc4fc6f5e
13 files changed, 730 insertions, 176 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/media/LocalMediaManagerFactory.kt b/packages/SystemUI/src/com/android/systemui/media/LocalMediaManagerFactory.kt new file mode 100644 index 000000000000..94a0835f22f8 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/LocalMediaManagerFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.media + +import android.content.Context + +import com.android.settingslib.bluetooth.LocalBluetoothManager +import com.android.settingslib.media.InfoMediaManager +import com.android.settingslib.media.LocalMediaManager + +import javax.inject.Inject + +/** + * Factory to create [LocalMediaManager] objects. + */ +class LocalMediaManagerFactory @Inject constructor( + private val context: Context, + private val localBluetoothManager: LocalBluetoothManager? +) { + /** Creates a [LocalMediaManager] for the given package. */ + fun create(packageName: String): LocalMediaManager { + return InfoMediaManager(context, packageName, null, localBluetoothManager).run { + LocalMediaManager(context, localBluetoothManager, this, packageName) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java index aee7a4649d08..f90798bd30b8 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java +++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java @@ -41,7 +41,6 @@ import android.util.Log; import android.view.View; import android.widget.ImageButton; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.SeekBar; import android.widget.TextView; @@ -56,14 +55,11 @@ import androidx.core.graphics.drawable.RoundedBitmapDrawable; import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; import com.android.settingslib.Utils; -import com.android.settingslib.media.LocalMediaManager; -import com.android.settingslib.media.MediaDevice; import com.android.settingslib.media.MediaOutputSliceConstants; import com.android.settingslib.widget.AdaptiveIcon; import com.android.systemui.R; import com.android.systemui.plugins.ActivityStarter; import com.android.systemui.qs.QSMediaBrowser; -import com.android.systemui.util.Assert; import com.android.systemui.util.concurrency.DelayableExecutor; import org.jetbrains.annotations.NotNull; @@ -77,7 +73,6 @@ import java.util.concurrent.Executor; */ public class MediaControlPanel { private static final String TAG = "MediaControlPanel"; - @Nullable private final LocalMediaManager mLocalMediaManager; // Button IDs for QS controls static final int[] ACTION_IDS = { @@ -100,7 +95,6 @@ public class MediaControlPanel { private MediaSession.Token mToken; private MediaController mController; private int mBackgroundColor; - private MediaDevice mDevice; protected ComponentName mServiceComponent; private boolean mIsRegistered = false; private List<KeyFrames> mKeyFrames; @@ -113,7 +107,6 @@ public class MediaControlPanel { public static final String MEDIA_PREFERENCE_KEY = "browser_components"; private SharedPreferences mSharedPrefs; private boolean mCheckedForResumption = false; - private boolean mIsRemotePlayback; private QSMediaBrowser mQSMediaBrowser; private final MediaController.Callback mSessionCallback = new MediaController.Callback() { @@ -122,7 +115,6 @@ public class MediaControlPanel { Log.d(TAG, "session destroyed"); mController.unregisterCallback(mSessionCallback); clearControls(); - makeInactive(); } @Override public void onPlaybackStateChanged(PlaybackState state) { @@ -130,31 +122,6 @@ public class MediaControlPanel { if (s == PlaybackState.STATE_NONE) { Log.d(TAG, "playback state change will trigger resumption, state=" + state); clearControls(); - makeInactive(); - } - } - }; - - private final LocalMediaManager.DeviceCallback mDeviceCallback = - new LocalMediaManager.DeviceCallback() { - @Override - public void onDeviceListUpdate(List<MediaDevice> devices) { - if (mLocalMediaManager == null) { - return; - } - MediaDevice currentDevice = mLocalMediaManager.getCurrentConnectedDevice(); - // Check because this can be called several times while changing devices - if (mDevice == null || !mDevice.equals(currentDevice)) { - mDevice = currentDevice; - updateDevice(mDevice); - } - } - - @Override - public void onSelectedDeviceStateChanged(MediaDevice device, int state) { - if (mDevice == null || !mDevice.equals(device)) { - mDevice = device; - updateDevice(mDevice); } } }; @@ -162,16 +129,13 @@ public class MediaControlPanel { /** * Initialize a new control panel * @param context - * @param routeManager Manager used to listen for device change events. * @param foregroundExecutor foreground executor * @param backgroundExecutor background executor, used for processing artwork * @param activityStarter activity starter */ - public MediaControlPanel(Context context, @Nullable LocalMediaManager routeManager, - Executor foregroundExecutor, DelayableExecutor backgroundExecutor, - ActivityStarter activityStarter) { + public MediaControlPanel(Context context, Executor foregroundExecutor, + DelayableExecutor backgroundExecutor, ActivityStarter activityStarter) { mContext = context; - mLocalMediaManager = routeManager; mForegroundExecutor = foregroundExecutor; mBackgroundExecutor = backgroundExecutor; mActivityStarter = activityStarter; @@ -183,7 +147,6 @@ public class MediaControlPanel { if (mSeekBarObserver != null) { mSeekBarViewModel.getProgress().removeObserver(mSeekBarObserver); } - makeInactive(); } private void loadDimens() { @@ -318,30 +281,67 @@ public class MediaControlPanel { artistText.setText(data.getArtist()); // Transfer chip - if (mLocalMediaManager != null) { - mViewHolder.getSeamless().setVisibility(View.VISIBLE); - setVisibleAndAlpha(collapsedSet, R.id.media_seamless, true /*visible */); - setVisibleAndAlpha(expandedSet, R.id.media_seamless, true /*visible */); - updateDevice(mLocalMediaManager.getCurrentConnectedDevice()); - mViewHolder.getSeamless().setOnClickListener(v -> { - final Intent intent = new Intent() - .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT) - .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME, - mController.getPackageName()) - .putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken); - mActivityStarter.startActivity(intent, false, true /* dismissShade */, - Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - }); - } else { - Log.d(TAG, "LocalMediaManager is null. Not binding output chip for pkg=" + pkgName); - } + mViewHolder.getSeamless().setVisibility(View.VISIBLE); + setVisibleAndAlpha(collapsedSet, R.id.media_seamless, true /*visible */); + setVisibleAndAlpha(expandedSet, R.id.media_seamless, true /*visible */); + mViewHolder.getSeamless().setOnClickListener(v -> { + final Intent intent = new Intent() + .setAction(MediaOutputSliceConstants.ACTION_MEDIA_OUTPUT) + .putExtra(MediaOutputSliceConstants.EXTRA_PACKAGE_NAME, + mController.getPackageName()) + .putExtra(MediaOutputSliceConstants.KEY_MEDIA_SESSION_TOKEN, mToken); + mActivityStarter.startActivity(intent, false, true /* dismissShade */, + Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + }); + final boolean isRemotePlayback; PlaybackInfo playbackInfo = mController.getPlaybackInfo(); if (playbackInfo != null) { - mIsRemotePlayback = playbackInfo.getPlaybackType() == PlaybackInfo.PLAYBACK_TYPE_REMOTE; + isRemotePlayback = playbackInfo.getPlaybackType() == PlaybackInfo.PLAYBACK_TYPE_REMOTE; } else { Log.d(TAG, "PlaybackInfo was null. Defaulting to local playback."); - mIsRemotePlayback = false; + isRemotePlayback = false; + } + + ImageView iconView = mViewHolder.getSeamlessIcon(); + TextView deviceName = mViewHolder.getSeamlessText(); + + // Update the outline color + RippleDrawable bkgDrawable = (RippleDrawable) mViewHolder.getSeamless().getBackground(); + GradientDrawable rect = (GradientDrawable) bkgDrawable.getDrawable(0); + rect.setStroke(2, deviceName.getCurrentTextColor()); + rect.setColor(Color.TRANSPARENT); + + if (isRemotePlayback) { + mViewHolder.getSeamless().setEnabled(false); + // TODO(b/156875717): setEnabled should cause the alpha to change. + mViewHolder.getSeamless().setAlpha(0.38f); + iconView.setImageResource(R.drawable.ic_hardware_speaker); + iconView.setVisibility(View.VISIBLE); + deviceName.setText(R.string.media_seamless_remote_device); + } else if (data.getDevice() != null && data.getDevice().getIcon() != null + && data.getDevice().getName() != null) { + mViewHolder.getSeamless().setEnabled(true); + mViewHolder.getSeamless().setAlpha(1f); + Drawable icon = data.getDevice().getIcon(); + iconView.setVisibility(View.VISIBLE); + + if (icon instanceof AdaptiveIcon) { + AdaptiveIcon aIcon = (AdaptiveIcon) icon; + aIcon.setBackgroundColor(mBackgroundColor); + iconView.setImageDrawable(aIcon); + } else { + iconView.setImageDrawable(icon); + } + deviceName.setText(data.getDevice().getName()); + } else { + // Reset to default + Log.w(TAG, "device is null. Not binding output chip."); + mViewHolder.getSeamless().setEnabled(true); + mViewHolder.getSeamless().setAlpha(1f); + iconView.setVisibility(View.GONE); + deviceName.setText(com.android.internal.R.string.ext_media_seamless_action); } + List<Integer> actionsWhenCollapsed = data.getActionsToShowInCompact(); // Media controls int i = 0; @@ -382,8 +382,6 @@ public class MediaControlPanel { // Set up long press menu // TODO: b/156036025 bring back media guts - makeActive(); - // Update both constraint sets to regenerate the animation. mViewHolder.getPlayer().updateState(R.id.collapsed, collapsedSet); mViewHolder.getPlayer().updateState(R.id.expanded, expandedSet); @@ -515,60 +513,6 @@ public class MediaControlPanel { } /** - * Update the current device information - * @param device device information to display - */ - private void updateDevice(MediaDevice device) { - mForegroundExecutor.execute(() -> { - updateChipInternal(device); - }); - } - - private void updateChipInternal(MediaDevice device) { - if (mViewHolder == null) { - return; - } - ImageView iconView = mViewHolder.getSeamlessIcon(); - TextView deviceName = mViewHolder.getSeamlessText(); - - // Update the outline color - LinearLayout viewLayout = (LinearLayout) mViewHolder.getSeamless(); - RippleDrawable bkgDrawable = (RippleDrawable) viewLayout.getBackground(); - GradientDrawable rect = (GradientDrawable) bkgDrawable.getDrawable(0); - rect.setStroke(2, deviceName.getCurrentTextColor()); - rect.setColor(Color.TRANSPARENT); - - if (mIsRemotePlayback) { - mViewHolder.getSeamless().setEnabled(false); - mViewHolder.getSeamless().setAlpha(0.38f); - iconView.setImageResource(R.drawable.ic_hardware_speaker); - iconView.setVisibility(View.VISIBLE); - deviceName.setText(R.string.media_seamless_remote_device); - } else if (device != null) { - mViewHolder.getSeamless().setEnabled(true); - mViewHolder.getSeamless().setAlpha(1f); - Drawable icon = device.getIcon(); - iconView.setVisibility(View.VISIBLE); - - if (icon instanceof AdaptiveIcon) { - AdaptiveIcon aIcon = (AdaptiveIcon) icon; - aIcon.setBackgroundColor(mBackgroundColor); - iconView.setImageDrawable(aIcon); - } else { - iconView.setImageDrawable(icon); - } - deviceName.setText(device.getName()); - } else { - // Reset to default - Log.d(TAG, "device is null. Not binding output chip."); - mViewHolder.getSeamless().setEnabled(true); - mViewHolder.getSeamless().setAlpha(1f); - iconView.setVisibility(View.GONE); - deviceName.setText(com.android.internal.R.string.ext_media_seamless_action); - } - } - - /** * Puts controls into a resumption state if possible, or calls removePlayer if no component was * found that could resume playback */ @@ -642,27 +586,6 @@ public class MediaControlPanel { set.setAlpha(actionId, visible ? 1.0f : 0.0f); } - private void makeActive() { - Assert.isMainThread(); - if (!mIsRegistered) { - if (mLocalMediaManager != null) { - mLocalMediaManager.registerCallback(mDeviceCallback); - mLocalMediaManager.startScan(); - } - mIsRegistered = true; - } - } - - private void makeInactive() { - Assert.isMainThread(); - if (mIsRegistered) { - if (mLocalMediaManager != null) { - mLocalMediaManager.stopScan(); - mLocalMediaManager.unregisterCallback(mDeviceCallback); - } - mIsRegistered = false; - } - } /** * Verify that we can connect to the given component with a MediaBrowser, and if so, add that * component to the list of resumption components diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt index 85965d03a096..41d411019921 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaData.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaData.kt @@ -34,7 +34,8 @@ data class MediaData( val actionsToShowInCompact: List<Int>, val packageName: String?, val token: MediaSession.Token?, - val clickIntent: PendingIntent? + val clickIntent: PendingIntent?, + val device: MediaDeviceData? ) /** State of a media action. */ @@ -43,3 +44,9 @@ data class MediaAction( val intent: PendingIntent?, val contentDescription: CharSequence? ) + +/** State of the media device. */ +data class MediaDeviceData( + val icon: Drawable?, + val name: String? +) diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataCombineLatest.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataCombineLatest.kt new file mode 100644 index 000000000000..cce9838bb8e2 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataCombineLatest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.media + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Combines updates from [MediaDataManager] with [MediaDeviceManager]. + */ +@Singleton +class MediaDataCombineLatest @Inject constructor( + private val dataSource: MediaDataManager, + private val deviceSource: MediaDeviceManager +) { + private val listeners: MutableSet<MediaDataManager.Listener> = mutableSetOf() + private val entries: MutableMap<String, Pair<MediaData?, MediaDeviceData?>> = mutableMapOf() + + init { + dataSource.addListener(object : MediaDataManager.Listener { + override fun onMediaDataLoaded(key: String, data: MediaData) { + entries[key] = data to entries[key]?.second + update(key) + } + override fun onMediaDataRemoved(key: String) { + remove(key) + } + }) + deviceSource.addListener(object : MediaDeviceManager.Listener { + override fun onMediaDeviceChanged(key: String, data: MediaDeviceData?) { + entries[key] = entries[key]?.first to data + update(key) + } + override fun onKeyRemoved(key: String) { + remove(key) + } + }) + } + + /** + * Add a listener for [MediaData] changes that has been combined with latest [MediaDeviceData]. + */ + fun addListener(listener: MediaDataManager.Listener) = listeners.add(listener) + + /** + * Remove a listener registered with addListener. + */ + fun removeListener(listener: MediaDataManager.Listener) = listeners.remove(listener) + + private fun update(key: String) { + val (entry, device) = entries[key] ?: null to null + if (entry != null && device != null) { + val data = entry.copy(device = device) + listeners.forEach { + it.onMediaDataLoaded(key, data) + } + } + } + + private fun remove(key: String) { + entries.remove(key)?.let { + listeners.forEach { + it.onMediaDataRemoved(key) + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt index f950d41f02b1..8cbe3ecf5387 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaDataManager.kt @@ -55,7 +55,19 @@ private const val LUMINOSITY_THRESHOLD = 0.05f private const val SATURATION_MULTIPLIER = 0.8f private val LOADING = MediaData(false, 0, null, null, null, null, null, - emptyList(), emptyList(), null, null, null) + emptyList(), emptyList(), null, null, null, null) + +fun isMediaNotification(sbn: StatusBarNotification): Boolean { + if (!sbn.notification.hasMediaSession()) { + return false + } + val notificationStyle = sbn.notification.notificationStyle + if (Notification.DecoratedMediaCustomViewStyle::class.java.equals(notificationStyle) || + Notification.MediaStyle::class.java.equals(notificationStyle)) { + return true + } + return false +} /** * A class that facilitates management and loading of Media Data, ready for binding. @@ -72,7 +84,7 @@ class MediaDataManager @Inject constructor( private val mediaEntries: LinkedHashMap<String, MediaData> = LinkedHashMap() fun onNotificationAdded(key: String, sbn: StatusBarNotification) { - if (isMediaNotification(sbn)) { + if (Utils.useQsMediaPlayer(context) && isMediaNotification(sbn)) { if (!mediaEntries.containsKey(key)) { mediaEntries.put(key, LOADING) } @@ -204,7 +216,7 @@ class MediaDataManager @Inject constructor( foregroundExecutor.execute { onMediaDataLoaded(key, MediaData(true, bgColor, app, smallIconDrawable, artist, song, artWorkIcon, actionIcons, actionsToShowCollapsed, sbn.packageName, token, - notif.contentIntent)) + notif.contentIntent, null)) } } @@ -270,21 +282,6 @@ class MediaDataManager @Inject constructor( } } - private fun isMediaNotification(sbn: StatusBarNotification): Boolean { - if (!Utils.useQsMediaPlayer(context)) { - return false - } - if (!sbn.notification.hasMediaSession()) { - return false - } - val notificationStyle = sbn.notification.notificationStyle - if (Notification.DecoratedMediaCustomViewStyle::class.java.equals(notificationStyle) || - Notification.MediaStyle::class.java.equals(notificationStyle)) { - return true - } - return false - } - /** * Are there any media notifications active? */ diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt new file mode 100644 index 000000000000..2d16e2930365 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/MediaDeviceManager.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.media + +import android.content.Context +import android.service.notification.StatusBarNotification +import com.android.settingslib.media.LocalMediaManager +import com.android.settingslib.media.MediaDevice +import com.android.systemui.dagger.qualifiers.Main +import java.util.concurrent.Executor +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Provides information about the route (ie. device) where playback is occurring. + */ +@Singleton +class MediaDeviceManager @Inject constructor( + private val context: Context, + private val localMediaManagerFactory: LocalMediaManagerFactory, + private val featureFlag: MediaFeatureFlag, + @Main private val fgExecutor: Executor +) { + private val listeners: MutableSet<Listener> = mutableSetOf() + private val entries: MutableMap<String, Token> = mutableMapOf() + + /** + * Add a listener for changes to the media route (ie. device). + */ + fun addListener(listener: Listener) = listeners.add(listener) + + /** + * Remove a listener that has been registered with addListener. + */ + fun removeListener(listener: Listener) = listeners.remove(listener) + + fun onNotificationAdded(key: String, sbn: StatusBarNotification) { + if (featureFlag.enabled && isMediaNotification(sbn)) { + var tok = entries[key] + if (tok == null) { + tok = Token(key, localMediaManagerFactory.create(sbn.packageName)) + entries[key] = tok + tok.start() + } + } else { + onNotificationRemoved(key) + } + } + + fun onNotificationRemoved(key: String) { + val token = entries.remove(key) + token?.stop() + token?.let { + listeners.forEach { + it.onKeyRemoved(key) + } + } + } + + private fun processDevice(key: String, device: MediaDevice?) { + val data = MediaDeviceData(device?.icon, device?.name) + listeners.forEach { + it.onMediaDeviceChanged(key, data) + } + } + + interface Listener { + /** Called when the route has changed for a given notification. */ + fun onMediaDeviceChanged(key: String, data: MediaDeviceData?) + /** Called when the notification was removed. */ + fun onKeyRemoved(key: String) + } + + private inner class Token( + val key: String, + val localMediaManager: LocalMediaManager + ) : LocalMediaManager.DeviceCallback { + private var current: MediaDevice? = null + set(value) { + if (value != field) { + field = value + processDevice(key, value) + } + } + fun start() { + localMediaManager.registerCallback(this) + localMediaManager.startScan() + current = localMediaManager.getCurrentConnectedDevice() + } + fun stop() { + localMediaManager.stopScan() + localMediaManager.unregisterCallback(this) + } + override fun onDeviceListUpdate(devices: List<MediaDevice>?) = fgExecutor.execute { + current = localMediaManager.getCurrentConnectedDevice() + } + override fun onSelectedDeviceStateChanged(device: MediaDevice, state: Int) { + fgExecutor.execute { + current = device + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaFeatureFlag.kt b/packages/SystemUI/src/com/android/systemui/media/MediaFeatureFlag.kt new file mode 100644 index 000000000000..75eb33da64d8 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/media/MediaFeatureFlag.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.media + +import android.content.Context +import com.android.systemui.util.Utils +import javax.inject.Inject + +/** + * Provides access to the current value of the feature flag. + */ +class MediaFeatureFlag @Inject constructor(private val context: Context) { + val enabled + get() = Utils.useQsMediaPlayer(context) +} diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt index 8db9dcc1ecec..17e8404fe705 100644 --- a/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt +++ b/packages/SystemUI/src/com/android/systemui/media/MediaViewManager.kt @@ -6,9 +6,6 @@ import android.view.View import android.view.ViewGroup import android.widget.HorizontalScrollView import android.widget.LinearLayout -import com.android.settingslib.bluetooth.LocalBluetoothManager -import com.android.settingslib.media.InfoMediaManager -import com.android.settingslib.media.LocalMediaManager import com.android.systemui.R import com.android.systemui.dagger.qualifiers.Background import com.android.systemui.dagger.qualifiers.Main @@ -30,10 +27,9 @@ class MediaViewManager @Inject constructor( private val context: Context, @Main private val foregroundExecutor: Executor, @Background private val backgroundExecutor: DelayableExecutor, - private val localBluetoothManager: LocalBluetoothManager?, private val visualStabilityManager: VisualStabilityManager, private val activityStarter: ActivityStarter, - mediaManager: MediaDataManager + mediaManager: MediaDataCombineLatest ) { private var playerWidth: Int = 0 private var playerWidthPlusPadding: Int = 0 @@ -42,7 +38,7 @@ class MediaViewManager @Inject constructor( val mediaCarousel: HorizontalScrollView private val mediaContent: ViewGroup private val mediaPlayers: MutableMap<String, MediaControlPanel> = mutableMapOf() - private val visualStabilityCallback : VisualStabilityManager.Callback + private val visualStabilityCallback: VisualStabilityManager.Callback private var activeMediaIndex: Int = 0 private var needsReordering: Boolean = false private var scrollIntoCurrentMedia: Int = 0 @@ -151,15 +147,8 @@ class MediaViewManager @Inject constructor( private fun updateView(key: String, data: MediaData) { var existingPlayer = mediaPlayers[key] if (existingPlayer == null) { - // Set up listener for device changes - // TODO: integrate with MediaTransferManager? - val imm = InfoMediaManager(context, data.packageName, - null /* notification */, localBluetoothManager) - val routeManager = LocalMediaManager(context, localBluetoothManager, - imm, data.packageName) - - existingPlayer = MediaControlPanel(context, routeManager, foregroundExecutor, - backgroundExecutor, activityStarter) + existingPlayer = MediaControlPanel(context, foregroundExecutor, backgroundExecutor, + activityStarter) existingPlayer.attach(PlayerViewHolder.create(LayoutInflater.from(context), mediaContent)) mediaPlayers[key] = existingPlayer diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java index 0fa1f553c1f3..8ed69d8fb982 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java @@ -53,6 +53,7 @@ import com.android.systemui.Interpolators; import com.android.systemui.colorextraction.SysuiColorExtractor; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.media.MediaDataManager; +import com.android.systemui.media.MediaDeviceManager; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.statusbar.dagger.StatusBarModule; import com.android.systemui.statusbar.notification.NotificationEntryListener; @@ -218,7 +219,8 @@ public class NotificationMediaManager implements Dumpable { KeyguardBypassController keyguardBypassController, @Main DelayableExecutor mainExecutor, DeviceConfigProxy deviceConfig, - MediaDataManager mediaDataManager) { + MediaDataManager mediaDataManager, + MediaDeviceManager mediaDeviceManager) { mContext = context; mMediaArtworkProcessor = mediaArtworkProcessor; mKeyguardBypassController = keyguardBypassController; @@ -239,11 +241,13 @@ public class NotificationMediaManager implements Dumpable { @Override public void onPendingEntryAdded(NotificationEntry entry) { mediaDataManager.onNotificationAdded(entry.getKey(), entry.getSbn()); + mediaDeviceManager.onNotificationAdded(entry.getKey(), entry.getSbn()); } @Override public void onPreEntryUpdated(NotificationEntry entry) { mediaDataManager.onNotificationAdded(entry.getKey(), entry.getSbn()); + mediaDeviceManager.onNotificationAdded(entry.getKey(), entry.getSbn()); } @Override @@ -264,6 +268,7 @@ public class NotificationMediaManager implements Dumpable { int reason) { onNotificationRemoved(entry.getKey()); mediaDataManager.onNotificationRemoved(entry.getKey()); + mediaDeviceManager.onNotificationRemoved(entry.getKey()); } }); diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java index 8c9ce500e3ea..ac2a9c118672 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java @@ -24,6 +24,7 @@ import com.android.internal.statusbar.IStatusBarService; import com.android.systemui.bubbles.BubbleController; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.media.MediaDataManager; +import com.android.systemui.media.MediaDeviceManager; import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.statusbar.ActionClickLogger; import com.android.systemui.statusbar.CommandQueue; @@ -101,7 +102,8 @@ public interface StatusBarDependenciesModule { KeyguardBypassController keyguardBypassController, @Main DelayableExecutor mainExecutor, DeviceConfigProxy deviceConfigProxy, - MediaDataManager mediaDataManager) { + MediaDataManager mediaDataManager, + MediaDeviceManager mediaDeviceManager) { return new NotificationMediaManager( context, statusBarLazy, @@ -111,7 +113,8 @@ public interface StatusBarDependenciesModule { keyguardBypassController, mainExecutor, deviceConfigProxy, - mediaDataManager); + mediaDataManager, + mediaDeviceManager); } /** */ 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 60d15a8fb480..e8fb41a18ce9 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaControlPanelTest.kt @@ -18,6 +18,8 @@ package com.android.systemui.media import android.content.res.ColorStateList import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.RippleDrawable import android.media.MediaMetadata import android.media.session.MediaSession import android.media.session.PlaybackState @@ -104,7 +106,7 @@ public class MediaControlPanelTest : SysuiTestCase() { activityStarter = mock(ActivityStarter::class.java) - player = MediaControlPanel(context, null, fgExecutor, bgExecutor, activityStarter) + player = MediaControlPanel(context, fgExecutor, bgExecutor, activityStarter) // Mock out a view holder for the player to attach to. holder = mock(PlayerViewHolder::class.java) @@ -129,6 +131,9 @@ public class MediaControlPanelTest : SysuiTestCase() { artistText = TextView(context) whenever(holder.artistText).thenReturn(artistText) seamless = FrameLayout(context) + val seamlessBackground = mock(RippleDrawable::class.java) + seamless.setBackground(seamlessBackground) + whenever(seamlessBackground.getDrawable(0)).thenReturn(mock(GradientDrawable::class.java)) whenever(holder.seamless).thenReturn(seamless) seamlessIcon = ImageView(context) whenever(holder.seamlessIcon).thenReturn(seamlessIcon) @@ -176,7 +181,7 @@ public class MediaControlPanelTest : SysuiTestCase() { @Test fun bindWhenUnattached() { val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(), - emptyList(), PACKAGE, null, null) + emptyList(), PACKAGE, null, null, MediaDeviceData(null, DEVICE_NAME)) player.bind(state) assertThat(player.isPlaying()).isFalse() } @@ -185,7 +190,8 @@ public class MediaControlPanelTest : SysuiTestCase() { fun bindText() { player.attach(holder) val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(), - emptyList(), PACKAGE, session.getSessionToken(), null) + emptyList(), PACKAGE, session.getSessionToken(), null, + MediaDeviceData(null, DEVICE_NAME)) player.bind(state) assertThat(appName.getText()).isEqualTo(APP) assertThat(titleText.getText()).isEqualTo(TITLE) @@ -196,7 +202,8 @@ public class MediaControlPanelTest : SysuiTestCase() { fun bindBackgroundColor() { player.attach(holder) val state = MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, emptyList(), - emptyList(), PACKAGE, session.getSessionToken(), null) + emptyList(), PACKAGE, session.getSessionToken(), null, + MediaDeviceData(null, DEVICE_NAME)) player.bind(state) assertThat(background.getBackgroundTintList()).isEqualTo(ColorStateList.valueOf(BG_COLOR)) } diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java new file mode 100644 index 000000000000..64a180f8aaab --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDataCombineLatestTest.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.media; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import android.graphics.Color; +import android.testing.AndroidTestingRunner; +import android.testing.TestableLooper; + +import androidx.test.filters.SmallTest; + +import com.android.systemui.SysuiTestCase; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; + +import java.util.ArrayList; + +@SmallTest +@RunWith(AndroidTestingRunner.class) +@TestableLooper.RunWithLooper +public class MediaDataCombineLatestTest extends SysuiTestCase { + + private static final String KEY = "TEST_KEY"; + private static final String APP = "APP"; + private static final String PACKAGE = "PKG"; + private static final int BG_COLOR = Color.RED; + private static final String ARTIST = "ARTIST"; + private static final String TITLE = "TITLE"; + private static final String DEVICE_NAME = "DEVICE_NAME"; + + private MediaDataCombineLatest mManager; + + @Mock private MediaDataManager mDataSource; + @Mock private MediaDeviceManager mDeviceSource; + @Mock private MediaDataManager.Listener mListener; + + private MediaDataManager.Listener mDataListener; + private MediaDeviceManager.Listener mDeviceListener; + + private MediaData mMediaData; + private MediaDeviceData mDeviceData; + + @Before + public void setUp() { + mDataSource = mock(MediaDataManager.class); + mDeviceSource = mock(MediaDeviceManager.class); + mListener = mock(MediaDataManager.Listener.class); + + mManager = new MediaDataCombineLatest(mDataSource, mDeviceSource); + + mDataListener = captureDataListener(); + mDeviceListener = captureDeviceListener(); + + mManager.addListener(mListener); + + mMediaData = new MediaData(true, BG_COLOR, APP, null, ARTIST, TITLE, null, + new ArrayList<>(), new ArrayList<>(), PACKAGE, null, null, null); + mDeviceData = new MediaDeviceData(null, DEVICE_NAME); + } + + @Test + public void eventNotEmittedWithoutDevice() { + // WHEN data source emits an event without device data + mDataListener.onMediaDataLoaded(KEY, mMediaData); + // THEN an event isn't emitted + verify(mListener, never()).onMediaDataLoaded(eq(KEY), any()); + } + + @Test + public void eventNotEmittedWithoutMedia() { + // WHEN device source emits an event without media data + mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData); + // THEN an event isn't emitted + verify(mListener, never()).onMediaDataLoaded(eq(KEY), any()); + } + + @Test + public void emitEventAfterDeviceFirst() { + // GIVEN that a device event has already been received + mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData); + // WHEN media event is received + mDataListener.onMediaDataLoaded(KEY, mMediaData); + // THEN the listener receives a combined event + ArgumentCaptor<MediaData> captor = ArgumentCaptor.forClass(MediaData.class); + verify(mListener).onMediaDataLoaded(eq(KEY), captor.capture()); + assertThat(captor.getValue().getDevice()).isNotNull(); + } + + @Test + public void emitEventAfterMediaFirst() { + // GIVEN that media event has already been received + mDataListener.onMediaDataLoaded(KEY, mMediaData); + // WHEN device event is received + mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData); + // THEN the listener receives a combined event + ArgumentCaptor<MediaData> captor = ArgumentCaptor.forClass(MediaData.class); + verify(mListener).onMediaDataLoaded(eq(KEY), captor.capture()); + assertThat(captor.getValue().getDevice()).isNotNull(); + } + + @Test + public void mediaDataRemoved() { + // WHEN media data is removed without first receiving device or data + mDataListener.onMediaDataRemoved(KEY); + // THEN a removed event isn't emitted + verify(mListener, never()).onMediaDataRemoved(eq(KEY)); + } + + @Test + public void mediaDataRemovedAfterMediaEvent() { + mDataListener.onMediaDataLoaded(KEY, mMediaData); + mDataListener.onMediaDataRemoved(KEY); + verify(mListener).onMediaDataRemoved(eq(KEY)); + } + + @Test + public void mediaDataRemovedAfterDeviceEvent() { + mDeviceListener.onMediaDeviceChanged(KEY, mDeviceData); + mDataListener.onMediaDataRemoved(KEY); + verify(mListener).onMediaDataRemoved(eq(KEY)); + } + + private MediaDataManager.Listener captureDataListener() { + ArgumentCaptor<MediaDataManager.Listener> captor = ArgumentCaptor.forClass( + MediaDataManager.Listener.class); + verify(mDataSource).addListener(captor.capture()); + return captor.getValue(); + } + + private MediaDeviceManager.Listener captureDeviceListener() { + ArgumentCaptor<MediaDeviceManager.Listener> captor = ArgumentCaptor.forClass( + MediaDeviceManager.Listener.class); + verify(mDeviceSource).addListener(captor.capture()); + return captor.getValue(); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt new file mode 100644 index 000000000000..ac6b5f6bca66 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/media/MediaDeviceManagerTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.media + +import android.app.Notification +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Process +import android.service.notification.StatusBarNotification +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import androidx.test.filters.SmallTest + +import com.android.settingslib.media.LocalMediaManager +import com.android.settingslib.media.MediaDevice +import com.android.systemui.SysuiTestCase +import com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.time.FakeSystemClock + +import com.google.common.truth.Truth.assertThat + +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever + +private const val KEY = "TEST_KEY" +private const val PACKAGE = "PKG" +private const val SESSION_KEY = "SESSION_KEY" +private const val SESSION_ARTIST = "SESSION_ARTIST" +private const val SESSION_TITLE = "SESSION_TITLE" +private const val DEVICE_NAME = "DEVICE_NAME" + +private fun <T> eq(value: T): T = Mockito.eq(value) ?: value + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper +public class MediaDeviceManagerTest : SysuiTestCase() { + + private lateinit var manager: MediaDeviceManager + + @Mock private lateinit var lmmFactory: LocalMediaManagerFactory + @Mock private lateinit var lmm: LocalMediaManager + @Mock private lateinit var featureFlag: MediaFeatureFlag + private lateinit var fakeExecutor: FakeExecutor + + @Mock private lateinit var device: MediaDevice + private lateinit var session: MediaSession + private lateinit var metadataBuilder: MediaMetadata.Builder + private lateinit var playbackBuilder: PlaybackState.Builder + private lateinit var notifBuilder: Notification.Builder + private lateinit var sbn: StatusBarNotification + + @Before + fun setup() { + lmmFactory = mock(LocalMediaManagerFactory::class.java) + lmm = mock(LocalMediaManager::class.java) + device = mock(MediaDevice::class.java) + whenever(device.name).thenReturn(DEVICE_NAME) + whenever(lmmFactory.create(PACKAGE)).thenReturn(lmm) + whenever(lmm.getCurrentConnectedDevice()).thenReturn(device) + featureFlag = mock(MediaFeatureFlag::class.java) + whenever(featureFlag.enabled).thenReturn(true) + + fakeExecutor = FakeExecutor(FakeSystemClock()) + + manager = MediaDeviceManager(context, lmmFactory, featureFlag, fakeExecutor) + + // Create a media sesssion and notification for testing. + metadataBuilder = MediaMetadata.Builder().apply { + putString(MediaMetadata.METADATA_KEY_ARTIST, SESSION_ARTIST) + putString(MediaMetadata.METADATA_KEY_TITLE, SESSION_TITLE) + } + playbackBuilder = PlaybackState.Builder().apply { + setState(PlaybackState.STATE_PAUSED, 6000L, 1f) + setActions(PlaybackState.ACTION_PLAY) + } + session = MediaSession(context, SESSION_KEY).apply { + setMetadata(metadataBuilder.build()) + setPlaybackState(playbackBuilder.build()) + } + session.setActive(true) + notifBuilder = Notification.Builder(context, "NONE").apply { + setContentTitle(SESSION_TITLE) + setContentText(SESSION_ARTIST) + setSmallIcon(android.R.drawable.ic_media_pause) + setStyle(Notification.MediaStyle().setMediaSession(session.getSessionToken())) + } + sbn = StatusBarNotification(PACKAGE, PACKAGE, 0, "TAG", Process.myUid(), 0, 0, + notifBuilder.build(), Process.myUserHandle(), 0) + } + + @After + fun tearDown() { + session.release() + } + + @Test + fun removeUnknown() { + manager.onNotificationRemoved("unknown") + } + + @Test + fun addNotification() { + manager.onNotificationAdded(KEY, sbn) + verify(lmmFactory).create(PACKAGE) + } + + @Test + fun featureDisabled() { + whenever(featureFlag.enabled).thenReturn(false) + manager.onNotificationAdded(KEY, sbn) + verify(lmmFactory, never()).create(PACKAGE) + } + + @Test + fun addAndRemoveNotification() { + manager.onNotificationAdded(KEY, sbn) + manager.onNotificationRemoved(KEY) + verify(lmm).unregisterCallback(any()) + } + + @Test + fun deviceListUpdate() { + val listener = mock(MediaDeviceManager.Listener::class.java) + manager.addListener(listener) + manager.onNotificationAdded(KEY, sbn) + val deviceCallback = captureCallback() + // WHEN the device list changes + deviceCallback.onDeviceListUpdate(mutableListOf(device)) + assertThat(fakeExecutor.runAllReady()).isEqualTo(1) + // THEN the update is dispatched to the listener + val captor = ArgumentCaptor.forClass(MediaDeviceData::class.java) + verify(listener).onMediaDeviceChanged(eq(KEY), captor.capture()) + val data = captor.getValue() + assertThat(data.name).isEqualTo(DEVICE_NAME) + } + + @Test + fun selectedDeviceStateChanged() { + val listener = mock(MediaDeviceManager.Listener::class.java) + manager.addListener(listener) + manager.onNotificationAdded(KEY, sbn) + val deviceCallback = captureCallback() + // WHEN the selected device changes state + deviceCallback.onSelectedDeviceStateChanged(device, 1) + assertThat(fakeExecutor.runAllReady()).isEqualTo(1) + // THEN the update is dispatched to the listener + val captor = ArgumentCaptor.forClass(MediaDeviceData::class.java) + verify(listener).onMediaDeviceChanged(eq(KEY), captor.capture()) + val data = captor.getValue() + assertThat(data.name).isEqualTo(DEVICE_NAME) + } + + @Test + fun listenerReceivesKeyRemoved() { + manager.onNotificationAdded(KEY, sbn) + val listener = mock(MediaDeviceManager.Listener::class.java) + manager.addListener(listener) + // WHEN the notification is removed + manager.onNotificationRemoved(KEY) + // THEN the listener receives key removed event + verify(listener).onKeyRemoved(eq(KEY)) + } + + fun captureCallback(): LocalMediaManager.DeviceCallback { + val captor = ArgumentCaptor.forClass(LocalMediaManager.DeviceCallback::class.java) + verify(lmm).registerCallback(captor.capture()) + return captor.getValue() + } +} |