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