Merge "AVRCP: Try update media metadata on app force quit"
diff --git a/src/com/android/bluetooth/avrcp/BrowsablePlayerConnector.java b/src/com/android/bluetooth/avrcp/BrowsablePlayerConnector.java
index b5886c2..486610f 100644
--- a/src/com/android/bluetooth/avrcp/BrowsablePlayerConnector.java
+++ b/src/com/android/bluetooth/avrcp/BrowsablePlayerConnector.java
@@ -74,6 +74,7 @@
         for (ResolveInfo info : players) {
             BrowsedPlayerWrapper player = BrowsedPlayerWrapper.wrap(
                             context,
+                            looper,
                             info.serviceInfo.packageName,
                             info.serviceInfo.name);
             newWrapper.mPendingPlayers.add(player);
diff --git a/src/com/android/bluetooth/avrcp/BrowsedPlayerWrapper.java b/src/com/android/bluetooth/avrcp/BrowsedPlayerWrapper.java
index b704aae..783da64 100644
--- a/src/com/android/bluetooth/avrcp/BrowsedPlayerWrapper.java
+++ b/src/com/android/bluetooth/avrcp/BrowsedPlayerWrapper.java
@@ -16,9 +16,14 @@
 
 package com.android.bluetooth.avrcp;
 
+import android.annotation.Nullable;
 import android.content.ComponentName;
 import android.content.Context;
 import android.media.browse.MediaBrowser.MediaItem;
+import android.media.session.PlaybackState;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
 import android.util.Log;
 
 import java.util.ArrayList;
@@ -34,7 +39,7 @@
  */
 class BrowsedPlayerWrapper {
     private static final String TAG = "AvrcpBrowsedPlayerWrapper";
-    private static final boolean DEBUG = true;
+    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
 
     enum ConnectionState {
         DISCONNECTED,
@@ -46,6 +51,10 @@
         void run(int status, BrowsedPlayerWrapper wrapper);
     }
 
+    interface PlaybackCallback {
+        void run(int status);
+    }
+
     interface BrowseCallback {
         void run(int status, String mediaId, List<ListItem> results);
     }
@@ -53,13 +62,16 @@
     public static final int STATUS_SUCCESS = 0;
     public static final int STATUS_CONN_ERROR = 1;
     public static final int STATUS_LOOKUP_ERROR = 2;
+    public static final int STATUS_PLAYBACK_TIMEOUT_ERROR = 3;
 
     private MediaBrowser mWrappedBrowser;
 
     // TODO (apanicke): Store the context in the factories so that we don't need to save this.
     // As long as the service is alive those factories will have a valid context.
-    private Context mContext;
-    private String mPackageName;
+    private final Context mContext;
+    private final Looper mLooper;
+    private final String mPackageName;
+    private final Object mCallbackLock = new Object();
     private ConnectionCallback mCallback;
 
     // TODO(apanicke): We cache this because normally you can only grab the root
@@ -91,10 +103,11 @@
     // TODO (apanicke): Investigate if there is a way to create this just by passing in the
     // MediaBrowser. Right now there is no obvious way to create the browser then update the
     // connection callback without being forced to re-create the object every time.
-    private BrowsedPlayerWrapper(Context context, String packageName, String className) {
+    private BrowsedPlayerWrapper(Context context, Looper looper, String packageName,
+                    String className) {
         mContext = context;
         mPackageName = packageName;
-
+        mLooper = looper;
         mWrappedBrowser = MediaBrowserFactory.make(
                 context,
                 new ComponentName(packageName, className),
@@ -102,35 +115,79 @@
                 null);
     }
 
-    static BrowsedPlayerWrapper wrap(Context context, String packageName, String className) {
+    static BrowsedPlayerWrapper wrap(Context context, Looper looper, String packageName,
+                    String className) {
         Log.i(TAG, "Wrapping Media Browser " + packageName);
         BrowsedPlayerWrapper wrapper =
-                new BrowsedPlayerWrapper(context, packageName, className);
+                new BrowsedPlayerWrapper(context, looper, packageName, className);
         return wrapper;
     }
 
-    void connect(ConnectionCallback cb) {
+    /**
+     * Connect to the media application's MediaBrowserService
+     *
+     * Connections are asynchronous in nature. The given callback will be invoked once the
+     * connection is established. The connection will be torn down once your callback is executed
+     * when using this function. If you wish to control the lifecycle of the connection on your own
+     * then use {@link #setCallbackAndConnect(ConnectionCallback)} instead.
+     *
+     * @param cb A callback to execute once the connection is established
+     * @return True if we successfully make a connection attempt, False otherwise
+     */
+    boolean connect(ConnectionCallback cb) {
         if (cb == null) {
             Log.wtfStack(TAG, "connect: Trying to connect to " + mPackageName
                     + "with null callback");
         }
-        if (mCallback != null) {
-            Log.w(TAG, "connect: Already trying to connect to " + mPackageName);
-            return;
-        }
-
-        if (DEBUG) Log.d(TAG, "connect: Connecting to browsable player: " + mPackageName);
-        mCallback = (int status, BrowsedPlayerWrapper wrapper) -> {
+        return setCallbackAndConnect((int status, BrowsedPlayerWrapper wrapper) -> {
             cb.run(status, wrapper);
-            wrapper.disconnect();
-        };
-        mWrappedBrowser.connect();
+            disconnect();
+        });
     }
 
+    /**
+     * Disconnect from the media application's MediaBrowserService
+     *
+     * This clears any pending requests. This function is safe to call even if a connection isn't
+     * currently open.
+     */
     void disconnect() {
         if (DEBUG) Log.d(TAG, "disconnect: Disconnecting from " + mPackageName);
         mWrappedBrowser.disconnect();
-        mCallback = null;
+        clearCallback();
+    }
+
+    boolean setCallbackAndConnect(ConnectionCallback callback) {
+        synchronized (mCallbackLock) {
+            if (mCallback != null) {
+                Log.w(TAG, "setCallbackAndConnect: Already trying to connect to ");
+                return false;
+            }
+            mCallback = callback;
+        }
+        if (DEBUG) Log.d(TAG, "Set mCallback, connecting to " + mPackageName);
+        mWrappedBrowser.connect();
+        return true;
+    }
+
+    void executeCallback(int status, BrowsedPlayerWrapper player) {
+        final ConnectionCallback callback;
+        synchronized (mCallbackLock) {
+            if (mCallback == null) {
+                Log.w(TAG, "Callback is NULL. Cannot execute");
+                return;
+            }
+            callback = mCallback;
+        }
+        if (DEBUG) Log.d(TAG, "Executing callback");
+        callback.run(status, player);
+    }
+
+    void clearCallback() {
+        synchronized (mCallbackLock) {
+            mCallback = null;
+        }
+        if (DEBUG) Log.d(TAG, "mCallback = null");
     }
 
     public String getPackageName() {
@@ -141,23 +198,39 @@
         return mRoot;
     }
 
-    public void playItem(String mediaId) {
-        if (DEBUG) Log.d(TAG, "playItem: Play Item from media ID: " + mediaId);
-        connect((int status, BrowsedPlayerWrapper wrapper) -> {
+    /**
+     * Requests to play a media item with a given media ID
+     *
+     * @param mediaId A string indicating the piece of media you would like to play
+     * @return False if any other requests are being serviced, True otherwise
+     */
+    public boolean playItem(String mediaId) {
+        if (DEBUG) Log.d(TAG, "playItem: Play item from media ID: " + mediaId);
+        return setCallbackAndConnect((int status, BrowsedPlayerWrapper wrapper) -> {
             if (DEBUG) Log.d(TAG, "playItem: Connected to browsable player " + mPackageName);
-
             MediaController controller = MediaControllerFactory.make(mContext,
                     wrapper.mWrappedBrowser.getSessionToken());
             MediaController.TransportControls ctrl = controller.getTransportControls();
             Log.i(TAG, "playItem: Playing " + mediaId);
             ctrl.playFromMediaId(mediaId, null);
+
+            MediaPlaybackListener mpl = new MediaPlaybackListener(mLooper, controller);
+            mpl.waitForPlayback((int playbackStatus) -> {
+                Log.i(TAG, "playItem: Media item playback returned, status: " + playbackStatus);
+                disconnect();
+            });
         });
-        return;
     }
 
-    // Returns false if the player is in the connecting state. Wait for it to either be
-    // connected or disconnected.
-    //
+    /**
+     * Request the contents of a folder item identified by the given media ID
+     *
+     * Contents must be loaded from a service and are returned asynchronously.
+     *
+     * @param mediaId A string indicating the piece of media you would like to play
+     * @param cb A Callback that returns the loaded contents of the requested media ID
+     * @return False if any other requests are being serviced, True otherwise
+     */
     // TODO (apanicke): Determine what happens when we subscribe to the same item while a
     // callback is in flight.
     //
@@ -172,27 +245,18 @@
         }
 
         if (cb == null) {
-            Log.wtfStack(TAG, "connect: Trying to connect to " + mPackageName
-                    + "with null callback");
-        }
-        if (mCallback != null) {
-            Log.w(TAG, "connect: Already trying to connect to " + mPackageName);
-            return false;
+            Log.wtfStack(TAG, "getFolderItems: Trying to connect to " + mPackageName
+                    + "with null browse callback");
         }
 
-        if (DEBUG) Log.d(TAG, "connect: Connecting to browsable player: " + mPackageName);
-        mCallback = (int status, BrowsedPlayerWrapper wrapper) -> {
+        if (DEBUG) Log.d(TAG, "getFolderItems: Connecting to browsable player: " + mPackageName);
+        return setCallbackAndConnect((int status, BrowsedPlayerWrapper wrapper) -> {
             Log.i(TAG, "getFolderItems: Connected to browsable player: " + mPackageName);
             if (status != STATUS_SUCCESS) {
                 cb.run(status, "", new ArrayList<ListItem>());
             }
-
-            // This will disconnect when the callback is called
             getFolderItemsInternal(mediaId, cb);
-        };
-        mWrappedBrowser.connect();
-
-        return true;
+        });
     }
 
     // Internal function to call once the Browser is connected
@@ -208,44 +272,128 @@
             // Get the root while connected because we may need to use it when disconnected.
             mRoot = mWrappedBrowser.getRoot();
 
-            if (mCallback == null) return;
-
             if (mRoot == null || mRoot.isEmpty()) {
-                mCallback.run(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this);
+                executeCallback(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this);
                 return;
             }
 
-            mCallback.run(STATUS_SUCCESS, BrowsedPlayerWrapper.this);
-            mCallback = null;
+            executeCallback(STATUS_SUCCESS, BrowsedPlayerWrapper.this);
         }
 
 
         @Override
         public void onConnectionFailed() {
             Log.w(TAG, "onConnectionFailed: Connection Failed with " + mPackageName);
-            if (mCallback != null) mCallback.run(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this);
-            mCallback = null;
+            executeCallback(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this);
+            // No need to call disconnect as we never connected. Just need to remove our callback.
+            clearCallback();
         }
 
         // TODO (apanicke): Add a check to list a player as unbrowsable if it suspends immediately
         // after connection.
         @Override
         public void onConnectionSuspended() {
-            mWrappedBrowser.disconnect();
+            executeCallback(STATUS_CONN_ERROR, BrowsedPlayerWrapper.this);
+            disconnect();
             Log.i(TAG, "onConnectionSuspended: Connection Suspended with " + mPackageName);
         }
     }
 
+    class TimeoutHandler extends Handler {
+        static final int MSG_TIMEOUT = 0;
+        static final long CALLBACK_TIMEOUT_MS = 5000;
+
+        private PlaybackCallback mPlaybackCallback = null;
+
+        TimeoutHandler(Looper looper, PlaybackCallback cb) {
+            super(looper);
+            mPlaybackCallback = cb;
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            if (msg.what != MSG_TIMEOUT) {
+                Log.wtf(TAG, "Unknown message on timeout handler: " + msg.what);
+                return;
+            }
+
+            Log.e(TAG, "Timeout while waiting for playback to begin on " + mPackageName);
+            mPlaybackCallback.run(STATUS_PLAYBACK_TIMEOUT_ERROR);
+        }
+    }
+
+    class MediaPlaybackListener extends MediaController.Callback {
+        private final Object mTimeoutHandlerLock = new Object();
+        private Handler mTimeoutHandler = null;
+        private Looper mLooper = null;
+        private MediaController mController = null;
+        private PlaybackCallback mPlaybackCallback = null;
+
+        MediaPlaybackListener(Looper looper, MediaController controller) {
+            synchronized (mTimeoutHandlerLock) {
+                mController = controller;
+                mLooper = looper;
+            }
+        }
+
+        void waitForPlayback(PlaybackCallback cb) {
+            synchronized (mTimeoutHandlerLock) {
+                mPlaybackCallback = cb;
+
+                // If we don't already have the proper state then register the callbacks to execute
+                // on the same thread as the timeout thread. This prevents a race condition where a
+                // timeout happens at the same time as an update. Then set the timeout
+                PlaybackState state = mController.getPlaybackState();
+                if (state == null || state.getState() != PlaybackState.STATE_PLAYING) {
+                    Log.d(TAG, "MediaPlayback: Waiting for media to play for " + mPackageName);
+                    mTimeoutHandler = new TimeoutHandler(mLooper, mPlaybackCallback);
+                    mController.registerCallback(this, mTimeoutHandler);
+                    mTimeoutHandler.sendEmptyMessageDelayed(TimeoutHandler.MSG_TIMEOUT,
+                                TimeoutHandler.CALLBACK_TIMEOUT_MS);
+                } else {
+                    Log.d(TAG, "MediaPlayback: Media is already playing for " + mPackageName);
+                    mPlaybackCallback.run(STATUS_SUCCESS);
+                    cleanup();
+                }
+            }
+        }
+
+        void cleanup() {
+            synchronized (mTimeoutHandlerLock) {
+                if (mController != null) {
+                    mController.unregisterCallback(this);
+                }
+                mController = null;
+
+                if (mTimeoutHandler != null) {
+                    mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT);
+                }
+                mTimeoutHandler = null;
+                mPlaybackCallback = null;
+            }
+        }
+
+        @Override
+        public void onPlaybackStateChanged(@Nullable PlaybackState state) {
+            if (DEBUG) Log.d(TAG, "MediaPlayback: " + mPackageName + " -> " + state.toString());
+            if (state.getState() == PlaybackState.STATE_PLAYING) {
+                mTimeoutHandler.removeMessages(TimeoutHandler.MSG_TIMEOUT);
+                mPlaybackCallback.run(STATUS_SUCCESS);
+                cleanup();
+            }
+        }
+    }
+
     /**
      * Subscription callback handler. Subscribe to a folder to get its contents. We generate a new
      * instance for this class for each subscribe call to make it easier to differentiate between
      * the callers.
      */
     private class BrowserSubscriptionCallback extends MediaBrowser.SubscriptionCallback {
-        BrowseCallback mCallback = null;
+        BrowseCallback mBrowseCallback = null;
 
         BrowserSubscriptionCallback(BrowseCallback cb) {
-            mCallback = cb;
+            mBrowseCallback = cb;
         }
 
         @Override
@@ -254,7 +402,7 @@
                 Log.d(TAG, "onChildrenLoaded: mediaId=" + parentId + " size= " + children.size());
             }
 
-            if (mCallback == null) {
+            if (mBrowseCallback == null) {
                 Log.w(TAG, "onChildrenLoaded: " + mPackageName
                         + " children loaded while callback is null");
             }
@@ -288,8 +436,8 @@
             mCachedFolders.put(parentId, return_list);
 
             // Clone the list so that the callee can mutate it without affecting the cached data
-            mCallback.run(STATUS_SUCCESS, parentId, Util.cloneList(return_list));
-            mCallback = null;
+            mBrowseCallback.run(STATUS_SUCCESS, parentId, Util.cloneList(return_list));
+            mBrowseCallback = null;
             disconnect();
         }
 
@@ -297,7 +445,7 @@
         @Override
         public void onError(String id) {
             Log.e(TAG, "BrowserSubscriptionCallback: Could not get folder items");
-            mCallback.run(STATUS_LOOKUP_ERROR, id, new ArrayList<ListItem>());
+            mBrowseCallback.run(STATUS_LOOKUP_ERROR, id, new ArrayList<ListItem>());
             disconnect();
         }
     }
diff --git a/src/com/android/bluetooth/avrcp/MediaPlayerWrapper.java b/src/com/android/bluetooth/avrcp/MediaPlayerWrapper.java
index 4e0b812..f0309fd 100644
--- a/src/com/android/bluetooth/avrcp/MediaPlayerWrapper.java
+++ b/src/com/android/bluetooth/avrcp/MediaPlayerWrapper.java
@@ -135,8 +135,9 @@
     }
 
     long getActiveQueueID() {
-        if (mMediaController.getPlaybackState() == null) return -1;
-        return mMediaController.getPlaybackState().getActiveQueueItemId();
+        PlaybackState state = mMediaController.getPlaybackState();
+        if (state == null) return -1;
+        return state.getActiveQueueItemId();
     }
 
     List<Metadata> getCurrentQueue() {
diff --git a/src/com/android/bluetooth/hfp/HeadsetStateMachine.java b/src/com/android/bluetooth/hfp/HeadsetStateMachine.java
index c5485f9..06c4822 100644
--- a/src/com/android/bluetooth/hfp/HeadsetStateMachine.java
+++ b/src/com/android/bluetooth/hfp/HeadsetStateMachine.java
@@ -1198,6 +1198,21 @@
         }
     }
 
+    class MyAudioServerStateCallback extends AudioManager.AudioServerStateCallback {
+        @Override
+        public void onAudioServerDown() {
+            logi("onAudioServerDown");
+        }
+
+        @Override
+        public void onAudioServerUp() {
+            logi("onAudioServerUp restoring audio parameters");
+            setAudioParameters();
+        }
+    }
+
+    MyAudioServerStateCallback mAudioServerStateCallback = new MyAudioServerStateCallback();
+
     class AudioOn extends ConnectedBase {
         @Override
         int getAudioStateInt() {
@@ -1216,10 +1231,21 @@
                 mHeadsetService.setActiveDevice(mDevice);
             }
             setAudioParameters();
+
+            mSystemInterface.getAudioManager().setAudioServerStateCallback(
+                    mHeadsetService.getMainExecutor(), mAudioServerStateCallback);
+
             broadcastStateTransitions();
         }
 
         @Override
+        public void exit() {
+            super.exit();
+
+            mSystemInterface.getAudioManager().clearAudioServerStateCallback();
+        }
+
+        @Override
         public boolean processMessage(Message message) {
             switch (message.what) {
                 case CONNECT: {
diff --git a/src/com/android/bluetooth/opp/BluetoothOppTransfer.java b/src/com/android/bluetooth/opp/BluetoothOppTransfer.java
index 91af9ca..06562e4 100644
--- a/src/com/android/bluetooth/opp/BluetoothOppTransfer.java
+++ b/src/com/android/bluetooth/opp/BluetoothOppTransfer.java
@@ -474,7 +474,7 @@
             mBatch.mStatus = Constants.BATCH_STATUS_FAILED;
             return;
         }
-
+        registerConnectionreceiver();
         if (mHandlerThread == null) {
             if (V) {
                 Log.v(TAG, "Create handler thread for batch " + mBatch.mId);
@@ -495,7 +495,7 @@
                 startObexSession();
             }
         }
-        registerConnectionreceiver();
+
     }
 
     /**
diff --git a/src/com/android/bluetooth/opp/BluetoothOppTransferActivity.java b/src/com/android/bluetooth/opp/BluetoothOppTransferActivity.java
index fc45d3f..a7c14ac 100644
--- a/src/com/android/bluetooth/opp/BluetoothOppTransferActivity.java
+++ b/src/com/android/bluetooth/opp/BluetoothOppTransferActivity.java
@@ -375,24 +375,13 @@
                             mTransInfo.mID);
                 } else if (mWhichDialog == DIALOG_SEND_COMPLETE_FAIL) {
                     // "try again"
-
                     // make current transfer "hidden"
                     BluetoothOppUtility.updateVisibilityToHidden(this, mUri);
 
                     // clear correspondent notification item
                     ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).cancel(
                             mTransInfo.mID);
-
-                    // retry the failed transfer
-                    Uri uri = BluetoothOppUtility.originalUri(Uri.parse(mTransInfo.mFileUri));
-                    BluetoothOppSendFileInfo sendFileInfo =
-                            BluetoothOppSendFileInfo.generateFileInfo(BluetoothOppTransferActivity
-                            .this, uri, mTransInfo.mFileType, false);
-                    uri = BluetoothOppUtility.generateUri(uri, sendFileInfo);
-                    BluetoothOppUtility.putSendFileInfo(uri, sendFileInfo);
-                    mTransInfo.mFileUri = uri.toString();
-                    BluetoothOppUtility.retryTransfer(this, mTransInfo);
-
+                    retryFailedTrasfer();
                     BluetoothDevice remoteDevice = mAdapter.getRemoteDevice(mTransInfo.mDestAddr);
 
                     // Display toast message
@@ -502,4 +491,23 @@
                     .setText(getString(R.string.upload_fail_cancel));
         }
     }
+
+   /*
+    * Retry the failed transfer in background thread
+    */
+   private void retryFailedTrasfer() {
+        new Thread() {
+            @Override
+            public void run() {
+                Uri uri = BluetoothOppUtility.originalUri(Uri.parse(mTransInfo.mFileUri));
+                BluetoothOppSendFileInfo sendFileInfo =
+                        BluetoothOppSendFileInfo.generateFileInfo(BluetoothOppTransferActivity
+                        .this, uri, mTransInfo.mFileType, false);
+                uri = BluetoothOppUtility.generateUri(uri, sendFileInfo);
+                BluetoothOppUtility.putSendFileInfo(uri, sendFileInfo);
+                mTransInfo.mFileUri = uri.toString();
+                BluetoothOppUtility.retryTransfer(BluetoothOppTransferActivity.this, mTransInfo);
+            }
+        }.start();
+    }
 }
diff --git a/tests/unit/src/com/android/bluetooth/newavrcp/BrowserPlayerWrapperTest.java b/tests/unit/src/com/android/bluetooth/newavrcp/BrowserPlayerWrapperTest.java
index 38d9c58..760edae 100644
--- a/tests/unit/src/com/android/bluetooth/newavrcp/BrowserPlayerWrapperTest.java
+++ b/tests/unit/src/com/android/bluetooth/newavrcp/BrowserPlayerWrapperTest.java
@@ -20,6 +20,9 @@
 
 import android.media.MediaDescription;
 import android.media.browse.MediaBrowser.MediaItem;
+import android.media.session.PlaybackState;
+import android.os.Handler;
+import android.os.HandlerThread;
 
 import androidx.test.filters.SmallTest;
 import androidx.test.runner.AndroidJUnit4;
@@ -42,15 +45,22 @@
 
     @Captor ArgumentCaptor<MediaBrowser.ConnectionCallback> mBrowserConnCb;
     @Captor ArgumentCaptor<MediaBrowser.SubscriptionCallback> mSubscriptionCb;
+    @Captor ArgumentCaptor<MediaController.Callback> mControllerCb;
+    @Captor ArgumentCaptor<Handler> mTimeoutHandler;
     @Captor ArgumentCaptor<List<ListItem>> mWrapperBrowseCb;
     @Mock MediaBrowser mMockBrowser;
     @Mock BrowsedPlayerWrapper.ConnectionCallback mConnCb;
     @Mock BrowsedPlayerWrapper.BrowseCallback mBrowseCb;
+    private HandlerThread mThread;
 
     @Before
     public void setUp() {
         MockitoAnnotations.initMocks(this);
 
+        // Set up Looper thread for the timeout handler
+        mThread = new HandlerThread("MediaPlayerWrapperTestThread");
+        mThread.start();
+
         when(mMockBrowser.getRoot()).thenReturn("root_folder");
 
         MediaBrowserFactory.inject(mMockBrowser);
@@ -58,7 +68,8 @@
 
     @Test
     public void testWrap() {
-        BrowsedPlayerWrapper wrapper = BrowsedPlayerWrapper.wrap(null, "test", "test");
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
         wrapper.connect(mConnCb);
         verify(mMockBrowser).testInit(any(), any(), mBrowserConnCb.capture(), any());
         verify(mMockBrowser).connect();
@@ -71,26 +82,54 @@
     }
 
     @Test
-    public void testConnect() {
-        BrowsedPlayerWrapper wrapper = BrowsedPlayerWrapper.wrap(null, "test", "test");
+    public void testConnect_Successful() {
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
         wrapper.connect(mConnCb);
         verify(mMockBrowser).testInit(any(), any(), mBrowserConnCb.capture(), any());
         MediaBrowser.ConnectionCallback browserConnCb = mBrowserConnCb.getValue();
-        browserConnCb.onConnectionFailed();
 
-        verify(mConnCb).run(eq(BrowsedPlayerWrapper.STATUS_CONN_ERROR), eq(wrapper));
-
-        wrapper.connect(mConnCb);
-        verify(mMockBrowser, times(2)).connect();
-
+        verify(mMockBrowser, times(1)).connect();
         browserConnCb.onConnected();
         verify(mConnCb).run(eq(BrowsedPlayerWrapper.STATUS_SUCCESS), eq(wrapper));
+        verify(mMockBrowser, times(1)).disconnect();
+    }
+
+    @Test
+    public void testConnect_Suspended() {
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
+        wrapper.connect(mConnCb);
+        verify(mMockBrowser).testInit(any(), any(), mBrowserConnCb.capture(), any());
+        MediaBrowser.ConnectionCallback browserConnCb = mBrowserConnCb.getValue();
+
+        verify(mMockBrowser, times(1)).connect();
+        browserConnCb.onConnectionSuspended();
+        verify(mConnCb).run(eq(BrowsedPlayerWrapper.STATUS_CONN_ERROR), eq(wrapper));
+        // Twice because our mConnCb is wrapped when using the plain connect() call and disconnect
+        // is called for us when the callback is invoked in addition to error handling calling
+        // disconnect.
         verify(mMockBrowser, times(2)).disconnect();
     }
 
     @Test
+    public void testConnect_Failed() {
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
+        wrapper.connect(mConnCb);
+        verify(mMockBrowser).testInit(any(), any(), mBrowserConnCb.capture(), any());
+        MediaBrowser.ConnectionCallback browserConnCb = mBrowserConnCb.getValue();
+
+        verify(mMockBrowser, times(1)).connect();
+        browserConnCb.onConnectionFailed();
+        verify(mConnCb).run(eq(BrowsedPlayerWrapper.STATUS_CONN_ERROR), eq(wrapper));
+        verify(mMockBrowser, times(1)).disconnect();
+    }
+
+    @Test
     public void testEmptyRoot() {
-        BrowsedPlayerWrapper wrapper = BrowsedPlayerWrapper.wrap(null, "test", "test");
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
 
         doReturn("").when(mMockBrowser).getRoot();
 
@@ -107,7 +146,8 @@
 
     @Test
     public void testDisconnect() {
-        BrowsedPlayerWrapper wrapper = BrowsedPlayerWrapper.wrap(null, "test", "test");
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
         wrapper.connect(mConnCb);
         verify(mMockBrowser).testInit(any(), any(), mBrowserConnCb.capture(), any());
         MediaBrowser.ConnectionCallback browserConnCb = mBrowserConnCb.getValue();
@@ -117,7 +157,8 @@
 
     @Test
     public void testGetRootId() {
-        BrowsedPlayerWrapper wrapper = BrowsedPlayerWrapper.wrap(null, "test", "test");
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
         wrapper.connect(mConnCb);
         verify(mMockBrowser).testInit(any(), any(), mBrowserConnCb.capture(), any());
         MediaBrowser.ConnectionCallback browserConnCb = mBrowserConnCb.getValue();
@@ -129,7 +170,8 @@
 
     @Test
     public void testPlayItem() {
-        BrowsedPlayerWrapper wrapper = BrowsedPlayerWrapper.wrap(null, "test", "test");
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
         verify(mMockBrowser).testInit(any(), any(), mBrowserConnCb.capture(), any());
         MediaBrowser.ConnectionCallback browserConnCb = mBrowserConnCb.getValue();
 
@@ -144,12 +186,56 @@
 
         browserConnCb.onConnected();
         verify(mockTransport).playFromMediaId(eq("test_item"), eq(null));
-        verify(mMockBrowser).disconnect();
+
+        // Do not immediately disconnect. Non-foreground playback serves will likely fail
+        verify(mMockBrowser, times(0)).disconnect();
+
+        verify(mockController).registerCallback(mControllerCb.capture(), any());
+        MediaController.Callback controllerCb = mControllerCb.getValue();
+        PlaybackState.Builder builder = new PlaybackState.Builder();
+
+        // Do not disconnect on an event that isn't "playing"
+        builder.setState(PlaybackState.STATE_PAUSED, 0, 1);
+        controllerCb.onPlaybackStateChanged(builder.build());
+        verify(mMockBrowser, times(0)).disconnect();
+
+        // Once we're told we're playing, make sure we disconnect
+        builder.setState(PlaybackState.STATE_PLAYING, 0, 1);
+        controllerCb.onPlaybackStateChanged(builder.build());
+        verify(mMockBrowser, times(1)).disconnect();
+    }
+
+    @Test
+    public void testPlayItem_Timeout() {
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
+        verify(mMockBrowser).testInit(any(), any(), mBrowserConnCb.capture(), any());
+        MediaBrowser.ConnectionCallback browserConnCb = mBrowserConnCb.getValue();
+
+        wrapper.playItem("test_item");
+        verify(mMockBrowser, times(1)).connect();
+
+        MediaController mockController = mock(MediaController.class);
+        MediaController.TransportControls mockTransport =
+                mock(MediaController.TransportControls.class);
+        when(mockController.getTransportControls()).thenReturn(mockTransport);
+        MediaControllerFactory.inject(mockController);
+
+        browserConnCb.onConnected();
+        verify(mockTransport).playFromMediaId(eq("test_item"), eq(null));
+
+        verify(mockController).registerCallback(any(), mTimeoutHandler.capture());
+        Handler timeoutHandler = mTimeoutHandler.getValue();
+
+        timeoutHandler.sendEmptyMessage(BrowsedPlayerWrapper.TimeoutHandler.MSG_TIMEOUT);
+
+        verify(mMockBrowser, timeout(2000).times(1)).disconnect();
     }
 
     @Test
     public void testGetFolderItems() {
-        BrowsedPlayerWrapper wrapper = BrowsedPlayerWrapper.wrap(null, "test", "test");
+        BrowsedPlayerWrapper wrapper =
+                BrowsedPlayerWrapper.wrap(null, mThread.getLooper(), "test", "test");
         verify(mMockBrowser).testInit(any(), any(), mBrowserConnCb.capture(), any());
         MediaBrowser.ConnectionCallback browserConnCb = mBrowserConnCb.getValue();