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