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