DO NOT MERGE Add MediaBrowse state to MediaBrowserViewModel and remove deprecated MediaSource.
SimpleMediaSource is now renamed to MediaSource.

Test: Manual
Change-Id: I5e04532b3c88ae20a7ed1d9ca0dfae36b42c7949
diff --git a/car-media-common/src/com/android/car/media/common/MediaSource.java b/car-media-common/src/com/android/car/media/common/MediaSource.java
deleted file mode 100644
index 5e65a49..0000000
--- a/car-media-common/src/com/android/car/media/common/MediaSource.java
+++ /dev/null
@@ -1,606 +0,0 @@
-/*
- * 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;
-
-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.os.Bundle;
-import android.os.Handler;
-import android.os.RemoteException;
-import android.service.media.MediaBrowserService;
-import android.support.v4.media.MediaBrowserCompat;
-import android.support.v4.media.session.MediaControllerCompat;
-import android.support.v4.media.session.MediaSessionCompat;
-import android.util.Log;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.function.Consumer;
-import java.util.stream.Collectors;
-
-/**
- * This represents a source of media content. It provides convenient methods to access media source
- * metadata, such as primary color and application name.
- *
- * <p>It also allows consumers to subscribe to its {@link android.service.media.MediaBrowserService}
- * if such service is implemented by the source.
- */
-public class MediaSource {
-    private static final String TAG = "MediaSource";
-
-    /** Number of times we will retry obtaining the list of children of a certain node */
-    private static final int CHILDREN_SUBSCRIPTION_RETRIES = 3;
-    /** Time between retries while trying to obtain the list of children of a certain node */
-    private static final int CHILDREN_SUBSCRIPTION_RETRY_TIME_MS = 1000;
-
-    private final String mPackageName;
-    @Nullable
-    private final String mBrowseServiceClassName;
-    @Nullable
-    private final MediaBrowserCompat mBrowser;
-    private final Context mContext;
-    private final Handler mHandler = new Handler();
-    private List<Observer> mObservers = new ArrayList<>();
-    private CharSequence mName;
-    private String mRootNode;
-
-    /**
-     * 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");
-    }
-
-    /**
-     * An observer of this media source.
-     */
-    public abstract static class Observer {
-        /**
-         * This method is called if a successful connection to the {@link MediaBrowserService} is
-         * made for this source. A connection is initiated as soon as there is at least one
-         * {@link Observer} subscribed by using {@link MediaSource#subscribe(Observer)}.
-         *
-         * @param success true if the connection was successful or false otherwise.
-         */
-        protected void onBrowseConnected(boolean success) {};
-
-        /**
-         * This method is called if the connection to the {@link MediaBrowserService} is lost.
-         */
-        protected void onBrowseDisconnected() {};
-    }
-
-    /**
-     * A subscription to a collection of items
-     */
-    public interface ItemsSubscription {
-        /**
-         * This method is called whenever media items are loaded or updated.
-         *
-         * @param mediaSource {@link MediaSource} these items belongs to
-         * @param parentId identifier of the items parent.
-         * @param items items loaded, or null if there was an error trying to load them.
-         */
-        void onChildrenLoaded(MediaSource mediaSource, String parentId,
-                @Nullable List<MediaItemMetadata> items);
-    }
-
-    private final MediaBrowserCompat.ConnectionCallback mConnectionCallback =
-            new MediaBrowserCompat.ConnectionCallback() {
-                @Override
-                public void onConnected() {
-                    MediaSource.this.notify(observer -> observer.onBrowseConnected(true));
-                }
-
-                @Override
-                public void onConnectionSuspended() {
-                    MediaSource.this.notify(Observer::onBrowseDisconnected);
-                }
-
-                @Override
-                public void onConnectionFailed() {
-                    MediaSource.this.notify(observer -> observer.onBrowseConnected(false));
-                }
-            };
-
-    /**
-     * Creates a {@link MediaSource} for the given application package name
-     */
-    public MediaSource(Context context, String packageName) {
-        mContext = context;
-        mPackageName = packageName;
-        mBrowseServiceClassName = getBrowseServiceClassName(packageName);
-        if (mBrowseServiceClassName != null) {
-            mBrowser = new MediaBrowserCompat(mContext,
-                    new ComponentName(mPackageName, mBrowseServiceClassName),
-                    mConnectionCallback,
-                    null);
-        } else {
-            // This media source doesn't provide browsing.
-            mBrowser = null;
-        }
-        extractComponentInfo(mPackageName, mBrowseServiceClassName);
-    }
-
-    /**
-     * @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(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;
-    }
-
-    /**
-     * Subscribes to this media source. This allows consuming browse information.
-
-     * @return true if a subscription could be added, or false otherwise (for example, if the
-     * {@link MediaBrowserService} is not available for this source.
-     */
-    public boolean subscribe(Observer observer) {
-        if (mBrowser == null) {
-            return false;
-        }
-        mObservers.add(observer);
-        if (!mBrowser.isConnected()) {
-            try {
-                mBrowser.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.
-            }
-        } else {
-            observer.onBrowseConnected(true);
-        }
-        return true;
-    }
-
-    /**
-     * Unsubscribe from this media source
-     */
-    public void unsubscribe(Observer observer) {
-        mObservers.remove(observer);
-        if (mObservers.isEmpty()) {
-            // TODO(b/77640010): Review MediaBrowse disconnection.
-            // Some media sources are not responding correctly to MediaBrowser#disconnect(). We
-            // are keeping the connection going.
-            //   mBrowser.disconnect();
-        }
-    }
-
-    private Map<String, ChildrenSubscription> mChildrenSubscriptions = new HashMap<>();
-
-    /**
-     * Obtains the root node in the browse tree of this node.
-     */
-    public String getRoot() {
-        if (mRootNode == null) {
-            mRootNode = mBrowser.getRoot();
-        }
-        return mRootNode;
-    }
-
-    /**
-     * Subscribes to changes on the list of media item children of the given parent. Multiple
-     * subscription can be added to the same node. If the node has been already loaded, then all
-     * new subscription will immediately obtain a copy of the last obtained list.
-     *
-     * @param parentId parent of the children to load, or null to indicate children of the root
-     *                 node.
-     * @param callback callback used to provide updates on the subscribed node.
-     * @throws IllegalStateException if browsing is not available or it is not connected.
-     */
-    public void subscribeChildren(@Nullable String parentId, ItemsSubscription callback) {
-        if (mBrowser == null) {
-            throw new IllegalStateException("Browsing is not available for this source: "
-                    + getName());
-        }
-        if (mRootNode == null && !mBrowser.isConnected()) {
-            throw new IllegalStateException("Subscribing to the root node can only be done while "
-                    + "connected: " + getName());
-        }
-        mRootNode = mBrowser.getRoot();
-
-        String itemId = parentId != null ? parentId : mRootNode;
-        ChildrenSubscription subscription = mChildrenSubscriptions.get(itemId);
-        if (subscription != null) {
-            subscription.add(callback);
-        } else {
-            subscription = new ChildrenSubscription(mBrowser, itemId);
-            subscription.add(callback);
-            mChildrenSubscriptions.put(itemId, subscription);
-            subscription.start(CHILDREN_SUBSCRIPTION_RETRIES,
-                    CHILDREN_SUBSCRIPTION_RETRY_TIME_MS);
-        }
-    }
-
-    /**
-     * Unsubscribes to changes on the list of media items children of the given parent
-     *
-     * @param parentId parent to unsubscribe, or null to unsubscribe from the root node.
-     * @param callback callback to remove
-     * @throws IllegalStateException if browsing is not available or it is not connected.
-     */
-    public void unsubscribeChildren(@Nullable String parentId, ItemsSubscription callback) {
-        // If we are not connected
-        if (mBrowser == null) {
-            throw new IllegalStateException("Browsing is not available for this source: "
-                    + getName());
-        }
-        if (parentId == null && mRootNode == null) {
-            // If we are trying to unsubscribe from root, but we haven't determine it's Id, then
-            // there is nothing we can do.
-            return;
-        }
-
-        String itemId = parentId != null ? parentId : mRootNode;
-        ChildrenSubscription subscription = mChildrenSubscriptions.get(itemId);
-        if (subscription != null) {
-            subscription.remove(callback);
-            if (subscription.count() == 0) {
-                subscription.stop();
-                mChildrenSubscriptions.remove(itemId);
-            }
-        }
-    }
-
-    /**
-     * {@link MediaBrowserCompat.SubscriptionCallback} wrapper used to overcome the lack of a
-     * reliable method to obtain the initial list of children of a given node.
-     * <p>
-     * When some 3rd party apps go through configuration changes (i.e., in the case of user-switch),
-     * they leave subscriptions in an intermediate state where neither
-     * {@link MediaBrowserCompat.SubscriptionCallback#onChildrenLoaded(String, List)} nor
-     * {@link MediaBrowserCompat.SubscriptionCallback#onError(String)} are invoked.
-     * <p>
-     * This wrapper works around this problem by retrying the subscription a given number of times
-     * if no data is received after a certain amount of time. This process is started by calling
-     * {@link #start(int, int)}, passing the number of retries and delay between them as
-     * parameters.
-     */
-    private class ChildrenSubscription extends MediaBrowserCompat.SubscriptionCallback {
-        private List<MediaItemMetadata> mItems;
-        private boolean mIsDataLoaded;
-        private List<ItemsSubscription> mSubscriptions = new ArrayList<>();
-        private String mParentId;
-        private int mRetries;
-        private int mRetryDelay;
-        private MediaBrowserCompat mMediaBrowser;
-        private Runnable mRetryRunnable = new Runnable() {
-            @Override
-            public void run() {
-                if (!mIsDataLoaded) {
-                    if (mRetries > 0) {
-                        mRetries--;
-                        mMediaBrowser.unsubscribe(mParentId);
-                        mMediaBrowser.subscribe(mParentId, ChildrenSubscription.this);
-                        mHandler.postDelayed(this, mRetryDelay);
-                    } else {
-                        mItems = null;
-                        mIsDataLoaded = true;
-                        notifySubscriptions();
-                    }
-                }
-            }
-        };
-
-        /**
-         * Creates a subscription to the list of children of a certain media browse item
-         *
-         * @param mediaBrowser {@link MediaBrowserCompat} used to create the subscription
-         * @param parentId identifier of the parent node to subscribe to
-         */
-        ChildrenSubscription(@NonNull MediaBrowserCompat mediaBrowser, String parentId) {
-            mParentId = parentId;
-            mMediaBrowser = mediaBrowser;
-        }
-
-        /**
-         * Adds a subscriber to this list of children
-         */
-        void add(ItemsSubscription subscription) {
-            mSubscriptions.add(subscription);
-            if (mIsDataLoaded) {
-                subscription.onChildrenLoaded(MediaSource.this, mParentId, mItems);
-            }
-        }
-
-        /**
-         * Removes a subscriber previously added with {@link #add(ItemsSubscription)}
-         */
-        void remove(ItemsSubscription subscription) {
-            mSubscriptions.remove(subscription);
-        }
-
-        /**
-         * Number of subscribers currently registered
-         */
-        int count() {
-            return mSubscriptions.size();
-        }
-
-        /**
-         * Starts trying to obtain the list of children
-         *
-         * @param retries number of times to retry. If children are not obtained in this time then
-         *                the {@link ItemsSubscription#onChildrenLoaded(MediaSource, String, List)}
-         *                will be invoked with a NULL list.
-         * @param retryDelay time between retries in milliseconds
-         */
-        void start(int retries, int retryDelay) {
-            if (mIsDataLoaded) {
-                notifySubscriptions();
-                mMediaBrowser.subscribe(mParentId, this);
-            } else {
-                mRetries = retries;
-                mRetryDelay = retryDelay;
-                mHandler.post(mRetryRunnable);
-            }
-        }
-
-        /**
-         * Stops retrying
-         */
-        void stop() {
-            mHandler.removeCallbacks(mRetryRunnable);
-            mMediaBrowser.unsubscribe(mParentId);
-        }
-
-        @Override
-        public void onChildrenLoaded(String parentId,
-                List<MediaBrowserCompat.MediaItem> children) {
-            mHandler.removeCallbacks(mRetryRunnable);
-            mItems = children.stream()
-                    .map(child -> new MediaItemMetadata(child))
-                    .collect(Collectors.toList());
-            mIsDataLoaded = true;
-            notifySubscriptions();
-        }
-
-        @Override
-        public void onChildrenLoaded(String parentId, List<MediaBrowserCompat.MediaItem> children,
-                Bundle options) {
-            onChildrenLoaded(parentId, children);
-        }
-
-        @Override
-        public void onError(String parentId) {
-            mHandler.removeCallbacks(mRetryRunnable);
-            mItems = null;
-            mIsDataLoaded = true;
-            notifySubscriptions();
-        }
-
-        @Override
-        public void onError(String parentId, Bundle options) {
-            onError(parentId);
-        }
-
-        private void notifySubscriptions() {
-            for (ItemsSubscription subscription : mSubscriptions) {
-                subscription.onChildrenLoaded(MediaSource.this, mParentId, mItems);
-            }
-        }
-    }
-
-    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 if (applicationInfo.labelRes != 0) {
-                mName = applicationInfo.loadLabel(mContext.getPackageManager());
-            } else {
-                mName = null;
-            }
-        } catch (PackageManager.NameNotFoundException e) {
-            Log.w(TAG, "Unable to update media client package attributes.", e);
-        }
-    }
-
-    private void notify(Consumer<Observer> notification) {
-        mHandler.post(() -> {
-            List<Observer> observers = new ArrayList<>(mObservers);
-            for (Observer observer : observers) {
-                notification.accept(observer);
-            }
-        });
-    }
-
-    /**
-     * @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 a {@link MediaControllerCompat} that allows controlling this media source, or NULL
-     * if the media source doesn't support browsing or the browser is not connected.
-     */
-    @Nullable
-    public MediaControllerCompat getMediaController() {
-        if (mBrowser == null || !mBrowser.isConnected()) {
-            return null;
-        }
-
-        MediaSessionCompat.Token token = mBrowser.getSessionToken();
-        try {
-            return new MediaControllerCompat(mContext, token);
-        } catch (RemoteException e) {
-            Log.e(TAG, "Couldn't get MediaControllerCompat", e);
-            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;
-    }
-
-    /**
-     * Indicates if this media source should not be templatize.
-     */
-    public boolean isCustom() {
-        return CUSTOM_MEDIA_SOURCES.contains(mPackageName);
-    }
-
-    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;
-        MediaSource that = (MediaSource) o;
-        return Objects.equals(mPackageName, that.mPackageName)
-                && Objects.equals(mBrowseServiceClassName, that.mBrowseServiceClassName);
-    }
-
-    /** @return the current media browser. This media browser might not be connected yet. */
-    public MediaBrowserCompat getMediaBrowser() {
-        return mBrowser;
-    }
-
-    @Override
-    public int hashCode() {
-        return Objects.hash(mPackageName, mBrowseServiceClassName);
-    }
-
-    @Override
-    public String toString() {
-        return getPackageName();
-    }
-}
diff --git a/car-media-common/src/com/android/car/media/common/PlaybackFragment.java b/car-media-common/src/com/android/car/media/common/PlaybackFragment.java
index b4a8efb..7008f7e 100644
--- a/car-media-common/src/com/android/car/media/common/PlaybackFragment.java
+++ b/car-media-common/src/com/android/car/media/common/PlaybackFragment.java
@@ -39,8 +39,8 @@
 
 import com.android.car.media.common.playback.AlbumArtLiveData;
 import com.android.car.media.common.playback.PlaybackViewModel;
+import com.android.car.media.common.source.MediaSource;
 import com.android.car.media.common.source.MediaSourceViewModel;
-import com.android.car.media.common.source.SimpleMediaSource;
 
 import com.bumptech.glide.request.target.Target;
 
@@ -106,7 +106,7 @@
         private static final Intent MEDIA_TEMPLATE_INTENT =
                 new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE);
 
-        private LiveData<SimpleMediaSource> mMediaSource;
+        private LiveData<MediaSource> mMediaSource;
         private LiveData<CharSequence> mAppName;
         private LiveData<Bitmap> mAppIcon;
         private LiveData<Intent> mOpenIntent;
@@ -129,8 +129,8 @@
             mPlaybackViewModel = playbackViewModel;
             mMediaSourceViewModel = mediaSourceViewModel;
             mMediaSource = mMediaSourceViewModel.getSelectedMediaSource();
-            mAppName = mapNonNull(mMediaSource, SimpleMediaSource::getName);
-            mAppIcon = mapNonNull(mMediaSource, SimpleMediaSource::getRoundPackageIcon);
+            mAppName = mapNonNull(mMediaSource, MediaSource::getName);
+            mAppIcon = mapNonNull(mMediaSource, MediaSource::getRoundPackageIcon);
             mOpenIntent = mapNonNull(mMediaSource, MEDIA_TEMPLATE_INTENT, source -> {
                 if (source.isCustom()) {
                     // We are playing a custom app. Jump to it, not to the template
diff --git a/car-media-common/src/com/android/car/media/common/browse/MediaBrowserViewModel.java b/car-media-common/src/com/android/car/media/common/browse/MediaBrowserViewModel.java
index f41bd16..8702719 100644
--- a/car-media-common/src/com/android/car/media/common/browse/MediaBrowserViewModel.java
+++ b/car-media-common/src/com/android/car/media/common/browse/MediaBrowserViewModel.java
@@ -30,6 +30,7 @@
 
 import androidx.lifecycle.AndroidViewModel;
 import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MediatorLiveData;
 import androidx.lifecycle.MutableLiveData;
 
 import com.android.car.arch.common.LoadingSwitchMap;
@@ -44,6 +45,20 @@
 
 public class MediaBrowserViewModel extends AndroidViewModel {
 
+    /**
+     * Possible states of the application UI
+     */
+    public enum BrowseState {
+        /** There is no content to show */
+        EMPTY,
+        /** We are still in the process of obtaining data */
+        LOADING,
+        /** Data has been loaded */
+        LOADED,
+        /** The content can't be shown due an error */
+        ERROR
+    }
+
     private final SwitchingLiveData<MediaBrowserCompat> mMediaBrowserSwitch =
             SwitchingLiveData.newInstance();
 
@@ -59,6 +74,38 @@
                             connectedMediaBrowser == null
                                     ? null
                                     : new BrowsedMediaItems(connectedMediaBrowser, browseId)));
+    private final LiveData<BrowseState> mBrowseState = new MediatorLiveData<BrowseState>() {
+        {
+            setValue(BrowseState.EMPTY);
+            addSource(mCurrentMediaItems.isLoading(), isLoading -> update());
+            addSource(mCurrentMediaItems.getOutput(), items -> update());
+        }
+
+        private void update() {
+            setValue(getState());
+        }
+
+        private BrowseState getState() {
+            Boolean isLoading = mCurrentMediaItems.isLoading().getValue();
+            if (isLoading == null) {
+                // Uninitialized
+                return BrowseState.EMPTY;
+            }
+            if (isLoading) {
+                return BrowseState.LOADING;
+            }
+            List<MediaItemMetadata> items = mCurrentMediaItems.getOutput().getValue();
+            if (items == null) {
+                // Normally this could be null if it hasn't been initialized, but in that case
+                // isLoading would not be false, so this means it must have encountered an error.
+                return BrowseState.ERROR;
+            }
+            if (items.isEmpty()) {
+                return BrowseState.EMPTY;
+            }
+            return BrowseState.LOADED;
+        }
+    };
 
     public MediaBrowserViewModel(@NonNull Application application) {
         super(application);
@@ -93,6 +140,10 @@
         return mCurrentBrowseId.getValue();
     }
 
+    public LiveData<BrowseState> getBrowseState() {
+        return mBrowseState;
+    }
+
     public LiveData<Boolean> isLoading() {
         return mCurrentMediaItems.isLoading();
     }
diff --git a/car-media-common/src/com/android/car/media/common/source/ActiveMediaSelector.java b/car-media-common/src/com/android/car/media/common/source/ActiveMediaSelector.java
index 3fa0bb4..754d943 100644
--- a/car-media-common/src/com/android/car/media/common/source/ActiveMediaSelector.java
+++ b/car-media-common/src/com/android/car/media/common/source/ActiveMediaSelector.java
@@ -64,7 +64,7 @@
      */
     @Nullable
     MediaControllerCompat getControllerForSource(@NonNull List<MediaControllerCompat> controllers,
-            @NonNull SimpleMediaSource mediaSource) {
+            @NonNull MediaSource mediaSource) {
         return getControllerForPackage(controllers, mediaSource.getPackageName());
     }
 
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/MediaSource.java
similarity index 92%
rename from car-media-common/src/com/android/car/media/common/source/SimpleMediaSource.java
rename to car-media-common/src/com/android/car/media/common/source/MediaSource.java
index 5268135..03275b0 100644
--- a/car-media-common/src/com/android/car/media/common/source/SimpleMediaSource.java
+++ b/car-media-common/src/com/android/car/media/common/source/MediaSource.java
@@ -36,8 +36,6 @@
 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;
@@ -47,8 +45,7 @@
  * 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 {
+public class MediaSource {
     private static final String TAG = "MediaSource";
 
     /**
@@ -66,9 +63,9 @@
     private CharSequence mName;
 
     /**
-     * Creates a {@link SimpleMediaSource} for the given application package name
+     * Creates a {@link MediaSource} for the given application package name
      */
-    public SimpleMediaSource(@NonNull Context context, @NonNull String packageName) {
+    public MediaSource(@NonNull Context context, @NonNull String packageName) {
         mContext = context;
         mPackageName = packageName;
         mBrowseServiceClassName = getBrowseServiceClassName(packageName);
@@ -76,14 +73,6 @@
     }
 
     /**
-     * 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
@@ -236,7 +225,7 @@
     public boolean equals(Object o) {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
-        SimpleMediaSource that = (SimpleMediaSource) o;
+        MediaSource that = (MediaSource) o;
         return Objects.equals(mPackageName, that.mPackageName)
                 && Objects.equals(mBrowseServiceClassName, that.mBrowseServiceClassName);
     }
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 1db676a..ebab15d 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
@@ -25,7 +25,9 @@
 
 import androidx.annotation.NonNull;
 
-/** Contains the colors for a {@link com.android.car.media.common.MediaSource MediaSource} */
+/**
+ * Contains the colors for a {@link MediaSource}
+ */
 public class MediaSourceColors {
     /**
      * Mark used to indicate that we couldn't find a color and the default one should be used
@@ -77,7 +79,7 @@
         }
 
         /** Extract colors for (@code mediaSource} and create a MediaSourceColors for it */
-        public MediaSourceColors extractColors(@NonNull SimpleMediaSource mediaSource) {
+        public MediaSourceColors extractColors(@NonNull MediaSource mediaSource) {
             return extractColors(mediaSource.getPackageName());
         }
 
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
index 94de3f3..506d2df 100644
--- 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
@@ -55,11 +55,11 @@
 public class MediaSourceViewModel extends AndroidViewModel {
     private static final String TAG = "MediaSourceViewModel";
 
-    private final LiveData<List<SimpleMediaSource>> mMediaSources;
+    private final LiveData<List<MediaSource>> mMediaSources;
 
     private final LiveData<Boolean> mHasMediaSources;
 
-    private final MutableLiveData<SimpleMediaSource> mSelectedMediaSource = new MutableLiveData<>();
+    private final MutableLiveData<MediaSource> mSelectedMediaSource = new MutableLiveData<>();
 
     private final LiveData<MediaBrowserCompat> mConnectedMediaBrowser;
 
@@ -76,7 +76,7 @@
      */
     @VisibleForTesting
     interface InputFactory {
-        LiveData<List<SimpleMediaSource>> createMediaSources();
+        LiveData<List<MediaSource>> createMediaSources();
 
         LiveData<MediaBrowserState> createMediaBrowserConnector(
                 @NonNull ComponentName browseService);
@@ -97,7 +97,7 @@
         this(application, new InputFactory() {
 
             @Override
-            public LiveData<List<SimpleMediaSource>> createMediaSources() {
+            public LiveData<List<MediaSource>> createMediaSources() {
                 return new MediaSourcesLiveData(application);
             }
 
@@ -180,7 +180,7 @@
      * Returns a live list of all MediaSources that can be selected for playback
      */
     @NonNull
-    public LiveData<List<SimpleMediaSource>> getMediaSources() {
+    public LiveData<List<MediaSource>> getMediaSources() {
         return mMediaSources;
     }
 
@@ -195,7 +195,7 @@
     /**
      * Returns a LiveData that emits the MediaSource that is to be browsed or displayed.
      */
-    public LiveData<SimpleMediaSource> getSelectedMediaSource() {
+    public LiveData<MediaSource> getSelectedMediaSource() {
         return mSelectedMediaSource;
     }
 
@@ -204,7 +204,7 @@
      * connection may be made and provided through {@link #getConnectedMediaBrowser()}.
      */
     @UiThread
-    public void setSelectedMediaSource(@Nullable SimpleMediaSource mediaSource) {
+    public void setSelectedMediaSource(@Nullable MediaSource mediaSource) {
         mSelectedMediaSource.setValue(mediaSource);
     }
 
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
index 7634f9f..c396e79 100644
--- 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
@@ -39,7 +39,7 @@
  * 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>> {
+class MediaSourcesLiveData extends LiveData<List<MediaSource>> {
 
     private static final String TAG = "MediaSourcesLiveData";
     private final Context mContext;
@@ -81,9 +81,9 @@
     }
 
     private void updateMediaSources() {
-        List<SimpleMediaSource> mediaSources = getPackageNames().stream()
+        List<MediaSource> mediaSources = getPackageNames().stream()
                 .filter(Objects::nonNull)
-                .map(packageName -> new SimpleMediaSource(mContext, packageName))
+                .map(packageName -> new MediaSource(mContext, packageName))
                 .filter(mediaSource -> {
                     if (mediaSource.getName() == null) {
                         Log.w(TAG, "Found media source without name: "
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
index c283fce..9a54066 100644
--- 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
@@ -65,7 +65,7 @@
     public final TestLifecycleOwner mLifecycleOwner = new TestLifecycleOwner();
 
     @Mock
-    public SimpleMediaSource mMediaSource;
+    public MediaSource mMediaSource;
     @Mock
     public MediaBrowserCompat mMediaBrowser;
     @Mock
@@ -75,7 +75,7 @@
     @Mock
     public MediaControllerCompat mMediaControllerFromSessionManager;
 
-    private final MutableLiveData<List<SimpleMediaSource>> mMediaSources = new MutableLiveData<>();
+    private final MutableLiveData<List<MediaSource>> mMediaSources = new MutableLiveData<>();
     private final MutableLiveData<MediaBrowserState> mMediaBrowserState = new MutableLiveData<>();
     private final MutableLiveData<List<MediaControllerCompat>> mActiveMediaControllers =
             new MutableLiveData<>();
@@ -100,7 +100,7 @@
 
         mViewModel = new MediaSourceViewModel(application, new MediaSourceViewModel.InputFactory() {
             @Override
-            public LiveData<List<SimpleMediaSource>> createMediaSources() {
+            public LiveData<List<MediaSource>> createMediaSources() {
                 return mMediaSources;
             }
 
@@ -161,7 +161,7 @@
 
     @Test
     public void testGetSelectedMediaSource() {
-        CaptureObserver<SimpleMediaSource> observer = new CaptureObserver<>();
+        CaptureObserver<MediaSource> observer = new CaptureObserver<>();
 
         mViewModel.getSelectedMediaSource().observe(mLifecycleOwner, observer);
 
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
index 01f4190..95d8256 100644
--- 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
@@ -106,15 +106,15 @@
 
     @Test
     public void testGetAppsOnActive() {
-        CaptureObserver<List<SimpleMediaSource>> observer = new CaptureObserver<>();
+        CaptureObserver<List<MediaSource>> observer = new CaptureObserver<>();
         MediaSourcesLiveData liveData = new MediaSourcesLiveData(application);
 
         liveData.observe(mLifecycleOwner, observer);
         assertThat(observer.hasBeenNotified()).isTrue();
-        List<SimpleMediaSource> observedValue = observer.getObservedValue();
+        List<MediaSource> observedValue = observer.getObservedValue();
         assertThat(observedValue).isNotNull();
         assertThat(
-                observedValue.stream().map(SimpleMediaSource::getPackageName)
+                observedValue.stream().map(MediaSource::getPackageName)
                         .collect(Collectors.toList()))
                 .containsExactly(TEST_ACTIVITY_PACKAGE_1, TEST_SERVICE_PACKAGE_1,
                         TEST_SERVICE_PACKAGE_WITH_METADATA);
@@ -122,7 +122,7 @@
 
     @Test
     public void testGetAppsOnPackageAdded() {
-        CaptureObserver<List<SimpleMediaSource>> observer = new CaptureObserver<>();
+        CaptureObserver<List<MediaSource>> observer = new CaptureObserver<>();
         MediaSourcesLiveData liveData = new MediaSourcesLiveData(application);
         liveData.observe(mLifecycleOwner, observer);
         observer.reset();
@@ -141,10 +141,10 @@
                         packageAdded));
 
         assertThat(observer.hasBeenNotified()).isTrue();
-        List<SimpleMediaSource> observedValue = observer.getObservedValue();
+        List<MediaSource> observedValue = observer.getObservedValue();
         assertThat(observedValue).isNotNull();
         assertThat(
-                observedValue.stream().map(SimpleMediaSource::getPackageName)
+                observedValue.stream().map(MediaSource::getPackageName)
                         .collect(Collectors.toList()))
                 .containsExactly(TEST_ACTIVITY_PACKAGE_1, TEST_ACTIVITY_PACKAGE_2,
                         TEST_SERVICE_PACKAGE_1, TEST_SERVICE_PACKAGE_2,
@@ -153,7 +153,7 @@
 
     @Test
     public void testGetAppsOnPackageRemoved() {
-        CaptureObserver<List<SimpleMediaSource>> observer = new CaptureObserver<>();
+        CaptureObserver<List<MediaSource>> observer = new CaptureObserver<>();
         MediaSourcesLiveData liveData = new MediaSourcesLiveData(application);
         liveData.observe(mLifecycleOwner, observer);
         observer.reset();
@@ -171,10 +171,10 @@
                         broadcastReceiver.onReceive(application, packageRemoved));
 
         assertThat(observer.hasBeenNotified()).isTrue();
-        List<SimpleMediaSource> observedValue = observer.getObservedValue();
+        List<MediaSource> observedValue = observer.getObservedValue();
         assertThat(observedValue).isNotNull();
         assertThat(
-                observedValue.stream().map(SimpleMediaSource::getPackageName)
+                observedValue.stream().map(MediaSource::getPackageName)
                         .collect(Collectors.toList()))
                 .containsExactly(TEST_ACTIVITY_PACKAGE_1, TEST_SERVICE_PACKAGE_WITH_METADATA);
     }