Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit d8643f4f authored by Robert Snoeberger's avatar Robert Snoeberger
Browse files

Separate updating views from deriving state

The benefit is that icon colors and icon scaling can be performed on a
background thread and then all of the views updated on the main thread.

Bug: 150454272
Test: atest KeyguardMediaPlayerTest.kt
Test: manual - play music and look at lock screen controls
Change-Id: I2423233f1ddeb081ab420053964c2b1cb2185514
parent d71a77f2
Loading
Loading
Loading
Loading
+33 −0
Original line number Diff line number Diff line
/*
 * 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.Drawable

import java.util.List

/** State for lock screen media controls. */
data class KeyguardMedia(
    val foregroundColor: Int,
    val backgroundColor: Int,
    val app: String?,
    val appIcon: Drawable?,
    val artist: String?,
    val song: String?,
    val artwork: Drawable?,
    val actionIcons: List<Drawable>
)
+233 −137
Original line number Diff line number Diff line
@@ -32,6 +32,9 @@ import android.widget.TextView;

import androidx.core.graphics.drawable.RoundedBitmapDrawable;
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.palette.graphics.Palette;

import com.android.internal.util.ContrastColorUtil;
@@ -64,39 +67,47 @@ public class KeyguardMediaPlayer {

    private final Context mContext;
    private final Executor mBackgroundExecutor;
    private float mAlbumArtRadius;
    private int mAlbumArtSize;
    private View mMediaNotifView;
    private final KeyguardMediaViewModel mViewModel;
    private KeyguardMediaObserver mObserver;

    @Inject
    public KeyguardMediaPlayer(Context context, @Background Executor backgroundExecutor) {
        mContext = context;
        mBackgroundExecutor = backgroundExecutor;
        loadDimens();
        mViewModel = new KeyguardMediaViewModel(context);
    }

    /** Binds media controls to a view hierarchy. */
    public void bindView(View v) {
        if (mMediaNotifView != null) {
        if (mObserver != null) {
            throw new IllegalStateException("cannot bind views, already bound");
        }
        mMediaNotifView = v;
        loadDimens();
        mViewModel.loadDimens();
        mObserver = new KeyguardMediaObserver(v);
        // Control buttons
        for (int i = 0; i < ACTION_IDS.length; i++) {
            ImageButton button = v.findViewById(ACTION_IDS[i]);
            if (button == null) {
                continue;
            }
            final int index = i;
            button.setOnClickListener(unused -> mViewModel.onActionClick(index));
        }
        mViewModel.getKeyguardMedia().observeForever(mObserver);
    }

    /** Unbinds media controls. */
    public void unbindView() {
        if (mMediaNotifView == null) {
        if (mObserver == null) {
            throw new IllegalStateException("cannot unbind views, nothing bound");
        }
        mMediaNotifView = null;
        mViewModel.getKeyguardMedia().removeObserver(mObserver);
        mObserver = null;
    }

    /** Clear the media controls because there isn't an active session. */
    public void clearControls() {
        if (mMediaNotifView != null) {
            mMediaNotifView.setVisibility(View.GONE);
        }
        mBackgroundExecutor.execute(mViewModel::clearControls);
    }

    /**
@@ -110,19 +121,49 @@ public class KeyguardMediaPlayer {
     */
    public void updateControls(NotificationEntry entry, Icon appIcon,
            MediaMetadata mediaMetadata) {
        if (mMediaNotifView == null) {
        if (mObserver == null) {
            throw new IllegalStateException("cannot update controls, views not bound");
        }
        if (mediaMetadata == null) {
            mMediaNotifView.setVisibility(View.GONE);
            Log.d(TAG, "media metadata was null");
            Log.d(TAG, "media metadata was null, closing media controls");
            // Note that clearControls() executes on the same background executor, so there
            // shouldn't be an issue with an outdated update running after clear. However, if stale
            // controls are observed then consider removing any enqueued updates.
            clearControls();
            return;
        }
        mMediaNotifView.setVisibility(View.VISIBLE);
        mBackgroundExecutor.execute(() -> mViewModel.updateControls(entry, appIcon, mediaMetadata));
    }

        Notification notif = entry.getSbn().getNotification();
    /** ViewModel for KeyguardMediaControls. */
    private static final class KeyguardMediaViewModel {

        private final Context mContext;
        private final MutableLiveData<KeyguardMedia> mMedia = new MutableLiveData<>();
        private final Object mActionsLock = new Object();
        private List<PendingIntent> mActions;
        private float mAlbumArtRadius;
        private int mAlbumArtSize;

        KeyguardMediaViewModel(Context context) {
            mContext = context;
            loadDimens();
        }

        /** Close the media player because there isn't an active session. */
        public void clearControls() {
            synchronized (mActionsLock) {
                mActions = null;
            }
            mMedia.postValue(null);
        }

        /** Update the media player with information about the active session. */
        public void updateControls(NotificationEntry entry, Icon appIcon,
                MediaMetadata mediaMetadata) {

        // Computed foreground and background color based on album art.
            // Foreground and Background colors computed from album art
            Notification notif = entry.getSbn().getNotification();
            int fgColor = notif.color;
            int bgColor = entry.getRow() == null ? -1 : entry.getRow().getCurrentBackgroundTint();
            Bitmap artworkBitmap = mediaMetadata.getBitmap(MediaMetadata.METADATA_KEY_ART);
@@ -144,125 +185,180 @@ public class KeyguardMediaPlayer {
            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);
            RoundedBitmapDrawable artwork = null;
            if (artworkBitmap != null) {
                Bitmap original = artworkBitmap.copy(Bitmap.Config.ARGB_8888, true);
                Bitmap scaled = Bitmap.createScaledBitmap(original, mAlbumArtSize, mAlbumArtSize,
                        false);
                artwork = RoundedBitmapDrawableFactory.create(mContext.getResources(), scaled);
                artwork.setCornerRadius(mAlbumArtRadius);
            }

            // 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);
        }
            String app = builder.loadHeaderAppName();

            // App Icon
            Drawable appIconDrawable = appIcon.loadDrawable(mContext);

            // 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);
        }
            String song = mediaMetadata.getString(MediaMetadata.METADATA_KEY_TITLE);

            // 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);
        }
            String artist = mediaMetadata.getString(MediaMetadata.METADATA_KEY_ARTIST);

            // Control buttons
        final List<Icon> icons = new ArrayList<>();
            List<Drawable> actionIcons = new ArrayList<>();
            final List<PendingIntent> intents = new ArrayList<>();
            Notification.Action[] actions = notif.actions;
        final int[] actionsToShow = notif.extras.getIntArray(Notification.EXTRA_COMPACT_ACTIONS);
            final int[] actionsToShow = notif.extras.getIntArray(
                    Notification.EXTRA_COMPACT_ACTIONS);

            Context packageContext = entry.getSbn().getPackageContext(mContext);
            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());
                    actionIcons.add(actions[idx].getIcon().loadDrawable(packageContext));
                    intents.add(actions[idx].actionIntent);
                } else {
                icons.add(null);
                    actionIcons.add(null);
                    intents.add(null);
                }
            }
            synchronized (mActionsLock) {
                mActions = intents;
            }

        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;
            KeyguardMedia data = new KeyguardMedia(fgColor, bgColor, app, appIconDrawable, artist,
                    song, artwork, actionIcons);
            mMedia.postValue(data);
        }

        /** Gets state for the lock screen media controls. */
        public LiveData<KeyguardMedia> getKeyguardMedia() {
            return mMedia;
        }

        /**
         * Handle user clicks on media control buttons (actions).
         *
         * @param index position of the button that was clicked.
         */
        public void onActionClick(int index) {
            PendingIntent intent = null;
            // This might block the ui thread to wait for the lock. Currently, however, the
            // lock is held by the bg thread to assign a member, which should be fast. An
            // alternative could be to add the intents to the state and let the observer set
            // the onClick listeners.
            synchronized (mActionsLock) {
                if (mActions != null && index < mActions.size()) {
                    intent = mActions.get(index);
                }
            }
            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);
                }
                    });
            }
        }

        void loadDimens() {
            mAlbumArtRadius = mContext.getResources().getDimension(R.dimen.qs_media_corner_radius);
            mAlbumArtSize = (int) mContext.getResources().getDimension(
                    R.dimen.qs_media_album_size);
        }
    }

    /**
     * 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");
    /** Observer for state changes of lock screen media controls. */
    private static final class KeyguardMediaObserver implements Observer<KeyguardMedia> {

        private final View mRootView;
        private final MediaHeaderView mMediaHeaderView;
        private final ImageView mAlbumView;
        private final ImageView mAppIconView;
        private final TextView mAppNameView;
        private final TextView mTitleView;
        private final TextView mArtistView;
        private final List<ImageButton> mButtonViews = new ArrayList<>();

        KeyguardMediaObserver(View v) {
            mRootView = v;
            mMediaHeaderView = v instanceof MediaHeaderView ? (MediaHeaderView) v : null;
            mAlbumView = v.findViewById(R.id.album_art);
            mAppIconView = v.findViewById(R.id.icon);
            mAppNameView = v.findViewById(R.id.app_name);
            mTitleView = v.findViewById(R.id.header_title);
            mArtistView = v.findViewById(R.id.header_artist);
            for (int i = 0; i < ACTION_IDS.length; i++) {
                mButtonViews.add(v.findViewById(ACTION_IDS[i]));
            }
        }

        // 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);
        });
        /** Updates lock screen media player views when state changes. */
        @Override
        public void onChanged(KeyguardMedia data) {
            if (data == null) {
                mRootView.setVisibility(View.GONE);
                return;
            }
            mRootView.setVisibility(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);
            // Background color
            if (mMediaHeaderView != null) {
                mMediaHeaderView.setBackgroundColor(data.getBackgroundColor());
            }

            // Album art
            if (mAlbumView != null) {
                mAlbumView.setImageDrawable(data.getArtwork());
                mAlbumView.setVisibility(data.getArtwork() == null ? View.GONE : View.VISIBLE);
            }

            // App icon
            if (mAppIconView != null) {
                Drawable iconDrawable = data.getAppIcon();
                iconDrawable.setTint(data.getForegroundColor());
                mAppIconView.setImageDrawable(iconDrawable);
            }

            // App name
            if (mAppNameView != null) {
                String appNameString = data.getApp();
                mAppNameView.setText(appNameString);
                mAppNameView.setTextColor(data.getForegroundColor());
            }

            // Song name
            if (mTitleView != null) {
                mTitleView.setText(data.getSong());
                mTitleView.setTextColor(data.getForegroundColor());
            }

            // Artist name
            if (mArtistView != null) {
                mArtistView.setText(data.getArtist());
                mArtistView.setTextColor(data.getForegroundColor());
            }

            // Control buttons
            for (int i = 0; i < ACTION_IDS.length; i++) {
                ImageButton button = mButtonViews.get(i);
                if (button == null) {
                    continue;
                }
                Drawable icon = data.getActionIcons().get(i);
                if (icon == null) {
                    button.setVisibility(View.GONE);
                    button.setImageDrawable(null);
                } else {
                    button.setVisibility(View.VISIBLE);
                    button.setImageDrawable(icon);
                    button.setImageTintList(ColorStateList.valueOf(data.getForegroundColor()));
                }
            }
        }
    }
}
+32 −7
Original line number Diff line number Diff line
@@ -22,6 +22,8 @@ import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.View
import android.widget.TextView
import androidx.arch.core.executor.ArchTaskExecutor
import androidx.arch.core.executor.TaskExecutor
import androidx.test.filters.SmallTest

import com.android.systemui.R
@@ -50,25 +52,46 @@ public class KeyguardMediaPlayerTest : SysuiTestCase() {
    private lateinit var mediaMetadata: MediaMetadata.Builder
    private lateinit var entry: NotificationEntryBuilder
    @Mock private lateinit var mockView: View
    private lateinit var textView: TextView
    private lateinit var songView: TextView
    private lateinit var artistView: TextView
    @Mock private lateinit var mockIcon: Icon

    private val taskExecutor: TaskExecutor = object : TaskExecutor() {
        public override fun executeOnDiskIO(runnable: Runnable) {
            runnable.run()
        }
        public override fun postToMainThread(runnable: Runnable) {
            runnable.run()
        }
        public override fun isMainThread(): Boolean {
            return true
        }
    }

    @Before
    public fun setup() {
        fakeExecutor = FakeExecutor(FakeSystemClock())
        keyguardMediaPlayer = KeyguardMediaPlayer(context, fakeExecutor)
        mockView = mock(View::class.java)
        textView = TextView(context)
        mockIcon = mock(Icon::class.java)

        mockView = mock(View::class.java)
        songView = TextView(context)
        artistView = TextView(context)
        whenever<TextView>(mockView.findViewById(R.id.header_title)).thenReturn(songView)
        whenever<TextView>(mockView.findViewById(R.id.header_artist)).thenReturn(artistView)

        mediaMetadata = MediaMetadata.Builder()
        entry = NotificationEntryBuilder()

        ArchTaskExecutor.getInstance().setDelegate(taskExecutor)

        keyguardMediaPlayer.bindView(mockView)
    }

    @After
    public fun tearDown() {
        keyguardMediaPlayer.unbindView()
        ArchTaskExecutor.getInstance().setDelegate(null)
    }

    @Test
@@ -87,34 +110,36 @@ public class KeyguardMediaPlayerTest : SysuiTestCase() {
    @Test
    public fun testUpdateControls() {
        keyguardMediaPlayer.updateControls(entry.build(), mockIcon, mediaMetadata.build())
        FakeExecutor.exhaustExecutors(fakeExecutor)
        verify(mockView).setVisibility(View.VISIBLE)
    }

    @Test
    public fun testClearControls() {
        keyguardMediaPlayer.clearControls()
        FakeExecutor.exhaustExecutors(fakeExecutor)
        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)
        assertThat(fakeExecutor.runAllReady()).isEqualTo(1)
        assertThat(songView.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)
        assertThat(fakeExecutor.runAllReady()).isEqualTo(1)
        assertThat(artistView.getText()).isEqualTo(artist)
    }
}