blob: 8ed80d55ff972995eebe1c44784eefede541124c [file] [log] [blame]
// 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.util.SparseArray;
import android.view.KeyEvent;
import android.view.View;
import android.widget.RemoteViews;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.Log;
import org.chromium.chrome.R;
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 MediaNotificationListener} calls for all registered listeners.
* There's one service started for a distinct notification id.
*/
public class MediaNotificationManager {
private static final String TAG = "cr_MediaNotification";
// We're always used on the UI thread but the LOCK is required by lint when creating the
// singleton.
private static final Object LOCK = new Object();
// Maps the notification ids to their corresponding notification managers.
private static SparseArray<MediaNotificationManager> sManagers;
/**
* Service used to transform intent requests triggered from the notification into
* {@code MediaNotificationListener} callbacks. We have to create a separate derived class for
* each type of notification since one class corresponds to one instance of the service only.
*/
private abstract static class ListenerService extends Service {
private static final String ACTION_PLAY =
"MediaNotificationManager.ListenerService.PLAY";
private static final String ACTION_PAUSE =
"MediaNotificationManager.ListenerService.PAUSE";
private static final String ACTION_STOP =
"MediaNotificationManager.ListenerService.STOP";
private static final String EXTRA_NOTIFICATION_ID =
MediaButtonReceiver.EXTRA_NOTIFICATION_ID;
// The notification id this service instance corresponds to.
private int mNotificationId = MediaNotificationInfo.INVALID_ID;
private PendingIntent getPendingIntent(String action) {
Intent intent = getIntent(this, mNotificationId).setAction(action);
return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onDestroy() {
super.onDestroy();
MediaNotificationManager manager = getManager(mNotificationId);
if (manager == null) return;
manager.onServiceDestroyed(mNotificationId);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (!processIntent(intent)) stopSelf();
return START_NOT_STICKY;
}
private boolean processIntent(Intent intent) {
if (intent == null) return false;
int notificationId = intent.getIntExtra(
EXTRA_NOTIFICATION_ID, MediaNotificationInfo.INVALID_ID);
// The notification id must always be valid and should match the first notification id
// the service got via the intent.
if (notificationId == MediaNotificationInfo.INVALID_ID
|| (mNotificationId != MediaNotificationInfo.INVALID_ID
&& mNotificationId != notificationId)) {
Log.w(TAG, "The service intent's notification id is invalid: ", notificationId);
return false;
}
// Either the notification id matches or it's the first intent we've got.
mNotificationId = notificationId;
assert mNotificationId != MediaNotificationInfo.INVALID_ID;
MediaNotificationManager manager = getManager(mNotificationId);
if (manager == null || manager.mMediaNotificationInfo == null) return false;
manager.onServiceStarted(this);
processAction(intent, manager);
return true;
}
private void processAction(Intent intent, MediaNotificationManager manager) {
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;
if (event.getAction() != KeyEvent.ACTION_DOWN) return;
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_MEDIA_PLAY:
manager.onPlay(
MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION);
break;
case KeyEvent.KEYCODE_MEDIA_PAUSE:
manager.onPause(
MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION);
break;
case KeyEvent.KEYCODE_HEADSETHOOK:
case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
if (manager.mMediaNotificationInfo.isPaused) {
manager.onPlay(MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION);
} else {
manager.onPause(
MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION);
}
break;
default:
break;
}
} else if (ACTION_STOP.equals(action)) {
manager.onStop(
MediaNotificationListener.ACTION_SOURCE_MEDIA_NOTIFICATION);
stopSelf();
} else if (ACTION_PLAY.equals(action)) {
manager.onPlay(MediaNotificationListener.ACTION_SOURCE_MEDIA_NOTIFICATION);
} else if (ACTION_PAUSE.equals(action)) {
manager.onPause(MediaNotificationListener.ACTION_SOURCE_MEDIA_NOTIFICATION);
}
}
}
/**
* This class is used internally but have to be public to be able to launch the service.
*/
public static final class PlaybackListenerService extends ListenerService {
private static final int NOTIFICATION_ID = R.id.media_playback_notification;
}
/**
* This class is used internally but have to be public to be able to launch the service.
*/
public static final class PresentationListenerService extends ListenerService {
private static final int NOTIFICATION_ID = R.id.presentation_notification;
}
// Two classes to specify the right notification id in the intent.
/**
* This class is used internally but have to be public to be able to launch the service.
*/
public static final class PlaybackMediaButtonReceiver extends MediaButtonReceiver {
@Override
public int getNotificationId() {
return PlaybackListenerService.NOTIFICATION_ID;
}
}
/**
* This class is used internally but have to be public to be able to launch the service.
*/
public static final class PresentationMediaButtonReceiver extends MediaButtonReceiver {
@Override
public int getNotificationId() {
return PresentationListenerService.NOTIFICATION_ID;
}
}
private static Intent getIntent(Context context, int notificationId) {
Intent intent = null;
if (notificationId == PlaybackListenerService.NOTIFICATION_ID) {
intent = new Intent(context, PlaybackListenerService.class);
} else if (notificationId == PresentationListenerService.NOTIFICATION_ID) {
intent = new Intent(context, PresentationListenerService.class);
} else {
return null;
}
return intent.putExtra(ListenerService.EXTRA_NOTIFICATION_ID, notificationId);
}
private static String getButtonReceiverClassName(int notificationId) {
if (notificationId == PlaybackListenerService.NOTIFICATION_ID) {
return PlaybackMediaButtonReceiver.class.getName();
}
if (notificationId == PresentationListenerService.NOTIFICATION_ID) {
return PresentationMediaButtonReceiver.class.getName();
}
assert false;
return null;
}
/**
* 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 notificationInfoBuilder information to show in the notification
*/
public static void show(Context applicationContext,
MediaNotificationInfo.Builder notificationInfoBuilder) {
synchronized (LOCK) {
if (sManagers == null) {
sManagers = new SparseArray<MediaNotificationManager>();
}
}
MediaNotificationInfo notificationInfo = notificationInfoBuilder.build();
MediaNotificationManager manager = sManagers.get(notificationInfo.id);
if (manager == null) {
manager = new MediaNotificationManager(applicationContext);
sManagers.put(notificationInfo.id, manager);
}
manager.mNotificationInfoBuilder = notificationInfoBuilder;
manager.showNotification(notificationInfo);
}
/**
* Hides the notification for the specified tabId and notificationId
*
* @param tabId the id of the tab that showed the notification or invalid tab id.
* @param notificationId the id of the notification to hide for this tab.
*/
public static void hide(int tabId, int notificationId) {
MediaNotificationManager manager = getManager(notificationId);
if (manager == null) return;
manager.hideNotification(tabId);
}
/**
* Hides notifications with the specified id for all tabs if shown.
*
* @param notificationId the id of the notification to hide for all tabs.
*/
public static void clear(int notificationId) {
MediaNotificationManager manager = getManager(notificationId);
if (manager == null) return;
manager.clearNotification();
sManagers.remove(notificationId);
}
/**
* Hides notifications with all known ids for all tabs if shown.
*/
public static void clearAll() {
if (sManagers == null) return;
for (int i = 0; i < sManagers.size(); ++i) {
MediaNotificationManager manager = sManagers.valueAt(i);
manager.clearNotification();
}
sManagers.clear();
}
private static MediaNotificationManager getManager(int notificationId) {
if (sManagers == null) return null;
return sManagers.get(notificationId);
}
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 final String mStopDescription;
private NotificationCompat.Builder mNotificationBuilder;
private Bitmap mNotificationIcon;
private final Bitmap mMediaSessionIcon;
private MediaNotificationInfo mMediaNotificationInfo;
private MediaNotificationInfo.Builder mNotificationInfoBuilder;
private MediaSessionCompat mMediaSession;
private static final class MediaSessionCallback extends MediaSessionCompat.Callback {
private final MediaNotificationManager mManager;
private MediaSessionCallback(MediaNotificationManager manager) {
mManager = manager;
}
@Override
public void onPlay() {
mManager.onPlay(MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION);
}
@Override
public void onPause() {
mManager.onPause(MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION);
}
}
private final MediaSessionCallback mMediaSessionCallback = new MediaSessionCallback(this);
private MediaNotificationManager(Context context) {
mContext = context;
mPlayDescription = context.getResources().getString(R.string.accessibility_play);
mPauseDescription = context.getResources().getString(R.string.accessibility_pause);
mStopDescription = context.getResources().getString(R.string.accessibility_stop);
// 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(ApiCompatibilityUtils.getColor(
context.getResources(), R.color.media_session_icon_color));
}
/**
* Registers the started {@link Service} with the manager and creates the notification.
*
* @param service the service that was started
*/
private void onServiceStarted(ListenerService service) {
mService = service;
updateNotification();
}
/**
* Handles the service destruction destruction.
*/
private void onServiceDestroyed(int notificationId) {
if (mService == null) return;
if (notificationId != -1) clear(notificationId);
mNotificationBuilder = null;
mService = null;
}
private void onPlay(int actionSource) {
if (!mMediaNotificationInfo.isPaused) return;
mMediaNotificationInfo = mNotificationInfoBuilder.setPaused(false).build();
updateNotification();
mMediaNotificationInfo.listener.onPlay(actionSource);
}
private void onPause(int actionSource) {
if (mMediaNotificationInfo.isPaused) return;
mMediaNotificationInfo = mNotificationInfoBuilder.setPaused(true).build();
updateNotification();
mMediaNotificationInfo.listener.onPause(actionSource);
}
private void onStop(int actionSource) {
// hideNotification() below will clear |mMediaNotificationInfo| but {@link
// MediaNotificationListener}.onStop() might also clear it so keep the listener and call it
// later.
// TODO(avayvod): make the notification delegate update its state, not the notification
// manager. See https://crbug.com/546981
MediaNotificationListener listener = mMediaNotificationInfo.listener;
hideNotification(mMediaNotificationInfo.tabId);
listener.onStop(actionSource);
}
private void showNotification(MediaNotificationInfo mediaNotificationInfo) {
mContext.startService(getIntent(mContext, mediaNotificationInfo.id));
if (mediaNotificationInfo.equals(mMediaNotificationInfo)) return;
mMediaNotificationInfo = mediaNotificationInfo;
updateNotification();
}
private void clearNotification() {
if (mMediaNotificationInfo == null) return;
int notificationId = mMediaNotificationInfo.id;
NotificationManagerCompat manager = NotificationManagerCompat.from(mContext);
manager.cancel(notificationId);
if (mMediaSession != null) {
mMediaSession.setActive(false);
mMediaSession.release();
mMediaSession = null;
}
mContext.stopService(getIntent(mContext, notificationId));
mMediaNotificationInfo = null;
}
private void hideNotification(int tabId) {
if (mMediaNotificationInfo == null || tabId != mMediaNotificationInfo.tabId) return;
clearNotification();
}
private RemoteViews createContentView() {
RemoteViews contentView =
new RemoteViews(mContext.getPackageName(), R.layout.playback_notification_bar);
// By default, play/pause button is the only one.
int playPauseButtonId = R.id.button1;
// 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 (mMediaNotificationInfo.supportsSwipeAway()
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP
|| mMediaNotificationInfo.supportsStop()) {
contentView.setOnClickPendingIntent(R.id.button1,
mService.getPendingIntent(ListenerService.ACTION_STOP));
contentView.setContentDescription(R.id.button1, mStopDescription);
// If the play/pause needs to be shown, it moves over to the second button from the end.
playPauseButtonId = R.id.button2;
}
contentView.setTextViewText(R.id.title, mMediaNotificationInfo.title);
contentView.setTextViewText(R.id.status, mMediaNotificationInfo.origin);
if (mNotificationIcon != null) {
contentView.setImageViewBitmap(R.id.icon, mNotificationIcon);
} else {
contentView.setImageViewResource(R.id.icon, mMediaNotificationInfo.icon);
}
if (mMediaNotificationInfo.supportsPlayPause()) {
if (mMediaNotificationInfo.isPaused) {
contentView.setImageViewResource(playPauseButtonId, R.drawable.ic_vidcontrol_play);
contentView.setContentDescription(playPauseButtonId, mPlayDescription);
contentView.setOnClickPendingIntent(playPauseButtonId,
mService.getPendingIntent(ListenerService.ACTION_PLAY));
} else {
// If we're here, the notification supports play/pause button and is playing.
contentView.setImageViewResource(playPauseButtonId, R.drawable.ic_vidcontrol_pause);
contentView.setContentDescription(playPauseButtonId, mPauseDescription);
contentView.setOnClickPendingIntent(playPauseButtonId,
mService.getPendingIntent(ListenerService.ACTION_PAUSE));
}
contentView.setViewVisibility(playPauseButtonId, View.VISIBLE);
} else {
contentView.setViewVisibility(playPauseButtonId, View.GONE);
}
return contentView;
}
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, mMediaNotificationInfo.icon);
mNotificationIcon = drawableToBitmap(notificationIconDrawable);
}
if (mNotificationBuilder == null) {
mNotificationBuilder = new NotificationCompat.Builder(mContext)
.setSmallIcon(mMediaNotificationInfo.icon)
.setAutoCancel(false)
.setLocalOnly(true)
.setDeleteIntent(mService.getPendingIntent(ListenerService.ACTION_STOP));
}
if (mMediaNotificationInfo.supportsSwipeAway()) {
mNotificationBuilder.setOngoing(!mMediaNotificationInfo.isPaused);
}
int tabId = mMediaNotificationInfo.tabId;
Intent tabIntent = Tab.createBringTabToFrontIntent(tabId);
if (tabIntent != null) {
mNotificationBuilder
.setContentIntent(PendingIntent.getActivity(mContext, tabId, tabIntent, 0));
}
mNotificationBuilder.setContent(createContentView());
mNotificationBuilder.setVisibility(
mMediaNotificationInfo.isPrivate ? NotificationCompat.VISIBILITY_PRIVATE
: NotificationCompat.VISIBILITY_PUBLIC);
if (mMediaNotificationInfo.supportsPlayPause()) {
if (mMediaSession == null) mMediaSession = createMediaSession();
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 {
// If notification only supports stop, still pretend
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.supportsSwipeAway() && mMediaNotificationInfo.isPaused) {
mService.stopForeground(false /* removeNotification */);
NotificationManagerCompat manager = NotificationManagerCompat.from(mContext);
manager.notify(mMediaNotificationInfo.id, notification);
} else {
mService.startForeground(mMediaNotificationInfo.id, notification);
}
}
private MediaSessionCompat createMediaSession() {
MediaSessionCompat mediaSession = new MediaSessionCompat(
mContext,
mContext.getString(R.string.app_name),
new ComponentName(mContext.getPackageName(),
getButtonReceiverClassName(mMediaNotificationInfo.id)),
null);
mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
| MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
mediaSession.setCallback(mMediaSessionCallback);
// TODO(mlamouri): the following code is to work around a bug that hopefully
// MediaSessionCompat will handle directly. see b/24051980.
try {
mediaSession.setActive(true);
} catch (NullPointerException e) {
// Some versions of KitKat do not support AudioManager.registerMediaButtonIntent
// with a PendingIntent. They will throw a NullPointerException, in which case
// they should be able to activate a MediaSessionCompat with only transport
// controls.
mediaSession.setActive(false);
mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS);
mediaSession.setActive(true);
}
return mediaSession;
}
private Bitmap drawableToBitmap(Drawable drawable) {
if (!(drawable instanceof BitmapDrawable)) return null;
BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
return bitmapDrawable.getBitmap();
}
}