DefaultMediaNotificationProvider.java

/*
 * Copyright 2022 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.C.INDEX_UNSET;
import static androidx.media3.common.Player.COMMAND_INVALID;
import static androidx.media3.common.Player.COMMAND_PLAY_PAUSE;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS;
import static androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM;
import static androidx.media3.common.Player.COMMAND_STOP;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.common.util.Util.castNonNull;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.graphics.drawable.IconCompat;
import androidx.media.app.NotificationCompat.MediaStyle;
import androidx.media3.common.C;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.util.Consumer;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutionException;

/**
 * The default {@link MediaNotification.Provider}.
 *
 * <h2>Actions</h2>
 *
 * The following actions are included in the provided notifications:
 *
 * <ul>
 *   <li>{@link MediaController#COMMAND_PLAY_PAUSE} to start or pause playback.
 *   <li>{@link MediaController#COMMAND_SEEK_TO_PREVIOUS} to seek to the previous item.
 *   <li>{@link MediaController#COMMAND_SEEK_TO_NEXT} to seek to the next item.
 * </ul>
 *
 * <h2>Custom commands</h2>
 *
 * Custom actions are sent to the session under the hood. You can receive them by overriding the
 * session callback method {@link MediaSession.Callback#onCustomCommand(MediaSession,
 * MediaSession.ControllerInfo, SessionCommand, Bundle)}. This is useful because starting with
 * Android 13, the System UI notification sends commands directly to the session. So handling the
 * custom commands on the session level allows you to handle them at the same callback for all API
 * levels.
 *
 * <h2>Drawables</h2>
 *
 * The drawables used can be overridden by drawables with the same names defined the application.
 * The drawables are:
 *
 * <ul>
 *   <li><b>{@code media3_notification_play}</b> - The play icon.
 *   <li><b>{@code media3_notification_pause}</b> - The pause icon.
 *   <li><b>{@code media3_notification_seek_to_previous}</b> - The previous icon.
 *   <li><b>{@code media3_notification_seek_to_next}</b> - The next icon.
 *   <li><b>{@code media3_notification_small_icon}</b> - The {@link
 *       NotificationCompat.Builder#setSmallIcon(int) small icon}.
 * </ul>
 */
@UnstableApi
public class DefaultMediaNotificationProvider implements MediaNotification.Provider {

  /**
   * An extras key that can be used to define the index of a {@link CommandButton} in {@linkplain
   * Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}.
   */
  public static final String COMMAND_KEY_COMPACT_VIEW_INDEX =
      "androidx.media3.session.command.COMPACT_VIEW_INDEX";

  private static final String TAG = "NotificationProvider";
  private static final int NOTIFICATION_ID = 1001;
  private static final String NOTIFICATION_CHANNEL_ID = "default_channel_id";
  private static final String NOTIFICATION_CHANNEL_NAME = "Now playing";

  private final Context context;
  private final NotificationManager notificationManager;
  private final BitmapLoader bitmapLoader;
  // Cache the last loaded bitmap to avoid reloading the bitmap again, particularly useful when
  // showing a notification for the same item (e.g. when switching from playing to paused).
  private final LoadedBitmapInfo lastLoadedBitmapInfo;
  private final Handler mainHandler;

  private OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;

  /** Creates an instance that uses a {@link SimpleBitmapLoader} for loading artwork images. */
  public DefaultMediaNotificationProvider(Context context) {
    this(context, new SimpleBitmapLoader());
  }

  /** Creates an instance that uses the {@code bitmapLoader} for loading artwork images. */
  public DefaultMediaNotificationProvider(Context context, BitmapLoader bitmapLoader) {
    this.context = context.getApplicationContext();
    this.bitmapLoader = bitmapLoader;
    lastLoadedBitmapInfo = new LoadedBitmapInfo();
    mainHandler = new Handler(Looper.getMainLooper());
    notificationManager =
        checkStateNotNull(
            (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
    pendingOnBitmapLoadedFutureCallback = new OnBitmapLoadedFutureCallback(bitmap -> {});
  }

  @Override
  public final MediaNotification createNotification(
      MediaSession mediaSession,
      ImmutableList<CommandButton> customLayout,
      MediaNotification.ActionFactory actionFactory,
      Callback onNotificationChangedCallback) {
    ensureNotificationChannel();

    Player player = mediaSession.getPlayer();
    NotificationCompat.Builder builder =
        new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID);

    MediaStyle mediaStyle = new MediaStyle();
    int[] compactViewIndices =
        addNotificationActions(
            mediaSession,
            getMediaButtons(player.getAvailableCommands(), customLayout, player.getPlayWhenReady()),
            builder,
            actionFactory);
    mediaStyle.setShowActionsInCompactView(compactViewIndices);

    // Set metadata info in the notification.
    MediaMetadata metadata = player.getMediaMetadata();
    builder.setContentTitle(metadata.title).setContentText(metadata.artist);
    @Nullable ListenableFuture<Bitmap> bitmapFuture = loadArtworkBitmap(metadata);
    if (bitmapFuture != null) {
      if (bitmapFuture.isDone()) {
        try {
          builder.setLargeIcon(Futures.getDone(bitmapFuture));
        } catch (ExecutionException e) {
          Log.w(TAG, "Failed to load bitmap", e);
        }
      } else {
        Futures.addCallback(
            bitmapFuture,
            new OnBitmapLoadedFutureCallback(
                bitmap -> {
                  builder.setLargeIcon(bitmap);
                  onNotificationChangedCallback.onNotificationChanged(
                      new MediaNotification(NOTIFICATION_ID, builder.build()));
                }),
            // This callback must be executed on the next looper iteration, after this method has
            // returned a media notification.
            mainHandler::post);
      }
    }

    if (player.isCommandAvailable(COMMAND_STOP) || Util.SDK_INT < 21) {
      // We must include a cancel intent for pre-L devices.
      mediaStyle.setCancelButtonIntent(
          actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP));
    }

    long playbackStartTimeMs = getPlaybackStartTimeEpochMs(player);
    boolean displayElapsedTimeWithChronometer = playbackStartTimeMs != C.TIME_UNSET;
    builder
        .setWhen(playbackStartTimeMs)
        .setShowWhen(displayElapsedTimeWithChronometer)
        .setUsesChronometer(displayElapsedTimeWithChronometer);

    Notification notification =
        builder
            .setContentIntent(mediaSession.getSessionActivity())
            .setDeleteIntent(
                actionFactory.createMediaActionPendingIntent(mediaSession, COMMAND_STOP))
            .setOnlyAlertOnce(true)
            .setSmallIcon(R.drawable.media3_notification_small_icon)
            .setStyle(mediaStyle)
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
            .setOngoing(false)
            .build();
    return new MediaNotification(NOTIFICATION_ID, notification);
  }

  @Override
  public final boolean handleCustomCommand(MediaSession session, String action, Bundle extras) {
    // Make the custom action being delegated to the session as a custom session command.
    return false;
  }

  /**
   * Returns the ordered list of {@linkplain CommandButton command buttons} to be used to build the
   * notification.
   *
   * <p>This method is called each time a new notification is built.
   *
   * <p>Override this method to customize the buttons on the notification. Commands of the buttons
   * returned by this method must be contained in {@link MediaController#getAvailableCommands()}.
   *
   * <p>By default the notification shows {@link Player#COMMAND_PLAY_PAUSE} in {@linkplain
   * Notification.MediaStyle#setShowActionsInCompactView(int...) compact view}. This can be
   * customized by defining the index of the command in compact view of up to 3 commands in their
   * extras with key {@link DefaultMediaNotificationProvider#COMMAND_KEY_COMPACT_VIEW_INDEX}.
   *
   * <p>To make the custom layout and commands work, you need to {@linkplain
   * MediaSession#setCustomLayout(List) set the custom layout of commands} and add the custom
   * commands to the available commands when a controller {@linkplain
   * MediaSession.Callback#onConnect(MediaSession, MediaSession.ControllerInfo) connects to the
   * session}. Controllers that connect after you called {@link MediaSession#setCustomLayout(List)}
   * need the custom command set in {@link MediaSession.Callback#onPostConnect(MediaSession,
   * MediaSession.ControllerInfo)} also.
   *
   * @param playerCommands The available player commands.
   * @param customLayout The {@linkplain MediaSession#setCustomLayout(List) custom layout of
   *     commands}.
   * @param playWhenReady The current {@code playWhenReady} state.
   * @return The ordered list of command buttons to be placed on the notification.
   */
  protected List<CommandButton> getMediaButtons(
      Player.Commands playerCommands, List<CommandButton> customLayout, boolean playWhenReady) {
    // Skip to previous action.
    List<CommandButton> commandButtons = new ArrayList<>();
    if (playerCommands.containsAny(COMMAND_SEEK_TO_PREVIOUS, COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) {
      Bundle commandButtonExtras = new Bundle();
      commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
      commandButtons.add(
          new CommandButton.Builder()
              .setPlayerCommand(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
              .setIconResId(R.drawable.media3_notification_seek_to_previous)
              .setDisplayName(
                  context.getString(R.string.media3_controls_seek_to_previous_description))
              .setExtras(commandButtonExtras)
              .build());
    }
    if (playerCommands.contains(COMMAND_PLAY_PAUSE)) {
      Bundle commandButtonExtras = new Bundle();
      commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
      commandButtons.add(
          new CommandButton.Builder()
              .setPlayerCommand(COMMAND_PLAY_PAUSE)
              .setIconResId(
                  playWhenReady
                      ? R.drawable.media3_notification_pause
                      : R.drawable.media3_notification_play)
              .setExtras(commandButtonExtras)
              .setDisplayName(
                  playWhenReady
                      ? context.getString(R.string.media3_controls_pause_description)
                      : context.getString(R.string.media3_controls_play_description))
              .build());
    }
    // Skip to next action.
    if (playerCommands.containsAny(COMMAND_SEEK_TO_NEXT, COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) {
      Bundle commandButtonExtras = new Bundle();
      commandButtonExtras.putInt(COMMAND_KEY_COMPACT_VIEW_INDEX, INDEX_UNSET);
      commandButtons.add(
          new CommandButton.Builder()
              .setPlayerCommand(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
              .setIconResId(R.drawable.media3_notification_seek_to_next)
              .setExtras(commandButtonExtras)
              .setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description))
              .build());
    }
    for (int i = 0; i < customLayout.size(); i++) {
      CommandButton button = customLayout.get(i);
      if (button.sessionCommand != null
          && button.sessionCommand.commandCode == SessionCommand.COMMAND_CODE_CUSTOM) {
        commandButtons.add(button);
      }
    }
    return commandButtons;
  }

  /**
   * Adds the media buttons to the notification builder for the given action factory.
   *
   * <p>The list of {@code mediaButtons} is the list resulting from {@link #getMediaButtons(
   * Player.Commands, List, boolean)}.
   *
   * <p>Override this method to customize how the media buttons {@linkplain
   * NotificationCompat.Builder#addAction(NotificationCompat.Action) are added} to the notification
   * and define which actions are shown in compact view by returning the indices of the buttons to
   * be shown in compact view.
   *
   * <p>By default {@link Player#COMMAND_PLAY_PAUSE} is shown in compact view, unless some of the
   * buttons are marked with {@link DefaultMediaNotificationProvider#COMMAND_KEY_COMPACT_VIEW_INDEX}
   * to declare the index in compact view of the given command button in the button extras.
   *
   * @param mediaSession The media session to which the actions will be sent.
   * @param mediaButtons The command buttons to be included in the notification.
   * @param builder The builder to add the actions to.
   * @param actionFactory The actions factory to be used to build notifications.
   * @return The indices of the buttons to be {@linkplain
   *     Notification.MediaStyle#setShowActionsInCompactView(int...) used in compact view of the
   *     notification}.
   */
  protected int[] addNotificationActions(
      MediaSession mediaSession,
      List<CommandButton> mediaButtons,
      NotificationCompat.Builder builder,
      MediaNotification.ActionFactory actionFactory) {
    int[] compactViewIndices = new int[3];
    Arrays.fill(compactViewIndices, INDEX_UNSET);
    int compactViewCommandCount = 0;
    for (int i = 0; i < mediaButtons.size(); i++) {
      CommandButton commandButton = mediaButtons.get(i);
      if (commandButton.sessionCommand != null) {
        builder.addAction(
            actionFactory.createCustomActionFromCustomCommandButton(mediaSession, commandButton));
      } else {
        checkState(commandButton.playerCommand != COMMAND_INVALID);
        builder.addAction(
            actionFactory.createMediaAction(
                mediaSession,
                IconCompat.createWithResource(context, commandButton.iconResId),
                commandButton.displayName,
                commandButton.playerCommand));
      }
      if (compactViewCommandCount == 3) {
        continue;
      }
      int compactViewIndex =
          commandButton.extras.getInt(
              COMMAND_KEY_COMPACT_VIEW_INDEX, /* defaultValue= */ INDEX_UNSET);
      if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.length) {
        compactViewCommandCount++;
        compactViewIndices[compactViewIndex] = i;
      } else if (commandButton.playerCommand == COMMAND_PLAY_PAUSE
          && compactViewCommandCount == 0) {
        // If there is no custom configuration we use the play/pause action in compact view.
        compactViewIndices[0] = i;
      }
    }
    for (int i = 0; i < compactViewIndices.length; i++) {
      if (compactViewIndices[i] == INDEX_UNSET) {
        compactViewIndices = Arrays.copyOf(compactViewIndices, i);
        break;
      }
    }
    return compactViewIndices;
  }

  private void ensureNotificationChannel() {
    if (Util.SDK_INT < 26
        || notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) {
      return;
    }
    NotificationChannel channel =
        new NotificationChannel(
            NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW);
    notificationManager.createNotificationChannel(channel);
  }

  /**
   * Requests from the bitmapLoader to load artwork or returns null if the metadata don't include
   * artwork.
   */
  @Nullable
  private ListenableFuture<Bitmap> loadArtworkBitmap(MediaMetadata metadata) {
    if (lastLoadedBitmapInfo.matches(metadata.artworkData)
        || lastLoadedBitmapInfo.matches(metadata.artworkUri)) {
      return Futures.immediateFuture(lastLoadedBitmapInfo.getBitmap());
    }

    ListenableFuture<Bitmap> future;
    Consumer<Bitmap> onBitmapLoaded;
    if (metadata.artworkData != null) {
      future = bitmapLoader.decodeBitmap(metadata.artworkData);
      onBitmapLoaded =
          bitmap -> lastLoadedBitmapInfo.setBitmapInfo(castNonNull(metadata.artworkData), bitmap);
    } else if (metadata.artworkUri != null) {
      future = bitmapLoader.loadBitmap(metadata.artworkUri);
      onBitmapLoaded =
          bitmap -> lastLoadedBitmapInfo.setBitmapInfo(castNonNull(metadata.artworkUri), bitmap);
    } else {
      return null;
    }

    pendingOnBitmapLoadedFutureCallback.discardIfPending();
    pendingOnBitmapLoadedFutureCallback = new OnBitmapLoadedFutureCallback(onBitmapLoaded);
    Futures.addCallback(
        future,
        pendingOnBitmapLoadedFutureCallback,
        // It's ok to run this immediately to update the last loaded bitmap.
        runnable -> Util.postOrRun(mainHandler, runnable));
    return future;
  }

  private static long getPlaybackStartTimeEpochMs(Player player) {
    // Changing "showWhen" causes notification flicker if SDK_INT < 21.
    if (Util.SDK_INT >= 21
        && player.isPlaying()
        && !player.isPlayingAd()
        && !player.isCurrentMediaItemDynamic()
        && player.getPlaybackParameters().speed == 1f) {
      return System.currentTimeMillis() - player.getContentPosition();
    } else {
      return C.TIME_UNSET;
    }
  }

  private static class OnBitmapLoadedFutureCallback implements FutureCallback<Bitmap> {

    private final Consumer<Bitmap> consumer;

    private boolean discarded;

    private OnBitmapLoadedFutureCallback(Consumer<Bitmap> consumer) {
      this.consumer = consumer;
    }

    public void discardIfPending() {
      discarded = true;
    }

    @Override
    public void onSuccess(Bitmap result) {
      if (!discarded) {
        consumer.accept(result);
      }
    }

    @Override
    public void onFailure(Throwable t) {
      if (!discarded) {
        Log.d(TAG, "Failed to load bitmap", t);
      }
    }
  }

  /**
   * Caches the last loaded bitmap. The key to identify a bitmap is either a byte array, if the
   * bitmap is loaded from compressed data, or a URI, if the bitmap was loaded from a URI.
   */
  private static class LoadedBitmapInfo {
    @Nullable private byte[] data;
    @Nullable private Uri uri;
    @Nullable private Bitmap bitmap;

    public boolean matches(@Nullable byte[] data) {
      return this.data != null && data != null && Arrays.equals(this.data, data);
    }

    public boolean matches(@Nullable Uri uri) {
      return this.uri != null && this.uri.equals(uri);
    }

    public Bitmap getBitmap() {
      return checkStateNotNull(bitmap);
    }

    public void setBitmapInfo(byte[] data, Bitmap bitmap) {
      this.data = data;
      this.bitmap = bitmap;
      this.uri = null;
    }

    public void setBitmapInfo(Uri uri, Bitmap bitmap) {
      this.uri = uri;
      this.bitmap = bitmap;
      this.data = null;
    }
  }
}