| /* |
| * 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(); |
| } |
| } |
| } |