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