/*
* 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 androidx.media3.common.util.Assertions.checkStateNotNull;
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 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.TimeUnit;
import java.util.concurrent.TimeoutException;
/**
* Manages media notifications for a {@link MediaSessionService} and sets the service as
* foreground/background according to the player state.
*/
/* package */ final class 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, ListenableFuture<MediaController>> controllerMap;
private final Map<MediaSession, ImmutableList<CommandButton>> customLayoutMap;
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());
controllerMap = new HashMap<>();
customLayoutMap = new HashMap<>();
startedInForeground = false;
}
public void addSession(MediaSession session) {
if (controllerMap.containsKey(session)) {
return;
}
customLayoutMap.put(session, ImmutableList.of());
MediaControllerListener listener =
new MediaControllerListener(mediaSessionService, session, customLayoutMap);
ListenableFuture<MediaController> controllerFuture =
new MediaController.Builder(mediaSessionService, session.getToken())
.setListener(listener)
.setApplicationLooper(Looper.getMainLooper())
.buildAsync();
controllerFuture.addListener(
() -> {
try {
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
listener.onConnected();
controller.addListener(listener);
} catch (CancellationException
| ExecutionException
| InterruptedException
| TimeoutException e) {
// MediaSession or MediaController is released too early. Stop monitoring the session.
mediaSessionService.removeSession(session);
}
},
mainExecutor);
controllerMap.put(session, controllerFuture);
}
public void removeSession(MediaSession session) {
customLayoutMap.remove(session);
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.remove(session);
if (controllerFuture != null) {
MediaController.releaseFuture(controllerFuture);
}
}
public void onCustomAction(MediaSession session, String action, Bundle extras) {
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
if (controllerFuture == null) {
return;
}
try {
MediaController mediaController = checkStateNotNull(Futures.getDone(controllerFuture));
if (!mediaNotificationProvider.handleCustomCommand(session, action, 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(customCommand, 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());
}
}
} catch (ExecutionException e) {
// We should never reach this.
throw new IllegalStateException(e);
}
}
/**
* 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;
MediaNotification.Provider.Callback callback =
notification ->
mainExecutor.execute(
() -> onNotificationUpdated(notificationSequence, session, notification));
MediaNotification mediaNotification =
this.mediaNotificationProvider.createNotification(
session, checkStateNotNull(customLayoutMap.get(session)), actionFactory, callback);
updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
}
public boolean isStartedInForeground() {
return startedInForeground;
}
private void onNotificationUpdated(
int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
if (notificationSequence == totalNotificationCount) {
boolean startInForegroundRequired =
MediaSessionService.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 {
maybeStopForegroundService(/* removeNotifications= */ false);
notificationManagerCompat.notify(
mediaNotification.notificationId, mediaNotification.notification);
}
}
/**
* 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 (MediaSessionService.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 static boolean shouldShowNotification(MediaSession session) {
Player player = session.getPlayer();
return !player.getCurrentTimeline().isEmpty() && player.getPlaybackState() != Player.STATE_IDLE;
}
private static final class MediaControllerListener
implements MediaController.Listener, Player.Listener {
private final MediaSessionService mediaSessionService;
private final MediaSession session;
private final Map<MediaSession, ImmutableList<CommandButton>> customLayoutMap;
public MediaControllerListener(
MediaSessionService mediaSessionService,
MediaSession session,
Map<MediaSession, ImmutableList<CommandButton>> customLayoutMap) {
this.mediaSessionService = mediaSessionService;
this.session = session;
this.customLayoutMap = customLayoutMap;
}
public void onConnected() {
if (shouldShowNotification(session)) {
mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false);
}
}
@Override
public ListenableFuture<SessionResult> onSetCustomLayout(
MediaController controller, List<CommandButton> layout) {
customLayoutMap.put(session, ImmutableList.copyOf(layout));
mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false);
return Futures.immediateFuture(new SessionResult(SessionResult.RESULT_SUCCESS));
}
@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)) {
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 void startForeground(MediaNotification mediaNotification) {
ContextCompat.startForegroundService(mediaSessionService, startSelfIntent);
if (Util.SDK_INT >= 29) {
Api29.startForeground(mediaSessionService, mediaNotification);
} else {
mediaSessionService.startForeground(
mediaNotification.notificationId, mediaNotification.notification);
}
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;
}
@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() {}
}
@RequiresApi(29)
private static class Api29 {
@DoNotInline
public static void startForeground(
MediaSessionService mediaSessionService, MediaNotification mediaNotification) {
try {
// startForeground() will throw if the service's foregroundServiceType is not defined in the
// manifest to include mediaPlayback.
mediaSessionService.startForeground(
mediaNotification.notificationId,
mediaNotification.notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} catch (RuntimeException e) {
Log.e(
TAG,
"The service must be declared with a foregroundServiceType that includes "
+ " mediaPlayback");
throw e;
}
}
private Api29() {}
}
}