blob: ff0529ece0607c7b1639dd0bef2502ca81c313dc [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.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.media.AudioManager;
import android.os.Build;
import android.os.IBinder;
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.support.v7.app.NotificationCompat;
import android.support.v7.media.MediaRouter;
import android.text.TextUtils;
import android.util.SparseArray;
import android.view.KeyEvent;
import org.chromium.base.SysUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.blink.mojom.MediaSessionAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.notifications.NotificationConstants;
import org.chromium.content_public.common.MediaMetadata;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.annotation.Nullable;
/**
* 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 = "MediaNotification";
@VisibleForTesting
static final int CUSTOM_MEDIA_SESSION_ACTION_STOP = MediaSessionAction.LAST + 1;
// The media artwork image resolution on high-end devices.
private static final int HIGH_IMAGE_SIZE_PX = 512;
// The media artwork image resolution on high-end devices.
private static final int LOW_IMAGE_SIZE_PX = 256;
// The maximum number of actions in CompactView media notification.
private static final int COMPACT_VIEW_ACTIONS_COUNT = 3;
// The maximum number of actions in BigView media notification.
private static final int BIG_VIEW_ACTIONS_COUNT = isRunningN() ? 5 : 3;
// 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;
private final Context mContext;
// ListenerService running for the notification. Only non-null when showing.
private ListenerService mService;
private SparseArray<MediaButtonInfo> mActionToButtonInfo;
private NotificationCompat.Builder mNotificationBuilder;
private Bitmap mDefaultNotificationLargeIcon;
// |mMediaNotificationInfo| should be not null if and only if the notification is showing.
private MediaNotificationInfo mMediaNotificationInfo;
private MediaSessionCompat mMediaSession;
private final MediaSessionCompat.Callback mMediaSessionCallback =
new MediaSessionCompat.Callback() {
@Override
public void onPlay() {
MediaNotificationManager.this.onPlay(
MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION);
}
@Override
public void onPause() {
MediaNotificationManager.this.onPause(
MediaNotificationListener.ACTION_SOURCE_MEDIA_SESSION);
}
@Override
public void onSkipToPrevious() {
MediaNotificationManager.this.onMediaSessionAction(
MediaSessionAction.PREVIOUS_TRACK);
}
@Override
public void onSkipToNext() {
MediaNotificationManager.this.onMediaSessionAction(
MediaSessionAction.NEXT_TRACK);
}
@Override
public void onFastForward() {
MediaNotificationManager.this.onMediaSessionAction(
MediaSessionAction.SEEK_FORWARD);
}
@Override
public void onRewind() {
MediaNotificationManager.this.onMediaSessionAction(
MediaSessionAction.SEEK_BACKWARD);
}
};
/**
* 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 ACTION_SWIPE =
"MediaNotificationManager.ListenerService.SWIPE";
private static final String ACTION_CANCEL =
"MediaNotificationManager.ListenerService.CANCEL";
private static final String ACTION_PREVIOUS_TRACK =
"MediaNotificationManager.ListenerService.PREVIOUS_TRACK";
private static final String ACTION_NEXT_TRACK =
"MediaNotificationManager.ListenerService.NEXT_TRACK";
private static final String ACTION_SEEK_FORWARD =
"MediaNotificationManager.ListenerService.SEEK_FORWARD";
private static final String ACTION_SEEK_BACKWARD =
"MediaNotificationmanager.ListenerService.SEEK_BACKWARD";
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onDestroy() {
super.onDestroy();
MediaNotificationManager manager = getManager();
if (manager == null) return;
manager.onServiceDestroyed();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (!processIntent(intent)) stopSelf();
return START_NOT_STICKY;
}
@Nullable
protected abstract MediaNotificationManager getManager();
private boolean processIntent(Intent intent) {
if (intent == null) return false;
MediaNotificationManager manager = getManager();
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;
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
manager.onMediaSessionAction(MediaSessionAction.PREVIOUS_TRACK);
break;
case KeyEvent.KEYCODE_MEDIA_NEXT:
manager.onMediaSessionAction(MediaSessionAction.NEXT_TRACK);
break;
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
manager.onMediaSessionAction(MediaSessionAction.SEEK_FORWARD);
break;
case KeyEvent.KEYCODE_MEDIA_REWIND:
manager.onMediaSessionAction(MediaSessionAction.SEEK_BACKWARD);
break;
default:
break;
}
} else if (ACTION_STOP.equals(action)
|| ACTION_SWIPE.equals(action)
|| ACTION_CANCEL.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);
} else if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(action)) {
manager.onPause(MediaNotificationListener.ACTION_SOURCE_HEADSET_UNPLUG);
} else if (ACTION_PREVIOUS_TRACK.equals(action)) {
manager.onMediaSessionAction(MediaSessionAction.PREVIOUS_TRACK);
} else if (ACTION_NEXT_TRACK.equals(action)) {
manager.onMediaSessionAction(MediaSessionAction.NEXT_TRACK);
} else if (ACTION_SEEK_FORWARD.equals(action)) {
manager.onMediaSessionAction(MediaSessionAction.SEEK_FORWARD);
} else if (ACTION_SEEK_BACKWARD.equals(action)) {
manager.onMediaSessionAction(MediaSessionAction.SEEK_BACKWARD);
}
}
}
/**
* 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;
@Override
public void onCreate() {
super.onCreate();
IntentFilter filter = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
registerReceiver(mAudioBecomingNoisyReceiver, filter);
}
@Override
public void onDestroy() {
unregisterReceiver(mAudioBecomingNoisyReceiver);
super.onDestroy();
}
@Override
@Nullable
protected MediaNotificationManager getManager() {
return MediaNotificationManager.getManager(NOTIFICATION_ID);
}
private BroadcastReceiver mAudioBecomingNoisyReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (!AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
return;
}
Intent i = new Intent(context, PlaybackListenerService.class);
i.setAction(intent.getAction());
context.startService(i);
}
};
}
/**
* 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;
@Override
@Nullable
protected MediaNotificationManager getManager() {
return MediaNotificationManager.getManager(NOTIFICATION_ID);
}
}
/**
* This class is used internally but have to be public to be able to launch the service.
*/
public static final class CastListenerService extends ListenerService {
private static final int NOTIFICATION_ID = R.id.remote_notification;
@Override
@Nullable
protected MediaNotificationManager getManager() {
return MediaNotificationManager.getManager(NOTIFICATION_ID);
}
}
// Three 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 String getServiceClassName() {
return PlaybackListenerService.class.getName();
}
}
/**
* 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 String getServiceClassName() {
return PresentationListenerService.class.getName();
}
}
/**
* This class is used internally but have to be public to be able to launch the service.
*/
public static final class CastMediaButtonReceiver extends MediaButtonReceiver {
@Override
public String getServiceClassName() {
return CastListenerService.class.getName();
}
}
private Intent createIntent(Context context) {
Intent intent = null;
if (mMediaNotificationInfo.id == PlaybackListenerService.NOTIFICATION_ID) {
intent = new Intent(context, PlaybackListenerService.class);
} else if (mMediaNotificationInfo.id == PresentationListenerService.NOTIFICATION_ID) {
intent = new Intent(context, PresentationListenerService.class);
} else if (mMediaNotificationInfo.id == CastListenerService.NOTIFICATION_ID) {
intent = new Intent(context, CastListenerService.class);
}
return intent;
}
private PendingIntent createPendingIntent(String action) {
assert mService != null;
Intent intent = createIntent(mService).setAction(action);
return PendingIntent.getService(mService, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
}
private String getButtonReceiverClassName() {
if (mMediaNotificationInfo.id == PlaybackListenerService.NOTIFICATION_ID) {
return PlaybackMediaButtonReceiver.class.getName();
} else if (mMediaNotificationInfo.id == PresentationListenerService.NOTIFICATION_ID) {
return PresentationMediaButtonReceiver.class.getName();
} else if (mMediaNotificationInfo.id == CastListenerService.NOTIFICATION_ID) {
return CastMediaButtonReceiver.class.getName();
}
assert false;
return null;
}
// Returns the notification group name used to prevent automatic grouping.
private String getNotificationGroupName() {
if (mMediaNotificationInfo.id == PlaybackListenerService.NOTIFICATION_ID) {
return NotificationConstants.GROUP_MEDIA_PLAYBACK;
} else if (mMediaNotificationInfo.id == PresentationListenerService.NOTIFICATION_ID) {
return NotificationConstants.GROUP_MEDIA_PRESENTATION;
} else if (mMediaNotificationInfo.id == CastListenerService.NOTIFICATION_ID) {
return NotificationConstants.GROUP_MEDIA_REMOTE;
}
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 notificationInfo information to show in the notification
*/
public static void show(Context applicationContext,
MediaNotificationInfo notificationInfo) {
synchronized (LOCK) {
if (sManagers == null) {
sManagers = new SparseArray<MediaNotificationManager>();
}
}
MediaNotificationManager manager = sManagers.get(notificationInfo.id);
if (manager == null) {
manager = new MediaNotificationManager(applicationContext, notificationInfo.id);
sManagers.put(notificationInfo.id, manager);
}
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();
}
/**
* Activates the Android MediaSession. This method is used to activate Android MediaSession more
* often because some old version of Android might send events to the latest active session
* based on when setActive(true) was called and regardless of the current playback state.
* @param tabId the id of the tab requesting to reactivate the Android MediaSession.
* @param notificationId the id of the notification to reactivate Android MediaSession for.
*/
public static void activateAndroidMediaSession(int tabId, int notificationId) {
MediaNotificationManager manager = getManager(notificationId);
if (manager == null) return;
manager.activateAndroidMediaSession(tabId);
}
/**
* Scales |icon| to the size returned by {@link getIdealMediaImageSize()}. Returns null if
* |icon| is null.
* @param icon The icon to be scaled.
*/
@Nullable
public static Bitmap scaleIconToIdealSize(Bitmap icon) {
if (icon == null) return null;
int targetSize = getIdealMediaImageSize();
Matrix m = new Matrix();
int dominantLength = Math.max(icon.getWidth(), icon.getHeight());
// Move the center to (0,0).
m.postTranslate(icon.getWidth() / -2.0f, icon.getHeight() / -2.0f);
// Scale to desired size.
float scale = 1.0f * targetSize / dominantLength;
m.postScale(scale, scale);
// Move to the desired place.
m.postTranslate(targetSize / 2.0f, targetSize / 2.0f);
// Draw the image.
Bitmap paddedBitmap = Bitmap.createBitmap(targetSize, targetSize, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(paddedBitmap);
Paint paint = new Paint(Paint.FILTER_BITMAP_FLAG);
canvas.drawBitmap(icon, m, paint);
return paddedBitmap;
}
/**
* @returns The ideal size of the media image.
*/
public static int getIdealMediaImageSize() {
if (SysUtils.isLowEndDevice()) {
return LOW_IMAGE_SIZE_PX;
}
return HIGH_IMAGE_SIZE_PX;
}
private static MediaNotificationManager getManager(int notificationId) {
if (sManagers == null) return null;
return sManagers.get(notificationId);
}
@VisibleForTesting
static boolean hasManagerForTesting(int notificationId) {
return getManager(notificationId) != null;
}
@VisibleForTesting
@Nullable
static NotificationCompat.Builder getNotificationBuilderForTesting(
int notificationId) {
MediaNotificationManager manager = getManager(notificationId);
if (manager == null) return null;
return manager.mNotificationBuilder;
}
private static boolean isRunningN() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N;
}
/**
* The class containing all the information for adding a button in the notification for an
* action.
*/
private static final class MediaButtonInfo {
/** The resource ID of this media button icon. */
public int iconResId;
/** The resource ID of this media button description. */
public int descriptionResId;
/** The intent string to be fired when this media button is clicked. */
public String intentString;
public MediaButtonInfo(int buttonResId, int descriptionResId, String intentString) {
this.iconResId = buttonResId;
this.descriptionResId = descriptionResId;
this.intentString = intentString;
}
}
private MediaNotificationManager(Context context, int notificationId) {
mContext = context;
mActionToButtonInfo = new SparseArray<>();
mActionToButtonInfo.put(MediaSessionAction.PLAY,
new MediaButtonInfo(R.drawable.ic_media_control_play, R.string.accessibility_play,
ListenerService.ACTION_PLAY));
mActionToButtonInfo.put(MediaSessionAction.PAUSE,
new MediaButtonInfo(R.drawable.ic_media_control_pause, R.string.accessibility_pause,
ListenerService.ACTION_PAUSE));
mActionToButtonInfo.put(CUSTOM_MEDIA_SESSION_ACTION_STOP,
new MediaButtonInfo(R.drawable.ic_media_control_stop, R.string.accessibility_stop,
ListenerService.ACTION_STOP));
mActionToButtonInfo.put(MediaSessionAction.PREVIOUS_TRACK,
new MediaButtonInfo(R.drawable.ic_media_control_skip_previous,
R.string.accessibility_previous_track,
ListenerService.ACTION_PREVIOUS_TRACK));
mActionToButtonInfo.put(MediaSessionAction.NEXT_TRACK,
new MediaButtonInfo(R.drawable.ic_media_control_skip_next,
R.string.accessibility_next_track, ListenerService.ACTION_NEXT_TRACK));
mActionToButtonInfo.put(MediaSessionAction.SEEK_FORWARD,
new MediaButtonInfo(R.drawable.ic_media_control_fast_forward,
R.string.accessibility_seek_forward, ListenerService.ACTION_SEEK_FORWARD));
mActionToButtonInfo.put(MediaSessionAction.SEEK_BACKWARD,
new MediaButtonInfo(R.drawable.ic_media_control_fast_rewind,
R.string.accessibility_seek_backward,
ListenerService.ACTION_SEEK_BACKWARD));
}
/**
* 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() {
// Service already detached
if (mService == null) return;
// Notification is not showing
if (mMediaNotificationInfo == null) return;
clear(mMediaNotificationInfo.id);
mNotificationBuilder = null;
mService = null;
}
private void onPlay(int actionSource) {
if (!mMediaNotificationInfo.isPaused) return;
mMediaNotificationInfo.listener.onPlay(actionSource);
}
private void onPause(int actionSource) {
if (mMediaNotificationInfo.isPaused) return;
mMediaNotificationInfo.listener.onPause(actionSource);
}
private void onStop(int actionSource) {
mMediaNotificationInfo.listener.onStop(actionSource);
}
private void onMediaSessionAction(int action) {
mMediaNotificationInfo.listener.onMediaSessionAction(action);
}
private void showNotification(MediaNotificationInfo mediaNotificationInfo) {
if (mediaNotificationInfo.equals(mMediaNotificationInfo)) return;
mMediaNotificationInfo = mediaNotificationInfo;
mContext.startService(createIntent(mContext));
updateNotification();
}
private void clearNotification() {
if (mMediaNotificationInfo == null) return;
NotificationManagerCompat manager = NotificationManagerCompat.from(mContext);
manager.cancel(mMediaNotificationInfo.id);
if (mMediaSession != null) {
mMediaSession.setCallback(null);
mMediaSession.setActive(false);
mMediaSession.release();
mMediaSession = null;
}
mContext.stopService(createIntent(mContext));
mMediaNotificationInfo = null;
}
private void hideNotification(int tabId) {
if (mMediaNotificationInfo == null || tabId != mMediaNotificationInfo.tabId) return;
clearNotification();
}
private MediaMetadataCompat createMetadata() {
MediaMetadataCompat.Builder metadataBuilder = new MediaMetadataCompat.Builder();
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_TITLE,
mMediaNotificationInfo.metadata.getTitle());
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST,
mMediaNotificationInfo.origin);
if (!TextUtils.isEmpty(mMediaNotificationInfo.metadata.getArtist())) {
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ARTIST,
mMediaNotificationInfo.metadata.getArtist());
}
if (!TextUtils.isEmpty(mMediaNotificationInfo.metadata.getAlbum())) {
metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM,
mMediaNotificationInfo.metadata.getAlbum());
}
if (mMediaNotificationInfo.mediaSessionImage != null) {
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART,
mMediaNotificationInfo.mediaSessionImage);
}
return metadataBuilder.build();
}
private void updateNotification() {
if (mService == null) return;
if (mMediaNotificationInfo == null) return;
updateMediaSession();
mNotificationBuilder = new NotificationCompat.Builder(mContext);
setMediaStyleLayoutForNotificationBuilder(mNotificationBuilder);
mNotificationBuilder.setSmallIcon(mMediaNotificationInfo.notificationSmallIcon);
mNotificationBuilder.setAutoCancel(false);
mNotificationBuilder.setLocalOnly(true);
mNotificationBuilder.setGroup(getNotificationGroupName());
mNotificationBuilder.setGroupSummary(true);
if (mMediaNotificationInfo.supportsSwipeAway()) {
mNotificationBuilder.setOngoing(!mMediaNotificationInfo.isPaused);
mNotificationBuilder.setDeleteIntent(createPendingIntent(ListenerService.ACTION_SWIPE));
}
// The intent will currently only be null when using a custom tab.
// TODO(avayvod) work out what we should do in this case. See https://crbug.com/585395.
if (mMediaNotificationInfo.contentIntent != null) {
mNotificationBuilder.setContentIntent(PendingIntent.getActivity(mContext,
mMediaNotificationInfo.tabId, mMediaNotificationInfo.contentIntent,
PendingIntent.FLAG_UPDATE_CURRENT));
// Set FLAG_UPDATE_CURRENT so that the intent extras is updated, otherwise the
// intent extras will stay the same for the same tab.
}
mNotificationBuilder.setVisibility(
mMediaNotificationInfo.isPrivate ? NotificationCompat.VISIBILITY_PRIVATE
: NotificationCompat.VISIBILITY_PUBLIC);
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 void updateMediaSession() {
if (!mMediaNotificationInfo.supportsPlayPause()) return;
if (mMediaSession == null) mMediaSession = createMediaSession();
activateAndroidMediaSession(mMediaNotificationInfo.tabId);
try {
// Tell the MediaRouter about the session, so that Chrome can control the volume
// on the remote cast device (if any).
// Pre-MR1 versions of JB do not have the complete MediaRouter APIs,
// so getting the MediaRouter instance will throw an exception.
MediaRouter.getInstance(mContext).setMediaSessionCompat(mMediaSession);
} catch (NoSuchMethodError e) {
// Do nothing. Chrome can't be casting without a MediaRouter, so there is nothing
// to do here.
}
mMediaSession.setMetadata(createMetadata());
PlaybackStateCompat.Builder playbackStateBuilder =
new PlaybackStateCompat.Builder().setActions(computeMediaSessionActions());
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());
}
private long computeMediaSessionActions() {
assert mMediaNotificationInfo != null;
long actions = PlaybackStateCompat.ACTION_PLAY | PlaybackStateCompat.ACTION_PAUSE;
if (mMediaNotificationInfo.mediaSessionActions.contains(
MediaSessionAction.PREVIOUS_TRACK)) {
actions |= PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
}
if (mMediaNotificationInfo.mediaSessionActions.contains(MediaSessionAction.NEXT_TRACK)) {
actions |= PlaybackStateCompat.ACTION_SKIP_TO_NEXT;
}
if (mMediaNotificationInfo.mediaSessionActions.contains(MediaSessionAction.SEEK_FORWARD)) {
actions |= PlaybackStateCompat.ACTION_FAST_FORWARD;
}
if (mMediaNotificationInfo.mediaSessionActions.contains(MediaSessionAction.SEEK_BACKWARD)) {
actions |= PlaybackStateCompat.ACTION_REWIND;
}
return actions;
}
private MediaSessionCompat createMediaSession() {
MediaSessionCompat mediaSession = new MediaSessionCompat(
mContext,
mContext.getString(R.string.app_name),
new ComponentName(mContext.getPackageName(),
getButtonReceiverClassName()),
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 void activateAndroidMediaSession(int tabId) {
if (mMediaNotificationInfo == null) return;
if (mMediaNotificationInfo.tabId != tabId) return;
if (!mMediaNotificationInfo.supportsPlayPause() || mMediaNotificationInfo.isPaused) return;
if (mMediaSession == null) return;
mMediaSession.setActive(true);
}
private void setMediaStyleLayoutForNotificationBuilder(NotificationCompat.Builder builder) {
setMediaStyleNotificationText(builder);
if (!mMediaNotificationInfo.supportsPlayPause()) {
builder.setLargeIcon(null);
} else if (mMediaNotificationInfo.notificationLargeIcon != null) {
builder.setLargeIcon(mMediaNotificationInfo.notificationLargeIcon);
} else if (!isRunningN()) {
if (mDefaultNotificationLargeIcon == null) {
int resourceId = (mMediaNotificationInfo.defaultNotificationLargeIcon != 0)
? mMediaNotificationInfo.defaultNotificationLargeIcon
: R.drawable.audio_playing_square;
mDefaultNotificationLargeIcon = scaleIconToIdealSize(
BitmapFactory.decodeResource(mContext.getResources(), resourceId));
}
builder.setLargeIcon(mDefaultNotificationLargeIcon);
}
// TODO(zqzhang): It's weird that setShowWhen() don't work on K. Calling setWhen() to force
// removing the time.
builder.setShowWhen(false).setWhen(0);
addNotificationButtons(builder);
}
private void addNotificationButtons(NotificationCompat.Builder builder) {
Set<Integer> actions = new HashSet<>();
// TODO(zqzhang): handle other actions when play/pause is not supported? See
// https://crbug.com/667500
if (mMediaNotificationInfo.supportsPlayPause()) {
actions.addAll(mMediaNotificationInfo.mediaSessionActions);
if (mMediaNotificationInfo.isPaused) {
actions.remove(MediaSessionAction.PAUSE);
actions.add(MediaSessionAction.PLAY);
} else {
actions.remove(MediaSessionAction.PLAY);
actions.add(MediaSessionAction.PAUSE);
}
}
if (mMediaNotificationInfo.supportsStop()) {
actions.add(CUSTOM_MEDIA_SESSION_ACTION_STOP);
}
List<Integer> bigViewActions = computeBigViewActions(actions);
for (int action : bigViewActions) {
MediaButtonInfo buttonInfo = mActionToButtonInfo.get(action);
builder.addAction(buttonInfo.iconResId,
mContext.getResources().getString(buttonInfo.descriptionResId),
createPendingIntent(buttonInfo.intentString));
}
// Only apply MediaStyle when NotificationInfo supports play/pause.
if (mMediaNotificationInfo.supportsPlayPause()) {
NotificationCompat.MediaStyle style = new NotificationCompat.MediaStyle();
style.setMediaSession(mMediaSession.getSessionToken());
int[] compactViewActionIndices = computeCompactViewActionIndices(bigViewActions);
style.setShowActionsInCompactView(compactViewActionIndices);
style.setCancelButtonIntent(createPendingIntent(ListenerService.ACTION_CANCEL));
style.setShowCancelButton(true);
builder.setStyle(style);
}
}
private Bitmap drawableToBitmap(Drawable drawable) {
if (!(drawable instanceof BitmapDrawable)) return null;
BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
return bitmapDrawable.getBitmap();
}
private void setMediaStyleNotificationText(NotificationCompat.Builder builder) {
builder.setContentTitle(mMediaNotificationInfo.metadata.getTitle());
String artistAndAlbumText = getArtistAndAlbumText(mMediaNotificationInfo.metadata);
if (isRunningN() || !artistAndAlbumText.isEmpty()) {
builder.setContentText(artistAndAlbumText);
builder.setSubText(mMediaNotificationInfo.origin);
} else {
// Leaving ContentText empty looks bad, so move origin up to the ContentText.
builder.setContentText(mMediaNotificationInfo.origin);
}
}
private String getArtistAndAlbumText(MediaMetadata metadata) {
String artist = (metadata.getArtist() == null) ? "" : metadata.getArtist();
String album = (metadata.getAlbum() == null) ? "" : metadata.getAlbum();
if (artist.isEmpty() || album.isEmpty()) {
return artist + album;
}
return artist + " - " + album;
}
/**
* Compute the actions to be shown in BigView media notification.
*
* The method assumes STOP cannot coexist with switch track actions and seeking actions. It also
* assumes PLAY and PAUSE cannot coexist.
*
* TODO(zqzhang): get UX feedback to decide which actions to select when the number of actions
* is greater than that can be displayed. See https://crbug.com/667500
*/
private List<Integer> computeBigViewActions(Set<Integer> actions) {
// STOP cannot coexist with switch track actions and seeking actions.
assert !actions.contains(CUSTOM_MEDIA_SESSION_ACTION_STOP)
|| !(actions.contains(MediaSessionAction.PREVIOUS_TRACK)
&& actions.contains(MediaSessionAction.NEXT_TRACK)
&& actions.contains(MediaSessionAction.SEEK_BACKWARD)
&& actions.contains(MediaSessionAction.SEEK_FORWARD));
// PLAY and PAUSE cannot coexist.
assert !actions.contains(MediaSessionAction.PLAY)
|| !actions.contains(MediaSessionAction.PAUSE);
int[] actionByOrder = {
MediaSessionAction.PREVIOUS_TRACK,
MediaSessionAction.SEEK_BACKWARD,
MediaSessionAction.PLAY,
MediaSessionAction.PAUSE,
MediaSessionAction.SEEK_FORWARD,
MediaSessionAction.NEXT_TRACK,
CUSTOM_MEDIA_SESSION_ACTION_STOP,
};
// First, select at most |BIG_VIEW_ACTIONS_COUNT| actions by priority.
Set<Integer> selectedActions;
if (actions.size() <= BIG_VIEW_ACTIONS_COUNT) {
selectedActions = actions;
} else {
selectedActions = new HashSet<>();
if (actions.contains(CUSTOM_MEDIA_SESSION_ACTION_STOP)) {
selectedActions.add(CUSTOM_MEDIA_SESSION_ACTION_STOP);
} else {
if (actions.contains(MediaSessionAction.PLAY)) {
selectedActions.add(MediaSessionAction.PLAY);
} else {
selectedActions.add(MediaSessionAction.PAUSE);
}
if (actions.contains(MediaSessionAction.PREVIOUS_TRACK)
&& actions.contains(MediaSessionAction.NEXT_TRACK)) {
selectedActions.add(MediaSessionAction.PREVIOUS_TRACK);
selectedActions.add(MediaSessionAction.NEXT_TRACK);
} else {
selectedActions.add(MediaSessionAction.SEEK_BACKWARD);
selectedActions.add(MediaSessionAction.SEEK_FORWARD);
}
}
}
// Second, sort the selected actions.
List<Integer> sortedActions = new ArrayList<>();
for (int action : actionByOrder) {
if (selectedActions.contains(action)) sortedActions.add(action);
}
return sortedActions;
}
/**
* Compute the actions to be shown in CompactView media notification.
*
* The method assumes STOP cannot coexist with switch track actions and seeking actions. It also
* assumes PLAY and PAUSE cannot coexist.
*
* Actions in pairs are preferred if there are more actions than |COMPACT_VIEW_ACTIONS_COUNT|.
*/
@VisibleForTesting
static int[] computeCompactViewActionIndices(List<Integer> actions) {
// STOP cannot coexist with switch track actions and seeking actions.
assert !actions.contains(CUSTOM_MEDIA_SESSION_ACTION_STOP)
|| !(actions.contains(MediaSessionAction.PREVIOUS_TRACK)
&& actions.contains(MediaSessionAction.NEXT_TRACK)
&& actions.contains(MediaSessionAction.SEEK_BACKWARD)
&& actions.contains(MediaSessionAction.SEEK_FORWARD));
// PLAY and PAUSE cannot coexist.
assert !actions.contains(MediaSessionAction.PLAY)
|| !actions.contains(MediaSessionAction.PAUSE);
if (actions.size() <= COMPACT_VIEW_ACTIONS_COUNT) {
// If the number of actions is less than |COMPACT_VIEW_ACTIONS_COUNT|, just return an
// array of 0, 1, ..., |actions.size()|-1.
int[] actionsArray = new int[actions.size()];
for (int i = 0; i < actions.size(); ++i) actionsArray[i] = i;
return actionsArray;
}
if (actions.contains(CUSTOM_MEDIA_SESSION_ACTION_STOP)) {
List<Integer> compactActions = new ArrayList<>();
if (actions.contains(MediaSessionAction.PLAY)) {
compactActions.add(actions.indexOf(MediaSessionAction.PLAY));
}
compactActions.add(actions.indexOf(CUSTOM_MEDIA_SESSION_ACTION_STOP));
return convertIntegerListToIntArray(compactActions);
}
int[] actionsArray = new int[COMPACT_VIEW_ACTIONS_COUNT];
if (actions.contains(MediaSessionAction.PREVIOUS_TRACK)
&& actions.contains(MediaSessionAction.NEXT_TRACK)) {
actionsArray[0] = actions.indexOf(MediaSessionAction.PREVIOUS_TRACK);
if (actions.contains(MediaSessionAction.PLAY)) {
actionsArray[1] = actions.indexOf(MediaSessionAction.PLAY);
} else {
actionsArray[1] = actions.indexOf(MediaSessionAction.PAUSE);
}
actionsArray[2] = actions.indexOf(MediaSessionAction.NEXT_TRACK);
return actionsArray;
}
assert actions.contains(MediaSessionAction.SEEK_BACKWARD)
&& actions.contains(MediaSessionAction.SEEK_FORWARD);
actionsArray[0] = actions.indexOf(MediaSessionAction.SEEK_BACKWARD);
if (actions.contains(MediaSessionAction.PLAY)) {
actionsArray[1] = actions.indexOf(MediaSessionAction.PLAY);
} else {
actionsArray[1] = actions.indexOf(MediaSessionAction.PAUSE);
}
actionsArray[2] = actions.indexOf(MediaSessionAction.SEEK_FORWARD);
return actionsArray;
}
static int[] convertIntegerListToIntArray(List<Integer> intList) {
int[] intArray = new int[intList.size()];
for (int i = 0; i < intList.size(); ++i) intArray[i] = i;
return intArray;
}
}