diff options
| author | 2020-03-20 11:56:22 -0400 | |
|---|---|---|
| committer | 2020-03-24 00:16:43 -0400 | |
| commit | b6464ea36c09b60e00dd1366b633bb8682a856d9 (patch) | |
| tree | f0fb718fe8eac3f346321466646f5875ae8259bb | |
| parent | 25eee749f91459bf57081026bf9011120548a8a8 (diff) | |
Lock screen media controls
Media controls are added as a new bucket/section to the NSSL. This
bucket is only shown on the lock screen.
Bug: 150454272
Test: manual - play music and check that controls appear on lock screen
Test: manual - play music and check that controls don't appear in shade
Change-Id: Ibbeebf05db9400048dae48dbbd8d3865664513a1
11 files changed, 790 insertions, 13 deletions
diff --git a/packages/SystemUI/res/layout/keyguard_media_header.xml b/packages/SystemUI/res/layout/keyguard_media_header.xml new file mode 100644 index 000000000000..9c2d244cb8ec --- /dev/null +++ b/packages/SystemUI/res/layout/keyguard_media_header.xml @@ -0,0 +1,153 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ 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 + --> + +<!-- Layout for media controls on the lockscreen --> +<com.android.systemui.statusbar.notification.stack.MediaHeaderView + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingStart="0dp" + android:paddingEnd="0dp" + android:focusable="true" + android:clickable="true" +> + + <!-- Background views required by ActivatableNotificationView. --> + <com.android.systemui.statusbar.notification.row.NotificationBackgroundView + android:id="@+id/backgroundNormal" + android:layout_width="match_parent" + android:layout_height="match_parent" + /> + + <com.android.systemui.statusbar.notification.row.NotificationBackgroundView + android:id="@+id/backgroundDimmed" + android:layout_width="match_parent" + android:layout_height="match_parent" + /> + + <com.android.systemui.statusbar.notification.FakeShadowView + android:id="@+id/fake_shadow" + android:layout_width="match_parent" + android:layout_height="match_parent" + /> + + <!-- Layout for media controls. --> + <LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/keyguard_media_view" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_horizontal|fill_vertical" + android:padding="16dp" + > + <ImageView + android:id="@+id/album_art" + android:layout_width="@dimen/qs_media_album_size" + android:layout_height="@dimen/qs_media_album_size" + android:layout_marginRight="16dp" + android:layout_weight="0" + /> + + <!-- Media information --> + <LinearLayout + android:orientation="vertical" + android:layout_width="0dp" + android:layout_height="@dimen/qs_media_album_size" + android:layout_weight="1" + > + <LinearLayout + android:orientation="horizontal" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center" + > + <com.android.internal.widget.CachingIconView + android:id="@+id/icon" + android:layout_width="16dp" + android:layout_height="16dp" + android:layout_marginEnd="5dp" + /> + <TextView + android:id="@+id/app_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textSize="14sp" + android:singleLine="true" + /> + </LinearLayout> + + <!-- Song name --> + <TextView + android:id="@+id/header_title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:singleLine="true" + android:fontFamily="@*android:string/config_headlineFontFamilyMedium" + android:textSize="18sp" + android:paddingBottom="6dp" + android:gravity="center"/> + + <!-- Artist name --> + <TextView + android:id="@+id/header_artist" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:fontFamily="@*android:string/config_bodyFontFamily" + android:textSize="14sp" + android:singleLine="true" + /> + </LinearLayout> + + <!-- Controls --> + <LinearLayout + android:id="@+id/media_actions" + android:orientation="horizontal" + android:layoutDirection="ltr" + android:layout_width="wrap_content" + android:layout_height="match_parent" + android:gravity="center" + android:layout_gravity="center" + > + <ImageButton + style="@android:style/Widget.Material.Button.Borderless.Small" + android:layout_width="48dp" + android:layout_height="48dp" + android:gravity="center" + android:visibility="gone" + android:id="@+id/action0" + /> + <ImageButton + style="@android:style/Widget.Material.Button.Borderless.Small" + android:layout_width="48dp" + android:layout_height="48dp" + android:gravity="center" + android:visibility="gone" + android:id="@+id/action1" + /> + <ImageButton + style="@android:style/Widget.Material.Button.Borderless.Small" + android:layout_width="48dp" + android:layout_height="48dp" + android:gravity="center" + android:visibility="gone" + android:id="@+id/action2" + /> + </LinearLayout> + </LinearLayout> + +</com.android.systemui.statusbar.notification.stack.MediaHeaderView> diff --git a/packages/SystemUI/src/com/android/keyguard/KeyguardMediaPlayer.java b/packages/SystemUI/src/com/android/keyguard/KeyguardMediaPlayer.java new file mode 100644 index 000000000000..b0017269ff7e --- /dev/null +++ b/packages/SystemUI/src/com/android/keyguard/KeyguardMediaPlayer.java @@ -0,0 +1,266 @@ +/* + * 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.keyguard; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.media.MediaMetadata; +import android.util.Log; +import android.view.View; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.core.graphics.drawable.RoundedBitmapDrawable; +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; +import androidx.palette.graphics.Palette; + +import com.android.internal.util.ContrastColorUtil; +import com.android.systemui.R; +import com.android.systemui.dagger.qualifiers.Background; +import com.android.systemui.statusbar.notification.MediaNotificationProcessor; +import com.android.systemui.statusbar.notification.collection.NotificationEntry; +import com.android.systemui.statusbar.notification.stack.MediaHeaderView; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Media controls to display on the lockscreen + * + * TODO: Should extend MediaControlPanel to avoid code duplication. + * Unfortunately, it isn't currently possible because the ActivatableNotificationView background is + * different. + */ +@Singleton +public class KeyguardMediaPlayer { + + private static final String TAG = "KeyguardMediaPlayer"; + // Buttons that can be displayed on lock screen media controls. + private static final int[] ACTION_IDS = {R.id.action0, R.id.action1, R.id.action2}; + + private final Context mContext; + private final Executor mBackgroundExecutor; + private float mAlbumArtRadius; + private int mAlbumArtSize; + private View mMediaNotifView; + + @Inject + public KeyguardMediaPlayer(Context context, @Background Executor backgroundExecutor) { + mContext = context; + mBackgroundExecutor = backgroundExecutor; + loadDimens(); + } + + /** Binds media controls to a view hierarchy. */ + public void bindView(View v) { + if (mMediaNotifView != null) { + throw new IllegalStateException("cannot bind views, already bound"); + } + mMediaNotifView = v; + loadDimens(); + } + + /** Unbinds media controls. */ + public void unbindView() { + if (mMediaNotifView == null) { + throw new IllegalStateException("cannot unbind views, nothing bound"); + } + mMediaNotifView = null; + } + + /** Clear the media controls because there isn't an active session. */ + public void clearControls() { + if (mMediaNotifView != null) { + mMediaNotifView.setVisibility(View.GONE); + } + } + + /** + * Update the media player + * + * TODO: consider registering a MediaLister instead of exposing this update method. + * + * @param entry Media notification that will be used to update the player + * @param appIcon Icon for the app playing the media + * @param mediaMetadata Media metadata that will be used to update the player + */ + public void updateControls(NotificationEntry entry, Icon appIcon, + MediaMetadata mediaMetadata) { + if (mMediaNotifView == null) { + throw new IllegalStateException("cannot update controls, views not bound"); + } + if (mediaMetadata == null) { + throw new IllegalArgumentException("media metadata was null"); + } + mMediaNotifView.setVisibility(View.VISIBLE); + + Notification notif = entry.getSbn().getNotification(); + + // Computed foreground and background color based on album art. + int fgColor = notif.color; + int bgColor = entry.getRow() == null ? -1 : entry.getRow().getCurrentBackgroundTint(); + Bitmap artworkBitmap = mediaMetadata.getBitmap(MediaMetadata.METADATA_KEY_ART); + if (artworkBitmap == null) { + artworkBitmap = mediaMetadata.getBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART); + } + if (artworkBitmap != null) { + // If we have art, get colors from that + Palette p = MediaNotificationProcessor.generateArtworkPaletteBuilder(artworkBitmap) + .generate(); + Palette.Swatch swatch = MediaNotificationProcessor.findBackgroundSwatch(p); + bgColor = swatch.getRgb(); + fgColor = MediaNotificationProcessor.selectForegroundColor(bgColor, p); + } + // Make sure colors will be legible + boolean isDark = !ContrastColorUtil.isColorLight(bgColor); + fgColor = ContrastColorUtil.resolveContrastColor(mContext, fgColor, bgColor, + isDark); + fgColor = ContrastColorUtil.ensureTextContrast(fgColor, bgColor, isDark); + + // Album art + ImageView albumView = mMediaNotifView.findViewById(R.id.album_art); + if (albumView != null) { + // Resize art in a background thread + final Bitmap bm = artworkBitmap; + mBackgroundExecutor.execute(() -> processAlbumArt(bm, albumView)); + } + + // App icon + ImageView appIconView = mMediaNotifView.findViewById(R.id.icon); + if (appIconView != null) { + Drawable iconDrawable = appIcon.loadDrawable(mContext); + iconDrawable.setTint(fgColor); + appIconView.setImageDrawable(iconDrawable); + } + + // App name + TextView appName = mMediaNotifView.findViewById(R.id.app_name); + if (appName != null) { + Notification.Builder builder = Notification.Builder.recoverBuilder(mContext, notif); + String appNameString = builder.loadHeaderAppName(); + appName.setText(appNameString); + appName.setTextColor(fgColor); + } + + // Song name + TextView titleText = mMediaNotifView.findViewById(R.id.header_title); + if (titleText != null) { + String songName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE); + titleText.setText(songName); + titleText.setTextColor(fgColor); + } + + // Artist name + TextView artistText = mMediaNotifView.findViewById(R.id.header_artist); + if (artistText != null) { + String artistName = mediaMetadata.getString(MediaMetadata.METADATA_KEY_ARTIST); + artistText.setText(artistName); + artistText.setTextColor(fgColor); + } + + // Background color + if (mMediaNotifView instanceof MediaHeaderView) { + MediaHeaderView head = (MediaHeaderView) mMediaNotifView; + head.setBackgroundColor(bgColor); + } + + // Control buttons + final List<Icon> icons = new ArrayList<>(); + final List<PendingIntent> intents = new ArrayList<>(); + Notification.Action[] actions = notif.actions; + final int[] actionsToShow = notif.extras.getIntArray(Notification.EXTRA_COMPACT_ACTIONS); + + for (int i = 0; i < ACTION_IDS.length; i++) { + if (actionsToShow != null && actions != null && i < actionsToShow.length + && actionsToShow[i] < actions.length) { + final int idx = actionsToShow[i]; + icons.add(actions[idx].getIcon()); + intents.add(actions[idx].actionIntent); + } else { + icons.add(null); + intents.add(null); + } + } + + Context packageContext = entry.getSbn().getPackageContext(mContext); + for (int i = 0; i < ACTION_IDS.length; i++) { + ImageButton button = mMediaNotifView.findViewById(ACTION_IDS[i]); + if (button == null) { + continue; + } + Icon icon = icons.get(i); + if (icon == null) { + button.setVisibility(View.GONE); + } else { + button.setVisibility(View.VISIBLE); + button.setImageDrawable(icon.loadDrawable(packageContext)); + button.setImageTintList(ColorStateList.valueOf(fgColor)); + final PendingIntent intent = intents.get(i); + if (intent != null) { + button.setOnClickListener(v -> { + try { + intent.send(); + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "failed to send action intent", e); + } + }); + } + } + } + } + + /** + * Process album art for layout + * @param albumArt bitmap to use for album art + * @param albumView view to hold the album art + */ + private void processAlbumArt(Bitmap albumArt, ImageView albumView) { + RoundedBitmapDrawable roundedDrawable = null; + if (albumArt != null) { + Bitmap original = albumArt.copy(Bitmap.Config.ARGB_8888, true); + Bitmap scaled = Bitmap.createScaledBitmap(original, mAlbumArtSize, mAlbumArtSize, + false); + roundedDrawable = RoundedBitmapDrawableFactory.create(mContext.getResources(), scaled); + roundedDrawable.setCornerRadius(mAlbumArtRadius); + } else { + Log.e(TAG, "No album art available"); + } + + // Now that it's resized, update the UI + final RoundedBitmapDrawable result = roundedDrawable; + albumView.post(() -> { + albumView.setImageDrawable(result); + albumView.setVisibility(result == null ? View.GONE : View.VISIBLE); + }); + } + + private void loadDimens() { + mAlbumArtRadius = mContext.getResources().getDimension(R.dimen.qs_media_corner_radius); + mAlbumArtSize = (int) mContext.getResources().getDimension( + R.dimen.qs_media_album_size); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java index 87be73998fcc..53928ab166e4 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/NotificationMediaManager.java @@ -46,6 +46,7 @@ import android.widget.ImageView; import com.android.internal.config.sysui.SystemUiDeviceConfigFlags; import com.android.internal.statusbar.NotificationVisibility; +import com.android.keyguard.KeyguardMediaPlayer; import com.android.systemui.Dependency; import com.android.systemui.Dumpable; import com.android.systemui.Interpolators; @@ -65,6 +66,7 @@ import com.android.systemui.statusbar.phone.ScrimState; import com.android.systemui.statusbar.phone.StatusBar; import com.android.systemui.statusbar.policy.KeyguardStateController; import com.android.systemui.util.DeviceConfigProxy; +import com.android.systemui.util.Utils; import java.io.FileDescriptor; import java.io.PrintWriter; @@ -110,6 +112,7 @@ public class NotificationMediaManager implements Dumpable { private ScrimController mScrimController; @Nullable private LockscreenWallpaper mLockscreenWallpaper; + private final KeyguardMediaPlayer mMediaPlayer; private final Executor mMainExecutor; @@ -183,11 +186,13 @@ public class NotificationMediaManager implements Dumpable { NotificationEntryManager notificationEntryManager, MediaArtworkProcessor mediaArtworkProcessor, KeyguardBypassController keyguardBypassController, + KeyguardMediaPlayer keyguardMediaPlayer, @Main Executor mainExecutor, DeviceConfigProxy deviceConfig) { mContext = context; mMediaArtworkProcessor = mediaArtworkProcessor; mKeyguardBypassController = keyguardBypassController; + mMediaPlayer = keyguardMediaPlayer; mMediaListeners = new ArrayList<>(); // TODO: use MediaSessionManager.SessionListener to hook us up to future updates // in session state @@ -468,6 +473,7 @@ public class NotificationMediaManager implements Dumpable { && mBiometricUnlockController.isWakeAndUnlock(); if (mKeyguardStateController.isLaunchTransitionFadingAway() || wakeAndUnlock) { mBackdrop.setVisibility(View.INVISIBLE); + mMediaPlayer.clearControls(); Trace.endSection(); return; } @@ -490,6 +496,14 @@ public class NotificationMediaManager implements Dumpable { } } + NotificationEntry entry = mEntryManager + .getActiveNotificationUnfiltered(mMediaNotificationKey); + if (entry != null) { + mMediaPlayer.updateControls(entry, getMediaIcon(), mediaMetadata); + } else { + mMediaPlayer.clearControls(); + } + // Process artwork on a background thread and send the resulting bitmap to // finishUpdateMediaMetaData. if (metaDataChanged) { @@ -498,7 +512,7 @@ public class NotificationMediaManager implements Dumpable { } mProcessArtworkTasks.clear(); } - if (artworkBitmap != null) { + if (artworkBitmap != null && !Utils.useQsMediaPlayer(mContext)) { mProcessArtworkTasks.add(new ProcessArtworkTask(this, metaDataChanged, allowEnterAnimation).execute(artworkBitmap)); } else { @@ -612,6 +626,7 @@ public class NotificationMediaManager implements Dumpable { // We are unlocking directly - no animation! mBackdrop.setVisibility(View.GONE); mBackdropBack.setImageDrawable(null); + mMediaPlayer.clearControls(); if (windowController != null) { windowController.setBackdropShowing(false); } @@ -628,6 +643,7 @@ public class NotificationMediaManager implements Dumpable { mBackdrop.setVisibility(View.GONE); mBackdropFront.animate().cancel(); mBackdropBack.setImageDrawable(null); + mMediaPlayer.clearControls(); mMainExecutor.execute(mHideBackdropFront); }); if (mKeyguardStateController.isKeyguardFadingAway()) { 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 4c99a90e7da0..e64b423aab60 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java @@ -21,6 +21,7 @@ import android.content.Context; import android.os.Handler; import com.android.internal.statusbar.IStatusBarService; +import com.android.keyguard.KeyguardMediaPlayer; import com.android.systemui.bubbles.BubbleController; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.plugins.statusbar.StatusBarStateController; @@ -93,6 +94,7 @@ public interface StatusBarDependenciesModule { NotificationEntryManager notificationEntryManager, MediaArtworkProcessor mediaArtworkProcessor, KeyguardBypassController keyguardBypassController, + KeyguardMediaPlayer keyguardMediaPlayer, @Main Executor mainExecutor, DeviceConfigProxy deviceConfigProxy) { return new NotificationMediaManager( @@ -102,6 +104,7 @@ public interface StatusBarDependenciesModule { notificationEntryManager, mediaArtworkProcessor, keyguardBypassController, + keyguardMediaPlayer, mainExecutor, deviceConfigProxy); } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/MediaNotificationProcessor.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/MediaNotificationProcessor.java index 5d1ab4fd9ad2..db5458664023 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/MediaNotificationProcessor.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/MediaNotificationProcessor.java @@ -152,7 +152,13 @@ public class MediaNotificationProcessor { } } - private int selectForegroundColor(int backgroundColor, Palette palette) { + /** + * Select a foreground color depending on whether the background color is dark or light + * @param backgroundColor Background color to coordinate with + * @param palette Artwork palette, should be obtained from {@link generateArtworkPaletteBuilder} + * @return foreground color + */ + public static int selectForegroundColor(int backgroundColor, Palette palette) { if (ContrastColorUtil.isColorLight(backgroundColor)) { return selectForegroundColorForSwatches(palette.getDarkVibrantSwatch(), palette.getVibrantSwatch(), @@ -170,7 +176,7 @@ public class MediaNotificationProcessor { } } - private int selectForegroundColorForSwatches(Palette.Swatch moreVibrant, + private static int selectForegroundColorForSwatches(Palette.Swatch moreVibrant, Palette.Swatch vibrant, Palette.Swatch moreMutedSwatch, Palette.Swatch mutedSwatch, Palette.Swatch dominantSwatch, int fallbackColor) { Palette.Swatch coloredCandidate = selectVibrantCandidate(moreVibrant, vibrant); @@ -194,7 +200,7 @@ public class MediaNotificationProcessor { } } - private Palette.Swatch selectMutedCandidate(Palette.Swatch first, + private static Palette.Swatch selectMutedCandidate(Palette.Swatch first, Palette.Swatch second) { boolean firstValid = hasEnoughPopulation(first); boolean secondValid = hasEnoughPopulation(second); @@ -215,7 +221,8 @@ public class MediaNotificationProcessor { return null; } - private Palette.Swatch selectVibrantCandidate(Palette.Swatch first, Palette.Swatch second) { + private static Palette.Swatch selectVibrantCandidate(Palette.Swatch first, + Palette.Swatch second) { boolean firstValid = hasEnoughPopulation(first); boolean secondValid = hasEnoughPopulation(second); if (firstValid && secondValid) { @@ -235,7 +242,7 @@ public class MediaNotificationProcessor { return null; } - private boolean hasEnoughPopulation(Palette.Swatch swatch) { + private static boolean hasEnoughPopulation(Palette.Swatch swatch) { // We want a fraction that is at least 1% of the image return swatch != null && (swatch.getPopulation() / (float) RESIZE_BITMAP_AREA > MINIMUM_IMAGE_FRACTION); @@ -257,7 +264,7 @@ public class MediaNotificationProcessor { * @param palette Artwork palette, should be obtained from {@link generateArtworkPaletteBuilder} * @return Swatch that should be used as the background of the media notification. */ - private static Palette.Swatch findBackgroundSwatch(Palette palette) { + public static Palette.Swatch findBackgroundSwatch(Palette palette) { // by default we use the dominant palette Palette.Swatch dominantSwatch = palette.getDominantSwatch(); if (dominantSwatch == null) { @@ -301,7 +308,7 @@ public class MediaNotificationProcessor { * @param artwork Media artwork * @return Builder that generates the {@link Palette} for the media artwork. */ - private static Palette.Builder generateArtworkPaletteBuilder(Bitmap artwork) { + public static Palette.Builder generateArtworkPaletteBuilder(Bitmap artwork) { // for the background we only take the left side of the image to ensure // a smooth transition return Palette.from(artwork) diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotificationSectionsFeatureManager.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotificationSectionsFeatureManager.kt index 48386dce5d3f..e2b01ffe59b8 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotificationSectionsFeatureManager.kt +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/NotificationSectionsFeatureManager.kt @@ -23,9 +23,11 @@ import com.android.internal.annotations.VisibleForTesting import com.android.internal.config.sysui.SystemUiDeviceConfigFlags.NOTIFICATIONS_USE_PEOPLE_FILTERING import com.android.systemui.statusbar.notification.stack.NotificationSectionsManager.BUCKET_ALERTING import com.android.systemui.statusbar.notification.stack.NotificationSectionsManager.BUCKET_HEADS_UP +import com.android.systemui.statusbar.notification.stack.NotificationSectionsManager.BUCKET_MEDIA_CONTROLS import com.android.systemui.statusbar.notification.stack.NotificationSectionsManager.BUCKET_PEOPLE import com.android.systemui.statusbar.notification.stack.NotificationSectionsManager.BUCKET_SILENT import com.android.systemui.util.DeviceConfigProxy +import com.android.systemui.util.Utils import javax.inject.Inject @@ -43,9 +45,18 @@ class NotificationSectionsFeatureManager @Inject constructor( return usePeopleFiltering(proxy) } + fun isMediaControlsEnabled(): Boolean { + return Utils.useQsMediaPlayer(context) + } + fun getNotificationBuckets(): IntArray { return when { - isFilteringEnabled() -> + isFilteringEnabled() && isMediaControlsEnabled() -> + intArrayOf(BUCKET_HEADS_UP, BUCKET_MEDIA_CONTROLS, BUCKET_PEOPLE, BUCKET_ALERTING, + BUCKET_SILENT) + !isFilteringEnabled() && isMediaControlsEnabled() -> + intArrayOf(BUCKET_HEADS_UP, BUCKET_MEDIA_CONTROLS, BUCKET_ALERTING, BUCKET_SILENT) + isFilteringEnabled() && !isMediaControlsEnabled() -> intArrayOf(BUCKET_HEADS_UP, BUCKET_PEOPLE, BUCKET_ALERTING, BUCKET_SILENT) NotificationUtils.useNewInterruptionModel(context) -> intArrayOf(BUCKET_ALERTING, BUCKET_SILENT) diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MediaHeaderView.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MediaHeaderView.java new file mode 100644 index 000000000000..ab055e1bdc36 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/MediaHeaderView.java @@ -0,0 +1,55 @@ +/* + * 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.statusbar.notification.stack; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import com.android.systemui.R; +import com.android.systemui.statusbar.notification.row.ActivatableNotificationView; + +/** + * Root view to insert Lock screen media controls into the notification stack. + */ +public class MediaHeaderView extends ActivatableNotificationView { + + private View mContentView; + + public MediaHeaderView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mContentView = findViewById(R.id.keyguard_media_view); + } + + @Override + protected View getContentView() { + return mContentView; + } + + /** + * Sets the background color, to be used when album art changes. + * @param color background + */ + public void setBackgroundColor(int color) { + setTintColor(color); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationSectionsManager.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationSectionsManager.java index 42a7c6a07e0f..7633aa589dde 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationSectionsManager.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationSectionsManager.java @@ -30,6 +30,7 @@ import android.view.LayoutInflater; import android.view.View; import com.android.internal.annotations.VisibleForTesting; +import com.android.keyguard.KeyguardMediaPlayer; import com.android.systemui.R; import com.android.systemui.plugins.ActivityStarter; import com.android.systemui.plugins.statusbar.StatusBarStateController; @@ -71,6 +72,7 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section private final StatusBarStateController mStatusBarStateController; private final ConfigurationController mConfigurationController; private final PeopleHubViewAdapter mPeopleHubViewAdapter; + private final KeyguardMediaPlayer mKeyguardMediaPlayer; private final NotificationSectionsFeatureManager mSectionsFeatureManager; private final int mNumberOfSections; @@ -110,17 +112,21 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section private boolean mPeopleHubVisible = false; @Nullable private Subscription mPeopleHubSubscription; + private MediaHeaderView mMediaControlsView; + @Inject NotificationSectionsManager( ActivityStarter activityStarter, StatusBarStateController statusBarStateController, ConfigurationController configurationController, PeopleHubViewAdapter peopleHubViewAdapter, + KeyguardMediaPlayer keyguardMediaPlayer, NotificationSectionsFeatureManager sectionsFeatureManager) { mActivityStarter = activityStarter; mStatusBarStateController = statusBarStateController; mConfigurationController = configurationController; mPeopleHubViewAdapter = peopleHubViewAdapter; + mKeyguardMediaPlayer = keyguardMediaPlayer; mSectionsFeatureManager = sectionsFeatureManager; mNumberOfSections = mSectionsFeatureManager.getNumberOfBuckets(); } @@ -188,6 +194,13 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section } mPeopleHubView = reinflateView(mPeopleHubView, layoutInflater, R.layout.people_strip); mPeopleHubSubscription = mPeopleHubViewAdapter.bindView(mPeopleHubViewBoundary); + + if (mMediaControlsView != null) { + mKeyguardMediaPlayer.unbindView(); + } + mMediaControlsView = reinflateView(mMediaControlsView, layoutInflater, + R.layout.keyguard_media_header); + mKeyguardMediaPlayer.bindView(mMediaControlsView); } /** Listener for when the "clear all" button is clicked on the gentle notification header. */ @@ -198,6 +211,7 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section @Override public boolean beginsSection(@NonNull View view, @Nullable View previous) { return view == mGentleHeader + || view == mMediaControlsView || view == mPeopleHubView || view == mAlertingHeader || !Objects.equals(getBucket(view), getBucket(previous)); @@ -211,6 +225,8 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section private Integer getBucket(View view) { if (view == mGentleHeader) { return BUCKET_SILENT; + } else if (view == mMediaControlsView) { + return BUCKET_MEDIA_CONTROLS; } else if (view == mPeopleHubView) { return BUCKET_PEOPLE; } else if (view == mAlertingHeader) { @@ -238,9 +254,15 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section final boolean showHeaders = mStatusBarStateController.getState() != StatusBarState.KEYGUARD; final boolean usingPeopleFiltering = mSectionsFeatureManager.isFilteringEnabled(); + final boolean isKeyguard = mStatusBarStateController.getState() == StatusBarState.KEYGUARD; + final boolean usingMediaControls = mSectionsFeatureManager.isMediaControlsEnabled(); boolean peopleNotifsPresent = false; + int currentMediaControlsIdx = -1; + // Currently, just putting media controls in the front and incrementing the position based + // on the number of heads-up notifs. + int mediaControlsTarget = isKeyguard && usingMediaControls ? 0 : -1; int currentPeopleHeaderIdx = -1; int peopleHeaderTarget = -1; int currentAlertingHeaderIdx = -1; @@ -255,6 +277,10 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section View child = mParent.getChildAt(i); // Track the existing positions of the headers + if (child == mMediaControlsView) { + currentMediaControlsIdx = i; + continue; + } if (child == mPeopleHubView) { currentPeopleHeaderIdx = i; continue; @@ -276,6 +302,9 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section // Once we enter a new section, calculate the target position for the header. switch (row.getEntry().getBucket()) { case BUCKET_HEADS_UP: + if (mediaControlsTarget != -1) { + mediaControlsTarget++; + } break; case BUCKET_PEOPLE: peopleNotifsPresent = true; @@ -345,6 +374,8 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section alertingHeaderTarget, mAlertingHeader, currentAlertingHeaderIdx); adjustHeaderVisibilityAndPosition( peopleHeaderTarget, mPeopleHubView, currentPeopleHeaderIdx); + adjustViewPosition( + mediaControlsTarget, mMediaControlsView, currentMediaControlsIdx); // Update headers to reflect state of section contents mGentleHeader.setAreThereDismissableGentleNotifs( @@ -378,6 +409,28 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section } } + private void adjustViewPosition(int targetPosition, ExpandableView header, + int currentPosition) { + if (targetPosition == -1) { + if (currentPosition != -1) { + mParent.removeView(header); + } + } else { + if (currentPosition == -1) { + // If the header is animating away, it will still have a parent, so detach it first + // TODO: We should really cancel the active animations here. This will happen + // automatically when the view's intro animation starts, but it's a fragile link. + if (header.getTransientContainer() != null) { + header.getTransientContainer().removeTransientView(header); + header.setTransientContainer(null); + } + mParent.addView(header, targetPosition); + } else { + mParent.changeViewPosition(header, targetPosition); + } + } + } + /** * Updates the boundaries (as tracked by their first and last views) of the priority sections. * @@ -464,6 +517,11 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section } @VisibleForTesting + ExpandableView getMediaControlsView() { + return mMediaControlsView; + } + + @VisibleForTesting void setPeopleHubVisible(boolean visible) { mPeopleHubVisible = visible; } @@ -502,13 +560,15 @@ public class NotificationSectionsManager implements StackScrollAlgorithm.Section @Retention(SOURCE) @IntDef(prefix = { "BUCKET_" }, value = { BUCKET_HEADS_UP, + BUCKET_MEDIA_CONTROLS, BUCKET_PEOPLE, BUCKET_ALERTING, BUCKET_SILENT }) public @interface PriorityBucket {} public static final int BUCKET_HEADS_UP = 0; - public static final int BUCKET_PEOPLE = 1; - public static final int BUCKET_ALERTING = 2; - public static final int BUCKET_SILENT = 3; + public static final int BUCKET_MEDIA_CONTROLS = 1; + public static final int BUCKET_PEOPLE = 2; + public static final int BUCKET_ALERTING = 3; + public static final int BUCKET_SILENT = 4; } diff --git a/packages/SystemUI/tests/src/com/android/keyguard/KeyguardMediaPlayerTest.kt b/packages/SystemUI/tests/src/com/android/keyguard/KeyguardMediaPlayerTest.kt new file mode 100644 index 000000000000..464a740c931c --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/keyguard/KeyguardMediaPlayerTest.kt @@ -0,0 +1,120 @@ +/* + * 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.keyguard + +import android.graphics.drawable.Icon +import android.media.MediaMetadata +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.view.View +import android.widget.TextView +import androidx.test.filters.SmallTest + +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder +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.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` as whenever + +@SmallTest +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper +public class KeyguardMediaPlayerTest : SysuiTestCase() { + + private lateinit var keyguardMediaPlayer: KeyguardMediaPlayer + private lateinit var fakeExecutor: FakeExecutor + private lateinit var mediaMetadata: MediaMetadata.Builder + private lateinit var entry: NotificationEntryBuilder + @Mock private lateinit var mockView: View + private lateinit var textView: TextView + @Mock private lateinit var mockIcon: Icon + + @Before + public fun setup() { + fakeExecutor = FakeExecutor(FakeSystemClock()) + keyguardMediaPlayer = KeyguardMediaPlayer(context, fakeExecutor) + mockView = mock(View::class.java) + textView = TextView(context) + mockIcon = mock(Icon::class.java) + mediaMetadata = MediaMetadata.Builder() + entry = NotificationEntryBuilder() + + keyguardMediaPlayer.bindView(mockView) + } + + @After + public fun tearDown() { + keyguardMediaPlayer.unbindView() + } + + @Test + public fun testBind() { + keyguardMediaPlayer.unbindView() + keyguardMediaPlayer.bindView(mockView) + } + + @Test + public fun testUnboundClearControls() { + keyguardMediaPlayer.unbindView() + keyguardMediaPlayer.clearControls() + keyguardMediaPlayer.bindView(mockView) + } + + @Test + public fun testUpdateControls() { + keyguardMediaPlayer.updateControls(entry.build(), mockIcon, mediaMetadata.build()) + verify(mockView).setVisibility(View.VISIBLE) + } + + @Test + public fun testClearControls() { + keyguardMediaPlayer.clearControls() + verify(mockView).setVisibility(View.GONE) + } + + @Test + public fun testSongName() { + whenever<TextView>(mockView.findViewById(R.id.header_title)).thenReturn(textView) + val song: String = "Song" + mediaMetadata.putText(MediaMetadata.METADATA_KEY_TITLE, song) + + keyguardMediaPlayer.updateControls(entry.build(), mockIcon, mediaMetadata.build()) + + assertThat(textView.getText()).isEqualTo(song) + } + + @Test + public fun testArtistName() { + whenever<TextView>(mockView.findViewById(R.id.header_artist)).thenReturn(textView) + val artist: String = "Artist" + mediaMetadata.putText(MediaMetadata.METADATA_KEY_ARTIST, artist) + + keyguardMediaPlayer.updateControls(entry.build(), mockIcon, mediaMetadata.build()) + + assertThat(textView.getText()).isEqualTo(artist) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationSectionsFeatureManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationSectionsFeatureManagerTest.kt index 6388fe1a69c1..b501a2ebeb64 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationSectionsFeatureManagerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/NotificationSectionsFeatureManagerTest.kt @@ -27,6 +27,7 @@ import com.android.internal.config.sysui.SystemUiDeviceConfigFlags.NOTIFICATIONS import com.android.systemui.SysuiTestCase import com.android.systemui.util.DeviceConfigProxyFake +import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -38,6 +39,7 @@ import org.junit.runner.RunWith class NotificationSectionsFeatureManagerTest : SysuiTestCase() { var manager: NotificationSectionsFeatureManager? = null val proxyFake = DeviceConfigProxyFake() + var originalQsMediaPlayer: Int = 0 @Before public fun setup() { @@ -45,6 +47,15 @@ class NotificationSectionsFeatureManagerTest : SysuiTestCase() { NOTIFICATION_NEW_INTERRUPTION_MODEL, 1) manager = NotificationSectionsFeatureManager(proxyFake, mContext) manager!!.clearCache() + originalQsMediaPlayer = Settings.System.getInt(context.getContentResolver(), + "qs_media_player", 1) + Settings.System.putInt(context.getContentResolver(), "qs_media_player", 0) + } + + @After + public fun teardown() { + Settings.System.putInt(context.getContentResolver(), "qs_media_player", + originalQsMediaPlayer) } @Test diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationSectionsManagerTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationSectionsManagerTest.java index a263a7232352..646bc9699ff8 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationSectionsManagerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/notification/stack/NotificationSectionsManagerTest.java @@ -42,6 +42,7 @@ import android.view.ViewGroup; import androidx.test.filters.SmallTest; +import com.android.keyguard.KeyguardMediaPlayer; import com.android.systemui.ActivityStarterDelegate; import com.android.systemui.SysuiTestCase; import com.android.systemui.plugins.statusbar.StatusBarStateController; @@ -73,6 +74,7 @@ public class NotificationSectionsManagerTest extends SysuiTestCase { @Mock private StatusBarStateController mStatusBarStateController; @Mock private ConfigurationController mConfigurationController; @Mock private PeopleHubViewAdapter mPeopleHubAdapter; + @Mock private KeyguardMediaPlayer mKeyguardMediaPlayer; @Mock private NotificationSectionsFeatureManager mSectionsFeatureManager; @Mock private NotificationRowComponent mNotificationRowComponent; @Mock private ActivatableNotificationViewController mActivatableNotificationViewController; @@ -91,6 +93,7 @@ public class NotificationSectionsManagerTest extends SysuiTestCase { mStatusBarStateController, mConfigurationController, mPeopleHubAdapter, + mKeyguardMediaPlayer, mSectionsFeatureManager ); // Required in order for the header inflation to work properly @@ -333,13 +336,82 @@ public class NotificationSectionsManagerTest extends SysuiTestCase { verify(mNssl).changeViewPosition(mSectionsManager.getPeopleHeaderView(), 0); } + @Test + public void testMediaControls_AddWhenEnterKeyguard() { + enableMediaControls(); + + // GIVEN a stack that doesn't include media controls + setStackState(ChildType.ALERTING, ChildType.GENTLE_HEADER, ChildType.GENTLE); + + // WHEN we go back to the keyguard + when(mStatusBarStateController.getState()).thenReturn(StatusBarState.KEYGUARD); + mSectionsManager.updateSectionBoundaries(); + + // Then the media controls are added + verify(mNssl).addView(mSectionsManager.getMediaControlsView(), 0); + } + + @Test + public void testMediaControls_AddWhenEnterKeyguardWithHeadsUp() { + enableMediaControls(); + + // GIVEN a stack that doesn't include media controls but includes HEADS_UP + setStackState(ChildType.HEADS_UP, ChildType.ALERTING, ChildType.GENTLE_HEADER, + ChildType.GENTLE); + + // WHEN we go back to the keyguard + when(mStatusBarStateController.getState()).thenReturn(StatusBarState.KEYGUARD); + mSectionsManager.updateSectionBoundaries(); + + // Then the media controls are added after HEADS_UP + verify(mNssl).addView(mSectionsManager.getMediaControlsView(), 1); + } + + @Test + public void testMediaControls_RemoveWhenExitKeyguard() { + enableMediaControls(); + + // GIVEN a stack with media controls + setStackState(ChildType.MEDIA_CONTROLS, ChildType.ALERTING, ChildType.GENTLE_HEADER, + ChildType.GENTLE); + + // WHEN we leave the keyguard + when(mStatusBarStateController.getState()).thenReturn(StatusBarState.SHADE); + mSectionsManager.updateSectionBoundaries(); + + // Then the media controls is removed + verify(mNssl).removeView(mSectionsManager.getMediaControlsView()); + } + + @Test + public void testMediaControls_RemoveWhenPullDownShade() { + enableMediaControls(); + + // GIVEN a stack with media controls + setStackState(ChildType.MEDIA_CONTROLS, ChildType.ALERTING, ChildType.GENTLE_HEADER, + ChildType.GENTLE); + + // WHEN we pull down the shade on the keyguard + when(mStatusBarStateController.getState()).thenReturn(StatusBarState.SHADE_LOCKED); + mSectionsManager.updateSectionBoundaries(); + + // Then the media controls is removed + verify(mNssl).removeView(mSectionsManager.getMediaControlsView()); + } + private void enablePeopleFiltering() { when(mSectionsFeatureManager.isFilteringEnabled()).thenReturn(true); when(mSectionsFeatureManager.getNumberOfBuckets()).thenReturn(4); } + private void enableMediaControls() { + when(mSectionsFeatureManager.isMediaControlsEnabled()).thenReturn(true); + when(mSectionsFeatureManager.getNumberOfBuckets()).thenReturn(4); + } + private enum ChildType { - PEOPLE_HEADER, ALERTING_HEADER, GENTLE_HEADER, HEADS_UP, PERSON, ALERTING, GENTLE, OTHER + MEDIA_CONTROLS, PEOPLE_HEADER, ALERTING_HEADER, GENTLE_HEADER, HEADS_UP, PERSON, ALERTING, + GENTLE, OTHER } private void setStackState(ChildType... children) { @@ -347,6 +419,9 @@ public class NotificationSectionsManagerTest extends SysuiTestCase { for (int i = 0; i < children.length; i++) { View child; switch (children[i]) { + case MEDIA_CONTROLS: + child = mSectionsManager.getMediaControlsView(); + break; case PEOPLE_HEADER: child = mSectionsManager.getPeopleHeaderView(); break; |