/*
* Copyright (C) 2015 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.libraries.cast.companionlibrary.notification;
import static com.google.android.libraries.cast.companionlibrary.utils.LogUtils.LOGD;
import static com.google.android.libraries.cast.companionlibrary.utils.LogUtils.LOGE;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaMetadata;
import com.google.android.gms.cast.MediaQueueItem;
import com.google.android.gms.cast.MediaStatus;
import com.google.android.libraries.cast.companionlibrary.R;
import com.google.android.libraries.cast.companionlibrary.cast.CastConfiguration;
import com.google.android.libraries.cast.companionlibrary.cast.MediaQueue;
import com.google.android.libraries.cast.companionlibrary.cast.VideoCastManager;
import com.google.android.libraries.cast.companionlibrary.cast.callbacks.VideoCastConsumerImpl;
import com.google.android.libraries.cast.companionlibrary.cast.exceptions.CastException;
import com.google.android.libraries.cast.companionlibrary.cast.exceptions.NoConnectionException;
import com.google.android.libraries.cast.companionlibrary.cast.exceptions
.TransientNetworkDisconnectionException;
import com.google.android.libraries.cast.companionlibrary.remotecontrol.VideoIntentReceiver;
import com.google.android.libraries.cast.companionlibrary.utils.FetchBitmapTask;
import com.google.android.libraries.cast.companionlibrary.utils.LogUtils;
import com.google.android.libraries.cast.companionlibrary.utils.Utils;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import android.support.v4.app.TaskStackBuilder;
import android.support.v7.app.NotificationCompat;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* A service to provide status bar Notifications when we are casting. For JB+ versions,
* notification area supports a number of actions such as play/pause toggle or an "x" button to
* disconnect but that for GB, these actions are not supported that due to the framework
* limitations.
*/
public class VideoCastNotificationService extends Service {
private static final String TAG = LogUtils.makeLogTag(VideoCastNotificationService.class);
public static final String ACTION_FORWARD =
"com.google.android.libraries.cast.companionlibrary.action.forward";
public static final String ACTION_REWIND =
"com.google.android.libraries.cast.companionlibrary.action.rewind";
public static final String ACTION_TOGGLE_PLAYBACK =
"com.google.android.libraries.cast.companionlibrary.action.toggleplayback";
public static final String ACTION_PLAY_NEXT =
"com.google.android.libraries.cast.companionlibrary.action.playnext";
public static final String ACTION_PLAY_PREV =
"com.google.android.libraries.cast.companionlibrary.action.playprev";
public static final String ACTION_STOP =
"com.google.android.libraries.cast.companionlibrary.action.stop";
public static final String ACTION_VISIBILITY =
"com.google.android.libraries.cast.companionlibrary.action.notificationvisibility";
public static final String EXTRA_FORWARD_STEP_MS = "ccl_extra_forward_step_ms";
protected static final int NOTIFICATION_ID = 1;
public static final String NOTIFICATION_VISIBILITY = "visible";
private static final long TEN_SECONDS_MILLIS = TimeUnit.SECONDS.toMillis(10);
private static final long THIRTY_SECONDS_MILLIS = TimeUnit.SECONDS.toMillis(30);
private Bitmap mVideoArtBitmap;
private boolean mIsPlaying;
private Class<?> mTargetActivity;
private int mOldStatus = -1;
protected Notification mNotification;
private boolean mVisible;
protected VideoCastManager mCastManager;
private VideoCastConsumerImpl mConsumer;
private FetchBitmapTask mBitmapDecoderTask;
private int mDimensionInPixels;
private boolean mHasNext;
private boolean mHasPrev;
private List<Integer> mNotificationActions;
private int[] mNotificationCompactActionsArray;
private long mForwardTimeInMillis;
@Override
public void onCreate() {
super.onCreate();
mDimensionInPixels = Utils.convertDpToPixel(VideoCastNotificationService.this,
getResources().getDimension(R.dimen.ccl_notification_image_size));
mCastManager = VideoCastManager.getInstance();
readPersistedData();
if (!mCastManager.isConnected() && !mCastManager.isConnecting()) {
mCastManager.reconnectSessionIfPossible();
}
MediaQueue mediaQueue = mCastManager.getMediaQueue();
if (mediaQueue != null) {
int position = mediaQueue.getCurrentItemPosition();
int size = mediaQueue.getCount();
mHasNext = position < (size - 1);
mHasPrev = position > 0;
}
mConsumer = new VideoCastConsumerImpl() {
@Override
public void onApplicationDisconnected(int errorCode) {
LOGD(TAG, "onApplicationDisconnected() was reached, stopping the notification"
+ " service");
stopSelf();
}
@Override
public void onDisconnected() {
stopSelf();
}
@Override
public void onRemoteMediaPlayerStatusUpdated() {
int mediaStatus = mCastManager.getPlaybackStatus();
VideoCastNotificationService.this.onRemoteMediaPlayerStatusUpdated(mediaStatus);
}
@Override
public void onUiVisibilityChanged(boolean visible) {
mVisible = !visible;
if (mNotification == null) {
try {
setUpNotification(mCastManager.getRemoteMediaInformation());
} catch (TransientNetworkDisconnectionException | NoConnectionException e) {
LOGE(TAG, "onStartCommand() failed to get media", e);
}
}
if (mVisible && (mNotification != null)) {
startForeground(NOTIFICATION_ID, mNotification);
} else {
stopForeground(true);
}
}
@Override
public void onMediaQueueUpdated(List<MediaQueueItem> queueItems, MediaQueueItem item,
int repeatMode, boolean shuffle) {
int size = 0;
int position = 0;
if (queueItems != null) {
size = queueItems.size();
position = queueItems.indexOf(item);
}
mHasNext = position < (size - 1);
mHasPrev = position > 0;
}
};
mCastManager.addVideoCastConsumer(mConsumer);
mNotificationActions = mCastManager.getCastConfiguration().getNotificationActions();
List<Integer> notificationCompactActions = mCastManager.getCastConfiguration()
.getNotificationCompactActions();
if (notificationCompactActions != null) {
mNotificationCompactActionsArray = new int[notificationCompactActions.size()];
for (int i = 0; i < notificationCompactActions.size(); i++) {
mNotificationCompactActionsArray[i] = notificationCompactActions.get(i);
}
}
mForwardTimeInMillis = TimeUnit.SECONDS
.toMillis(mCastManager.getCastConfiguration().getForwardStep());
}
@Override
public IBinder onBind(Intent arg0) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
LOGD(TAG, "onStartCommand");
if (intent != null) {
String action = intent.getAction();
if (ACTION_VISIBILITY.equals(action)) {
mVisible = intent.getBooleanExtra(NOTIFICATION_VISIBILITY, false);
LOGD(TAG, "onStartCommand(): Action: ACTION_VISIBILITY " + mVisible);
onRemoteMediaPlayerStatusUpdated(mCastManager.getPlaybackStatus());
if (mNotification == null) {
try {
setUpNotification(mCastManager.getRemoteMediaInformation());
} catch (TransientNetworkDisconnectionException | NoConnectionException e) {
LOGE(TAG, "onStartCommand() failed to get media", e);
}
}
if (mVisible && mNotification != null) {
startForeground(NOTIFICATION_ID, mNotification);
} else {
stopForeground(true);
}
}
}
return Service.START_STICKY;
}
private void setUpNotification(final MediaInfo info)
throws TransientNetworkDisconnectionException, NoConnectionException {
if (info == null) {
return;
}
if (mBitmapDecoderTask != null) {
mBitmapDecoderTask.cancel(false);
}
Uri imgUri = null;
try {
if (!info.getMetadata().hasImages()) {
build(info, null, mIsPlaying);
return;
} else {
imgUri = info.getMetadata().getImages().get(0).getUrl();
}
} catch (CastException e) {
LOGE(TAG, "Failed to build notification", e);
}
mBitmapDecoderTask = new FetchBitmapTask() {
@Override
protected void onPostExecute(Bitmap bitmap) {
try {
mVideoArtBitmap = Utils.scaleAndCenterCropBitmap(bitmap, mDimensionInPixels,
mDimensionInPixels);
build(info, mVideoArtBitmap, mIsPlaying);
} catch (CastException | NoConnectionException
| TransientNetworkDisconnectionException e) {
LOGE(TAG, "Failed to set notification for " + info.toString(), e);
}
if (mVisible && (mNotification != null)) {
startForeground(NOTIFICATION_ID, mNotification);
}
if (this == mBitmapDecoderTask) {
mBitmapDecoderTask = null;
}
}
};
mBitmapDecoderTask.execute(imgUri);
}
/**
* Removes the existing notification.
*/
private void removeNotification() {
((NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE)).
cancel(NOTIFICATION_ID);
}
protected void onRemoteMediaPlayerStatusUpdated(int mediaStatus) {
if (mOldStatus == mediaStatus) {
// not need to make any updates here
return;
}
mOldStatus = mediaStatus;
LOGD(TAG, "onRemoteMediaPlayerStatusUpdated() reached with status: " + mediaStatus);
try {
switch (mediaStatus) {
case MediaStatus.PLAYER_STATE_BUFFERING: // (== 4)
mIsPlaying = false;
setUpNotification(mCastManager.getRemoteMediaInformation());
break;
case MediaStatus.PLAYER_STATE_PLAYING: // (== 2)
mIsPlaying = true;
setUpNotification(mCastManager.getRemoteMediaInformation());
break;
case MediaStatus.PLAYER_STATE_PAUSED: // (== 3)
mIsPlaying = false;
setUpNotification(mCastManager.getRemoteMediaInformation());
break;
case MediaStatus.PLAYER_STATE_IDLE: // (== 1)
mIsPlaying = false;
if (!mCastManager.shouldRemoteUiBeVisible(mediaStatus,
mCastManager.getIdleReason())) {
stopForeground(true);
} else {
setUpNotification(mCastManager.getRemoteMediaInformation());
}
break;
case MediaStatus.PLAYER_STATE_UNKNOWN: // (== 0)
mIsPlaying = false;
stopForeground(true);
break;
default:
break;
}
} catch (TransientNetworkDisconnectionException | NoConnectionException e) {
LOGE(TAG, "Failed to update the playback status due to network issues", e);
}
}
/*
* (non-Javadoc)
* @see android.app.Service#onDestroy()
*/
@Override
public void onDestroy() {
if (mBitmapDecoderTask != null) {
mBitmapDecoderTask.cancel(false);
}
removeNotification();
if (mCastManager != null && mConsumer != null) {
mCastManager.removeVideoCastConsumer(mConsumer);
mCastManager = null;
}
}
/**
* Build the MediaStyle notification. The action that are added to this notification are
* selected by the client application from a pre-defined set of actions
*
* @see CastConfiguration.Builder#addNotificationAction(int, boolean)
**/
protected void build(MediaInfo info, Bitmap bitmap, boolean isPlaying)
throws CastException, TransientNetworkDisconnectionException, NoConnectionException {
// Media metadata
MediaMetadata metadata = info.getMetadata();
String castingTo = getResources().getString(R.string.ccl_casting_to_device,
mCastManager.getDeviceName());
NotificationCompat.Builder builder
= (NotificationCompat.Builder) new NotificationCompat.Builder(this)
.setSmallIcon(R.drawable.ic_stat_action_notification)
.setContentTitle(metadata.getString(MediaMetadata.KEY_TITLE))
.setContentText(castingTo)
.setContentIntent(getContentIntent(info))
.setLargeIcon(bitmap)
.setStyle(new NotificationCompat.MediaStyle()
.setShowActionsInCompactView(mNotificationCompactActionsArray)
.setMediaSession(mCastManager.getMediaSessionCompatToken()))
.setOngoing(true)
.setShowWhen(false)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
for (Integer notificationType : mNotificationActions) {
switch (notificationType) {
case CastConfiguration.NOTIFICATION_ACTION_DISCONNECT:
builder.addAction(getDisconnectAction());
break;
case CastConfiguration.NOTIFICATION_ACTION_PLAY_PAUSE:
builder.addAction(getPlayPauseAction(info, isPlaying));
break;
case CastConfiguration.NOTIFICATION_ACTION_SKIP_NEXT:
builder.addAction(getSkipNextAction());
break;
case CastConfiguration.NOTIFICATION_ACTION_SKIP_PREVIOUS:
builder.addAction(getSkipPreviousAction());
break;
case CastConfiguration.NOTIFICATION_ACTION_FORWARD:
builder.addAction(getForwardAction(mForwardTimeInMillis));
break;
case CastConfiguration.NOTIFICATION_ACTION_REWIND:
builder.addAction(getRewindAction(mForwardTimeInMillis));
break;
}
}
mNotification = builder.build();
}
/**
* Returns the {@link NotificationCompat.Action} for forwarding the current media by
* {@code millis} milliseconds.
*/
protected NotificationCompat.Action getForwardAction(long millis) {
Intent intent = new Intent(this, VideoIntentReceiver.class);
intent.setAction(ACTION_FORWARD);
intent.setPackage(getPackageName());
intent.putExtra(EXTRA_FORWARD_STEP_MS, (int) millis);
PendingIntent pendingIntent = PendingIntent
.getBroadcast(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
int iconResourceId = R.drawable.ic_notification_forward_48dp;
if (millis == TEN_SECONDS_MILLIS) {
iconResourceId = R.drawable.ic_notification_forward10_48dp;
} else if (millis == THIRTY_SECONDS_MILLIS) {
iconResourceId = R.drawable.ic_notification_forward30_48dp;
}
return new NotificationCompat.Action.Builder(iconResourceId,
getString(R.string.ccl_forward), pendingIntent).build();
}
/**
* Returns the {@link NotificationCompat.Action} for rewinding the current media by
* {@code millis} milliseconds.
*/
protected NotificationCompat.Action getRewindAction(long millis) {
Intent intent = new Intent(this, VideoIntentReceiver.class);
intent.setAction(ACTION_REWIND);
intent.setPackage(getPackageName());
intent.putExtra(EXTRA_FORWARD_STEP_MS, (int)-millis);
PendingIntent pendingIntent = PendingIntent
.getBroadcast(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT);
int iconResourceId = R.drawable.ic_notification_rewind_48dp;
if (millis == TEN_SECONDS_MILLIS) {
iconResourceId = R.drawable.ic_notification_rewind10_48dp;
} else if (millis == THIRTY_SECONDS_MILLIS) {
iconResourceId = R.drawable.ic_notification_rewind30_48dp;
}
return new NotificationCompat.Action.Builder(iconResourceId,
getString(R.string.ccl_rewind), pendingIntent).build();
}
/**
* Returns the {@link NotificationCompat.Action} for skipping to the next item in the queue. If
* we are already at the end of the queue, we show a dimmed version of the icon for this action
* and won't send any {@link PendingIntent}
*/
protected NotificationCompat.Action getSkipNextAction() {
PendingIntent pendingIntent = null;
int iconResourceId = R.drawable.ic_notification_skip_next_semi_48dp;
if (mHasNext) {
Intent intent = new Intent(this, VideoIntentReceiver.class);
intent.setAction(ACTION_PLAY_NEXT);
intent.setPackage(getPackageName());
pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0);
iconResourceId = R.drawable.ic_notification_skip_next_48dp;
}
return new NotificationCompat.Action.Builder(iconResourceId,
getString(R.string.ccl_skip_next), pendingIntent).build();
}
/**
* Returns the {@link NotificationCompat.Action} for skipping to the previous item in the queue.
* If we are already at the beginning of the queue, we show a dimmed version of the icon for
* this action and won't send any {@link PendingIntent}
*/
protected NotificationCompat.Action getSkipPreviousAction() {
PendingIntent pendingIntent = null;
int iconResourceId = R.drawable.ic_notification_skip_prev_semi_48dp;
if (mHasPrev) {
Intent intent = new Intent(this, VideoIntentReceiver.class);
intent.setAction(ACTION_PLAY_PREV);
intent.setPackage(getPackageName());
pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0);
iconResourceId = R.drawable.ic_notification_skip_prev_48dp;
}
return new NotificationCompat.Action.Builder(iconResourceId,
getString(R.string.ccl_skip_previous), pendingIntent).build();
}
/**
* Returns the {@link NotificationCompat.Action} for toggling play/pause/stop of the currently
* playing item.
*/
protected NotificationCompat.Action getPlayPauseAction(MediaInfo info, boolean isPlaying) {
int pauseOrStopResourceId;
if (info.getStreamType() == MediaInfo.STREAM_TYPE_LIVE) {
pauseOrStopResourceId = R.drawable.ic_notification_stop_48dp;
} else {
pauseOrStopResourceId = R.drawable.ic_notification_pause_48dp;
}
int pauseOrPlayTextResourceId = isPlaying ? R.string.ccl_pause : R.string.ccl_play;
int pauseOrPlayResourceId = isPlaying ? pauseOrStopResourceId
: R.drawable.ic_notification_play_48dp;
Intent intent = new Intent(this, VideoIntentReceiver.class);
intent.setAction(ACTION_TOGGLE_PLAYBACK);
intent.setPackage(getPackageName());
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0);
return new NotificationCompat.Action.Builder(pauseOrPlayResourceId,
getString(pauseOrPlayTextResourceId), pendingIntent).build();
}
/**
* Returns the {@link NotificationCompat.Action} for disconnecting this app from the cast
* device.
*/
protected NotificationCompat.Action getDisconnectAction() {
Intent intent = new Intent(this, VideoIntentReceiver.class);
intent.setAction(ACTION_STOP);
intent.setPackage(getPackageName());
PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0);
return new NotificationCompat.Action.Builder(R.drawable.ic_notification_disconnect_24dp,
getString(R.string.ccl_disconnect), pendingIntent).build();
}
/**
* Returns the {@link PendingIntent} for showing the full screen cast controller page. We also
* build an appropriate "back stack" so that when user is sent to that full screen controller,
* clicking on the Back button would allow navigation into the app.
*/
protected PendingIntent getContentIntent(MediaInfo mediaInfo) {
Bundle mediaWrapper = Utils.mediaInfoToBundle(mediaInfo);
Intent contentIntent = new Intent(this, mTargetActivity);
contentIntent.putExtra(VideoCastManager.EXTRA_MEDIA, mediaWrapper);
TaskStackBuilder stackBuilder = TaskStackBuilder.create(this);
stackBuilder.addParentStack(mTargetActivity);
stackBuilder.addNextIntent(contentIntent);
if (stackBuilder.getIntentCount() > 1) {
stackBuilder.editIntentAt(1).putExtra(VideoCastManager.EXTRA_MEDIA, mediaWrapper);
}
return stackBuilder.getPendingIntent(NOTIFICATION_ID, PendingIntent.FLAG_UPDATE_CURRENT);
}
/*
* Reads application ID and target activity from preference storage.
*/
private void readPersistedData() {
mTargetActivity = mCastManager.getCastConfiguration().getTargetActivity();
if (mTargetActivity == null) {
mTargetActivity = VideoCastManager.DEFAULT_TARGET_ACTIVITY;
}
}
}