MediaSession2: Move MediaSession2/MediaController2 from experimental

APIs will be unhidden later

Test: Run MediaComponentsTest
Change-Id: I4e6f5937baa7e09cf850929e534ac44b5278d744
diff --git a/packages/MediaComponents/Android.mk b/packages/MediaComponents/Android.mk
index bbd2afa..f094f97 100644
--- a/packages/MediaComponents/Android.mk
+++ b/packages/MediaComponents/Android.mk
@@ -59,3 +59,5 @@
 LOCAL_USE_AAPT2 := true
 
 include $(BUILD_PACKAGE)
+
+include $(call all-makefiles-under,$(LOCAL_PATH))
diff --git a/packages/MediaComponents/src/com/android/media/MediaController2Impl.java b/packages/MediaComponents/src/com/android/media/MediaController2Impl.java
new file mode 100644
index 0000000..d7364da
--- /dev/null
+++ b/packages/MediaComponents/src/com/android/media/MediaController2Impl.java
@@ -0,0 +1,483 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.media;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.media.IMediaSession2;
+import android.media.IMediaSession2Callback;
+import android.media.MediaController2;
+import android.media.MediaController2.ControllerCallback;
+import android.media.MediaPlayerBase;
+import android.media.MediaSessionService2;
+import android.media.SessionToken;
+import android.media.session.PlaybackState;
+import android.media.update.MediaController2Provider;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.support.annotation.GuardedBy;
+import android.util.Log;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Executor;
+
+public class MediaController2Impl implements MediaController2Provider {
+    private static final String TAG = "MediaController2";
+    private static final boolean DEBUG = true; // TODO(jaewan): Change
+
+    private final MediaController2 mInstance;
+
+    /**
+     * Flag used by MediaController2Record to filter playback callback.
+     */
+    static final int CALLBACK_FLAG_PLAYBACK = 0x1;
+
+    static final int REQUEST_CODE_ALL = 0;
+
+    private final Object mLock = new Object();
+
+    private final Context mContext;
+    private final MediaSession2CallbackStub mSessionCallbackStub;
+    private final SessionToken mToken;
+    private final ControllerCallback mCallback;
+    private final Executor mCallbackExecutor;
+    private final IBinder.DeathRecipient mDeathRecipient;
+
+    @GuardedBy("mLock")
+    private final List<PlaybackListenerHolder> mPlaybackListeners = new ArrayList<>();
+    @GuardedBy("mLock")
+    private SessionServiceConnection mServiceConnection;
+    @GuardedBy("mLock")
+    private boolean mIsReleased;
+
+    // Assignment should be used with the lock hold, but should be used without a lock to prevent
+    // potential deadlock.
+    // Postfix -Binder is added to explicitly show that it's potentially remote process call.
+    // Technically -Interface is more correct, but it may misread that it's interface (vs class)
+    // so let's keep this postfix until we find better postfix.
+    @GuardedBy("mLock")
+    private volatile IMediaSession2 mSessionBinder;
+
+    // TODO(jaewan): Require session activeness changed listener, because controller can be
+    //               available when the session's player is null.
+    public MediaController2Impl(MediaController2 instance, Context context, SessionToken token,
+            ControllerCallback callback, Executor executor) {
+        mInstance = instance;
+
+        if (context == null) {
+            throw new IllegalArgumentException("context shouldn't be null");
+        }
+        if (token == null) {
+            throw new IllegalArgumentException("token shouldn't be null");
+        }
+        if (callback == null) {
+            throw new IllegalArgumentException("callback shouldn't be null");
+        }
+        if (executor == null) {
+            throw new IllegalArgumentException("executor shouldn't be null");
+        }
+        mContext = context;
+        mSessionCallbackStub = new MediaSession2CallbackStub(this);
+        mToken = token;
+        mCallback = callback;
+        mCallbackExecutor = executor;
+        mDeathRecipient = () -> {
+            mInstance.release();
+        };
+
+        mSessionBinder = null;
+
+        if (token.getSessionBinder() == null) {
+            mServiceConnection = new SessionServiceConnection();
+            connectToService();
+        } else {
+            mServiceConnection = null;
+            connectToSession(token.getSessionBinder());
+        }
+    }
+
+    // Should be only called by constructor.
+    private void connectToService() {
+        // Service. Needs to get fresh binder whenever connection is needed.
+        final Intent intent = new Intent(MediaSessionService2.SERVICE_INTERFACE);
+        intent.setClassName(mToken.getPackageName(), mToken.getServiceName());
+
+        // Use bindService() instead of startForegroundService() to start session service for three
+        // reasons.
+        // 1. Prevent session service owner's stopSelf() from destroying service.
+        //    With the startForegroundService(), service's call of stopSelf() will trigger immediate
+        //    onDestroy() calls on the main thread even when onConnect() is running in another
+        //    thread.
+        // 2. Minimize APIs for developers to take care about.
+        //    With bindService(), developers only need to take care about Service.onBind()
+        //    but Service.onStartCommand() should be also taken care about with the
+        //    startForegroundService().
+        // 3. Future support for UI-less playback
+        //    If a service wants to keep running, it should be either foreground service or
+        //    bounded service. But there had been request for the feature for system apps
+        //    and using bindService() will be better fit with it.
+        // TODO(jaewan): Use bindServiceAsUser()??
+        boolean result = mContext.bindService(intent, mServiceConnection, Context.BIND_AUTO_CREATE);
+        if (!result) {
+            Log.w(TAG, "bind to " + mToken + " failed");
+        } else if (DEBUG) {
+            Log.d(TAG, "bind to " + mToken + " success");
+        }
+    }
+
+    private void connectToSession(IMediaSession2 sessionBinder) {
+        try {
+            sessionBinder.connect(mContext.getPackageName(), mSessionCallbackStub);
+        } catch (RemoteException e) {
+            Log.w(TAG, "Failed to call connection request. Framework will retry"
+                    + " automatically");
+        }
+    }
+
+    @Override
+    public void release_impl() {
+        final IMediaSession2 binder;
+        synchronized (mLock) {
+            if (mIsReleased) {
+                // Prevent re-enterance from the ControllerCallback.onDisconnected()
+                return;
+            }
+            mIsReleased = true;
+            if (mServiceConnection != null) {
+                mContext.unbindService(mServiceConnection);
+                mServiceConnection = null;
+            }
+            mPlaybackListeners.clear();
+            binder = mSessionBinder;
+            mSessionBinder = null;
+            mSessionCallbackStub.destroy();
+        }
+        if (binder != null) {
+            try {
+                binder.asBinder().unlinkToDeath(mDeathRecipient, 0);
+                binder.release(mSessionCallbackStub);
+            } catch (RemoteException e) {
+                // No-op.
+            }
+        }
+        mCallbackExecutor.execute(() -> {
+            mCallback.onDisconnected();
+        });
+    }
+
+    @Override
+    public SessionToken getSessionToken_impl() {
+        return mToken;
+    }
+
+    @Override
+    public boolean isConnected_impl() {
+        final IMediaSession2 binder = mSessionBinder;
+        return binder != null;
+    }
+
+    @Override
+    public void play_impl() {
+        final IMediaSession2 binder = mSessionBinder;
+        if (binder != null) {
+            try {
+                binder.play(mSessionCallbackStub);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Cannot connect to the service or the session is gone", e);
+            }
+        } else {
+            Log.w(TAG, "Session isn't active", new IllegalStateException());
+        }
+    }
+
+    @Override
+    public void pause_impl() {
+        final IMediaSession2 binder = mSessionBinder;
+        if (binder != null) {
+            try {
+                binder.pause(mSessionCallbackStub);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Cannot connect to the service or the session is gone", e);
+            }
+        } else {
+            Log.w(TAG, "Session isn't active", new IllegalStateException());
+        }
+    }
+
+    @Override
+    public void stop_impl() {
+        final IMediaSession2 binder = mSessionBinder;
+        if (binder != null) {
+            try {
+                binder.stop(mSessionCallbackStub);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Cannot connect to the service or the session is gone", e);
+            }
+        } else {
+            Log.w(TAG, "Session isn't active", new IllegalStateException());
+        }
+    }
+
+    @Override
+    public void skipToPrevious_impl() {
+        final IMediaSession2 binder = mSessionBinder;
+        if (binder != null) {
+            try {
+                binder.skipToPrevious(mSessionCallbackStub);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Cannot connect to the service or the session is gone", e);
+            }
+        } else {
+            Log.w(TAG, "Session isn't active", new IllegalStateException());
+        }
+    }
+
+    @Override
+    public void skipToNext_impl() {
+        final IMediaSession2 binder = mSessionBinder;
+        if (binder != null) {
+            try {
+                binder.skipToNext(mSessionCallbackStub);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Cannot connect to the service or the session is gone", e);
+            }
+        } else {
+            Log.w(TAG, "Session isn't active", new IllegalStateException());
+        }
+    }
+
+    @Override
+    public PlaybackState getPlaybackState_impl() {
+        final IMediaSession2 binder = mSessionBinder;
+        if (binder != null) {
+            try {
+                return binder.getPlaybackState();
+            } catch (RemoteException e) {
+                Log.w(TAG, "Cannot connect to the service or the session is gone", e);
+            }
+        } else {
+            Log.w(TAG, "Session isn't active", new IllegalStateException());
+        }
+        // TODO(jaewan): What to return for error case?
+        return null;
+    }
+
+    @Override
+    public void addPlaybackListener_impl(
+            MediaPlayerBase.PlaybackListener listener, Handler handler) {
+        if (listener == null) {
+            throw new IllegalArgumentException("listener shouldn't be null");
+        }
+        if (handler == null) {
+            throw new IllegalArgumentException("handler shouldn't be null");
+        }
+        boolean registerCallback;
+        synchronized (mLock) {
+            if (PlaybackListenerHolder.contains(mPlaybackListeners, listener)) {
+                throw new IllegalArgumentException("listener is already added. Ignoring.");
+            }
+            registerCallback = mPlaybackListeners.isEmpty();
+            mPlaybackListeners.add(new PlaybackListenerHolder(listener, handler));
+        }
+        if (registerCallback) {
+            registerCallbackForPlaybackNotLocked();
+        }
+    }
+
+    @Override
+    public void removePlaybackListener_impl(MediaPlayerBase.PlaybackListener listener) {
+        if (listener == null) {
+            throw new IllegalArgumentException("listener shouldn't be null");
+        }
+        boolean unregisterCallback;
+        synchronized (mLock) {
+            int idx = PlaybackListenerHolder.indexOf(mPlaybackListeners, listener);
+            if (idx >= 0) {
+                mPlaybackListeners.get(idx).removeCallbacksAndMessages(null);
+                mPlaybackListeners.remove(idx);
+            }
+            unregisterCallback = mPlaybackListeners.isEmpty();
+        }
+        if (unregisterCallback) {
+            final IMediaSession2 binder = mSessionBinder;
+            if (binder != null) {
+                // Lazy unregister
+                try {
+                    binder.unregisterCallback(mSessionCallbackStub, CALLBACK_FLAG_PLAYBACK);
+                } catch (RemoteException e) {
+                    Log.e(TAG, "Cannot connect to the service or the session is gone", e);
+                }
+            }
+        }
+    }
+
+    ///////////////////////////////////////////////////
+    // Protected or private methods
+    ///////////////////////////////////////////////////
+    // Should be used without a lock to prevent potential deadlock.
+    private void registerCallbackForPlaybackNotLocked() {
+        final IMediaSession2 binder = mSessionBinder;
+        if (binder != null) {
+            try {
+                binder.registerCallback(mSessionCallbackStub,
+                        CALLBACK_FLAG_PLAYBACK, REQUEST_CODE_ALL);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Cannot connect to the service or the session is gone", e);
+            }
+        }
+    }
+
+    private void pushPlaybackStateChanges(final PlaybackState state) {
+        synchronized (mLock) {
+            for (int i = 0; i < mPlaybackListeners.size(); i++) {
+                mPlaybackListeners.get(i).postPlaybackChange(state);
+            }
+        }
+    }
+
+    // Called when the result for connecting to the session was delivered.
+    // Should be used without a lock to prevent potential deadlock.
+    private void onConnectionChangedNotLocked(IMediaSession2 sessionBinder, long commands) {
+        if (DEBUG) {
+            Log.d(TAG, "onConnectionChangedNotLocked sessionBinder=" + sessionBinder);
+        }
+        boolean release = false;
+        try {
+            if (sessionBinder == null) {
+                // Connection rejected.
+                release = true;
+                return;
+            }
+            boolean registerCallbackForPlaybackNeeded;
+            synchronized (mLock) {
+                if (mIsReleased) {
+                    return;
+                }
+                if (mSessionBinder != null) {
+                    Log.e(TAG, "Cannot be notified about the connection result many times."
+                            + " Probably a bug or malicious app.");
+                    release = true;
+                    return;
+                }
+                mSessionBinder = sessionBinder;
+                try {
+                    // Implementation for the local binder is no-op,
+                    // so can be used without worrying about deadlock.
+                    mSessionBinder.asBinder().linkToDeath(mDeathRecipient, 0);
+                } catch (RemoteException e) {
+                    release = true;
+                    return;
+                }
+                registerCallbackForPlaybackNeeded = !mPlaybackListeners.isEmpty();
+            }
+            // TODO(jaewan): Keep commands to prevents illegal API calls.
+            mCallbackExecutor.execute(() -> {
+                mCallback.onConnected(commands);
+            });
+            if (registerCallbackForPlaybackNeeded) {
+                registerCallbackForPlaybackNotLocked();
+            }
+        } finally {
+            if (release) {
+                // Trick to call release() without holding the lock, to prevent potential deadlock
+                // with the developer's custom lock within the ControllerCallback.onDisconnected().
+                mInstance.release();
+            }
+        }
+    }
+
+    private static class MediaSession2CallbackStub extends IMediaSession2Callback.Stub {
+        private final WeakReference<MediaController2Impl> mController;
+
+        private MediaSession2CallbackStub(MediaController2Impl controller) {
+            mController = new WeakReference<>(controller);
+        }
+
+        private MediaController2Impl getController() throws IllegalStateException {
+            final MediaController2Impl controller = mController.get();
+            if (controller == null) {
+                throw new IllegalStateException("Controller is released");
+            }
+            return controller;
+        }
+
+        public void destroy() {
+            mController.clear();
+        }
+
+        @Override
+        public void onPlaybackStateChanged(PlaybackState state) throws RuntimeException {
+            final MediaController2Impl controller = getController();
+            controller.pushPlaybackStateChanges(state);
+        }
+
+        @Override
+        public void onConnectionChanged(IMediaSession2 sessionBinder, long commands)
+                throws RuntimeException {
+            final MediaController2Impl controller;
+            try {
+                controller = getController();
+            } catch (IllegalStateException e) {
+                Log.w(TAG, "Don't fail silently here. Highly likely a bug");
+                return;
+            }
+            controller.onConnectionChangedNotLocked(sessionBinder, commands);
+        }
+    }
+
+    // This will be called on the main thread.
+    private class SessionServiceConnection implements ServiceConnection {
+        @Override
+        public void onServiceConnected(ComponentName name, IBinder service) {
+            // Note that it's always main-thread.
+            if (DEBUG) {
+                Log.d(TAG, "onServiceConnected " + name + " " + this);
+            }
+            // Sanity check
+            if (!mToken.getPackageName().equals(name.getPackageName())) {
+                Log.wtf(TAG, name + " was connected, but expected pkg="
+                        + mToken.getPackageName() + " with id=" + mToken.getId());
+                return;
+            }
+            final IMediaSession2 sessionBinder = IMediaSession2.Stub.asInterface(service);
+            connectToSession(sessionBinder);
+        }
+
+        @Override
+        public void onServiceDisconnected(ComponentName name) {
+            // Temporal lose of the binding because of the service crash. System will automatically
+            // rebind, so just no-op.
+            // TODO(jaewan): Really? Either disconnect cleanly or
+            if (DEBUG) {
+                Log.w(TAG, "Session service " + name + " is disconnected.");
+            }
+        }
+
+        @Override
+        public void onBindingDied(ComponentName name) {
+            // Permanent lose of the binding because of the service package update or removed.
+            // This SessionServiceRecord will be removed accordingly, but forget session binder here
+            // for sure.
+            mInstance.release();
+        }
+    }
+}
diff --git a/packages/MediaComponents/src/com/android/media/MediaSession2Impl.java b/packages/MediaComponents/src/com/android/media/MediaSession2Impl.java
new file mode 100644
index 0000000..09d7adc
--- /dev/null
+++ b/packages/MediaComponents/src/com/android/media/MediaSession2Impl.java
@@ -0,0 +1,424 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.media;
+
+import android.Manifest.permission;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.media.IMediaSession2Callback;
+import android.media.MediaController2;
+import android.media.MediaPlayerBase;
+import android.media.MediaSession2;
+import android.media.MediaSession2.Builder;
+import android.media.MediaSession2.ControllerInfo;
+import android.media.MediaSession2.SessionCallback;
+import android.media.SessionToken;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+import android.media.update.MediaSession2Provider;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.util.Log;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
+
+public class MediaSession2Impl implements MediaSession2Provider {
+    private static final String TAG = "MediaSession2";
+    private static final boolean DEBUG = true;//Log.isLoggable(TAG, Log.DEBUG);
+
+    private final MediaSession2 mInstance;
+
+    private final Context mContext;
+    private final String mId;
+    private final Handler mHandler;
+    private final SessionCallback mCallback;
+    private final MediaSession2Stub mSessionStub;
+    private final SessionToken mSessionToken;
+
+    private MediaPlayerBase mPlayer;
+
+    private final List<PlaybackListenerHolder> mListeners = new ArrayList<>();
+    private MyPlaybackListener mListener;
+    private MediaSession2 instance;
+
+    /**
+     * Can be only called by the {@link Builder#build()}.
+     *
+     * @param instance
+     * @param context
+     * @param player
+     * @param id
+     * @param callback
+     */
+    public MediaSession2Impl(MediaSession2 instance, Context context, MediaPlayerBase player,
+            String id, SessionCallback callback) {
+        mInstance = instance;
+
+        // Argument checks are done by builder already.
+        // Initialize finals first.
+        mContext = context;
+        mId = id;
+        mHandler = new Handler(Looper.myLooper());
+        mCallback = callback;
+        mSessionStub = new MediaSession2Stub(this);
+        // Ask server to create session token for following reasons.
+        //   1. Make session ID unique per package.
+        //      Server can only know if the package has another process and has another session
+        //      with the same id. Let server check this.
+        //      Note that 'ID is unique per package' is important for controller to distinguish
+        //      a session in another package.
+        //   2. Easier to know the type of session.
+        //      Session created here can be the session service token. In order distinguish,
+        //      we need to iterate AndroidManifest.xml but it's already done by the server.
+        //      Let server to create token with the type.
+        MediaSessionManager manager =
+                (MediaSessionManager) mContext.getSystemService(Context.MEDIA_SESSION_SERVICE);
+        mSessionToken = manager.createSessionToken(mContext.getPackageName(), mId, mSessionStub);
+        if (mSessionToken == null) {
+            throw new IllegalStateException("Session with the same id is already used by"
+                    + " another process. Use MediaController2 instead.");
+        }
+
+        setPlayerInternal(player);
+    }
+
+    // TODO(jaewan): Add explicit release() and do not remove session object with the
+    //               setPlayer(null). Token can be available when player is null, and
+    //               controller can also attach to session.
+    @Override
+    public void setPlayer_impl(MediaPlayerBase player) throws IllegalArgumentException {
+        ensureCallingThread();
+        // TODO(jaewan): Remove this when we don't inherits MediaPlayerBase.
+        if (player instanceof MediaSession2 || player instanceof MediaController2) {
+            throw new IllegalArgumentException("player doesn't accept MediaSession2 nor"
+                    + " MediaController2");
+        }
+        if (player != null && mPlayer == player) {
+            // Player didn't changed. No-op.
+            return;
+        }
+        setPlayerInternal(player);
+    }
+
+    private void setPlayerInternal(MediaPlayerBase player) {
+        mHandler.removeCallbacksAndMessages(null);
+        if (mPlayer == null && player != null) {
+            if (DEBUG) {
+                Log.d(TAG, "session is ready to use, id=" + mId);
+            }
+        } else if (mPlayer != null && player == null) {
+            if (DEBUG) {
+                Log.d(TAG, "session is now unavailable, id=" + mId);
+            }
+            if (mSessionStub != null) {
+                // Invalidate previously published session stub.
+                mSessionStub.destroyNotLocked();
+            }
+        }
+        if (mPlayer != null && mListener != null) {
+            // This might not work for a poorly implemented player.
+            mPlayer.removePlaybackListener(mListener);
+        }
+        if (player != null) {
+            mListener = new MyPlaybackListener(this, player);
+            player.addPlaybackListener(mListener, mHandler);
+            notifyPlaybackStateChanged(player.getPlaybackState());
+        }
+        mPlayer = player;
+    }
+
+    @Override
+    public MediaPlayerBase getPlayer_impl() {
+        return getPlayer();
+    }
+
+    // TODO(jaewan): Change this to @NonNull
+    @Override
+    public SessionToken getToken_impl() {
+        return mSessionToken;
+    }
+
+    @Override
+    public List<ControllerInfo> getConnectedControllers_impl() {
+        return mSessionStub.getControllers();
+    }
+
+    @Override
+    public void play_impl() {
+        ensureCallingThread();
+        ensurePlayer();
+        mPlayer.play();
+    }
+
+    @Override
+    public void pause_impl() {
+        ensureCallingThread();
+        ensurePlayer();
+        mPlayer.pause();
+    }
+
+    @Override
+    public void stop_impl() {
+        ensureCallingThread();
+        ensurePlayer();
+        mPlayer.stop();
+    }
+
+    @Override
+    public void skipToPrevious_impl() {
+        ensureCallingThread();
+        ensurePlayer();
+        mPlayer.skipToPrevious();
+    }
+
+    @Override
+    public void skipToNext_impl() {
+        ensureCallingThread();
+        ensurePlayer();
+        mPlayer.skipToNext();
+    }
+
+    @Override
+    public PlaybackState getPlaybackState_impl() {
+        ensureCallingThread();
+        ensurePlayer();
+        return mPlayer.getPlaybackState();
+    }
+
+    @Override
+    public void addPlaybackListener_impl(
+            MediaPlayerBase.PlaybackListener listener, Handler handler) {
+        if (listener == null) {
+            throw new IllegalArgumentException("listener shouldn't be null");
+        }
+        if (handler == null) {
+            throw new IllegalArgumentException("handler shouldn't be null");
+        }
+        ensureCallingThread();
+        if (PlaybackListenerHolder.contains(mListeners, listener)) {
+            Log.w(TAG, "listener is already added. Ignoring.");
+            return;
+        }
+        mListeners.add(new PlaybackListenerHolder(listener, handler));
+    }
+
+    @Override
+    public void removePlaybackListener_impl(MediaPlayerBase.PlaybackListener listener) {
+        if (listener == null) {
+            throw new IllegalArgumentException("listener shouldn't be null");
+        }
+        ensureCallingThread();
+        int idx = PlaybackListenerHolder.indexOf(mListeners, listener);
+        if (idx >= 0) {
+            mListeners.get(idx).removeCallbacksAndMessages(null);
+            mListeners.remove(idx);
+        }
+    }
+
+    ///////////////////////////////////////////////////
+    // Protected or private methods
+    ///////////////////////////////////////////////////
+
+    // Enforces developers to call all the methods on the initially given thread
+    // because calls from the MediaController2 will be run on the thread.
+    // TODO(jaewan): Should we allow calls from the multiple thread?
+    //               I prefer this way because allowing multiple thread may case tricky issue like
+    //               b/63446360. If the {@link #setPlayer()} with {@code null} can be called from
+    //               another thread, transport controls can be called after that.
+    //               That's basically the developer's mistake, but they cannot understand what's
+    //               happening behind until we tell them so.
+    //               If enforcing callling thread doesn't look good, we can alternatively pick
+    //               1. Allow calls from random threads for all methods.
+    //               2. Allow calls from random threads for all methods, except for the
+    //                  {@link #setPlayer()}.
+    // TODO(jaewan): Should we pend command instead of exception?
+    private void ensureCallingThread() {
+        if (mHandler.getLooper() != Looper.myLooper()) {
+            throw new IllegalStateException("Run this on the given thread");
+        }
+    }
+
+    private void ensurePlayer() {
+        // TODO(jaewan): Should we pend command instead? Follow the decision from MP2.
+        //               Alternatively we can add a API like setAcceptsPendingCommands(boolean).
+        if (mPlayer == null) {
+            throw new IllegalStateException("Player isn't set");
+        }
+    }
+
+    Handler getHandler() {
+        return mHandler;
+    }
+
+    private void notifyPlaybackStateChanged(PlaybackState state) {
+        // Notify to listeners added directly to this session
+        for (int i = 0; i < mListeners.size(); i++) {
+            mListeners.get(i).postPlaybackChange(state);
+        }
+        // Notify to controllers as well.
+        mSessionStub.notifyPlaybackStateChangedNotLocked(state);
+    }
+
+    Context getContext() {
+        return mContext;
+    }
+
+    MediaSession2 getInstance() {
+        return mInstance;
+    }
+
+    SessionCallback getCallback() {
+        return mCallback;
+    }
+
+    MediaPlayerBase getPlayer() {
+        return mPlayer;
+    }
+
+    private static class MyPlaybackListener implements MediaPlayerBase.PlaybackListener {
+        private final WeakReference<MediaSession2Impl> mSession;
+        private final MediaPlayerBase mPlayer;
+
+        private MyPlaybackListener(MediaSession2Impl session, MediaPlayerBase player) {
+            mSession = new WeakReference<>(session);
+            mPlayer = player;
+        }
+
+        @Override
+        public void onPlaybackChanged(PlaybackState state) {
+            MediaSession2Impl session = mSession.get();
+            if (session == null || session.getHandler().getLooper() != Looper.myLooper()
+                    || mPlayer != session.mInstance.getPlayer()) {
+                Log.w(TAG, "Unexpected playback state change notifications. Ignoring.",
+                        new IllegalStateException());
+                return;
+            }
+            session.notifyPlaybackStateChanged(state);
+        }
+    }
+
+    public static class ControllerInfoImpl implements ControllerInfoProvider {
+        private final ControllerInfo mInstance;
+        private final int mUid;
+        private final String mPackageName;
+        private final boolean mIsTrusted;
+        private final IMediaSession2Callback mControllerBinder;
+
+        // Flag to indicate which callbacks should be returned for the controller binder.
+        // Either 0 or combination of {@link #CALLBACK_FLAG_PLAYBACK},
+        // {@link #CALLBACK_FLAG_SESSION_ACTIVENESS}
+        private int mFlag;
+
+        public ControllerInfoImpl(ControllerInfo instance, Context context, int uid,
+                int pid, String packageName, IMediaSession2Callback callback) {
+            mInstance = instance;
+            mUid = uid;
+            mPackageName = packageName;
+
+            // TODO(jaewan): Remove this workaround
+            if ("com.android.server.media".equals(packageName)) {
+                mIsTrusted = true;
+            } else if (context.checkPermission(permission.MEDIA_CONTENT_CONTROL, pid, uid) ==
+                    PackageManager.PERMISSION_GRANTED) {
+                mIsTrusted = true;
+            } else {
+                // TODO(jaewan): Also consider enabled notification listener.
+                mIsTrusted = false;
+                // System apps may bind across the user so uid can be differ.
+                // Skip sanity check for the system app.
+                try {
+                    int uidForPackage = context.getPackageManager().getPackageUid(packageName, 0);
+                    if (uid != uidForPackage) {
+                        throw new IllegalArgumentException("Illegal call from uid=" + uid +
+                                ", pkg=" + packageName + ". Expected uid" + uidForPackage);
+                    }
+                } catch (NameNotFoundException e) {
+                    // Rethrow exception with different name because binder methods only accept
+                    // RemoteException.
+                    throw new IllegalArgumentException(e);
+                }
+            }
+            mControllerBinder = callback;
+        }
+
+        @Override
+        public String getPackageName_impl() {
+            return mPackageName;
+        }
+
+        @Override
+        public int getUid_impl() {
+            return mUid;
+        }
+
+        @Override
+        public boolean isTrusted_impl() {
+            return mIsTrusted;
+        }
+
+        @Override
+        public int hashCode_impl() {
+            return mControllerBinder.hashCode();
+        }
+
+        @Override
+        public boolean equals_impl(ControllerInfoProvider obj) {
+            return equals(obj);
+        }
+
+        @Override
+        public int hashCode() {
+            return mControllerBinder.hashCode();
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (!(obj instanceof ControllerInfoImpl)) {
+                return false;
+            }
+            ControllerInfoImpl other = (ControllerInfoImpl) obj;
+            return mControllerBinder.asBinder().equals(other.mControllerBinder.asBinder());
+        }
+
+        public ControllerInfo getInstance() {
+            return mInstance;
+        }
+
+        public IBinder getId() {
+            return mControllerBinder.asBinder();
+        }
+
+        public IMediaSession2Callback getControllerBinder() {
+            return mControllerBinder;
+        }
+
+        public boolean containsFlag(int flag) {
+            return (mFlag & flag) != 0;
+        }
+
+        public void addFlag(int flag) {
+            mFlag |= flag;
+        }
+
+        public void removeFlag(int flag) {
+            mFlag &= ~flag;
+        }
+    }
+}
diff --git a/packages/MediaComponents/src/com/android/media/MediaSession2Stub.java b/packages/MediaComponents/src/com/android/media/MediaSession2Stub.java
new file mode 100644
index 0000000..20e493e
--- /dev/null
+++ b/packages/MediaComponents/src/com/android/media/MediaSession2Stub.java
@@ -0,0 +1,324 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.media;
+
+import static com.android.media.MediaController2Impl.CALLBACK_FLAG_PLAYBACK;
+
+import android.content.Context;
+import android.media.IMediaSession2;
+import android.media.IMediaSession2Callback;
+import android.media.MediaSession2;
+import android.media.MediaSession2.CommandFlags;
+import android.media.MediaSession2.ControllerInfo;
+import android.media.session.PlaybackState;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.os.RemoteException;
+import android.support.annotation.GuardedBy;
+import android.util.ArrayMap;
+import android.util.Log;
+import com.android.media.MediaSession2Impl.ControllerInfoImpl;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
+
+// TODO(jaewan): Add a hook for media apps to log which app requested specific command.
+// TODO(jaewan): Add a way to block specific command from a specific app. Also add supported
+// command per apps.
+public class MediaSession2Stub extends IMediaSession2.Stub {
+    private static final String TAG = "MediaSession2Stub";
+    private static final boolean DEBUG = true; // TODO(jaewan): Rename.
+
+    private final Object mLock = new Object();
+    private final CommandHandler mCommandHandler;
+    private final WeakReference<MediaSession2Impl> mSession;
+    private final Context mContext;
+
+    @GuardedBy("mLock")
+    private final ArrayMap<IBinder, ControllerInfo> mControllers = new ArrayMap<>();
+
+    public MediaSession2Stub(MediaSession2Impl session) {
+        mSession = new WeakReference<>(session);
+        mContext = session.getContext();
+        mCommandHandler = new CommandHandler(session.getHandler().getLooper());
+    }
+
+    public void destroyNotLocked() {
+        final List<ControllerInfo> list;
+        synchronized (mLock) {
+            mSession.clear();
+            mCommandHandler.removeCallbacksAndMessages(null);
+            list = getControllers();
+            mControllers.clear();
+        }
+        for (int i = 0; i < list.size(); i++) {
+            IMediaSession2Callback callbackBinder =
+                    ((ControllerInfoImpl) list.get(i).getProvider()).getControllerBinder();
+            try {
+                // Should be used without a lock hold to prevent potential deadlock.
+                callbackBinder.onConnectionChanged(null, 0);
+            } catch (RemoteException e) {
+                // Controller is gone. Should be fine because we're destroying.
+            }
+        }
+    }
+
+    private MediaSession2Impl getSession() throws IllegalStateException {
+        final MediaSession2Impl session = mSession.get();
+        if (session == null) {
+            throw new IllegalStateException("Session is died");
+        }
+        return session;
+    }
+
+    @Override
+    public void connect(String callingPackage, IMediaSession2Callback callback) {
+        if (callback == null) {
+            // Requesting connect without callback to receive result.
+            return;
+        }
+        ControllerInfo request = new ControllerInfo(mContext,
+                Binder.getCallingUid(), Binder.getCallingPid(), callingPackage, callback);
+        mCommandHandler.postConnect(request);
+    }
+
+    @Override
+    public void release(IMediaSession2Callback caller) throws RemoteException {
+        synchronized (mLock) {
+            ControllerInfo controllerInfo = mControllers.remove(caller.asBinder());
+            if (DEBUG) {
+                Log.d(TAG, "releasing " + controllerInfo);
+            }
+        }
+    }
+
+    @Override
+    public void play(IMediaSession2Callback caller) throws RemoteException {
+        onCommand(caller, MediaSession2.COMMAND_FLAG_PLAYBACK_START);
+    }
+
+    @Override
+    public void pause(IMediaSession2Callback caller) throws RemoteException {
+        onCommand(caller, MediaSession2.COMMAND_FLAG_PLAYBACK_PAUSE);
+    }
+
+    @Override
+    public void stop(IMediaSession2Callback caller) throws RemoteException {
+        onCommand(caller, MediaSession2.COMMAND_FLAG_PLAYBACK_STOP);
+    }
+
+    @Override
+    public void skipToPrevious(IMediaSession2Callback caller) throws RemoteException {
+        onCommand(caller, MediaSession2.COMMAND_FLAG_PLAYBACK_SKIP_PREV_ITEM);
+    }
+
+    @Override
+    public void skipToNext(IMediaSession2Callback caller) throws RemoteException {
+        onCommand(caller, MediaSession2.COMMAND_FLAG_PLAYBACK_SKIP_NEXT_ITEM);
+    }
+
+    private void onCommand(IMediaSession2Callback caller, @CommandFlags long command)
+            throws IllegalArgumentException {
+        ControllerInfo controller = getController(caller);
+        if (controller == null) {
+            if (DEBUG) {
+                Log.d(TAG, "Command from a controller that hasn't connected. Ignore");
+            }
+            return;
+        }
+        mCommandHandler.postCommand(controller, command);
+    }
+
+    @Deprecated
+    @Override
+    public PlaybackState getPlaybackState() throws RemoteException {
+        MediaSession2Impl session = getSession();
+        // TODO(jaewan): Check if mPlayer.getPlaybackState() is safe here.
+        return session.getInstance().getPlayer().getPlaybackState();
+    }
+
+    @Deprecated
+    @Override
+    public void registerCallback(final IMediaSession2Callback callbackBinder,
+            final int callbackFlag, final int requestCode) throws RemoteException {
+        // TODO(jaewan): Call onCommand() here. To do so, you should pend message.
+        synchronized (mLock) {
+            ControllerInfo controllerInfo = getController(callbackBinder);
+            if (controllerInfo == null) {
+                return;
+            }
+            ((ControllerInfoImpl) controllerInfo.getProvider()).addFlag(callbackFlag);
+        }
+    }
+
+    @Deprecated
+    @Override
+    public void unregisterCallback(IMediaSession2Callback callbackBinder, int callbackFlag)
+            throws RemoteException {
+        // TODO(jaewan): Call onCommand() here. To do so, you should pend message.
+        synchronized (mLock) {
+            ControllerInfo controllerInfo = getController(callbackBinder);
+            if (controllerInfo == null) {
+                return;
+            }
+            ControllerInfoImpl impl =
+                    ((ControllerInfoImpl) controllerInfo.getProvider());
+            impl.removeFlag(callbackFlag);
+        }
+    }
+
+    private ControllerInfo getController(IMediaSession2Callback caller) {
+        synchronized (mLock) {
+            return mControllers.get(caller.asBinder());
+        }
+    }
+
+    public List<ControllerInfo> getControllers() {
+        ArrayList<ControllerInfo> controllers = new ArrayList<>();
+        synchronized (mLock) {
+            for (int i = 0; i < mControllers.size(); i++) {
+                controllers.add(mControllers.valueAt(i));
+            }
+        }
+        return controllers;
+    }
+
+    public List<ControllerInfo> getControllersWithFlag(int flag) {
+        ArrayList<ControllerInfo> controllers = new ArrayList<>();
+        synchronized (mLock) {
+            for (int i = 0; i < mControllers.size(); i++) {
+                ControllerInfo controllerInfo = mControllers.valueAt(i);
+                if (((ControllerInfoImpl) controllerInfo.getProvider()).containsFlag(flag)) {
+                    controllers.add(controllerInfo);
+                }
+            }
+        }
+        return controllers;
+    }
+
+    // Should be used without a lock to prevent potential deadlock.
+    public void notifyPlaybackStateChangedNotLocked(PlaybackState state) {
+        final List<ControllerInfo> list = getControllersWithFlag(CALLBACK_FLAG_PLAYBACK);
+        for (int i = 0; i < list.size(); i++) {
+            IMediaSession2Callback callbackBinder =
+                    ((ControllerInfoImpl) list.get(i).getProvider())
+                            .getControllerBinder();
+            try {
+                callbackBinder.onPlaybackStateChanged(state);
+            } catch (RemoteException e) {
+                Log.w(TAG, "Controller is gone", e);
+                // TODO(jaewan): What to do when the controller is gone?
+            }
+        }
+    }
+
+    private class CommandHandler extends Handler {
+        public static final int MSG_CONNECT = 1000;
+        public static final int MSG_COMMAND = 1001;
+
+        public CommandHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            final MediaSession2Impl session = MediaSession2Stub.this.mSession.get();
+            if (session == null || session.getPlayer() == null) {
+                return;
+            }
+
+            switch (msg.what) {
+                case MSG_CONNECT:
+                    ControllerInfo request = (ControllerInfo) msg.obj;
+                    long allowedCommands = session.getCallback().onConnect(request);
+                    // Don't reject connection for the request from trusted app.
+                    // Otherwise server will fail to retrieve session's information to dispatch
+                    // media keys to.
+                    boolean accept = (allowedCommands != 0) || request.isTrusted();
+                    ControllerInfoImpl impl =
+                            (ControllerInfoImpl) request.getProvider();
+                    if (accept) {
+                        synchronized (mLock) {
+                            mControllers.put(impl.getId(), request);
+                        }
+                    }
+                    if (DEBUG) {
+                        Log.d(TAG, "onConnectResult, request=" + request
+                                + " accept=" + accept);
+                    }
+                    try {
+                        impl.getControllerBinder().onConnectionChanged(
+                                accept ? MediaSession2Stub.this : null,
+                                allowedCommands);
+                    } catch (RemoteException e) {
+                        // Controller may be died prematurely.
+                    }
+                    break;
+                case MSG_COMMAND:
+                    CommandParam param = (CommandParam) msg.obj;
+                    long command = param.command;
+                    boolean accepted = session.getCallback().onCommand(param.controller, command);
+                    if (!accepted) {
+                        // Don't run rejected command.
+                        if (DEBUG) {
+                            Log.d(TAG, "Command " + command + " from "
+                                    + param.controller + " was rejected by " + session);
+                        }
+                        return;
+                    }
+
+                    // Switch cannot be used because command is long, but switch only supports
+                    // int.
+                    // TODO(jaewan): Replace this with the switch
+                    if (command == MediaSession2.COMMAND_FLAG_PLAYBACK_START) {
+                        session.getInstance().play();
+                    } else if (command == MediaSession2.COMMAND_FLAG_PLAYBACK_PAUSE) {
+                        session.getInstance().pause();
+                    } else if (command == MediaSession2.COMMAND_FLAG_PLAYBACK_STOP) {
+                        session.getInstance().stop();
+                    } else if (command == MediaSession2.COMMAND_FLAG_PLAYBACK_SKIP_PREV_ITEM) {
+                        session.getInstance().skipToPrevious();
+                    } else if (command == MediaSession2.COMMAND_FLAG_PLAYBACK_SKIP_NEXT_ITEM) {
+                        session.getInstance().skipToNext();
+                    }
+                    break;
+            }
+        }
+
+        public void postConnect(ControllerInfo request) {
+            obtainMessage(MSG_CONNECT, request).sendToTarget();
+        }
+
+        public void postCommand(ControllerInfo controller, @CommandFlags long command) {
+            CommandParam param = new CommandParam(controller, command);
+            obtainMessage(MSG_COMMAND, param).sendToTarget();
+        }
+    }
+
+    private static class CommandParam {
+        public final ControllerInfo controller;
+        public final @CommandFlags long command;
+
+        private CommandParam(ControllerInfo controller, long command) {
+            this.controller = controller;
+            this.command = command;
+        }
+    }
+}
diff --git a/packages/MediaComponents/src/com/android/media/MediaSessionService2Impl.java b/packages/MediaComponents/src/com/android/media/MediaSessionService2Impl.java
new file mode 100644
index 0000000..6a4760d
--- /dev/null
+++ b/packages/MediaComponents/src/com/android/media/MediaSessionService2Impl.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.media;
+
+import static android.content.Context.NOTIFICATION_SERVICE;
+import static android.media.MediaSessionService2.DEFAULT_MEDIA_NOTIFICATION_CHANNEL_ID;
+import static android.media.MediaSessionService2.DEFAULT_MEDIA_NOTIFICATION_ID;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.media.MediaPlayerBase.PlaybackListener;
+import android.media.MediaSession2;
+import android.media.MediaSessionService2;
+import android.media.MediaSessionService2.MediaNotification;
+import android.media.session.PlaybackState;
+import android.media.update.MediaSessionService2Provider;
+import android.os.IBinder;
+import android.os.Looper;
+import android.support.annotation.GuardedBy;
+import android.util.Log;
+
+// Need a test for session service itself.
+public class MediaSessionService2Impl implements MediaSessionService2Provider {
+
+    private static final String TAG = "MPSessionService"; // to meet 23 char limit in Log tag
+    private static final boolean DEBUG = true; // TODO(jaewan): Change this.
+
+    private final MediaSessionService2 mInstance;
+    private final PlaybackListener mListener = new SessionServicePlaybackListener();
+
+    private final Object mLock = new Object();
+    @GuardedBy("mLock")
+    private NotificationManager mNotificationManager;
+    @GuardedBy("mLock")
+    private Intent mStartSelfIntent;
+
+    private boolean mIsRunningForeground;
+    private NotificationChannel mDefaultNotificationChannel;
+    private MediaSession2 mSession;
+
+    public MediaSessionService2Impl(MediaSessionService2 instance) {
+        if (DEBUG) {
+            Log.d(TAG, "MediaSessionService2Impl(" + instance + ")");
+        }
+        mInstance = instance;
+    }
+
+    @Override
+    public MediaSession2 getSession_impl() {
+        synchronized (mLock) {
+            return mSession;
+        }
+    }
+
+    @Override
+    public MediaNotification onUpdateNotification_impl(PlaybackState state) {
+        return createDefaultNotification(state);
+    }
+
+    // TODO(jaewan): Remove this for framework release.
+    private MediaNotification createDefaultNotification(PlaybackState state) {
+        // TODO(jaewan): Place better notification here.
+        if (mDefaultNotificationChannel == null) {
+            mDefaultNotificationChannel = new NotificationChannel(
+                    DEFAULT_MEDIA_NOTIFICATION_CHANNEL_ID,
+                    DEFAULT_MEDIA_NOTIFICATION_CHANNEL_ID,
+                    NotificationManager.IMPORTANCE_DEFAULT);
+            mNotificationManager.createNotificationChannel(mDefaultNotificationChannel);
+        }
+        Notification notification = new Notification.Builder(
+                mInstance, DEFAULT_MEDIA_NOTIFICATION_CHANNEL_ID)
+                .setContentTitle(mInstance.getPackageName())
+                .setContentText("Playback state: " + state.getState())
+                .setSmallIcon(android.R.drawable.sym_def_app_icon).build();
+        return MediaNotification.create(DEFAULT_MEDIA_NOTIFICATION_ID, notification);
+    }
+
+
+    @Override
+    public void onCreate_impl() {
+        mNotificationManager = (NotificationManager) mInstance.getSystemService(
+                NOTIFICATION_SERVICE);
+        mStartSelfIntent = new Intent(mInstance, mInstance.getClass());
+
+        Intent serviceIntent = new Intent(mInstance, mInstance.getClass());
+        serviceIntent.setAction(MediaSessionService2.SERVICE_INTERFACE);
+        ResolveInfo resolveInfo = mInstance.getPackageManager()
+                .resolveService(serviceIntent,
+                        PackageManager.GET_META_DATA);
+        String id;
+        if (resolveInfo == null || resolveInfo.serviceInfo == null) {
+            throw new IllegalArgumentException("service " + mInstance + " doesn't implement"
+                    + MediaSessionService2.SERVICE_INTERFACE);
+        } else if (resolveInfo.serviceInfo.metaData == null) {
+            if (DEBUG) {
+                Log.d(TAG, "Failed to resolve ID for " + mInstance + ". Using empty id");
+            }
+            id = "";
+        } else {
+            id = resolveInfo.serviceInfo.metaData.getString(
+                    MediaSessionService2.SERVICE_META_DATA, "");
+        }
+        mSession = mInstance.onCreateSession(id);
+        if (mSession == null || !id.equals(mSession.getToken().getId())) {
+            throw new RuntimeException("Expected session with id " + id + ", but got " + mSession);
+        }
+    }
+
+    public IBinder onBind_impl(Intent intent) {
+        if (MediaSessionService2.SERVICE_INTERFACE.equals(intent.getAction())) {
+            return mSession.getToken().getSessionBinder().asBinder();
+        }
+        return null;
+    }
+
+    private void updateNotification(PlaybackState state) {
+        MediaNotification mediaNotification = mInstance.onUpdateNotification(state);
+        if (mediaNotification == null) {
+            mediaNotification = createDefaultNotification(state);
+        }
+        switch((int) state.getState()) {
+            case PlaybackState.STATE_PLAYING:
+                if (!mIsRunningForeground) {
+                    mIsRunningForeground = true;
+                    mInstance.startForegroundService(mStartSelfIntent);
+                    mInstance.startForeground(mediaNotification.id, mediaNotification.notification);
+                    return;
+                }
+                break;
+            case PlaybackState.STATE_STOPPED:
+                if (mIsRunningForeground) {
+                    mIsRunningForeground = false;
+                    mInstance.stopForeground(true);
+                    return;
+                }
+                break;
+        }
+        mNotificationManager.notify(mediaNotification.id, mediaNotification.notification);
+    }
+
+    private class SessionServicePlaybackListener implements PlaybackListener {
+        @Override
+        public void onPlaybackChanged(PlaybackState state) {
+            if (state == null) {
+                Log.w(TAG, "Ignoring null playback state");
+                return;
+            }
+            MediaSession2Impl impl = (MediaSession2Impl) mSession.getProvider();
+            if (impl.getHandler().getLooper() != Looper.myLooper()) {
+                Log.w(TAG, "Ignoring " + state + ". Expected " + impl.getHandler().getLooper()
+                        + " but " + Looper.myLooper());
+                return;
+            }
+            updateNotification(state);
+        }
+    }
+}
diff --git a/packages/MediaComponents/src/com/android/media/PlaybackListenerHolder.java b/packages/MediaComponents/src/com/android/media/PlaybackListenerHolder.java
new file mode 100644
index 0000000..4d06463
--- /dev/null
+++ b/packages/MediaComponents/src/com/android/media/PlaybackListenerHolder.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.media;
+
+import android.media.MediaPlayerBase;
+import android.media.MediaPlayerBase.PlaybackListener;
+import android.media.session.PlaybackState;
+import android.os.Handler;
+import android.os.Message;
+import android.support.annotation.NonNull;
+import java.util.List;
+
+/**
+ * Holds {@link android.media.MediaPlayerBase.PlaybackListener} with the {@link Handler}.
+ */
+public class PlaybackListenerHolder extends Handler {
+    private static final int ON_PLAYBACK_CHANGED = 1;
+
+    public final MediaPlayerBase.PlaybackListener listener;
+
+    public PlaybackListenerHolder(
+            @NonNull MediaPlayerBase.PlaybackListener listener, @NonNull Handler handler) {
+        super(handler.getLooper());
+        this.listener = listener;
+    }
+
+    @Override
+    public void handleMessage(Message msg) {
+        switch (msg.what) {
+            case ON_PLAYBACK_CHANGED:
+                listener.onPlaybackChanged((PlaybackState) msg.obj);
+                break;
+        }
+    }
+
+    public void postPlaybackChange(PlaybackState state) {
+        obtainMessage(ON_PLAYBACK_CHANGED, state).sendToTarget();
+    }
+
+    /**
+     * Returns {@code true} if the given list contains a {@link PlaybackListenerHolder} that holds
+     * the given listener.
+     *
+     * @param list list to check
+     * @param listener listener to check
+     * @return {@code true} if the given list contains listener. {@code false} otherwise.
+     */
+    public static <Holder extends PlaybackListenerHolder> boolean contains(
+            @NonNull List<Holder> list, PlaybackListener listener) {
+        return indexOf(list, listener) >= 0;
+    }
+
+    /**
+     * Returns the index of the {@link PlaybackListenerHolder} that contains the given listener.
+     *
+     * @param list list to check
+     * @param listener listener to check
+     * @return {@code index} of item if the given list contains listener. {@code -1} otherwise.
+     */
+    public static <Holder extends PlaybackListenerHolder> int indexOf(
+            @NonNull List<Holder> list, PlaybackListener listener) {
+        for (int i = 0; i < list.size(); i++) {
+            if (list.get(i).listener == listener) {
+                return i;
+            }
+        }
+        return -1;
+    }
+}
diff --git a/packages/MediaComponents/src/com/android/media/update/ApiFactory.java b/packages/MediaComponents/src/com/android/media/update/ApiFactory.java
index 633a342..cfef77f 100644
--- a/packages/MediaComponents/src/com/android/media/update/ApiFactory.java
+++ b/packages/MediaComponents/src/com/android/media/update/ApiFactory.java
@@ -16,9 +16,21 @@
 
 package com.android.media.update;
 
+import android.content.Context;
 import android.content.res.Resources;
 import android.content.res.Resources.Theme;
+import android.media.MediaController2;
+import android.media.MediaPlayerBase;
+import android.media.MediaSession2;
+import android.media.MediaSession2.ControllerInfo;
+import android.media.MediaSession2.SessionCallback;
+import android.media.MediaSessionService2;
+import android.media.IMediaSession2Callback;
+import android.media.SessionToken;
 import android.media.update.MediaControlView2Provider;
+import android.media.update.MediaController2Provider;
+import android.media.update.MediaSession2Provider;
+import android.media.update.MediaSessionService2Provider;
 import android.media.update.VideoView2Provider;
 import android.media.update.StaticProvider;
 import android.media.update.ViewProvider;
@@ -27,9 +39,14 @@
 import android.widget.MediaControlView2;
 import android.widget.VideoView2;
 
+import com.android.media.MediaController2Impl;
+import com.android.media.MediaSession2Impl;
+import com.android.media.MediaSessionService2Impl;
 import com.android.widget.MediaControlView2Impl;
 import com.android.widget.VideoView2Impl;
 
+import java.util.concurrent.Executor;
+
 public class ApiFactory implements StaticProvider {
     public static Object initialize(Resources libResources, Theme libTheme)
             throws ReflectiveOperationException {
@@ -38,6 +55,33 @@
     }
 
     @Override
+    public MediaController2Provider createMediaController2(
+            MediaController2 instance, Context context, SessionToken token,
+            MediaController2.ControllerCallback callback, Executor executor) {
+        return new MediaController2Impl(instance, context, token, callback, executor);
+    }
+
+    @Override
+    public MediaSession2Provider createMediaSession2(MediaSession2 instance, Context context,
+            MediaPlayerBase player, String id, SessionCallback callback) {
+        return new MediaSession2Impl(instance, context, player, id, callback);
+    }
+
+    @Override
+    public MediaSession2Provider.ControllerInfoProvider createMediaSession2ControllerInfoProvider(
+            ControllerInfo instance, Context context, int uid, int pid, String packageName,
+            IMediaSession2Callback callback) {
+        return new MediaSession2Impl.ControllerInfoImpl(
+                instance, context, uid, pid, packageName, callback);
+    }
+
+    @Override
+    public MediaSessionService2Provider createMediaSessionService2(
+            MediaSessionService2 instance) {
+        return new MediaSessionService2Impl(instance);
+    }
+
+    @Override
     public MediaControlView2Provider createMediaControlView2(
             MediaControlView2 instance, ViewProvider superProvider) {
         return new MediaControlView2Impl(instance, superProvider);
diff --git a/packages/MediaComponents/test/Android.mk b/packages/MediaComponents/test/Android.mk
new file mode 100644
index 0000000..8703b9f
--- /dev/null
+++ b/packages/MediaComponents/test/Android.mk
@@ -0,0 +1,28 @@
+# Copyright 2018 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+# TODO(jaewan): Copy this to the CTS as well
+LOCAL_MODULE_TAGS := tests
+
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+LOCAL_STATIC_JAVA_LIBRARIES := \
+    android-support-test \
+    mockito-target-minus-junit4 \
+    compatibility-device-util
+
+LOCAL_PACKAGE_NAME := MediaComponentsTest
+include $(BUILD_PACKAGE)
diff --git a/packages/MediaComponents/test/AndroidManifest.xml b/packages/MediaComponents/test/AndroidManifest.xml
new file mode 100644
index 0000000..fe16583
--- /dev/null
+++ b/packages/MediaComponents/test/AndroidManifest.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright 2018 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+        http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="android.media.test">
+
+    <application android:label="Media API Test">
+        <uses-library android:name="android.test.runner" />
+
+        <activity android:name="android.widget2.VideoView2TestActivity"
+                  android:configChanges="keyboardHidden|orientation|screenSize"
+                  android:label="VideoView2TestActivity">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.FRAMEWORK_INSTRUMENTATION_TEST" />
+            </intent-filter>
+          </activity>
+
+        <!-- Keep the test services synced together with the TestUtils.java -->
+        <service android:name="android.media.MockMediaSessionService2">
+            <intent-filter>
+                <action android:name="android.media.session.MediaSessionService2" />
+            </intent-filter>
+            <meta-data android:name="android.media.session" android:value="TestSession" />
+        </service>
+    </application>
+
+    <instrumentation
+        android:name="android.support.test.runner.AndroidJUnitRunner"
+        android:targetPackage="android.media.test"
+        android:label="Media API test" />
+
+</manifest>
diff --git a/packages/MediaComponents/test/runtest.sh b/packages/MediaComponents/test/runtest.sh
new file mode 100644
index 0000000..bda48b0
--- /dev/null
+++ b/packages/MediaComponents/test/runtest.sh
@@ -0,0 +1,189 @@
+#!/bin/bash
+# Copyright 2018 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Usage '. runtest.sh'
+
+function _runtest_mediacomponent_usage() {
+  echo 'runtest-MediaComponents [option]: Run MediaComponents test'
+  echo '     -h|--help: This help'
+  echo '     --skip: Skip build. Just rerun-tests.'
+  echo '     --min: Only rebuild test apk and updatable library.'
+  echo '     -s [device_id]: Specify a device name to run test against.'
+  echo '                     You can define ${ADBHOST} instead.'
+  echo '     -r [count]: Repeat tests for given count. It will stop when fails.'
+  echo '     --ignore: Keep repeating tests even when it fails.'
+  echo '     -t [test]: Only run the specific test. Can be either a class or a method.'
+}
+
+function runtest-MediaComponents() {
+  # Edit here if you want to support other tests.
+  # List up libs and apks in the media_api needed for tests, and place test target at the last.
+  local TEST_PACKAGE_DIR=("frameworks/av/packages/MediaComponents/test")
+  local BUILD_TARGETS=("MediaComponents" "MediaComponentsTest")
+  local INSTALL_TARGETS=("MediaComponentsTest")
+  local TEST_RUNNER="android.support.test.runner.AndroidJUnitRunner"
+  local DEPENDENCIES=("mockito-target-minus-junit4" "android-support-test" "compatibility-device-util")
+
+  if [[ -z "${ANDROID_BUILD_TOP}" ]]; then
+    echo "Needs to lunch a target first"
+    return
+  fi
+
+  local old_path=${OLDPWD}
+  while true; do
+    local OPTION_SKIP="false"
+    local OPTION_MIN="false"
+    local OPTION_REPEAT_COUNT="1"
+    local OPTION_IGNORE="false"
+    local OPTION_TEST_TARGET=""
+    local adbhost_local
+    while (( "$#" )); do
+      case "${1}" in
+        -h|--help)
+          _runtest_mediacomponent_usage
+          return
+          ;;
+        --skip)
+          OPTION_SKIP="true"
+          ;;
+        --min)
+          OPTION_MIN="true"
+          ;;
+        -s)
+          shift
+          adbhost_local=${1}
+          ;;
+        -r)
+          shift
+          OPTION_REPEAT_COUNT="${1}"
+          ;;
+        --ignore)
+          OPTION_IGNORE="true"
+          ;;
+        -t)
+          shift
+          OPTION_TEST_TARGET="${1}"
+      esac
+      shift
+    done
+
+    # Build adb command.
+    local adb
+    if [[ -z "${adbhost_local}" ]]; then
+      adbhost_local=${ADBHOST}
+    fi
+    if [[ -z "${adbhost_local}" ]]; then
+      local device_count=$(adb devices | sed '/^[[:space:]]*$/d' | wc -l)
+      if [[ "${device_count}" != "2" ]]; then
+        echo "Too many devices. Specify a device." && break
+      fi
+      adb="adb"
+    else
+      adb="adb -s ${adbhost_local}"
+    fi
+
+    local target_dir="${ANDROID_BUILD_TOP}/${TEST_PACKAGE_DIR}"
+    local TEST_PACKAGE=$(sed -n 's/^.*\bpackage\b="\([a-z0-9\.]*\)".*$/\1/p' ${target_dir}/AndroidManifest.xml)
+
+    if [[ "${OPTION_SKIP}" != "true" ]]; then
+      # Build dependencies if needed.
+      local dependency
+      local build_dependency=""
+      for dependency in ${DEPENDENCIES[@]}; do
+        if [[ "${dependency}" == "out/"* ]]; then
+          if [[ ! -f ${ANDROID_BUILD_TOP}/${dependency} ]]; then
+            build_dependency="true"
+            break
+          fi
+        else
+          if [[ "$(find ${OUT} -name ${dependency}_intermediates | wc -l)" == "0" ]]; then
+            build_dependency="true"
+            break
+          fi
+        fi
+      done
+      if [[ "${build_dependency}" == "true" ]]; then
+        echo "Building dependencies. Will only print stderr."
+        m ${DEPENDENCIES[@]} -j > /dev/null
+      fi
+
+      # Build test apk and required apk.
+      local build_targets="${BUILD_TARGETS[@]}"
+      if [[ "${OPTION_MIN}" != "true" ]]; then
+        build_targets="${build_targets} droid"
+      fi
+      m ${build_targets} -j || (echo "Build failed. stop" ; break)
+
+      ${adb} root
+      ${adb} remount
+      ${adb} shell stop
+      ${adb} sync
+      ${adb} shell start
+      ${adb} wait-for-device || break
+      # Ensure package manager is loaded.
+      sleep 5
+
+      # Install apks
+      local install_failed="false"
+      for target in ${INSTALL_TARGETS[@]}; do
+        echo "${target}"
+        local target_dir=$(mgrep -l -e '^LOCAL_PACKAGE_NAME.*'"${target}$")
+        if [[ -z ${target_dir} ]]; then
+          continue
+        fi
+        target_dir=$(dirname ${target_dir})
+        local package=$(sed -n 's/^.*\bpackage\b="\([a-z0-9\._]*\)".*$/\1/p' ${target_dir}/AndroidManifest.xml)
+        local apk_path=$(find ${OUT} -name ${target}.apk)
+        if [[ -z "${apk_path}" ]]; then
+          echo "Cannot locate ${target}.apk" && break
+        fi
+        echo "Installing ${target}.apk. path=${apk_path}"
+        ${adb} install -r ${apk_path}
+        if [[ "${?}" != "0" ]]; then
+          install_failed="true"
+          break
+        fi
+      done
+      if [[ "${install_failed}" == "true" ]]; then
+        echo "Failed to install. Test wouldn't run."
+        break
+      fi
+    fi
+
+    local test_target=""
+    if [[ -n "${OPTION_TEST_TARGET}" ]]; then
+      test_target="-e class ${OPTION_TEST_TARGET}"
+    fi
+
+    local i
+    local tmpfile=$(tempfile)
+    for ((i=1; i <= ${OPTION_REPEAT_COUNT}; i++)); do
+      echo "Run test ${i}/${OPTION_REPEAT_COUNT}"
+      ${adb} shell am instrument ${test_target} -w ${TEST_PACKAGE}/${TEST_RUNNER} >& ${tmpfile}
+      cat ${tmpfile}
+      if [[ "${OPTION_IGNORE}" != "true" ]]; then
+        if [[ -n "$(grep ${tmpfile} -e 'FAILURE\|crashed')" ]]; then
+          # am instrument doesn't return error code so need to grep result message instead
+          break
+        fi
+      fi
+    done
+    rm ${tmpfile}
+    break
+  done
+}
+
+echo "Following functions are added to your environment:"
+_runtest_mediacomponent_usage
diff --git a/packages/MediaComponents/test/src/android/media/MediaController2Test.java b/packages/MediaComponents/test/src/android/media/MediaController2Test.java
new file mode 100644
index 0000000..161a463
--- /dev/null
+++ b/packages/MediaComponents/test/src/android/media/MediaController2Test.java
@@ -0,0 +1,452 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.media;
+
+import android.media.MediaPlayerBase.PlaybackListener;
+import android.media.MediaSession2.ControllerInfo;
+import android.media.MediaSession2.SessionCallback;
+import android.media.TestUtils.SyncHandler;
+import android.media.session.PlaybackState;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Process;
+import android.support.test.filters.FlakyTest;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static android.media.TestUtils.createPlaybackState;
+import static org.junit.Assert.*;
+
+/**
+ * Tests {@link MediaController2}.
+ */
+// TODO(jaewan): Implement host-side test so controller and session can run in different processes.
+// TODO(jaewan): Fix flaky failure -- see MediaController2Impl.getController()
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+@FlakyTest
+public class MediaController2Test extends MediaSession2TestBase {
+    private static final String TAG = "MediaController2Test";
+
+    private MediaSession2 mSession;
+    private MediaController2Wrapper mController;
+    private MockPlayer mPlayer;
+
+    @Before
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        // Create this test specific MediaSession2 to use our own Handler.
+        sHandler.postAndSync(()->{
+            mPlayer = new MockPlayer(1);
+            mSession = new MediaSession2.Builder(mContext, mPlayer).setId(TAG).build();
+        });
+
+        mController = createController(mSession.getToken());
+        TestServiceRegistry.getInstance().setHandler(sHandler);
+    }
+
+    @After
+    @Override
+    public void cleanUp() throws Exception {
+        super.cleanUp();
+        sHandler.postAndSync(() -> {
+            if (mSession != null) {
+                mSession.setPlayer(null);
+            }
+        });
+        TestServiceRegistry.getInstance().cleanUp();
+    }
+
+    @Test
+    public void testPlay() throws InterruptedException {
+        mController.play();
+        try {
+            assertTrue(mPlayer.mCountDownLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        } catch (InterruptedException e) {
+            fail(e.getMessage());
+        }
+        assertTrue(mPlayer.mPlayCalled);
+    }
+
+    @Test
+    public void testPause() throws InterruptedException {
+        mController.pause();
+        try {
+            assertTrue(mPlayer.mCountDownLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        } catch (InterruptedException e) {
+            fail(e.getMessage());
+        }
+        assertTrue(mPlayer.mPauseCalled);
+    }
+
+
+    @Test
+    public void testSkipToPrevious() throws InterruptedException {
+        mController.skipToPrevious();
+        try {
+            assertTrue(mPlayer.mCountDownLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        } catch (InterruptedException e) {
+            fail(e.getMessage());
+        }
+        assertTrue(mPlayer.mSkipToPreviousCalled);
+    }
+
+    @Test
+    public void testSkipToNext() throws InterruptedException {
+        mController.skipToNext();
+        try {
+            assertTrue(mPlayer.mCountDownLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        } catch (InterruptedException e) {
+            fail(e.getMessage());
+        }
+        assertTrue(mPlayer.mSkipToNextCalled);
+    }
+
+    @Test
+    public void testStop() throws InterruptedException {
+        mController.stop();
+        try {
+            assertTrue(mPlayer.mCountDownLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        } catch (InterruptedException e) {
+            fail(e.getMessage());
+        }
+        assertTrue(mPlayer.mStopCalled);
+    }
+
+    @Test
+    public void testGetPackageName() {
+        assertEquals(mContext.getPackageName(), mController.getSessionToken().getPackageName());
+    }
+
+    @Test
+    public void testGetPlaybackState() throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(1);
+        final MediaPlayerBase.PlaybackListener listener = (state) -> {
+            assertEquals(PlaybackState.STATE_BUFFERING, state.getState());
+            latch.countDown();
+        };
+        assertNull(mController.getPlaybackState());
+        mController.addPlaybackListener(listener, sHandler);
+
+        mPlayer.notifyPlaybackState(createPlaybackState(PlaybackState.STATE_BUFFERING));
+        assertTrue(latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        assertEquals(PlaybackState.STATE_BUFFERING, mController.getPlaybackState().getState());
+    }
+
+    @Test
+    public void testAddPlaybackListener() throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(2);
+        final MediaPlayerBase.PlaybackListener listener = (state) -> {
+            switch ((int) latch.getCount()) {
+                case 2:
+                    assertEquals(PlaybackState.STATE_PLAYING, state.getState());
+                    break;
+                case 1:
+                    assertEquals(PlaybackState.STATE_PAUSED, state.getState());
+                    break;
+            }
+            latch.countDown();
+        };
+
+        mController.addPlaybackListener(listener, sHandler);
+        sHandler.postAndSync(()->{
+            mPlayer.notifyPlaybackState(createPlaybackState(PlaybackState.STATE_PLAYING));
+            mPlayer.notifyPlaybackState(createPlaybackState(PlaybackState.STATE_PAUSED));
+        });
+        assertTrue(latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+    }
+
+    @Test
+    public void testRemovePlaybackListener() throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(1);
+        final MediaPlayerBase.PlaybackListener listener = (state) -> {
+            fail();
+            latch.countDown();
+        };
+        mController.addPlaybackListener(listener, sHandler);
+        mController.removePlaybackListener(listener);
+        mPlayer.notifyPlaybackState(createPlaybackState(PlaybackState.STATE_PLAYING));
+        assertFalse(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+    }
+
+    @Test
+    public void testControllerCallback_onConnected() throws InterruptedException {
+        // createController() uses controller callback to wait until the controller becomes
+        // available.
+        MediaController2 controller = createController(mSession.getToken());
+        assertNotNull(controller);
+    }
+
+    @Test
+    public void testControllerCallback_sessionRejects() throws InterruptedException {
+        final MediaSession2.SessionCallback sessionCallback = new SessionCallback() {
+            @Override
+            public long onConnect(ControllerInfo controller) {
+                return 0;
+            }
+        };
+        sHandler.postAndSync(() -> {
+            mSession.setPlayer(null);
+            mSession = new MediaSession2.Builder(mContext, mPlayer)
+                    .setSessionCallback(sessionCallback).build();
+        });
+        MediaController2Wrapper controller = createController(mSession.getToken(), false, null);
+        assertNotNull(controller);
+        controller.waitForConnect(false);
+        controller.waitForDisconnect(true);
+    }
+
+    @Test
+    public void testControllerCallback_releaseSession() throws InterruptedException {
+        sHandler.postAndSync(() -> {
+            mSession.setPlayer(null);
+        });
+        mController.waitForDisconnect(true);
+    }
+
+    @Test
+    public void testControllerCallback_release() throws InterruptedException {
+        mController.release();
+        mController.waitForDisconnect(true);
+    }
+
+    @Test
+    public void testIsConnected() throws InterruptedException {
+        assertTrue(mController.isConnected());
+        sHandler.postAndSync(()->{
+            mSession.setPlayer(null);
+        });
+        // postAndSync() to wait until the disconnection is propagated.
+        sHandler.postAndSync(()->{
+            assertFalse(mController.isConnected());
+        });
+    }
+
+    /**
+     * Test potential deadlock for calls between controller and session.
+     */
+    @Test
+    public void testDeadlock() throws InterruptedException {
+        sHandler.postAndSync(() -> {
+            mSession.setPlayer(null);
+            mSession = null;
+        });
+
+        // Two more threads are needed not to block test thread nor test wide thread (sHandler).
+        final HandlerThread sessionThread = new HandlerThread("testDeadlock_session");
+        final HandlerThread testThread = new HandlerThread("testDeadlock_test");
+        sessionThread.start();
+        testThread.start();
+        final SyncHandler sessionHandler = new SyncHandler(sessionThread.getLooper());
+        final Handler testHandler = new Handler(testThread.getLooper());
+        final CountDownLatch latch = new CountDownLatch(1);
+        try {
+            final MockPlayer player = new MockPlayer(0);
+            sessionHandler.postAndSync(() -> {
+                mSession = new MediaSession2.Builder(mContext, mPlayer)
+                        .setId("testDeadlock").build();
+            });
+            final MediaController2 controller = createController(mSession.getToken());
+            testHandler.post(() -> {
+                controller.addPlaybackListener((state) -> {
+                    // no-op. Just to set a binder call path from session to controller.
+                }, sessionHandler);
+                final PlaybackState state = createPlaybackState(PlaybackState.STATE_ERROR);
+                for (int i = 0; i < 100; i++) {
+                    // triggers call from session to controller.
+                    player.notifyPlaybackState(state);
+                    // triggers call from controller to session.
+                    controller.play();
+
+                    // Repeat above
+                    player.notifyPlaybackState(state);
+                    controller.pause();
+                    player.notifyPlaybackState(state);
+                    controller.stop();
+                    player.notifyPlaybackState(state);
+                    controller.skipToNext();
+                    player.notifyPlaybackState(state);
+                    controller.skipToPrevious();
+                }
+                // This may hang if deadlock happens.
+                latch.countDown();
+            });
+            assertTrue(latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        } finally {
+            if (mSession != null) {
+                sessionHandler.postAndSync(() -> {
+                    // Clean up here because sessionHandler will be removed afterwards.
+                    mSession.setPlayer(null);
+                    mSession = null;
+                });
+            }
+            if (sessionThread != null) {
+                sessionThread.quitSafely();
+            }
+            if (testThread != null) {
+                testThread.quitSafely();
+            }
+        }
+    }
+
+    @Ignore
+    @Test
+    public void testGetServiceToken() {
+        SessionToken token = TestUtils.getServiceToken(mContext, MockMediaSessionService2.ID);
+        assertNotNull(token);
+        assertEquals(mContext.getPackageName(), token.getPackageName());
+        assertEquals(MockMediaSessionService2.ID, token.getId());
+        assertNull(token.getSessionBinder());
+        assertEquals(SessionToken.TYPE_SESSION_SERVICE, token.getType());
+    }
+
+    private void connectToService(SessionToken token) throws InterruptedException {
+        mController = createController(token);
+        mSession = TestServiceRegistry.getInstance().getServiceInstance().getSession();
+        mPlayer = (MockPlayer) mSession.getPlayer();
+    }
+
+    @Ignore
+    @Test
+    public void testConnectToService() throws InterruptedException {
+        connectToService(TestUtils.getServiceToken(mContext, MockMediaSessionService2.ID));
+
+        TestServiceRegistry serviceInfo = TestServiceRegistry.getInstance();
+        ControllerInfo info = serviceInfo.getOnConnectControllerInfo();
+        assertEquals(mContext.getPackageName(), info.getPackageName());
+        assertEquals(Process.myUid(), info.getUid());
+        assertFalse(info.isTrusted());
+
+        // Test command from controller to session service
+        mController.play();
+        assertTrue(mPlayer.mCountDownLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+        assertTrue(mPlayer.mPlayCalled);
+
+        // Test command from session service to controller
+        final CountDownLatch latch = new CountDownLatch(1);
+        mController.addPlaybackListener((state) -> {
+            assertNotNull(state);
+            assertEquals(PlaybackState.STATE_REWINDING, state.getState());
+            latch.countDown();
+        }, sHandler);
+        mPlayer.notifyPlaybackState(
+                TestUtils.createPlaybackState(PlaybackState.STATE_REWINDING));
+        assertTrue(latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+    }
+
+    @Test
+    public void testControllerAfterSessionIsGone_session() throws InterruptedException {
+        testControllerAfterSessionIsGone(mSession.getToken().getId());
+    }
+
+    @Ignore
+    @Test
+    public void testControllerAfterSessionIsGone_sessionService() throws InterruptedException {
+        connectToService(TestUtils.getServiceToken(mContext, MockMediaSessionService2.ID));
+        testControllerAfterSessionIsGone(MockMediaSessionService2.ID);
+    }
+
+    @Test
+    public void testRelease_beforeConnected() throws InterruptedException {
+        MediaController2 controller =
+                createController(mSession.getToken(), false, null);
+        controller.release();
+    }
+
+    @Test
+    public void testRelease_twice() throws InterruptedException {
+        mController.release();
+        mController.release();
+    }
+
+    @Test
+    public void testRelease_session() throws InterruptedException {
+        final String id = mSession.getToken().getId();
+        mController.release();
+        // Release is done immediately for session.
+        testNoInteraction();
+
+        // Test whether the controller is notified about later release of the session or
+        // re-creation.
+        testControllerAfterSessionIsGone(id);
+    }
+
+    @Ignore
+    @Test
+    public void testRelease_sessionService() throws InterruptedException {
+        connectToService(TestUtils.getServiceToken(mContext, MockMediaSessionService2.ID));
+        final CountDownLatch latch = new CountDownLatch(1);
+        TestServiceRegistry.getInstance().setServiceInstanceChangedCallback((service) -> {
+            if (service == null) {
+                // Destroying..
+                latch.countDown();
+            }
+        });
+        mController.release();
+        // Wait until release triggers onDestroy() of the session service.
+        assertTrue(latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        assertNull(TestServiceRegistry.getInstance().getServiceInstance());
+        testNoInteraction();
+
+        // Test whether the controller is notified about later release of the session or
+        // re-creation.
+        testControllerAfterSessionIsGone(MockMediaSessionService2.ID);
+    }
+
+    private void testControllerAfterSessionIsGone(final String id) throws InterruptedException {
+        sHandler.postAndSync(() -> {
+            // TODO(jaewan): Use Session.release later when we add the API.
+            mSession.setPlayer(null);
+        });
+        mController.waitForDisconnect(true);
+        testNoInteraction();
+
+        // Test with the newly created session.
+        sHandler.postAndSync(() -> {
+            // Recreated session has different session stub, so previously created controller
+            // shouldn't be available.
+            mSession = new MediaSession2.Builder(mContext, mPlayer).setId(id).build();
+        });
+        testNoInteraction();
+    }
+
+
+    private void testNoInteraction() throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(1);
+        final PlaybackListener playbackListener = (state) -> {
+            fail("Controller shouldn't be notified about change in session after the release.");
+            latch.countDown();
+        };
+        mController.addPlaybackListener(playbackListener, sHandler);
+        mPlayer.notifyPlaybackState(TestUtils.createPlaybackState(PlaybackState.STATE_BUFFERING));
+        assertFalse(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+        mController.removePlaybackListener(playbackListener);
+    }
+
+    // TODO(jaewan): Add  test for service connect rejection, when we differentiate session
+    //               active/inactive and connection accept/refuse
+}
diff --git a/packages/MediaComponents/test/src/android/media/MediaSession2Test.java b/packages/MediaComponents/test/src/android/media/MediaSession2Test.java
new file mode 100644
index 0000000..a77fd63
--- /dev/null
+++ b/packages/MediaComponents/test/src/android/media/MediaSession2Test.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.media;
+
+import android.media.MediaPlayerBase.PlaybackListener;
+import android.media.MediaSession2.Builder;
+import android.media.MediaSession2.ControllerInfo;
+import android.media.MediaSession2.SessionCallback;
+import android.media.session.PlaybackState;
+import android.os.Process;
+import android.os.Looper;
+import android.support.annotation.NonNull;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import java.util.ArrayList;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static android.media.TestUtils.createPlaybackState;
+import static org.junit.Assert.*;
+
+/**
+ * Tests {@link MediaSession2}.
+ */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class MediaSession2Test extends MediaSession2TestBase {
+    private static final String TAG = "MediaSession2Test";
+
+    private MediaSession2 mSession;
+    private MockPlayer mPlayer;
+
+    @Before
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        sHandler.postAndSync(() -> {
+            mPlayer = new MockPlayer(0);
+            mSession = new MediaSession2.Builder(mContext, mPlayer).build();
+        });
+    }
+
+    @After
+    @Override
+    public void cleanUp() throws Exception {
+        super.cleanUp();
+        sHandler.postAndSync(() -> {
+            mSession.setPlayer(null);
+        });
+    }
+
+    @Test
+    public void testBuilder() throws Exception {
+        try {
+            MediaSession2.Builder builder = new Builder(mContext, null);
+            fail("null player shouldn't be allowed");
+        } catch (IllegalArgumentException e) {
+            // expected. pass-through
+        }
+        MediaSession2.Builder builder = new Builder(mContext, mPlayer);
+        try {
+            builder.setId(null);
+            fail("null id shouldn't be allowed");
+        } catch (IllegalArgumentException e) {
+            // expected. pass-through
+        }
+    }
+
+    @Test
+    public void testSetPlayer() throws Exception {
+        sHandler.postAndSync(() -> {
+            MockPlayer player = new MockPlayer(0);
+            // Test if setPlayer doesn't crash with various situations.
+            mSession.setPlayer(mPlayer);
+            mSession.setPlayer(player);
+            mSession.setPlayer(null);
+        });
+    }
+
+    @Test
+    public void testPlay() throws Exception {
+        sHandler.postAndSync(() -> {
+            mSession.play();
+            assertTrue(mPlayer.mPlayCalled);
+        });
+    }
+
+    @Test
+    public void testPause() throws Exception {
+        sHandler.postAndSync(() -> {
+            mSession.pause();
+            assertTrue(mPlayer.mPauseCalled);
+        });
+    }
+
+    @Test
+    public void testStop() throws Exception {
+        sHandler.postAndSync(() -> {
+            mSession.stop();
+            assertTrue(mPlayer.mStopCalled);
+        });
+    }
+
+    @Test
+    public void testSkipToNext() throws Exception {
+        sHandler.postAndSync(() -> {
+            mSession.skipToNext();
+            assertTrue(mPlayer.mSkipToNextCalled);
+        });
+    }
+
+    @Test
+    public void testSkipToPrevious() throws Exception {
+        sHandler.postAndSync(() -> {
+            mSession.skipToPrevious();
+            assertTrue(mPlayer.mSkipToPreviousCalled);
+        });
+    }
+
+    @Test
+    public void testPlaybackStateChangedListener() throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(2);
+        final MockPlayer player = new MockPlayer(0);
+        final PlaybackListener listener = (state) -> {
+            assertEquals(sHandler.getLooper(), Looper.myLooper());
+            assertNotNull(state);
+            switch ((int) latch.getCount()) {
+                case 2:
+                    assertEquals(PlaybackState.STATE_PLAYING, state.getState());
+                    break;
+                case 1:
+                    assertEquals(PlaybackState.STATE_PAUSED, state.getState());
+                    break;
+                case 0:
+                    fail();
+            }
+            latch.countDown();
+        };
+        player.notifyPlaybackState(createPlaybackState(PlaybackState.STATE_PLAYING));
+        sHandler.postAndSync(() -> {
+            mSession.addPlaybackListener(listener, sHandler);
+            // When the player is set, listeners will be notified about the player's current state.
+            mSession.setPlayer(player);
+        });
+        player.notifyPlaybackState(createPlaybackState(PlaybackState.STATE_PAUSED));
+        assertTrue(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+    }
+
+    @Test
+    public void testBadPlayer() throws InterruptedException {
+        final CountDownLatch latch = new CountDownLatch(3); // expected call + 1
+        final BadPlayer player = new BadPlayer(0);
+        sHandler.postAndSync(() -> {
+            mSession.addPlaybackListener((state) -> {
+                // This will be called for every setPlayer() calls, but no more.
+                assertNull(state);
+                latch.countDown();
+            }, sHandler);
+            mSession.setPlayer(player);
+            mSession.setPlayer(mPlayer);
+        });
+        player.notifyPlaybackState(createPlaybackState(PlaybackState.STATE_PAUSED));
+        assertFalse(latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+    }
+
+    private static class BadPlayer extends MockPlayer {
+        public BadPlayer(int count) {
+            super(count);
+        }
+
+        @Override
+        public void removePlaybackListener(@NonNull PlaybackListener listener) {
+            // No-op. This bad player will keep push notification to the listener that is previously
+            // registered by session.setPlayer().
+        }
+    }
+
+    @Test
+    public void testOnCommandCallback() throws InterruptedException {
+        final MockOnCommandCallback callback = new MockOnCommandCallback();
+        sHandler.postAndSync(() -> {
+            mSession.setPlayer(null);
+            mPlayer = new MockPlayer(1);
+            mSession = new MediaSession2.Builder(mContext, mPlayer)
+                    .setSessionCallback(callback).build();
+        });
+        MediaController2 controller = createController(mSession.getToken());
+        controller.pause();
+        assertFalse(mPlayer.mCountDownLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        assertFalse(mPlayer.mPauseCalled);
+        assertEquals(1, callback.commands.size());
+        assertEquals(MediaSession2.COMMAND_FLAG_PLAYBACK_PAUSE,
+                (long) callback.commands.get(0));
+        controller.skipToNext();
+        assertTrue(mPlayer.mCountDownLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        assertTrue(mPlayer.mSkipToNextCalled);
+        assertFalse(mPlayer.mPauseCalled);
+        assertEquals(2, callback.commands.size());
+        assertEquals(MediaSession2.COMMAND_FLAG_PLAYBACK_SKIP_NEXT_ITEM,
+                (long) callback.commands.get(1));
+    }
+
+    @Test
+    public void testOnConnectCallback() throws InterruptedException {
+        final MockOnConnectCallback sessionCallback = new MockOnConnectCallback();
+        sHandler.postAndSync(() -> {
+            mSession.setPlayer(null);
+            mSession = new MediaSession2.Builder(mContext, mPlayer)
+                    .setSessionCallback(sessionCallback).build();
+        });
+        MediaController2Wrapper controller = createController(mSession.getToken(), false, null);
+        assertNotNull(controller);
+        controller.waitForConnect(false);
+        controller.waitForDisconnect(true);
+    }
+
+    public class MockOnConnectCallback extends SessionCallback {
+        @Override
+        public long onConnect(ControllerInfo controllerInfo) {
+            if (Process.myUid() != controllerInfo.getUid()) {
+                return 0;
+            }
+            assertEquals(mContext.getPackageName(), controllerInfo.getPackageName());
+            assertEquals(Process.myUid(), controllerInfo.getUid());
+            assertFalse(controllerInfo.isTrusted());
+            // Reject all
+            return 0;
+        }
+    }
+
+    public class MockOnCommandCallback extends SessionCallback {
+        public final ArrayList<Long> commands = new ArrayList<>();
+
+        @Override
+        public boolean onCommand(ControllerInfo controllerInfo, long command) {
+            assertEquals(mContext.getPackageName(), controllerInfo.getPackageName());
+            assertEquals(Process.myUid(), controllerInfo.getUid());
+            assertFalse(controllerInfo.isTrusted());
+            commands.add(command);
+            if (command == MediaSession2.COMMAND_FLAG_PLAYBACK_PAUSE) {
+                return false;
+            }
+            return true;
+        }
+    }
+}
diff --git a/packages/MediaComponents/test/src/android/media/MediaSession2TestBase.java b/packages/MediaComponents/test/src/android/media/MediaSession2TestBase.java
new file mode 100644
index 0000000..80b8b79
--- /dev/null
+++ b/packages/MediaComponents/test/src/android/media/MediaSession2TestBase.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.media;
+
+import static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import android.content.Context;
+import android.os.HandlerThread;
+import android.support.annotation.CallSuper;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.test.InstrumentationRegistry;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+/**
+ * Base class for session test.
+ */
+abstract class MediaSession2TestBase {
+    // Expected success
+    static final int WAIT_TIME_MS = 1000;
+
+    // Expected timeout
+    static final int TIMEOUT_MS = 500;
+
+    static TestUtils.SyncHandler sHandler;
+    static Executor sHandlerExecutor;
+
+    Context mContext;
+    private List<MediaController2> mControllers = new ArrayList<>();
+
+    @BeforeClass
+    public static void setUpThread() {
+        if (sHandler == null) {
+            HandlerThread handlerThread = new HandlerThread("MediaSession2TestBase");
+            handlerThread.start();
+            sHandler = new TestUtils.SyncHandler(handlerThread.getLooper());
+            sHandlerExecutor = (runnable) -> {
+                sHandler.post(runnable);
+            };
+        }
+    }
+
+    @AfterClass
+    public static void cleanUpThread() {
+        if (sHandler != null) {
+            sHandler.getLooper().quitSafely();
+            sHandler = null;
+            sHandlerExecutor = null;
+        }
+    }
+
+    @CallSuper
+    public void setUp() throws Exception {
+        mContext = InstrumentationRegistry.getTargetContext();
+    }
+
+    @CallSuper
+    public void cleanUp() throws Exception {
+        for (int i = 0; i < mControllers.size(); i++) {
+            mControllers.get(i).release();
+        }
+    }
+
+    MediaController2Wrapper createController(SessionToken token) throws InterruptedException {
+        return createController(token, true, null);
+    }
+
+    MediaController2Wrapper createController(@NonNull SessionToken token, boolean waitForConnect,
+            @Nullable TestControllerCallback callback)
+            throws InterruptedException {
+        if (callback == null) {
+            callback = new TestControllerCallback();
+        }
+        MediaController2Wrapper controller = new MediaController2Wrapper(mContext, token, callback);
+        mControllers.add(controller);
+        if (waitForConnect) {
+            controller.waitForConnect(true);
+        }
+        return controller;
+    }
+
+    public static class TestControllerCallback extends MediaController2.ControllerCallback {
+        public final CountDownLatch connectLatch = new CountDownLatch(1);
+        public final CountDownLatch disconnectLatch = new CountDownLatch(1);
+
+        @CallSuper
+        @Override
+        public void onConnected(long commands) {
+            super.onConnected(commands);
+            connectLatch.countDown();
+        }
+
+        @CallSuper
+        @Override
+        public void onDisconnected() {
+            super.onDisconnected();
+            disconnectLatch.countDown();
+        }
+    }
+
+    public class MediaController2Wrapper extends MediaController2 {
+        private final TestControllerCallback mCallback;
+
+        public MediaController2Wrapper(@NonNull Context context, @NonNull SessionToken token,
+                @NonNull TestControllerCallback callback) {
+            super(context, token, callback, sHandlerExecutor);
+            mCallback = callback;
+        }
+
+        public void waitForConnect(boolean expect) throws InterruptedException {
+            if (expect) {
+                assertTrue(mCallback.connectLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+            } else {
+                assertFalse(mCallback.connectLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+            }
+        }
+
+        public void waitForDisconnect(boolean expect) throws InterruptedException {
+            if (expect) {
+                assertTrue(mCallback.disconnectLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+            } else {
+                assertFalse(mCallback.disconnectLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS));
+            }
+        }
+    }
+}
diff --git a/packages/MediaComponents/test/src/android/media/MediaSessionManager_MediaSession2.java b/packages/MediaComponents/test/src/android/media/MediaSessionManager_MediaSession2.java
new file mode 100644
index 0000000..552f0a6
--- /dev/null
+++ b/packages/MediaComponents/test/src/android/media/MediaSessionManager_MediaSession2.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.media;
+
+import android.content.Context;
+import android.media.MediaSession2.ControllerInfo;
+import android.media.MediaSession2.SessionCallback;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static android.media.TestUtils.createPlaybackState;
+import static org.junit.Assert.*;
+
+/**
+ * Tests {@link MediaSessionManager} with {@link MediaSession2} specific APIs.
+ */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+@Ignore
+// TODO(jaewan): Reenable test when the media session service detects newly installed sesison
+//               service app.
+public class MediaSessionManager_MediaSession2 extends MediaSession2TestBase {
+    private static final String TAG = "MediaSessionManager_MediaSession2";
+
+    private MediaSessionManager mManager;
+    private MediaSession2 mSession;
+
+    @Before
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        mManager = (MediaSessionManager) mContext.getSystemService(Context.MEDIA_SESSION_SERVICE);
+
+        // Specify TAG here so {@link MediaSession2.getInstance()} doesn't complaint about
+        // per test thread differs across the {@link MediaSession2} with the same TAG.
+        final MockPlayer player = new MockPlayer(1);
+        sHandler.postAndSync(() -> {
+            mSession = new MediaSession2.Builder(mContext, player).setId(TAG).build();
+        });
+        ensureChangeInSession();
+    }
+
+    @After
+    @Override
+    public void cleanUp() throws Exception {
+        super.cleanUp();
+        sHandler.removeCallbacksAndMessages(null);
+        sHandler.postAndSync(() -> {
+            mSession.setPlayer(null);
+        });
+    }
+
+    // TODO(jaewan): Make this host-side test to see per-user behavior.
+    @Test
+    public void testGetMediaSession2Tokens_hasMediaController() throws InterruptedException {
+        final MockPlayer player = (MockPlayer) mSession.getPlayer();
+        player.notifyPlaybackState(createPlaybackState(PlaybackState.STATE_STOPPED));
+
+        MediaController2 controller = null;
+        List<SessionToken> tokens = mManager.getActiveSessionTokens();
+        assertNotNull(tokens);
+        for (int i = 0; i < tokens.size(); i++) {
+            SessionToken token = tokens.get(i);
+            if (mContext.getPackageName().equals(token.getPackageName())
+                    && TAG.equals(token.getId())) {
+                assertNotNull(token.getSessionBinder());
+                assertNull(controller);
+                controller = createController(token);
+            }
+        }
+        assertNotNull(controller);
+
+        // Test if the found controller is correct one.
+        assertEquals(PlaybackState.STATE_STOPPED, controller.getPlaybackState().getState());
+        controller.play();
+
+        assertTrue(player.mCountDownLatch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+        assertTrue(player.mPlayCalled);
+    }
+
+    /**
+     * Test if server recognizes session even if session refuses the connection from server.
+     *
+     * @throws InterruptedException
+     */
+    @Test
+    public void testGetSessionTokens_sessionRejected() throws InterruptedException {
+        sHandler.postAndSync(() -> {
+            mSession.setPlayer(null);
+            mSession = new MediaSession2.Builder(mContext, new MockPlayer(0)).setId(TAG)
+                    .setSessionCallback(new SessionCallback() {
+                        @Override
+                        public long onConnect(ControllerInfo controller) {
+                            // Reject all connection request.
+                            return 0;
+                        }
+                    }).build();
+        });
+        ensureChangeInSession();
+
+        boolean foundSession = false;
+        List<SessionToken> tokens = mManager.getActiveSessionTokens();
+        assertNotNull(tokens);
+        for (int i = 0; i < tokens.size(); i++) {
+            SessionToken token = tokens.get(i);
+            if (mContext.getPackageName().equals(token.getPackageName())
+                    && TAG.equals(token.getId())) {
+                assertFalse(foundSession);
+                foundSession = true;
+            }
+        }
+        assertTrue(foundSession);
+    }
+
+    @Test
+    public void testGetMediaSession2Tokens_playerRemoved() throws InterruptedException {
+        // Release
+        sHandler.postAndSync(() -> {
+            mSession.setPlayer(null);
+        });
+        ensureChangeInSession();
+
+        // When the mSession's player becomes null, it should lose binder connection between server.
+        // So server will forget the session.
+        List<SessionToken> tokens = mManager.getActiveSessionTokens();
+        for (int i = 0; i < tokens.size(); i++) {
+            SessionToken token = tokens.get(i);
+            assertFalse(mContext.getPackageName().equals(token.getPackageName())
+                    && TAG.equals(token.getId()));
+        }
+    }
+
+    @Test
+    public void testGetMediaSessionService2Token() throws InterruptedException {
+        boolean foundTestSessionService = false;
+        List<SessionToken> tokens = mManager.getSessionServiceTokens();
+        for (int i = 0; i < tokens.size(); i++) {
+            SessionToken token = tokens.get(i);
+            if (mContext.getPackageName().equals(token.getPackageName())
+                    && MockMediaSessionService2.ID.equals(token.getId())) {
+                assertFalse(foundTestSessionService);
+                assertEquals(SessionToken.TYPE_SESSION_SERVICE, token.getType());
+                assertNull(token.getSessionBinder());
+                foundTestSessionService = true;
+            }
+        }
+        assertTrue(foundTestSessionService);
+    }
+
+    @Test
+    public void testGetAllSessionTokens() throws InterruptedException {
+        boolean foundTestSession = false;
+        boolean foundTestSessionService = false;
+        List<SessionToken> tokens = mManager.getAllSessionTokens();
+        for (int i = 0; i < tokens.size(); i++) {
+            SessionToken token = tokens.get(i);
+            if (!mContext.getPackageName().equals(token.getPackageName())) {
+                continue;
+            }
+            switch (token.getId()) {
+                case TAG:
+                    assertFalse(foundTestSession);
+                    foundTestSession = true;
+                    break;
+                case MockMediaSessionService2.ID:
+                    assertFalse(foundTestSessionService);
+                    foundTestSessionService = true;
+                    assertEquals(SessionToken.TYPE_SESSION_SERVICE, token.getType());
+                    break;
+                default:
+                    fail("Unexpected session " + token + " exists in the package");
+            }
+        }
+        assertTrue(foundTestSession);
+        assertTrue(foundTestSessionService);
+    }
+
+    // Ensures if the session creation/release is notified to the server.
+    private void ensureChangeInSession() throws InterruptedException {
+        // TODO(jaewan): Wait by listener.
+        Thread.sleep(WAIT_TIME_MS);
+    }
+}
diff --git a/packages/MediaComponents/test/src/android/media/MockMediaSessionService2.java b/packages/MediaComponents/test/src/android/media/MockMediaSessionService2.java
new file mode 100644
index 0000000..0fa2f52
--- /dev/null
+++ b/packages/MediaComponents/test/src/android/media/MockMediaSessionService2.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.media;
+
+import static junit.framework.Assert.fail;
+
+import android.media.MediaSession2.ControllerInfo;
+import android.media.MediaSession2.SessionCallback;
+import android.media.TestUtils.SyncHandler;
+import android.os.Process;
+
+/**
+ * Mock implementation of {@link android.media.MediaSessionService2} for testing.
+ */
+public class MockMediaSessionService2 extends MediaSessionService2 {
+    // Keep in sync with the AndroidManifest.xml
+    public static final String ID = "TestSession";
+    public MediaSession2 mSession;
+
+    @Override
+    public MediaSession2 onCreateSession(String sessionId) {
+        final MockPlayer player = new MockPlayer(1);
+        SyncHandler handler = (SyncHandler) TestServiceRegistry.getInstance().getHandler();
+        try {
+            handler.postAndSync(() -> {
+                mSession = new MediaSession2.Builder(MockMediaSessionService2.this, player)
+                        .setId(sessionId).setSessionCallback(new MySessionCallback()).build();
+            });
+        } catch (InterruptedException e) {
+            fail(e.toString());
+        }
+        return mSession;
+    }
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+    }
+
+    @Override
+    public void onDestroy() {
+        TestServiceRegistry.getInstance().cleanUp();
+        super.onDestroy();
+    }
+
+    private class MySessionCallback extends SessionCallback {
+        @Override
+        public long onConnect(ControllerInfo controller) {
+            if (Process.myUid() != controller.getUid()) {
+                // It's system app wants to listen changes. Ignore.
+                return super.onConnect(controller);
+            }
+            TestServiceRegistry.getInstance().setServiceInstance(
+                    MockMediaSessionService2.this, controller);
+            return super.onConnect(controller);
+        }
+    }
+}
diff --git a/packages/MediaComponents/test/src/android/media/MockPlayer.java b/packages/MediaComponents/test/src/android/media/MockPlayer.java
new file mode 100644
index 0000000..b0d7a69
--- /dev/null
+++ b/packages/MediaComponents/test/src/android/media/MockPlayer.java
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.media;
+
+import android.media.session.PlaybackState;
+import android.os.Handler;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * A mock implementation of {@link MediaPlayerBase} for testing.
+ */
+public class MockPlayer extends MediaPlayerBase {
+    public final CountDownLatch mCountDownLatch;
+
+    public boolean mPlayCalled;
+    public boolean mPauseCalled;
+    public boolean mStopCalled;
+    public boolean mSkipToPreviousCalled;
+    public boolean mSkipToNextCalled;
+    public List<PlaybackListenerHolder> mListeners = new ArrayList<>();
+    private PlaybackState mLastPlaybackState;
+
+    public MockPlayer(int count) {
+        mCountDownLatch = (count > 0) ? new CountDownLatch(count) : null;
+    }
+
+    @Override
+    public void play() {
+        mPlayCalled = true;
+        if (mCountDownLatch != null) {
+            mCountDownLatch.countDown();
+        }
+    }
+
+    @Override
+    public void pause() {
+        mPauseCalled = true;
+        if (mCountDownLatch != null) {
+            mCountDownLatch.countDown();
+        }
+    }
+
+    @Override
+    public void stop() {
+        mStopCalled = true;
+        if (mCountDownLatch != null) {
+            mCountDownLatch.countDown();
+        }
+    }
+
+    @Override
+    public void skipToPrevious() {
+        mSkipToPreviousCalled = true;
+        if (mCountDownLatch != null) {
+            mCountDownLatch.countDown();
+        }
+    }
+
+    @Override
+    public void skipToNext() {
+        mSkipToNextCalled = true;
+        if (mCountDownLatch != null) {
+            mCountDownLatch.countDown();
+        }
+    }
+
+    @Nullable
+    @Override
+    public PlaybackState getPlaybackState() {
+        return mLastPlaybackState;
+    }
+
+    @Override
+    public void addPlaybackListener(
+            @NonNull PlaybackListener listener, @NonNull Handler handler) {
+        mListeners.add(new PlaybackListenerHolder(listener, handler));
+    }
+
+    @Override
+    public void removePlaybackListener(@NonNull PlaybackListener listener) {
+        int index = PlaybackListenerHolder.indexOf(mListeners, listener);
+        if (index >= 0) {
+            mListeners.remove(index);
+        }
+    }
+
+    public void notifyPlaybackState(final PlaybackState state) {
+        mLastPlaybackState = state;
+        for (int i = 0; i < mListeners.size(); i++) {
+            mListeners.get(i).postPlaybackChange(state);
+        }
+    }
+}
diff --git a/packages/MediaComponents/test/src/android/media/PlaybackListenerHolder.java b/packages/MediaComponents/test/src/android/media/PlaybackListenerHolder.java
new file mode 100644
index 0000000..b0b87de
--- /dev/null
+++ b/packages/MediaComponents/test/src/android/media/PlaybackListenerHolder.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.media;
+
+import android.media.MediaPlayerBase.PlaybackListener;
+import android.media.session.PlaybackState;
+import android.os.Handler;
+import android.os.Message;
+import android.support.annotation.NonNull;
+
+import java.util.List;
+
+/**
+ * Holds {@link PlaybackListener} with the {@link Handler}.
+ */
+public class PlaybackListenerHolder extends Handler {
+    private static final int ON_PLAYBACK_CHANGED = 1;
+
+    public final PlaybackListener listener;
+
+    public PlaybackListenerHolder(
+            @NonNull PlaybackListener listener, @NonNull Handler handler) {
+        super(handler.getLooper());
+        this.listener = listener;
+    }
+
+    @Override
+    public void handleMessage(Message msg) {
+        switch (msg.what) {
+            case ON_PLAYBACK_CHANGED:
+                listener.onPlaybackChanged((PlaybackState) msg.obj);
+                break;
+        }
+    }
+
+    public void postPlaybackChange(PlaybackState state) {
+        obtainMessage(ON_PLAYBACK_CHANGED, state).sendToTarget();
+    }
+
+    /**
+     * Returns {@code true} if the given list contains a {@link PlaybackListenerHolder} that holds
+     * the given listener.
+     *
+     * @param list list to check
+     * @param listener listener to check
+     * @return {@code true} if the given list contains listener. {@code false} otherwise.
+     */
+    public static <Holder extends PlaybackListenerHolder> boolean contains(
+            @NonNull List<Holder> list, PlaybackListener listener) {
+        return indexOf(list, listener) >= 0;
+    }
+
+    /**
+     * Returns the index of the {@link PlaybackListenerHolder} that contains the given listener.
+     *
+     * @param list list to check
+     * @param listener listener to check
+     * @return {@code index} of item if the given list contains listener. {@code -1} otherwise.
+     */
+    public static <Holder extends PlaybackListenerHolder> int indexOf(
+            @NonNull List<Holder> list, PlaybackListener listener) {
+        for (int i = 0; i < list.size(); i++) {
+            if (list.get(i).listener == listener) {
+                return i;
+            }
+        }
+        return -1;
+    }
+}
diff --git a/packages/MediaComponents/test/src/android/media/TestServiceRegistry.java b/packages/MediaComponents/test/src/android/media/TestServiceRegistry.java
new file mode 100644
index 0000000..378a6c4
--- /dev/null
+++ b/packages/MediaComponents/test/src/android/media/TestServiceRegistry.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.media;
+
+import static org.junit.Assert.fail;
+
+import android.media.MediaSession2.ControllerInfo;
+import android.media.TestUtils.SyncHandler;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.GuardedBy;
+
+/**
+ * Keeps the instance of currently running {@link MockMediaSessionService2}. And also provides
+ * a way to control them in one place.
+ * <p>
+ * It only support only one service at a time.
+ */
+public class TestServiceRegistry {
+    public interface ServiceInstanceChangedCallback {
+        void OnServiceInstanceChanged(MediaSessionService2 service);
+    }
+
+    @GuardedBy("TestServiceRegistry.class")
+    private static TestServiceRegistry sInstance;
+    @GuardedBy("TestServiceRegistry.class")
+    private MediaSessionService2 mService;
+    @GuardedBy("TestServiceRegistry.class")
+    private SyncHandler mHandler;
+    @GuardedBy("TestServiceRegistry.class")
+    private ControllerInfo mOnConnectControllerInfo;
+    @GuardedBy("TestServiceRegistry.class")
+    private ServiceInstanceChangedCallback mCallback;
+
+    public static TestServiceRegistry getInstance() {
+        synchronized (TestServiceRegistry.class) {
+            if (sInstance == null) {
+                sInstance = new TestServiceRegistry();
+            }
+            return sInstance;
+        }
+    }
+
+    public void setHandler(Handler handler) {
+        synchronized (TestServiceRegistry.class) {
+            mHandler = new SyncHandler(handler.getLooper());
+        }
+    }
+
+    public void setServiceInstanceChangedCallback(ServiceInstanceChangedCallback callback) {
+        synchronized (TestServiceRegistry.class) {
+            mCallback = callback;
+        }
+    }
+
+    public Handler getHandler() {
+        synchronized (TestServiceRegistry.class) {
+            return mHandler;
+        }
+    }
+
+    public void setServiceInstance(MediaSessionService2 service, ControllerInfo controller) {
+        synchronized (TestServiceRegistry.class) {
+            if (mService != null) {
+                fail("Previous service instance is still running. Clean up manually to ensure"
+                        + " previoulsy running service doesn't break current test");
+            }
+            mService = service;
+            mOnConnectControllerInfo = controller;
+            if (mCallback != null) {
+                mCallback.OnServiceInstanceChanged(service);
+            }
+        }
+    }
+
+    public MediaSessionService2 getServiceInstance() {
+        synchronized (TestServiceRegistry.class) {
+            return mService;
+        }
+    }
+
+    public ControllerInfo getOnConnectControllerInfo() {
+        synchronized (TestServiceRegistry.class) {
+            return mOnConnectControllerInfo;
+        }
+    }
+
+
+    public void cleanUp() {
+        synchronized (TestServiceRegistry.class) {
+            final ServiceInstanceChangedCallback callback = mCallback;
+            if (mService != null) {
+                try {
+                    if (mHandler.getLooper() == Looper.myLooper()) {
+                        mService.getSession().setPlayer(null);
+                    } else {
+                        mHandler.postAndSync(() -> {
+                            mService.getSession().setPlayer(null);
+                        });
+                    }
+                } catch (InterruptedException e) {
+                    // No-op. Service containing session will die, but shouldn't be a huge issue.
+                }
+                // stopSelf() would not kill service while the binder connection established by
+                // bindService() exists, and setPlayer(null) above will do the job instead.
+                // So stopSelf() isn't really needed, but just for sure.
+                mService.stopSelf();
+                mService = null;
+            }
+            if (mHandler != null) {
+                mHandler.removeCallbacksAndMessages(null);
+            }
+            mCallback = null;
+            mOnConnectControllerInfo = null;
+
+            if (callback != null) {
+                callback.OnServiceInstanceChanged(null);
+            }
+        }
+    }
+}
diff --git a/packages/MediaComponents/test/src/android/media/TestUtils.java b/packages/MediaComponents/test/src/android/media/TestUtils.java
new file mode 100644
index 0000000..0cca12c
--- /dev/null
+++ b/packages/MediaComponents/test/src/android/media/TestUtils.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.media;
+
+import android.content.Context;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+import android.os.Handler;
+
+import android.os.Looper;
+
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Utilities for tests.
+ */
+public final class TestUtils {
+    private static final int WAIT_TIME_MS = 1000;
+    private static final int WAIT_SERVICE_TIME_MS = 5000;
+
+    /**
+     * Creates a {@link android.media.session.PlaybackState} with the given state.
+     *
+     * @param state one of the PlaybackState.STATE_xxx.
+     * @return a PlaybackState
+     */
+    public static PlaybackState createPlaybackState(int state) {
+        return new PlaybackState.Builder().setState(state, 0, 1.0f).build();
+    }
+
+    public static SessionToken getServiceToken(Context context, String id) {
+        MediaSessionManager manager =
+                (MediaSessionManager) context.getSystemService(Context.MEDIA_SESSION_SERVICE);
+        List<SessionToken> tokens = manager.getSessionServiceTokens();
+        for (int i = 0; i < tokens.size(); i++) {
+            SessionToken token = tokens.get(i);
+            if (context.getPackageName().equals(token.getPackageName())
+                    && id.equals(token.getId())) {
+                return token;
+            }
+        }
+        fail("Failed to find service");
+        return null;
+    }
+
+    /**
+     * Handler that always waits until the Runnable finishes.
+     */
+    public static class SyncHandler extends Handler {
+        public SyncHandler(Looper looper) {
+            super(looper);
+        }
+
+        public void postAndSync(Runnable runnable) throws InterruptedException {
+            final CountDownLatch latch = new CountDownLatch(1);
+            if (getLooper() == Looper.myLooper()) {
+                runnable.run();
+            } else {
+                post(()->{
+                    runnable.run();
+                    latch.countDown();
+                });
+                assertTrue(latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS));
+            }
+        }
+    }
+}