MediaLibrarySessionImplBase.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.media3.session;

import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.session.LibraryResult.RESULT_SUCCESS;

import android.app.PendingIntent;
import android.content.Context;
import android.os.Bundle;
import android.os.RemoteException;
import android.support.v4.media.session.MediaSessionCompat;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
import androidx.collection.ArrayMap;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Player;
import androidx.media3.common.util.Log;
import androidx.media3.session.MediaLibraryService.LibraryParams;
import androidx.media3.session.MediaLibraryService.MediaLibrarySession;
import androidx.media3.session.MediaSession.ControllerCb;
import androidx.media3.session.MediaSession.ControllerInfo;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;

/* package */ class MediaLibrarySessionImplBase extends MediaSessionImplBase
    implements MediaLibrarySession.MediaLibrarySessionImpl {

  @GuardedBy("lock")
  private final ArrayMap<ControllerCb, Set<String>> subscriptions = new ArrayMap<>();

  public MediaLibrarySessionImplBase(
      MediaSession instance,
      Context context,
      String id,
      Player player,
      @Nullable PendingIntent sessionActivity,
      MediaSession.SessionCallback callback,
      MediaSession.MediaItemFiller mediaItemFiller,
      Bundle tokenExtras) {
    super(instance, context, id, player, sessionActivity, callback, mediaItemFiller, tokenExtras);
  }

  @Override
  public MediaSessionServiceLegacyStub createLegacyBrowserService(
      MediaSessionCompat.Token compatToken) {
    MediaLibraryServiceLegacyStub stub = new MediaLibraryServiceLegacyStub(this);
    stub.initialize(compatToken);
    return stub;
  }

  @Override
  public MediaLibrarySession getInstance() {
    return (MediaLibrarySession) super.getInstance();
  }

  @Override
  public MediaLibrarySession.MediaLibrarySessionCallback getCallback() {
    return (MediaLibrarySession.MediaLibrarySessionCallback) super.getCallback();
  }

  @Override
  @Nullable
  protected MediaLibraryServiceLegacyStub getLegacyBrowserService() {
    return (MediaLibraryServiceLegacyStub) super.getLegacyBrowserService();
  }

  @Override
  public List<ControllerInfo> getConnectedControllers() {
    List<ControllerInfo> list = super.getConnectedControllers();
    @Nullable MediaLibraryServiceLegacyStub legacyStub = getLegacyBrowserService();
    if (legacyStub != null) {
      list.addAll(legacyStub.getConnectedControllersManager().getConnectedControllers());
    }
    return list;
  }

  @Override
  public boolean isConnected(ControllerInfo controller) {
    if (super.isConnected(controller)) {
      return true;
    }
    @Nullable MediaLibraryServiceLegacyStub legacyStub = getLegacyBrowserService();
    return legacyStub != null
        && legacyStub.getConnectedControllersManager().isConnected(controller);
  }

  @Override
  public void notifyChildrenChanged(
      String parentId, int itemCount, @Nullable LibraryParams params) {
    dispatchRemoteControllerTaskWithoutReturn(
        new RemoteControllerTask() {
          @Override
          public void run(ControllerCb callback, int seq) throws RemoteException {
            if (isSubscribed(callback, parentId)) {
              callback.onChildrenChanged(seq, parentId, itemCount, params);
            }
          }
        });
  }

  @Override
  public void notifyChildrenChanged(
      ControllerInfo browser, String parentId, int itemCount, @Nullable LibraryParams params) {
    dispatchRemoteControllerTaskWithoutReturn(
        browser,
        new RemoteControllerTask() {
          @Override
          public void run(ControllerCb callback, int seq) throws RemoteException {
            if (!isSubscribed(callback, parentId)) {
              return;
            }
            callback.onChildrenChanged(seq, parentId, itemCount, params);
          }
        });
  }

  @Override
  public void notifySearchResultChanged(
      ControllerInfo browser, String query, int itemCount, @Nullable LibraryParams params) {
    dispatchRemoteControllerTaskWithoutReturn(
        browser,
        new RemoteControllerTask() {
          @Override
          public void run(ControllerCb callback, int seq) throws RemoteException {
            callback.onSearchResultChanged(seq, query, itemCount, params);
          }
        });
  }

  private static void verifyResultItems(
      LibraryResult<ImmutableList<MediaItem>> result, int pageSize) {
    if (result.resultCode == RESULT_SUCCESS) {
      List<MediaItem> items = checkNotNull(result.value);
      if (items.size() > pageSize) {
        throw new AssertionError(
            "The number of items must be less than or equal to the pageSize"
                + ", size="
                + items.size()
                + ", pageSize="
                + pageSize);
      }
    }
  }

  /** Called by {@link MediaSessionStub#getLibraryRoot(IMediaController, int, Bundle)}. */
  @Override
  public ListenableFuture<LibraryResult<MediaItem>> onGetLibraryRootOnHandler(
      ControllerInfo browser, @Nullable LibraryParams params) {
    // onGetLibraryRoot is defined to return a non-null result but it's implemented by applications,
    // so we explicitly null-check the result to fail early if an app accidentally returns null.
    return checkNotNull(
        getCallback().onGetLibraryRoot(getInstance(), browser, params),
        "onGetLibraryRoot must return non-null future");
  }

  /**
   * Called by {@link MediaSessionStub#getItem(IMediaController, int, String)}.
   *
   * @return
   */
  @Override
  public ListenableFuture<LibraryResult<MediaItem>> onGetItemOnHandler(
      ControllerInfo browser, String mediaId) {
    // onGetItem is defined to return a non-null result but it's implemented by applications,
    // so we explicitly null-check the result to fail early if an app accidentally returns null.
    return checkNotNull(
        getCallback().onGetItem(getInstance(), browser, mediaId),
        "onGetItem must return non-null future");
  }

  @Override
  public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> onGetChildrenOnHandler(
      ControllerInfo browser,
      String parentId,
      int page,
      int pageSize,
      @Nullable LibraryParams params) {
    // onGetChildren is defined to return a non-null result but it's implemented by applications,
    // so we explicitly null-check the result to fail early if an app accidentally returns null.
    ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> future =
        checkNotNull(
            getCallback().onGetChildren(getInstance(), browser, parentId, page, pageSize, params),
            "onGetChildren must return non-null future");
    future.addListener(
        () -> {
          @Nullable LibraryResult<ImmutableList<MediaItem>> result = tryGetFutureResult(future);
          if (result != null) {
            verifyResultItems(result, pageSize);
          }
        },
        MoreExecutors.directExecutor());
    return future;
  }

  @Override
  public ListenableFuture<LibraryResult<Void>> onSubscribeOnHandler(
      ControllerInfo browser, String parentId, @Nullable LibraryParams params) {
    ControllerCb controller = checkStateNotNull(browser.getControllerCb());
    synchronized (lock) {
      @Nullable Set<String> subscription = subscriptions.get(controller);
      if (subscription == null) {
        subscription = new HashSet<>();
        subscriptions.put(controller, subscription);
      }
      subscription.add(parentId);
    }

    // Call callbacks after adding it to the subscription list because library session may want
    // to call notifyChildrenChanged() in the callback.
    //
    // onSubscribe is defined to return a non-null result but it's implemented by applications,
    // so we explicitly null-check the result to fail early if an app accidentally returns null.
    ListenableFuture<LibraryResult<Void>> future =
        checkNotNull(
            getCallback().onSubscribe(getInstance(), browser, parentId, params),
            "onSubscribe must return non-null future");

    // When error happens, remove from the subscription list.
    future.addListener(
        () -> {
          @Nullable LibraryResult<Void> result = tryGetFutureResult(future);
          if (result == null || result.resultCode != RESULT_SUCCESS) {
            synchronized (lock) {
              subscriptions.remove(checkStateNotNull(browser.getControllerCb()));
            }
          }
        },
        MoreExecutors.directExecutor());

    return future;
  }

  @Override
  public ListenableFuture<LibraryResult<Void>> onUnsubscribeOnHandler(
      ControllerInfo browser, String parentId) {
    // onUnsubscribe is defined to return a non-null result but it's implemented by applications,
    // so we explicitly null-check the result to fail early if an app accidentally returns null.
    ListenableFuture<LibraryResult<Void>> future =
        checkNotNull(
            getCallback().onUnsubscribe(getInstance(), browser, parentId),
            "onUnsubscribe must return non-null future");

    future.addListener(
        () -> {
          synchronized (lock) {
            subscriptions.remove(checkStateNotNull(browser.getControllerCb()));
          }
        },
        MoreExecutors.directExecutor());

    return future;
  }

  @Override
  public ListenableFuture<LibraryResult<Void>> onSearchOnHandler(
      ControllerInfo browser, String query, @Nullable LibraryParams params) {
    // onSearch is defined to return a non-null result but it's implemented by applications,
    // so we explicitly null-check the result to fail early if an app accidentally returns null.
    return checkNotNull(
        getCallback().onSearch(getInstance(), browser, query, params),
        "onSearch must return non-null future");
  }

  @Override
  public ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> onGetSearchResultOnHandler(
      ControllerInfo browser,
      String query,
      int page,
      int pageSize,
      @Nullable LibraryParams params) {
    // onGetSearchResult is defined to return a non-null result but it's implemented by
    // applications, so we explicitly null-check the result to fail early if an app accidentally
    // returns null.
    ListenableFuture<LibraryResult<ImmutableList<MediaItem>>> future =
        checkNotNull(
            getCallback().onGetSearchResult(getInstance(), browser, query, page, pageSize, params),
            "onGetSearchResult must return non-null future");
    future.addListener(
        () -> {
          @Nullable LibraryResult<ImmutableList<MediaItem>> result = tryGetFutureResult(future);
          if (result != null) {
            verifyResultItems(result, pageSize);
          }
        },
        MoreExecutors.directExecutor());
    return future;
  }

  @Override
  protected void dispatchRemoteControllerTaskWithoutReturn(RemoteControllerTask task) {
    super.dispatchRemoteControllerTaskWithoutReturn(task);
    @Nullable MediaLibraryServiceLegacyStub legacyStub = getLegacyBrowserService();
    if (legacyStub != null) {
      try {
        task.run(legacyStub.getBrowserLegacyCbForBroadcast(), /* seq= */ 0);
      } catch (RemoteException e) {
        Log.e(TAG, "Exception in using media1 API", e);
      }
    }
  }

  private boolean isSubscribed(ControllerCb callback, String parentId) {
    synchronized (lock) {
      @Nullable Set<String> subscriptions = this.subscriptions.get(callback);
      if (subscriptions == null || !subscriptions.contains(parentId)) {
        return false;
      }
    }
    return true;
  }

  @Nullable
  private static <T> T tryGetFutureResult(Future<T> future) {
    checkState(future.isDone());
    try {
      return future.get();
    } catch (CancellationException | ExecutionException | InterruptedException unused) {
      return null;
    }
  }
}