Fix thread access when creating notifications for media sessions
The sessions may have different application threads for their players, and the service with its notification provider runs on the main thread. To ensure everything runs on the correct thread, this change labels methods where needed and fixes thread access in some places. Issue: androidx/media#318 PiperOrigin-RevId: 524849598
This commit is contained in:
parent
9081c70788
commit
ffa3743069
@ -54,6 +54,9 @@
|
||||
([#296](https://github.com/androidx/media/issues/296)).
|
||||
* Fix issue where `Player.COMMAND_GET_CURRENT_MEDIA_ITEM` needs to be
|
||||
available to access metadata via the legacy `MediaSessionCompat`.
|
||||
* Fix issue where `MediaSession` instances on a background thread cause
|
||||
crashes when used in `MediaSessionService`
|
||||
([#318](https://github.com/androidx/media/issues/318)).
|
||||
* Audio:
|
||||
* Fix bug where some playbacks fail when tunneling is enabled and
|
||||
`AudioProcessors` are active, e.g. for gapless trimming
|
||||
|
@ -43,6 +43,7 @@ dependencies {
|
||||
androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion
|
||||
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
|
||||
testImplementation project(modulePrefix + 'test-utils')
|
||||
testImplementation project(modulePrefix + 'test-utils-robolectric')
|
||||
testImplementation project(modulePrefix + 'lib-exoplayer')
|
||||
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
|
||||
}
|
||||
|
@ -33,8 +33,6 @@ import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import androidx.annotation.DoNotInline;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.Nullable;
|
||||
@ -245,7 +243,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
|
||||
private final String channelId;
|
||||
@StringRes private final int channelNameResourceId;
|
||||
private final NotificationManager notificationManager;
|
||||
private final Handler mainHandler;
|
||||
|
||||
private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
|
||||
@DrawableRes private int smallIconResourceId;
|
||||
@ -278,7 +275,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
|
||||
notificationManager =
|
||||
checkStateNotNull(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
|
||||
mainHandler = new Handler(Looper.getMainLooper());
|
||||
smallIconResourceId = R.drawable.media3_notification_small_icon;
|
||||
}
|
||||
|
||||
@ -346,7 +342,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
|
||||
pendingOnBitmapLoadedFutureCallback,
|
||||
// This callback must be executed on the next looper iteration, after this method has
|
||||
// returned a media notification.
|
||||
mainHandler::post);
|
||||
mediaSession.getImpl().getApplicationHandler()::post);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -35,6 +35,10 @@ public final class MediaNotification {
|
||||
/**
|
||||
* Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending
|
||||
* intents} for notifications.
|
||||
*
|
||||
* <p>All methods will be called on the {@link Player#getApplicationLooper() application thread}
|
||||
* of the {@link Player} associated with the {@link MediaSession} the notification is provided
|
||||
* for.
|
||||
*/
|
||||
@UnstableApi
|
||||
public interface ActionFactory {
|
||||
@ -109,10 +113,20 @@ public final class MediaNotification {
|
||||
*
|
||||
* <p>The provider is required to create a {@linkplain androidx.core.app.NotificationChannelCompat
|
||||
* notification channel}, which is required to show notification for {@code SDK_INT >= 26}.
|
||||
*
|
||||
* <p>All methods will be called on the {@link Player#getApplicationLooper() application thread}
|
||||
* of the {@link Player} associated with the {@link MediaSession} the notification is provided
|
||||
* for.
|
||||
*/
|
||||
@UnstableApi
|
||||
public interface Provider {
|
||||
/** Receives updates for a notification. */
|
||||
/**
|
||||
* Receives updates for a notification.
|
||||
*
|
||||
* <p>All methods will be called on the {@link Player#getApplicationLooper() application thread}
|
||||
* of the {@link Player} associated with the {@link MediaSession} the notification is provided
|
||||
* for.
|
||||
*/
|
||||
interface Callback {
|
||||
/**
|
||||
* Called when a {@link MediaNotification} is changed.
|
||||
|
@ -50,6 +50,8 @@ 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 {
|
||||
|
||||
@ -96,11 +98,12 @@ import java.util.concurrent.TimeoutException;
|
||||
.setListener(listener)
|
||||
.setApplicationLooper(Looper.getMainLooper())
|
||||
.buildAsync();
|
||||
controllerMap.put(session, controllerFuture);
|
||||
controllerFuture.addListener(
|
||||
() -> {
|
||||
try {
|
||||
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
|
||||
listener.onConnected();
|
||||
listener.onConnected(shouldShowNotification(session));
|
||||
controller.addListener(listener);
|
||||
} catch (CancellationException
|
||||
| ExecutionException
|
||||
@ -111,7 +114,6 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
},
|
||||
mainExecutor);
|
||||
controllerMap.put(session, controllerFuture);
|
||||
}
|
||||
|
||||
public void removeSession(MediaSession session) {
|
||||
@ -123,46 +125,19 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
|
||||
public void onCustomAction(MediaSession session, String action, Bundle extras) {
|
||||
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
|
||||
if (controllerFuture == null) {
|
||||
@Nullable MediaController mediaController = getConnectedControllerForSession(session);
|
||||
if (mediaController == 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;
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -178,27 +153,42 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
|
||||
int notificationSequence = ++totalNotificationCount;
|
||||
ImmutableList<CommandButton> customLayout = checkStateNotNull(customLayoutMap.get(session));
|
||||
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);
|
||||
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 =
|
||||
MediaSessionService.shouldRunInForeground(
|
||||
session, /* startInForegroundWhenPaused= */ false);
|
||||
shouldRunInForeground(session, /* startInForegroundWhenPaused= */ false);
|
||||
updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
|
||||
}
|
||||
}
|
||||
@ -236,8 +226,7 @@ import java.util.concurrent.TimeoutException;
|
||||
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)) {
|
||||
if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -251,9 +240,56 @@ import java.util.concurrent.TimeoutException;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean shouldShowNotification(MediaSession session) {
|
||||
Player player = session.getPlayer();
|
||||
return !player.getCurrentTimeline().isEmpty() && player.getPlaybackState() != Player.STATE_IDLE;
|
||||
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) {
|
||||
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session);
|
||||
if (controllerFuture == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Futures.getDone(controllerFuture);
|
||||
} catch (ExecutionException exception) {
|
||||
// We should never reach this.
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendCustomCommandIfCommandIsAvailable(
|
||||
MediaController mediaController, String action) {
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MediaControllerListener
|
||||
@ -271,8 +307,8 @@ import java.util.concurrent.TimeoutException;
|
||||
this.customLayoutMap = customLayoutMap;
|
||||
}
|
||||
|
||||
public void onConnected() {
|
||||
if (shouldShowNotification(session)) {
|
||||
public void onConnected(boolean shouldShowNotification) {
|
||||
if (shouldShowNotification) {
|
||||
mediaSessionService.onUpdateNotificationInternal(
|
||||
session, /* startInForegroundWhenPaused= */ false);
|
||||
}
|
||||
|
@ -902,12 +902,20 @@ public class MediaSession {
|
||||
impl.setSessionPositionUpdateDelayMsOnHandler(updateDelayMs);
|
||||
}
|
||||
|
||||
/** Sets the {@linkplain Listener listener}. */
|
||||
/**
|
||||
* Sets the {@linkplain Listener listener}.
|
||||
*
|
||||
* <p>This method must be called on the main thread.
|
||||
*/
|
||||
/* package */ final void setListener(Listener listener) {
|
||||
impl.setMediaSessionListener(listener);
|
||||
}
|
||||
|
||||
/** Clears the {@linkplain Listener listener}. */
|
||||
/**
|
||||
* Clears the {@linkplain Listener listener}.
|
||||
*
|
||||
* <p>This method must be called on the main thread.
|
||||
*/
|
||||
/* package */ final void clearListener() {
|
||||
impl.clearMediaSessionListener();
|
||||
}
|
||||
@ -1439,7 +1447,11 @@ public class MediaSession {
|
||||
default void onRenderedFirstFrame(int seq) throws RemoteException {}
|
||||
}
|
||||
|
||||
/** Listener for media session events */
|
||||
/**
|
||||
* Listener for media session events.
|
||||
*
|
||||
* <p>All methods must be called on the main thread.
|
||||
*/
|
||||
/* package */ interface Listener {
|
||||
|
||||
/**
|
||||
|
@ -69,9 +69,11 @@ import androidx.media3.session.SequencedFutureManager.SequencedFuture;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
import com.google.common.util.concurrent.SettableFuture;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import org.checkerframework.checker.initialization.qual.Initialized;
|
||||
|
||||
/* package */ class MediaSessionImpl {
|
||||
@ -113,8 +115,10 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||
private final Handler applicationHandler;
|
||||
private final BitmapLoader bitmapLoader;
|
||||
private final Runnable periodicSessionPositionInfoUpdateRunnable;
|
||||
private final Handler mainHandler;
|
||||
|
||||
@Nullable private PlayerListener playerListener;
|
||||
|
||||
@Nullable private MediaSession.Listener mediaSessionListener;
|
||||
|
||||
private PlayerInfo playerInfo;
|
||||
@ -149,6 +153,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||
sessionStub = new MediaSessionStub(thisRef);
|
||||
this.sessionActivity = sessionActivity;
|
||||
|
||||
mainHandler = new Handler(Looper.getMainLooper());
|
||||
applicationHandler = new Handler(player.getApplicationLooper());
|
||||
this.callback = callback;
|
||||
this.bitmapLoader = bitmapLoader;
|
||||
@ -546,12 +551,25 @@ import org.checkerframework.checker.initialization.qual.Initialized;
|
||||
}
|
||||
|
||||
/* package */ void onNotificationRefreshRequired() {
|
||||
if (this.mediaSessionListener != null) {
|
||||
this.mediaSessionListener.onNotificationRefreshRequired(instance);
|
||||
}
|
||||
postOrRun(
|
||||
mainHandler,
|
||||
() -> {
|
||||
if (this.mediaSessionListener != null) {
|
||||
this.mediaSessionListener.onNotificationRefreshRequired(instance);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* package */ boolean onPlayRequested() {
|
||||
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||
SettableFuture<Boolean> playRequested = SettableFuture.create();
|
||||
mainHandler.post(() -> playRequested.set(onPlayRequested()));
|
||||
try {
|
||||
return playRequested.get();
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
if (this.mediaSessionListener != null) {
|
||||
return this.mediaSessionListener.onPlayRequested(instance);
|
||||
}
|
||||
|
@ -40,7 +40,6 @@ import androidx.annotation.RequiresApi;
|
||||
import androidx.collection.ArrayMap;
|
||||
import androidx.media.MediaBrowserServiceCompat;
|
||||
import androidx.media.MediaSessionManager;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.Log;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.util.Util;
|
||||
@ -183,7 +182,6 @@ public abstract class MediaSessionService extends Service {
|
||||
@Nullable
|
||||
private Listener listener;
|
||||
|
||||
@GuardedBy("lock")
|
||||
private boolean defaultMethodCalled;
|
||||
|
||||
/** Creates a service. */
|
||||
@ -198,6 +196,8 @@ public abstract class MediaSessionService extends Service {
|
||||
* Called when the service is created.
|
||||
*
|
||||
* <p>Override this method if you need your own initialization.
|
||||
*
|
||||
* <p>This method will be called on the main thread.
|
||||
*/
|
||||
@CallSuper
|
||||
@Override
|
||||
@ -234,7 +234,7 @@ public abstract class MediaSessionService extends Service {
|
||||
* <p>For those special cases, the values returned by {@link ControllerInfo#getUid()} and {@link
|
||||
* ControllerInfo#getConnectionHints()} have no meaning.
|
||||
*
|
||||
* <p>This method is always called on the main thread.
|
||||
* <p>This method will be called on the main thread.
|
||||
*
|
||||
* @param controllerInfo The information of the controller that is trying to connect.
|
||||
* @return A {@link MediaSession} for the controller, or {@code null} to reject the connection.
|
||||
@ -251,6 +251,8 @@ public abstract class MediaSessionService extends Service {
|
||||
* <p>The added session will be removed automatically {@linkplain MediaSession#release() when the
|
||||
* session is released}.
|
||||
*
|
||||
* <p>This method can be called from any thread.
|
||||
*
|
||||
* @param session A session to be added.
|
||||
* @see #removeSession(MediaSession)
|
||||
* @see #getSessions()
|
||||
@ -268,8 +270,12 @@ public abstract class MediaSessionService extends Service {
|
||||
// Session has returned for the first time. Register callbacks.
|
||||
// TODO(b/191644474): Check whether the session is registered to multiple services.
|
||||
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
||||
postOrRun(mainHandler, () -> notificationManager.addSession(session));
|
||||
session.setListener(new MediaSessionListener());
|
||||
postOrRun(
|
||||
mainHandler,
|
||||
() -> {
|
||||
notificationManager.addSession(session);
|
||||
session.setListener(new MediaSessionListener());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -277,6 +283,8 @@ public abstract class MediaSessionService extends Service {
|
||||
* Removes a {@link MediaSession} from this service. This is not necessary for most media apps.
|
||||
* See <a href="#MultipleSessions">Supporting Multiple Sessions</a> for details.
|
||||
*
|
||||
* <p>This method can be called from any thread.
|
||||
*
|
||||
* @param session A session to be removed.
|
||||
* @see #addSession(MediaSession)
|
||||
* @see #getSessions()
|
||||
@ -288,13 +296,19 @@ public abstract class MediaSessionService extends Service {
|
||||
sessions.remove(session.getId());
|
||||
}
|
||||
MediaNotificationManager notificationManager = getMediaNotificationManager();
|
||||
postOrRun(mainHandler, () -> notificationManager.removeSession(session));
|
||||
session.clearListener();
|
||||
postOrRun(
|
||||
mainHandler,
|
||||
() -> {
|
||||
notificationManager.removeSession(session);
|
||||
session.clearListener();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of {@linkplain MediaSession sessions} that you've added to this service via
|
||||
* {@link #addSession} or {@link #onGetSession(ControllerInfo)}.
|
||||
*
|
||||
* <p>This method can be called from any thread.
|
||||
*/
|
||||
public final List<MediaSession> getSessions() {
|
||||
synchronized (lock) {
|
||||
@ -305,6 +319,8 @@ public abstract class MediaSessionService extends Service {
|
||||
/**
|
||||
* Returns whether {@code session} has been added to this service via {@link #addSession} or
|
||||
* {@link #onGetSession(ControllerInfo)}.
|
||||
*
|
||||
* <p>This method can be called from any thread.
|
||||
*/
|
||||
public final boolean isSessionAdded(MediaSession session) {
|
||||
synchronized (lock) {
|
||||
@ -312,7 +328,11 @@ public abstract class MediaSessionService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets the {@linkplain Listener listener}. */
|
||||
/**
|
||||
* Sets the {@linkplain Listener listener}.
|
||||
*
|
||||
* <p>This method can be called from any thread.
|
||||
*/
|
||||
@UnstableApi
|
||||
public final void setListener(Listener listener) {
|
||||
synchronized (lock) {
|
||||
@ -320,7 +340,11 @@ public abstract class MediaSessionService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the {@linkplain Listener listener}. */
|
||||
/**
|
||||
* Clears the {@linkplain Listener listener}.
|
||||
*
|
||||
* <p>This method can be called from any thread.
|
||||
*/
|
||||
@UnstableApi
|
||||
public final void clearListener() {
|
||||
synchronized (lock) {
|
||||
@ -335,6 +359,8 @@ public abstract class MediaSessionService extends Service {
|
||||
* controllers}. In this case, the intent will have the action {@link #SERVICE_INTERFACE}.
|
||||
* Override this method if this service also needs to handle actions other than {@link
|
||||
* #SERVICE_INTERFACE}.
|
||||
*
|
||||
* <p>This method will be called on the main thread.
|
||||
*/
|
||||
@CallSuper
|
||||
@Override
|
||||
@ -378,6 +404,8 @@ public abstract class MediaSessionService extends Service {
|
||||
* <p>The default implementation handles the incoming media button events. In this case, the
|
||||
* intent will have the action {@link Intent#ACTION_MEDIA_BUTTON}. Override this method if this
|
||||
* service also needs to handle actions other than {@link Intent#ACTION_MEDIA_BUTTON}.
|
||||
*
|
||||
* <p>This method will be called on the main thread.
|
||||
*/
|
||||
@CallSuper
|
||||
@Override
|
||||
@ -417,6 +445,8 @@ public abstract class MediaSessionService extends Service {
|
||||
* Called when the service is no longer used and is being removed.
|
||||
*
|
||||
* <p>Override this method if you need your own clean up.
|
||||
*
|
||||
* <p>This method will be called on the main thread.
|
||||
*/
|
||||
@CallSuper
|
||||
@Override
|
||||
@ -435,7 +465,7 @@ public abstract class MediaSessionService extends Service {
|
||||
*/
|
||||
@Deprecated
|
||||
public void onUpdateNotification(MediaSession session) {
|
||||
setDefaultMethodCalled(true);
|
||||
defaultMethodCalled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -460,13 +490,15 @@ public abstract class MediaSessionService extends Service {
|
||||
* <p>Apps targeting {@code SDK_INT >= 28} must request the permission, {@link
|
||||
* android.Manifest.permission#FOREGROUND_SERVICE}.
|
||||
*
|
||||
* <p>This method will be called on the main thread.
|
||||
*
|
||||
* @param session A session that needs notification update.
|
||||
* @param startInForegroundRequired Whether the service is required to start in the foreground.
|
||||
*/
|
||||
@SuppressWarnings("deprecation") // Calling deprecated method.
|
||||
public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) {
|
||||
onUpdateNotification(session);
|
||||
if (isDefaultMethodCalled()) {
|
||||
if (defaultMethodCalled) {
|
||||
getMediaNotificationManager().updateNotification(session, startInForegroundRequired);
|
||||
}
|
||||
}
|
||||
@ -475,6 +507,8 @@ public abstract class MediaSessionService extends Service {
|
||||
* Sets the {@link MediaNotification.Provider} to customize notifications.
|
||||
*
|
||||
* <p>This should be called before {@link #onCreate()} returns.
|
||||
*
|
||||
* <p>This method can be called from any thread.
|
||||
*/
|
||||
@UnstableApi
|
||||
protected final void setMediaNotificationProvider(
|
||||
@ -491,11 +525,16 @@ public abstract class MediaSessionService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers notification update and handles {@code ForegroundServiceStartNotAllowedException}.
|
||||
*
|
||||
* <p>This method will be called on the main thread.
|
||||
*/
|
||||
/* package */ boolean onUpdateNotificationInternal(
|
||||
MediaSession session, boolean startInForegroundWhenPaused) {
|
||||
try {
|
||||
boolean startInForegroundRequired =
|
||||
shouldRunInForeground(session, startInForegroundWhenPaused);
|
||||
getMediaNotificationManager().shouldRunInForeground(session, startInForegroundWhenPaused);
|
||||
onUpdateNotification(session, startInForegroundRequired);
|
||||
} catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) {
|
||||
if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) {
|
||||
@ -508,14 +547,6 @@ public abstract class MediaSessionService extends Service {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* package */ static boolean shouldRunInForeground(
|
||||
MediaSession session, boolean startInForegroundWhenPaused) {
|
||||
Player player = session.getPlayer();
|
||||
return (player.getPlayWhenReady() || startInForegroundWhenPaused)
|
||||
&& (player.getPlaybackState() == Player.STATE_READY
|
||||
|| player.getPlaybackState() == Player.STATE_BUFFERING);
|
||||
}
|
||||
|
||||
private MediaNotificationManager getMediaNotificationManager() {
|
||||
synchronized (lock) {
|
||||
if (mediaNotificationManager == null) {
|
||||
@ -547,18 +578,6 @@ public abstract class MediaSessionService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDefaultMethodCalled() {
|
||||
synchronized (lock) {
|
||||
return this.defaultMethodCalled;
|
||||
}
|
||||
}
|
||||
|
||||
private void setDefaultMethodCalled(boolean defaultMethodCalled) {
|
||||
synchronized (lock) {
|
||||
this.defaultMethodCalled = defaultMethodCalled;
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
private void onForegroundServiceStartNotAllowedException() {
|
||||
mainHandler.post(
|
||||
|
@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2023 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.test.utils.robolectric.RobolectricUtil.runMainLooperUntil;
|
||||
import static com.google.common.truth.Truth8.assertThat;
|
||||
import static java.util.Arrays.stream;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import android.service.notification.StatusBarNotification;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.test.utils.TestExoPlayerBuilder;
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.android.controller.ServiceController;
|
||||
import org.robolectric.shadows.ShadowLooper;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class MediaSessionServiceTest {
|
||||
|
||||
@Test
|
||||
public void service_multipleSessionsOnMainThread_createsNotificationForEachSession() {
|
||||
Context context = ApplicationProvider.getApplicationContext();
|
||||
ExoPlayer player1 = new TestExoPlayerBuilder(context).build();
|
||||
ExoPlayer player2 = new TestExoPlayerBuilder(context).build();
|
||||
MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build();
|
||||
MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build();
|
||||
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
|
||||
TestService service = serviceController.create().get();
|
||||
service.setMediaNotificationProvider(
|
||||
new DefaultMediaNotificationProvider(
|
||||
service,
|
||||
session -> 2000 + Integer.parseInt(session.getId()),
|
||||
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID,
|
||||
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID));
|
||||
|
||||
service.addSession(session1);
|
||||
service.addSession(session2);
|
||||
// Start the players so that we also create notifications for them.
|
||||
player1.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
|
||||
player1.prepare();
|
||||
player1.play();
|
||||
player2.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
|
||||
player2.prepare();
|
||||
player2.play();
|
||||
ShadowLooper.idleMainLooper();
|
||||
|
||||
NotificationManager notificationService =
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
assertThat(
|
||||
stream(notificationService.getActiveNotifications()).map(StatusBarNotification::getId))
|
||||
.containsExactly(2001, 2002);
|
||||
|
||||
serviceController.destroy();
|
||||
session1.release();
|
||||
session2.release();
|
||||
player1.release();
|
||||
player2.release();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void service_multipleSessionsOnDifferentThreads_createsNotificationForEachSession()
|
||||
throws Exception {
|
||||
Context context = ApplicationProvider.getApplicationContext();
|
||||
HandlerThread thread1 = new HandlerThread("player1");
|
||||
HandlerThread thread2 = new HandlerThread("player2");
|
||||
thread1.start();
|
||||
thread2.start();
|
||||
ExoPlayer player1 = new TestExoPlayerBuilder(context).setLooper(thread1.getLooper()).build();
|
||||
ExoPlayer player2 = new TestExoPlayerBuilder(context).setLooper(thread2.getLooper()).build();
|
||||
MediaSession session1 = new MediaSession.Builder(context, player1).setId("1").build();
|
||||
MediaSession session2 = new MediaSession.Builder(context, player2).setId("2").build();
|
||||
ServiceController<TestService> serviceController = Robolectric.buildService(TestService.class);
|
||||
TestService service = serviceController.create().get();
|
||||
service.setMediaNotificationProvider(
|
||||
new DefaultMediaNotificationProvider(
|
||||
service,
|
||||
session -> 2000 + Integer.parseInt(session.getId()),
|
||||
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_ID,
|
||||
DefaultMediaNotificationProvider.DEFAULT_CHANNEL_NAME_RESOURCE_ID));
|
||||
NotificationManager notificationService =
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
service.addSession(session1);
|
||||
service.addSession(session2);
|
||||
// Start the players so that we also create notifications for them.
|
||||
new Handler(thread1.getLooper())
|
||||
.post(
|
||||
() -> {
|
||||
player1.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
|
||||
player1.prepare();
|
||||
player1.play();
|
||||
});
|
||||
new Handler(thread2.getLooper())
|
||||
.post(
|
||||
() -> {
|
||||
player2.setMediaItem(MediaItem.fromUri("asset:///media/mp4/sample.mp4"));
|
||||
player2.prepare();
|
||||
player2.play();
|
||||
});
|
||||
runMainLooperUntil(() -> notificationService.getActiveNotifications().length == 2);
|
||||
|
||||
assertThat(
|
||||
stream(notificationService.getActiveNotifications()).map(StatusBarNotification::getId))
|
||||
.containsExactly(2001, 2002);
|
||||
|
||||
serviceController.destroy();
|
||||
session1.release();
|
||||
session2.release();
|
||||
new Handler(thread1.getLooper()).post(player1::release);
|
||||
new Handler(thread2.getLooper()).post(player2::release);
|
||||
thread1.quit();
|
||||
thread2.quit();
|
||||
}
|
||||
|
||||
private static final class TestService extends MediaSessionService {
|
||||
@Nullable
|
||||
@Override
|
||||
public MediaSession onGetSession(MediaSession.ControllerInfo controllerInfo) {
|
||||
return null; // No need to support binding or pending intents for this test.
|
||||
}
|
||||
}
|
||||
}
|
@ -20,6 +20,7 @@ dependencies {
|
||||
api 'androidx.test.ext:truth:' + androidxTestTruthVersion
|
||||
api 'junit:junit:' + junitVersion
|
||||
api 'com.google.truth:truth:' + truthVersion
|
||||
api 'com.google.truth.extensions:truth-java8-extension:' + truthVersion
|
||||
compileOnly 'org.checkerframework:checker-qual:' + checkerframeworkVersion
|
||||
compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
|
||||
compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion
|
||||
|
Loading…
x
Reference in New Issue
Block a user