chromium/components/browser_ui/media/android/java/src/org/chromium/components/browser_ui/media/MediaSessionHelper.java

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.browser_ui.media;

import android.app.Activity;
import android.content.Intent;
import android.graphics.Bitmap;
import android.media.AudioManager;
import android.os.Build;
import android.os.Handler;
import android.os.SystemClock;
import android.text.TextUtils;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.SysUtils;
import org.chromium.components.browser_ui.media.MediaSessionUma.MediaSessionActionSource;
import org.chromium.components.favicon.LargeIconBridge;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.content_public.browser.MediaSession;
import org.chromium.content_public.browser.MediaSessionObserver;
import org.chromium.content_public.browser.NavigationHandle;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsObserver;
import org.chromium.media_session.mojom.MediaSessionAction;
import org.chromium.services.media_session.MediaImage;
import org.chromium.services.media_session.MediaMetadata;
import org.chromium.services.media_session.MediaPosition;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.url.GURL;

import java.util.Collections;
import java.util.List;
import java.util.Set;

/**
 * Glue code that relays events from the {@link org.chromium.content.browser.MediaSession} for a
 * WebContents to a delegate (ultimately, to {@link MediaNotificationController}).
 */
public class MediaSessionHelper implements MediaImageCallback {
    private static final String UNICODE_PLAY_CHARACTER = "\u25B6";
    @VisibleForTesting public static final int HIDE_NOTIFICATION_DELAY_MILLIS = 2500;

    private Delegate mDelegate;
    private WebContents mWebContents;
    @VisibleForTesting public WebContentsObserver mWebContentsObserver;
    @VisibleForTesting public MediaSessionObserver mMediaSessionObserver;
    private MediaImageManager mMediaImageManager;
    private Bitmap mPageMediaImage;
    @VisibleForTesting public Bitmap mFavicon;
    private Bitmap mCurrentMediaImage;
    private String mOrigin;
    private int mPreviousVolumeControlStream = AudioManager.USE_DEFAULT_STREAM_TYPE;
    @VisibleForTesting public MediaNotificationInfo.Builder mNotificationInfoBuilder;
    // The fallback title if |mPageMetadata| is null or its title is empty.
    private String mFallbackTitle;
    // Set to true if favicon update callback was called at least once.
    private boolean mMaybeHasFavicon;
    // The metadata set by the page.
    private MediaMetadata mPageMetadata;
    // The currently showing metadata.
    private MediaMetadata mCurrentMetadata;
    private Set<Integer> mMediaSessionActions = Collections.emptySet();
    private @Nullable MediaPosition mMediaPosition;
    private Handler mHandler;
    // The delayed task to hide notification. Hiding notification can be immediate or delayed.
    // Delayed hiding will schedule this delayed task to |mHandler|. The task will be canceled when
    // showing or immediate hiding.
    private Runnable mHideNotificationDelayedTask;
    @VisibleForTesting public LargeIconBridge mLargeIconBridge;

    // Used to override the MediaSession object get from WebContents. This is to work around the
    // static getter {@link MediaSession#fromWebContents()}.
    @VisibleForTesting public static MediaSession sOverriddenMediaSession;

    private MediaNotificationListener mControlsListener =
            new MediaNotificationListener() {
                @Override
                public void onPlay(int actionSource) {
                    if (isNotificationHidingOrHidden()) return;

                    MediaSessionUma.recordPlay(
                            MediaSessionHelper.convertMediaActionSourceToUMA(actionSource));

                    if (mMediaSessionObserver.getMediaSession() == null) return;

                    mMediaSessionObserver.getMediaSession().resume();
                }

                @Override
                public void onPause(int actionSource) {
                    if (isNotificationHidingOrHidden()) return;

                    if (mMediaSessionObserver.getMediaSession() == null) return;

                    mMediaSessionObserver.getMediaSession().suspend();
                }

                @Override
                public void onStop(int actionSource) {
                    if (isNotificationHidingOrHidden()) return;

                    if (mMediaSessionObserver.getMediaSession() != null) {
                        mMediaSessionObserver.getMediaSession().stop();
                    }
                }

                @Override
                public void onMediaSessionAction(int action) {
                    if (!MediaSessionAction.isKnownValue(action)) return;
                    if (mMediaSessionObserver != null) {
                        mMediaSessionObserver.getMediaSession().didReceiveAction(action);
                    }
                }

                @Override
                public void onMediaSessionSeekTo(long pos) {
                    if (mMediaSessionObserver == null) return;
                    mMediaSessionObserver.getMediaSession().seekTo(pos);
                }
            };

    private void hideNotificationDelayed() {
        if (mWebContentsObserver == null) return;
        if (mHideNotificationDelayedTask != null) return;

        mHideNotificationDelayedTask =
                new Runnable() {
                    @Override
                    public void run() {
                        mHideNotificationDelayedTask = null;
                        hideNotificationInternal();
                    }
                };
        mHandler.postDelayed(mHideNotificationDelayedTask, HIDE_NOTIFICATION_DELAY_MILLIS);

        mNotificationInfoBuilder = null;
        mFavicon = null;
    }

    private void hideNotificationImmediately() {
        if (mWebContentsObserver == null) return;
        if (mHideNotificationDelayedTask != null) {
            mHandler.removeCallbacks(mHideNotificationDelayedTask);
            mHideNotificationDelayedTask = null;
        }

        hideNotificationInternal();
        mNotificationInfoBuilder = null;
    }

    /**
     * This method performs the common steps for hiding the notification. It should only be called
     * by {@link #hideNotificationDelayed()} and {@link #hideNotificationImmediately()}.
     */
    private void hideNotificationInternal() {
        mDelegate.hideMediaNotification();
        Activity activity = getActivity();
        if (activity != null) {
            activity.setVolumeControlStream(mPreviousVolumeControlStream);
        }
    }

    private void showNotification() {
        assert mNotificationInfoBuilder != null;
        if (mHideNotificationDelayedTask != null) {
            mHandler.removeCallbacks(mHideNotificationDelayedTask);
            mHideNotificationDelayedTask = null;
        }
        mDelegate.showMediaNotification(mNotificationInfoBuilder.build());
    }

    private MediaSessionObserver createMediaSessionObserver(MediaSession mediaSession) {
        return new MediaSessionObserver(mediaSession) {
            @Override
            public void mediaSessionDestroyed() {
                hideNotificationImmediately();
                cleanupMediaSessionObserver();
            }

            @Override
            public void mediaSessionStateChanged(boolean isControllable, boolean isPaused) {
                if (!isControllable) {
                    hideNotificationDelayed();
                    return;
                }

                Intent contentIntent = mDelegate.createBringTabToFrontIntent();

                if (mFallbackTitle == null) mFallbackTitle = sanitizeMediaTitle(mOrigin);

                mCurrentMetadata = getMetadata();
                mCurrentMediaImage = getCachedNotificationImage();
                rebaseMediaPosition(isPaused);
                mNotificationInfoBuilder =
                        mDelegate
                                .createMediaNotificationInfoBuilder()
                                .setMetadata(mCurrentMetadata)
                                .setPaused(isPaused)
                                .setOrigin(mOrigin)
                                .setPrivate(mWebContents.isIncognito())
                                .setNotificationSmallIcon(R.drawable.audio_playing)
                                .setNotificationLargeIcon(mCurrentMediaImage)
                                .setMediaSessionImage(mPageMediaImage)
                                .setActions(
                                        MediaNotificationInfo.ACTION_PLAY_PAUSE
                                                | MediaNotificationInfo.ACTION_SWIPEAWAY
                                                | MediaNotificationInfo.ACTION_STOP)
                                .setContentIntent(contentIntent)
                                .setListener(mControlsListener)
                                .setMediaSessionActions(mMediaSessionActions)
                                .setMediaPosition(mMediaPosition);

                // Show a default icon in incognito contents, as they don't show the media icon.
                // Also show a default icon if we won't get a favicon from {@link mDelegate}. If the
                // delegate will pass a favicon later, show nothing for now; we expect the favicon
                // to arrive quickly.
                if (mWebContents.isIncognito()
                        || (mCurrentMediaImage == null && !fetchLargeFaviconImage())) {
                    mNotificationInfoBuilder.setDefaultNotificationLargeIcon(
                            R.drawable.audio_playing_square);
                }
                showNotification();
                Activity activity = getActivity();
                if (activity != null) {
                    activity.setVolumeControlStream(AudioManager.STREAM_MUSIC);
                }
            }

            @Override
            public void mediaSessionMetadataChanged(MediaMetadata metadata) {
                mPageMetadata = metadata;
                updateNotificationMetadata();
            }

            @Override
            public void mediaSessionActionsChanged(Set<Integer> actions) {
                mMediaSessionActions = actions;
                updateNotificationActions();
            }

            @Override
            public void mediaSessionArtworkChanged(List<MediaImage> images) {
                mMediaImageManager.downloadImage(images, MediaSessionHelper.this);
                updateNotificationMetadata();
            }

            @Override
            public void mediaSessionPositionChanged(@Nullable MediaPosition position) {
                mMediaPosition = position;
                updateNotificationPosition();
            }

            /**
             * Adjust `mMediaPosition` so that it's unambiguous about what the current media time
             * is.  Otherwise, when transitioning into the paused state, the platform won't know to
             * adjust the time from the `getLastUpdatedTime()`.  This is especially bad since the
             * playback rate in the MediaPosition and `isPaused` don't always agree immediately;
             * we can find out about `isPaused` before being told of the final, playback rate = 0,
             * MediaPosition.  To avoid this, we adjust the MediaPosition based on its current
             * playback rate, and update the playback rate to zero so that it's unambiguous.
             */
            private void rebaseMediaPosition(boolean isPaused) {
                if (mMediaPosition == null) return;

                long now = SystemClock.elapsedRealtime();
                long rebased_position =
                        mMediaPosition.getPosition()
                                + (long)
                                        ((now - mMediaPosition.getLastUpdatedTime())
                                                * mMediaPosition.getPlaybackRate());
                mMediaPosition =
                        new MediaPosition(
                                mMediaPosition.getDuration(),
                                rebased_position,
                                isPaused ? 0 : mMediaPosition.getPlaybackRate(),
                                now);
            }
        };
    }

    public void setWebContents(@Nullable WebContents webContents) {
        if (mWebContents == webContents) return;

        mWebContents = webContents;

        if (mWebContentsObserver != null) mWebContentsObserver.destroy();

        mMediaImageManager.setWebContents(mWebContents);

        if (webContents == null) {
            mWebContentsObserver = null;
            cleanupMediaSessionObserver();
            return;
        }

        mWebContentsObserver =
                new WebContentsObserver(webContents) {
                    @Override
                    public void didFinishNavigationInPrimaryMainFrame(NavigationHandle navigation) {
                        if (!navigation.hasCommitted() || navigation.isSameDocument()) {
                            return;
                        }

                        mOrigin =
                                UrlFormatter.formatUrlForDisplayOmitSchemeOmitTrivialSubdomains(
                                        webContents.getVisibleUrl().getOrigin().getSpec());
                        mFavicon = null;
                        mPageMediaImage = null;
                        mPageMetadata = null;
                        // |mCurrentMetadata| selects either |mPageMetadata| or |mFallbackTitle|. As
                        // there is no guarantee {@link #titleWasSet()} will be called before or
                        // after this method, |mFallbackTitle| is not reset in this callback, i.e.
                        // relying solely on {@link #titleWasSet()}. The following assignment is
                        // to keep |mCurrentMetadata| up to date as |mPageMetadata| may have
                        // changed.
                        mCurrentMetadata = getMetadata();
                        mMediaSessionActions = Collections.emptySet();

                        if (isNotificationHidingOrHidden()) return;

                        mNotificationInfoBuilder.setOrigin(mOrigin);
                        mNotificationInfoBuilder.setNotificationLargeIcon(mFavicon);
                        mNotificationInfoBuilder.setMediaSessionImage(mPageMediaImage);
                        mNotificationInfoBuilder.setMetadata(mCurrentMetadata);
                        mNotificationInfoBuilder.setMediaSessionActions(mMediaSessionActions);
                        showNotification();
                    }

                    @Override
                    public void titleWasSet(String title) {
                        String newFallbackTitle = sanitizeMediaTitle(title);
                        if (!TextUtils.equals(mFallbackTitle, newFallbackTitle)) {
                            mFallbackTitle = newFallbackTitle;
                            updateNotificationMetadata();
                        }
                    }

                    @Override
                    public void wasShown() {
                        mDelegate.activateAndroidMediaSession();
                    }

                    @Override
                    public void mediaSessionCreated(MediaSession mediaSession) {
                        setUpMediaSessionObserver(mediaSession);
                    }
                };

        MediaSession mediaSession = getMediaSession(webContents);
        setUpMediaSessionObserver(mediaSession);
    }

    private void setUpMediaSessionObserver(MediaSession mediaSession) {
        if (mMediaSessionObserver != null
                && mediaSession == mMediaSessionObserver.getMediaSession()) {
            return;
        }

        cleanupMediaSessionObserver();
        if (mediaSession != null) {
            mMediaSessionObserver = createMediaSessionObserver(mediaSession);
        }
    }

    private void cleanupMediaSessionObserver() {
        if (mMediaSessionObserver == null) return;
        mMediaSessionObserver.stopObserving();
        mMediaSessionObserver = null;
        mMediaSessionActions = Collections.emptySet();
    }

    /** An interface for dispatching embedder-specific behavior. */
    public interface Delegate {
        /** Returns an intent that brings the associated web contents to the front. */
        Intent createBringTabToFrontIntent();

        /** Returns the {@link LargeIconBridge} to be used while obtaining icons. */
        LargeIconBridge getLargeIconBridge();

        /**
         * Creates a {@link MediaNotificationInfo.Builder} with basic embedder-specific
         * initialization.
         */
        public MediaNotificationInfo.Builder createMediaNotificationInfoBuilder();

        /** Shows a notification with the given metadata. */
        void showMediaNotification(MediaNotificationInfo notificationInfo);

        /** Hides the active notification. */
        void hideMediaNotification();

        /** Activates the Android MediaSession. */
        void activateAndroidMediaSession();
    }

    public MediaSessionHelper(@NonNull WebContents webContents, @NonNull Delegate delegate) {
        mDelegate = delegate;
        mMediaImageManager =
                new MediaImageManager(
                        MediaNotificationImageUtils.MINIMAL_MEDIA_IMAGE_SIZE_PX,
                        MediaNotificationImageUtils.getIdealMediaImageSize());
        mHandler = new Handler();
        setWebContents(webContents);

        Activity activity = getActivity();
        if (activity != null) {
            mPreviousVolumeControlStream = activity.getVolumeControlStream();
        }
    }

    /**
     * Called when this object should no longer manage a media session because owning code no longer
     * requires it.
     */
    public void destroy() {
        cleanupMediaSessionObserver();
        hideNotificationImmediately();
        if (mWebContentsObserver != null) mWebContentsObserver.destroy();
        mWebContentsObserver = null;
        if (mLargeIconBridge != null) mLargeIconBridge.destroy();
        mLargeIconBridge = null;
    }

    /**
     * Removes all the leading/trailing white spaces and the quite common unicode play character.
     * It improves the visibility of the title in the notification.
     *
     * @param title The original tab title, e.g. "   ▶   Foo - Bar  "
     * @return The sanitized tab title, e.g. "Foo - Bar"
     */
    private String sanitizeMediaTitle(String title) {
        title = title.trim();
        return title.startsWith(UNICODE_PLAY_CHARACTER) ? title.substring(1).trim() : title;
    }

    /**
     * Converts the {@link MediaNotificationListener} action source enum into the
     * {@link MediaSessionUma} one to ensure matching the histogram values.
     * @param source the source id, must be one of the ACTION_SOURCE_* constants defined in the
     *               {@link MediaNotificationListener} interface.
     * @return the corresponding histogram value.
     */
    public static @Nullable @MediaSessionActionSource Integer convertMediaActionSourceToUMA(
            int source) {
        if (source == MediaNotificationListener.ACTION_SOURCE_MEDIA_NOTIFICATION) {
            return MediaSessionActionSource.MEDIA_NOTIFICATION;
        } else if (source == MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION) {
            return MediaSessionActionSource.MEDIA_SESSION;
        } else if (source == MediaNotificationListener.ACTION_SOURCE_HEADSET_UNPLUG) {
            return MediaSessionActionSource.HEADSET_UNPLUG;
        }

        assert false;
        return null;
    }

    private Activity getActivity() {
        assert mWebContents != null;
        WindowAndroid windowAndroid = mWebContents.getTopLevelNativeWindow();
        if (windowAndroid == null) return null;

        return windowAndroid.getActivity().get();
    }

    /** Returns true if a large favicon might be found. */
    private boolean fetchLargeFaviconImage() {
        // The page does not have a favicon yet to fetch since onFaviconUpdated was never called.
        // Don't waste time trying to find it.
        if (!mMaybeHasFavicon) return false;

        GURL pageUrl = mWebContents.getLastCommittedUrl();
        int size = MediaNotificationImageUtils.MINIMAL_MEDIA_IMAGE_SIZE_PX;
        if (mLargeIconBridge == null) {
            mLargeIconBridge = mDelegate.getLargeIconBridge();
        }
        LargeIconBridge.LargeIconCallback callback =
                new LargeIconBridge.LargeIconCallback() {
                    @Override
                    public void onLargeIconAvailable(
                            Bitmap icon,
                            int fallbackColor,
                            boolean isFallbackColorDefault,
                            int iconType) {
                        setLargeIcon(icon);
                    }
                };

        return mLargeIconBridge.getLargeIconForUrl(pageUrl, size, callback);
    }

    /**
     * Updates the best favicon if the given icon is better and the favicon is shown in
     * notification.
     */
    public void updateFavicon(Bitmap icon) {
        if (icon == null) return;

        mMaybeHasFavicon = true;

        // Store the favicon only if notification is being shown. Otherwise the favicon is
        // obtained from large icon bridge when needed.
        if (isNotificationHidingOrHidden() || mPageMediaImage != null) return;

        // Disable favicons in notifications for low memory devices on O
        // where the notification icon is optional.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && SysUtils.isLowEndDevice()) return;

        if (!MediaNotificationImageUtils.isBitmapSuitableAsMediaImage(icon)) return;
        if (mFavicon != null
                && (icon.getWidth() < mFavicon.getWidth()
                        || icon.getHeight() < mFavicon.getHeight())) {
            return;
        }
        mFavicon = MediaNotificationImageUtils.downscaleIconToIdealSize(icon);
        updateNotificationImage(mFavicon);
    }

    /** Sets an icon which will preferentially be used in place of a smaller favicon. */
    public void setLargeIcon(Bitmap icon) {
        if (isNotificationHidingOrHidden()) return;

        if (icon == null) {
            // If we do not have any favicon then make sure we show default sound icon. This
            // icon is used by notification manager only if we do not show any icon.
            mNotificationInfoBuilder.setDefaultNotificationLargeIcon(
                    R.drawable.audio_playing_square);
            showNotification();
        } else {
            updateFavicon(icon);
        }
    }

    /**
     * Updates the metadata in media notification. This method should be called whenever
     * |mPageMetadata| or |mFallbackTitle| is changed.
     */
    private void updateNotificationMetadata() {
        if (isNotificationHidingOrHidden()) return;

        MediaMetadata newMetadata = getMetadata();
        if (mCurrentMetadata.equals(newMetadata)) return;

        mCurrentMetadata = newMetadata;
        mNotificationInfoBuilder.setMetadata(mCurrentMetadata);
        showNotification();
    }

    /**
     * @return The up-to-date MediaSession metadata. Returns the cached object like |mPageMetadata|
     * or |mCurrentMetadata| if it reflects the current state. Otherwise will return a new
     * {@link MediaMetadata} object.
     */
    private MediaMetadata getMetadata() {
        String title = mFallbackTitle;
        String artist = "";
        String album = "";
        if (mPageMetadata != null) {
            if (!TextUtils.isEmpty(mPageMetadata.getTitle())) return mPageMetadata;

            artist = mPageMetadata.getArtist();
            album = mPageMetadata.getAlbum();
        }

        if (mCurrentMetadata != null
                && TextUtils.equals(title, mCurrentMetadata.getTitle())
                && TextUtils.equals(artist, mCurrentMetadata.getArtist())
                && TextUtils.equals(album, mCurrentMetadata.getAlbum())) {
            return mCurrentMetadata;
        }

        return new MediaMetadata(title, artist, album);
    }

    private void updateNotificationActions() {
        if (isNotificationHidingOrHidden()) return;

        mNotificationInfoBuilder.setMediaSessionActions(mMediaSessionActions);
        showNotification();
    }

    private void updateNotificationPosition() {
        if (isNotificationHidingOrHidden()) return;

        mNotificationInfoBuilder.setMediaPosition(mMediaPosition);
        showNotification();
    }

    @Override
    public void onImageDownloaded(Bitmap image) {
        mPageMediaImage = MediaNotificationImageUtils.downscaleIconToIdealSize(image);
        mFavicon = null;
        updateNotificationImage(mPageMediaImage);
    }

    private void updateNotificationImage(Bitmap newMediaImage) {
        if (mCurrentMediaImage == newMediaImage) return;

        mCurrentMediaImage = newMediaImage;

        if (isNotificationHidingOrHidden()) return;
        mNotificationInfoBuilder.setNotificationLargeIcon(mCurrentMediaImage);
        mNotificationInfoBuilder.setMediaSessionImage(mPageMediaImage);
        showNotification();
    }

    private Bitmap getCachedNotificationImage() {
        if (mPageMediaImage != null) return mPageMediaImage;
        if (mFavicon != null) return mFavicon;
        return null;
    }

    private boolean isNotificationHidingOrHidden() {
        return mNotificationInfoBuilder == null;
    }

    private MediaSession getMediaSession(WebContents contents) {
        return (sOverriddenMediaSession != null)
                ? sOverriddenMediaSession
                : MediaSession.fromWebContents(contents);
    }
}