blob: f6fd509fd245adc706da2ea298079e677c892149 [file] [log] [blame]
/*
* 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 android.media;
import static android.media.MediaConstants.KEY_CONNECTION_HINTS;
import static android.media.MediaConstants.KEY_PACKAGE_NAME;
import static android.media.MediaConstants.KEY_PID;
import android.annotation.CallSuper;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.media.MediaSession2.ControllerInfo;
import android.media.session.MediaSessionManager;
import android.media.session.MediaSessionManager.RemoteUserInfo;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.util.ArrayMap;
import android.util.Log;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* This API is not generally intended for third party application developers.
* Use the <a href="{@docRoot}jetpack/androidx.html">AndroidX</a>
* <a href="{@docRoot}reference/androidx/media2/session/package-summary.html">Media2 session
* Library</a> for consistent behavior across all devices.
* <p>
* Service containing {@link MediaSession2}.
*/
public abstract class MediaSession2Service extends Service {
/**
* The {@link Intent} that must be declared as handled by the service.
*/
public static final String SERVICE_INTERFACE = "android.media.MediaSession2Service";
private static final String TAG = "MediaSession2Service";
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
private final MediaSession2.ForegroundServiceEventCallback mForegroundServiceEventCallback =
new MediaSession2.ForegroundServiceEventCallback() {
@Override
public void onPlaybackActiveChanged(MediaSession2 session, boolean playbackActive) {
MediaSession2Service.this.onPlaybackActiveChanged(session, playbackActive);
}
@Override
public void onSessionClosed(MediaSession2 session) {
removeSession(session);
}
};
private final Object mLock = new Object();
//@GuardedBy("mLock")
private NotificationManager mNotificationManager;
//@GuardedBy("mLock")
private MediaSessionManager mMediaSessionManager;
//@GuardedBy("mLock")
private Intent mStartSelfIntent;
//@GuardedBy("mLock")
private Map<String, MediaSession2> mSessions = new ArrayMap<>();
//@GuardedBy("mLock")
private Map<MediaSession2, MediaNotification> mNotifications = new ArrayMap<>();
//@GuardedBy("mLock")
private MediaSession2ServiceStub mStub;
/**
* Called by the system when the service is first created. Do not call this method directly.
* <p>
* Override this method if you need your own initialization. Derived classes MUST call through
* to the super class's implementation of this method.
*/
@CallSuper
@Override
public void onCreate() {
super.onCreate();
synchronized (mLock) {
mStub = new MediaSession2ServiceStub(this);
mStartSelfIntent = new Intent(this, this.getClass());
mNotificationManager =
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
mMediaSessionManager =
(MediaSessionManager) getSystemService(Context.MEDIA_SESSION_SERVICE);
}
}
@CallSuper
@Override
@Nullable
public IBinder onBind(@NonNull Intent intent) {
if (SERVICE_INTERFACE.equals(intent.getAction())) {
synchronized (mLock) {
return mStub;
}
}
return null;
}
/**
* Called by the system to notify that it is no longer used and is being removed. Do not call
* this method directly.
* <p>
* Override this method if you need your own clean up. Derived classes MUST call through
* to the super class's implementation of this method.
*/
@CallSuper
@Override
public void onDestroy() {
super.onDestroy();
synchronized (mLock) {
List<MediaSession2> sessions = getSessions();
for (MediaSession2 session : sessions) {
removeSession(session);
}
mSessions.clear();
mNotifications.clear();
}
mStub.close();
}
/**
* Called when a {@link MediaController2} is created with the this service's
* {@link Session2Token}. Return the session for telling the controller which session to
* connect. Return {@code null} to reject the connection from this controller.
* <p>
* Session returned here will be added to this service automatically. You don't need to call
* {@link #addSession(MediaSession2)} for that.
* <p>
* This method is always called on the main thread.
*
* @param controllerInfo information of the controller which is trying to connect.
* @return a {@link MediaSession2} instance for the controller to connect to, or {@code null}
* to reject connection
* @see MediaSession2.Builder
* @see #getSessions()
*/
@Nullable
public abstract MediaSession2 onGetSession(@NonNull ControllerInfo controllerInfo);
/**
* Called when notification UI needs update. Override this method to show or cancel your own
* notification UI.
* <p>
* This would be called on {@link MediaSession2}'s callback executor when playback state is
* changed.
* <p>
* With the notification returned here, the service becomes foreground service when the playback
* is started. Apps must request the permission
* {@link android.Manifest.permission#FOREGROUND_SERVICE} in order to use this API. It becomes
* background service after the playback is stopped.
*
* @param session a session that needs notification update.
* @return a {@link MediaNotification}. Can be {@code null}.
*/
@Nullable
public abstract MediaNotification onUpdateNotification(@NonNull MediaSession2 session);
/**
* Adds a session to this service.
* <p>
* Added session will be removed automatically when it's closed, or removed when
* {@link #removeSession} is called.
*
* @param session a session to be added.
* @see #removeSession(MediaSession2)
*/
public final void addSession(@NonNull MediaSession2 session) {
if (session == null) {
throw new IllegalArgumentException("session shouldn't be null");
}
if (session.isClosed()) {
throw new IllegalArgumentException("session is already closed");
}
synchronized (mLock) {
MediaSession2 previousSession = mSessions.get(session.getId());
if (previousSession != null) {
if (previousSession != session) {
Log.w(TAG, "Session ID should be unique, ID=" + session.getId()
+ ", previous=" + previousSession + ", session=" + session);
}
return;
}
mSessions.put(session.getId(), session);
session.setForegroundServiceEventCallback(mForegroundServiceEventCallback);
}
}
/**
* Removes a session from this service.
*
* @param session a session to be removed.
* @see #addSession(MediaSession2)
*/
public final void removeSession(@NonNull MediaSession2 session) {
if (session == null) {
throw new IllegalArgumentException("session shouldn't be null");
}
MediaNotification notification;
synchronized (mLock) {
if (mSessions.get(session.getId()) != session) {
// Session isn't added or removed already.
return;
}
mSessions.remove(session.getId());
notification = mNotifications.remove(session);
}
session.setForegroundServiceEventCallback(null);
if (notification != null) {
mNotificationManager.cancel(notification.getNotificationId());
}
if (getSessions().isEmpty()) {
stopForeground(false);
}
}
/**
* Gets the list of {@link MediaSession2}s that you've added to this service.
*
* @return sessions
*/
public final @NonNull List<MediaSession2> getSessions() {
List<MediaSession2> list = new ArrayList<>();
synchronized (mLock) {
list.addAll(mSessions.values());
}
return list;
}
/**
* Returns the {@link MediaSessionManager}.
*/
@NonNull
MediaSessionManager getMediaSessionManager() {
synchronized (mLock) {
return mMediaSessionManager;
}
}
/**
* Called by registered {@link MediaSession2.ForegroundServiceEventCallback}
*
* @param session session with change
* @param playbackActive {@code true} if playback is active.
*/
void onPlaybackActiveChanged(MediaSession2 session, boolean playbackActive) {
MediaNotification mediaNotification = onUpdateNotification(session);
if (mediaNotification == null) {
// The service implementation doesn't want to use the automatic start/stopForeground
// feature.
return;
}
synchronized (mLock) {
mNotifications.put(session, mediaNotification);
}
int id = mediaNotification.getNotificationId();
Notification notification = mediaNotification.getNotification();
if (!playbackActive) {
mNotificationManager.notify(id, notification);
return;
}
// playbackActive == true
startForegroundService(mStartSelfIntent);
startForeground(id, notification);
}
/**
* This API is not generally intended for third party application developers.
* Use the <a href="{@docRoot}jetpack/androidx.html">AndroidX</a>
* <a href="{@docRoot}reference/androidx/media2/session/package-summary.html">Media2 session
* Library</a> for consistent behavior across all devices.
* <p>
* Returned by {@link #onUpdateNotification(MediaSession2)} for making session service
* foreground service to keep playback running in the background. It's highly recommended to
* show media style notification here.
*/
public static class MediaNotification {
private final int mNotificationId;
private final Notification mNotification;
/**
* Default constructor
*
* @param notificationId notification id to be used for
* {@link NotificationManager#notify(int, Notification)}.
* @param notification a notification to make session service run in the foreground. Media
* style notification is recommended here.
*/
public MediaNotification(int notificationId, @NonNull Notification notification) {
if (notification == null) {
throw new IllegalArgumentException("notification shouldn't be null");
}
mNotificationId = notificationId;
mNotification = notification;
}
/**
* Gets the id of the notification.
*
* @return the notification id
*/
public int getNotificationId() {
return mNotificationId;
}
/**
* Gets the notification.
*
* @return the notification
*/
@NonNull
public Notification getNotification() {
return mNotification;
}
}
private static final class MediaSession2ServiceStub extends IMediaSession2Service.Stub
implements AutoCloseable {
final WeakReference<MediaSession2Service> mService;
final Handler mHandler;
MediaSession2ServiceStub(MediaSession2Service service) {
mService = new WeakReference<>(service);
mHandler = new Handler(service.getMainLooper());
}
@Override
public void connect(Controller2Link caller, int seq, Bundle connectionRequest) {
if (mService.get() == null) {
if (DEBUG) {
Log.d(TAG, "Service is already destroyed");
}
return;
}
if (caller == null || connectionRequest == null) {
if (DEBUG) {
Log.d(TAG, "Ignoring calls with illegal arguments, caller=" + caller
+ ", connectionRequest=" + connectionRequest);
}
return;
}
final int pid = Binder.getCallingPid();
final int uid = Binder.getCallingUid();
final long token = Binder.clearCallingIdentity();
try {
mHandler.post(() -> {
boolean shouldNotifyDisconnected = true;
try {
final MediaSession2Service service = mService.get();
if (service == null) {
if (DEBUG) {
Log.d(TAG, "Service isn't available");
}
return;
}
String callingPkg = connectionRequest.getString(KEY_PACKAGE_NAME);
// The Binder.getCallingPid() can be 0 for an oneway call from the
// remote process. If it's the case, use PID from the connectionRequest.
RemoteUserInfo remoteUserInfo = new RemoteUserInfo(
callingPkg,
pid == 0 ? connectionRequest.getInt(KEY_PID) : pid,
uid);
Bundle connectionHints = connectionRequest.getBundle(KEY_CONNECTION_HINTS);
if (connectionHints == null) {
Log.w(TAG, "connectionHints shouldn't be null.");
connectionHints = Bundle.EMPTY;
} else if (MediaSession2.hasCustomParcelable(connectionHints)) {
Log.w(TAG, "connectionHints contain custom parcelable. Ignoring.");
connectionHints = Bundle.EMPTY;
}
final ControllerInfo controllerInfo = new ControllerInfo(
remoteUserInfo,
service.getMediaSessionManager()
.isTrustedForMediaControl(remoteUserInfo),
caller,
connectionHints);
if (DEBUG) {
Log.d(TAG, "Handling incoming connection request from the"
+ " controller=" + controllerInfo);
}
final MediaSession2 session;
session = service.onGetSession(controllerInfo);
if (session == null) {
if (DEBUG) {
Log.d(TAG, "Rejecting incoming connection request from the"
+ " controller=" + controllerInfo);
}
// Note: Trusted controllers also can be rejected according to the
// service implementation.
return;
}
service.addSession(session);
shouldNotifyDisconnected = false;
session.onConnect(caller, pid, uid, seq, connectionRequest);
} catch (Exception e) {
// Don't propagate exception in service to the controller.
Log.w(TAG, "Failed to add a session to session service", e);
} finally {
// Trick to call onDisconnected() in one place.
if (shouldNotifyDisconnected) {
if (DEBUG) {
Log.d(TAG, "Notifying the controller of its disconnection");
}
try {
caller.notifyDisconnected(0);
} catch (RuntimeException e) {
// Controller may be died prematurely.
// Not an issue because we'll ignore it anyway.
}
}
}
});
} finally {
Binder.restoreCallingIdentity(token);
}
}
@Override
public void close() {
mHandler.removeCallbacksAndMessages(null);
mService.clear();
}
}
}