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));
+ }
+ }
+ }
+}