MediaNotificationHandler.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 android.support.v4.media.session.PlaybackStateCompat.ACTION_PAUSE;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_STOP;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.support.v4.media.session.PlaybackStateCompat;
import android.view.KeyEvent;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import androidx.media.app.NotificationCompat.MediaStyle;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.util.Util;
import java.util.List;
import java.util.WeakHashMap;

/**
 * Class to provide default media notification for {@link MediaSessionService}, and set the service
 * as foreground/background according to the player state.
 */
/* package */ class MediaNotificationHandler
    implements MediaSession.ForegroundServiceEventCallback {

  private static final int NOTIFICATION_ID = 1001;
  private static final String NOTIFICATION_CHANNEL_ID = "default_channel_id";

  private final Object lock;
  private final MediaSessionService service;
  private final NotificationManager notificationManager;
  private final String notificationChannelName;

  private final Intent startSelfIntent;
  private final NotificationCompat.Action playAction;
  private final NotificationCompat.Action pauseAction;
  private final NotificationCompat.Action skipToPrevAction;
  private final NotificationCompat.Action skipToNextAction;

  @GuardedBy("lock")
  private final WeakHashMap<MediaSession, PlayerInfo> playerInfoMap;

  public MediaNotificationHandler(MediaSessionService service) {
    lock = new Object();
    this.service = service;
    startSelfIntent = new Intent(service, service.getClass());

    notificationManager =
        (NotificationManager) service.getSystemService(Context.NOTIFICATION_SERVICE);
    notificationChannelName =
        service.getResources().getString(R.string.default_notification_channel_name);

    playAction =
        createNotificationAction(
            service,
            R.drawable.media_session_service_notification_ic_play,
            R.string.play_button_content_description,
            ACTION_PLAY);
    pauseAction =
        createNotificationAction(
            service,
            R.drawable.media_session_service_notification_ic_pause,
            R.string.pause_button_content_description,
            ACTION_PAUSE);
    skipToPrevAction =
        createNotificationAction(
            service,
            R.drawable.media_session_service_notification_ic_skip_to_previous,
            R.string.skip_to_previous_item_button_content_description,
            ACTION_SKIP_TO_PREVIOUS);
    skipToNextAction =
        createNotificationAction(
            service,
            R.drawable.media_session_service_notification_ic_skip_to_next,
            R.string.skip_to_next_item_button_content_description,
            ACTION_SKIP_TO_NEXT);

    playerInfoMap = new WeakHashMap<>();
  }

  private void updateNotificationIfNeeded(MediaSession session) {
    @Nullable
    MediaSessionService.MediaNotification mediaNotification = service.onUpdateNotification(session);
    if (mediaNotification == null) {
      // The service implementation doesn't want to use the automatic start/stopForeground
      // feature.
      return;
    }

    int id = mediaNotification.notificationId;
    Notification notification = mediaNotification.notification;

    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();
      notification.extras.putParcelable(Notification.EXTRA_MEDIA_SESSION, fwkToken);
    }

    PlayerInfo playerInfo = getPlayerInfoOfSession(session);
    if (playerInfo.playWhenReady) {
      ContextCompat.startForegroundService(service, startSelfIntent);
      service.startForeground(id, notification);
    } else {
      stopForegroundServiceIfNeeded();
      notificationManager.notify(id, notification);
    }
  }

  @Override
  public void onPlayerInfoChanged(
      MediaSession session, PlayerInfo oldPlayerInfo, PlayerInfo newPlayerInfo) {
    synchronized (lock) {
      playerInfoMap.put(session, newPlayerInfo);
    }
    if (Util.areEqual(oldPlayerInfo.mediaMetadata, newPlayerInfo.mediaMetadata)
        && oldPlayerInfo.playWhenReady == newPlayerInfo.playWhenReady) {
      return;
    }
    updateNotificationIfNeeded(session);
  }

  @Override
  public void onSessionReleased(MediaSession session) {
    service.removeSession(session);
    stopForegroundServiceIfNeeded();
  }

  private void stopForegroundServiceIfNeeded() {
    List<MediaSession> sessions = service.getSessions();
    for (int i = 0; i < sessions.size(); i++) {
      PlayerInfo playerInfo = getPlayerInfoOfSession(sessions.get(i));
      if (playerInfo.playWhenReady) {
        return;
      }
    }
    // Calling stopForeground(true) is a workaround for pre-L devices which prevents
    // the media notification from being undismissable.
    boolean shouldRemoveNotification = Util.SDK_INT < 21;
    service.stopForeground(shouldRemoveNotification);
  }

  /** Creates a default media style notification for {@link MediaSessionService}. */
  public MediaSessionService.MediaNotification onUpdateNotification(MediaSession session) {
    PlayerInfo playerInfo = getPlayerInfoOfSession(session);

    ensureNotificationChannel();

    NotificationCompat.Builder builder =
        new NotificationCompat.Builder(service, NOTIFICATION_CHANNEL_ID);

    // TODO(b/193193926): Filter actions depending on the player's available commands.
    builder.addAction(skipToPrevAction);
    if (playerInfo.playWhenReady) {
      builder.addAction(pauseAction);
    } else {
      builder.addAction(playAction);
    }
    builder.addAction(skipToNextAction);

    // Set metadata info in the notification.
    MediaMetadata metadata = playerInfo.mediaMetadata;
    builder.setContentTitle(metadata.title).setContentText(metadata.artist);
    if (metadata.artworkData != null) {
      Bitmap artworkBitmap =
          BitmapFactory.decodeByteArray(metadata.artworkData, 0, metadata.artworkData.length);
      builder.setLargeIcon(artworkBitmap);
    }

    MediaStyle mediaStyle =
        new MediaStyle()
            .setCancelButtonIntent(createPendingIntent(service, ACTION_STOP))
            .setMediaSession(session.getSessionCompat().getSessionToken())
            .setShowActionsInCompactView(1 /* Show play/pause button only in compact view */);

    Notification notification =
        builder
            .setContentIntent(session.getImpl().getSessionActivity())
            .setDeleteIntent(createPendingIntent(service, ACTION_STOP))
            .setOnlyAlertOnce(true)
            .setSmallIcon(getSmallIconResId())
            .setStyle(mediaStyle)
            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
            .setOngoing(false)
            .build();

    return new MediaSessionService.MediaNotification(NOTIFICATION_ID, notification);
  }

  private void ensureNotificationChannel() {
    if (Util.SDK_INT < 26
        || notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) {
      return;
    }
    // Need to create a notification channel.
    NotificationChannel channel =
        new NotificationChannel(
            NOTIFICATION_CHANNEL_ID, notificationChannelName, NotificationManager.IMPORTANCE_LOW);
    notificationManager.createNotificationChannel(channel);
  }

  private int getSmallIconResId() {
    int appIcon = service.getApplicationInfo().icon;
    if (appIcon != 0) {
      return appIcon;
    } else {
      // App icon is not set.
      return R.drawable.media_session_service_notification_ic_music_note;
    }
  }

  private PlayerInfo getPlayerInfoOfSession(MediaSession session) {
    @Nullable PlayerInfo playerInfo;
    synchronized (lock) {
      playerInfo = playerInfoMap.get(session);
    }
    return playerInfo == null ? PlayerInfo.DEFAULT : playerInfo;
  }

  private static NotificationCompat.Action createNotificationAction(
      MediaSessionService service,
      int iconResId,
      int titleResId,
      @PlaybackStateCompat.Actions long action) {
    CharSequence title = service.getResources().getText(titleResId);
    return new NotificationCompat.Action(iconResId, title, createPendingIntent(service, action));
  }

  private static PendingIntent createPendingIntent(
      MediaSessionService service, @PlaybackStateCompat.Actions long action) {
    int keyCode = PlaybackStateCompat.toKeyCode(action);
    Intent intent = new Intent(Intent.ACTION_MEDIA_BUTTON);
    intent.setComponent(new ComponentName(service, service.getClass()));
    intent.putExtra(Intent.EXTRA_KEY_EVENT, new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));

    if (Util.SDK_INT >= 26 && action != ACTION_PAUSE && action != ACTION_STOP) {
      return PendingIntent.getForegroundService(
          service, /* requestCode= */ keyCode, intent, PendingIntent.FLAG_IMMUTABLE);
    } else {
      return PendingIntent.getService(
          service,
          /* requestCode= */ keyCode,
          intent,
          Util.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0);
    }
  }
}