Java tutorial
// Copyright 2013 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.remote; import android.app.Notification; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.graphics.Bitmap; import android.os.Handler; import android.os.IBinder; import android.support.v4.app.NotificationCompat; import android.util.DisplayMetrics; import android.view.View; import android.widget.RemoteViews; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.R; import org.chromium.chrome.browser.media.remote.RemoteVideoInfo.PlayerState; import java.util.Set; import javax.annotation.Nullable; /** * A class for notifications that provide information and optional transport controls for a given * remote control. Internally implements a Service for transforming notification Intents into * {@link TransportControl.Listener} calls for all registered listeners. */ public class NotificationTransportControl extends TransportControl implements MediaRouteController.UiListener { /** * Service used to transform intent requests triggered from the notification into * {@code Listener} callbacks. Ideally this class should be protected, but public is required * to create as a service. */ public static class ListenerService extends Service { private static final String ACTION_PREFIX = ListenerService.class.getName() + "."; // Constants used by intent actions public static final int ACTION_ID_PLAY = 0; public static final int ACTION_ID_PAUSE = 1; public static final int ACTION_ID_SEEK = 2; public static final int ACTION_ID_STOP = 3; public static final int ACTION_ID_SELECT = 4; // Intent parameters public static final String SEEK_POSITION = "SEEK_POSITION"; // Must be kept in sync with the ACTION_ID_XXX constants above private static final String[] ACTION_VERBS = { "PLAY", "PAUSE", "SEEK", "STOP", "SELECT" }; private PendingIntent[] mPendingIntents; private Notification mNotification; @VisibleForTesting PendingIntent getPendingIntent(int id) { return mPendingIntents[id]; } @Override public IBinder onBind(Intent intent) { return null; } @Override public void onCreate() { checkState(sInstance != null); checkState(sInstance.mService == null); super.onCreate(); sInstance.mService = this; // Create all the PendingIntents int actionCount = ACTION_VERBS.length; mPendingIntents = new PendingIntent[actionCount]; for (int i = 0; i < actionCount; ++i) { Intent intent = new Intent(this, ListenerService.class); intent.setAction(ACTION_PREFIX + ACTION_VERBS[i]); mPendingIntents[i] = PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT); } } @Override public void onDestroy() { checkState(sInstance != null); checkState(sInstance.mService != null); stop(); sInstance.mService = null; } @Override public void onTaskRemoved(Intent rootIntent) { stop(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { checkState(sInstance != null); if (intent != null) { String action = intent.getAction(); if (action != null && action.startsWith(ACTION_PREFIX)) { Set<Listener> listeners = sInstance.getListeners(); // Strip the prefix for matching the verb action = action.substring(ACTION_PREFIX.length()); if (ACTION_VERBS[ACTION_ID_PLAY].equals(action)) { for (Listener listener : listeners) { listener.onPlay(); } } else if (ACTION_VERBS[ACTION_ID_PAUSE].equals(action)) { for (Listener listener : listeners) { listener.onPause(); } } else if (ACTION_VERBS[ACTION_ID_SEEK].equals(action)) { int seekPosition = intent.getIntExtra(SEEK_POSITION, 0); for (Listener listener : listeners) { listener.onSeek(seekPosition); } } else if (ACTION_VERBS[ACTION_ID_STOP].equals(action)) { stop(); } else if (ACTION_VERBS[ACTION_ID_SELECT].equals(action)) { ExpandedControllerActivity.startActivity(this); } } } updateNotification(); return START_NOT_STICKY; } private void stop() { for (Listener listener : sInstance.getListeners()) listener.onStop(); stopSelf(); } private void createNotification() { NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this).setDefaults(0) .setSmallIcon(R.drawable.ic_notification_media_route).setLocalOnly(true) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC).setOnlyAlertOnce(true).setOngoing(true) .setContent(createContentView()) .setContentIntent(getPendingIntent(ListenerService.ACTION_ID_SELECT)) .setDeleteIntent(getPendingIntent(ListenerService.ACTION_ID_STOP)); mNotification = notificationBuilder.build(); } private void updateNotification() { RemoteVideoInfo videoInfo = sInstance.getVideoInfo(); if (!sInstance.isShowing() || videoInfo == null || videoInfo.state == PlayerState.STOPPED || videoInfo.state == PlayerState.FINISHED || videoInfo.state == PlayerState.INVALIDATED) { stopForeground(true); mNotification = null; } else { if (mNotification == null) createNotification(); RemoteViews contentView = createContentView(); contentView.setTextViewText(R.id.title, sInstance.getScreenName()); contentView.setTextViewText(R.id.status, getStatus()); if (sInstance.mIcon != null) { contentView.setImageViewBitmap(R.id.icon, sInstance.mIcon); } else { contentView.setImageViewResource(R.id.icon, R.drawable.ic_notification_media_route); } boolean showPlayPause = false; boolean showProgress = false; switch (videoInfo.state) { case PLAYING: showProgress = true; showPlayPause = true; contentView.setProgressBar(R.id.progress, videoInfo.durationMillis, videoInfo.currentTimeMillis, false); contentView.setImageViewResource(R.id.playpause, R.drawable.ic_vidcontrol_pause); contentView.setContentDescription(R.id.playpause, sInstance.mPauseDescription); contentView.setOnClickPendingIntent(R.id.playpause, getPendingIntent(ListenerService.ACTION_ID_PAUSE)); scheduleProgressUpdate(); break; case PAUSED: showProgress = true; showPlayPause = true; contentView.setProgressBar(R.id.progress, videoInfo.durationMillis, videoInfo.currentTimeMillis, false); contentView.setImageViewResource(R.id.playpause, R.drawable.ic_vidcontrol_play); contentView.setContentDescription(R.id.playpause, sInstance.mPlayDescription); contentView.setOnClickPendingIntent(R.id.playpause, getPendingIntent(ListenerService.ACTION_ID_PLAY)); break; case LOADING: showProgress = true; contentView.setProgressBar(R.id.progress, 0, 0, true); break; case ERROR: showProgress = true; break; default: break; } contentView.setViewVisibility(R.id.playpause, showPlayPause ? View.VISIBLE : View.INVISIBLE); // We use GONE instead of INVISIBLE for this because the notification looks funny // with a large gap in the middle if we have no duration. Setting it to GONE forces // the layout to squeeze tighter to the middle. contentView.setViewVisibility(R.id.progress, (showProgress && videoInfo.durationMillis > 0) ? View.VISIBLE : View.GONE); contentView.setViewVisibility(R.id.stop, showPlayPause ? View.VISIBLE : View.INVISIBLE); mNotification.contentView = contentView; startForeground(R.id.remote_notification, mNotification); } } private RemoteViews createContentView() { RemoteViews contentView = new RemoteViews(getPackageName(), R.layout.remote_notification_bar); contentView.setOnClickPendingIntent(R.id.stop, getPendingIntent(ListenerService.ACTION_ID_STOP)); return contentView; } private String getStatus() { if (sInstance.hasError()) return sInstance.getError(); RemoteVideoInfo videoInfo = sInstance.getVideoInfo(); // TODO(bclayton): Is there something better to display here? if (videoInfo == null) return ""; String videoTitle = videoInfo.title; switch (videoInfo.state) { case PLAYING: return videoTitle != null ? getString(R.string.cast_notification_playing_for_video, videoTitle) : getString(R.string.cast_notification_playing); case LOADING: return videoTitle != null ? getString(R.string.cast_notification_loading_for_video, videoTitle) : getString(R.string.cast_notification_loading); case PAUSED: return videoTitle != null ? getString(R.string.cast_notification_paused_for_video, videoTitle) : getString(R.string.cast_notification_paused); case STOPPED: return getString(R.string.cast_notification_stopped); case FINISHED: case INVALIDATED: return videoTitle != null ? getString(R.string.cast_notification_finished_for_video, videoTitle) : getString(R.string.cast_notification_finished); case ERROR: default: return videoInfo.errorMessage; } } private void scheduleProgressUpdate() { sInstance.mHandler.postDelayed(new Runnable() { @Override public void run() { sInstance.onPositionChanged(sInstance.mMediaRouteController.getPosition()); } }, sInstance.mProgressUpdateInterval); } } private static NotificationTransportControl sInstance = null; private static final Object LOCK = new Object(); private static final int MINIMUM_PROGRESS_UPDATE_INTERVAL_MS = 1000; private boolean mShowing = false; private final String mPlayDescription; private final String mPauseDescription; private final Context mContext; private MediaRouteController mMediaRouteController; // ListenerService running for the notification. private ListenerService mService; private Bitmap mIcon; private Handler mHandler; private int mProgressUpdateInterval = MINIMUM_PROGRESS_UPDATE_INTERVAL_MS; /** * Returns the singleton NotificationTransportControl object. * * @param context The Context that the notification service needs to be created in. * @param mrc The MediaRouteController object to use. * @return A {@code NotificationTransportControl} object that uses the given * MediaRouteController object. */ public static NotificationTransportControl getOrCreate(Context context, @Nullable MediaRouteController mrc) { synchronized (LOCK) { if (sInstance == null) { sInstance = new NotificationTransportControl(context); sInstance.setVideoInfo(new RemoteVideoInfo(null, 0, RemoteVideoInfo.PlayerState.STOPPED, 0, null)); } sInstance.setMediaRouteController(mrc); return sInstance; } } @VisibleForTesting static NotificationTransportControl getIfExists() { return sInstance; } /** * Ensures the truth of an expression involving the state of the calling instance, but not * involving any parameters to the calling method. * * @param expression a boolean expression * @throws IllegalStateException if {@code expression} is false */ private static void checkState(boolean expression) { if (!expression) { throw new IllegalStateException(); } } /** * Scale the specified bitmap to the desired with and height while preserving aspect ratio. */ private Bitmap scaleBitmap(Bitmap bitmap, int maxWidth, int maxHeight) { if (bitmap == null) { return null; } float scaleX = 1.0f; float scaleY = 1.0f; if (bitmap.getWidth() > maxWidth) { scaleX = maxWidth / (float) bitmap.getWidth(); } if (bitmap.getHeight() > maxHeight) { scaleY = maxHeight / (float) bitmap.getHeight(); } float scale = Math.min(scaleX, scaleY); int width = (int) (bitmap.getWidth() * scale); int height = (int) (bitmap.getHeight() * scale); return Bitmap.createScaledBitmap(bitmap, width, height, false); } private NotificationTransportControl(Context context) { this.mContext = context; mHandler = new Handler(context.getMainLooper()); mPlayDescription = context.getResources().getString(R.string.accessibility_play); mPauseDescription = context.getResources().getString(R.string.accessibility_pause); } @Override public void hide() { mShowing = false; mContext.stopService(new Intent(mContext, ListenerService.class)); } /** * @return true if the notification is currently visible to the user. */ @VisibleForTesting boolean isShowing() { return mShowing; } @Override public void onDurationUpdated(int durationMillis) { // Set the progress update interval based on the screen height/width, since there's no point // in updating the progress bar more frequently than what the user can see. // getDisplayMetrics() is dependent on the current orientation, so we need to get the max // of both height and width so that both portrait and landscape notifications are covered. DisplayMetrics metrics = mContext.getResources().getDisplayMetrics(); float density = metrics.density; float dpHeight = metrics.heightPixels / density; float dpWidth = metrics.widthPixels / density; float maxDimen = Math.max(dpHeight, dpWidth); mProgressUpdateInterval = Math.max(MINIMUM_PROGRESS_UPDATE_INTERVAL_MS, Math.round(durationMillis / maxDimen)); RemoteVideoInfo videoInfo = new RemoteVideoInfo(getVideoInfo()); videoInfo.durationMillis = durationMillis; setVideoInfo(videoInfo); } @Override public void onError(int error, String errorMessage) { // Stop the session for all errors hide(); } @Override public void onPlaybackStateChanged(PlayerState oldState, PlayerState newState) { RemoteVideoInfo videoInfo = new RemoteVideoInfo(getVideoInfo()); videoInfo.state = newState; setVideoInfo(videoInfo); } @Override public void onPositionChanged(int positionMillis) { RemoteVideoInfo videoInfo = new RemoteVideoInfo(getVideoInfo()); videoInfo.currentTimeMillis = positionMillis; setVideoInfo(videoInfo); } @Override public void onPrepared(MediaRouteController mediaRouteController) { } @Override public void onRouteSelected(String name, MediaRouteController mediaRouteController) { setScreenName(name); } @Override public void onRouteUnselected(MediaRouteController mediaRouteController) { hide(); } @Override public void onTitleChanged(String title) { RemoteVideoInfo videoInfo = new RemoteVideoInfo(getVideoInfo()); videoInfo.title = title; setVideoInfo(videoInfo); } @Override public void setRouteController(MediaRouteController controller) { setMediaRouteController(controller); } @Override public void show(PlayerState initialState) { mShowing = true; RemoteVideoInfo newVideoInfo = new RemoteVideoInfo(mVideoInfo); newVideoInfo.state = initialState; setVideoInfo(newVideoInfo); } @VisibleForTesting Notification getNotification() { // TODO(aberent). This is only used by the tests, which should actually simply be using // isShowing(); but the tests are still downstream, so this needs to be changed in stages. if (getService() == null) return null; return getService().mNotification; } @VisibleForTesting final ListenerService getService() { // TODO(aberent). This is only used by the tests, which should actually simply be using // isShowing(); but the tests are still downstream, so this needs to be changed in stages. if (!isShowing()) return null; return mService; } @Override protected void onErrorChanged() { mContext.startService(new Intent(mContext, ListenerService.class)); } @Override protected void onPosterBitmapChanged() { Bitmap posterBitmap = getPosterBitmap(); mIcon = scaleBitmapForIcon(posterBitmap); super.onPosterBitmapChanged(); } @Override protected void onScreenNameChanged() { mContext.startService(new Intent(mContext, ListenerService.class)); } @Override protected void onVideoInfoChanged() { mContext.startService(new Intent(mContext, ListenerService.class)); } /** * Scale the specified bitmap to properly fit as a notification icon. If the argument is null * the function returns null. */ private Bitmap scaleBitmapForIcon(Bitmap bitmap) { Resources res = mContext.getResources(); float maxWidth = res.getDimension(R.dimen.remote_notification_logo_max_width); float maxHeight = res.getDimension(R.dimen.remote_notification_logo_max_height); return scaleBitmap(bitmap, (int) maxWidth, (int) maxHeight); } /** * Sets the MediaRouteController the notification should be using to get the data from. * * @param mrc the MediaRouteController object to use. If null, the previous MediaRouteController * object will not be overwritten. */ private void setMediaRouteController(@Nullable MediaRouteController mrc) { if (mrc == null || mMediaRouteController == mrc) return; if (mMediaRouteController != null) { mMediaRouteController.removeUiListener(this); } mMediaRouteController = mrc; mMediaRouteController.addUiListener(this); } }