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