/*
* 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 android.support.v4.media.MediaBrowserCompat.EXTRA_PAGE;
import static android.support.v4.media.MediaBrowserCompat.EXTRA_PAGE_SIZE;
import static androidx.media2.session.LibraryResult.RESULT_SUCCESS;
import static androidx.media2.session.MediaUtils.TRANSACTION_SIZE_LIMIT_IN_BYTES;
import android.content.Context;
import android.os.BadParcelableException;
import android.os.Bundle;
import android.os.Process;
import android.os.RemoteException;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.core.util.ObjectsCompat;
import androidx.media.MediaBrowserServiceCompat;
import androidx.media.MediaSessionManager.RemoteUserInfo;
import androidx.media2.common.MediaItem;
import androidx.media2.common.MediaMetadata;
import androidx.media2.common.SessionPlayer.PlayerResult;
import androidx.media2.common.SessionPlayer.TrackInfo;
import androidx.media2.common.SubtitleData;
import androidx.media2.common.VideoSize;
import androidx.media2.session.MediaController.PlaybackInfo;
import androidx.media2.session.MediaLibraryService.LibraryParams;
import androidx.media2.session.MediaLibraryService.MediaLibrarySession.MediaLibrarySessionImpl;
import androidx.media2.session.MediaSession.CommandButton;
import androidx.media2.session.MediaSession.ControllerInfo;
import java.util.ArrayList;
import java.util.List;
/**
* Implementation of {@link MediaBrowserServiceCompat} for interoperability between
* {@link MediaLibraryService} and {@link MediaBrowserCompat}.
*/
class MediaLibraryServiceLegacyStub extends MediaSessionServiceLegacyStub {
private static final String TAG = "MLS2LegacyStub";
private static final boolean DEBUG = false;
private final ControllerInfo mControllersForAll;
@SuppressWarnings("WeakerAccess") /* synthetic access */
final MediaLibrarySessionImpl mLibrarySessionImpl;
// Note: We'd better not obtain token from the session because it's called inside of the
// session's constructor and session's token may not be initialized here.
MediaLibraryServiceLegacyStub(Context context, MediaLibrarySessionImpl session,
MediaSessionCompat.Token token) {
super(context, session, token);
mLibrarySessionImpl = session;
mControllersForAll = new ControllerInfo(new RemoteUserInfo(
RemoteUserInfo.LEGACY_CONTROLLER, Process.myPid(), Process.myUid()),
false /* trusted */,
new BrowserLegacyCbForAll(this), null /* connectionHints */);
}
@Override
public BrowserRoot onGetRoot(String clientPackageName, int clientUid, final Bundle rootHints) {
BrowserRoot browserRoot = super.onGetRoot(clientPackageName, clientUid, rootHints);
if (browserRoot == null) {
return null;
}
final ControllerInfo controller = getCurrentController();
if (controller == null) {
return null;
}
if (getConnectedControllersManager().isAllowedCommand(controller,
SessionCommand.COMMAND_CODE_LIBRARY_GET_LIBRARY_ROOT)) {
// Call callbacks directly instead of execute on the executor. Here's the reason.
// We need to return browser root here. So if we run the callback on the executor, we
// should wait for the completion.
// However, we cannot wait if the callback executor is the main executor, which posts
// the runnable to the main thread's. In that case, since this onGetRoot() always runs
// on the main thread, the posted runnable for calling onGetLibraryRoot() wouldn't run
// in here. Even worse, we cannot know whether it would be run on the main thread or
// not.
// Because of the reason, just call onGetLibraryRoot() directly here. onGetLibraryRoot()
// has documentation that it may be called on the main thread.
LibraryParams params = MediaUtils.convertToLibraryParams(
mLibrarySessionImpl.getContext(), rootHints);
LibraryResult result = mLibrarySessionImpl.getCallback().onGetLibraryRoot(
mLibrarySessionImpl.getInstance(), controller, params);
if (result != null && result.getResultCode() == RESULT_SUCCESS
&& result.getMediaItem() != null) {
MediaMetadata metadata = result.getMediaItem().getMetadata();
String id = metadata != null
? metadata.getString(MediaMetadata.METADATA_KEY_MEDIA_ID) : "";
return new BrowserRoot(id,
MediaUtils.convertToRootHints(result.getLibraryParams()));
} else if (DEBUG) {
Log.d(TAG, "Unexpected LibraryResult for getting the root from the legacy browser."
+ " Will return stub root to allow getting session.");
}
} else if (DEBUG) {
Log.d(TAG, "Command MBC.connect from " + controller + " was rejected by "
+ mLibrarySessionImpl);
}
// No library root, but keep browser compat connected to allow getting session.
return MediaUtils.sDefaultBrowserRoot;
}
@Override
public void onSubscribe(final String id, final Bundle option) {
final ControllerInfo controller = getCurrentController();
if (TextUtils.isEmpty(id)) {
Log.w(TAG, "onSubscribe(): Ignoring empty id from " + controller);
return;
}
mLibrarySessionImpl.getCallbackExecutor().execute(new Runnable() {
@Override
public void run() {
// Note: If a developer calls notifyChildrenChanged inside, onLoadChildren will be
// called twice for a single subscription event.
// TODO(post 1.0): Fix the issue above.
if (!getConnectedControllersManager().isAllowedCommand(controller,
SessionCommand.COMMAND_CODE_LIBRARY_SUBSCRIBE)) {
if (DEBUG) {
Log.d(TAG, "Command MBC.subscribe() from " + controller + " was rejected"
+ " by " + mLibrarySessionImpl);
}
return;
}
LibraryParams params = MediaUtils.convertToLibraryParams(
mLibrarySessionImpl.getContext(), option);
mLibrarySessionImpl.getCallback().onSubscribe(mLibrarySessionImpl.getInstance(),
controller, id, params);
}
});
}
@Override
public void onUnsubscribe(final String id) {
final ControllerInfo controller = getCurrentController();
if (TextUtils.isEmpty(id)) {
Log.w(TAG, "onUnsubscribe(): Ignoring empty id from " + controller);
return;
}
mLibrarySessionImpl.getCallbackExecutor().execute(new Runnable() {
@Override
public void run() {
if (!getConnectedControllersManager().isAllowedCommand(controller,
SessionCommand.COMMAND_CODE_LIBRARY_UNSUBSCRIBE)) {
if (DEBUG) {
Log.d(TAG, "Command MBC.unsubscribe() from " + controller + " was rejected"
+ " by " + mLibrarySessionImpl);
}
return;
}
mLibrarySessionImpl.getCallback().onUnsubscribe(mLibrarySessionImpl.getInstance(),
controller, id);
}
});
}
@Override
public void onLoadChildren(String parentId, Result<List<MediaBrowserCompat.MediaItem>> result) {
onLoadChildren(parentId, result, null);
}
@Override
public void onLoadChildren(final String parentId,
final Result<List<MediaBrowserCompat.MediaItem>> result, final Bundle options) {
final ControllerInfo controller = getCurrentController();
if (TextUtils.isEmpty(parentId)) {
Log.w(TAG, "onLoadChildren(): Ignoring empty parentId from " + controller);
result.sendError(null);
return;
}
result.detach();
mLibrarySessionImpl.getCallbackExecutor().execute(new Runnable() {
@Override
public void run() {
if (!getConnectedControllersManager().isAllowedCommand(controller,
SessionCommand.COMMAND_CODE_LIBRARY_GET_CHILDREN)) {
if (DEBUG) {
Log.d(TAG, "Command MBC.subscribe() from " + controller + " was rejected"
+ " by " + mLibrarySessionImpl);
}
result.sendError(null);
return;
}
if (options != null) {
options.setClassLoader(mLibrarySessionImpl.getContext().getClassLoader());
try {
int page = options.getInt(EXTRA_PAGE);
int pageSize = options.getInt(EXTRA_PAGE_SIZE);
if (page > 0 && pageSize > 0) {
// Requesting the list of children through pagination.
LibraryParams params = MediaUtils.convertToLibraryParams(
mLibrarySessionImpl.getContext(), options);
LibraryResult libraryResult = mLibrarySessionImpl.getCallback()
.onGetChildren(mLibrarySessionImpl.getInstance(), controller,
parentId, page, pageSize, params);
if (libraryResult == null
|| libraryResult.getResultCode() != RESULT_SUCCESS) {
result.sendResult(null);
} else {
result.sendResult(MediaUtils.truncateListBySize(
MediaUtils.convertToMediaItemList(
libraryResult.getMediaItems()),
TRANSACTION_SIZE_LIMIT_IN_BYTES));
}
return;
}
// Cannot distinguish onLoadChildren() why it's called either by
// {@link MediaBrowserCompat#subscribe()} or
// {@link MediaBrowserServiceCompat#notifyChildrenChanged}.
} catch (BadParcelableException e) {
// pass-through.
}
}
// A MediaBrowserCompat called loadChildren with no pagination option.
LibraryResult libraryResult = mLibrarySessionImpl.getCallback()
.onGetChildren(mLibrarySessionImpl.getInstance(), controller, parentId,
0 /* page */, Integer.MAX_VALUE /* pageSize*/,
null /* extras */);
if (libraryResult == null
|| libraryResult.getResultCode() != RESULT_SUCCESS) {
result.sendResult(null);
} else {
result.sendResult(MediaUtils.truncateListBySize(
MediaUtils.convertToMediaItemList(libraryResult.getMediaItems()),
TRANSACTION_SIZE_LIMIT_IN_BYTES));
}
}
});
}
@Override
public void onLoadItem(final String itemId, final Result<MediaBrowserCompat.MediaItem> result) {
final ControllerInfo controller = getCurrentController();
if (TextUtils.isEmpty(itemId)) {
Log.w(TAG, "Ignoring empty itemId from " + controller);
result.sendError(null);
return;
}
result.detach();
mLibrarySessionImpl.getCallbackExecutor().execute(new Runnable() {
@Override
public void run() {
if (!getConnectedControllersManager().isAllowedCommand(controller,
SessionCommand.COMMAND_CODE_LIBRARY_GET_ITEM)) {
if (DEBUG) {
Log.d(TAG, "Command MBC.getItem() from " + controller + " was rejected by "
+ mLibrarySessionImpl);
}
result.sendError(null);
return;
}
LibraryResult libraryResult = mLibrarySessionImpl.getCallback().onGetItem(
mLibrarySessionImpl.getInstance(), controller, itemId);
if (libraryResult == null || libraryResult.getResultCode() != RESULT_SUCCESS) {
result.sendResult(null);
} else {
result.sendResult(MediaUtils.convertToMediaItem(libraryResult.getMediaItem()));
}
}
});
}
@Override
public void onSearch(final String query, final Bundle extras,
final Result<List<MediaBrowserCompat.MediaItem>> result) {
final ControllerInfo controller = getCurrentController();
if (TextUtils.isEmpty(query)) {
Log.w(TAG, "Ignoring empty query from " + controller);
result.sendError(null);
return;
}
if (!(controller.getControllerCb() instanceof BrowserLegacyCb)) {
if (DEBUG) {
throw new IllegalStateException("Callback hasn't registered. Must be a bug");
}
return;
}
result.detach();
mLibrarySessionImpl.getCallbackExecutor().execute(new Runnable() {
@Override
public void run() {
if (!getConnectedControllersManager().isAllowedCommand(controller,
SessionCommand.COMMAND_CODE_LIBRARY_SEARCH)) {
if (DEBUG) {
Log.d(TAG, "Command MBC.search() from " + controller + " was rejected by "
+ mLibrarySessionImpl);
}
result.sendError(null);
return;
}
BrowserLegacyCb cb = (BrowserLegacyCb) controller.getControllerCb();
cb.registerSearchRequest(controller, query, extras, result);
LibraryParams params = MediaUtils.convertToLibraryParams(
mLibrarySessionImpl.getContext(), extras);
mLibrarySessionImpl.getCallback().onSearch(mLibrarySessionImpl.getInstance(),
controller, query, params);
// Actual search result will be sent by notifySearchResultChanged().
}
});
}
@Override
public void onCustomAction(final String action, final Bundle extras,
final Result<Bundle> result) {
if (result != null) {
result.detach();
}
final ControllerInfo controller = getCurrentController();
mLibrarySessionImpl.getCallbackExecutor().execute(new Runnable() {
@Override
public void run() {
SessionCommand command = new SessionCommand(action, null);
if (!getConnectedControllersManager().isAllowedCommand(controller, command)) {
if (DEBUG) {
Log.d(TAG, "Command MBC.sendCustomAction(" + command + ") from "
+ controller + " was rejected by " + mLibrarySessionImpl);
}
if (result != null) {
result.sendError(null);
}
return;
}
SessionResult sessionResult = mLibrarySessionImpl.getCallback().onCustomCommand(
mLibrarySessionImpl.getInstance(), controller, command, extras);
if (sessionResult != null) {
result.sendResult(sessionResult.getCustomCommandResult());
}
}
});
}
@Override
ControllerInfo createControllerInfo(RemoteUserInfo remoteUserInfo) {
return new ControllerInfo(remoteUserInfo,
mManager.isTrustedForMediaControl(remoteUserInfo),
new BrowserLegacyCb(remoteUserInfo), null /* connectionHints */);
}
ControllerInfo getControllersForAll() {
return mControllersForAll;
}
private ControllerInfo getCurrentController() {
return getConnectedControllersManager().getController(getCurrentBrowserInfo());
}
private static class SearchRequest {
public final ControllerInfo mController;
public final RemoteUserInfo mRemoteUserInfo;
public final String mQuery;
public final Bundle mExtras;
public final Result<List<MediaBrowserCompat.MediaItem>> mResult;
SearchRequest(ControllerInfo controller, RemoteUserInfo remoteUserInfo, String query,
Bundle extras, Result<List<MediaBrowserCompat.MediaItem>> result) {
mController = controller;
mRemoteUserInfo = remoteUserInfo;
mQuery = query;
mExtras = extras;
mResult = result;
}
}
// Base class for MediaBrowserCompat's ControllerCb.
// This documents
// 1) Why some APIs does nothing
// 2) Why some APIs should throw exception when DEBUG is {@code true}.
private abstract static class BaseBrowserLegacyCb extends MediaSession.ControllerCb {
@Override
void onPlayerResult(int seq, PlayerResult result) throws RemoteException {
// No-op. BrowserCompat doesn't understand Session features.
}
@Override
void onSessionResult(int seq, SessionResult result) throws RemoteException {
// No-op. BrowserCompat doesn't understand Session features.
}
@Override
void onLibraryResult(int seq, LibraryResult result) throws RemoteException {
// No-op. BrowserCompat doesn't understand Browser features.
}
@Override
final void setCustomLayout(int seq, List<CommandButton> layout) throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onPlaybackInfoChanged(int seq, PlaybackInfo info) throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onAllowedCommandsChanged(int seq, SessionCommandGroup commands)
throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void sendCustomCommand(int seq, SessionCommand command, Bundle args)
throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onPlayerStateChanged(int seq, long eventTimeMs, long positionMs, int playerState)
throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onPlaybackSpeedChanged(int seq, long eventTimeMs, long positionMs, float speed)
throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onBufferingStateChanged(int seq, MediaItem item, int bufferingState,
long bufferedPositionMs, long eventTimeMs, long positionMs) throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onSeekCompleted(int seq, long eventTimeMs, long positionMs, long position)
throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onCurrentMediaItemChanged(int seq, MediaItem item, int currentIdx,
int previousIdx, int nextIdx) throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onPlaylistChanged(int seq, List<MediaItem> playlist, MediaMetadata metadata,
int currentIdx, int previousIdx, int nextIdx) throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onPlaylistMetadataChanged(int seq, MediaMetadata metadata)
throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onShuffleModeChanged(int seq, int shuffleMode, int currentIdx, int previousIdx,
int nextIdx) throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onRepeatModeChanged(int seq, int repeatMode, int currentIdx, int previousIdx,
int nextIdx) throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onPlaybackCompleted(int seq) throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onDisconnected(int seq) throws RemoteException {
// No-op. BrowserCompat doesn't have concept of receiving release of a session.
}
@Override
final void onVideoSizeChanged(int seq, @NonNull MediaItem item,
@NonNull VideoSize videoSize) {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
void onTrackInfoChanged(int seq, List<TrackInfo> trackInfos,
TrackInfo selectedVideoTrack, TrackInfo selectedAudioTrack,
TrackInfo selectedSubtitleTrack, TrackInfo selectedMetadataTrack)
throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onTrackSelected(int seq, TrackInfo trackInfo)
throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onTrackDeselected(int seq, TrackInfo trackInfo)
throws RemoteException {
// No-op. BrowserCompat doesn't understand Controller features.
}
@Override
final void onSubtitleData(int seq, @NonNull MediaItem item,
@NonNull TrackInfo track, @NonNull SubtitleData data) {
// No-op. BrowserCompat doesn't understand Controller features.
}
}
private class BrowserLegacyCb extends BaseBrowserLegacyCb {
private final Object mLock = new Object();
private final RemoteUserInfo mRemoteUserInfo;
@GuardedBy("mLock")
private final List<SearchRequest> mSearchRequests = new ArrayList<>();
BrowserLegacyCb(RemoteUserInfo remoteUserInfo) {
mRemoteUserInfo = remoteUserInfo;
}
@Override
void onChildrenChanged(int seq, String parentId, int itemCount, LibraryParams params)
throws RemoteException {
Bundle extras = params != null ? params.getExtras() : null;
notifyChildrenChanged(mRemoteUserInfo, parentId, extras);
}
@Override
void onSearchResultChanged(int seq, String query, int itemCount, LibraryParams params)
throws RemoteException {
// In MediaLibrarySession/MediaBrowser, we have two different APIs for getting size of
// search result (and also starting search) and getting result.
// However, MediaBrowserService/MediaBrowserCompat only have one search API for getting
// search result.
final List<SearchRequest> searchRequests = new ArrayList<>();
synchronized (mLock) {
for (int i = mSearchRequests.size() - 1; i >= 0; i--) {
SearchRequest iter = mSearchRequests.get(i);
if (ObjectsCompat.equals(mRemoteUserInfo, iter.mRemoteUserInfo)
&& iter.mQuery.equals(query)) {
searchRequests.add(iter);
mSearchRequests.remove(i);
}
}
if (searchRequests.size() == 0) {
if (DEBUG) {
Log.d(TAG, "search() hasn't called by " + mRemoteUserInfo
+ " with query=" + query);
}
return;
}
}
mLibrarySessionImpl.getCallbackExecutor().execute(new Runnable() {
@Override
public void run() {
for (int i = 0; i < searchRequests.size(); i++) {
SearchRequest request = searchRequests.get(i);
int page = 0;
int pageSize = Integer.MAX_VALUE;
if (request.mExtras != null) {
try {
request.mExtras.setClassLoader(
mLibrarySessionImpl.getContext().getClassLoader());
page = request.mExtras.getInt(MediaBrowserCompat.EXTRA_PAGE, -1);
pageSize = request.mExtras
.getInt(MediaBrowserCompat.EXTRA_PAGE_SIZE, -1);
} catch (BadParcelableException e) {
request.mResult.sendResult(null);
return;
}
}
if (page < 0 || pageSize < 1) {
page = 0;
pageSize = Integer.MAX_VALUE;
}
LibraryParams params = MediaUtils.convertToLibraryParams(
mLibrarySessionImpl.getContext(), request.mExtras);
LibraryResult libraryResult = mLibrarySessionImpl.getCallback()
.onGetSearchResult(mLibrarySessionImpl.getInstance(),
request.mController, request.mQuery, page, pageSize,
params);
if (libraryResult == null
|| libraryResult.getResultCode() != RESULT_SUCCESS) {
request.mResult.sendResult(null);
} else {
request.mResult.sendResult(
MediaUtils.truncateListBySize(
MediaUtils.convertToMediaItemList(
libraryResult.getMediaItems()),
TRANSACTION_SIZE_LIMIT_IN_BYTES));
}
}
}
});
}
void registerSearchRequest(ControllerInfo controller, String query, Bundle extras,
Result<List<MediaBrowserCompat.MediaItem>> result) {
synchronized (mLock) {
mSearchRequests.add(new SearchRequest(controller, controller.getRemoteUserInfo(),
query, extras, result));
}
}
@Override
public int hashCode() {
return ObjectsCompat.hash(mRemoteUserInfo);
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || obj.getClass() != BrowserLegacyCb.class) {
return false;
}
BrowserLegacyCb other = (BrowserLegacyCb) obj;
return ObjectsCompat.equals(mRemoteUserInfo, other.mRemoteUserInfo);
}
}
/**
* Intentionally static class to prevent lint warning 'SynteheticAccessor' in constructor.
*/
private static class BrowserLegacyCbForAll extends BaseBrowserLegacyCb {
private final MediaBrowserServiceCompat mService;
BrowserLegacyCbForAll(MediaBrowserServiceCompat service) {
mService = service;
}
@Override
void onChildrenChanged(int seq, String parentId, int itemCount, LibraryParams libraryParams)
throws RemoteException {
// This will trigger {@link MediaLibraryServiceLegacyStub#onLoadChildren}.
if (libraryParams == null || libraryParams.getExtras() == null) {
mService.notifyChildrenChanged(parentId);
} else {
mService.notifyChildrenChanged(parentId, libraryParams.getExtras());
}
}
@Override
void onSearchResultChanged(int seq, String query, int itemCount, LibraryParams params)
throws RemoteException {
// Shouldn't be called. If it's called, it's bug.
// This method in the base class is introduced to internally send return of
// {@link MediaLibrarySessionCallback#onSearchResultChanged}. However, for
// BrowserCompat, it should be done by {@link Result#sendResult} from
// {@link MediaLibraryServiceLegacyStub#onSearch} instead.
if (DEBUG) {
throw new RuntimeException("Unexpected API call. Use result.sendResult() for"
+ " sending onSearchResultChanged() result instead of this");
}
}
}
}