Block camera access for apps running on VirtualDevices

Bug: 170935709
Test: atest CameraAccessControllerTest
Change-Id: I15beeb0d4aa5aa3588e70ef37542192997121499
This commit is contained in:
Antony Sargent
2022-02-06 19:11:23 -08:00
parent c0715a6c1e
commit d1528caffc
7 changed files with 464 additions and 2 deletions

View File

@@ -6266,4 +6266,8 @@ ul.</string>
</string>
<!-- Action label of notification for user to check background apps. [CHAR LIMIT=NONE] -->
<string name="notification_action_check_bg_apps">Check active apps</string>
<!-- Strings for VirtualDeviceManager -->
<!-- Error message indicating the camera cannot be accessed when running on a virtual device. [CHAR LIMIT=NONE] -->
<string name="vdm_camera_access_denied">Cannot access camera from this device</string>
</resources>

View File

@@ -4724,5 +4724,8 @@
<java-symbol type="bool" name="config_lowPowerStandbyEnabledByDefault" />
<java-symbol type="integer" name="config_lowPowerStandbyNonInteractiveTimeout" />
<!-- For VirtualDeviceManager -->
<java-symbol type="string" name="vdm_camera_access_denied" />
<java-symbol type="color" name="camera_privacy_light"/>
</resources>

View File

@@ -0,0 +1,212 @@
/*
* Copyright (C) 2022 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.companion.virtual;
import static android.hardware.camera2.CameraInjectionSession.InjectionStatusCallback.ERROR_INJECTION_UNSUPPORTED;
import android.annotation.NonNull;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraInjectionSession;
import android.hardware.camera2.CameraManager;
import android.util.ArrayMap;
import android.util.Slog;
import com.android.internal.annotations.GuardedBy;
/**
* Handles blocking access to the camera for apps running on virtual devices.
*/
class CameraAccessController extends CameraManager.AvailabilityCallback {
private static final String TAG = "CameraAccessController";
private final Object mLock = new Object();
private final Context mContext;
private VirtualDeviceManagerInternal mVirtualDeviceManagerInternal;
CameraAccessBlockedCallback mBlockedCallback;
private CameraManager mCameraManager;
private boolean mListeningForCameraEvents;
private PackageManager mPackageManager;
@GuardedBy("mLock")
private ArrayMap<String, InjectionSessionData> mPackageToSessionData = new ArrayMap<>();
static class InjectionSessionData {
public int appUid;
public ArrayMap<String, CameraInjectionSession> cameraIdToSession = new ArrayMap<>();
}
interface CameraAccessBlockedCallback {
/**
* Called whenever an app was blocked from accessing a camera.
* @param appUid uid for the app which was blocked
*/
void onCameraAccessBlocked(int appUid);
}
CameraAccessController(Context context,
VirtualDeviceManagerInternal virtualDeviceManagerInternal,
CameraAccessBlockedCallback blockedCallback) {
mContext = context;
mVirtualDeviceManagerInternal = virtualDeviceManagerInternal;
mBlockedCallback = blockedCallback;
mCameraManager = mContext.getSystemService(CameraManager.class);
mPackageManager = mContext.getPackageManager();
}
/**
* Starts watching for camera access by uids running on a virtual device, if we were not
* already doing so.
*/
public void startObservingIfNeeded() {
synchronized (mLock) {
if (!mListeningForCameraEvents) {
mCameraManager.registerAvailabilityCallback(mContext.getMainExecutor(), this);
mListeningForCameraEvents = true;
}
}
}
/**
* Stop watching for camera access.
*/
public void stopObserving() {
synchronized (mLock) {
mCameraManager.unregisterAvailabilityCallback(this);
mListeningForCameraEvents = false;
}
}
@Override
public void onCameraOpened(@NonNull String cameraId, @NonNull String packageName) {
synchronized (mLock) {
try {
final ApplicationInfo ainfo =
mPackageManager.getApplicationInfo(packageName, 0);
InjectionSessionData data = mPackageToSessionData.get(packageName);
if (!mVirtualDeviceManagerInternal.isAppRunningOnAnyVirtualDevice(ainfo.uid)) {
CameraInjectionSession existingSession =
(data != null) ? data.cameraIdToSession.get(cameraId) : null;
if (existingSession != null) {
existingSession.close();
data.cameraIdToSession.remove(cameraId);
if (data.cameraIdToSession.isEmpty()) {
mPackageToSessionData.remove(packageName);
}
}
return;
}
if (data == null) {
data = new InjectionSessionData();
data.appUid = ainfo.uid;
mPackageToSessionData.put(packageName, data);
}
if (data.cameraIdToSession.containsKey(cameraId)) {
return;
}
startBlocking(packageName, cameraId);
} catch (PackageManager.NameNotFoundException e) {
Slog.e(TAG, "onCameraOpened - unknown package " + packageName, e);
return;
}
}
}
@Override
public void onCameraClosed(@NonNull String cameraId) {
synchronized (mLock) {
for (int i = mPackageToSessionData.size() - 1; i >= 0; i--) {
InjectionSessionData data = mPackageToSessionData.valueAt(i);
CameraInjectionSession session = data.cameraIdToSession.get(cameraId);
if (session != null) {
session.close();
data.cameraIdToSession.remove(cameraId);
if (data.cameraIdToSession.isEmpty()) {
mPackageToSessionData.removeAt(i);
}
}
}
}
}
/**
* Turns on blocking for a particular camera and package.
*/
private void startBlocking(String packageName, String cameraId) {
try {
mCameraManager.injectCamera(packageName, cameraId, /* externalCamId */ "",
mContext.getMainExecutor(),
new CameraInjectionSession.InjectionStatusCallback() {
@Override
public void onInjectionSucceeded(
@NonNull CameraInjectionSession session) {
CameraAccessController.this.onInjectionSucceeded(cameraId, packageName,
session);
}
@Override
public void onInjectionError(@NonNull int errorCode) {
CameraAccessController.this.onInjectionError(cameraId, packageName,
errorCode);
}
});
} catch (CameraAccessException e) {
Slog.e(TAG,
"Failed to injectCamera for cameraId:" + cameraId + " package:" + packageName,
e);
}
}
private void onInjectionSucceeded(String cameraId, String packageName,
@NonNull CameraInjectionSession session) {
synchronized (mLock) {
InjectionSessionData data = mPackageToSessionData.get(packageName);
if (data == null) {
Slog.e(TAG, "onInjectionSucceeded didn't find expected entry for package "
+ packageName);
session.close();
return;
}
CameraInjectionSession existingSession = data.cameraIdToSession.put(cameraId, session);
if (existingSession != null) {
Slog.e(TAG, "onInjectionSucceeded found unexpected existing session for camera "
+ cameraId);
existingSession.close();
}
}
}
private void onInjectionError(String cameraId, String packageName, @NonNull int errorCode) {
if (errorCode != ERROR_INJECTION_UNSUPPORTED) {
// ERROR_INJECTION_UNSUPPORTED means that there wasn't an external camera to map to the
// internal camera, which is expected when using the injection interface as we are in
// this class to simply block camera access. Any other error is unexpected.
Slog.e(TAG, "Unexpected injection error code:" + errorCode + " for camera:" + cameraId
+ " and package:" + packageName);
return;
}
synchronized (mLock) {
InjectionSessionData data = mPackageToSessionData.get(packageName);
if (data != null) {
mBlockedCallback.onCameraAccessBlocked(data.appUid);
}
}
}
}

View File

@@ -24,6 +24,7 @@ import static android.view.WindowManager.LayoutParams.SYSTEM_FLAG_HIDE_NON_SYSTE
import android.annotation.NonNull;
import android.annotation.RequiresPermission;
import android.annotation.StringRes;
import android.app.Activity;
import android.app.ActivityOptions;
import android.app.PendingIntent;
@@ -57,6 +58,8 @@ import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Slog;
import android.util.SparseArray;
import android.view.Display;
import android.widget.Toast;
import android.window.DisplayWindowPolicyController;
import com.android.internal.annotations.GuardedBy;
@@ -583,6 +586,26 @@ final class VirtualDeviceImpl extends IVirtualDevice.Stub
return false;
}
/**
* Shows a toast on virtual displays owned by this device which have a given uid running.
*/
void showToastWhereUidIsRunning(int uid, @StringRes int resId, @Toast.Duration int duration) {
synchronized (mVirtualDeviceLock) {
DisplayManager displayManager = mContext.getSystemService(DisplayManager.class);
final int size = mWindowPolicyControllers.size();
for (int i = 0; i < size; i++) {
if (mWindowPolicyControllers.valueAt(i).containsUid(uid)) {
int displayId = mWindowPolicyControllers.keyAt(i);
Display display = displayManager.getDisplay(displayId);
if (display != null && display.isValid()) {
Toast.makeText(mContext.createDisplayContext(display), resId,
duration).show();
}
}
}
}
}
interface OnDeviceCloseListener {
void onClose(int associationId);
}

View File

@@ -39,6 +39,7 @@ import android.os.RemoteException;
import android.util.ExceptionUtils;
import android.util.Slog;
import android.util.SparseArray;
import android.widget.Toast;
import android.window.DisplayWindowPolicyController;
import com.android.internal.annotations.GuardedBy;
@@ -62,8 +63,10 @@ public class VirtualDeviceManagerService extends SystemService {
private final Object mVirtualDeviceManagerLock = new Object();
private final VirtualDeviceManagerImpl mImpl;
private VirtualDeviceManagerInternal mLocalService;
private final Handler mHandler = new Handler(Looper.getMainLooper());
private final PendingTrampolineMap mPendingTrampolines = new PendingTrampolineMap(mHandler);
private final CameraAccessController mCameraAccessController;
/**
* Mapping from CDM association IDs to virtual devices. Only one virtual device is allowed for
@@ -90,6 +93,9 @@ public class VirtualDeviceManagerService extends SystemService {
public VirtualDeviceManagerService(Context context) {
super(context);
mImpl = new VirtualDeviceManagerImpl();
mLocalService = new LocalService();
mCameraAccessController = new CameraAccessController(getContext(), mLocalService,
this::onCameraAccessBlocked);
}
private final ActivityInterceptorCallback mActivityInterceptorCallback =
@@ -118,8 +124,7 @@ public class VirtualDeviceManagerService extends SystemService {
@Override
public void onStart() {
publishBinderService(Context.VIRTUAL_DEVICE_SERVICE, mImpl);
publishLocalService(VirtualDeviceManagerInternal.class, new LocalService());
publishLocalService(VirtualDeviceManagerInternal.class, mLocalService);
ActivityTaskManagerInternal activityTaskManagerInternal = getLocalService(
ActivityTaskManagerInternal.class);
activityTaskManagerInternal.registerActivityStartInterceptor(
@@ -169,6 +174,16 @@ public class VirtualDeviceManagerService extends SystemService {
}
}
void onCameraAccessBlocked(int appUid) {
synchronized (mVirtualDeviceManagerLock) {
int size = mVirtualDevices.size();
for (int i = 0; i < size; i++) {
mVirtualDevices.valueAt(i).showToastWhereUidIsRunning(appUid,
com.android.internal.R.string.vdm_camera_access_denied, Toast.LENGTH_LONG);
}
}
}
class VirtualDeviceManagerImpl extends IVirtualDeviceManager.Stub implements
VirtualDeviceImpl.PendingTrampolineCallback {
@@ -205,10 +220,14 @@ public class VirtualDeviceManagerService extends SystemService {
public void onClose(int associationId) {
synchronized (mVirtualDeviceManagerLock) {
mVirtualDevices.remove(associationId);
if (mVirtualDevices.size() == 0) {
mCameraAccessController.stopObserving();
}
}
}
},
this, activityListener, params);
mCameraAccessController.startObservingIfNeeded();
mVirtualDevices.put(associationInfo.getId(), virtualDevice);
return virtualDevice;
}

View File

@@ -53,6 +53,7 @@ android_test {
"service-jobscheduler",
"service-permission.impl",
"service-supplementalprocess.impl",
"services.companion",
"services.core",
"services.devicepolicy",
"services.net",

View File

@@ -0,0 +1,200 @@
/*
* Copyright (C) 2022 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.companion.virtual;
import static android.hardware.camera2.CameraInjectionSession.InjectionStatusCallback.ERROR_INJECTION_UNSUPPORTED;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraInjectionSession;
import android.hardware.camera2.CameraManager;
import android.os.Process;
import android.testing.TestableContext;
import androidx.test.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.android.server.LocalServices;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
@RunWith(AndroidJUnit4.class)
public class CameraAccessControllerTest {
private static final String FRONT_CAMERA = "0";
private static final String REAR_CAMERA = "1";
private static final String TEST_APP_PACKAGE = "some.package";
private static final String OTHER_APP_PACKAGE = "other.package";
private CameraAccessController mController;
@Rule
public final TestableContext mContext = new TestableContext(
InstrumentationRegistry.getContext());
@Mock
private CameraManager mCameraManager;
@Mock
private PackageManager mPackageManager;
@Mock
private VirtualDeviceManagerInternal mDeviceManagerInternal;
@Mock
private CameraAccessController.CameraAccessBlockedCallback mBlockedCallback;
private ApplicationInfo mTestAppInfo = new ApplicationInfo();
private ApplicationInfo mOtherAppInfo = new ApplicationInfo();
@Captor
ArgumentCaptor<CameraInjectionSession.InjectionStatusCallback> mInjectionCallbackCaptor;
@Before
public void setUp() throws PackageManager.NameNotFoundException {
MockitoAnnotations.initMocks(this);
mContext.addMockSystemService(CameraManager.class, mCameraManager);
mContext.setMockPackageManager(mPackageManager);
LocalServices.removeServiceForTest(VirtualDeviceManagerInternal.class);
LocalServices.addService(VirtualDeviceManagerInternal.class, mDeviceManagerInternal);
mController = new CameraAccessController(mContext, mDeviceManagerInternal,
mBlockedCallback);
mTestAppInfo.uid = Process.FIRST_APPLICATION_UID;
mOtherAppInfo.uid = Process.FIRST_APPLICATION_UID + 1;
when(mPackageManager.getApplicationInfo(eq(TEST_APP_PACKAGE), anyInt())).thenReturn(
mTestAppInfo);
when(mPackageManager.getApplicationInfo(eq(OTHER_APP_PACKAGE), anyInt())).thenReturn(
mOtherAppInfo);
mController.startObservingIfNeeded();
}
@Test
public void onCameraOpened_uidNotRunning_noCameraBlocking() throws CameraAccessException {
when(mDeviceManagerInternal.isAppRunningOnAnyVirtualDevice(
eq(mTestAppInfo.uid))).thenReturn(false);
mController.onCameraOpened(FRONT_CAMERA, TEST_APP_PACKAGE);
verify(mCameraManager, never()).injectCamera(any(), any(), any(), any(), any());
}
@Test
public void onCameraOpened_uidRunning_cameraBlocked() throws CameraAccessException {
when(mDeviceManagerInternal.isAppRunningOnAnyVirtualDevice(
eq(mTestAppInfo.uid))).thenReturn(true);
mController.onCameraOpened(FRONT_CAMERA, TEST_APP_PACKAGE);
verify(mCameraManager).injectCamera(eq(TEST_APP_PACKAGE), eq(FRONT_CAMERA), anyString(),
any(), any());
}
@Test
public void onCameraClosed_injectionWasActive_cameraUnblocked() throws CameraAccessException {
when(mDeviceManagerInternal.isAppRunningOnAnyVirtualDevice(
eq(mTestAppInfo.uid))).thenReturn(true);
mController.onCameraOpened(FRONT_CAMERA, TEST_APP_PACKAGE);
verify(mCameraManager).injectCamera(eq(TEST_APP_PACKAGE), eq(FRONT_CAMERA), anyString(),
any(), mInjectionCallbackCaptor.capture());
CameraInjectionSession session = mock(CameraInjectionSession.class);
mInjectionCallbackCaptor.getValue().onInjectionSucceeded(session);
mController.onCameraClosed(FRONT_CAMERA);
verify(session).close();
}
@Test
public void onCameraClosed_otherCameraClosed_cameraNotUnblocked() throws CameraAccessException {
when(mDeviceManagerInternal.isAppRunningOnAnyVirtualDevice(
eq(mTestAppInfo.uid))).thenReturn(true);
mController.onCameraOpened(FRONT_CAMERA, TEST_APP_PACKAGE);
verify(mCameraManager).injectCamera(eq(TEST_APP_PACKAGE), eq(FRONT_CAMERA), anyString(),
any(), mInjectionCallbackCaptor.capture());
CameraInjectionSession session = mock(CameraInjectionSession.class);
mInjectionCallbackCaptor.getValue().onInjectionSucceeded(session);
mController.onCameraClosed(REAR_CAMERA);
verify(session, never()).close();
}
@Test
public void onCameraClosed_twoCamerasBlocked_correctCameraUnblocked()
throws CameraAccessException {
when(mDeviceManagerInternal.isAppRunningOnAnyVirtualDevice(
eq(mTestAppInfo.uid))).thenReturn(true);
when(mDeviceManagerInternal.isAppRunningOnAnyVirtualDevice(
eq(mOtherAppInfo.uid))).thenReturn(true);
mController.onCameraOpened(FRONT_CAMERA, TEST_APP_PACKAGE);
mController.onCameraOpened(REAR_CAMERA, OTHER_APP_PACKAGE);
verify(mCameraManager).injectCamera(eq(TEST_APP_PACKAGE), eq(FRONT_CAMERA), anyString(),
any(), mInjectionCallbackCaptor.capture());
CameraInjectionSession frontCameraSession = mock(CameraInjectionSession.class);
mInjectionCallbackCaptor.getValue().onInjectionSucceeded(frontCameraSession);
verify(mCameraManager).injectCamera(eq(OTHER_APP_PACKAGE), eq(REAR_CAMERA), anyString(),
any(), mInjectionCallbackCaptor.capture());
CameraInjectionSession rearCameraSession = mock(CameraInjectionSession.class);
mInjectionCallbackCaptor.getValue().onInjectionSucceeded(rearCameraSession);
mController.onCameraClosed(REAR_CAMERA);
verify(frontCameraSession, never()).close();
verify(rearCameraSession).close();
}
@Test
public void onInjectionError_callbackFires() throws CameraAccessException {
when(mDeviceManagerInternal.isAppRunningOnAnyVirtualDevice(
eq(mTestAppInfo.uid))).thenReturn(true);
mController.onCameraOpened(FRONT_CAMERA, TEST_APP_PACKAGE);
verify(mCameraManager).injectCamera(eq(TEST_APP_PACKAGE), eq(FRONT_CAMERA), anyString(),
any(), mInjectionCallbackCaptor.capture());
CameraInjectionSession session = mock(CameraInjectionSession.class);
mInjectionCallbackCaptor.getValue().onInjectionSucceeded(session);
mInjectionCallbackCaptor.getValue().onInjectionError(ERROR_INJECTION_UNSUPPORTED);
verify(mBlockedCallback).onCameraAccessBlocked(eq(mTestAppInfo.uid));
}
@Test
public void twoCameraAccesses_onlyOneOnVirtualDisplay_callbackFiresForCorrectUid()
throws CameraAccessException {
when(mDeviceManagerInternal.isAppRunningOnAnyVirtualDevice(
eq(mTestAppInfo.uid))).thenReturn(true);
mController.onCameraOpened(FRONT_CAMERA, TEST_APP_PACKAGE);
mController.onCameraOpened(REAR_CAMERA, OTHER_APP_PACKAGE);
verify(mCameraManager).injectCamera(eq(TEST_APP_PACKAGE), eq(FRONT_CAMERA), anyString(),
any(), mInjectionCallbackCaptor.capture());
CameraInjectionSession session = mock(CameraInjectionSession.class);
mInjectionCallbackCaptor.getValue().onInjectionSucceeded(session);
mInjectionCallbackCaptor.getValue().onInjectionError(ERROR_INJECTION_UNSUPPORTED);
verify(mBlockedCallback).onCameraAccessBlocked(eq(mTestAppInfo.uid));
}
}