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