org.chromium.chrome.browser.media.ui.NotificationMediaPlaybackControls.java Source code

Java tutorial

Introduction

Here is the source code for org.chromium.chrome.browser.media.ui.NotificationMediaPlaybackControls.java

Source

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

package org.chromium.chrome.browser.media.ui;

import android.app.Notification;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.IBinder;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.NotificationManagerCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.view.KeyEvent;
import android.view.View;
import android.widget.RemoteViews;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.metrics.MediaSessionUMA;
import org.chromium.chrome.browser.tab.Tab;

/**
 * A class for notifications that provide information and optional media controls for a given media.
 * Internally implements a Service for transforming notification Intents into
 * {@link MediaPlaybackListener} calls for all registered listeners.
 */
public class NotificationMediaPlaybackControls {
    private static final int PLAYBACK_STATE_PAUSED = 0;
    private static final int PLAYBACK_STATE_PLAYING = 1;

    private static final Object LOCK = new Object();
    private static NotificationMediaPlaybackControls sInstance;

    /**
     * Service used to transform intent requests triggered from the notification into
     * {@code Listener} callbacks. Ideally this class should be private, but public is required to
     * create as a service.
     */
    public static class ListenerService extends Service {
        private static final String ACTION_PLAY = "NotificationMediaPlaybackControls.ListenerService.PLAY";
        private static final String ACTION_PAUSE = "NotificationMediaPlaybackControls.ListenerService.PAUSE";
        private static final String ACTION_STOP = "NotificationMediaPlaybackControls.ListenerService.STOP";

        private PendingIntent getPendingIntent(String action) {
            Intent intent = new Intent(this, ListenerService.class);
            intent.setAction(action);
            return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
        }

        @Override
        public IBinder onBind(Intent intent) {
            return null;
        }

        @Override
        public void onCreate() {
            super.onCreate();

            // This would only happen if we have been recreated by the OS after Chrome has died.
            // In this case, there can be no media playback happening so we don't have to show
            // the notification.
            if (sInstance == null)
                return;

            onServiceStarted(this);
        }

        @Override
        public void onDestroy() {
            super.onDestroy();

            if (sInstance == null)
                return;

            onServiceDestroyed();
        }

        @Override
        public int onStartCommand(Intent intent, int flags, int startId) {
            if (intent == null || sInstance == null || sInstance.mMediaNotificationInfo == null
                    || sInstance.mMediaNotificationInfo.listener == null) {
                stopSelf();
                return START_NOT_STICKY;
            }

            String action = intent.getAction();

            // Before Android L, instead of using the MediaSession callback, the system will fire
            // ACTION_MEDIA_BUTTON intents which stores the information about the key event.
            if (Intent.ACTION_MEDIA_BUTTON.equals(action)) {
                assert Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP;

                KeyEvent event = (KeyEvent) intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT);
                if (event == null)
                    return START_NOT_STICKY;
                if (event.getAction() != KeyEvent.ACTION_DOWN)
                    return START_NOT_STICKY;

                switch (event.getKeyCode()) {
                case KeyEvent.KEYCODE_MEDIA_PLAY:
                    if (!sInstance.mMediaNotificationInfo.isPaused)
                        break;
                    sInstance.onPlaybackStateChanged(PLAYBACK_STATE_PLAYING);
                    break;
                case KeyEvent.KEYCODE_MEDIA_PAUSE:
                    if (sInstance.mMediaNotificationInfo.isPaused)
                        break;
                    sInstance.onPlaybackStateChanged(PLAYBACK_STATE_PAUSED);
                    break;
                case KeyEvent.KEYCODE_HEADSETHOOK:
                case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
                    if (sInstance.mMediaNotificationInfo.isPaused) {
                        sInstance.onPlaybackStateChanged(PLAYBACK_STATE_PLAYING);
                    } else {
                        sInstance.onPlaybackStateChanged(PLAYBACK_STATE_PAUSED);
                    }
                    break;
                default:
                    break;
                }
                return START_NOT_STICKY;
            }

            if (ACTION_STOP.equals(action)) {
                sInstance.mMediaNotificationInfo.listener.onStop();
                stopSelf();
                return START_NOT_STICKY;
            }

            if (ACTION_PLAY.equals(action)) {
                MediaSessionUMA.recordPlay(MediaSessionUMA.MEDIA_SESSION_ACTION_SOURCE_MEDIA_NOTIFICATION);
                sInstance.onPlaybackStateChanged(PLAYBACK_STATE_PLAYING);
            } else if (ACTION_PAUSE.equals(action)) {
                MediaSessionUMA.recordPause(MediaSessionUMA.MEDIA_SESSION_ACTION_SOURCE_MEDIA_NOTIFICATION);
                sInstance.onPlaybackStateChanged(PLAYBACK_STATE_PAUSED);
            }

            return START_NOT_STICKY;
        }
    }

    /**
     * Shows the notification with media controls with the specified media info. Replaces/updates
     * the current notification if already showing. Does nothing if |mediaNotificationInfo| hasn't
     * changed from the last one.
     *
     * @param applicationContext context to create the notification with
     * @param mediaNotificationInfo information to show in the notification
     */
    public static void show(Context applicationContext, MediaNotificationInfo mediaNotificationInfo) {
        synchronized (LOCK) {
            if (sInstance == null) {
                sInstance = new NotificationMediaPlaybackControls(applicationContext);
            }
        }
        sInstance.showNotification(mediaNotificationInfo);
    }

    /**
     * Hides the notification for the specified tabId.
     *
     * @param tabId the id of the tab that showed the notification or invalid tab id.
     */
    public static void hide(int tabId) {
        if (sInstance == null)
            return;
        sInstance.hideNotification(tabId);
    }

    /**
     * Hides any notification if shown by this service.
     */
    public static void clear() {
        if (sInstance == null)
            return;
        sInstance.clearNotification();
    }

    /**
     * Registers the started {@link Service} with the singleton and creates the notification.
     *
     * @param service the service that was started
     */
    private static void onServiceStarted(ListenerService service) {
        assert sInstance != null;
        assert sInstance.mService == null;
        sInstance.mService = service;
        sInstance.updateNotification();
    }

    /**
     * Handles the destruction
     */
    private static void onServiceDestroyed() {
        assert sInstance != null;
        assert sInstance.mService != null;

        clear();
        sInstance.mNotificationBuilder = null;
        sInstance.mService = null;
    }

    private final Context mContext;

    // ListenerService running for the notification. Only non-null when showing.
    private ListenerService mService;

    private final String mPlayDescription;

    private final String mPauseDescription;

    private NotificationCompat.Builder mNotificationBuilder;

    private Bitmap mNotificationIcon;

    private final Bitmap mMediaSessionIcon;

    private MediaNotificationInfo mMediaNotificationInfo;

    private MediaSessionCompat mMediaSession;

    private final MediaSessionCompat.Callback mMediaSessionCallback = new MediaSessionCompat.Callback() {
        @Override
        public void onPlay() {
            if (!sInstance.mMediaNotificationInfo.isPaused)
                return;
            sInstance.onPlaybackStateChanged(PLAYBACK_STATE_PLAYING);
        }

        @Override
        public void onPause() {
            if (sInstance.mMediaNotificationInfo.isPaused)
                return;
            sInstance.onPlaybackStateChanged(PLAYBACK_STATE_PAUSED);
        }
    };

    private NotificationMediaPlaybackControls(Context context) {
        mContext = context;
        mPlayDescription = context.getResources().getString(R.string.accessibility_play);
        mPauseDescription = context.getResources().getString(R.string.accessibility_pause);

        // The MediaSession icon is a plain color.
        int size = context.getResources().getDimensionPixelSize(R.dimen.media_session_icon_size);
        mMediaSessionIcon = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
        mMediaSessionIcon.eraseColor(context.getResources().getColor(R.color.media_session_icon_color));
    }

    private void showNotification(MediaNotificationInfo mediaNotificationInfo) {
        mContext.startService(new Intent(mContext, ListenerService.class));

        assert mediaNotificationInfo != null;

        if (mediaNotificationInfo.equals(mMediaNotificationInfo))
            return;

        mMediaNotificationInfo = new MediaNotificationInfo(sanitizeMediaTitle(mediaNotificationInfo.title),
                mediaNotificationInfo.isPaused, mediaNotificationInfo.origin, mediaNotificationInfo.tabId,
                mediaNotificationInfo.isPrivate, mediaNotificationInfo.listener);
        updateNotification();
    }

    private void clearNotification() {
        NotificationManagerCompat manager = NotificationManagerCompat.from(mContext);
        manager.cancel(R.id.media_playback_notification);

        if (mMediaSession != null) {
            mMediaSession.setActive(false);
            mMediaSession.release();
            mMediaSession = null;
        }
        mMediaNotificationInfo = null;
        mContext.stopService(new Intent(mContext, ListenerService.class));
    }

    private void hideNotification(int tabId) {
        if (mMediaNotificationInfo == null || tabId != mMediaNotificationInfo.tabId)
            return;
        clearNotification();
    }

    private void onPlaybackStateChanged(int playbackState) {
        assert mMediaNotificationInfo != null;
        assert playbackState == PLAYBACK_STATE_PLAYING || playbackState == PLAYBACK_STATE_PAUSED;

        mMediaNotificationInfo = new MediaNotificationInfo(mMediaNotificationInfo.title,
                playbackState == PLAYBACK_STATE_PAUSED, mMediaNotificationInfo.origin, mMediaNotificationInfo.tabId,
                mMediaNotificationInfo.isPrivate, mMediaNotificationInfo.listener);
        updateNotification();

        if (playbackState == PLAYBACK_STATE_PAUSED) {
            mMediaNotificationInfo.listener.onPause();
        } else {
            mMediaNotificationInfo.listener.onPlay();
        }
    }

    private RemoteViews createContentView() {
        RemoteViews contentView = new RemoteViews(mContext.getPackageName(), R.layout.playback_notification_bar);

        // On Android pre-L, dismissing the notification when the service is no longer in foreground
        // doesn't work. Instead, a STOP button is shown.
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
            contentView.setViewVisibility(R.id.stop, View.VISIBLE);
            contentView.setOnClickPendingIntent(R.id.stop, mService.getPendingIntent(ListenerService.ACTION_STOP));
        }
        return contentView;
    }

    private String sanitizeMediaTitle(String title) {
        // Improve the visibility of the title by removing all the leading/trailing white spaces
        // and the quite common unicode play icon.
        title = title.trim();
        return title.startsWith("\u25B6") ? title.substring(1).trim() : title;
    }

    private String getStatus() {
        if (mMediaNotificationInfo.origin != null) {
            return mContext.getString(R.string.media_notification_link_text, mMediaNotificationInfo.origin);
        }
        return mContext.getString(R.string.media_notification_text_no_link);
    }

    private PendingIntent createContentIntent() {
        int tabId = mMediaNotificationInfo.tabId;
        return PendingIntent.getActivity(mContext, tabId, Tab.createBringTabToFrontIntent(tabId), 0);
    }

    private MediaMetadataCompat createMetadata() {
        MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, mMediaNotificationInfo.title);
            metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE,
                    mMediaNotificationInfo.origin);
            metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, mMediaSessionIcon);
        } else {
            metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, mMediaNotificationInfo.title);
            metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, mMediaNotificationInfo.origin);
            metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, mMediaSessionIcon);
        }

        return metadataBuilder.build();
    }

    private void updateNotification() {
        if (mService == null)
            return;

        if (mMediaNotificationInfo == null) {
            // Notification was hidden before we could update it.
            assert mNotificationBuilder == null;
            return;
        }

        // Android doesn't badge the icons for RemoteViews automatically when
        // running the app under the Work profile.
        if (mNotificationIcon == null) {
            Drawable notificationIconDrawable = ApiCompatibilityUtils.getUserBadgedIcon(mContext,
                    R.drawable.audio_playing);
            mNotificationIcon = drawableToBitmap(notificationIconDrawable);
        }

        if (mNotificationBuilder == null) {
            mNotificationBuilder = new NotificationCompat.Builder(mContext).setSmallIcon(R.drawable.audio_playing)
                    .setAutoCancel(false).setLocalOnly(true)
                    .setDeleteIntent(mService.getPendingIntent(ListenerService.ACTION_STOP));
        }
        mNotificationBuilder.setOngoing(!mMediaNotificationInfo.isPaused);
        mNotificationBuilder.setContentIntent(createContentIntent());

        RemoteViews contentView = createContentView();

        contentView.setTextViewText(R.id.title, mMediaNotificationInfo.title);
        contentView.setTextViewText(R.id.status, getStatus());
        if (mNotificationIcon != null) {
            contentView.setImageViewBitmap(R.id.icon, mNotificationIcon);
        } else {
            contentView.setImageViewResource(R.id.icon, R.drawable.audio_playing);
        }

        if (mMediaNotificationInfo.isPaused) {
            contentView.setImageViewResource(R.id.playpause, R.drawable.ic_vidcontrol_play);
            contentView.setContentDescription(R.id.playpause, mPlayDescription);
            contentView.setOnClickPendingIntent(R.id.playpause,
                    mService.getPendingIntent(ListenerService.ACTION_PLAY));
        } else {
            contentView.setImageViewResource(R.id.playpause, R.drawable.ic_vidcontrol_pause);
            contentView.setContentDescription(R.id.playpause, mPauseDescription);
            contentView.setOnClickPendingIntent(R.id.playpause,
                    mService.getPendingIntent(ListenerService.ACTION_PAUSE));
        }

        mNotificationBuilder.setContent(contentView);
        mNotificationBuilder.setVisibility(mMediaNotificationInfo.isPrivate ? NotificationCompat.VISIBILITY_PRIVATE
                : NotificationCompat.VISIBILITY_PUBLIC);

        if (mMediaSession == null) {
            mMediaSession = new MediaSessionCompat(mContext, mContext.getString(R.string.app_name),
                    new ComponentName(mContext.getPackageName(), MediaButtonReceiver.class.getName()), null);
            mMediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
                    | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
            mMediaSession.setCallback(mMediaSessionCallback);
            mMediaSession.setActive(true);
        }

        mMediaSession.setMetadata(createMetadata());

        PlaybackStateCompat.Builder playbackStateBuilder = new PlaybackStateCompat.Builder()
                .setActions(PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_PAUSE);
        if (mMediaNotificationInfo.isPaused) {
            playbackStateBuilder.setState(PlaybackStateCompat.STATE_PAUSED,
                    PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1.0f);
        } else {
            playbackStateBuilder.setState(PlaybackStateCompat.STATE_PLAYING,
                    PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, 1.0f);
        }
        mMediaSession.setPlaybackState(playbackStateBuilder.build());

        Notification notification = mNotificationBuilder.build();

        // We keep the service as a foreground service while the media is playing. When it is not,
        // the service isn't stopped but is no longer in foreground, thus at a lower priority.
        // While the service is in foreground, the associated notification can't be swipped away.
        // Moving it back to background allows the user to remove the notification.
        if (mMediaNotificationInfo.isPaused) {
            mService.stopForeground(false /* removeNotification */);

            NotificationManagerCompat manager = NotificationManagerCompat.from(mContext);
            manager.notify(R.id.media_playback_notification, notification);
        } else {
            mService.startForeground(R.id.media_playback_notification, notification);
        }
    }

    private Bitmap drawableToBitmap(Drawable drawable) {
        if (!(drawable instanceof BitmapDrawable))
            return null;

        BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
        return bitmapDrawable.getBitmap();
    }
}