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:
tonihei 2023-04-17 16:08:37 +01:00 committed by Rohit Singh
parent 9081c70788
commit ffa3743069
10 changed files with 341 additions and 97 deletions

View File

@ -54,6 +54,9 @@
([#296](https://github.com/androidx/media/issues/296)). ([#296](https://github.com/androidx/media/issues/296)).
* Fix issue where `Player.COMMAND_GET_CURRENT_MEDIA_ITEM` needs to be * Fix issue where `Player.COMMAND_GET_CURRENT_MEDIA_ITEM` needs to be
available to access metadata via the legacy `MediaSessionCompat`. 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: * Audio:
* Fix bug where some playbacks fail when tunneling is enabled and * Fix bug where some playbacks fail when tunneling is enabled and
`AudioProcessors` are active, e.g. for gapless trimming `AudioProcessors` are active, e.g. for gapless trimming

View File

@ -43,6 +43,7 @@ dependencies {
androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion androidTestImplementation 'androidx.multidex:multidex:' + androidxMultidexVersion
androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion androidTestImplementation 'androidx.test:runner:' + androidxTestRunnerVersion
testImplementation project(modulePrefix + 'test-utils') testImplementation project(modulePrefix + 'test-utils')
testImplementation project(modulePrefix + 'test-utils-robolectric')
testImplementation project(modulePrefix + 'lib-exoplayer') testImplementation project(modulePrefix + 'lib-exoplayer')
testImplementation 'org.robolectric:robolectric:' + robolectricVersion testImplementation 'org.robolectric:robolectric:' + robolectricVersion
} }

View File

@ -33,8 +33,6 @@ import android.app.NotificationManager;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.DoNotInline; import androidx.annotation.DoNotInline;
import androidx.annotation.DrawableRes; import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -245,7 +243,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
private final String channelId; private final String channelId;
@StringRes private final int channelNameResourceId; @StringRes private final int channelNameResourceId;
private final NotificationManager notificationManager; private final NotificationManager notificationManager;
private final Handler mainHandler;
private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback; private @MonotonicNonNull OnBitmapLoadedFutureCallback pendingOnBitmapLoadedFutureCallback;
@DrawableRes private int smallIconResourceId; @DrawableRes private int smallIconResourceId;
@ -278,7 +275,6 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
notificationManager = notificationManager =
checkStateNotNull( checkStateNotNull(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)); (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
mainHandler = new Handler(Looper.getMainLooper());
smallIconResourceId = R.drawable.media3_notification_small_icon; smallIconResourceId = R.drawable.media3_notification_small_icon;
} }
@ -346,7 +342,7 @@ public class DefaultMediaNotificationProvider implements MediaNotification.Provi
pendingOnBitmapLoadedFutureCallback, pendingOnBitmapLoadedFutureCallback,
// This callback must be executed on the next looper iteration, after this method has // This callback must be executed on the next looper iteration, after this method has
// returned a media notification. // returned a media notification.
mainHandler::post); mediaSession.getImpl().getApplicationHandler()::post);
} }
} }
} }

View File

@ -35,6 +35,10 @@ public final class MediaNotification {
/** /**
* Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending * Creates {@linkplain NotificationCompat.Action actions} and {@linkplain PendingIntent pending
* intents} for notifications. * 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 @UnstableApi
public interface ActionFactory { public interface ActionFactory {
@ -109,10 +113,20 @@ public final class MediaNotification {
* *
* <p>The provider is required to create a {@linkplain androidx.core.app.NotificationChannelCompat * <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}. * 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 @UnstableApi
public interface Provider { 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 { interface Callback {
/** /**
* Called when a {@link MediaNotification} is changed. * Called when a {@link MediaNotification} is changed.

View File

@ -50,6 +50,8 @@ import java.util.concurrent.TimeoutException;
/** /**
* Manages media notifications for a {@link MediaSessionService} and sets the service as * Manages media notifications for a {@link MediaSessionService} and sets the service as
* foreground/background according to the player state. * foreground/background according to the player state.
*
* <p>All methods must be called on the main thread.
*/ */
/* package */ final class MediaNotificationManager { /* package */ final class MediaNotificationManager {
@ -96,11 +98,12 @@ import java.util.concurrent.TimeoutException;
.setListener(listener) .setListener(listener)
.setApplicationLooper(Looper.getMainLooper()) .setApplicationLooper(Looper.getMainLooper())
.buildAsync(); .buildAsync();
controllerMap.put(session, controllerFuture);
controllerFuture.addListener( controllerFuture.addListener(
() -> { () -> {
try { try {
MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS); MediaController controller = controllerFuture.get(/* time= */ 0, TimeUnit.MILLISECONDS);
listener.onConnected(); listener.onConnected(shouldShowNotification(session));
controller.addListener(listener); controller.addListener(listener);
} catch (CancellationException } catch (CancellationException
| ExecutionException | ExecutionException
@ -111,7 +114,6 @@ import java.util.concurrent.TimeoutException;
} }
}, },
mainExecutor); mainExecutor);
controllerMap.put(session, controllerFuture);
} }
public void removeSession(MediaSession session) { public void removeSession(MediaSession session) {
@ -123,46 +125,19 @@ import java.util.concurrent.TimeoutException;
} }
public void onCustomAction(MediaSession session, String action, Bundle extras) { public void onCustomAction(MediaSession session, String action, Bundle extras) {
@Nullable ListenableFuture<MediaController> controllerFuture = controllerMap.get(session); @Nullable MediaController mediaController = getConnectedControllerForSession(session);
if (controllerFuture == null) { if (mediaController == null) {
return; return;
} }
try { // Let the notification provider handle the command first before forwarding it directly.
MediaController mediaController = checkStateNotNull(Futures.getDone(controllerFuture)); Util.postOrRun(
if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) { new Handler(session.getPlayer().getApplicationLooper()),
@Nullable SessionCommand customCommand = null; () -> {
for (SessionCommand command : mediaController.getAvailableSessionCommands().commands) { if (!mediaNotificationProvider.handleCustomCommand(session, action, extras)) {
if (command.commandCode == SessionCommand.COMMAND_CODE_CUSTOM mainExecutor.execute(
&& command.customAction.equals(action)) { () -> sendCustomCommandIfCommandIsAvailable(mediaController, 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);
}
} }
/** /**
@ -178,27 +153,42 @@ import java.util.concurrent.TimeoutException;
} }
int notificationSequence = ++totalNotificationCount; int notificationSequence = ++totalNotificationCount;
ImmutableList<CommandButton> customLayout = checkStateNotNull(customLayoutMap.get(session));
MediaNotification.Provider.Callback callback = MediaNotification.Provider.Callback callback =
notification -> notification ->
mainExecutor.execute( mainExecutor.execute(
() -> onNotificationUpdated(notificationSequence, session, notification)); () -> onNotificationUpdated(notificationSequence, session, notification));
Util.postOrRun(
MediaNotification mediaNotification = new Handler(session.getPlayer().getApplicationLooper()),
this.mediaNotificationProvider.createNotification( () -> {
session, checkStateNotNull(customLayoutMap.get(session)), actionFactory, callback); MediaNotification mediaNotification =
updateNotificationInternal(session, mediaNotification, startInForegroundRequired); this.mediaNotificationProvider.createNotification(
session, customLayout, actionFactory, callback);
mainExecutor.execute(
() ->
updateNotificationInternal(
session, mediaNotification, startInForegroundRequired));
});
} }
public boolean isStartedInForeground() { public boolean isStartedInForeground() {
return startedInForeground; 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( private void onNotificationUpdated(
int notificationSequence, MediaSession session, MediaNotification mediaNotification) { int notificationSequence, MediaSession session, MediaNotification mediaNotification) {
if (notificationSequence == totalNotificationCount) { if (notificationSequence == totalNotificationCount) {
boolean startInForegroundRequired = boolean startInForegroundRequired =
MediaSessionService.shouldRunInForeground( shouldRunInForeground(session, /* startInForegroundWhenPaused= */ false);
session, /* startInForegroundWhenPaused= */ false);
updateNotificationInternal(session, mediaNotification, startInForegroundRequired); updateNotificationInternal(session, mediaNotification, startInForegroundRequired);
} }
} }
@ -236,8 +226,7 @@ import java.util.concurrent.TimeoutException;
private void maybeStopForegroundService(boolean removeNotifications) { private void maybeStopForegroundService(boolean removeNotifications) {
List<MediaSession> sessions = mediaSessionService.getSessions(); List<MediaSession> sessions = mediaSessionService.getSessions();
for (int i = 0; i < sessions.size(); i++) { for (int i = 0; i < sessions.size(); i++) {
if (MediaSessionService.shouldRunInForeground( if (shouldRunInForeground(sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
sessions.get(i), /* startInForegroundWhenPaused= */ false)) {
return; return;
} }
} }
@ -251,9 +240,56 @@ import java.util.concurrent.TimeoutException;
} }
} }
private static boolean shouldShowNotification(MediaSession session) { private boolean shouldShowNotification(MediaSession session) {
Player player = session.getPlayer(); MediaController controller = getConnectedControllerForSession(session);
return !player.getCurrentTimeline().isEmpty() && player.getPlaybackState() != Player.STATE_IDLE; 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 private static final class MediaControllerListener
@ -271,8 +307,8 @@ import java.util.concurrent.TimeoutException;
this.customLayoutMap = customLayoutMap; this.customLayoutMap = customLayoutMap;
} }
public void onConnected() { public void onConnected(boolean shouldShowNotification) {
if (shouldShowNotification(session)) { if (shouldShowNotification) {
mediaSessionService.onUpdateNotificationInternal( mediaSessionService.onUpdateNotificationInternal(
session, /* startInForegroundWhenPaused= */ false); session, /* startInForegroundWhenPaused= */ false);
} }

View File

@ -902,12 +902,20 @@ public class MediaSession {
impl.setSessionPositionUpdateDelayMsOnHandler(updateDelayMs); 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) { /* package */ final void setListener(Listener listener) {
impl.setMediaSessionListener(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() { /* package */ final void clearListener() {
impl.clearMediaSessionListener(); impl.clearMediaSessionListener();
} }
@ -1439,7 +1447,11 @@ public class MediaSession {
default void onRenderedFirstFrame(int seq) throws RemoteException {} 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 { /* package */ interface Listener {
/** /**

View File

@ -69,9 +69,11 @@ import androidx.media3.session.SequencedFutureManager.SequencedFuture;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import java.lang.ref.WeakReference; import java.lang.ref.WeakReference;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutionException;
import org.checkerframework.checker.initialization.qual.Initialized; import org.checkerframework.checker.initialization.qual.Initialized;
/* package */ class MediaSessionImpl { /* package */ class MediaSessionImpl {
@ -113,8 +115,10 @@ import org.checkerframework.checker.initialization.qual.Initialized;
private final Handler applicationHandler; private final Handler applicationHandler;
private final BitmapLoader bitmapLoader; private final BitmapLoader bitmapLoader;
private final Runnable periodicSessionPositionInfoUpdateRunnable; private final Runnable periodicSessionPositionInfoUpdateRunnable;
private final Handler mainHandler;
@Nullable private PlayerListener playerListener; @Nullable private PlayerListener playerListener;
@Nullable private MediaSession.Listener mediaSessionListener; @Nullable private MediaSession.Listener mediaSessionListener;
private PlayerInfo playerInfo; private PlayerInfo playerInfo;
@ -149,6 +153,7 @@ import org.checkerframework.checker.initialization.qual.Initialized;
sessionStub = new MediaSessionStub(thisRef); sessionStub = new MediaSessionStub(thisRef);
this.sessionActivity = sessionActivity; this.sessionActivity = sessionActivity;
mainHandler = new Handler(Looper.getMainLooper());
applicationHandler = new Handler(player.getApplicationLooper()); applicationHandler = new Handler(player.getApplicationLooper());
this.callback = callback; this.callback = callback;
this.bitmapLoader = bitmapLoader; this.bitmapLoader = bitmapLoader;
@ -546,12 +551,25 @@ import org.checkerframework.checker.initialization.qual.Initialized;
} }
/* package */ void onNotificationRefreshRequired() { /* package */ void onNotificationRefreshRequired() {
if (this.mediaSessionListener != null) { postOrRun(
this.mediaSessionListener.onNotificationRefreshRequired(instance); mainHandler,
} () -> {
if (this.mediaSessionListener != null) {
this.mediaSessionListener.onNotificationRefreshRequired(instance);
}
});
} }
/* package */ boolean onPlayRequested() { /* 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) { if (this.mediaSessionListener != null) {
return this.mediaSessionListener.onPlayRequested(instance); return this.mediaSessionListener.onPlayRequested(instance);
} }

View File

@ -40,7 +40,6 @@ import androidx.annotation.RequiresApi;
import androidx.collection.ArrayMap; import androidx.collection.ArrayMap;
import androidx.media.MediaBrowserServiceCompat; import androidx.media.MediaBrowserServiceCompat;
import androidx.media.MediaSessionManager; import androidx.media.MediaSessionManager;
import androidx.media3.common.Player;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
@ -183,7 +182,6 @@ public abstract class MediaSessionService extends Service {
@Nullable @Nullable
private Listener listener; private Listener listener;
@GuardedBy("lock")
private boolean defaultMethodCalled; private boolean defaultMethodCalled;
/** Creates a service. */ /** Creates a service. */
@ -198,6 +196,8 @@ public abstract class MediaSessionService extends Service {
* Called when the service is created. * Called when the service is created.
* *
* <p>Override this method if you need your own initialization. * <p>Override this method if you need your own initialization.
*
* <p>This method will be called on the main thread.
*/ */
@CallSuper @CallSuper
@Override @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 * <p>For those special cases, the values returned by {@link ControllerInfo#getUid()} and {@link
* ControllerInfo#getConnectionHints()} have no meaning. * 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. * @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. * @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 * <p>The added session will be removed automatically {@linkplain MediaSession#release() when the
* session is released}. * session is released}.
* *
* <p>This method can be called from any thread.
*
* @param session A session to be added. * @param session A session to be added.
* @see #removeSession(MediaSession) * @see #removeSession(MediaSession)
* @see #getSessions() * @see #getSessions()
@ -268,8 +270,12 @@ public abstract class MediaSessionService extends Service {
// Session has returned for the first time. Register callbacks. // Session has returned for the first time. Register callbacks.
// TODO(b/191644474): Check whether the session is registered to multiple services. // TODO(b/191644474): Check whether the session is registered to multiple services.
MediaNotificationManager notificationManager = getMediaNotificationManager(); MediaNotificationManager notificationManager = getMediaNotificationManager();
postOrRun(mainHandler, () -> notificationManager.addSession(session)); postOrRun(
session.setListener(new MediaSessionListener()); 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. * 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. * 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. * @param session A session to be removed.
* @see #addSession(MediaSession) * @see #addSession(MediaSession)
* @see #getSessions() * @see #getSessions()
@ -288,13 +296,19 @@ public abstract class MediaSessionService extends Service {
sessions.remove(session.getId()); sessions.remove(session.getId());
} }
MediaNotificationManager notificationManager = getMediaNotificationManager(); MediaNotificationManager notificationManager = getMediaNotificationManager();
postOrRun(mainHandler, () -> notificationManager.removeSession(session)); postOrRun(
session.clearListener(); mainHandler,
() -> {
notificationManager.removeSession(session);
session.clearListener();
});
} }
/** /**
* Returns the list of {@linkplain MediaSession sessions} that you've added to this service via * Returns the list of {@linkplain MediaSession sessions} that you've added to this service via
* {@link #addSession} or {@link #onGetSession(ControllerInfo)}. * {@link #addSession} or {@link #onGetSession(ControllerInfo)}.
*
* <p>This method can be called from any thread.
*/ */
public final List<MediaSession> getSessions() { public final List<MediaSession> getSessions() {
synchronized (lock) { 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 * Returns whether {@code session} has been added to this service via {@link #addSession} or
* {@link #onGetSession(ControllerInfo)}. * {@link #onGetSession(ControllerInfo)}.
*
* <p>This method can be called from any thread.
*/ */
public final boolean isSessionAdded(MediaSession session) { public final boolean isSessionAdded(MediaSession session) {
synchronized (lock) { 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 @UnstableApi
public final void setListener(Listener listener) { public final void setListener(Listener listener) {
synchronized (lock) { 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 @UnstableApi
public final void clearListener() { public final void clearListener() {
synchronized (lock) { 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}. * 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 * Override this method if this service also needs to handle actions other than {@link
* #SERVICE_INTERFACE}. * #SERVICE_INTERFACE}.
*
* <p>This method will be called on the main thread.
*/ */
@CallSuper @CallSuper
@Override @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 * <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 * 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}. * service also needs to handle actions other than {@link Intent#ACTION_MEDIA_BUTTON}.
*
* <p>This method will be called on the main thread.
*/ */
@CallSuper @CallSuper
@Override @Override
@ -417,6 +445,8 @@ public abstract class MediaSessionService extends Service {
* Called when the service is no longer used and is being removed. * Called when the service is no longer used and is being removed.
* *
* <p>Override this method if you need your own clean up. * <p>Override this method if you need your own clean up.
*
* <p>This method will be called on the main thread.
*/ */
@CallSuper @CallSuper
@Override @Override
@ -435,7 +465,7 @@ public abstract class MediaSessionService extends Service {
*/ */
@Deprecated @Deprecated
public void onUpdateNotification(MediaSession session) { 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 * <p>Apps targeting {@code SDK_INT >= 28} must request the permission, {@link
* android.Manifest.permission#FOREGROUND_SERVICE}. * android.Manifest.permission#FOREGROUND_SERVICE}.
* *
* <p>This method will be called on the main thread.
*
* @param session A session that needs notification update. * @param session A session that needs notification update.
* @param startInForegroundRequired Whether the service is required to start in the foreground. * @param startInForegroundRequired Whether the service is required to start in the foreground.
*/ */
@SuppressWarnings("deprecation") // Calling deprecated method. @SuppressWarnings("deprecation") // Calling deprecated method.
public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) { public void onUpdateNotification(MediaSession session, boolean startInForegroundRequired) {
onUpdateNotification(session); onUpdateNotification(session);
if (isDefaultMethodCalled()) { if (defaultMethodCalled) {
getMediaNotificationManager().updateNotification(session, startInForegroundRequired); getMediaNotificationManager().updateNotification(session, startInForegroundRequired);
} }
} }
@ -475,6 +507,8 @@ public abstract class MediaSessionService extends Service {
* Sets the {@link MediaNotification.Provider} to customize notifications. * Sets the {@link MediaNotification.Provider} to customize notifications.
* *
* <p>This should be called before {@link #onCreate()} returns. * <p>This should be called before {@link #onCreate()} returns.
*
* <p>This method can be called from any thread.
*/ */
@UnstableApi @UnstableApi
protected final void setMediaNotificationProvider( 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( /* package */ boolean onUpdateNotificationInternal(
MediaSession session, boolean startInForegroundWhenPaused) { MediaSession session, boolean startInForegroundWhenPaused) {
try { try {
boolean startInForegroundRequired = boolean startInForegroundRequired =
shouldRunInForeground(session, startInForegroundWhenPaused); getMediaNotificationManager().shouldRunInForeground(session, startInForegroundWhenPaused);
onUpdateNotification(session, startInForegroundRequired); onUpdateNotification(session, startInForegroundRequired);
} catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) { } catch (/* ForegroundServiceStartNotAllowedException */ IllegalStateException e) {
if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) { if ((Util.SDK_INT >= 31) && Api31.instanceOfForegroundServiceStartNotAllowedException(e)) {
@ -508,14 +547,6 @@ public abstract class MediaSessionService extends Service {
return true; 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() { private MediaNotificationManager getMediaNotificationManager() {
synchronized (lock) { synchronized (lock) {
if (mediaNotificationManager == null) { 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) @RequiresApi(31)
private void onForegroundServiceStartNotAllowedException() { private void onForegroundServiceStartNotAllowedException() {
mainHandler.post( mainHandler.post(

View File

@ -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.
}
}
}

View File

@ -20,6 +20,7 @@ dependencies {
api 'androidx.test.ext:truth:' + androidxTestTruthVersion api 'androidx.test.ext:truth:' + androidxTestTruthVersion
api 'junit:junit:' + junitVersion api 'junit:junit:' + junitVersion
api 'com.google.truth:truth:' + truthVersion 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-qual:' + checkerframeworkVersion
compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion compileOnly 'org.checkerframework:checker-compat-qual:' + checkerframeworkCompatVersion
compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion compileOnly 'org.jetbrains.kotlin:kotlin-annotations-jvm:' + kotlinAnnotationsVersion