1 /* 2 * Copyright (C) 2014 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.music.utils; 18 19 import android.app.Notification; 20 import android.app.NotificationManager; 21 import android.app.PendingIntent; 22 import android.content.BroadcastReceiver; 23 import android.content.Context; 24 import android.content.Intent; 25 import android.content.IntentFilter; 26 import android.graphics.Bitmap; 27 import android.graphics.BitmapFactory; 28 import android.graphics.Color; 29 import android.media.MediaDescription; 30 import android.media.MediaMetadata; 31 import android.media.session.MediaController; 32 import android.media.session.MediaSession; 33 import android.media.session.PlaybackState; 34 import android.service.media.MediaBrowserService; 35 import android.util.Log; 36 import com.android.music.MediaPlaybackService; 37 import com.android.music.R; 38 39 /** 40 * Keeps track of a notification and updates it automatically for a given 41 * MediaSession. Maintaining a visible notification (usually) guarantees that the music service 42 * won't be killed during playback. 43 */ 44 public class MediaNotificationManager extends BroadcastReceiver { 45 private static final String TAG = LogHelper.makeLogTag(MediaNotificationManager.class); 46 47 private static final int NOTIFICATION_ID = 412; 48 private static final int REQUEST_CODE = 100; 49 50 public static final String ACTION_PAUSE = "com.android.music.pause"; 51 public static final String ACTION_PLAY = "com.android.music.play"; 52 public static final String ACTION_PREV = "com.android.music.prev"; 53 public static final String ACTION_NEXT = "com.android.music.next"; 54 55 private final MediaPlaybackService mService; 56 private MediaSession.Token mSessionToken; 57 private MediaController mController; 58 private MediaController.TransportControls mTransportControls; 59 60 private PlaybackState mPlaybackState; 61 private MediaMetadata mMetadata; 62 63 private NotificationManager mNotificationManager; 64 65 private PendingIntent mPauseIntent; 66 private PendingIntent mPlayIntent; 67 private PendingIntent mPreviousIntent; 68 private PendingIntent mNextIntent; 69 70 private int mNotificationColor; 71 72 private boolean mStarted = false; 73 74 public MediaNotificationManager(MediaPlaybackService service) { 75 mService = service; 76 updateSessionToken(); 77 78 mNotificationColor = 79 ResourceHelper.getThemeColor(mService, android.R.attr.colorPrimary, Color.DKGRAY); 80 81 mNotificationManager = 82 (NotificationManager) mService.getSystemService(Context.NOTIFICATION_SERVICE); 83 84 String pkg = mService.getPackageName(); 85 mPauseIntent = PendingIntent.getBroadcast(mService, REQUEST_CODE, 86 new Intent(ACTION_PAUSE).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); 87 mPlayIntent = PendingIntent.getBroadcast(mService, REQUEST_CODE, 88 new Intent(ACTION_PLAY).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); 89 mPreviousIntent = PendingIntent.getBroadcast(mService, REQUEST_CODE, 90 new Intent(ACTION_PREV).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); 91 mNextIntent = PendingIntent.getBroadcast(mService, REQUEST_CODE, 92 new Intent(ACTION_NEXT).setPackage(pkg), PendingIntent.FLAG_CANCEL_CURRENT); 93 94 // Cancel all notifications to handle the case where the Service was killed and 95 // restarted by the system. 96 mNotificationManager.cancelAll(); 97 } 98 99 /** 100 * Posts the notification and starts tracking the session to keep it 101 * updated. The notification will automatically be removed if the session is 102 * destroyed before {@link #stopNotification} is called. 103 */ 104 public void startNotification() { 105 if (!mStarted) { 106 mMetadata = mController.getMetadata(); 107 mPlaybackState = mController.getPlaybackState(); 108 109 // The notification must be updated after setting started to true 110 Notification notification = createNotification(); 111 if (notification != null) { 112 mController.registerCallback(mCb); 113 IntentFilter filter = new IntentFilter(); 114 filter.addAction(ACTION_NEXT); 115 filter.addAction(ACTION_PAUSE); 116 filter.addAction(ACTION_PLAY); 117 filter.addAction(ACTION_PREV); 118 mService.registerReceiver(this, filter); 119 120 mService.startForeground(NOTIFICATION_ID, notification); 121 mStarted = true; 122 } 123 } 124 } 125 126 /** 127 * Removes the notification and stops tracking the session. If the session 128 * was destroyed this has no effect. 129 */ 130 public void stopNotification() { 131 if (mStarted) { 132 mStarted = false; 133 mController.unregisterCallback(mCb); 134 try { 135 mNotificationManager.cancel(NOTIFICATION_ID); 136 mService.unregisterReceiver(this); 137 } catch (IllegalArgumentException ex) { 138 // ignore if the receiver is not registered. 139 } 140 mService.stopForeground(true); 141 } 142 } 143 144 @Override 145 public void onReceive(Context context, Intent intent) { 146 final String action = intent.getAction(); 147 LogHelper.d(TAG, "Received intent with action " + action); 148 switch (action) { 149 case ACTION_PAUSE: 150 mTransportControls.pause(); 151 break; 152 case ACTION_PLAY: 153 mTransportControls.play(); 154 break; 155 case ACTION_NEXT: 156 mTransportControls.skipToNext(); 157 break; 158 case ACTION_PREV: 159 mTransportControls.skipToPrevious(); 160 break; 161 default: 162 LogHelper.w(TAG, "Unknown intent ignored. Action=", action); 163 } 164 } 165 166 /** 167 * Update the state based on a change on the session token. Called either when 168 * we are running for the first time or when the media session owner has destroyed the session 169 * (see {@link android.media.session.MediaController.Callback#onSessionDestroyed()}) 170 */ 171 private void updateSessionToken() { 172 MediaSession.Token freshToken = mService.getSessionToken(); 173 if (mSessionToken == null || !mSessionToken.equals(freshToken)) { 174 if (mController != null) { 175 mController.unregisterCallback(mCb); 176 } 177 mSessionToken = freshToken; 178 mController = new MediaController(mService, mSessionToken); 179 mTransportControls = mController.getTransportControls(); 180 if (mStarted) { 181 mController.registerCallback(mCb); 182 } 183 } 184 } 185 186 private PendingIntent createContentIntent() { 187 Intent openUI = new Intent(mService, MediaBrowserService.class); 188 openUI.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); 189 return PendingIntent.getActivity( 190 mService, REQUEST_CODE, openUI, PendingIntent.FLAG_CANCEL_CURRENT); 191 } 192 193 private final MediaController.Callback mCb = new MediaController.Callback() { 194 @Override 195 public void onPlaybackStateChanged(PlaybackState state) { 196 mPlaybackState = state; 197 LogHelper.d(TAG, "Received new playback state", state); 198 if (state != null 199 && (state.getState() == PlaybackState.STATE_STOPPED 200 || state.getState() == PlaybackState.STATE_NONE)) { 201 stopNotification(); 202 } else { 203 Notification notification = createNotification(); 204 if (notification != null) { 205 mNotificationManager.notify(NOTIFICATION_ID, notification); 206 } 207 } 208 } 209 210 @Override 211 public void onMetadataChanged(MediaMetadata metadata) { 212 mMetadata = metadata; 213 LogHelper.d(TAG, "Received new metadata ", metadata); 214 Notification notification = createNotification(); 215 if (notification != null) { 216 mNotificationManager.notify(NOTIFICATION_ID, notification); 217 } 218 } 219 220 @Override 221 public void onSessionDestroyed() { 222 super.onSessionDestroyed(); 223 LogHelper.d(TAG, "Session was destroyed, resetting to the new session token"); 224 updateSessionToken(); 225 } 226 }; 227 228 private Notification createNotification() { 229 LogHelper.d(TAG, "updateNotificationMetadata. mMetadata=" + mMetadata); 230 if (mMetadata == null || mPlaybackState == null) { 231 return null; 232 } 233 234 Notification.Builder notificationBuilder = new Notification.Builder(mService); 235 int playPauseButtonPosition = 0; 236 237 // If skip to previous action is enabled 238 if ((mPlaybackState.getActions() & PlaybackState.ACTION_SKIP_TO_PREVIOUS) != 0) { 239 notificationBuilder.addAction(R.drawable.ic_skip_previous_white_24dp, 240 mService.getString(R.string.skip_previous), mPreviousIntent); 241 242 // If there is a "skip to previous" button, the play/pause button will 243 // be the second one. We need to keep track of it, because the MediaStyle notification 244 // requires to specify the index of the buttons (actions) that should be visible 245 // when in compact view. 246 playPauseButtonPosition = 1; 247 } 248 249 addPlayPauseAction(notificationBuilder); 250 251 // If skip to next action is enabled 252 if ((mPlaybackState.getActions() & PlaybackState.ACTION_SKIP_TO_NEXT) != 0) { 253 notificationBuilder.addAction(R.drawable.ic_skip_next_white_24dp, 254 mService.getString(R.string.skip_next), mNextIntent); 255 } 256 257 MediaDescription description = mMetadata.getDescription(); 258 259 String fetchArtUrl = null; 260 Bitmap art = null; 261 if (description.getIconUri() != null) { 262 // This sample assumes the iconUri will be a valid URL formatted String, but 263 // it can actually be any valid Android Uri formatted String. 264 // async fetch the album art icon 265 String artUrl = description.getIconUri().toString(); 266 art = AlbumArtCache.getInstance().getBigImage(artUrl); 267 if (art == null) { 268 fetchArtUrl = artUrl; 269 // use a placeholder art while the remote art is being downloaded 270 art = BitmapFactory.decodeResource( 271 mService.getResources(), R.drawable.ic_default_art); 272 } 273 } 274 275 notificationBuilder 276 .setStyle(new Notification.MediaStyle() 277 .setShowActionsInCompactView( 278 playPauseButtonPosition) // show only play/pause in 279 // compact view 280 .setMediaSession(mSessionToken)) 281 .setColor(mNotificationColor) 282 .setSmallIcon(R.drawable.ic_notification) 283 .setVisibility(Notification.VISIBILITY_PUBLIC) 284 .setUsesChronometer(true) 285 .setContentIntent(createContentIntent()) 286 .setContentTitle(description.getTitle()) 287 .setContentText(description.getSubtitle()) 288 .setLargeIcon(art); 289 290 setNotificationPlaybackState(notificationBuilder); 291 if (fetchArtUrl != null) { 292 fetchBitmapFromURLAsync(fetchArtUrl, notificationBuilder); 293 } 294 295 return notificationBuilder.build(); 296 } 297 298 private void addPlayPauseAction(Notification.Builder builder) { 299 LogHelper.d(TAG, "updatePlayPauseAction"); 300 String label; 301 int icon; 302 PendingIntent intent; 303 if (mPlaybackState.getState() == PlaybackState.STATE_PLAYING) { 304 label = mService.getString(R.string.play_pause); 305 icon = R.drawable.ic_pause_white_24dp; 306 intent = mPauseIntent; 307 } else { 308 label = mService.getString(R.string.play_item); 309 icon = R.drawable.ic_play_arrow_white_24dp; 310 intent = mPlayIntent; 311 } 312 builder.addAction(new Notification.Action(icon, label, intent)); 313 } 314 315 private void setNotificationPlaybackState(Notification.Builder builder) { 316 LogHelper.d(TAG, "updateNotificationPlaybackState. mPlaybackState=" + mPlaybackState); 317 if (mPlaybackState == null || !mStarted) { 318 LogHelper.d(TAG, "updateNotificationPlaybackState. cancelling notification!"); 319 mService.stopForeground(true); 320 return; 321 } 322 if (mPlaybackState.getState() == PlaybackState.STATE_PLAYING 323 && mPlaybackState.getPosition() >= 0) { 324 LogHelper.d(TAG, "updateNotificationPlaybackState. updating playback position to ", 325 (System.currentTimeMillis() - mPlaybackState.getPosition()) / 1000, " seconds"); 326 builder.setWhen(System.currentTimeMillis() - mPlaybackState.getPosition()) 327 .setShowWhen(true) 328 .setUsesChronometer(true); 329 } else { 330 LogHelper.d(TAG, "updateNotificationPlaybackState. hiding playback position"); 331 builder.setWhen(0).setShowWhen(false).setUsesChronometer(false); 332 } 333 334 // Make sure that the notification can be dismissed by the user when we are not playing: 335 builder.setOngoing(mPlaybackState.getState() == PlaybackState.STATE_PLAYING); 336 } 337 338 private void fetchBitmapFromURLAsync( 339 final String bitmapUrl, final Notification.Builder builder) { 340 AlbumArtCache.getInstance().fetch(bitmapUrl, new AlbumArtCache.FetchListener() { 341 @Override 342 public void onFetched(String artUrl, Bitmap bitmap, Bitmap icon) { 343 if (mMetadata != null && mMetadata.getDescription() != null 344 && artUrl.equals(mMetadata.getDescription().getIconUri().toString())) { 345 // If the media is still the same, update the notification: 346 LogHelper.d(TAG, "fetchBitmapFromURLAsync: set bitmap to ", artUrl); 347 builder.setLargeIcon(bitmap); 348 mNotificationManager.notify(NOTIFICATION_ID, builder.build()); 349 } 350 } 351 }); 352 } 353 } 354