| /* |
| * Copyright 2020 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.Manifest.permission.MEDIA_CONTENT_CONTROL; |
| import static android.annotation.SystemApi.Client.MODULE_LIBRARIES; |
| |
| import android.annotation.CallbackExecutor; |
| import android.annotation.FlaggedApi; |
| import android.annotation.IntRange; |
| import android.annotation.NonNull; |
| import android.annotation.RequiresPermission; |
| import android.annotation.SystemApi; |
| import android.annotation.SystemService; |
| import android.content.Context; |
| import android.media.session.MediaSession; |
| import android.media.session.MediaSessionManager; |
| import android.os.Build; |
| import android.os.RemoteException; |
| import android.os.UserHandle; |
| import android.service.media.MediaBrowserService; |
| import android.util.Log; |
| import android.view.KeyEvent; |
| |
| import androidx.annotation.RequiresApi; |
| |
| import com.android.internal.annotations.GuardedBy; |
| import com.android.media.mainline.flags.Flags; |
| import com.android.modules.annotation.MinSdk; |
| import com.android.modules.utils.build.SdkLevel; |
| |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.Objects; |
| import java.util.concurrent.CopyOnWriteArrayList; |
| import java.util.concurrent.Executor; |
| |
| /** |
| * Provides support for interacting with {@link android.media.MediaSession2 MediaSession2s} |
| * that applications have published to express their ongoing media playback state. |
| */ |
| @MinSdk(Build.VERSION_CODES.S) |
| @RequiresApi(Build.VERSION_CODES.S) |
| @SystemService(Context.MEDIA_COMMUNICATION_SERVICE) |
| public class MediaCommunicationManager { |
| private static final String TAG = "MediaCommunicationManager"; |
| |
| /** |
| * The manager version used from beginning. |
| */ |
| private static final int VERSION_1 = 1; |
| |
| /** |
| * Current manager version. |
| */ |
| private static final int CURRENT_VERSION = VERSION_1; |
| |
| private final Context mContext; |
| // Do not access directly use getService(). |
| private IMediaCommunicationService mService; |
| |
| private final Object mLock = new Object(); |
| private final CopyOnWriteArrayList<SessionCallbackRecord> mTokenCallbackRecords = |
| new CopyOnWriteArrayList<>(); |
| |
| @GuardedBy("mLock") |
| private MediaCommunicationServiceCallbackStub mCallbackStub; |
| |
| // TODO: remove this when MCS implements dispatchMediaKeyEvent. |
| private MediaSessionManager mMediaSessionManager; |
| |
| /** |
| * @hide |
| */ |
| public MediaCommunicationManager(@NonNull Context context) { |
| if (!SdkLevel.isAtLeastS()) { |
| throw new UnsupportedOperationException("Android version must be S or greater."); |
| } |
| mContext = context; |
| } |
| |
| /** |
| * Gets the version of this {@link MediaCommunicationManager}. |
| */ |
| public @IntRange(from = 1) int getVersion() { |
| return CURRENT_VERSION; |
| } |
| |
| /** |
| * Notifies that a new {@link MediaSession2} with type {@link Session2Token#TYPE_SESSION} is |
| * created. |
| * @param token newly created session2 token |
| * @hide |
| */ |
| public void notifySession2Created(@NonNull Session2Token token) { |
| Objects.requireNonNull(token, "token shouldn't be null"); |
| if (token.getType() != Session2Token.TYPE_SESSION) { |
| throw new IllegalArgumentException("token's type should be TYPE_SESSION"); |
| } |
| try { |
| getService().notifySession2Created(token); |
| } catch (RemoteException e) { |
| e.rethrowFromSystemServer(); |
| } |
| } |
| |
| /** |
| * Checks whether the remote user is a trusted app. |
| * <p> |
| * An app is trusted if the app holds the |
| * {@link android.Manifest.permission#MEDIA_CONTENT_CONTROL} permission or has an enabled |
| * notification listener. |
| * |
| * @param userInfo The remote user info from either |
| * {@link MediaSession#getCurrentControllerInfo()} or |
| * {@link MediaBrowserService#getCurrentBrowserInfo()}. |
| * @return {@code true} if the remote user is trusted or {@code false} otherwise. |
| * @hide |
| */ |
| public boolean isTrustedForMediaControl(@NonNull MediaSessionManager.RemoteUserInfo userInfo) { |
| Objects.requireNonNull(userInfo, "userInfo shouldn't be null"); |
| if (userInfo.getPackageName() == null) { |
| return false; |
| } |
| try { |
| return getService().isTrusted( |
| userInfo.getPackageName(), userInfo.getPid(), userInfo.getUid()); |
| } catch (RemoteException e) { |
| Log.w(TAG, "Cannot communicate with the service.", e); |
| } |
| return false; |
| } |
| |
| /** |
| * 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> |
| * Gets a list of {@link Session2Token} with type {@link Session2Token#TYPE_SESSION} for the |
| * current user. |
| * <p> |
| * Although this API can be used without any restriction, each session owners can accept or |
| * reject your uses of {@link MediaSession2}. |
| * |
| * @return A list of {@link Session2Token}. |
| */ |
| @NonNull |
| public List<Session2Token> getSession2Tokens() { |
| return getSession2Tokens(UserHandle.myUserId()); |
| } |
| |
| /** |
| * Adds a callback to be notified when the list of active sessions changes. |
| * <p> |
| * This requires the {@link android.Manifest.permission#MEDIA_CONTENT_CONTROL} permission be |
| * held by the calling app. |
| * </p> |
| * @hide |
| */ |
| @SystemApi(client = MODULE_LIBRARIES) |
| @RequiresPermission(MEDIA_CONTENT_CONTROL) |
| public void registerSessionCallback(@CallbackExecutor @NonNull Executor executor, |
| @NonNull SessionCallback callback) { |
| Objects.requireNonNull(executor, "executor must not be null"); |
| Objects.requireNonNull(callback, "callback must not be null"); |
| |
| if (!mTokenCallbackRecords.addIfAbsent( |
| new SessionCallbackRecord(executor, callback))) { |
| Log.w(TAG, "registerSession2TokenCallback: Ignoring the same callback"); |
| return; |
| } |
| synchronized (mLock) { |
| if (mCallbackStub == null) { |
| MediaCommunicationServiceCallbackStub callbackStub = |
| new MediaCommunicationServiceCallbackStub(); |
| try { |
| getService().registerCallback(callbackStub, mContext.getPackageName()); |
| mCallbackStub = callbackStub; |
| } catch (RemoteException ex) { |
| ex.rethrowFromSystemServer(); |
| } |
| } |
| } |
| } |
| |
| /** |
| * Stops receiving active sessions updates on the specified callback. |
| * @hide |
| */ |
| @SystemApi(client = MODULE_LIBRARIES) |
| public void unregisterSessionCallback(@NonNull SessionCallback callback) { |
| if (!mTokenCallbackRecords.remove( |
| new SessionCallbackRecord(null, callback))) { |
| Log.w(TAG, "unregisterSession2TokenCallback: Ignoring an unknown callback."); |
| return; |
| } |
| synchronized (mLock) { |
| if (mCallbackStub != null && mTokenCallbackRecords.isEmpty()) { |
| try { |
| getService().unregisterCallback(mCallbackStub); |
| } catch (RemoteException ex) { |
| Log.e(TAG, "Failed to unregister callback.", ex); |
| } |
| mCallbackStub = null; |
| } |
| } |
| } |
| |
| private IMediaCommunicationService getService() { |
| if (mService == null) { |
| mService = IMediaCommunicationService.Stub.asInterface( |
| MediaFrameworkInitializer.getMediaServiceManager() |
| .getMediaCommunicationServiceRegisterer() |
| .get()); |
| } |
| return mService; |
| } |
| |
| // TODO: remove this when MCS implements dispatchMediaKeyEvent. |
| private MediaSessionManager getMediaSessionManager() { |
| if (mMediaSessionManager == null) { |
| mMediaSessionManager = mContext.getSystemService(MediaSessionManager.class); |
| } |
| return mMediaSessionManager; |
| } |
| |
| private List<Session2Token> getSession2Tokens(int userId) { |
| try { |
| MediaParceledListSlice slice = getService().getSession2Tokens(userId); |
| return slice == null ? Collections.emptyList() : slice.getList(); |
| } catch (RemoteException e) { |
| Log.e(TAG, "Failed to get session tokens", e); |
| } |
| return Collections.emptyList(); |
| } |
| |
| /** |
| * Sends a media key event. The receiver will be selected automatically. |
| * |
| * @param keyEvent the key event to send, non-media key events will be ignored. |
| * @param asSystemService if {@code true}, the event is sent to the session as if it was come |
| * from the system service instead of the app process. It only affects |
| * {@link MediaSession.Callback#getCurrentControllerInfo()}. |
| * @hide |
| */ |
| @SystemApi(client = SystemApi.Client.MODULE_LIBRARIES) |
| public void dispatchMediaKeyEvent(@NonNull KeyEvent keyEvent, boolean asSystemService) { |
| Objects.requireNonNull(keyEvent, "keyEvent shouldn't be null"); |
| |
| // When MCS handles this, caller is changed. |
| // TODO: remove this when MCS implementation is done. |
| if (!asSystemService) { |
| getMediaSessionManager().dispatchMediaKeyEvent(keyEvent, false); |
| return; |
| } |
| |
| try { |
| getService().dispatchMediaKeyEvent(mContext.getPackageName(), |
| keyEvent, asSystemService); |
| } catch (RemoteException e) { |
| Log.e(TAG, "Failed to send key event.", e); |
| } |
| } |
| |
| /** |
| * Callback for listening to changes to the sessions. |
| * @see #registerSessionCallback(Executor, SessionCallback) |
| * @hide |
| */ |
| @SystemApi(client = MODULE_LIBRARIES) |
| public interface SessionCallback { |
| /** |
| * Equivalent to {@link #onSession2TokenCreated(Session2Token, int)}, except |
| * it does not take the pid of the session. |
| * |
| * <p>Not invoked if the implementation overrides {@link |
| * #onSession2TokenCreated(Session2Token, int)}. |
| * |
| * @param token the newly created token |
| */ |
| default void onSession2TokenCreated(@NonNull Session2Token token) {} |
| |
| /** |
| * Called when a new {@link MediaSession2} is created. |
| * |
| * <p>The default implementation calls {@link #onSession2TokenCreated(Session2Token, |
| * int)}. |
| * |
| * @param token the newly created token |
| * @param pid the pid of the process hosting the media session |
| */ |
| @FlaggedApi(Flags.FLAG_ENABLE_PID_TO_MEDIA_SESSION_2) |
| default void onSession2TokenCreated(@NonNull Session2Token token, int pid) { |
| onSession2TokenCreated(token); |
| } |
| |
| /** |
| * Called when {@link #getSession2Tokens() session tokens} are changed. |
| */ |
| default void onSession2TokensChanged(@NonNull List<Session2Token> tokens) {} |
| } |
| |
| private static final class SessionCallbackRecord { |
| public final Executor executor; |
| public final SessionCallback callback; |
| |
| SessionCallbackRecord(Executor executor, SessionCallback callback) { |
| this.executor = executor; |
| this.callback = callback; |
| } |
| |
| @Override |
| public int hashCode() { |
| return Objects.hash(callback); |
| } |
| |
| @Override |
| public boolean equals(Object obj) { |
| if (this == obj) { |
| return true; |
| } |
| if (!(obj instanceof SessionCallbackRecord)) { |
| return false; |
| } |
| return Objects.equals(this.callback, ((SessionCallbackRecord) obj).callback); |
| } |
| } |
| |
| class MediaCommunicationServiceCallbackStub extends IMediaCommunicationServiceCallback.Stub { |
| @Override |
| public void onSession2Created(Session2Token token, int pid) throws RemoteException { |
| for (SessionCallbackRecord record : mTokenCallbackRecords) { |
| record.executor.execute(() -> { |
| if (Flags.enablePidToMediaSession2()) { |
| record.callback.onSession2TokenCreated(token, pid); |
| } else { |
| record.callback.onSession2TokenCreated(token); |
| } |
| }); |
| } |
| } |
| |
| @Override |
| public void onSession2Changed(MediaParceledListSlice tokens) throws RemoteException { |
| List<Session2Token> tokenList = tokens.getList(); |
| for (SessionCallbackRecord record : mTokenCallbackRecords) { |
| record.executor.execute(() -> record.callback.onSession2TokensChanged(tokenList)); |
| } |
| } |
| } |
| } |