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