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