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
17package com.example.android.supportv4.media;
18
19import android.app.Notification;
20import android.app.PendingIntent;
21import android.content.BroadcastReceiver;
22import android.content.Context;
23import android.content.Intent;
24import android.content.IntentFilter;
25import android.graphics.Bitmap;
26import android.graphics.BitmapFactory;
27import android.graphics.Color;
28import android.os.RemoteException;
29import android.support.v4.media.MediaDescriptionCompat;
30import android.support.v4.media.MediaMetadataCompat;
31import android.support.v4.media.session.MediaControllerCompat;
32import android.support.v4.media.session.MediaSessionCompat;
33import android.support.v4.media.session.PlaybackStateCompat;
34import android.util.Log;
35
36import androidx.core.app.NotificationCompat;
37import androidx.core.app.NotificationManagerCompat;
38
39import com.example.android.supportv4.R;
40import 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 */
47public 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
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     */
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     */
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
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     */
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
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
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
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
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
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