Add MediaSourceViewModel.
Adds a ViewModel for obtaining and selecting MediaSources. SimpleMediaSource is a copy of MediaSource, and will be renamed once all references to the old class are removed. ActiveMediaControllerLiveData is a heavily modified copy of ActiveMediaSourceManager. MediaSourcesLiveData is modeled after MediaSourcesManager.
Test: Full tests for LiveDatas and ViewModels in this CL. Also fixes test breakage in PlaybackViewModelTest.
Change-Id: Icd8072c4c9aaa618a08f96afb39977f10a655775
diff --git a/car-media-common/src/com/android/car/media/common/source/ActiveMediaControllerLiveData.java b/car-media-common/src/com/android/car/media/common/source/ActiveMediaControllerLiveData.java
new file mode 100644
index 0000000..cfec8d4
--- /dev/null
+++ b/car-media-common/src/com/android/car/media/common/source/ActiveMediaControllerLiveData.java
@@ -0,0 +1,259 @@
+/*
+ * 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.car.media.common.source;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.media.session.MediaController;
+import android.media.session.MediaSessionManager;
+import android.media.session.PlaybackState;
+import android.util.Log;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.LiveData;
+
+import com.android.car.media.common.playback.PlaybackStateAnnotations;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * This is an abstractions over {@link MediaSessionManager} that provides information about the
+ * currently "active" media session.
+ * <p>
+ * It automatically determines the foreground media app (the one that would normally receive
+ * playback events) and exposes metadata and events from such app, or when a different app becomes
+ * foreground.
+ * <p>
+ * This requires the android.Manifest.permission.MEDIA_CONTENT_CONTROL permission to be held by the
+ * calling app.
+ */
+class ActiveMediaControllerLiveData extends LiveData<MediaController> {
+ private static final String TAG = "ActiveSourceManager";
+
+ private static final String PLAYBACK_MODEL_SHARED_PREFS =
+ "com.android.car.media.PLAYBACK_MODEL";
+ private static final String PLAYBACK_MODEL_ACTIVE_PACKAGE_NAME_KEY =
+ "active_packagename";
+
+ private final MediaSessionManager mMediaSessionManager;
+ private final MediaSessionUpdater mMediaSessionUpdater = new MediaSessionUpdater();
+ private final SharedPreferences mSharedPreferences;
+
+ /**
+ * Temporary work-around to bug b/76017849. MediaSessionManager is not notifying media session
+ * priority changes. As a work-around we subscribe to playback state changes on all controllers
+ * to detect potential priority changes. This might cause a few unnecessary checks, but
+ * selecting the top-most controller is a cheap operation.
+ */
+ private class MediaSessionUpdater {
+ private List<MediaController> mControllers = new ArrayList<>();
+
+ private MediaController.Callback mCallback = new MediaController.Callback() {
+ @Override
+ public void onPlaybackStateChanged(@Nullable PlaybackState state) {
+ selectMediaController(mMediaSessionManager.getActiveSessions(null));
+ }
+
+ @Override
+ public void onSessionDestroyed() {
+ selectMediaController(mMediaSessionManager.getActiveSessions(null));
+ }
+ };
+
+ void registerCallbacks(List<MediaController> newControllers) {
+ for (MediaController oldController : mControllers) {
+ oldController.unregisterCallback(mCallback);
+ }
+ for (MediaController newController : newControllers) {
+ newController.registerCallback(mCallback);
+ }
+ mControllers.clear();
+ mControllers.addAll(newControllers);
+ }
+ }
+
+ private MediaSessionManager.OnActiveSessionsChangedListener mSessionChangeListener =
+ this::selectMediaController;
+
+ ActiveMediaControllerLiveData(Context context) {
+ this(Objects.requireNonNull(context.getSystemService(MediaSessionManager.class)),
+ context.getSharedPreferences(PLAYBACK_MODEL_SHARED_PREFS, Context.MODE_PRIVATE));
+ }
+
+ @VisibleForTesting
+ ActiveMediaControllerLiveData(@NonNull MediaSessionManager mediaSessionManager,
+ @NonNull SharedPreferences preferences) {
+ mMediaSessionManager = mediaSessionManager;
+ mSharedPreferences = preferences;
+ }
+
+ /**
+ * Selects one of the provided controllers as the "currently playing" one.
+ */
+ private void selectMediaController(List<MediaController> controllers) {
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ dump("Selecting a media controller from: ", controllers);
+ }
+ changeMediaController(getTopMostController(controllers), true);
+ mMediaSessionUpdater.registerCallbacks(controllers);
+ }
+
+ private void dump(@SuppressWarnings("SameParameterValue") String title,
+ List<MediaController> controllers) {
+ Log.d(TAG, title + " (total: " + controllers.size() + ")");
+ for (MediaController controller : controllers) {
+ String stateName = getStateName(controller.getPlaybackState() != null
+ ? controller.getPlaybackState().getState()
+ : PlaybackState.STATE_NONE);
+ Log.d(TAG, String.format("\t%s: %s",
+ controller.getPackageName(),
+ stateName));
+ }
+ }
+
+ private String getStateName(@PlaybackStateAnnotations.State int state) {
+ switch (state) {
+ case PlaybackState.STATE_NONE:
+ return "NONE";
+ case PlaybackState.STATE_STOPPED:
+ return "STOPPED";
+ case PlaybackState.STATE_PAUSED:
+ return "PAUSED";
+ case PlaybackState.STATE_PLAYING:
+ return "PLAYING";
+ case PlaybackState.STATE_FAST_FORWARDING:
+ return "FORWARDING";
+ case PlaybackState.STATE_REWINDING:
+ return "REWINDING";
+ case PlaybackState.STATE_BUFFERING:
+ return "BUFFERING";
+ case PlaybackState.STATE_ERROR:
+ return "ERROR";
+ case PlaybackState.STATE_CONNECTING:
+ return "CONNECTING";
+ case PlaybackState.STATE_SKIPPING_TO_PREVIOUS:
+ return "SKIPPING_TO_PREVIOUS";
+ case PlaybackState.STATE_SKIPPING_TO_NEXT:
+ return "SKIPPING_TO_NEXT";
+ case PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM:
+ return "SKIPPING_TO_QUEUE_ITEM";
+ default:
+ return "UNKNOWN";
+ }
+ }
+
+ /**
+ * Returns the controller most likely to be the currently active one, out of the list of active
+ * controllers reported by {@link MediaSessionManager}. It does so by picking the first one (in
+ * order of priority) with an active state as reported by
+ * {@link MediaController#getPlaybackState()}
+ */
+ @Nullable
+ private MediaController getTopMostController(List<MediaController> controllers) {
+ if (controllers != null && controllers.size() > 0) {
+ for (MediaController candidate : controllers) {
+ @PlaybackStateAnnotations.State int state = candidate.getPlaybackState() != null
+ ? candidate.getPlaybackState().getState()
+ : PlaybackState.STATE_NONE;
+ if (state == PlaybackState.STATE_BUFFERING
+ || state == PlaybackState.STATE_CONNECTING
+ || state == PlaybackState.STATE_FAST_FORWARDING
+ || state == PlaybackState.STATE_PLAYING
+ || state == PlaybackState.STATE_REWINDING
+ || state == PlaybackState.STATE_SKIPPING_TO_NEXT
+ || state == PlaybackState.STATE_SKIPPING_TO_PREVIOUS
+ || state == PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM) {
+ return candidate;
+ }
+ }
+ // If no source is active, we go for the last known source
+ String packageName = getLastKnownActivePackageName();
+ if (packageName != null) {
+ for (MediaController candidate : controllers) {
+ if (candidate.getPackageName().equals(packageName)) {
+ return candidate;
+ }
+ }
+ }
+ return controllers.get(0);
+ }
+ return null;
+ }
+
+ private void changeMediaController(MediaController mediaController, boolean persist) {
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ Log.d(TAG, "New media controller: " + (mediaController != null
+ ? mediaController.getPackageName() : null));
+ }
+ if ((mediaController == null && getValue() == null)
+ || (mediaController != null && getValue() != null
+ && mediaController.getPackageName().equals(getValue().getPackageName()))) {
+ // If no change, do nothing.
+ return;
+ }
+ postValue(mediaController);
+ if (persist) {
+ setLastKnownActivePackageName(mediaController != null
+ ? mediaController.getPackageName()
+ : null);
+ }
+ }
+
+ @Override
+ protected void onActive() {
+ mMediaSessionManager.addOnActiveSessionsChangedListener(mSessionChangeListener, null);
+ selectMediaController(mMediaSessionManager.getActiveSessions(null));
+ }
+
+ @Override
+ protected void onInactive() {
+ mMediaSessionUpdater.registerCallbacks(new ArrayList<>());
+ mMediaSessionManager.removeOnActiveSessionsChangedListener(mSessionChangeListener);
+ changeMediaController(null, false);
+ }
+
+
+ private String getLastKnownActivePackageName() {
+ return mSharedPreferences.getString(PLAYBACK_MODEL_ACTIVE_PACKAGE_NAME_KEY, null);
+ }
+
+ private void setLastKnownActivePackageName(String packageName) {
+ mSharedPreferences.edit()
+ .putString(PLAYBACK_MODEL_ACTIVE_PACKAGE_NAME_KEY, packageName)
+ .apply();
+ }
+
+ /**
+ * Returns the {@link MediaController} corresponding to the given package name, or NULL if no
+ * active session exists for it.
+ */
+ @Nullable
+ MediaController getControllerForPackage(String packageName) {
+ List<MediaController> controllers = mMediaSessionManager.getActiveSessions(null);
+ for (MediaController controller : controllers) {
+ if (controller.getPackageName().equals(packageName)) {
+ return controller;
+ }
+ }
+ return null;
+ }
+
+}
diff --git a/car-media-common/src/com/android/car/media/common/source/MediaBrowserConnector.java b/car-media-common/src/com/android/car/media/common/source/MediaBrowserConnector.java
new file mode 100644
index 0000000..2b93713
--- /dev/null
+++ b/car-media-common/src/com/android/car/media/common/source/MediaBrowserConnector.java
@@ -0,0 +1,134 @@
+/*
+ * 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.car.media.common.source;
+
+import android.annotation.NonNull;
+import android.content.ComponentName;
+import android.content.Context;
+import android.media.browse.MediaBrowser;
+
+import androidx.annotation.IntDef;
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.LiveData;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * A LiveData that emits a MediaBrowserState for the current connection status. Attempts to maintain
+ * a connection while active.
+ */
+
+class MediaBrowserConnector extends LiveData<MediaBrowserConnector.MediaBrowserState> {
+
+ /**
+ * Contains the {@link MediaBrowser} for a {@link MediaBrowserConnector} and its associated
+ * connection status.
+ */
+ public static class MediaBrowserState {
+ public final MediaBrowser mMediaBrowser;
+
+ @ConnectionState
+ public final int mConnectionState;
+
+ MediaBrowserState(MediaBrowser mediaBrowser, @ConnectionState int connectionState) {
+ mMediaBrowser = mediaBrowser;
+ mConnectionState = connectionState;
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(value = {ConnectionState.DISCONNECTED, ConnectionState.CONNECTING,
+ ConnectionState.CONNECTED, ConnectionState.CONNECTION_FAILED})
+ public @interface ConnectionState {
+ int DISCONNECTED = 0;
+ int CONNECTING = 1;
+ int CONNECTED = 2;
+ int CONNECTION_FAILED = 3;
+ }
+
+ private final MediaBrowser mBrowser;
+
+ /**
+ * Create a new MediaBrowserConnector for the specified component.
+ *
+ * @param context The Context with which to build the MediaBrowser.
+ * @param browseService The ComponentName of the media browser service.
+ * @see MediaBrowser#MediaBrowser(Context, ComponentName, MediaBrowser.ConnectionCallback,
+ * android.os.Bundle)
+ */
+ MediaBrowserConnector(@NonNull Context context,
+ @NonNull ComponentName browseService) {
+ mBrowser = createMediaBrowser(context, browseService,
+ new MediaBrowser.ConnectionCallback() {
+ @Override
+ public void onConnected() {
+ setValue(new MediaBrowserState(mBrowser, ConnectionState.CONNECTED));
+ }
+
+ @Override
+ public void onConnectionFailed() {
+ setValue(
+ new MediaBrowserState(mBrowser, ConnectionState.CONNECTION_FAILED));
+ }
+
+ @Override
+ public void onConnectionSuspended() {
+ setValue(new MediaBrowserState(mBrowser, ConnectionState.DISCONNECTED));
+ }
+ });
+ }
+
+ /**
+ * Instantiate the MediaBrowser this MediaBrowserConnector will connect with.
+ */
+ @VisibleForTesting()
+ protected MediaBrowser createMediaBrowser(@NonNull Context context,
+ @NonNull ComponentName browseService,
+ @NonNull MediaBrowser.ConnectionCallback callback) {
+ return new MediaBrowser(context, browseService, callback, null);
+ }
+
+ @Override
+ protected void onActive() {
+ super.onActive();
+ if (mBrowser.isConnected()) {
+ setValue(new MediaBrowserState(mBrowser, ConnectionState.CONNECTED));
+ } else {
+ try {
+ connect();
+ } catch (IllegalStateException ex) {
+ // Ignore: MediaBrowse could be in an intermediate state (not connected, but not
+ // disconnected either.). In this situation, trying to connect again can throw
+ // this exception, but there is no way to know without trying.
+ }
+ }
+ }
+
+ private void connect() {
+ setValue(new MediaBrowserState(mBrowser, ConnectionState.CONNECTING));
+ mBrowser.connect();
+ }
+
+ @Override
+ protected void onInactive() {
+ // TODO(b/77640010): Review MediaBrowse disconnection.
+ // Some media sources are not responding correctly to MediaBrowser#disconnect(). We
+ // are keeping the connection going.
+ // mBrowser.disconnect();
+ }
+}
diff --git a/car-media-common/src/com/android/car/media/common/source/MediaSourceColors.java b/car-media-common/src/com/android/car/media/common/source/MediaSourceColors.java
index 0de155c..1db676a 100644
--- a/car-media-common/src/com/android/car/media/common/source/MediaSourceColors.java
+++ b/car-media-common/src/com/android/car/media/common/source/MediaSourceColors.java
@@ -76,6 +76,11 @@
mContext = context;
}
+ /** Extract colors for (@code mediaSource} and create a MediaSourceColors for it */
+ public MediaSourceColors extractColors(@NonNull SimpleMediaSource mediaSource) {
+ return extractColors(mediaSource.getPackageName());
+ }
+
/** Extract colors for {@code packageName} and create a MediaSourceColors for it */
public MediaSourceColors extractColors(@NonNull String packageName) {
TypedArray ta = null;
diff --git a/car-media-common/src/com/android/car/media/common/source/MediaSourceViewModel.java b/car-media-common/src/com/android/car/media/common/source/MediaSourceViewModel.java
new file mode 100644
index 0000000..22cedaa
--- /dev/null
+++ b/car-media-common/src/com/android/car/media/common/source/MediaSourceViewModel.java
@@ -0,0 +1,245 @@
+/*
+ * 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.car.media.common.source;
+
+import static androidx.lifecycle.Transformations.map;
+import static androidx.lifecycle.Transformations.switchMap;
+
+import static com.android.car.arch.common.LiveDataFunctions.coalesceNull;
+import static com.android.car.arch.common.LiveDataFunctions.combine;
+import static com.android.car.arch.common.LiveDataFunctions.mapNonNull;
+import static com.android.car.arch.common.LiveDataFunctions.nullLiveData;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UiThread;
+import android.app.Application;
+import android.content.ComponentName;
+import android.media.browse.MediaBrowser;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import com.android.car.media.common.source.MediaBrowserConnector.ConnectionState;
+import com.android.car.media.common.source.MediaBrowserConnector.MediaBrowserState;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Contains observable data needed for displaying playback and browse UI
+ */
+public class MediaSourceViewModel extends AndroidViewModel {
+
+ private final InputFactory mInputFactory;
+
+ private final LiveData<List<SimpleMediaSource>> mMediaSources;
+
+ private final LiveData<Boolean> mHasMediaSources;
+
+ private final MutableLiveData<SimpleMediaSource> mSelectedMediaSource = new MutableLiveData<>();
+
+ private final LiveData<MediaBrowser> mConnectedMediaBrowser;
+
+ // Media controller for selected media source.
+ private final LiveData<MediaController> mMediaController;
+
+ // Media controller for active media source, may not be the same as selected media source.
+ private final LiveData<MediaController> mActiveMediaController;
+
+ private final LiveData<Boolean> mIsCurrentMediaSourcePlaying;
+
+ /**
+ * Factory for creating dependencies. Can be swapped out for testing.
+ */
+ @VisibleForTesting
+ interface InputFactory {
+ LiveData<List<SimpleMediaSource>> createMediaSources();
+
+ LiveData<MediaBrowserState> createMediaBrowserConnector(
+ @NonNull ComponentName browseService);
+
+ LiveData<MediaController> createActiveMediaController();
+
+ MediaController getControllerForPackage(String packageName);
+
+ MediaController getControllerForSession(@Nullable MediaSession.Token session);
+ }
+
+ /**
+ * Create a new instance of MediaSourceViewModel
+ *
+ * @see AndroidViewModel
+ */
+ public MediaSourceViewModel(@NonNull Application application) {
+ this(application, new InputFactory() {
+
+ private ActiveMediaControllerLiveData mActiveMediaControllerLiveData =
+ new ActiveMediaControllerLiveData(application);
+
+ @Override
+ public LiveData<List<SimpleMediaSource>> createMediaSources() {
+ return new MediaSourcesLiveData(application);
+ }
+
+ @Override
+ public LiveData<MediaBrowserState> createMediaBrowserConnector(
+ @NonNull ComponentName browseService) {
+ return new MediaBrowserConnector(application, browseService);
+ }
+
+ @Override
+ public LiveData<MediaController> createActiveMediaController() {
+ return mActiveMediaControllerLiveData;
+ }
+
+ @Override
+ public MediaController getControllerForPackage(String packageName) {
+ return mActiveMediaControllerLiveData.getControllerForPackage(packageName);
+ }
+
+ @Override
+ public MediaController getControllerForSession(@Nullable MediaSession.Token token) {
+ if (token == null) return null;
+ return new MediaController(application, token);
+ }
+ });
+ }
+
+ @VisibleForTesting
+ MediaSourceViewModel(@NonNull Application application, @NonNull InputFactory inputFactory) {
+ super(application);
+
+ mInputFactory = inputFactory;
+
+ mActiveMediaController = inputFactory.createActiveMediaController();
+
+ mMediaSources = inputFactory.createMediaSources();
+ mHasMediaSources = map(mMediaSources, sources -> sources != null && !sources.isEmpty());
+
+ LiveData<MediaBrowserState> mediaBrowserState = switchMap(mSelectedMediaSource,
+ (mediaSource) -> {
+ if (mediaSource == null) {
+ return nullLiveData();
+ }
+ ComponentName browseService = mediaSource.getBrowseServiceComponentName();
+ if (browseService == null) {
+ return nullLiveData();
+ }
+ return inputFactory.createMediaBrowserConnector(browseService);
+ });
+ mConnectedMediaBrowser = map(mediaBrowserState,
+ state -> state != null && (state.mConnectionState == ConnectionState.CONNECTED)
+ ? state.mMediaBrowser : null);
+
+ LiveData<MediaController> controllerFromActiveControllers = mapNonNull(mSelectedMediaSource,
+ source -> getControllerForPackage(source.getPackageName()));
+ LiveData<MediaController> controllerFromMediaBrowser = mapNonNull(mConnectedMediaBrowser,
+ browser -> inputFactory.getControllerForSession(browser.getSessionToken()));
+ mMediaController = coalesceNull(
+ // Prefer fetching MediaController from MediaSessionManager's active controller
+ // list.
+ controllerFromActiveControllers,
+ // If that isn't found, try to connect to the browse service and create a
+ // controller from there.
+ controllerFromMediaBrowser);
+
+ mIsCurrentMediaSourcePlaying = combine(mActiveMediaController, mSelectedMediaSource,
+ (mediaController, mediaSource) ->
+ mediaController != null && mediaSource != null
+ && Objects.equals(mediaController.getPackageName(),
+ mediaSource.getPackageName()));
+ }
+
+ /**
+ * Returns a live list of all MediaSources that can be selected for playback
+ */
+ public LiveData<List<SimpleMediaSource>> getMediaSources() {
+ return mMediaSources;
+ }
+
+ /**
+ * Returns a LiveData that emits whether there are any media sources that can be selected for
+ * playback
+ */
+ public LiveData<Boolean> hasMediaSources() {
+ return mHasMediaSources;
+ }
+
+ /**
+ * Returns a LiveData that emits the MediaSource that is to be browsed or displayed.
+ */
+ public LiveData<SimpleMediaSource> getSelectedMediaSource() {
+ return mSelectedMediaSource;
+ }
+
+ /**
+ * Set the MediaSource that is to be browsed or displayed. If a browse service is available, a
+ * connection may be made and provided through {@link #getConnectedMediaBrowser()}.
+ */
+ @UiThread
+ public void setSelectedMediaSource(@Nullable SimpleMediaSource mediaSource) {
+ mSelectedMediaSource.setValue(mediaSource);
+ }
+
+ /**
+ * Returns a LiveData that emits the currently connected MediaBrowser. Emits {@code null} if no
+ * MediaSource is set, if the MediaSource does not support browsing, or if the MediaBrowser is
+ * not connected. Observing the LiveData will attempt to connect to a media browse session if
+ * possible.
+ */
+ public LiveData<MediaBrowser> getConnectedMediaBrowser() {
+ return mConnectedMediaBrowser;
+ }
+
+ /**
+ * Returns a LiveData that emits a {@link MediaController} that allows controlling this media
+ * source, or emits {@code null} if the media source doesn't support browsing or the browser is
+ * not connected.
+ */
+ public LiveData<MediaController> getMediaController() {
+ return mMediaController;
+ }
+
+ /**
+ * Returns a LiveData that emits a {@link MediaController} for the active media source. Note
+ * that this may not be from the selected media source.
+ */
+ public LiveData<MediaController> getActiveMediaController() {
+ return mActiveMediaController;
+ }
+
+ /**
+ * Returns a MediaController for the specified package if an active MediaSession is available.
+ */
+ @Nullable
+ private MediaController getControllerForPackage(String packageName) {
+ return mInputFactory.getControllerForPackage(packageName);
+ }
+
+ /**
+ * Emits {@code true} iff the selected media source is the active media source
+ */
+ public LiveData<Boolean> isCurrentMediaSourcePlaying() {
+ return mIsCurrentMediaSourcePlaying;
+ }
+
+}
diff --git a/car-media-common/src/com/android/car/media/common/source/MediaSourcesLiveData.java b/car-media-common/src/com/android/car/media/common/source/MediaSourcesLiveData.java
new file mode 100644
index 0000000..7634f9f
--- /dev/null
+++ b/car-media-common/src/com/android/car/media/common/source/MediaSourcesLiveData.java
@@ -0,0 +1,126 @@
+/*
+ * 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.car.media.common.source;
+
+import android.annotation.NonNull;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.service.media.MediaBrowserService;
+import android.util.Log;
+
+import androidx.lifecycle.LiveData;
+
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * A LiveData that provides access to the list of all possible media sources that can be selected
+ * to be played.
+ */
+class MediaSourcesLiveData extends LiveData<List<SimpleMediaSource>> {
+
+ private static final String TAG = "MediaSourcesLiveData";
+ private final Context mContext;
+
+ private final BroadcastReceiver mAppInstallUninstallReceiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ updateMediaSources();
+ }
+ };
+
+ MediaSourcesLiveData(@NonNull Context context) {
+ mContext = context;
+ }
+
+ @Override
+ protected void onActive() {
+ super.onActive();
+ updateMediaSources();
+ registerBroadcastReceiver();
+ }
+
+ @Override
+ protected void onInactive() {
+ super.onInactive();
+ unregisterBroadcastReceiver();
+ }
+
+ private void registerBroadcastReceiver() {
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(Intent.ACTION_PACKAGE_ADDED);
+ filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
+ filter.addDataScheme("package");
+ mContext.getApplicationContext().registerReceiver(mAppInstallUninstallReceiver, filter);
+ }
+
+ private void unregisterBroadcastReceiver() {
+ mContext.getApplicationContext().unregisterReceiver(mAppInstallUninstallReceiver);
+ }
+
+ private void updateMediaSources() {
+ List<SimpleMediaSource> mediaSources = getPackageNames().stream()
+ .filter(Objects::nonNull)
+ .map(packageName -> new SimpleMediaSource(mContext, packageName))
+ .filter(mediaSource -> {
+ if (mediaSource.getName() == null) {
+ Log.w(TAG, "Found media source without name: "
+ + mediaSource.getPackageName());
+ return false;
+ }
+ return true;
+ })
+ .sorted(Comparator.comparing(mediaSource -> mediaSource.getName().toString()))
+ .collect(Collectors.toList());
+ setValue(mediaSources);
+ }
+
+ /**
+ * Generates a set of all possible apps to choose from, including the ones that are just
+ * media services.
+ */
+ private Set<String> getPackageNames() {
+ PackageManager packageManager = mContext.getPackageManager();
+ Intent intent = new Intent(Intent.ACTION_MAIN, null);
+ intent.addCategory(Intent.CATEGORY_APP_MUSIC);
+
+ Intent mediaIntent = new Intent();
+ mediaIntent.setAction(MediaBrowserService.SERVICE_INTERFACE);
+
+ List<ResolveInfo> availableActivities = packageManager.queryIntentActivities(intent, 0);
+ List<ResolveInfo> mediaServices = packageManager.queryIntentServices(mediaIntent,
+ PackageManager.GET_RESOLVED_FILTER);
+
+ Set<String> apps = new HashSet<>();
+ for (ResolveInfo info : mediaServices) {
+ apps.add(info.serviceInfo.packageName);
+ }
+ for (ResolveInfo info : availableActivities) {
+ apps.add(info.activityInfo.packageName);
+ }
+ return apps;
+ }
+
+}
diff --git a/car-media-common/src/com/android/car/media/common/source/SimpleMediaSource.java b/car-media-common/src/com/android/car/media/common/source/SimpleMediaSource.java
new file mode 100644
index 0000000..5268135
--- /dev/null
+++ b/car-media-common/src/com/android/car/media/common/source/SimpleMediaSource.java
@@ -0,0 +1,254 @@
+/*
+ * 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.car.media.common.source;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ServiceInfo;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.service.media.MediaBrowserService;
+import android.util.Log;
+
+import com.android.car.media.common.MediaSource;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * This represents a source of media content. It provides convenient methods to access media source
+ * metadata, such as primary color and application name.
+ */
+// TODO (keyboardr): Rename to MediaSource once other MediaSource is removed
+public class SimpleMediaSource {
+ private static final String TAG = "MediaSource";
+
+ /**
+ * Custom media sources which should not be templatized.
+ */
+ private static final Set<String> CUSTOM_MEDIA_SOURCES = new HashSet<>();
+ static {
+ CUSTOM_MEDIA_SOURCES.add("com.android.car.radio");
+ }
+
+ private final String mPackageName;
+ @Nullable
+ private final String mBrowseServiceClassName;
+ private final Context mContext;
+ private CharSequence mName;
+
+ /**
+ * Creates a {@link SimpleMediaSource} for the given application package name
+ */
+ public SimpleMediaSource(@NonNull Context context, @NonNull String packageName) {
+ mContext = context;
+ mPackageName = packageName;
+ mBrowseServiceClassName = getBrowseServiceClassName(packageName);
+ extractComponentInfo(mPackageName, mBrowseServiceClassName);
+ }
+
+ /**
+ * Returns a MediaSource equivalent to the one represented by this instance.
+ */
+ // TODO (keyboardr): Remove this method once SimpleMediaSource and MediaSource are merged
+ public MediaSource toMediaSource() {
+ return new MediaSource(mContext, mPackageName);
+ }
+
+ /**
+ * @return the classname corresponding to a {@link MediaBrowserService} in the media source, or
+ * null if the media source doesn't implement {@link MediaBrowserService}. A non-null result
+ * doesn't imply that this service is accessible. The consumer code should attempt to connect
+ * and handle rejections gracefully.
+ */
+ @Nullable
+ private String getBrowseServiceClassName(@NonNull String packageName) {
+ PackageManager packageManager = mContext.getPackageManager();
+ Intent intent = new Intent();
+ intent.setAction(MediaBrowserService.SERVICE_INTERFACE);
+ intent.setPackage(packageName);
+ List<ResolveInfo> resolveInfos = packageManager.queryIntentServices(intent,
+ PackageManager.GET_RESOLVED_FILTER);
+ if (resolveInfos == null || resolveInfos.isEmpty()) {
+ return null;
+ }
+ return resolveInfos.get(0).serviceInfo.name;
+ }
+
+
+
+ private void extractComponentInfo(@NonNull String packageName,
+ @Nullable String browseServiceClassName) {
+ try {
+ ApplicationInfo applicationInfo =
+ mContext.getPackageManager().getApplicationInfo(packageName,
+ PackageManager.GET_META_DATA);
+ ServiceInfo serviceInfo = browseServiceClassName != null
+ ? mContext.getPackageManager().getServiceInfo(
+ new ComponentName(packageName, browseServiceClassName),
+ PackageManager.GET_META_DATA)
+ : null;
+
+ // Get the proper app name, check service label, then application label.
+ if (serviceInfo != null && serviceInfo.labelRes != 0) {
+ mName = serviceInfo.loadLabel(mContext.getPackageManager());
+ } else {
+ mName = applicationInfo.loadLabel(mContext.getPackageManager());
+ }
+ } catch (PackageManager.NameNotFoundException e) {
+ Log.w(TAG, "Unable to update media client package attributes.", e);
+ }
+ }
+
+ /**
+ * @return media source human readable name.
+ */
+ public CharSequence getName() {
+ return mName;
+ }
+
+ /**
+ * @return the package name that identifies this media source.
+ */
+ public String getPackageName() {
+ return mPackageName;
+ }
+
+ /**
+ * @return a {@link ComponentName} referencing this media source's {@link MediaBrowserService},
+ * or NULL if this media source doesn't implement such service.
+ */
+ @Nullable
+ public ComponentName getBrowseServiceComponentName() {
+ if (mBrowseServiceClassName != null) {
+ return new ComponentName(mPackageName, mBrowseServiceClassName);
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns this media source's icon as a {@link Drawable}
+ */
+ public Drawable getPackageIcon() {
+ try {
+ return mContext.getPackageManager().getApplicationIcon(getPackageName());
+ } catch (PackageManager.NameNotFoundException e) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns this media source's icon cropped to a circle.
+ */
+ public Bitmap getRoundPackageIcon() {
+ Drawable packageIcon = getPackageIcon();
+ return packageIcon != null
+ ? getRoundCroppedBitmap(drawableToBitmap(getPackageIcon()))
+ : null;
+ }
+
+ /**
+ * Returns {@code true} iff this media source should not be templatized.
+ */
+ public boolean isCustom() {
+ return CUSTOM_MEDIA_SOURCES.contains(mPackageName);
+ }
+
+ /**
+ * Returns {@code true} iff this media source has a browse service to connect to.
+ */
+ public boolean isBrowsable() {
+ return mBrowseServiceClassName != null;
+ }
+
+ private Bitmap drawableToBitmap(Drawable drawable) {
+ Bitmap bitmap = null;
+
+ if (drawable instanceof BitmapDrawable) {
+ BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable;
+ if (bitmapDrawable.getBitmap() != null) {
+ return bitmapDrawable.getBitmap();
+ }
+ }
+
+ if (drawable.getIntrinsicWidth() <= 0 || drawable.getIntrinsicHeight() <= 0) {
+ bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888);
+ } else {
+ bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(),
+ drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
+ }
+
+ Canvas canvas = new Canvas(bitmap);
+ drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ drawable.draw(canvas);
+ return bitmap;
+ }
+
+ private Bitmap getRoundCroppedBitmap(Bitmap bitmap) {
+ Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(),
+ Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(output);
+
+ final int color = 0xff424242;
+ final Paint paint = new Paint();
+ final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight());
+
+ paint.setAntiAlias(true);
+ canvas.drawARGB(0, 0, 0, 0);
+ paint.setColor(color);
+ canvas.drawCircle(bitmap.getWidth() / 2, bitmap.getHeight() / 2,
+ bitmap.getWidth() / 2f, paint);
+ paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
+ canvas.drawBitmap(bitmap, rect, rect, paint);
+ return output;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ SimpleMediaSource that = (SimpleMediaSource) o;
+ return Objects.equals(mPackageName, that.mPackageName)
+ && Objects.equals(mBrowseServiceClassName, that.mBrowseServiceClassName);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(mPackageName, mBrowseServiceClassName);
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ return getPackageName();
+ }
+}
diff --git a/car-media-common/tests/robotests/src/com/android/car/media/common/playback/PlaybackViewModelTest.java b/car-media-common/tests/robotests/src/com/android/car/media/common/playback/PlaybackViewModelTest.java
index 0e3e0c3..1b31468 100644
--- a/car-media-common/tests/robotests/src/com/android/car/media/common/playback/PlaybackViewModelTest.java
+++ b/car-media-common/tests/robotests/src/com/android/car/media/common/playback/PlaybackViewModelTest.java
@@ -25,13 +25,13 @@
import static org.mockito.Mockito.when;
import static org.robolectric.RuntimeEnvironment.application;
-import android.annotation.NonNull;
import android.media.MediaDescription;
import android.media.MediaMetadata;
import android.media.session.MediaController;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
+import androidx.annotation.NonNull;
import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
import com.android.car.arch.common.testing.CaptureObserver;
@@ -70,6 +70,8 @@
@Mock
public MediaMetadata mMediaMetadata;
@Mock
+ public MediaDescription mMediaDescription;
+ @Mock
public PlaybackState mPlaybackState;
@Captor
private ArgumentCaptor<MediaController.Callback> mCapturedCallback;
@@ -79,6 +81,7 @@
@Before
public void setUp() {
doNothing().when(mMediaController).registerCallback(mCapturedCallback.capture());
+ when(mMediaMetadata.getDescription()).thenReturn(mMediaDescription);
mPlaybackViewModel = new PlaybackViewModel(application);
mPlaybackViewModel.setMediaController(dataOf(mMediaController));
}
@@ -95,21 +98,23 @@
public void testGetMetadata() {
CaptureObserver<MediaItemMetadata> observer = new CaptureObserver<>();
mPlaybackViewModel.getMetadata().observe(mLifecycleOwner, observer);
+ observer.reset();
- assertThat(observer.hasBeenNotified()).isFalse();
assertThat(mCapturedCallback.getValue()).isNotNull();
mCapturedCallback.getValue().onMetadataChanged(mMediaMetadata);
assertThat(observer.hasBeenNotified()).isTrue();
- assertThat(observer.getObservedValue()).isEqualTo(mMediaMetadata);
+ MediaItemMetadata observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(observedValue).isEqualTo(new MediaItemMetadata(mMediaMetadata));
}
@Test
public void testGetPlaybackState() {
CaptureObserver<PlaybackState> observer = new CaptureObserver<>();
mPlaybackViewModel.getPlaybackState().observe(mLifecycleOwner, observer);
+ observer.reset();
- assertThat(observer.hasBeenNotified()).isFalse();
assertThat(mCapturedCallback.getValue()).isNotNull();
mCapturedCallback.getValue().onPlaybackStateChanged(mPlaybackState);
@@ -125,9 +130,8 @@
List<MediaSession.QueueItem> queue = Collections.singletonList(queueItem);
CaptureObserver<List<MediaItemMetadata>> observer = new CaptureObserver<>();
mPlaybackViewModel.getQueue().observe(mLifecycleOwner, observer);
+ observer.reset();
- assertThat(observer.hasBeenNotified()).isFalse();
- assertThat(mCapturedCallback.getValue()).isNotNull();
mCapturedCallback.getValue().onQueueChanged(queue);
assertThat(observer.hasBeenNotified()).isTrue();
@@ -144,9 +148,10 @@
public void testGetHasQueue_null() {
CaptureObserver<Boolean> observer = new CaptureObserver<>();
mPlaybackViewModel.hasQueue().observe(mLifecycleOwner, observer);
+ mCapturedCallback.getValue().onQueueChanged(
+ Collections.singletonList(createQueueItem("title", 1)));
+ observer.reset();
- assertThat(observer.hasBeenNotified()).isFalse();
- assertThat(mCapturedCallback.getValue()).isNotNull();
mCapturedCallback.getValue().onQueueChanged(null);
assertThat(observer.hasBeenNotified()).isTrue();
@@ -158,9 +163,10 @@
List<MediaSession.QueueItem> queue = Collections.emptyList();
CaptureObserver<Boolean> observer = new CaptureObserver<>();
mPlaybackViewModel.hasQueue().observe(mLifecycleOwner, observer);
+ mCapturedCallback.getValue().onQueueChanged(
+ Collections.singletonList(createQueueItem("title", 1)));
+ observer.reset();
- assertThat(observer.hasBeenNotified()).isFalse();
- assertThat(mCapturedCallback.getValue()).isNotNull();
mCapturedCallback.getValue().onQueueChanged(queue);
assertThat(observer.hasBeenNotified()).isTrue();
@@ -172,9 +178,8 @@
List<MediaSession.QueueItem> queue = Collections.singletonList(createQueueItem("title", 1));
CaptureObserver<Boolean> observer = new CaptureObserver<>();
mPlaybackViewModel.hasQueue().observe(mLifecycleOwner, observer);
+ observer.reset();
- assertThat(observer.hasBeenNotified()).isFalse();
- assertThat(mCapturedCallback.getValue()).isNotNull();
mCapturedCallback.getValue().onQueueChanged(queue);
assertThat(observer.hasBeenNotified()).isTrue();
diff --git a/car-media-common/tests/robotests/src/com/android/car/media/common/source/ActiveMediaControllerLiveDataTest.java b/car-media-common/tests/robotests/src/com/android/car/media/common/source/ActiveMediaControllerLiveDataTest.java
new file mode 100644
index 0000000..89a8d11
--- /dev/null
+++ b/car-media-common/tests/robotests/src/com/android/car/media/common/source/ActiveMediaControllerLiveDataTest.java
@@ -0,0 +1,297 @@
+/*
+ * 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.car.media.common.source;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.RuntimeEnvironment.application;
+
+import android.content.SharedPreferences;
+import android.media.session.MediaController;
+import android.media.session.MediaSessionManager;
+import android.media.session.MediaSessionManager.OnActiveSessionsChangedListener;
+import android.media.session.PlaybackState;
+import android.preference.PreferenceManager;
+
+import androidx.annotation.Nullable;
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
+import androidx.lifecycle.Lifecycle;
+
+import com.android.car.arch.common.testing.CaptureObserver;
+import com.android.car.arch.common.testing.TestLifecycleOwner;
+import com.android.car.media.common.TestConfig;
+import com.android.car.media.common.playback.PlaybackStateAnnotations;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION)
+public class ActiveMediaControllerLiveDataTest {
+
+ private static final String TEST_PACKAGE_1 = "package1";
+ private static final String TEST_PACKAGE_2 = "package2";
+ @Rule
+ public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule
+ public final InstantTaskExecutorRule mTaskExecutorRule = new InstantTaskExecutorRule();
+ @Rule
+ public final TestLifecycleOwner mLifecycleOwner = new TestLifecycleOwner();
+
+ @Mock
+ public MediaSessionManager mMediaSessionManager;
+ @Mock
+ public MediaController mFirstMediaController;
+ @Mock
+ public MediaController mSecondMediaController;
+ @Captor
+ public ArgumentCaptor<OnActiveSessionsChangedListener> mSessionChangeListenerCaptor;
+ @Captor
+ public ArgumentCaptor<MediaController.Callback> mFirstControllerCallbackCaptor;
+ @Captor
+ public ArgumentCaptor<MediaController.Callback> mSecondControllerCallbackCaptor;
+
+ private List<MediaController> mMediaControllerList;
+ private SharedPreferences mSharedPreferences;
+ private ActiveMediaControllerLiveData mLiveData;
+
+
+ @Before
+ public void setUp() {
+ mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(
+ application);
+ mSharedPreferences.edit().clear().apply();
+
+ mMediaControllerList = new ArrayList<>();
+ mMediaControllerList.add(mFirstMediaController);
+ mMediaControllerList.add(mSecondMediaController);
+
+ setControllerState(mFirstMediaController, PlaybackState.STATE_PLAYING);
+ when(mFirstMediaController.getPackageName()).thenReturn(TEST_PACKAGE_1);
+ doNothing().when(mFirstMediaController)
+ .registerCallback(mFirstControllerCallbackCaptor.capture());
+ setControllerState(mSecondMediaController, PlaybackState.STATE_PLAYING);
+ when(mSecondMediaController.getPackageName()).thenReturn(TEST_PACKAGE_2);
+ doNothing().when(mSecondMediaController)
+ .registerCallback(mSecondControllerCallbackCaptor.capture());
+
+ doNothing().when(mMediaSessionManager).addOnActiveSessionsChangedListener(
+ mSessionChangeListenerCaptor.capture(), any());
+ doAnswer(invocation -> {
+ OnActiveSessionsChangedListener argument = invocation.getArgument(0);
+ if (argument == mSessionChangeListenerCaptor.getValue()) {
+ mSessionChangeListenerCaptor =
+ ArgumentCaptor.forClass(OnActiveSessionsChangedListener.class);
+ }
+ return null;
+ }).when(mMediaSessionManager).removeOnActiveSessionsChangedListener(any());
+
+ mLiveData = new ActiveMediaControllerLiveData(mMediaSessionManager, mSharedPreferences);
+ }
+
+ private void doOnActiveObservation(CaptureObserver<MediaController> observer) {
+ when(mMediaSessionManager.getActiveSessions(any())).thenReturn(mMediaControllerList);
+
+ mLiveData.observe(mLifecycleOwner, observer);
+ }
+
+ private void doOnUpdateObservation(CaptureObserver<MediaController> observer) {
+ mLiveData.observe(mLifecycleOwner, observer);
+ observer.reset();
+ mSessionChangeListenerCaptor.getValue().onActiveSessionsChanged(mMediaControllerList);
+ }
+
+ @Test
+ public void testChooseFirstController_onActive() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+
+ doOnActiveObservation(observer);
+
+ assertObservedController(observer, mFirstMediaController);
+ }
+
+ @Test
+ public void testChooseFirstController_onUpdate() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+
+ doOnUpdateObservation(observer);
+
+ assertObservedController(observer, mFirstMediaController);
+ }
+
+ @Test
+ public void testPickPlayingController_onActive() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ setControllerState(mFirstMediaController, PlaybackState.STATE_PAUSED);
+
+ doOnActiveObservation(observer);
+
+ assertObservedController(observer, mSecondMediaController);
+ }
+
+ @Test
+ public void testPickPlayingController_onUpdate() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ setControllerState(mFirstMediaController, PlaybackState.STATE_PAUSED);
+
+ doOnUpdateObservation(observer);
+
+ assertObservedController(observer, mSecondMediaController);
+ }
+
+ @Test
+ public void testUsedLastWhenAllPaused_onActive() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ setControllerState(mFirstMediaController, PlaybackState.STATE_PAUSED);
+ setControllerState(mSecondMediaController, PlaybackState.STATE_PAUSED);
+ setLastObservedController(mSecondMediaController);
+
+ doOnActiveObservation(observer);
+
+ assertObservedController(observer, mSecondMediaController);
+ }
+
+ @Test
+ public void testUsedLastWhenAllPaused_onUpdate() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ setControllerState(mFirstMediaController, PlaybackState.STATE_PAUSED);
+ setControllerState(mSecondMediaController, PlaybackState.STATE_PAUSED);
+ setLastObservedController(mSecondMediaController);
+
+ doOnUpdateObservation(observer);
+
+ assertObservedController(observer, mSecondMediaController);
+ }
+
+ @Test
+ public void testPlaybackStateChangedTriggersUpdate() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ doOnActiveObservation(observer);
+ observer.reset();
+ setControllerState(mFirstMediaController, PlaybackState.STATE_PAUSED);
+
+ mFirstControllerCallbackCaptor.getValue()
+ .onPlaybackStateChanged(mFirstMediaController.getPlaybackState());
+
+ assertObservedController(observer, mSecondMediaController);
+ }
+
+ @Test
+ public void testSessionDestroyedTriggersUpdate() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ doOnActiveObservation(observer);
+ observer.reset();
+ setControllerState(mFirstMediaController, PlaybackState.STATE_NONE);
+
+ mFirstControllerCallbackCaptor.getValue().onSessionDestroyed();
+
+ assertObservedController(observer, mSecondMediaController);
+ }
+
+ @Test
+ public void testGetControllerForPackage() {
+ // Ensure LiveData is active
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ doOnActiveObservation(observer);
+
+ assertThat(mLiveData.getControllerForPackage(TEST_PACKAGE_2)).isSameAs(
+ mSecondMediaController);
+ }
+
+ @Test
+ public void testGetControllerForPackage_noMatch() {
+ // Ensure LiveData is active
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ doOnActiveObservation(observer);
+
+ assertThat(mLiveData.getControllerForPackage("")).isNull();
+ }
+
+ @Test
+ public void testUnregisterOnInactive() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ doOnActiveObservation(observer);
+
+ // Need to hold reference to captor since it will be swapped out when listener is
+ // unregistered.
+ ArgumentCaptor<OnActiveSessionsChangedListener> oldArgumentCaptor =
+ mSessionChangeListenerCaptor;
+ mLifecycleOwner.markState(Lifecycle.State.DESTROYED);
+
+ verify(mMediaSessionManager)
+ .removeOnActiveSessionsChangedListener(oldArgumentCaptor.getValue());
+ }
+
+ private void setLastObservedController(@Nullable MediaController mediaController) {
+ PlaybackState oldControllerState =
+ mediaController == null ? null : mediaController.getPlaybackState();
+ if (mediaController != null) {
+ setControllerState(mediaController, PlaybackState.STATE_PLAYING);
+ }
+
+ MediaSessionManager mediaSessionManager = mock(MediaSessionManager.class);
+ List<MediaController> mediaControllers =
+ mediaController == null ? Collections.emptyList()
+ : Collections.singletonList(mediaController);
+ when(mediaSessionManager.getActiveSessions(any())).thenReturn(mediaControllers);
+
+ // Use another instance of ActiveMediaControllerLiveData (not the one under test) to
+ // inject the desired value into SharedPreferences.
+ ActiveMediaControllerLiveData injectorData =
+ new ActiveMediaControllerLiveData(mediaSessionManager, mSharedPreferences);
+
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ injectorData.observe(mLifecycleOwner, observer);
+ injectorData.removeObserver(observer);
+
+ if (mediaController != null) {
+ when(mediaController.getPlaybackState()).thenReturn(oldControllerState);
+ }
+ }
+
+ private void setControllerState(MediaController mediaController,
+ @PlaybackStateAnnotations.State int state) {
+ when(mediaController.getPlaybackState())
+ .thenReturn(
+ new PlaybackState.Builder().setState(state, 0, 0).build());
+ }
+
+ private void assertObservedController(CaptureObserver<MediaController> observer,
+ MediaController mediaController) {
+ assertThat(observer.hasBeenNotified()).isTrue();
+ assertThat(observer.getObservedValue()).isSameAs(mediaController);
+ }
+}
diff --git a/car-media-common/tests/robotests/src/com/android/car/media/common/source/MediaBrowserConnectorTest.java b/car-media-common/tests/robotests/src/com/android/car/media/common/source/MediaBrowserConnectorTest.java
new file mode 100644
index 0000000..d5cbac0
--- /dev/null
+++ b/car-media-common/tests/robotests/src/com/android/car/media/common/source/MediaBrowserConnectorTest.java
@@ -0,0 +1,187 @@
+/*
+ * 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.car.media.common.source;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.robolectric.RuntimeEnvironment.application;
+
+import android.annotation.NonNull;
+import android.content.ComponentName;
+import android.content.Context;
+import android.media.browse.MediaBrowser;
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
+
+import com.android.car.arch.common.testing.CaptureObserver;
+import com.android.car.arch.common.testing.TestLifecycleOwner;
+import com.android.car.media.common.TestConfig;
+import com.android.car.media.common.source.MediaBrowserConnector.ConnectionState;
+import com.android.car.media.common.source.MediaBrowserConnector.MediaBrowserState;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION)
+public class MediaBrowserConnectorTest {
+
+ @Rule
+ public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule
+ public final InstantTaskExecutorRule mTaskExecutorRule = new InstantTaskExecutorRule();
+ @Rule
+ public final TestLifecycleOwner mLifecycleOwner = new TestLifecycleOwner();
+
+ @Mock
+ public MediaBrowser mMediaBrowser;
+
+ private MediaBrowserConnector mLiveData;
+ private MediaBrowser.ConnectionCallback mConnectionCallback;
+
+ @Before
+ public void setUp() {
+ mLiveData = new MediaBrowserConnector(application, new ComponentName("", "")) {
+ @Override
+ protected MediaBrowser createMediaBrowser(@NonNull Context context,
+ @NonNull ComponentName browseService,
+ @NonNull MediaBrowser.ConnectionCallback callback) {
+ mConnectionCallback = callback;
+ return mMediaBrowser;
+ }
+ };
+ }
+
+ @Test
+ public void testConnectOnActive() {
+ CaptureObserver<MediaBrowserState> observer = new CaptureObserver<>();
+ when(mMediaBrowser.isConnected()).thenReturn(false);
+
+ mLiveData.observe(mLifecycleOwner, observer);
+
+ verify(mMediaBrowser).connect();
+ assertThat(observer.hasBeenNotified()).isTrue();
+ MediaBrowserState observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(observedValue.mConnectionState).isEqualTo(ConnectionState.CONNECTING);
+ }
+
+ @Test
+ public void testAlreadyConnectedOnActive() {
+ CaptureObserver<MediaBrowserState> observer = new CaptureObserver<>();
+ when(mMediaBrowser.isConnected()).thenReturn(true);
+
+ mLiveData.observe(mLifecycleOwner, observer);
+
+ verify(mMediaBrowser, never()).connect();
+ assertThat(observer.hasBeenNotified()).isTrue();
+ MediaBrowserState observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(observedValue.mMediaBrowser).isSameAs(mMediaBrowser);
+ assertThat(observedValue.mConnectionState).isEqualTo(ConnectionState.CONNECTED);
+ }
+
+ @Test
+ public void testExceptionOnConnect() {
+ CaptureObserver<MediaBrowserState> observer = new CaptureObserver<>();
+ when(mMediaBrowser.isConnected()).thenReturn(false);
+ setConnectionAction(() -> {
+ throw new IllegalStateException("expected");
+ });
+
+ mLiveData.observe(mLifecycleOwner, observer);
+
+ verify(mMediaBrowser).connect();
+ assertThat(observer.hasBeenNotified()).isTrue();
+ MediaBrowserState observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(observedValue.mConnectionState).isEqualTo(ConnectionState.CONNECTING);
+ }
+
+ @Test
+ public void testConnectionCallback_onConnected() {
+ CaptureObserver<MediaBrowserState> observer = new CaptureObserver<>();
+ when(mMediaBrowser.isConnected()).thenReturn(false);
+ setConnectionAction(() -> {
+ observer.reset();
+ mConnectionCallback.onConnected();
+ });
+
+ mLiveData.observe(mLifecycleOwner, observer);
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ MediaBrowserState observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(observedValue.mMediaBrowser).isSameAs(mMediaBrowser);
+ assertThat(observedValue.mConnectionState).isEqualTo(ConnectionState.CONNECTED);
+ }
+
+ @Test
+ public void testConnectionCallback_onConnectionFailed() {
+ CaptureObserver<MediaBrowserState> observer = new CaptureObserver<>();
+ when(mMediaBrowser.isConnected()).thenReturn(false);
+ setConnectionAction(() -> {
+ observer.reset();
+ mConnectionCallback.onConnectionFailed();
+ });
+
+ mLiveData.observe(mLifecycleOwner, observer);
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ MediaBrowserState observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(observedValue.mConnectionState).isEqualTo(ConnectionState.CONNECTION_FAILED);
+ }
+
+ @Test
+ public void testConnectionCallback_onConnectionSuspended() {
+ CaptureObserver<MediaBrowserState> observer = new CaptureObserver<>();
+ when(mMediaBrowser.isConnected()).thenReturn(false);
+ setConnectionAction(() -> {
+ mConnectionCallback.onConnected();
+ observer.reset();
+ mConnectionCallback.onConnectionSuspended();
+ });
+
+ mLiveData.observe(mLifecycleOwner, observer);
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ MediaBrowserState observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(observedValue.mConnectionState).isEqualTo(ConnectionState.DISCONNECTED);
+ }
+
+ private void setConnectionAction(@NonNull Runnable action) {
+ doAnswer(invocation -> {
+ action.run();
+ return null;
+ }).when(mMediaBrowser).connect();
+ }
+
+
+}
diff --git a/car-media-common/tests/robotests/src/com/android/car/media/common/source/MediaSourceViewModelTest.java b/car-media-common/tests/robotests/src/com/android/car/media/common/source/MediaSourceViewModelTest.java
new file mode 100644
index 0000000..6481755
--- /dev/null
+++ b/car-media-common/tests/robotests/src/com/android/car/media/common/source/MediaSourceViewModelTest.java
@@ -0,0 +1,238 @@
+/*
+ * 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.car.media.common.source;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+import static org.robolectric.RuntimeEnvironment.application;
+
+import android.annotation.NonNull;
+import android.content.ComponentName;
+import android.media.browse.MediaBrowser;
+import android.media.session.MediaController;
+import android.media.session.MediaSession;
+
+import androidx.annotation.Nullable;
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import com.android.car.arch.common.testing.CaptureObserver;
+import com.android.car.arch.common.testing.TestLifecycleOwner;
+import com.android.car.media.common.TestConfig;
+import com.android.car.media.common.source.MediaBrowserConnector.MediaBrowserState;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.util.Collections;
+import java.util.List;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION)
+public class MediaSourceViewModelTest {
+
+ private static final String SESSION_CONTROLLER_PACKAGE_NAME = "session";
+ private static final String PACKAGE_CONTROLLER_PACKAGE_NAME = "package";
+ @Rule
+ public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule
+ public final InstantTaskExecutorRule mTaskExecutorRule = new InstantTaskExecutorRule();
+ @Rule
+ public final TestLifecycleOwner mLifecycleOwner = new TestLifecycleOwner();
+
+ @Mock
+ public SimpleMediaSource mMediaSource;
+ @Mock
+ public MediaBrowser mMediaBrowser;
+ @Mock
+ public MediaController mMediaControllerForPackage;
+ @Mock
+ public MediaController mMediaControllerForSession;
+
+ private final MutableLiveData<List<SimpleMediaSource>> mMediaSources = new MutableLiveData<>();
+ private final MutableLiveData<MediaBrowserState> mMediaBrowserState = new MutableLiveData<>();
+ private final MutableLiveData<MediaController> mActiveMediaController = new MutableLiveData<>();
+
+ private MediaSourceViewModel mViewModel;
+
+ private ComponentName mRequestedBrowseService;
+
+ @Before
+ public void setUp() {
+ when(mMediaControllerForPackage.getPackageName()).thenReturn(
+ PACKAGE_CONTROLLER_PACKAGE_NAME);
+ when(mMediaControllerForSession.getPackageName()).thenReturn(
+ SESSION_CONTROLLER_PACKAGE_NAME);
+ mRequestedBrowseService = null;
+ mViewModel = new MediaSourceViewModel(application, new MediaSourceViewModel.InputFactory() {
+ @Override
+ public LiveData<List<SimpleMediaSource>> createMediaSources() {
+ return mMediaSources;
+ }
+
+ @Override
+ public LiveData<MediaBrowserState> createMediaBrowserConnector(
+ @NonNull ComponentName browseService) {
+ mRequestedBrowseService = browseService;
+ return mMediaBrowserState;
+ }
+
+ @Override
+ public LiveData<MediaController> createActiveMediaController() {
+ return mActiveMediaController;
+ }
+
+ @Override
+ public MediaController getControllerForPackage(String packageName) {
+ return mMediaControllerForPackage;
+ }
+
+ @Override
+ public MediaController getControllerForSession(@Nullable MediaSession.Token token) {
+ return mMediaControllerForSession;
+ }
+ });
+ mViewModel.setSelectedMediaSource(mMediaSource);
+ }
+
+
+ @Test
+ public void testGetMediaSources() {
+ assertThat(mViewModel.getMediaSources()).isSameAs(mMediaSources);
+ }
+
+ @Test
+ public void testHasMediaSources() {
+ CaptureObserver<Boolean> observer = new CaptureObserver<>();
+ mMediaSources.setValue(Collections.singletonList(mMediaSource));
+
+ mViewModel.hasMediaSources().observe(mLifecycleOwner, observer);
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ assertThat(observer.getObservedValue()).isTrue();
+ observer.reset();
+
+ mMediaSources.setValue(Collections.emptyList());
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ assertThat(observer.getObservedValue()).isFalse();
+ observer.reset();
+
+ mMediaSources.setValue(null);
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ assertThat(observer.getObservedValue()).isFalse();
+ }
+
+ @Test
+ public void testGetSelectedMediaSource() {
+ CaptureObserver<SimpleMediaSource> observer = new CaptureObserver<>();
+
+ mViewModel.getSelectedMediaSource().observe(mLifecycleOwner, observer);
+
+ assertThat(observer.getObservedValue()).isSameAs(mMediaSource);
+ }
+
+ @Test
+ public void testGetMediaController_fromActiveSession() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+
+ mViewModel.getMediaController().observe(mLifecycleOwner, observer);
+
+ assertThat(observer.getObservedValue()).isSameAs(mMediaControllerForPackage);
+ assertThat(mRequestedBrowseService).isNull();
+ }
+
+ @Test
+ public void testGetMediaController_noActiveSession() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ mMediaControllerForPackage = null;
+ ComponentName testComponent = new ComponentName("test", "test");
+ when(mMediaSource.getBrowseServiceComponentName()).thenReturn(testComponent);
+ mMediaBrowserState.setValue(new MediaBrowserState(mMediaBrowser,
+ MediaBrowserConnector.ConnectionState.CONNECTED));
+
+ mViewModel.getMediaController().observe(mLifecycleOwner, observer);
+
+ assertThat(observer.getObservedValue()).isSameAs(mMediaControllerForSession);
+ assertThat(mRequestedBrowseService).isEqualTo(testComponent);
+ }
+
+ @Test
+ public void testGetMediaController_noActiveSession_noBrowseService() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ mMediaControllerForPackage = null;
+ when(mMediaSource.getBrowseServiceComponentName()).thenReturn(null);
+ mMediaBrowserState.setValue(new MediaBrowserState(mMediaBrowser,
+ MediaBrowserConnector.ConnectionState.CONNECTED));
+
+ mViewModel.getMediaController().observe(mLifecycleOwner, observer);
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ assertThat(observer.getObservedValue()).isNull();
+ assertThat(mRequestedBrowseService).isEqualTo(null);
+ }
+
+ @Test
+ public void testGetMediaController_noActiveSession_notConnected() {
+ CaptureObserver<MediaController> observer = new CaptureObserver<>();
+ mMediaControllerForPackage = null;
+ ComponentName testComponent = new ComponentName("test", "test");
+ when(mMediaSource.getBrowseServiceComponentName()).thenReturn(testComponent);
+ mMediaBrowserState.setValue(new MediaBrowserState(mMediaBrowser,
+ MediaBrowserConnector.ConnectionState.CONNECTING));
+
+ mViewModel.getMediaController().observe(mLifecycleOwner, observer);
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ assertThat(observer.getObservedValue()).isNull();
+ assertThat(mRequestedBrowseService).isEqualTo(testComponent);
+ }
+
+ @Test
+ public void testGetActiveMediaController() {
+ assertThat(mViewModel.getActiveMediaController()).isSameAs(mActiveMediaController);
+ }
+
+ @Test
+ public void testIsCurrentMediaSourcePlaying() {
+ CaptureObserver<Boolean> observer = new CaptureObserver<>();
+ ComponentName testComponent = new ComponentName("test", "test");
+ when(mMediaSource.getBrowseServiceComponentName()).thenReturn(testComponent);
+ when(mMediaSource.getPackageName()).thenReturn(SESSION_CONTROLLER_PACKAGE_NAME);
+ mMediaBrowserState.setValue(new MediaBrowserState(mMediaBrowser,
+ MediaBrowserConnector.ConnectionState.CONNECTED));
+ mActiveMediaController.setValue(mMediaControllerForSession);
+
+ mViewModel.isCurrentMediaSourcePlaying().observe(mLifecycleOwner, observer);
+
+ assertThat(observer.getObservedValue()).isTrue();
+
+ mActiveMediaController.setValue(mMediaControllerForPackage);
+
+ assertThat(observer.getObservedValue()).isFalse();
+ }
+}
diff --git a/car-media-common/tests/robotests/src/com/android/car/media/common/source/MediaSourcesLiveDataTest.java b/car-media-common/tests/robotests/src/com/android/car/media/common/source/MediaSourcesLiveDataTest.java
new file mode 100644
index 0000000..01f4190
--- /dev/null
+++ b/car-media-common/tests/robotests/src/com/android/car/media/common/source/MediaSourcesLiveDataTest.java
@@ -0,0 +1,246 @@
+/*
+ * 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.car.media.common.source;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.robolectric.RuntimeEnvironment.application;
+import static org.robolectric.Shadows.shadowOf;
+
+import android.annotation.NonNull;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ServiceInfo;
+import android.os.Bundle;
+import android.service.media.MediaBrowserService;
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule;
+
+import com.android.car.arch.common.testing.CaptureObserver;
+import com.android.car.arch.common.testing.TestLifecycleOwner;
+import com.android.car.media.common.TestConfig;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowApplication;
+import org.robolectric.shadows.ShadowPackageManager;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION)
+public class MediaSourcesLiveDataTest {
+
+ @Rule
+ public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+ @Rule
+ public final InstantTaskExecutorRule mTaskExecutorRule = new InstantTaskExecutorRule();
+ @Rule
+ public final TestLifecycleOwner mLifecycleOwner = new TestLifecycleOwner();
+
+ private static final String TEST_ACTIVITY_PACKAGE_1 = "activity_package1";
+ private static final String TEST_ACTIVITY_PACKAGE_2 = "activity_package2";
+ private static final String TEST_SERVICE_PACKAGE_1 = "service_package1";
+ private static final String TEST_SERVICE_PACKAGE_2 = "service_package2";
+ private static final String TEST_SERVICE_PACKAGE_WITH_METADATA = "service_package3";
+
+ private Intent mActivityIntent;
+ private Intent mServiceIntent;
+
+ @Before
+ public void setUp() {
+ mActivityIntent = new Intent(Intent.ACTION_MAIN, null);
+ mActivityIntent.addCategory(Intent.CATEGORY_APP_MUSIC);
+
+ mServiceIntent = new Intent(MediaBrowserService.SERVICE_INTERFACE);
+
+ ShadowPackageManager packageManager = shadowOf(application.getPackageManager());
+
+ List<ResolveInfo> activityResolveInfo = buildActivityResolveInfo();
+ List<ResolveInfo> allActivityResolveInfo = new ArrayList<>(activityResolveInfo);
+ allActivityResolveInfo.add(newActivityResolveInfo(TEST_ACTIVITY_PACKAGE_2));
+ for (ResolveInfo info : allActivityResolveInfo) {
+ PackageInfo packageInfo = new PackageInfo();
+ packageInfo.activities = new ActivityInfo[]{info.activityInfo};
+ packageInfo.packageName = info.activityInfo.packageName;
+ packageInfo.applicationInfo = info.activityInfo.applicationInfo;
+ packageManager.addPackage(packageInfo);
+ }
+ List<ResolveInfo> serviceResolveInfo = buildServiceResolveInfo();
+ List<ResolveInfo> allServiceResolveInfo = new ArrayList<>(serviceResolveInfo);
+ allServiceResolveInfo.add(newServiceResolveInfo(TEST_SERVICE_PACKAGE_2));
+ for (ResolveInfo info : allServiceResolveInfo) {
+ PackageInfo packageInfo = new PackageInfo();
+ packageInfo.services = new ServiceInfo[]{info.serviceInfo};
+ packageInfo.packageName = info.serviceInfo.packageName;
+ packageInfo.applicationInfo = info.serviceInfo.applicationInfo;
+ packageManager.addPackage(packageInfo);
+ }
+ setPackageManagerResolveInfos(activityResolveInfo, serviceResolveInfo);
+ }
+
+ @Test
+ public void testGetAppsOnActive() {
+ CaptureObserver<List<SimpleMediaSource>> observer = new CaptureObserver<>();
+ MediaSourcesLiveData liveData = new MediaSourcesLiveData(application);
+
+ liveData.observe(mLifecycleOwner, observer);
+ assertThat(observer.hasBeenNotified()).isTrue();
+ List<SimpleMediaSource> observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(
+ observedValue.stream().map(SimpleMediaSource::getPackageName)
+ .collect(Collectors.toList()))
+ .containsExactly(TEST_ACTIVITY_PACKAGE_1, TEST_SERVICE_PACKAGE_1,
+ TEST_SERVICE_PACKAGE_WITH_METADATA);
+ }
+
+ @Test
+ public void testGetAppsOnPackageAdded() {
+ CaptureObserver<List<SimpleMediaSource>> observer = new CaptureObserver<>();
+ MediaSourcesLiveData liveData = new MediaSourcesLiveData(application);
+ liveData.observe(mLifecycleOwner, observer);
+ observer.reset();
+
+ List<ResolveInfo> activityResolveInfo = buildActivityResolveInfo();
+ activityResolveInfo.add(newActivityResolveInfo(TEST_ACTIVITY_PACKAGE_2));
+ List<ResolveInfo> serviceResolveInfo = buildServiceResolveInfo();
+ serviceResolveInfo.add(newServiceResolveInfo(TEST_SERVICE_PACKAGE_2));
+ setPackageManagerResolveInfos(activityResolveInfo, serviceResolveInfo);
+
+ Intent packageAdded = new Intent(Intent.ACTION_PACKAGE_ADDED);
+ ShadowApplication.getInstance().getRegisteredReceivers().stream()
+ .filter(wrapper -> wrapper.intentFilter.hasAction(Intent.ACTION_PACKAGE_ADDED))
+ .map(wrapper -> wrapper.broadcastReceiver)
+ .forEach(broadcastReceiver -> broadcastReceiver.onReceive(application,
+ packageAdded));
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ List<SimpleMediaSource> observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(
+ observedValue.stream().map(SimpleMediaSource::getPackageName)
+ .collect(Collectors.toList()))
+ .containsExactly(TEST_ACTIVITY_PACKAGE_1, TEST_ACTIVITY_PACKAGE_2,
+ TEST_SERVICE_PACKAGE_1, TEST_SERVICE_PACKAGE_2,
+ TEST_SERVICE_PACKAGE_WITH_METADATA);
+ }
+
+ @Test
+ public void testGetAppsOnPackageRemoved() {
+ CaptureObserver<List<SimpleMediaSource>> observer = new CaptureObserver<>();
+ MediaSourcesLiveData liveData = new MediaSourcesLiveData(application);
+ liveData.observe(mLifecycleOwner, observer);
+ observer.reset();
+
+ List<ResolveInfo> activityResolveInfo = buildActivityResolveInfo();
+ List<ResolveInfo> serviceResolveInfo = buildServiceResolveInfo();
+ serviceResolveInfo.remove(0);
+ setPackageManagerResolveInfos(activityResolveInfo, serviceResolveInfo);
+
+ Intent packageRemoved = new Intent(Intent.ACTION_PACKAGE_REMOVED);
+ ShadowApplication.getInstance().getRegisteredReceivers().stream()
+ .filter(wrapper -> wrapper.intentFilter.hasAction(Intent.ACTION_PACKAGE_REMOVED))
+ .map(wrapper -> wrapper.broadcastReceiver)
+ .forEach(broadcastReceiver ->
+ broadcastReceiver.onReceive(application, packageRemoved));
+
+ assertThat(observer.hasBeenNotified()).isTrue();
+ List<SimpleMediaSource> observedValue = observer.getObservedValue();
+ assertThat(observedValue).isNotNull();
+ assertThat(
+ observedValue.stream().map(SimpleMediaSource::getPackageName)
+ .collect(Collectors.toList()))
+ .containsExactly(TEST_ACTIVITY_PACKAGE_1, TEST_SERVICE_PACKAGE_WITH_METADATA);
+ }
+
+ @NonNull
+ private List<ResolveInfo> buildActivityResolveInfo() {
+ List<ResolveInfo> activityResolveInfo = new ArrayList<>();
+ activityResolveInfo.add(newActivityResolveInfo(TEST_ACTIVITY_PACKAGE_1));
+ return activityResolveInfo;
+ }
+
+ @NonNull
+ private List<ResolveInfo> buildServiceResolveInfo() {
+ List<ResolveInfo> serviceResolveInfo = new ArrayList<>();
+ serviceResolveInfo.add(newServiceResolveInfo(TEST_SERVICE_PACKAGE_1));
+ ResolveInfo withMetadata = newServiceResolveInfo(TEST_SERVICE_PACKAGE_WITH_METADATA);
+ withMetadata.serviceInfo.applicationInfo.metaData = new Bundle();
+ serviceResolveInfo.add(withMetadata);
+ return serviceResolveInfo;
+ }
+
+ private void setPackageManagerResolveInfos(List<ResolveInfo> activityResolveInfo,
+ List<ResolveInfo> serviceResolveInfo) {
+ ShadowPackageManager packageManager = shadowOf(application.getPackageManager());
+ packageManager.removeResolveInfosForIntent(mActivityIntent, TEST_ACTIVITY_PACKAGE_1);
+ packageManager.removeResolveInfosForIntent(mActivityIntent, TEST_ACTIVITY_PACKAGE_2);
+ packageManager.removeResolveInfosForIntent(mServiceIntent, TEST_SERVICE_PACKAGE_1);
+ packageManager.removeResolveInfosForIntent(mServiceIntent, TEST_SERVICE_PACKAGE_2);
+ packageManager.removeResolveInfosForIntent(mServiceIntent,
+ TEST_SERVICE_PACKAGE_WITH_METADATA);
+ packageManager.addResolveInfoForIntent(mActivityIntent, activityResolveInfo);
+
+ packageManager.addResolveInfoForIntent(mServiceIntent, serviceResolveInfo);
+ for (ResolveInfo info : serviceResolveInfo) {
+ Intent intent = new Intent(mServiceIntent);
+ intent.setPackage(info.serviceInfo.packageName);
+ packageManager.addResolveInfoForIntent(intent, info);
+ }
+ }
+
+
+ private ResolveInfo newActivityResolveInfo(String packageName) {
+ ResolveInfo resolveInfo = new ResolveInfo();
+ ActivityInfo activityInfo = new ActivityInfo();
+ activityInfo.packageName = packageName;
+ activityInfo.name = "activity";
+ activityInfo.nonLocalizedLabel = "Activity Label " + packageName;
+ ApplicationInfo applicationInfo = new ApplicationInfo();
+ applicationInfo.packageName = packageName;
+ applicationInfo.nonLocalizedLabel = "Activity Label " + packageName;
+ activityInfo.applicationInfo = applicationInfo;
+ resolveInfo.activityInfo = activityInfo;
+ return resolveInfo;
+ }
+
+ private ResolveInfo newServiceResolveInfo(String packageName) {
+ ResolveInfo resolveInfo = new ResolveInfo();
+ ServiceInfo serviceInfo = new ServiceInfo();
+ serviceInfo.packageName = packageName;
+ serviceInfo.name = "service";
+ serviceInfo.nonLocalizedLabel = "Service Label " + packageName;
+ ApplicationInfo applicationInfo = new ApplicationInfo();
+ applicationInfo.packageName = packageName;
+ applicationInfo.nonLocalizedLabel = "Service Label " + packageName;
+ serviceInfo.applicationInfo = applicationInfo;
+ resolveInfo.serviceInfo = serviceInfo;
+ return resolveInfo;
+ }
+}