diff options
7 files changed, 464 insertions, 2 deletions
diff --git a/core/res/res/values/strings.xml b/core/res/res/values/strings.xml index 6297ed90fcba..cb209ab15f03 100644 --- a/core/res/res/values/strings.xml +++ b/core/res/res/values/strings.xml @@ -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> diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml index d731180bd56a..e10ed2434676 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -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> diff --git a/services/companion/java/com/android/server/companion/virtual/CameraAccessController.java b/services/companion/java/com/android/server/companion/virtual/CameraAccessController.java new file mode 100644 index 000000000000..2c42c910b9ab --- /dev/null +++ b/services/companion/java/com/android/server/companion/virtual/CameraAccessController.java @@ -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); + } + } + } +} diff --git a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java index 3fa1c865bda1..2f6a46a02338 100644 --- a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java +++ b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceImpl.java @@ -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); } diff --git a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceManagerService.java b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceManagerService.java index b507110d5473..7842697d6871 100644 --- a/services/companion/java/com/android/server/companion/virtual/VirtualDeviceManagerService.java +++ b/services/companion/java/com/android/server/companion/virtual/VirtualDeviceManagerService.java @@ -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; } diff --git a/services/tests/mockingservicestests/Android.bp b/services/tests/mockingservicestests/Android.bp index 75669d50c75d..3e60af33e408 100644 --- a/services/tests/mockingservicestests/Android.bp +++ b/services/tests/mockingservicestests/Android.bp @@ -53,6 +53,7 @@ android_test { "service-jobscheduler", "service-permission.impl", "service-supplementalprocess.impl", + "services.companion", "services.core", "services.devicepolicy", "services.net", diff --git a/services/tests/mockingservicestests/src/com/android/server/companion/virtual/CameraAccessControllerTest.java b/services/tests/mockingservicestests/src/com/android/server/companion/virtual/CameraAccessControllerTest.java new file mode 100644 index 000000000000..1b9cb28dc8b2 --- /dev/null +++ b/services/tests/mockingservicestests/src/com/android/server/companion/virtual/CameraAccessControllerTest.java @@ -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)); + } +} |