• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2018 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.bluetooth.audio_util;
18 
19 import android.annotation.Nullable;
20 import android.content.Context;
21 import android.media.MediaMetadata;
22 import android.media.session.MediaSession;
23 import android.media.session.PlaybackState;
24 import android.os.Handler;
25 import android.os.Looper;
26 import android.os.Message;
27 import android.util.Log;
28 
29 import com.android.internal.annotations.GuardedBy;
30 import com.android.internal.annotations.VisibleForTesting;
31 
32 import java.util.List;
33 import java.util.Objects;
34 
35 /*
36  * A class to synchronize Media Controller Callbacks and only pass through
37  * an update once all the relevant information is current.
38  *
39  * TODO (apanicke): Once MediaPlayer2 is supported better, replace this class
40  * with that.
41  */
42 public class MediaPlayerWrapper {
43     private static final String TAG = "AudioMediaPlayerWrapper";
44     private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
45     static boolean sTesting = false;
46     private static final int PLAYBACK_STATE_CHANGE_EVENT_LOGGER_SIZE = 5;
47     private static final String PLAYBACK_STATE_CHANGE_LOGGER_EVENT_TITLE =
48             "Playback State change Event";
49 
50     final Context mContext;
51     private MediaController mMediaController;
52     private String mPackageName;
53     private Looper mLooper;
54     private final BTAudioEventLogger mPlaybackStateChangeEventLogger;
55 
56     private MediaData mCurrentData;
57 
58     @GuardedBy("mCallbackLock")
59     private MediaControllerListener mControllerCallbacks = null;
60     private final Object mCallbackLock = new Object();
61     private Callback mRegisteredCallback = null;
62 
63     public interface Callback {
mediaUpdatedCallback(MediaData data)64         void mediaUpdatedCallback(MediaData data);
sessionUpdatedCallback(String packageName)65         void sessionUpdatedCallback(String packageName);
66     }
67 
isPlaybackStateReady()68     boolean isPlaybackStateReady() {
69         if (getPlaybackState() == null) {
70             d("isPlaybackStateReady(): PlaybackState is null");
71             return false;
72         }
73 
74         return true;
75     }
76 
isMetadataReady()77     boolean isMetadataReady() {
78         if (getMetadata() == null) {
79             d("isMetadataReady(): Metadata is null");
80             return false;
81         }
82 
83         return true;
84     }
85 
MediaPlayerWrapper(Context context, MediaController controller, Looper looper)86     MediaPlayerWrapper(Context context, MediaController controller, Looper looper) {
87         mContext = context;
88         mMediaController = controller;
89         mPackageName = controller.getPackageName();
90         mLooper = looper;
91         mPlaybackStateChangeEventLogger = new BTAudioEventLogger(
92                 PLAYBACK_STATE_CHANGE_EVENT_LOGGER_SIZE, PLAYBACK_STATE_CHANGE_LOGGER_EVENT_TITLE);
93 
94         mCurrentData = new MediaData(null, null, null);
95         mCurrentData.queue = Util.toMetadataList(mContext, getQueue());
96         mCurrentData.metadata = Util.toMetadata(mContext, getMetadata());
97         mCurrentData.state = getPlaybackState();
98     }
99 
cleanup()100     void cleanup() {
101         unregisterCallback();
102 
103         mMediaController = null;
104         mLooper = null;
105     }
106 
getPackageName()107     public String getPackageName() {
108         return mPackageName;
109     }
110 
getSessionToken()111     public MediaSession.Token getSessionToken() {
112         return mMediaController.getSessionToken();
113     }
114 
getQueue()115     protected List<MediaSession.QueueItem> getQueue() {
116         return mMediaController.getQueue();
117     }
118 
getMetadata()119     protected MediaMetadata getMetadata() {
120         return mMediaController.getMetadata();
121     }
122 
getCurrentMetadata()123     Metadata getCurrentMetadata() {
124         return Util.toMetadata(mContext, getMetadata());
125     }
126 
getPlaybackState()127     public PlaybackState getPlaybackState() {
128         return mMediaController.getPlaybackState();
129     }
130 
getActiveQueueID()131     long getActiveQueueID() {
132         PlaybackState state = mMediaController.getPlaybackState();
133         if (state == null) return -1;
134         return state.getActiveQueueItemId();
135     }
136 
getCurrentQueue()137     List<Metadata> getCurrentQueue() {
138         // MediaSession#QueueItem's MediaDescription doesn't necessarily include media duration,
139         // so the playing media info metadata should be obtained by the MediaController.
140         // MediaSession doesn't include the Playlist Metadata, only the current song one.
141         Metadata mediaPlayingMetadata = getCurrentMetadata();
142 
143         // The queue metadata is built with QueueId in place of MediaId, so we can't compare it.
144         // MediaDescription is usually compared via its title, artist and album.
145         if (mediaPlayingMetadata != null) {
146             for (Metadata metadata : mCurrentData.queue) {
147                 if (metadata.title == null || metadata.artist == null || metadata.album == null) {
148                     // if one of the informations is missing we can't assume it is the same media.
149                     continue;
150                 }
151                 if (metadata.title.equals(mediaPlayingMetadata.title)
152                         && metadata.artist.equals(mediaPlayingMetadata.artist)
153                         && metadata.album.equals(mediaPlayingMetadata.album)) {
154                     // Replace default values by MediaController non default values.
155                     metadata.replaceDefaults(mediaPlayingMetadata);
156                 }
157             }
158         }
159         return mCurrentData.queue;
160     }
161 
162     // We don't return the cached info here in order to always provide the freshest data.
getCurrentMediaData()163     MediaData getCurrentMediaData() {
164         MediaData data = new MediaData(
165                 getCurrentMetadata(),
166                 getPlaybackState(),
167                 getCurrentQueue());
168         return data;
169     }
170 
playItemFromQueue(long qid)171     void playItemFromQueue(long qid) {
172         // Return immediately if no queue exists.
173         if (getQueue() == null) {
174             Log.w(TAG, "playItemFromQueue: Trying to play item for player that has no queue: "
175                     + mPackageName);
176             return;
177         }
178 
179         MediaController.TransportControls controller = mMediaController.getTransportControls();
180         controller.skipToQueueItem(qid);
181     }
182 
playCurrent()183     public void playCurrent() {
184         MediaController.TransportControls controller = mMediaController.getTransportControls();
185         controller.play();
186     }
187 
stopCurrent()188     public void stopCurrent() {
189         MediaController.TransportControls controller = mMediaController.getTransportControls();
190         controller.stop();
191     }
192 
pauseCurrent()193     public void pauseCurrent() {
194         MediaController.TransportControls controller = mMediaController.getTransportControls();
195         controller.pause();
196     }
197 
seekTo(long position)198     public void seekTo(long position) {
199         MediaController.TransportControls controller = mMediaController.getTransportControls();
200         controller.seekTo(position);
201     }
202 
fastForward()203     public void fastForward() {
204         MediaController.TransportControls controller = mMediaController.getTransportControls();
205         controller.fastForward();
206     }
207 
rewind()208     public void rewind() {
209         MediaController.TransportControls controller = mMediaController.getTransportControls();
210         controller.rewind();
211     }
212 
skipToPrevious()213     public void skipToPrevious() {
214         MediaController.TransportControls controller = mMediaController.getTransportControls();
215         controller.skipToPrevious();
216     }
217 
skipToNext()218     public void skipToNext() {
219         MediaController.TransportControls controller = mMediaController.getTransportControls();
220         controller.skipToNext();
221     }
222 
setPlaybackSpeed(float speed)223     public void setPlaybackSpeed(float speed) {
224         MediaController.TransportControls controller = mMediaController.getTransportControls();
225         controller.setPlaybackSpeed(speed);
226     }
227 
228     // TODO (apanicke): Implement shuffle and repeat support. Right now these use custom actions
229     // and it may only be possible to do this with Google Play Music
isShuffleSupported()230     public boolean isShuffleSupported() {
231         return false;
232     }
233 
isRepeatSupported()234     public boolean isRepeatSupported() {
235         return false;
236     }
237 
isShuffleSet()238     public boolean isShuffleSet() {
239         return false;
240     }
241 
isRepeatSet()242     public boolean isRepeatSet() {
243         return false;
244     }
245 
toggleShuffle(boolean on)246     void toggleShuffle(boolean on) {
247         return;
248     }
249 
toggleRepeat(boolean on)250     void toggleRepeat(boolean on) {
251         return;
252     }
253 
254     /**
255      * Return whether the queue, metadata, and queueID are all in sync.
256      */
isMetadataSynced()257     boolean isMetadataSynced() {
258         List<MediaSession.QueueItem> queue = getQueue();
259         if (queue != null && getActiveQueueID() != -1) {
260             // Check if currentPlayingQueueId is in the current Queue
261             MediaSession.QueueItem currItem = null;
262 
263             for (MediaSession.QueueItem item : queue) {
264                 if (item.getQueueId()
265                         == getActiveQueueID()) { // The item exists in the current queue
266                     currItem = item;
267                     break;
268                 }
269             }
270 
271             // Check if current playing song in Queue matches current Metadata
272             Metadata qitem = Util.toMetadata(mContext, currItem);
273             Metadata mdata = Util.toMetadata(mContext, getMetadata());
274             if (currItem == null || !qitem.equals(mdata)) {
275                 if (DEBUG) {
276                     Log.d(TAG, "Metadata currently out of sync for " + mPackageName);
277                     Log.d(TAG, "  └ Current queueItem: " + qitem);
278                     Log.d(TAG, "  └ Current metadata : " + mdata);
279                 }
280 
281                 // Some player do not provide full song info in queue item, allow case
282                 // that only title and artist match.
283                 if (Objects.equals(qitem.title, mdata.title)
284                         && Objects.equals(qitem.artist, mdata.artist)) {
285                     Log.d(TAG, mPackageName + " Only Title and Artist info sync for metadata");
286                     return true;
287                 }
288                 return false;
289             }
290         }
291 
292         return true;
293     }
294 
295     /**
296      * Register a callback which gets called when media updates happen. The callbacks are
297      * called on the same Looper that was passed in to create this object.
298      */
registerCallback(Callback callback)299     void registerCallback(Callback callback) {
300         if (callback == null) {
301             e("Cannot register null callbacks for " + mPackageName);
302             return;
303         }
304 
305         synchronized (mCallbackLock) {
306             mRegisteredCallback = callback;
307         }
308 
309         // Update the current data since it could have changed while we weren't registered for
310         // updates
311         mCurrentData = new MediaData(
312                 Util.toMetadata(mContext, getMetadata()),
313                 getPlaybackState(),
314                 Util.toMetadataList(mContext, getQueue()));
315 
316         synchronized (mCallbackLock) {
317             mControllerCallbacks = new MediaControllerListener(mMediaController, mLooper);
318         }
319     }
320 
321     /**
322      * Unregisters from updates. Note, this doesn't require the looper to be shut down.
323      */
unregisterCallback()324     void unregisterCallback() {
325         // Prevent a race condition where a callback could be called while shutting down
326         synchronized (mCallbackLock) {
327             mRegisteredCallback = null;
328             if (mControllerCallbacks == null) return;
329             mControllerCallbacks.cleanup();
330             mControllerCallbacks = null;
331         }
332     }
333 
updateMediaController(MediaController newController)334     void updateMediaController(MediaController newController) {
335         if (Objects.equals(newController, mMediaController)) return;
336 
337         mMediaController = newController;
338 
339         synchronized (mCallbackLock) {
340             if (mRegisteredCallback == null || mControllerCallbacks == null) {
341                 d("Controller for " + mPackageName + " maybe is not activated.");
342                 return;
343             }
344 
345             mControllerCallbacks.cleanup();
346 
347             // Update the current data since it could be different on the new controller for the
348             // player
349             mCurrentData = new MediaData(
350                     Util.toMetadata(mContext, getMetadata()),
351                     getPlaybackState(),
352                     Util.toMetadataList(mContext, getQueue()));
353 
354             mControllerCallbacks = new MediaControllerListener(mMediaController, mLooper);
355         }
356         d("Controller for " + mPackageName + " was updated.");
357     }
358 
sendMediaUpdate()359     private void sendMediaUpdate() {
360         MediaData newData = new MediaData(
361                 Util.toMetadata(mContext, getMetadata()),
362                 getPlaybackState(),
363                 Util.toMetadataList(mContext, getQueue()));
364 
365         if (newData.equals(mCurrentData)) {
366             // This may happen if the controller is fully synced by the time the
367             // first update is completed
368             Log.v(TAG, "Trying to update with last sent metadata");
369             return;
370         }
371 
372         synchronized (mCallbackLock) {
373             if (mRegisteredCallback == null) {
374                 Log.e(TAG, mPackageName
375                         + ": Trying to send an update with no registered callback");
376                 return;
377             }
378 
379             Log.v(TAG, "trySendMediaUpdate(): Metadata has been updated for " + mPackageName);
380             mRegisteredCallback.mediaUpdatedCallback(newData);
381         }
382 
383         mCurrentData = newData;
384     }
385 
386     class TimeoutHandler extends Handler {
387         private static final int MSG_TIMEOUT = 0;
388         private static final long CALLBACK_TIMEOUT_MS = 2000;
389 
TimeoutHandler(Looper looper)390         TimeoutHandler(Looper looper) {
391             super(looper);
392         }
393 
394         @Override
handleMessage(Message msg)395         public void handleMessage(Message msg) {
396             if (msg.what != MSG_TIMEOUT) {
397                 Log.wtf(TAG, "Unknown message on timeout handler: " + msg.what);
398                 return;
399             }
400 
401             Log.e(TAG, "Timeout while waiting for metadata to sync for " + mPackageName);
402             Log.e(TAG, "  └ Current Metadata: " +  Util.toMetadata(mContext, getMetadata()));
403             Log.e(TAG, "  └ Current Playstate: " + getPlaybackState());
404             List<Metadata> current_queue = Util.toMetadataList(mContext, getQueue());
405             for (int i = 0; i < current_queue.size(); i++) {
406                 Log.e(TAG, "  └ QueueItem(" + i + "): " + current_queue.get(i));
407             }
408 
409             sendMediaUpdate();
410 
411             // TODO(apanicke): Add metric collection here.
412 
413             if (sTesting) Log.wtf(TAG, "Crashing the stack");
414         }
415     }
416 
417     class MediaControllerListener extends MediaController.Callback {
418         private final Object mTimeoutHandlerLock = new Object();
419         private Handler mTimeoutHandler;
420         private MediaController mController;
421 
MediaControllerListener(MediaController controller, Looper newLooper)422         MediaControllerListener(MediaController controller, Looper newLooper) {
423             synchronized (mTimeoutHandlerLock) {
424                 mTimeoutHandler = new TimeoutHandler(newLooper);
425 
426                 mController = controller;
427                 // Register the callbacks to execute on the same thread as the timeout thread. This
428                 // prevents a race condition where a timeout happens at the same time as an update.
429                 mController.registerCallback(this, mTimeoutHandler);
430             }
431         }
432 
cleanup()433         void cleanup() {
434             synchronized (mTimeoutHandlerLock) {
435                 mController.unregisterCallback(this);
436                 mController = null;
437                 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT);
438                 mTimeoutHandler = null;
439             }
440         }
441 
trySendMediaUpdate()442         void trySendMediaUpdate() {
443             synchronized (mTimeoutHandlerLock) {
444                 if (mTimeoutHandler == null) return;
445                 mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT);
446 
447                 if (!isMetadataSynced()) {
448                     d("trySendMediaUpdate(): Starting media update timeout");
449                     mTimeoutHandler.sendEmptyMessageDelayed(TimeoutHandler.MSG_TIMEOUT,
450                             TimeoutHandler.CALLBACK_TIMEOUT_MS);
451                     return;
452                 }
453             }
454 
455             sendMediaUpdate();
456         }
457 
458         @Override
onMetadataChanged(@ullable MediaMetadata mediaMetadata)459         public void onMetadataChanged(@Nullable MediaMetadata mediaMetadata) {
460             if (!isMetadataReady()) {
461                 Log.v(TAG, "onMetadataChanged(): " + mPackageName
462                         + " tried to update with no queue");
463                 return;
464             }
465 
466             if (DEBUG) {
467                 Log.v(TAG, "onMetadataChanged(): " + mPackageName + " : "
468                         + Util.toMetadata(mContext, mediaMetadata));
469             }
470 
471             if (!Objects.equals(mediaMetadata, getMetadata())) {
472                 e("The callback metadata doesn't match controller metadata");
473             }
474 
475             // TODO: Certain players update different metadata fields as they load, such as Album
476             // Art. For track changed updates we only care about the song information like title
477             // and album and duration. In the future we can use this to know when Album art is
478             // loaded.
479 
480             // TODO: Spotify needs a metadata update debouncer as it sometimes updates the metadata
481             // twice in a row with the only difference being that the song duration is rounded to
482             // the nearest second.
483             if (Objects.equals(Util.toMetadata(mContext, mediaMetadata), mCurrentData.metadata)) {
484                 Log.w(TAG, "onMetadataChanged(): " + mPackageName
485                         + " tried to update with no new data");
486                 return;
487             }
488 
489             trySendMediaUpdate();
490         }
491 
492         @Override
onPlaybackStateChanged(@ullable PlaybackState state)493         public void onPlaybackStateChanged(@Nullable PlaybackState state) {
494             if (!isPlaybackStateReady()) {
495                 Log.v(TAG, "onPlaybackStateChanged(): " + mPackageName
496                         + " tried to update with no queue");
497                 return;
498             }
499 
500             mPlaybackStateChangeEventLogger.logv(TAG, "onPlaybackStateChanged(): "
501                     + mPackageName + " : " + state.toString());
502 
503             if (!playstateEquals(state, getPlaybackState())) {
504                 e("The callback playback state doesn't match the current state");
505             }
506 
507             if (playstateEquals(state, mCurrentData.state)) {
508                 Log.w(TAG, "onPlaybackStateChanged(): " + mPackageName
509                         + " tried to update with no new data");
510                 return;
511             }
512 
513             // If there is no playstate, ignore the update.
514             if (state.getState() == PlaybackState.STATE_NONE) {
515                 Log.v(TAG, "Waiting to send update as controller has no playback state");
516                 return;
517             }
518 
519             trySendMediaUpdate();
520         }
521 
522         @Override
onQueueChanged(@ullable List<MediaSession.QueueItem> queue)523         public void onQueueChanged(@Nullable List<MediaSession.QueueItem> queue) {
524             if (!isPlaybackStateReady() || !isMetadataReady()) {
525                 Log.v(TAG, "onQueueChanged(): " + mPackageName
526                         + " tried to update with no queue");
527                 return;
528             }
529 
530             Log.v(TAG, "onQueueChanged(): " + mPackageName);
531 
532             if (!Objects.equals(queue, getQueue())) {
533                 e("The callback queue isn't the current queue");
534             }
535 
536             List<Metadata> current_queue = Util.toMetadataList(mContext, queue);
537             if (current_queue.equals(mCurrentData.queue)) {
538                 Log.w(TAG, "onQueueChanged(): " + mPackageName
539                         + " tried to update with no new data");
540                 return;
541             }
542 
543             if (DEBUG) {
544                 for (int i = 0; i < current_queue.size(); i++) {
545                     Log.d(TAG, "  └ QueueItem(" + i + "): " + current_queue.get(i));
546                 }
547             }
548 
549             trySendMediaUpdate();
550         }
551 
552         @Override
onSessionDestroyed()553         public void onSessionDestroyed() {
554             Log.w(TAG, "The session was destroyed " + mPackageName);
555             mRegisteredCallback.sessionUpdatedCallback(mPackageName);
556         }
557 
558         @VisibleForTesting
getTimeoutHandler()559         Handler getTimeoutHandler() {
560             return mTimeoutHandler;
561         }
562     }
563 
564     /**
565      * Checks wheter the core information of two PlaybackStates match. This function allows a
566      * certain amount of deviation between the position fields of the PlaybackStates. This is to
567      * prevent matches from failing when updates happen in quick succession.
568      *
569      * The maximum allowed deviation is defined by PLAYSTATE_BOUNCE_IGNORE_PERIOD and is measured
570      * in milliseconds.
571      */
572     private static final long PLAYSTATE_BOUNCE_IGNORE_PERIOD = 500;
playstateEquals(PlaybackState a, PlaybackState b)573     public static boolean playstateEquals(PlaybackState a, PlaybackState b) {
574         if (a == b) return true;
575 
576         if (a != null && b != null
577                 && a.getState() == b.getState()
578                 && a.getActiveQueueItemId() == b.getActiveQueueItemId()
579                 && Math.abs(a.getPosition() - b.getPosition()) < PLAYSTATE_BOUNCE_IGNORE_PERIOD) {
580             return true;
581         }
582 
583         return false;
584     }
585 
e(String message)586     private static void e(String message) {
587         if (sTesting) {
588             Log.wtf(TAG, message);
589         } else {
590             Log.e(TAG, message);
591         }
592     }
593 
d(String message)594     private void d(String message) {
595         if (DEBUG) Log.d(TAG, mPackageName + ": " + message);
596     }
597 
598     @VisibleForTesting
getTimeoutHandler()599     Handler getTimeoutHandler() {
600         synchronized (mCallbackLock) {
601             if (mControllerCallbacks == null) return null;
602             return mControllerCallbacks.getTimeoutHandler();
603         }
604     }
605 
606     @Override
toString()607     public String toString() {
608         StringBuilder sb = new StringBuilder();
609         sb.append(mMediaController.toString() + "\n");
610         sb.append("Current Data:\n");
611         sb.append("  Song: " + mCurrentData.metadata + "\n");
612         sb.append("  PlayState: " + mCurrentData.state + "\n");
613         sb.append("  Queue: size=" + mCurrentData.queue.size() + "\n");
614         for (Metadata data : mCurrentData.queue) {
615             sb.append("    " + data + "\n");
616         }
617         mPlaybackStateChangeEventLogger.dump(sb);
618         return sb.toString();
619     }
620 }
621