blob: ed31aa3d2a39ff6da199df4cf1930f8b1996bc55 [file] [log] [blame]
/*
* 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 com.android.server.media;
import static android.Manifest.permission.INTERACT_ACROSS_USERS_FULL;
import static android.os.UserHandle.ALL;
import static android.os.UserHandle.getUserHandleForUid;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.app.NotificationManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.media.IMediaCommunicationService;
import android.media.IMediaCommunicationServiceCallback;
import android.media.MediaController2;
import android.media.MediaParceledListSlice;
import android.media.Session2CommandGroup;
import android.media.Session2Token;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Process;
import android.os.RemoteException;
import android.os.UserHandle;
import android.os.UserManager;
import android.util.Log;
import android.util.SparseArray;
import android.util.SparseIntArray;
import com.android.internal.annotations.GuardedBy;
import com.android.server.SystemService;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
/**
* A system service that manages {@link android.media.MediaSession2} creations
* and their ongoing media playback state.
* @hide
*/
public class MediaCommunicationService extends SystemService {
private static final String TAG = "MediaCommunicationService";
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
final Context mContext;
final Object mLock = new Object();
final Handler mHandler = new Handler(Looper.getMainLooper());
@GuardedBy("mLock")
private final SparseIntArray mFullUserIds = new SparseIntArray();
@GuardedBy("mLock")
private final SparseArray<FullUserRecord> mUserRecords = new SparseArray<>();
final Executor mRecordExecutor = Executors.newSingleThreadExecutor();
@GuardedBy("mLock")
final List<CallbackRecord> mCallbackRecords = new ArrayList<>();
final NotificationManager mNotificationManager;
public MediaCommunicationService(Context context) {
super(context);
mContext = context;
mNotificationManager = context.getSystemService(NotificationManager.class);
}
@Override
public void onStart() {
publishBinderService(Context.MEDIA_COMMUNICATION_SERVICE, new Stub());
updateUser();
}
@Override
public void onUserStarting(@NonNull TargetUser user) {
if (DEBUG) Log.d(TAG, "onUserStarting: " + user);
updateUser();
}
@Override
public void onUserSwitching(@Nullable TargetUser from, @NonNull TargetUser to) {
if (DEBUG) Log.d(TAG, "onUserSwitching: " + to);
updateUser();
}
@Override
public void onUserStopped(@NonNull TargetUser targetUser) {
int userId = targetUser.getUserHandle().getIdentifier();
if (DEBUG) Log.d(TAG, "onUserStopped: " + userId);
synchronized (mLock) {
FullUserRecord user = getFullUserRecordLocked(userId);
if (user != null) {
if (user.getFullUserId() == userId) {
user.destroyAllSessions();
mUserRecords.remove(userId);
} else {
user.destroySessionsForUser(userId);
}
}
}
updateUser();
}
@Nullable
CallbackRecord findCallbackRecordLocked(@Nullable IMediaCommunicationServiceCallback callback) {
if (callback == null) {
return null;
}
for (CallbackRecord record : mCallbackRecords) {
if (Objects.equals(callback.asBinder(), record.mCallback.asBinder())) {
return record;
}
}
return null;
}
List<Session2Token> getSession2TokensLocked(int userId) {
List<Session2Token> list = new ArrayList<>();
if (userId == ALL.getIdentifier()) {
int size = mUserRecords.size();
for (int i = 0; i < size; i++) {
list.addAll(mUserRecords.valueAt(i).getAllSession2Tokens());
}
} else {
FullUserRecord user = getFullUserRecordLocked(userId);
if (user != null) {
list.addAll(user.getSession2Tokens(userId));
}
}
return list;
}
private FullUserRecord getFullUserRecordLocked(int userId) {
int fullUserId = mFullUserIds.get(userId, -1);
if (fullUserId < 0) {
return null;
}
return mUserRecords.get(fullUserId);
}
private boolean hasMediaControlPermission(int pid, int uid) {
// Check if it's system server or has MEDIA_CONTENT_CONTROL.
// Note that system server doesn't have MEDIA_CONTENT_CONTROL, so we need extra
// check here.
if (uid == Process.SYSTEM_UID || mContext.checkPermission(
android.Manifest.permission.MEDIA_CONTENT_CONTROL, pid, uid)
== PackageManager.PERMISSION_GRANTED) {
return true;
} else if (DEBUG) {
Log.d(TAG, "uid(" + uid + ") hasn't granted MEDIA_CONTENT_CONTROL");
}
return false;
}
private void updateUser() {
UserManager manager = mContext.getSystemService(UserManager.class);
List<UserHandle> allUsers = manager.getUserHandles(/*excludeDying=*/false);
synchronized (mLock) {
mFullUserIds.clear();
if (allUsers != null) {
for (UserHandle user : allUsers) {
UserHandle parent = manager.getProfileParent(user);
if (parent != null) {
mFullUserIds.put(user.getIdentifier(), parent.getIdentifier());
} else {
mFullUserIds.put(user.getIdentifier(), user.getIdentifier());
if (mUserRecords.get(user.getIdentifier()) == null) {
mUserRecords.put(user.getIdentifier(),
new FullUserRecord(user.getIdentifier()));
}
}
}
}
// Ensure that the current full user exists.
int currentFullUserId = ActivityManager.getCurrentUser();
FullUserRecord currentFullUserRecord = mUserRecords.get(currentFullUserId);
if (currentFullUserRecord == null) {
Log.w(TAG, "Cannot find FullUserInfo for the current user " + currentFullUserId);
currentFullUserRecord = new FullUserRecord(currentFullUserId);
mUserRecords.put(currentFullUserId, currentFullUserRecord);
}
mFullUserIds.put(currentFullUserId, currentFullUserId);
}
}
void dispatchSession2Created(Session2Token token) {
synchronized (mLock) {
for (CallbackRecord record : mCallbackRecords) {
if (record.mUserId != ALL.getIdentifier()
&& record.mUserId != getUserHandleForUid(token.getUid()).getIdentifier()) {
continue;
}
try {
record.mCallback.onSession2Created(token);
} catch (RemoteException e) {
Log.w(TAG, "Failed to notify session2 token created " + record);
}
}
}
}
void dispatchSession2Changed(int userId) {
MediaParceledListSlice<Session2Token> allSession2Tokens;
MediaParceledListSlice<Session2Token> userSession2Tokens;
synchronized (mLock) {
allSession2Tokens =
new MediaParceledListSlice<>(getSession2TokensLocked(ALL.getIdentifier()));
userSession2Tokens = new MediaParceledListSlice<>(getSession2TokensLocked(userId));
}
allSession2Tokens.setInlineCountLimit(1);
userSession2Tokens.setInlineCountLimit(1);
synchronized (mLock) {
for (CallbackRecord record : mCallbackRecords) {
if (record.mUserId == ALL.getIdentifier()) {
try {
record.mCallback.onSession2Changed(allSession2Tokens);
} catch (RemoteException e) {
Log.w(TAG, "Failed to notify session2 tokens changed " + record);
}
} else if (record.mUserId == userId) {
try {
record.mCallback.onSession2Changed(userSession2Tokens);
} catch (RemoteException e) {
Log.w(TAG, "Failed to notify session2 tokens changed " + record);
}
}
}
}
}
void onSessionDied(Session2Record session) {
if (DEBUG) {
Log.d(TAG, "Destroying " + session);
}
if (session.isClosed()) {
Log.w(TAG, "Destroying already destroyed session. Ignoring.");
return;
}
FullUserRecord user = session.getFullUser();
if (user != null) {
user.removeSession(session);
}
session.close();
}
private class Stub extends IMediaCommunicationService.Stub {
@Override
public void notifySession2Created(Session2Token sessionToken) {
final int pid = Binder.getCallingPid();
final int uid = Binder.getCallingUid();
final long token = Binder.clearCallingIdentity();
try {
if (DEBUG) {
Log.d(TAG, "Session2 is created " + sessionToken);
}
if (uid != sessionToken.getUid()) {
throw new SecurityException("Unexpected Session2Token's UID, expected=" + uid
+ " but actually=" + sessionToken.getUid());
}
FullUserRecord user;
int userId = getUserHandleForUid(sessionToken.getUid()).getIdentifier();
synchronized (mLock) {
user = getFullUserRecordLocked(userId);
}
if (user == null) {
Log.w(TAG, "notifySession2Created: Ignore session of an unknown user");
return;
}
user.addSession(new Session2Record(MediaCommunicationService.this,
user, sessionToken, mRecordExecutor));
} finally {
Binder.restoreCallingIdentity(token);
}
}
/**
* Returns if the controller's package is trusted (i.e. has either MEDIA_CONTENT_CONTROL
* permission or an enabled notification listener)
*
* @param controllerPackageName package name of the controller app
* @param controllerPid pid of the controller app
* @param controllerUid uid of the controller app
*/
@Override
public boolean isTrusted(String controllerPackageName, int controllerPid,
int controllerUid) {
final int uid = Binder.getCallingUid();
final int userId = UserHandle.getUserHandleForUid(uid).getIdentifier();
final long token = Binder.clearCallingIdentity();
try {
// Don't perform check between controllerPackageName and controllerUid.
// When an (activity|service) runs on the another apps process by specifying
// android:process in the AndroidManifest.xml, then PID and UID would have the
// running process' information instead of the (activity|service) that has created
// MediaController.
// Note that we can use Context#getOpPackageName() instead of
// Context#getPackageName() for getting package name that matches with the PID/UID,
// but it doesn't tell which package has created the MediaController, so useless.
return hasMediaControlPermission(controllerPid, controllerUid)
|| hasEnabledNotificationListener(
userId, controllerPackageName, controllerUid);
} finally {
Binder.restoreCallingIdentity(token);
}
}
@Override
public MediaParceledListSlice getSession2Tokens(int userId) {
final int pid = Binder.getCallingPid();
final int uid = Binder.getCallingUid();
final long token = Binder.clearCallingIdentity();
try {
// Check that they can make calls on behalf of the user and get the final user id
int resolvedUserId = handleIncomingUser(pid, uid, userId, null);
List<Session2Token> result;
synchronized (mLock) {
result = getSession2TokensLocked(resolvedUserId);
}
MediaParceledListSlice parceledListSlice = new MediaParceledListSlice<>(result);
parceledListSlice.setInlineCountLimit(1);
return parceledListSlice;
} finally {
Binder.restoreCallingIdentity(token);
}
}
@Override
public void registerCallback(IMediaCommunicationServiceCallback callback,
String packageName) throws RemoteException {
Objects.requireNonNull(callback, "callback should not be null");
Objects.requireNonNull(packageName, "packageName should not be null");
synchronized (mLock) {
if (findCallbackRecordLocked(callback) == null) {
CallbackRecord record = new CallbackRecord(callback, packageName,
Binder.getCallingUid(), Binder.getCallingPid());
mCallbackRecords.add(record);
try {
callback.asBinder().linkToDeath(record, 0);
} catch (RemoteException e) {
Log.w(TAG, "Failed to register callback", e);
mCallbackRecords.remove(record);
}
} else {
Log.e(TAG, "registerCallback is called with already registered callback. "
+ "packageName=" + packageName);
}
}
}
@Override
public void unregisterCallback(IMediaCommunicationServiceCallback callback)
throws RemoteException {
synchronized (mLock) {
CallbackRecord existingRecord = findCallbackRecordLocked(callback);
if (existingRecord != null) {
mCallbackRecords.remove(existingRecord);
callback.asBinder().unlinkToDeath(existingRecord, 0);
} else {
Log.e(TAG, "unregisterCallback is called with unregistered callback.");
}
}
}
private boolean hasEnabledNotificationListener(int callingUserId,
String controllerPackageName, int controllerUid) {
int controllerUserId = UserHandle.getUserHandleForUid(controllerUid).getIdentifier();
if (callingUserId != controllerUserId) {
// Enabled notification listener only works within the same user.
return false;
}
if (mNotificationManager.hasEnabledNotificationListener(controllerPackageName,
UserHandle.getUserHandleForUid(controllerUid))) {
return true;
}
if (DEBUG) {
Log.d(TAG, controllerPackageName + " (uid=" + controllerUid
+ ") doesn't have an enabled notification listener");
}
return false;
}
// Handles incoming user by checking whether the caller has permission to access the
// given user id's information or not. Permission is not necessary if the given user id is
// equal to the caller's user id, but if not, the caller needs to have the
// INTERACT_ACROSS_USERS_FULL permission. Otherwise, a security exception will be thrown.
// The return value will be the given user id, unless the given user id is
// UserHandle.CURRENT, which will return the ActivityManager.getCurrentUser() value instead.
private int handleIncomingUser(int pid, int uid, int userId, String packageName) {
int callingUserId = UserHandle.getUserHandleForUid(uid).getIdentifier();
if (userId == callingUserId) {
return userId;
}
boolean canInteractAcrossUsersFull = mContext.checkPermission(
INTERACT_ACROSS_USERS_FULL, pid, uid) == PackageManager.PERMISSION_GRANTED;
if (canInteractAcrossUsersFull) {
if (userId == UserHandle.CURRENT.getIdentifier()) {
return ActivityManager.getCurrentUser();
}
return userId;
}
throw new SecurityException("Permission denied while calling from " + packageName
+ " with user id: " + userId + "; Need to run as either the calling user id ("
+ callingUserId + "), or with " + INTERACT_ACROSS_USERS_FULL + " permission");
}
}
final class CallbackRecord implements IBinder.DeathRecipient {
private final IMediaCommunicationServiceCallback mCallback;
private final String mPackageName;
private final int mUid;
private int mPid;
private final int mUserId;
CallbackRecord(IMediaCommunicationServiceCallback callback,
String packageName, int uid, int pid) {
mCallback = callback;
mPackageName = packageName;
mUid = uid;
mPid = pid;
mUserId = (mContext.checkPermission(
INTERACT_ACROSS_USERS_FULL, pid, uid) == PackageManager.PERMISSION_GRANTED)
? ALL.getIdentifier() : UserHandle.getUserHandleForUid(mUid).getIdentifier();
}
@Override
public String toString() {
return "CallbackRecord[callback=" + mCallback + ", pkg=" + mPackageName
+ ", uid=" + mUid + ", pid=" + mPid + "]";
}
@Override
public void binderDied() {
synchronized (mLock) {
mCallbackRecords.remove(this);
}
}
}
final class FullUserRecord {
private final int mFullUserId;
private final Object mUserLock = new Object();
@GuardedBy("mUserLock")
private final List<Session2Record> mSessionRecords = new ArrayList<>();
FullUserRecord(int fullUserId) {
mFullUserId = fullUserId;
}
public void addSession(Session2Record record) {
synchronized (mUserLock) {
mSessionRecords.add(record);
}
mHandler.post(() -> dispatchSession2Created(record.mSessionToken));
mHandler.post(() -> dispatchSession2Changed(mFullUserId));
}
private void removeSession(Session2Record record) {
synchronized (mUserLock) {
mSessionRecords.remove(record);
}
mHandler.post(() -> dispatchSession2Changed(mFullUserId));
//TODO: Handle if the removed session was the media button session.
}
public int getFullUserId() {
return mFullUserId;
}
public List<Session2Token> getAllSession2Tokens() {
synchronized (mUserLock) {
return mSessionRecords.stream()
.map(Session2Record::getSessionToken)
.collect(Collectors.toList());
}
}
public List<Session2Token> getSession2Tokens(int userId) {
synchronized (mUserLock) {
return mSessionRecords.stream()
.filter(record -> record.getUserId() == userId)
.map(Session2Record::getSessionToken)
.collect(Collectors.toList());
}
}
public void destroyAllSessions() {
synchronized (mUserLock) {
for (Session2Record session : mSessionRecords) {
session.close();
}
mSessionRecords.clear();
}
mHandler.post(() -> dispatchSession2Changed(mFullUserId));
}
public void destroySessionsForUser(int userId) {
boolean changed = false;
synchronized (mUserLock) {
for (int i = mSessionRecords.size() - 1; i >= 0; i--) {
Session2Record session = mSessionRecords.get(i);
if (session.getUserId() == userId) {
mSessionRecords.remove(i);
session.close();
changed = true;
}
}
}
if (changed) {
mHandler.post(() -> dispatchSession2Changed(mFullUserId));
}
}
}
static final class Session2Record {
final Session2Token mSessionToken;
final Object mSession2RecordLock = new Object();
final WeakReference<MediaCommunicationService> mServiceRef;
final WeakReference<FullUserRecord> mFullUserRef;
@GuardedBy("mSession2RecordLock")
private final MediaController2 mController;
@GuardedBy("mSession2RecordLock")
boolean mIsConnected;
@GuardedBy("mSession2RecordLock")
private boolean mIsClosed;
Session2Record(MediaCommunicationService service, FullUserRecord fullUser,
Session2Token token, Executor controllerExecutor) {
mServiceRef = new WeakReference<>(service);
mFullUserRef = new WeakReference<>(fullUser);
mSessionToken = token;
mController = new MediaController2.Builder(service.getContext(), token)
.setControllerCallback(controllerExecutor, new Controller2Callback())
.build();
}
public int getUserId() {
return UserHandle.getUserHandleForUid(mSessionToken.getUid()).getIdentifier();
}
public FullUserRecord getFullUser() {
return mFullUserRef.get();
}
public boolean isClosed() {
synchronized (mSession2RecordLock) {
return mIsClosed;
}
}
public void close() {
synchronized (mSession2RecordLock) {
mIsClosed = true;
mController.close();
}
}
public Session2Token getSessionToken() {
return mSessionToken;
}
private class Controller2Callback extends MediaController2.ControllerCallback {
@Override
public void onConnected(MediaController2 controller,
Session2CommandGroup allowedCommands) {
if (DEBUG) {
Log.d(TAG, "connected to " + mSessionToken + ", allowed=" + allowedCommands);
}
synchronized (mSession2RecordLock) {
mIsConnected = true;
}
}
@Override
public void onDisconnected(MediaController2 controller) {
if (DEBUG) {
Log.d(TAG, "disconnected from " + mSessionToken);
}
synchronized (mSession2RecordLock) {
mIsConnected = false;
}
MediaCommunicationService service = mServiceRef.get();
if (service != null) {
service.onSessionDied(Session2Record.this);
}
}
}
}
}