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