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