MediaNotificationManager.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 android.app.Service.STOP_FOREGROUND_DETACH;
import static android.app.Service.STOP_FOREGROUND_REMOVE;
import static android.view.KeyEvent.KEYCODE_MEDIA_FAST_FORWARD;
import static android.view.KeyEvent.KEYCODE_MEDIA_NEXT;
import static android.view.KeyEvent.KEYCODE_MEDIA_PAUSE;
import static android.view.KeyEvent.KEYCODE_MEDIA_PLAY;
import static android.view.KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE;
import static android.view.KeyEvent.KEYCODE_MEDIA_PREVIOUS;
import static android.view.KeyEvent.KEYCODE_MEDIA_REWIND;
import static android.view.KeyEvent.KEYCODE_MEDIA_STOP;
import static java.util.concurrent.TimeUnit.MILLISECONDS;

import android.annotation.SuppressLint;
import android.app.Notification;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.KeyEvent;
import androidx.annotation.DoNotInline;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import androidx.media3.common.Player;
import androidx.media3.common.util.Log;
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 com.google.common.util.concurrent.MoreExecutors;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeoutException;

/**
 * Manages media notifications for a {@link MediaSessionService} and sets the service as
 * foreground/background according to the player state.
 *
 * <p>All methods must be called on the main thread.
 */
/* package */ final class MediaNotificationManager {

  /* package */ static final String KEY_MEDIA_NOTIFICATION_MANAGER =
      "androidx.media3.session.MediaNotificationManager";
  private static final String TAG = "MediaNtfMng";

  private final MediaSessionService mediaSessionService;
  private final MediaNotification.Provider mediaNotificationProvider;
  private final MediaNotification.ActionFactory actionFactory;
  private final NotificationManagerCompat notificationManagerCompat;
  private final Executor mainExecutor;
  private final Intent startSelfIntent;
  private final Map<MediaSession, ControllerAndListener> controllerAndListenerMap;

  private int totalNotificationCount;
  @Nullable private MediaNotification mediaNotification;
  private boolean startedInForeground;

  public MediaNotificationManager(
      MediaSessionService mediaSessionService,
      MediaNotification.Provider mediaNotificationProvider,
      MediaNotification.ActionFactory actionFactory) {
    this.mediaSessionService = mediaSessionService;
    this.mediaNotificationProvider = mediaNotificationProvider;
    this.actionFactory = actionFactory;
    notificationManagerCompat = NotificationManagerCompat.from(mediaSessionService);
    Handler mainHandler = new Handler(Looper.getMainLooper());
    mainExecutor = (runnable) -> Util.postOrRun(mainHandler, runnable);
    startSelfIntent = new Intent(mediaSessionService, mediaSessionService.getClass());
    controllerAndListenerMap = new HashMap<>();
    startedInForeground = false;
  }

  public void addSession(MediaSession session) {
    if (controllerAndListenerMap.containsKey(session)) {
      return;
    }
    MediaControllerListener controllerListener =
        new MediaControllerListener(mediaSessionService, session);
    PlayerListener playerListener = new PlayerListener(mediaSessionService, session);
    Bundle connectionHints = new Bundle();
    connectionHints.putBoolean(KEY_MEDIA_NOTIFICATION_MANAGER, true);
    ListenableFuture<MediaController> controllerFuture =
        new MediaController.Builder(mediaSessionService, session.getToken())
            .setConnectionHints(connectionHints)
            .setListener(controllerListener)
            .setApplicationLooper(Looper.getMainLooper())
            .buildAsync();
    controllerAndListenerMap.put(
        session, new ControllerAndListener(controllerFuture, playerListener));
    controllerFuture.addListener(
        () -> {
          try {
            // Assert connection success.
            controllerFuture.get(/* time= */ 0, MILLISECONDS);
            controllerListener.onConnected(shouldShowNotification(session));
            session.getImpl().addPlayerListener(playerListener);
          } catch (CancellationException
              | ExecutionException
              | InterruptedException
              | TimeoutException e) {
            // MediaSession or MediaController is released too early. Stop monitoring the session.
            mediaSessionService.removeSession(session);
          }
        },
        mainExecutor);
  }

  public void removeSession(MediaSession session) {
    ControllerAndListener controllerAndListener = controllerAndListenerMap.remove(session);
    if (controllerAndListener != null) {
      session.getImpl().removePlayerListener(controllerAndListener.listener);
      MediaController.releaseFuture(controllerAndListener.controller);
    }
  }

  public void onMediaButtonEvent(MediaSession session, KeyEvent keyEvent) {
    int keyCode = keyEvent.getKeyCode();
    @Nullable MediaController mediaController = getConnectedControllerForSession(session);
    if (mediaController == null) {
      session.getSessionCompat().getController().dispatchMediaButtonEvent(keyEvent);
      return;
    }
    switch (keyCode) {
      case KEYCODE_MEDIA_PLAY_PAUSE:
        if (mediaController.getPlayWhenReady()) {
          mediaController.pause();
        } else {
          mediaController.play();
        }
        break;
      case KEYCODE_MEDIA_PLAY:
        mediaController.play();
        break;
      case KEYCODE_MEDIA_PAUSE:
        mediaController.pause();
        break;
      case KEYCODE_MEDIA_NEXT:
        mediaController.seekToNext();
        break;
      case KEYCODE_MEDIA_PREVIOUS:
        mediaController.seekToPrevious();
        break;
      case KEYCODE_MEDIA_FAST_FORWARD:
        mediaController.seekForward();
        break;
      case KEYCODE_MEDIA_REWIND:
        mediaController.seekBack();
        break;
      case KEYCODE_MEDIA_STOP:
        mediaController.stop();
        break;
      default:
        Log.w(TAG, "Received media button event with unsupported key code: " + keyCode);
        break;
    }
  }

  public void onCustomAction(MediaSession session, String action, Bundle extras) {
    @Nullable MediaController mediaController = getConnectedControllerForSession(session);
    if (mediaController == null) {
      return;
    }
    // Let the notification provider handle the command first before forwarding it directly.
    Util.postOrRun(
        new Handler(session.getPlayer().getApplicationLooper()),
        () -> {
          if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) {
            mainExecutor.execute(
                () -> sendCustomCommandIfCommandIsAvailable(mediaController, action, extras));
          }
        });
  }

  /**
   * Updates the notification.
   *
   * @param session A session that needs notification update.
   * @param startInForegroundRequired Whether the service is required to start in the foreground.
   */
  public void updateNotification(MediaSession session, boolean startInForegroundRequired) {
    if (!mediaSessionService.isSessionAdded(session) || !shouldShowNotification(session)) {
      maybeStopForegroundService(/* removeNotifications= */ true);
      return;
    }

    int notificationSequence = ++totalNotificationCount;
    MediaController mediaNotificationController = null;
    ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session);
    if (controllerAndListener != null && controllerAndListener.controller.isDone()) {
      try {
        mediaNotificationController = Futures.getDone(controllerAndListener.controller);
      } catch (ExecutionException e) {
        // Ignore.
      }
    }
    ImmutableList<CommandButton> customLayout =
        mediaNotificationController != null
            ? mediaNotificationController.getCustomLayout()
            : ImmutableList.of();
    MediaNotification.Provider.Callback callback =
        notification ->
            mainExecutor.execute(
                () -> onNotificationUpdated(notificationSequence, session, notification));
    Util.postOrRun(
        new Handler(session.getPlayer().getApplicationLooper()),
        () -> {
          MediaNotification mediaNotification =
              this.mediaNotificationProvider.createNotification(
                  session, customLayout, actionFactory, callback);
          mainExecutor.execute(
              () ->
                  updateNotificationInternal(
                      session, mediaNotification, startInForegroundRequired));
        });
  }

  public boolean isStartedInForeground() {
    return startedInForeground;
  }

  /* package */ boolean shouldRunInForeground(
      MediaSession session, boolean startInForegroundWhenPaused) {
    @Nullable MediaController controller = getConnectedControllerForSession(session);
    return controller != null
        && (controller.getPlayWhenReady() || startInForegroundWhenPaused)
        && (controller.getPlaybackState() == Player.STATE_READY
            || controller.getPlaybackState() == Player.STATE_BUFFERING);
  }

  private void onNotificationUpdated(
      int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
    if (notificationSequence == totalNotificationCount) {
      boolean startInForegroundRequired =
          shouldRunInForeground(session, /* startInForegroundWhenPaused= */ false);
      updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
    }
  }

  private void updateNotificationInternal(
      MediaSession session,
      MediaNotification mediaNotification,
      boolean startInForegroundRequired) {
    if (Util.SDK_INT >= 21) {
      // Call Notification.MediaStyle#setMediaSession() indirectly.
      android.media.session.MediaSession.Token fwkToken =
          (android.media.session.MediaSession.Token)
              session.getSessionCompat().getSessionToken().getToken();
      mediaNotification.notification.extras.putParcelable(
          Notification.EXTRA_MEDIA_SESSION, fwkToken);
    }
    this.mediaNotification = mediaNotification;
    if (startInForegroundRequired) {
      startForeground(mediaNotification);
    } else {
      // Notification manager has to be updated first to avoid missing updates
      // (https://github.com/androidx/media/issues/192).
      notificationManagerCompat.notify(
          mediaNotification.notificationId, mediaNotification.notification);
      maybeStopForegroundService(/* removeNotifications= */ false);
    }
  }

  /**
   * Stops the service from the foreground, if no player is actively playing content.
   *
   * @param removeNotifications Whether to remove notifications, if the service is stopped from the
   *     foreground.
   */
  private void maybeStopForegroundService(boolean removeNotifications) {
    List<MediaSession> sessions = mediaSessionService.getSessions();
    for (int i = 0; i < sessions.size(); i++) {
      if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
        return;
      }
    }
    stopForeground(removeNotifications);
    if (removeNotifications && mediaNotification != null) {
      notificationManagerCompat.cancel(mediaNotification.notificationId);
      // Update the notification count so that if a pending notification callback arrives (e.g., a
      // bitmap is loaded), we don't show the notification.
      totalNotificationCount++;
      mediaNotification = null;
    }
  }

  private boolean shouldShowNotification(MediaSession session) {
    MediaController controller = getConnectedControllerForSession(session);
    return controller != null
        && !controller.getCurrentTimeline().isEmpty()
        && controller.getPlaybackState() != Player.STATE_IDLE;
  }

  @Nullable
  private MediaController getConnectedControllerForSession(MediaSession session) {
    ControllerAndListener controllerAndListener = controllerAndListenerMap.get(session);
    if (controllerAndListener == null) {
      return null;
    }
    try {
      return Futures.getDone(controllerAndListener.controller);
    } catch (ExecutionException exception) {
      // We should never reach this.
      throw new IllegalStateException(exception);
    }
  }

  private void sendCustomCommandIfCommandIsAvailable(
      MediaController mediaController, String action, Bundle extras) {
    @Nullable SessionCommand customCommand = null;
    for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) {
      if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
          && command.customAction.equals(action)) {
        customCommand = command;
        break;
      }
    }
    if (customCommand != null
        && mediaController.getAvailableSessionCommands().contains(customCommand)) {
      ListenableFuture<SessionResult> future =
          mediaController.sendCustomCommand(
              new SessionCommand(action, extras), /* args= */ Bundle.EMPTY);
      Futures.addCallback(
          future,
          new FutureCallback<SessionResult>() {
            @Override
            public void onSuccess(SessionResult result) {
              // Do nothing.
            }

            @Override
            public void onFailure(Throwable t) {
              Log.w(TAG, "custom command " + action + " produced an error: " + t.getMessage(), t);
            }
          },
          MoreExecutors.directExecutor());
    }
  }

  private static final class MediaControllerListener implements MediaController.Listener {
    private final MediaSessionService mediaSessionService;
    private final MediaSession session;

    public MediaControllerListener(MediaSessionService mediaSessionService, MediaSession session) {
      this.mediaSessionService = mediaSessionService;
      this.session = session;
    }

    public void onConnected(boolean shouldShowNotification) {
      if (shouldShowNotification) {
        mediaSessionService.onUpdateNotificationInternal(
            session, /* startInForegroundWhenPaused= */ false);
      }
    }

    @Override
    public void onCustomLayoutChanged(MediaController controller, List<CommandButton> layout) {
      mediaSessionService.onUpdateNotificationInternal(
          session, /* startInForegroundWhenPaused= */ false);
    }

    @Override
    public void onAvailableSessionCommandsChanged(
        MediaController controller, SessionCommands commands) {
      mediaSessionService.onUpdateNotificationInternal(
          session, /* startInForegroundWhenPaused= */ false);
    }

    @Override
    public void onDisconnected(MediaController controller) {
      mediaSessionService.removeSession(session);
      // We may need to hide the notification.
      mediaSessionService.onUpdateNotificationInternal(
          session, /* startInForegroundWhenPaused= */ false);
    }
  }

  private static class PlayerListener implements Player.Listener {
    private final MediaSessionService mediaSessionService;
    private final MediaSession session;
    private final Handler mainHandler;

    public PlayerListener(MediaSessionService mediaSessionService, MediaSession session) {
      this.mediaSessionService = mediaSessionService;
      this.session = session;
      mainHandler = new Handler(Looper.getMainLooper());
    }

    @Override
    public void onEvents(Player player, Player.Events events) {
      // We must limit the frequency of notification updates, otherwise the system may suppress
      // them.
      if (events.containsAny(
          Player.EVENT_PLAYBACK_STATE_CHANGED,
          Player.EVENT_PLAY_WHEN_READY_CHANGED,
          Player.EVENT_MEDIA_METADATA_CHANGED,
          Player.EVENT_TIMELINE_CHANGED)) {
        // onUpdateNotificationInternal is required to be called on the main thread and the
        // application thread of the player may be a different thread.
        Util.postOrRun(
            mainHandler,
            () ->
                mediaSessionService.onUpdateNotificationInternal(
                    session, /* startInForegroundWhenPaused= */ false));
      }
    }
  }

  @SuppressLint("InlinedApi") // Using compile time constant FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
  private void startForeground(MediaNotification mediaNotification) {
    ContextCompat.startForegroundService(mediaSessionService, startSelfIntent);
    Util.setForegroundServiceNotification(
        mediaSessionService,
        mediaNotification.notificationId,
        mediaNotification.notification,
        ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
        "mediaPlayback");
    startedInForeground = true;
  }

  private void stopForeground(boolean removeNotifications) {
    // To hide the notification on all API levels, we need to call both Service.stopForeground(true)
    // and notificationManagerCompat.cancel(notificationId).
    if (Util.SDK_INT >= 24) {
      Api24.stopForeground(mediaSessionService, removeNotifications);
    } else {
      // For pre-L devices, we must call Service.stopForeground(true) anyway as a workaround
      // that prevents the media notification from being undismissable.
      mediaSessionService.stopForeground(removeNotifications || Util.SDK_INT < 21);
    }
    startedInForeground = false;
  }

  private static class ControllerAndListener {
    public final ListenableFuture<MediaController> controller;
    public final Player.Listener listener;

    private ControllerAndListener(
        ListenableFuture<MediaController> controller, Player.Listener listener) {
      this.controller = controller;
      this.listener = listener;
    }
  }

  @RequiresApi(24)
  private static class Api24 {

    @DoNotInline
    public static void stopForeground(MediaSessionService service, boolean removeNotification) {
      service.stopForeground(removeNotification ? STOP_FOREGROUND_REMOVE : STOP_FOREGROUND_DETACH);
    }

    private Api24() {}
  }
}