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