Jack He | f02d3c6 | 2017-02-21 00:39:22 -0500 | [diff] [blame] | 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 | } |