MediaBrowserImplLegacy.java

/*
 * Copyright 2019 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 androidx.media2.session;

import static androidx.media2.common.MediaMetadata.BROWSABLE_TYPE_MIXED;
import static androidx.media2.common.MediaMetadata.METADATA_KEY_BROWSABLE;
import static androidx.media2.common.MediaMetadata.METADATA_KEY_MEDIA_ID;
import static androidx.media2.common.MediaMetadata.METADATA_KEY_PLAYABLE;
import static androidx.media2.session.LibraryResult.RESULT_ERROR_BAD_VALUE;
import static androidx.media2.session.LibraryResult.RESULT_ERROR_SESSION_DISCONNECTED;
import static androidx.media2.session.LibraryResult.RESULT_ERROR_UNKNOWN;
import static androidx.media2.session.LibraryResult.RESULT_SUCCESS;

import android.content.Context;
import android.os.Bundle;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.MediaBrowserCompat.ItemCallback;
import android.support.v4.media.MediaBrowserCompat.SubscriptionCallback;
import android.text.TextUtils;
import android.util.Log;

import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.concurrent.futures.ResolvableFuture;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.session.MediaBrowser.BrowserCallback;
import androidx.media2.session.MediaBrowser.BrowserCallbackRunnable;
import androidx.media2.session.MediaLibraryService.LibraryParams;

import com.google.common.util.concurrent.ListenableFuture;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;

/**
 * Implementation of MediaBrowser with the {@link MediaBrowserCompat} for legacy support.
 */
class MediaBrowserImplLegacy extends MediaControllerImplLegacy implements
        MediaBrowser.MediaBrowserImpl {
    private static final String TAG = "MB2ImplLegacy";

    @GuardedBy("mLock")
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    final HashMap<LibraryParams, MediaBrowserCompat> mBrowserCompats = new HashMap<>();
    @GuardedBy("mLock")
    private final HashMap<String, List<SubscribeCallback>> mSubscribeCallbacks = new HashMap<>();

    MediaBrowserImplLegacy(@NonNull Context context, MediaBrowser instance,
            @NonNull SessionToken token) {
        super(context, instance, token);
    }

    @NonNull
    MediaBrowser getMediaBrowser() {
        return (MediaBrowser) mInstance;
    }

    @Override
    public void close() {
        synchronized (mLock) {
            for (MediaBrowserCompat browserCompat : mBrowserCompats.values()) {
                browserCompat.disconnect();
            }
            mBrowserCompats.clear();
            // Ensure that ControllerCallback#onDisconnected() is called by super.close().
            super.close();
        }
    }

    @Override
    public ListenableFuture<LibraryResult> getLibraryRoot(@Nullable final LibraryParams params) {
        final ResolvableFuture<LibraryResult> result = ResolvableFuture.create();
        final MediaBrowserCompat browserCompat = getBrowserCompat(params);
        if (browserCompat != null) {
            // Already connected with the given extras.
            result.set(new LibraryResult(RESULT_SUCCESS, createRootMediaItem(browserCompat),
                    null));
        } else {
            mHandler.post(new Runnable() {
                @Override
                public void run() {
                    // Do this on the callback executor to set the looper of MediaBrowserCompat's
                    // callback handler to this looper.
                    Bundle rootHints = MediaUtils.convertToRootHints(params);
                    MediaBrowserCompat newBrowser = new MediaBrowserCompat(getContext(),
                            getConnectedToken().getComponentName(),
                            new GetLibraryRootCallback(result, params), rootHints);
                    synchronized (mLock) {
                        mBrowserCompats.put(params, newBrowser);
                    }
                    newBrowser.connect();
                }
            });
        }
        return result;
    }

    @Override
    public ListenableFuture<LibraryResult> subscribe(@NonNull String parentId,
            @Nullable LibraryParams params) {
        MediaBrowserCompat browserCompat = getBrowserCompat();
        if (browserCompat == null) {
            return LibraryResult.createFutureWithResult(RESULT_ERROR_SESSION_DISCONNECTED);
        }
        ResolvableFuture<LibraryResult> future = ResolvableFuture.create();
        SubscribeCallback callback = new SubscribeCallback(future);
        synchronized (mLock) {
            List<SubscribeCallback> list = mSubscribeCallbacks.get(parentId);
            if (list == null) {
                list = new ArrayList<>();
                mSubscribeCallbacks.put(parentId, list);
            }
            list.add(callback);
        }
        browserCompat.subscribe(parentId, createOptions(params), callback);
        return future;
    }

    @Override
    public ListenableFuture<LibraryResult> unsubscribe(@NonNull String parentId) {
        MediaBrowserCompat browserCompat = getBrowserCompat();
        if (browserCompat == null) {
            return LibraryResult.createFutureWithResult(RESULT_ERROR_SESSION_DISCONNECTED);
        }
        // Note: don't use MediaBrowserCompat#unsubscribe(String) here, to keep the subscription
        // callback for getChildren.
        synchronized (mLock) {
            List<SubscribeCallback> list = mSubscribeCallbacks.get(parentId);
            if (list == null) {
                return LibraryResult.createFutureWithResult(RESULT_ERROR_BAD_VALUE);
            }
            for (int i = 0; i < list.size(); i++) {
                browserCompat.unsubscribe(parentId, list.get(i));
            }
        }

        // No way to get result. Just return success.
        return LibraryResult.createFutureWithResult(LibraryResult.RESULT_SUCCESS);
    }

    @Override
    public ListenableFuture<LibraryResult> getChildren(@NonNull String parentId, int page,
            int pageSize, @Nullable LibraryParams params) {
        MediaBrowserCompat browserCompat = getBrowserCompat();
        if (browserCompat == null) {
            return LibraryResult.createFutureWithResult(RESULT_ERROR_SESSION_DISCONNECTED);
        }

        final ResolvableFuture<LibraryResult> future = ResolvableFuture.create();
        Bundle options = createOptions(params, page, pageSize);
        browserCompat.subscribe(parentId, options, new GetChildrenCallback(future, parentId));
        return future;
    }

    @Override
    public ListenableFuture<LibraryResult> getItem(@NonNull final String mediaId) {
        MediaBrowserCompat browserCompat = getBrowserCompat();
        if (browserCompat == null) {
            return LibraryResult.createFutureWithResult(RESULT_ERROR_SESSION_DISCONNECTED);
        }
        final ResolvableFuture<LibraryResult> result = ResolvableFuture.create();
        browserCompat.getItem(mediaId, new ItemCallback() {
            @Override
            public void onItemLoaded(final MediaBrowserCompat.MediaItem item) {
                mHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        if (item != null) {
                            result.set(new LibraryResult(RESULT_SUCCESS,
                                    MediaUtils.convertToMediaItem(item), null));
                        } else {
                            result.set(new LibraryResult(RESULT_ERROR_BAD_VALUE));
                        }
                    }
                });
            }

            @Override
            public void onError(@NonNull String itemId) {
                mHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        result.set(new LibraryResult(RESULT_ERROR_UNKNOWN));
                    }
                });
            }
        });
        return result;
    }

    @Override
    public ListenableFuture<LibraryResult> search(@NonNull String query,
            @Nullable LibraryParams params) {
        MediaBrowserCompat browserCompat = getBrowserCompat();
        if (browserCompat == null) {
            return LibraryResult.createFutureWithResult(RESULT_ERROR_SESSION_DISCONNECTED);
        }
        browserCompat.search(query, getExtras(params), new MediaBrowserCompat.SearchCallback() {
            @Override
            public void onSearchResult(@NonNull final String query, final Bundle extras,
                    @NonNull final List<MediaBrowserCompat.MediaItem> items) {
                getMediaBrowser().notifyBrowserCallback(new BrowserCallbackRunnable() {
                    @Override
                    public void run(@NonNull BrowserCallback callback) {
                        // Set extra null here, because 'extra' have different meanings between old
                        // API and new API as follows.
                        // - Old API: Extra/Option specified with search().
                        // - New API: Extra from MediaLibraryService to MediaBrowser
                        // TODO(Post-P): Cache search result for later getSearchResult() calls.
                        callback.onSearchResultChanged(
                                getMediaBrowser(), query, items.size(), null);
                    }
                });
            }

            @Override
            public void onError(@NonNull final String query, final Bundle extras) {
                getMediaBrowser().notifyBrowserCallback(new BrowserCallbackRunnable() {
                    @Override
                    public void run(@NonNull BrowserCallback callback) {
                        // Set extra null here, because 'extra' have different meanings between old
                        // API and new API as follows.
                        // - Old API: Extra/Option specified with search().
                        // - New API: Extra from MediaLibraryService to MediaBrowser
                        callback.onSearchResultChanged(
                                getMediaBrowser(), query, 0, null);
                    }
                });
            }
        });
        // No way to get result. Just return success.
        return LibraryResult.createFutureWithResult(RESULT_SUCCESS);
    }

    @Override
    public ListenableFuture<LibraryResult> getSearchResult(@NonNull final String query,
            final int page, final int pageSize, @Nullable final LibraryParams params) {
        MediaBrowserCompat browserCompat = getBrowserCompat();
        if (browserCompat == null) {
            return LibraryResult.createFutureWithResult(RESULT_ERROR_SESSION_DISCONNECTED);
        }

        final ResolvableFuture<LibraryResult> future = ResolvableFuture.create();
        Bundle options = createOptions(params, page, pageSize);
        browserCompat.search(query, options, new MediaBrowserCompat.SearchCallback() {
            @Override
            public void onSearchResult(@NonNull final String query, final Bundle extrasSent,
                    @NonNull final List<MediaBrowserCompat.MediaItem> items) {
                mHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        List<MediaItem> item2List =
                                MediaUtils.convertMediaItemListToMediaItemList(items);
                        future.set(new LibraryResult(RESULT_SUCCESS, item2List, null));
                    }
                });
            }

            @Override
            public void onError(@NonNull final String query, final Bundle extrasSent) {
                mHandler.post(new Runnable() {
                    @Override
                    public void run() {
                        future.set(new LibraryResult(RESULT_ERROR_UNKNOWN));
                    }
                });
            }
        });
        return future;
    }

    private MediaBrowserCompat getBrowserCompat(LibraryParams extras) {
        synchronized (mLock) {
            return mBrowserCompats.get(extras);
        }
    }

    private static Bundle createOptions(@Nullable LibraryParams params) {
        return params == null || params.getExtras() == null
                ? new Bundle() : new Bundle(params.getExtras());
    }

    private static Bundle createOptions(@Nullable LibraryParams params, int page, int pageSize) {
        Bundle options = createOptions(params);
        options.putInt(MediaBrowserCompat.EXTRA_PAGE, page);
        options.putInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, pageSize);
        return options;
    }

    private static Bundle getExtras(@Nullable LibraryParams params) {
        return params != null ? params.getExtras() : null;
    }

    @SuppressWarnings("WeakerAccess") /* synthetic access */
    MediaItem createRootMediaItem(@NonNull MediaBrowserCompat browserCompat) {
        // TODO: Query again with getMediaItem() to get real media item.
        MediaMetadata metadata = new MediaMetadata.Builder()
                .putString(METADATA_KEY_MEDIA_ID, browserCompat.getRoot())
                .putLong(METADATA_KEY_BROWSABLE, BROWSABLE_TYPE_MIXED)
                .putLong(METADATA_KEY_PLAYABLE, 0)
                .setExtras(browserCompat.getExtras())
                .build();
        return new MediaItem.Builder().setMetadata(metadata).build();
    }

    private class GetLibraryRootCallback extends MediaBrowserCompat.ConnectionCallback {
        final ResolvableFuture<LibraryResult> mResult;
        final LibraryParams mParams;

        GetLibraryRootCallback(ResolvableFuture<LibraryResult> result, LibraryParams params) {
            super();
            mResult = result;
            mParams = params;
        }

        @Override
        public void onConnected() {
            MediaBrowserCompat browserCompat;
            synchronized (mLock) {
                browserCompat = mBrowserCompats.get(mParams);
            }
            if (browserCompat == null) {
                // Shouldn't be happen. Internal error?
                mResult.set(new LibraryResult(RESULT_ERROR_UNKNOWN));
            } else {
                mResult.set(new LibraryResult(RESULT_SUCCESS,
                        createRootMediaItem(browserCompat),
                        MediaUtils.convertToLibraryParams(mContext, browserCompat.getExtras())));
            }
        }

        @Override
        public void onConnectionSuspended() {
            onConnectionFailed();
        }

        @Override
        public void onConnectionFailed() {
            // Unknown extra field.
            mResult.set(new LibraryResult(RESULT_ERROR_BAD_VALUE));
            close();
        }
    }

    private class SubscribeCallback extends SubscriptionCallback {
        final ResolvableFuture<LibraryResult> mFuture;

        SubscribeCallback(ResolvableFuture<LibraryResult> future) {
            mFuture = future;
        }

        @Override
        public void onError(@NonNull String parentId) {
            onErrorInternal();
        }

        @Override
        public void onError(@NonNull String parentId, @NonNull Bundle options) {
            onErrorInternal();
        }

        @Override
        public void onChildrenLoaded(@NonNull String parentId,
                @NonNull List<MediaBrowserCompat.MediaItem> children) {
            onChildrenLoadedInternal(parentId, children);
        }

        @Override
        public void onChildrenLoaded(@NonNull final String parentId,
                @NonNull List<MediaBrowserCompat.MediaItem> children,
                @NonNull final Bundle options) {
            onChildrenLoadedInternal(parentId, children);
        }

        private void onErrorInternal() {
            // Don't need to unsubscribe here, because MediaBrowserServiceCompat can notify children
            // changed after the initial failure and MediaBrowserCompat could receive the changes.
            mFuture.set(new LibraryResult(RESULT_ERROR_UNKNOWN));
        }

        private void onChildrenLoadedInternal(@NonNull final String parentId,
                @Nullable List<MediaBrowserCompat.MediaItem> children) {
            if (TextUtils.isEmpty(parentId)) {
                Log.w(TAG, "SubscribeCallback.onChildrenLoaded(): Ignoring empty parentId");
                return;
            }
            final MediaBrowserCompat browserCompat = getBrowserCompat();
            if (browserCompat == null) {
                // Browser is closed.
                return;
            }
            final int itemCount;
            if (children != null) {
                itemCount = children.size();
            } else {
                // Currently no way to tell failures in MediaBrowser#subscribe().
                return;
            }

            final LibraryParams params = MediaUtils.convertToLibraryParams(mContext,
                    browserCompat.getNotifyChildrenChangedOptions());
            getMediaBrowser().notifyBrowserCallback(new BrowserCallbackRunnable() {
                @Override
                public void run(@NonNull BrowserCallback callback) {
                    // TODO(Post-P): Cache children result for later getChildren() calls.
                    callback.onChildrenChanged(getMediaBrowser(), parentId, itemCount, params);
                }
            });
            mFuture.set(new LibraryResult(RESULT_SUCCESS));
        }
    }

    private class GetChildrenCallback extends SubscriptionCallback {
        final ResolvableFuture<LibraryResult> mFuture;
        final String mParentId;

        GetChildrenCallback(ResolvableFuture<LibraryResult> future, String parentId) {
            super();
            mFuture = future;
            mParentId = parentId;
        }

        @Override
        public void onError(@NonNull String parentId) {
            onErrorInternal();
        }

        @Override
        public void onError(@NonNull String parentId, @NonNull Bundle options) {
            onErrorInternal();
        }

        @Override
        public void onChildrenLoaded(@NonNull String parentId,
                @NonNull List<MediaBrowserCompat.MediaItem> children) {
            onChildrenLoadedInternal(parentId, children);
        }

        @Override
        public void onChildrenLoaded(@NonNull final String parentId,
                @NonNull List<MediaBrowserCompat.MediaItem> children, @NonNull Bundle options) {
            onChildrenLoadedInternal(parentId, children);
        }

        private void onErrorInternal() {
            mFuture.set(new LibraryResult(RESULT_ERROR_UNKNOWN));
        }

        private void onChildrenLoadedInternal(@NonNull final String parentId,
                @NonNull List<MediaBrowserCompat.MediaItem> children) {
            if (TextUtils.isEmpty(parentId)) {
                Log.w(TAG, "GetChildrenCallback.onChildrenLoaded(): Ignoring empty parentId");
                return;
            }
            MediaBrowserCompat browserCompat = getBrowserCompat();
            if (browserCompat == null) {
                mFuture.set(new LibraryResult(RESULT_ERROR_SESSION_DISCONNECTED));
                return;
            }
            browserCompat.unsubscribe(mParentId, GetChildrenCallback.this);

            final List<MediaItem> items = new ArrayList<>();
            if (children == null) {
                // list are non-Null, so it must be internal error.
                mFuture.set(new LibraryResult(RESULT_ERROR_UNKNOWN));
            } else {
                for (int i = 0; i < children.size(); i++) {
                    items.add(MediaUtils.convertToMediaItem(children.get(i)));
                }
                // Don't set extra here, because 'extra' have different meanings between old
                // API and new API as follows.
                // - Old API: Extra/Option specified with subscribe().
                // - New API: Extra from MediaLibraryService to MediaBrowser
                mFuture.set(new LibraryResult(RESULT_SUCCESS, items, null));
            }
        }
    }
}