/*
* 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.media3.session;
import static androidx.media3.session.LibraryResult.RESULT_ERROR_BAD_VALUE;
import static androidx.media3.session.LibraryResult.RESULT_ERROR_PERMISSION_DENIED;
import static androidx.media3.session.LibraryResult.RESULT_ERROR_SESSION_DISCONNECTED;
import static androidx.media3.session.LibraryResult.RESULT_ERROR_UNKNOWN;
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 androidx.annotation.Nullable;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.util.Log;
import androidx.media3.session.MediaLibraryService.LibraryParams;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
/** Implementation of MediaBrowser with the {@link MediaBrowserCompat} for legacy support. */
/* package */ class MediaBrowserImplLegacy extends MediaControllerImplLegacy
implements MediaBrowser.MediaBrowserImpl {
private static final String TAG = "MB2ImplLegacy";
private final HashMap<LibraryParams, MediaBrowserCompat> browserCompats = new HashMap<>();
private final HashMap<String, List<SubscribeCallback>> subscribeCallbacks = new HashMap<>();
MediaBrowserImplLegacy(Context context, MediaBrowser instance, SessionToken token) {
super(context, instance, token);
}
MediaBrowser getMediaBrowser() {
return (MediaBrowser) instance;
}
@Override
public void release() {
for (MediaBrowserCompat browserCompat : browserCompats.values()) {
browserCompat.disconnect();
}
browserCompats.clear();
// Ensure that MediaController.Listener#onDisconnected() is called by super.release().
super.release();
}
@Override
public SessionCommands getAvailableSessionCommands() {
@Nullable MediaBrowserCompat browserCompat = getBrowserCompat();
if (browserCompat != null) {
return super.getAvailableSessionCommands().buildUpon().addAllLibraryCommands().build();
}
return super.getAvailableSessionCommands();
}
@Override
public ListenableFuture<LibraryResult<MediaItem>> getLibraryRoot(@Nullable LibraryParams params) {
if (!instance.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT)) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_PERMISSION_DENIED));
}
SettableFuture<LibraryResult<MediaItem>> result = SettableFuture.create();
MediaBrowserCompat browserCompat = getBrowserCompat(params);
if (browserCompat != null) {
// Already connected with the given extras.
result.set(LibraryResult.ofItem(createRootMediaItem(browserCompat), null));
} else {
Bundle rootHints = MediaUtils.convertToRootHints(params);
MediaBrowserCompat newBrowser =
new MediaBrowserCompat(
getContext(),
getConnectedToken().getComponentName(),
new GetLibraryRootCallback(result, params),
rootHints);
browserCompats.put(params, newBrowser);
newBrowser.connect();
}
return result;
}
@Override
public ListenableFuture<LibraryResult<Void>> subscribe(
String parentId, @Nullable LibraryParams params) {
if (!instance.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_SUBSCRIBE)) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_PERMISSION_DENIED));
}
MediaBrowserCompat browserCompat = getBrowserCompat();
if (browserCompat == null) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_SESSION_DISCONNECTED));
}
SettableFuture<LibraryResult<Void>> future = SettableFuture.create();
SubscribeCallback callback = new SubscribeCallback(future);
List<SubscribeCallback> list = subscribeCallbacks.get(parentId);
if (list == null) {
list = new ArrayList<>();
subscribeCallbacks.put(parentId, list);
}
list.add(callback);
browserCompat.subscribe(parentId, createOptions(params), callback);
return future;
}
@Override
public ListenableFuture<LibraryResult<Void>> unsubscribe(String parentId) {
if (!instance.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_UNSUBSCRIBE)) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_PERMISSION_DENIED));
}
MediaBrowserCompat browserCompat = getBrowserCompat();
if (browserCompat == null) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_SESSION_DISCONNECTED));
}
// Note: don't use MediaBrowserCompat#unsubscribe(String) here, to keep the subscription
// callback for getChildren.
List<SubscribeCallback> list = subscribeCallbacks.get(parentId);
if (list == null) {
return Futures.immediateFuture(LibraryResult.ofError(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 Futures.immediateFuture(LibraryResult.ofVoid());
}
@Override
public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> getChildren(
String parentId, int page, int pageSize, @Nullable LibraryParams params) {
if (!instance.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_GET_CHILDREN)) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_PERMISSION_DENIED));
}
MediaBrowserCompat browserCompat = getBrowserCompat();
if (browserCompat == null) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_SESSION_DISCONNECTED));
}
SettableFuture<LibraryResult<ImmutableList<MediaItem>>> future = SettableFuture.create();
Bundle options = createOptions(params, page, pageSize);
browserCompat.subscribe(parentId, options, new GetChildrenCallback(future, parentId));
return future;
}
@Override
public ListenableFuture<LibraryResult<MediaItem>> getItem(String mediaId) {
if (!instance.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM)) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_PERMISSION_DENIED));
}
MediaBrowserCompat browserCompat = getBrowserCompat();
if (browserCompat == null) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_SESSION_DISCONNECTED));
}
SettableFuture<LibraryResult<MediaItem>> result = SettableFuture.create();
browserCompat.getItem(
mediaId,
new ItemCallback() {
@Override
public void onItemLoaded(MediaBrowserCompat.MediaItem item) {
if (item != null) {
result.set(
LibraryResult.ofItem(MediaUtils.convertToMediaItem(item), /* params= */ null));
} else {
result.set(LibraryResult.ofError(RESULT_ERROR_BAD_VALUE));
}
}
@Override
public void onError(String itemId) {
result.set(LibraryResult.ofError(RESULT_ERROR_UNKNOWN));
}
});
return result;
}
@Override
public ListenableFuture<LibraryResult<Void>> search(
String query, @Nullable LibraryParams params) {
if (!instance.isSessionCommandAvailable(SessionCommand.COMMAND_CODE_LIBRARY_SEARCH)) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_PERMISSION_DENIED));
}
MediaBrowserCompat browserCompat = getBrowserCompat();
if (browserCompat == null) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_SESSION_DISCONNECTED));
}
browserCompat.search(
query,
getExtras(params),
new MediaBrowserCompat.SearchCallback() {
@Override
public void onSearchResult(
String query, Bundle extras, List<MediaBrowserCompat.MediaItem> items) {
getMediaBrowser()
.notifyBrowserListener(
listener -> {
// 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(b/193193565): Cache search result for later getSearchResult() calls.
listener.onSearchResultChanged(getMediaBrowser(), query, items.size(), null);
});
}
@Override
public void onError(String query, Bundle extras) {
getMediaBrowser()
.notifyBrowserListener(
listener -> {
// 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
listener.onSearchResultChanged(getMediaBrowser(), query, 0, null);
});
}
});
// No way to get result. Just return success.
return Futures.immediateFuture(LibraryResult.ofVoid());
}
@Override
public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> getSearchResult(
String query, int page, int pageSize, @Nullable LibraryParams params) {
if (!instance.isSessionCommandAvailable(
SessionCommand.COMMAND_CODE_LIBRARY_GET_SEARCH_RESULT)) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_PERMISSION_DENIED));
}
MediaBrowserCompat browserCompat = getBrowserCompat();
if (browserCompat == null) {
return Futures.immediateFuture(LibraryResult.ofError(RESULT_ERROR_SESSION_DISCONNECTED));
}
SettableFuture<LibraryResult<ImmutableList<MediaItem>>> future = SettableFuture.create();
Bundle options = createOptions(params, page, pageSize);
options.putInt(MediaBrowserCompat.EXTRA_PAGE, page);
options.putInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, pageSize);
browserCompat.search(
query,
options,
new MediaBrowserCompat.SearchCallback() {
@Override
public void onSearchResult(
String query, Bundle extrasSent, List<MediaBrowserCompat.MediaItem> items) {
future.set(
LibraryResult.ofItemList(
MediaUtils.convertBrowserItemListToMediaItemList(items), /* params= */ null));
}
@Override
public void onError(String query, Bundle extrasSent) {
future.set(LibraryResult.ofError(RESULT_ERROR_UNKNOWN));
}
});
return future;
}
private MediaBrowserCompat getBrowserCompat(LibraryParams extras) {
return browserCompats.get(extras);
}
private static Bundle createOptions(@Nullable LibraryParams params) {
return params == null ? new Bundle() : new Bundle(params.extras);
}
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.extras : null;
}
private MediaItem createRootMediaItem(MediaBrowserCompat browserCompat) {
// TODO(b/193193690): Query again with getMediaItem() to get real media item.
String mediaId = browserCompat.getRoot();
MediaMetadata mediaMetadata =
new MediaMetadata.Builder()
.setFolderType(MediaMetadata.FOLDER_TYPE_MIXED)
.setIsPlayable(false)
.setExtras(browserCompat.getExtras())
.build();
return new MediaItem.Builder().setMediaId(mediaId).setMediaMetadata(mediaMetadata).build();
}
private class GetLibraryRootCallback extends MediaBrowserCompat.ConnectionCallback {
private final SettableFuture<LibraryResult<MediaItem>> result;
private final LibraryParams params;
public GetLibraryRootCallback(
SettableFuture<LibraryResult<MediaItem>> result, LibraryParams params) {
super();
this.result = result;
this.params = params;
}
@Override
public void onConnected() {
MediaBrowserCompat browserCompat = browserCompats.get(params);
if (browserCompat == null) {
// Shouldn't be happen. Internal error?
result.set(LibraryResult.ofError(RESULT_ERROR_UNKNOWN));
} else {
result.set(
LibraryResult.ofItem(
createRootMediaItem(browserCompat),
MediaUtils.convertToLibraryParams(context, browserCompat.getExtras())));
}
}
@Override
public void onConnectionSuspended() {
onConnectionFailed();
}
@Override
public void onConnectionFailed() {
// Unknown extra field.
result.set(LibraryResult.ofError(RESULT_ERROR_BAD_VALUE));
release();
}
}
private class SubscribeCallback extends SubscriptionCallback {
private final SettableFuture<LibraryResult<Void>> future;
public SubscribeCallback(SettableFuture<LibraryResult<Void>> future) {
this.future = future;
}
@Override
public void onError(String parentId) {
onErrorInternal();
}
@Override
public void onError(String parentId, Bundle options) {
onErrorInternal();
}
@Override
public void onChildrenLoaded(String parentId, List<MediaBrowserCompat.MediaItem> children) {
onChildrenLoadedInternal(parentId, children);
}
@Override
public void onChildrenLoaded(
String parentId, List<MediaBrowserCompat.MediaItem> children, 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.
future.set(LibraryResult.ofError(RESULT_ERROR_UNKNOWN));
}
private void onChildrenLoadedInternal(
String parentId, @Nullable List<MediaBrowserCompat.MediaItem> children) {
if (TextUtils.isEmpty(parentId)) {
Log.w(TAG, "SubscribeCallback.onChildrenLoaded(): Ignoring empty parentId");
return;
}
MediaBrowserCompat browserCompat = getBrowserCompat();
if (browserCompat == null) {
// Browser is closed.
return;
}
int itemCount;
if (children != null) {
itemCount = children.size();
} else {
// Currently no way to tell failures in MediaBrowser#subscribe().
return;
}
LibraryParams params =
MediaUtils.convertToLibraryParams(
context, browserCompat.getNotifyChildrenChangedOptions());
getMediaBrowser()
.notifyBrowserListener(
listener -> {
// TODO(b/193193565): Cache children result for later getChildren() calls.
listener.onChildrenChanged(getMediaBrowser(), parentId, itemCount, params);
});
future.set(LibraryResult.ofVoid());
}
}
private class GetChildrenCallback extends SubscriptionCallback {
private final SettableFuture<LibraryResult<ImmutableList<MediaItem>>> future;
private final String parentId;
public GetChildrenCallback(
SettableFuture<LibraryResult<ImmutableList<MediaItem>>> future, String parentId) {
super();
this.future = future;
this.parentId = parentId;
}
@Override
public void onError(String parentId) {
onErrorInternal();
}
@Override
public void onError(String parentId, Bundle options) {
onErrorInternal();
}
@Override
public void onChildrenLoaded(String parentId, List<MediaBrowserCompat.MediaItem> children) {
onChildrenLoadedInternal(parentId, children);
}
@Override
public void onChildrenLoaded(
String parentId, List<MediaBrowserCompat.MediaItem> children, Bundle options) {
onChildrenLoadedInternal(parentId, children);
}
private void onErrorInternal() {
future.set(LibraryResult.ofError(RESULT_ERROR_UNKNOWN));
}
private void onChildrenLoadedInternal(
String parentId, List<MediaBrowserCompat.MediaItem> children) {
if (TextUtils.isEmpty(parentId)) {
Log.w(TAG, "GetChildrenCallback.onChildrenLoaded(): Ignoring empty parentId");
return;
}
MediaBrowserCompat browserCompat = getBrowserCompat();
if (browserCompat == null) {
future.set(LibraryResult.ofError(RESULT_ERROR_SESSION_DISCONNECTED));
return;
}
browserCompat.unsubscribe(this.parentId, GetChildrenCallback.this);
if (children == null) {
// list are non-Null, so it must be internal error.
future.set(LibraryResult.ofError(RESULT_ERROR_UNKNOWN));
} else {
// 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
future.set(
LibraryResult.ofItemList(
MediaUtils.convertBrowserItemListToMediaItemList(children), /* params= */ null));
}
}
}
}