MediaBrowser.java

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

import static androidx.annotation.RestrictTo.Scope.LIBRARY;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;

import android.content.Context;
import android.os.SystemClock;
import android.text.TextUtils;
import android.util.Log;

import androidx.annotation.IntDef;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.concurrent.futures.ResolvableFuture;
import androidx.media2.MediaLibraryService.LibraryParams;
import androidx.media2.MediaLibraryService.LibraryResult;
import androidx.media2.MediaLibraryService.MediaLibrarySession;
import androidx.versionedparcelable.NonParcelField;
import androidx.versionedparcelable.ParcelField;

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

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.List;
import java.util.concurrent.Executor;

/**
 * Browses media content offered by a {@link MediaLibraryService}.
 */
public class MediaBrowser extends MediaController {
    static final String TAG = "MediaBrowser";
    static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);

    /**
     * Callback to listen events from {@link MediaLibraryService}.
     */
    public static class BrowserCallback extends MediaController.ControllerCallback {
        /**
         * Called when there's change in the parent's children after you've subscribed to the parent
         * with {@link #subscribe}.
         * <p>
         * This API is called when the library service called
         * {@link MediaLibrarySession#notifyChildrenChanged} for the parent.
         *
         * @param browser the browser for this event
         * @param parentId non-empty parent id that you've specified with
         *                 {@link #subscribe(String, LibraryParams)}
         * @param itemCount number of children
         * @param params library params from the library service. Can be differ from params
         *               that you've specified with {@link #subscribe(String, LibraryParams)}.
         */
        public void onChildrenChanged(@NonNull MediaBrowser browser, @NonNull String parentId,
                @IntRange(from = 0) int itemCount, @Nullable LibraryParams params) { }

        /**
         * Called when there's change in the search result requested by the previous
         * {@link MediaBrowser#search(String, LibraryParams)}.
         *
         * @param browser the browser for this event
         * @param query non-empty search query that you've specified with
         *              {@link #search(String, LibraryParams)}
         * @param itemCount The item count for the search result
         * @param params library params from the library service. Can be differ from params
         *               that you've specified with {@link #search(String, LibraryParams)}.
         */
        public void onSearchResultChanged(@NonNull MediaBrowser browser, @NonNull String query,
                @IntRange(from = 0) int itemCount, @Nullable LibraryParams params) { }
    }

    public MediaBrowser(@NonNull Context context, @NonNull SessionToken token,
            @NonNull /*@CallbackExecutor*/ Executor executor, @NonNull BrowserCallback callback) {
        super(context, token, executor, callback);
    }

    @Override
    MediaBrowserImpl createImpl(@NonNull Context context, @NonNull SessionToken token,
            @NonNull Executor executor, @NonNull MediaController.ControllerCallback callback) {
        if (token.isLegacySession()) {
            return new MediaBrowserImplLegacy(
                    context, this, token, executor, (BrowserCallback) callback);
        } else {
            return new MediaBrowserImplBase(
                    context, this, token, executor, (BrowserCallback) callback);
        }
    }

    @Override
    MediaBrowserImpl getImpl() {
        return (MediaBrowserImpl) super.getImpl();
    }

    @Override
    BrowserCallback getCallback() {
        return (BrowserCallback) super.getCallback();
    }

    /**
     * Gets the library root.
     * <p>
     * If it's successfully completed, {@link BrowserResult#getMediaItem()} will return the library
     * root.
     *
     * @param params library params getting root
     * @see BrowserResult#getMediaItem()
     */
    @NonNull
    public ListenableFuture<BrowserResult> getLibraryRoot(@Nullable final LibraryParams params) {
        if (isConnected()) {
            return getImpl().getLibraryRoot(params);
        }
        return createDisconnectedFuture();
    }

    /**
     * Subscribes to a parent id for the change in its children. When there's a change,
     * {@link BrowserCallback#onChildrenChanged(MediaBrowser, String, int, LibraryParams)} will be
     * called with the library params. You should call
     * {@link #getChildren(String, int, int, LibraryParams)} to get the items under the parent.
     *
     * @param parentId non-empty parent id
     * @param params library params
     */
    public @NonNull ListenableFuture<BrowserResult> subscribe(@NonNull String parentId,
            @Nullable LibraryParams params) {
        if (TextUtils.isEmpty(parentId)) {
            throw new IllegalArgumentException("parentId shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().subscribe(parentId, params);
        }
        return createDisconnectedFuture();
    }

    /**
     * Unsubscribes for changes to the children of the parent, which was previously subscribed with
     * {@link #subscribe(String, LibraryParams)}.
     * <p>
     * This unsubscribes all previous subscription with the parent id, regardless of the library
     * param that was previously sent to the library service.
     *
     * @param parentId non-empty parent id
     */
    public @NonNull ListenableFuture<BrowserResult> unsubscribe(@NonNull String parentId) {
        if (TextUtils.isEmpty(parentId)) {
            throw new IllegalArgumentException("parentId shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().unsubscribe(parentId);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the list of children under the parent.
     * <p>
     * If it's successfully completed, {@link BrowserResult#getMediaItems()} will return the list
     * of children.
     *
     * @param parentId non-empty parent id for getting the children
     * @param page page number to get the result. Starts from {@code 0}
     * @param pageSize page size. Should be greater or equal to {@code 1}
     * @param params library params
     * @see BrowserResult#getMediaItems()
     */
    public @NonNull ListenableFuture<BrowserResult> getChildren(@NonNull String parentId,
            @IntRange(from = 0) int page, @IntRange(from = 1) int pageSize,
            @Nullable LibraryParams params) {
        if (TextUtils.isEmpty(parentId)) {
            throw new IllegalArgumentException("parentId shouldn't be empty");
        }
        if (page < 0) {
            throw new IllegalArgumentException("page shouldn't be negative");
        }
        if (pageSize < 1) {
            throw new IllegalArgumentException("pageSize shouldn't be less than 1");
        }
        if (isConnected()) {
            return getImpl().getChildren(parentId, page, pageSize, params);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the media item with the given media id.
     * <p>
     * If it's successfully completed, {@link BrowserResult#getMediaItem()} will return the media
     * item.
     *
     * @param mediaId non-empty media id for specifying the item
     * @see BrowserResult#getMediaItems()
     */
    public @NonNull ListenableFuture<BrowserResult> getItem(@NonNull final String mediaId) {
        if (TextUtils.isEmpty(mediaId)) {
            throw new IllegalArgumentException("mediaId shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().getItem(mediaId);
        }
        return createDisconnectedFuture();
    }

    /**
     * Sends a search request to the library service.
     * <p>
     * Returned {@link BrowserResult} will only tell whether the attemp to search was successful.
     * For getting the search result, waits for
     * {@link BrowserCallback#getSearchResult(String, int, int, LibraryParams)} the search result
     * and calls {@link #getSearchResult(String, int, int, LibraryParams)}} for getting the result.
     *
     * @param query non-empty search query
     * @param params library params
     * @see BrowserCallback#getSearchResult(String, int, int, LibraryParams)
     * @see #getSearchResult(String, int, int, LibraryParams)
     */
    public @NonNull ListenableFuture<BrowserResult> search(@NonNull String query,
            @Nullable LibraryParams params) {
        if (TextUtils.isEmpty(query)) {
            throw new IllegalArgumentException("query shouldn't be empty");
        }
        if (isConnected()) {
            return getImpl().search(query, params);
        }
        return createDisconnectedFuture();
    }

    /**
     * Gets the search result from lhe library service.
     * <p>
     * If it's successfully completed, {@link BrowserResult#getMediaItems()} will return the search
     * result.
     *
     * @param query non-empty search query that you've specified with
     *              {@link #search(String, LibraryParams)}.
     * @param page page number to get search result. Starts from {@code 0}
     * @param pageSize page size. Should be greater or equal to {@code 1}
     * @param params library params
     * @see BrowserResult#getMediaItems()
     */
    public @NonNull ListenableFuture<BrowserResult> getSearchResult(final @NonNull String query,
            @IntRange(from = 0) int page, @IntRange(from = 1) int pageSize,
            final @Nullable LibraryParams params) {
        if (TextUtils.isEmpty(query)) {
            throw new IllegalArgumentException("query shouldn't be empty");
        }
        if (page < 0) {
            throw new IllegalArgumentException("page shouldn't be negative");
        }
        if (pageSize < 1) {
            throw new IllegalArgumentException("pageSize shouldn't be less than 1");
        }
        if (isConnected()) {
            return getImpl().getSearchResult(query, page, pageSize, params);
        }
        return createDisconnectedFuture();
    }

    private static ListenableFuture<BrowserResult> createDisconnectedFuture() {
        return BrowserResult.createFutureWithResult(BrowserResult.RESULT_CODE_DISCONNECTED);
    }

    /**
     * Result class to be used with {@link ListenableFuture} for asynchronous calls.
     */
    // Specify full name to workaround build error 'cannot find symbol'
    @androidx.versionedparcelable.VersionedParcelize(isCustom = true)
    public static class BrowserResult extends androidx.versionedparcelable.CustomVersionedParcelable
            implements RemoteResult {
        /**
         * @hide
         */
        @IntDef(flag = false, /*prefix = "RESULT_CODE",*/ value = {
                RESULT_CODE_SUCCESS,
                RESULT_CODE_UNKNOWN_ERROR,
                RESULT_CODE_INVALID_STATE,
                RESULT_CODE_BAD_VALUE,
                RESULT_CODE_PERMISSION_DENIED,
                RESULT_CODE_IO_ERROR,
                RESULT_CODE_SKIPPED,
                RESULT_CODE_DISCONNECTED,
                RESULT_CODE_NOT_SUPPORTED,
                RESULT_CODE_AUTHENTICATION_EXPIRED,
                RESULT_CODE_PREMIUM_ACCOUNT_REQUIRED,
                RESULT_CODE_CONCURRENT_STREAM_LIMIT,
                RESULT_CODE_PARENTAL_CONTROL_RESTRICTED,
                RESULT_CODE_NOT_AVAILABLE_IN_REGION,
                RESULT_CODE_SKIP_LIMIT_REACHED,
                RESULT_CODE_SETUP_REQUIRED})
        @Retention(RetentionPolicy.SOURCE)
        @RestrictTo(LIBRARY_GROUP)
        public @interface ResultCode {}

        @ParcelField(1)
        int mResultCode;
        @ParcelField(2)
        long mCompletionTime;
        @ParcelField(3)
        MediaItem mItem;
        @ParcelField(4)
        LibraryParams mParams;
        // Mark list of media items NonParcelField to send the list through the ParcelImpListSlice.
        @NonParcelField
        List<MediaItem> mItemList;
        @ParcelField(5)
        ParcelImplListSlice mItemListSlice;

        // For versioned parcelable.
        BrowserResult() {
            // no-op
        }

        BrowserResult(@ResultCode int resultCode) {
            this(resultCode, null, null, null);
        }

        BrowserResult(@ResultCode int resultCode, @Nullable MediaItem item,
                @Nullable LibraryParams params) {
            this(resultCode, item, null, params);
        }

        BrowserResult(@ResultCode int resultCode, @Nullable List<MediaItem> items,
                @Nullable LibraryParams params) {
            this(resultCode, null, items, params);
        }

        BrowserResult(@ResultCode int resultCode, @Nullable MediaItem item,
                @Nullable List<MediaItem> items, @Nullable LibraryParams params) {
            this(resultCode, item, items, params, SystemClock.elapsedRealtime());
        }

        BrowserResult(@ResultCode int resultCode, @Nullable MediaItem item,
                @Nullable List<MediaItem> items, @Nullable LibraryParams params,
                long elapsedTime) {
            mResultCode = resultCode;
            mItem = item;
            mItemList = items;
            mParams = params;
            mCompletionTime = elapsedTime;
        }

        static ListenableFuture<BrowserResult> createFutureWithResult(@ResultCode int resultCode) {
            ResolvableFuture<BrowserResult> result = ResolvableFuture.create();
            result.set(new BrowserResult(resultCode));
            return result;
        }

        static BrowserResult from(@Nullable LibraryResult result) {
            if (result == null) {
                return null;
            }
            return new BrowserResult(result.getResultCode(), result.getMediaItem(),
                    result.getMediaItems(), result.getLibraryParams(), result.getCompletionTime());
        }

        /**
         * Gets the result code.
         *
         * @return result code
         * @see #RESULT_CODE_SUCCESS
         * @see #RESULT_CODE_UNKNOWN_ERROR
         * @see #RESULT_CODE_INVALID_STATE
         * @see #RESULT_CODE_BAD_VALUE
         * @see #RESULT_CODE_PERMISSION_DENIED
         * @see #RESULT_CODE_IO_ERROR
         * @see #RESULT_CODE_SKIPPED
         * @see #RESULT_CODE_DISCONNECTED
         * @see #RESULT_CODE_NOT_SUPPORTED
         * @see #RESULT_CODE_AUTHENTICATION_EXPIRED
         * @see #RESULT_CODE_PREMIUM_ACCOUNT_REQUIRED
         * @see #RESULT_CODE_CONCURRENT_STREAM_LIMIT
         * @see #RESULT_CODE_PARENTAL_CONTROL_RESTRICTED
         * @see #RESULT_CODE_NOT_AVAILABLE_IN_REGION
         * @see #RESULT_CODE_SKIP_LIMIT_REACHED
         * @see #RESULT_CODE_SETUP_REQUIRED
         */
        @Override
        public @ResultCode int getResultCode() {
            return mResultCode;
        }

        /**
         * Gets the completion time of the command. Being more specific, it's the same as
         * {@link android.os.SystemClock#elapsedRealtime()} when the command is completed.
         *
         * @return completion time of the command
         */
        @Override
        public long getCompletionTime() {
            return mCompletionTime;
        }

        /**
         * Gets the media item.
         * <p>
         * Can be {@code null} if an error happened or the command doesn't return a media item.
         *
         * @return media item
         * @see MediaBrowser#getLibraryRoot(LibraryParams)
         * @see MediaBrowser#getItem(String)
         */
        @Override
        public @Nullable MediaItem getMediaItem() {
            return mItem;
        }

        /**
         * Gets the list of media item.
         * <p>
         * Can be {@code null} if an error happened or the command doesn't return a list of media
         * items.
         *
         * @return list of media item
         * @see MediaBrowser#getSearchResult(String, int, int, LibraryParams)
         * @see MediaBrowser#getChildren(String, int, int, LibraryParams)
         **/
        public @Nullable List<MediaItem> getMediaItems() {
            return mItemList;
        }

        /**
         * Gets the library params
         *
         * @return library params.
         */
        public @Nullable LibraryParams getLibraryParams() {
            return mParams;
        }

        /**
         * @hide
         * @param isStream
         */
        @RestrictTo(LIBRARY)
        @Override
        public void onPreParceling(boolean isStream) {
            mItemListSlice = MediaUtils.convertMediaItemListToParcelImplListSlice(mItemList);
        }

        /**
         * @hide
         */
        @RestrictTo(LIBRARY)
        @Override
        public void onPostParceling() {
            mItemList = MediaUtils.convertParcelImplListSliceToMediaItemList(mItemListSlice);
            mItemListSlice = null;
        }
    }

    interface MediaBrowserImpl extends MediaControllerImpl {
        ListenableFuture<BrowserResult> getLibraryRoot(@Nullable LibraryParams rootHints);
        ListenableFuture<BrowserResult> subscribe(@NonNull String parentId,
                @Nullable LibraryParams params);
        ListenableFuture<BrowserResult> unsubscribe(@NonNull String parentId);
        ListenableFuture<BrowserResult> getChildren(@NonNull String parentId, int page,
                int pageSize, @Nullable LibraryParams params);
        ListenableFuture<BrowserResult> getItem(@NonNull String mediaId);
        ListenableFuture<BrowserResult> search(@NonNull String query,
                @Nullable LibraryParams params);
        ListenableFuture<BrowserResult> getSearchResult(@NonNull String query, int page,
                int pageSize, @Nullable LibraryParams params);
    }
}