Camera Compat: Track cameraId, and the app and task that had it opened.
This allows the camera policies to restore task and app config if a
different task takes over camera. The existing solution can override
mCameraTask when a new task takes over camera: due to a deliberate
difference in open and close delays, onCameraOpened for task2 will
likely come before onCameraClosed for task1 when cameras are
switched quickly. In this case, task2 will override task1 in
mCameraTask, and task1 will not be restored onCameraClosed.
This change also allows Policies to receive a single onCameraOpened and
onCameraClosed per task, even when refreshing the activity or switching
cameras, thus abstracting away cameraId, and open/close signals that
are irrelevant for camera compat policies, like switching cameras,
resizing, or finishing camera compat mode setup.
Flag: com.android.window.flags.enable_camera_compat_track_task_and_app_bugfix
Test: atest WmTests:CameraCompatFreeformPolicyTests
Fixes: 380840084
Change-Id: I6422a86672c2c619901baea82c8e87443e4da7fc
diff --git a/services/core/java/com/android/server/wm/AppCompatCameraStateStrategyForTask.java b/services/core/java/com/android/server/wm/AppCompatCameraStateStrategyForTask.java
new file mode 100644
index 0000000..8e8ce86
--- /dev/null
+++ b/services/core/java/com/android/server/wm/AppCompatCameraStateStrategyForTask.java
@@ -0,0 +1,283 @@
+/*
+ * Copyright (C) 2025 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.wm;
+
+import static android.app.ActivityTaskManager.INVALID_TASK_ID;
+import static android.os.Process.INVALID_PID;
+
+import static com.android.internal.protolog.WmProtoLogGroups.WM_DEBUG_STATES;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+
+import com.android.internal.protolog.ProtoLog;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+/** {@link AppCompatCameraStateStrategy} that tracks task-cameraId (and app) pairs. */
+class AppCompatCameraStateStrategyForTask implements AppCompatCameraStateStrategy {
+ // Data set for app data and active camera IDs since we need to 1) get a camera id by a task
+ // when setting up camera compat mode; 2) get a task by a camera id when camera connection is
+ // closed and we need to clean up our records.
+ private final CameraIdAppPairsSet mCameraAppInfoSet = new CameraIdAppPairsSet();
+
+ // Repository for the newest camera state update. Camera opened and closed signals are processed
+ // with a delay. In case of different signals/states pending without being processed, only the
+ // newest state will be processed, and the old overwritten.
+ private final PendingCameraUpdateRepository mPendingCameraUpdateRepository =
+ new PendingCameraUpdateRepository();
+
+ @NonNull
+ private final DisplayContent mDisplayContent;
+
+ AppCompatCameraStateStrategyForTask(@NonNull DisplayContent displayContent) {
+ mDisplayContent = displayContent;
+ }
+
+ @Override
+ @NonNull
+ public CameraAppInfo trackOnCameraOpened(@NonNull String cameraId,
+ @NonNull String packageName) {
+ final CameraAppInfo cameraAppInfo = createCameraAppInfo(cameraId, packageName);
+ ProtoLog.v(WM_DEBUG_STATES,
+ "Display id=%d is notified that Camera %s is open for package %s",
+ mDisplayContent.mDisplayId, cameraId, packageName);
+ mPendingCameraUpdateRepository.trackPendingCameraOpen(cameraAppInfo);
+ return cameraAppInfo;
+ }
+
+ @Override
+ public void notifyPolicyCameraOpenedIfNeeded(@NonNull CameraAppInfo cameraAppInfo,
+ @NonNull AppCompatCameraStatePolicy policy) {
+ if (!mPendingCameraUpdateRepository.removePendingCameraOpen(cameraAppInfo)) {
+ // Camera compat mode update has happened already or was cancelled
+ // because camera was closed.
+ return;
+ }
+
+ final WindowProcessController cameraApp = getAppProcessForCallingId(cameraAppInfo.mPid);
+
+ if (cameraApp == null) {
+ return;
+ }
+ final ActivityRecord cameraActivity = findUniqueActivityWithPackageName(
+ cameraApp.mInfo.packageName);
+
+ if (cameraActivity == null || cameraActivity.getTask() == null) {
+ return;
+ }
+
+ // TODO(b/423883666): Use `WM_DEBUG_CAMERA_COMPAT`.
+ ProtoLog.v(WM_DEBUG_STATES, "CameraOpen: cameraApp=" + cameraApp
+ + " cameraInfo.mPid=" + cameraAppInfo.mPid
+ + " cameraTask=" + cameraActivity.getTask()
+ + " cameraAppInfo.mTaskId=" + cameraAppInfo.mTaskId);
+
+ final boolean anyCameraAlreadyOpenForTask = mCameraAppInfoSet
+ .containsAnyCameraForTaskId(cameraActivity.getTask().mTaskId);
+
+ mCameraAppInfoSet.add(cameraAppInfo);
+ if (!anyCameraAlreadyOpenForTask) {
+ // Only notify listeners if the app has newly opened camera.
+ // This does not currently support multiple camera tasks in a single app - this
+ // would be a very rare use case (especially for targeted fixed-orientation apps).
+ // Given that the camera framework notifies CameraStateMonitor with a packageName
+ // and not a task or activity, it would be difficult to correctly and consistently
+ // know which task has camera access.
+ //
+ // The above check is for whether the same task has opened camera, which usually
+ // means either the camera was restarted due to config change, or because the app
+ // switched between front and back cameras - either way this is not interesting for
+ // camera policies.
+ // Note: if any camera policy ever needs to dynamically change the treatment based
+ // on the camera (front, back, external) this should notify when camera changes and
+ // add a method policies can call to check if camera has been running (mostly used
+ // to return early).
+ policy.onCameraOpened(cameraApp, cameraActivity.getTask());
+ }
+ }
+
+ @Override
+ @NonNull
+ public CameraAppInfo trackOnCameraClosed(@NonNull String cameraId) {
+ // This function is synchronous, and cameraClosed signal will come before cameraOpened.
+ // Therefore, there will be only one app recorded with this camera opened.
+ CameraAppInfo cameraAppInfo = mCameraAppInfoSet.getAnyCameraAppStateForCameraId(cameraId);
+ if (cameraAppInfo == null) {
+ ProtoLog.w(WM_DEBUG_STATES, "Camera closed but cannot find the app which had it"
+ + " opened.");
+ cameraAppInfo = new CameraAppInfo(cameraId, INVALID_PID, INVALID_TASK_ID, null);
+ }
+ mPendingCameraUpdateRepository.trackPendingCameraClose(cameraAppInfo);
+ return cameraAppInfo;
+ }
+
+ @Override
+ public boolean notifyPolicyCameraClosedIfNeeded(@NonNull CameraAppInfo cameraAppInfo,
+ @NonNull AppCompatCameraStatePolicy policy) {
+ if (!mPendingCameraUpdateRepository.removePendingCameraClose(cameraAppInfo)) {
+ // Already reconnected to this camera, no need to clean up.
+ return true;
+ }
+
+ final Task cameraTask = mDisplayContent.getTask(task ->
+ task.getTaskInfo().taskId == cameraAppInfo.mTaskId);
+ final boolean canClose = cameraTask == null
+ || policy.canCameraBeClosed(cameraAppInfo.mCameraId, cameraTask);
+ if (canClose) {
+ // Finish cleaning up. Remove only cameraId of this particular task.
+ mCameraAppInfoSet.remove(cameraAppInfo);
+ if (!mCameraAppInfoSet.containsAnyCameraForTaskId(cameraAppInfo.mTaskId)) {
+ final WindowProcessController app = getAppProcessForCallingId(
+ cameraAppInfo.mPid);
+ // Only notify the listeners if the camera is not running - this close signal
+ // could be from switching cameras (e.g. back to front camera, and vice versa).
+ policy.onCameraClosed(app, cameraTask);
+ }
+ }
+
+ return canClose;
+ }
+
+ @Override
+ public boolean isCameraRunningForActivity(@NonNull ActivityRecord activity) {
+ return activity.getTask() != null && mCameraAppInfoSet
+ .containsAnyCameraForTaskId(activity.getTask().mTaskId);
+ }
+
+ // TODO(b/336474959): try to decouple `cameraId` from the listeners.
+ @Override
+ public boolean isCameraWithIdRunningForActivity(@NonNull ActivityRecord activity,
+ @NonNull String cameraId) {
+ return activity.getTask() != null && mCameraAppInfoSet
+ .containsCameraIdAndTask(cameraId, activity.getTask().mTaskId);
+ }
+
+ @NonNull
+ private CameraAppInfo createCameraAppInfo(@NonNull String cameraId,
+ @Nullable String packageName) {
+ final ActivityRecord cameraActivity = packageName == null ? null
+ : findUniqueActivityWithPackageName(packageName);
+ final Task cameraTask = cameraActivity == null ? null : cameraActivity.getTask();
+ final WindowProcessController cameraApp = cameraActivity == null ? null
+ : cameraActivity.app;
+ return new CameraAppInfo(cameraId,
+ cameraApp == null ? INVALID_PID : cameraApp.getPid(),
+ cameraTask == null ? INVALID_TASK_ID : cameraTask.mTaskId,
+ packageName);
+ }
+
+ // TODO(b/335165310): verify that this works in multi instance and permission dialogs.
+ /**
+ * Finds a visible activity with the given package name.
+ *
+ * <p>If there are multiple visible activities with a given package name, and none of them are
+ * the `topRunningActivity`, returns null.
+ */
+ @Nullable
+ private ActivityRecord findUniqueActivityWithPackageName(@NonNull String packageName) {
+ final ActivityRecord topActivity = mDisplayContent.topRunningActivity(
+ /* considerKeyguardState= */ true);
+ if (topActivity != null && topActivity.packageName.equals(packageName)) {
+ return topActivity;
+ }
+
+ final List<ActivityRecord> activitiesOfPackageWhichOpenedCamera = new ArrayList<>();
+ mDisplayContent.forAllActivities(activityRecord -> {
+ if (activityRecord.isVisibleRequested()
+ && activityRecord.packageName.equals(packageName)) {
+ activitiesOfPackageWhichOpenedCamera.add(activityRecord);
+ }
+ });
+
+ if (activitiesOfPackageWhichOpenedCamera.isEmpty()) {
+ ProtoLog.w(WM_DEBUG_STATES, "Cannot find camera activity.");
+ return null;
+ }
+
+ if (activitiesOfPackageWhichOpenedCamera.size() == 1) {
+ return activitiesOfPackageWhichOpenedCamera.getFirst();
+ }
+
+ // Return null if we cannot determine which activity opened camera. This is preferred to
+ // applying treatment to the wrong activity.
+ ProtoLog.w(WM_DEBUG_STATES, "Cannot determine which activity opened camera.");
+ return null;
+ }
+
+ @NonNull
+ public String toString() {
+ return " mCameraAppInfoSet=" + mCameraAppInfoSet
+ .getSummaryForDisplayRotationHistoryRecord();
+ }
+
+ @Nullable
+ private WindowProcessController getAppProcessForCallingId(int pid) {
+ return mDisplayContent.mAtmService.mProcessMap.getProcess(pid);
+ }
+
+ /**
+ * Repository for @{@link CameraAppInfo}s and the camera status changes (opening or closing
+ * camera) that are in-flight.
+ *
+ * <p>As camera opening and closing have different delays, and some of them are quick switches
+ * caused by activity refresh or front/back camera switch, tracking pending states enables
+ * to skip brief activity changes which cause flickering.
+ */
+ static class PendingCameraUpdateRepository {
+ /** Enum describing the newest camera state that is not yet processed. */
+ enum PendingCameraState {
+ OPENED,
+ CLOSED
+ }
+
+ /**
+ * Set of apps that have camera status update (newly opened or closed) scheduled to be
+ * processed.
+ *
+ * <p>Existing state will be overwritten, as the newest signal (opened/closed) should be
+ * respected.
+ */
+ private final HashMap<CameraAppInfo, PendingCameraState> mPendingCameraStateMap =
+ new HashMap<>();
+
+ void trackPendingCameraOpen(@NonNull CameraAppInfo cameraAppInfo) {
+ // Some apps can’t handle configuration changes coming at the same time with Camera
+ // setup so delaying orientation update to accommodate for that.
+ mPendingCameraStateMap.put(cameraAppInfo, PendingCameraState.OPENED);
+ }
+
+ void trackPendingCameraClose(@NonNull CameraAppInfo cameraAppInfo) {
+ mPendingCameraStateMap.put(cameraAppInfo, PendingCameraState.CLOSED);
+ }
+
+ /**
+ * @return true if camera open was pending for given {@param cameraAppInfo}.
+ */
+ boolean removePendingCameraOpen(@NonNull CameraAppInfo cameraAppInfo) {
+ return mPendingCameraStateMap.remove(cameraAppInfo, PendingCameraState.OPENED);
+ }
+
+ /**
+ * @return true if camera close was pending for given {@param cameraAppInfo}.
+ */
+ boolean removePendingCameraClose(@NonNull CameraAppInfo cameraAppInfo) {
+ return mPendingCameraStateMap.remove(cameraAppInfo, PendingCameraState.CLOSED);
+ }
+ }
+}
diff --git a/services/core/java/com/android/server/wm/CameraIdAppPairsSet.java b/services/core/java/com/android/server/wm/CameraIdAppPairsSet.java
new file mode 100644
index 0000000..e888360
--- /dev/null
+++ b/services/core/java/com/android/server/wm/CameraIdAppPairsSet.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2025 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.wm;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.util.ArraySet;
+
+/**
+ * Data set for the currently active cameraId and the app that opened it.
+ *
+ * <p>This class is not thread-safe.
+ */
+final class CameraIdAppPairsSet {
+ private final ArraySet<CameraAppInfo> mCameraAppInfoSet = new ArraySet<>();
+
+ boolean isEmpty() {
+ return mCameraAppInfoSet.isEmpty();
+ }
+
+ void add(@NonNull CameraAppInfo cameraAppInfo) {
+ mCameraAppInfoSet.add(cameraAppInfo);
+ }
+
+ boolean containsAnyCameraForTaskId(int taskId) {
+ for (int i = 0; i < mCameraAppInfoSet.size(); i++) {
+ final CameraAppInfo info = mCameraAppInfoSet.valueAt(i);
+ if (info.mTaskId == taskId) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Nullable
+ CameraAppInfo getAnyCameraAppStateForCameraId(@NonNull String cameraId) {
+ for (int i = 0; i < mCameraAppInfoSet.size(); i++) {
+ final CameraAppInfo info = mCameraAppInfoSet.valueAt(i);
+ if (info.mCameraId.equals(cameraId)) {
+ return info;
+ }
+ }
+ return null;
+ }
+
+ boolean containsCameraIdAndTask(@NonNull String cameraId, int taskId) {
+ for (int i = 0; i < mCameraAppInfoSet.size(); i++) {
+ final CameraAppInfo info = mCameraAppInfoSet.valueAt(i);
+ if (info.mCameraId.equals(cameraId) && info.mTaskId == taskId) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ boolean remove(@NonNull CameraAppInfo cameraAppInfo) {
+ return mCameraAppInfoSet.remove(cameraAppInfo);
+ }
+
+ @NonNull
+ String getSummaryForDisplayRotationHistoryRecord() {
+ return "{ mCameraAppInfoSet=" + mCameraAppInfoSet + " }";
+ }
+
+ @Override
+ public String toString() {
+ return getSummaryForDisplayRotationHistoryRecord();
+ }
+}
diff --git a/services/core/java/com/android/server/wm/CameraStateMonitor.java b/services/core/java/com/android/server/wm/CameraStateMonitor.java
index ab65edb..5be682c 100644
--- a/services/core/java/com/android/server/wm/CameraStateMonitor.java
+++ b/services/core/java/com/android/server/wm/CameraStateMonitor.java
@@ -26,6 +26,7 @@
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.protolog.ProtoLog;
+import com.android.window.flags.Flags;
/**
* Class that listens to camera open/closed signals, keeps track of the current apps using camera,
@@ -92,7 +93,9 @@
mAppCompatCameraStatePolicy = appCompatCameraStatePolicy;
mWmService = displayContent.mWmService;
mCameraManager = mWmService.mContext.getSystemService(CameraManager.class);
- mAppCompatCameraStateStrategy = new AppCompatCameraStateStrategyForPackage(displayContent);
+ mAppCompatCameraStateStrategy = Flags.enableCameraCompatTrackTaskAndAppBugfix()
+ ? new AppCompatCameraStateStrategyForTask(displayContent)
+ : new AppCompatCameraStateStrategyForPackage(displayContent);
}
/** Starts listening to camera opened/closed signals. */
diff --git a/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraStateStrategyForTaskTests.java b/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraStateStrategyForTaskTests.java
new file mode 100644
index 0000000..db28c73
--- /dev/null
+++ b/services/tests/wmtests/src/com/android/server/wm/AppCompatCameraStateStrategyForTaskTests.java
@@ -0,0 +1,325 @@
+/*
+ * Copyright (C) 2025 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.wm;
+
+import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION;
+
+import static com.android.window.flags.Flags.FLAG_ENABLE_CAMERA_COMPAT_TRACK_TASK_AND_APP_BUGFIX;
+
+import static org.junit.Assert.assertEquals;
+
+import android.compat.testing.PlatformCompatChangeRule;
+import android.platform.test.annotations.EnableFlags;
+import android.platform.test.annotations.Presubmit;
+import android.util.ArraySet;
+
+import androidx.annotation.NonNull;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TestRule;
+import org.junit.runner.RunWith;
+
+import java.util.Set;
+import java.util.function.Consumer;
+
+/**
+ * Test class for {@link AppCompatCameraStateStrategyForTask}.
+ * <p>
+ * Build/Install/Run:
+ * atest WmTests:AppCompatCameraStateStrategyForTaskTests
+ */
+@Presubmit
+@RunWith(WindowTestRunner.class)
+public class AppCompatCameraStateStrategyForTaskTests extends WindowTestsBase {
+ private static final String TEST_PACKAGE_1 = "com.android.frameworks.wmtests";
+ private static final String CAMERA_ID_1 = "camera-1";
+ private static final String CAMERA_ID_2 = "camera-2";
+
+ @Rule
+ public TestRule mCompatChangeRule = new PlatformCompatChangeRule();
+
+ @Test
+ @EnableFlags(FLAG_ENABLE_CAMERA_COMPAT_TRACK_TASK_AND_APP_BUGFIX)
+ public void testTrackCameraOpened_returnsCorrectCameraAppInfo() {
+ runTestScenario((robot) -> {
+ robot.addPolicyThatCanClose();
+
+ robot.assertCorrectCameraAppInfoOnCameraOpened(CAMERA_ID_1, TEST_PACKAGE_1);
+ });
+ }
+
+ @Test
+ @EnableFlags(FLAG_ENABLE_CAMERA_COMPAT_TRACK_TASK_AND_APP_BUGFIX)
+ public void testTrackCameraOpened_cameraNotYetOpened() {
+ runTestScenario((robot) -> {
+ robot.addPolicyThatCanClose();
+
+ robot.assertCorrectCameraAppInfoOnCameraOpened(CAMERA_ID_1, TEST_PACKAGE_1);
+
+ robot.checkIsCameraOpened(false);
+ robot.checkCameraOpenedCalledForCanClosePolicy(0);
+ });
+ }
+
+ @Test
+ @EnableFlags(FLAG_ENABLE_CAMERA_COMPAT_TRACK_TASK_AND_APP_BUGFIX)
+ public void testOnCameraOpened_notifiesPolicy() {
+ runTestScenario((robot) -> {
+ robot.addPolicyThatCanClose();
+
+ robot.assertCorrectCameraAppInfoOnCameraOpened(CAMERA_ID_1, TEST_PACKAGE_1);
+ robot.maybeNotifyPolicyCameraOpened(CAMERA_ID_1);
+
+ robot.checkCameraOpenedCalledForCanClosePolicy(1);
+ });
+ }
+
+ @Test
+ @EnableFlags(FLAG_ENABLE_CAMERA_COMPAT_TRACK_TASK_AND_APP_BUGFIX)
+ public void testOnCameraOpened_cameraIsOpened() {
+ runTestScenario((robot) -> {
+ robot.addPolicyThatCanClose();
+
+ robot.assertCorrectCameraAppInfoOnCameraOpened(CAMERA_ID_1, TEST_PACKAGE_1);
+ robot.maybeNotifyPolicyCameraOpened(CAMERA_ID_1);
+
+ robot.checkIsCameraOpened(true);
+ });
+ }
+
+ @Test
+ @EnableFlags(FLAG_ENABLE_CAMERA_COMPAT_TRACK_TASK_AND_APP_BUGFIX)
+ public void testTrackCameraClosed_returnsCorrectCameraAppInfo() {
+ runTestScenario((robot) -> {
+ robot.addPolicyThatCanClose();
+ robot.assertCorrectCameraAppInfoOnCameraOpened(CAMERA_ID_1, TEST_PACKAGE_1);
+ robot.maybeNotifyPolicyCameraOpened(CAMERA_ID_1);
+
+ robot.assertCorrectCameraAppInfoOnCameraClosed(CAMERA_ID_1);
+ });
+ }
+
+ @Test
+ @EnableFlags(FLAG_ENABLE_CAMERA_COMPAT_TRACK_TASK_AND_APP_BUGFIX)
+ public void testOnCameraClosed_policyCanCloseCamera_cameraIsClosed() {
+ runTestScenario((robot) -> {
+ robot.addPolicyThatCanClose();
+ robot.assertCorrectCameraAppInfoOnCameraOpened(CAMERA_ID_1, TEST_PACKAGE_1);
+ robot.maybeNotifyPolicyCameraOpened(CAMERA_ID_1);
+ robot.assertCorrectCameraAppInfoOnCameraClosed(CAMERA_ID_1);
+
+ robot.assertReportsCloseStatusOnCameraClose(CAMERA_ID_1);
+ });
+ }
+
+ @Test
+ @EnableFlags(FLAG_ENABLE_CAMERA_COMPAT_TRACK_TASK_AND_APP_BUGFIX)
+ public void testOnCameraClosed_activityCannotCloseCamera_returnsCorrectStatus() {
+ runTestScenario((robot) -> {
+ robot.addPolicyThatCannotCloseOnce();
+
+ robot.assertCorrectCameraAppInfoOnCameraOpened(CAMERA_ID_1, TEST_PACKAGE_1);
+ robot.maybeNotifyPolicyCameraOpened(CAMERA_ID_1);
+ robot.assertCorrectCameraAppInfoOnCameraClosed(CAMERA_ID_1);
+
+ robot.assertReportsCloseStatusOnCameraClose(CAMERA_ID_1);
+ });
+ }
+
+ @Test
+ @EnableFlags(FLAG_ENABLE_CAMERA_COMPAT_TRACK_TASK_AND_APP_BUGFIX)
+ public void testActivitySwitchesCameras_policyIsNotNotifiedAgain() {
+ runTestScenario((robot) -> {
+ robot.addPolicyThatCanClose();
+ robot.assertCorrectCameraAppInfoOnCameraOpened(CAMERA_ID_1, TEST_PACKAGE_1);
+ robot.maybeNotifyPolicyCameraOpened(CAMERA_ID_1);
+
+ // Only one camera can be active at a time, so Camera 1 close signal will come before
+ // Camera 2 open signal.
+ robot.assertCorrectCameraAppInfoOnCameraClosed(CAMERA_ID_1);
+ robot.assertCorrectCameraAppInfoOnCameraOpened(CAMERA_ID_2, TEST_PACKAGE_1);
+ // However, processing delay for opening the camera is shorter than for closing the
+ // camera, therefore it will happen first.
+ robot.maybeNotifyPolicyCameraOpened(CAMERA_ID_2);
+ robot.maybeNotifyPolicyCameraClosed(CAMERA_ID_1);
+
+ robot.checkCameraOpenedCalledForCanClosePolicy(1);
+ });
+ }
+
+ /**
+ * Runs a test scenario providing a Robot.
+ */
+ void runTestScenario(@NonNull Consumer<AppCompatCameraStateStrategyForTaskRobotTest> consumer) {
+ final AppCompatCameraStateStrategyForTaskRobotTest robot =
+ new AppCompatCameraStateStrategyForTaskRobotTest(mWm, mAtm, mSupervisor, this);
+ consumer.accept(robot);
+ }
+
+ private static class AppCompatCameraStateStrategyForTaskRobotTest extends AppCompatRobotBase {
+ private final WindowTestsBase mWindowTestsBase;
+
+ private FakeAppCompatCameraStatePolicy mFakePolicyCannotCloseOnce;
+ private FakeAppCompatCameraStatePolicy mFakePolicyCanClose;
+
+ private Set<FakeAppCompatCameraStatePolicy> mRegisteredPolicies = new ArraySet<>();
+
+ AppCompatCameraStateStrategyForTaskRobotTest(@NonNull WindowManagerService wm,
+ @NonNull ActivityTaskManagerService atm,
+ @NonNull ActivityTaskSupervisor supervisor,
+ @NonNull WindowTestsBase windowTestsBase) {
+ super(wm, atm, supervisor);
+ mWindowTestsBase = windowTestsBase;
+ setupAppCompatConfiguration();
+ configureActivityAndDisplay();
+ }
+
+ @Override
+ void onPostDisplayContentCreation(@NonNull DisplayContent displayContent) {
+ super.onPostDisplayContentCreation(displayContent);
+ mRegisteredPolicies = new ArraySet<>();
+ mFakePolicyCannotCloseOnce = new FakeAppCompatCameraStatePolicy(true);
+ mFakePolicyCanClose = new FakeAppCompatCameraStatePolicy(false);
+ }
+
+ @Override
+ void onPostActivityCreation(@NonNull ActivityRecord activity) {
+ super.onPostActivityCreation(activity);
+
+ // Adds activity to process map.
+ final WindowState win = mWindowTestsBase.newWindowBuilder("app1",
+ TYPE_APPLICATION).setWindowToken(activity).build();
+ }
+
+ private void configureActivityAndDisplay() {
+ applyOnActivity(a -> {
+ a.createActivityWithComponentInNewTaskAndDisplay();
+ });
+ }
+
+ private void setupAppCompatConfiguration() {
+ applyOnConf((c) -> {
+ c.enableCameraCompatTreatment(true);
+ c.enableCameraCompatTreatmentAtBuildTime(true);
+ });
+ }
+
+ private void addPolicyThatCanClose() {
+ getAppCompatCameraStateSource().addCameraStatePolicy(mFakePolicyCanClose);
+ mRegisteredPolicies.add(mFakePolicyCanClose);
+ }
+
+ private void addPolicyThatCannotCloseOnce() {
+ getAppCompatCameraStateSource().addCameraStatePolicy(mFakePolicyCannotCloseOnce);
+ mRegisteredPolicies.add(mFakePolicyCannotCloseOnce);
+ }
+
+ private AppCompatCameraStateSource getAppCompatCameraStateSource() {
+ return (AppCompatCameraStateSource) activity().top().mDisplayContent
+ .mAppCompatCameraPolicy.mCameraStateMonitor.mAppCompatCameraStatePolicy;
+ }
+
+ private void assertCorrectCameraAppInfoOnCameraOpened(@NonNull String cameraId,
+ @NonNull String packageName) {
+ final CameraAppInfo cameraAppInfo = trackCameraOpened(cameraId, packageName);
+ assertEquals(cameraId, cameraAppInfo.mCameraId);
+ assertEquals(packageName, cameraAppInfo.mPackageName);
+ assertEquals(activity().top().getTask().mTaskId, cameraAppInfo.mTaskId);
+ assertEquals(activity().top().app.getPid(), cameraAppInfo.mPid);
+ }
+
+ private void assertCorrectCameraAppInfoOnCameraClosed(@NonNull String cameraId) {
+ final CameraAppInfo cameraAppInfo = trackCameraClosed(cameraId);
+ assertEquals(cameraId, cameraAppInfo.mCameraId);
+ assertEquals(activity().top().packageName, cameraAppInfo.mPackageName);
+ assertEquals(activity().top().getTask().mTaskId, cameraAppInfo.mTaskId);
+ assertEquals(activity().top().app.getPid(), cameraAppInfo.mPid);
+ }
+
+ private void checkIsCameraOpened(boolean expectedIsOpened) {
+ assertEquals(expectedIsOpened, getCameraStateMonitor().mAppCompatCameraStateStrategy
+ .isCameraRunningForActivity(activity().top()));
+ }
+
+ private void checkCameraOpenedCalledForCanClosePolicy(int times) {
+ assertEquals(times, mFakePolicyCanClose.mOnCameraOpenedCounter);
+ }
+
+ private void assertReportsCloseStatusOnCameraClose(@NonNull String cameraId) {
+ assertReportsCloseStatusOnCameraClose(getExpectedCameraAppInfo(cameraId));
+ }
+
+ private void assertReportsCloseStatusOnCameraClose(@NonNull CameraAppInfo cameraAppInfo) {
+ for (FakeAppCompatCameraStatePolicy policy : mRegisteredPolicies) {
+ boolean simulateCannotClose = policy == mFakePolicyCannotCloseOnce;
+ assertEquals(!simulateCannotClose, maybeNotifyPolicyCameraClosed(cameraAppInfo,
+ simulateCannotClose ? mFakePolicyCannotCloseOnce : mFakePolicyCanClose));
+ }
+ }
+
+ private CameraAppInfo trackCameraOpened(@NonNull String cameraId,
+ @NonNull String packageName) {
+ return activity().displayContent().mAppCompatCameraPolicy.mCameraStateMonitor
+ .mAppCompatCameraStateStrategy.trackOnCameraOpened(cameraId, packageName);
+ }
+
+ private void maybeNotifyPolicyCameraOpened(@NonNull String cameraId) {
+ for (FakeAppCompatCameraStatePolicy policy : mRegisteredPolicies) {
+ maybeNotifyPolicyCameraOpened(getExpectedCameraAppInfo(cameraId),
+ policy);
+ }
+ }
+
+ private void maybeNotifyPolicyCameraOpened(@NonNull CameraAppInfo cameraAppInfo,
+ @NonNull AppCompatCameraStatePolicy policy) {
+ activity().displayContent().mAppCompatCameraPolicy.mCameraStateMonitor
+ .mAppCompatCameraStateStrategy.notifyPolicyCameraOpenedIfNeeded(cameraAppInfo,
+ policy);
+ }
+
+ private CameraAppInfo trackCameraClosed(@NonNull String cameraId) {
+ return activity().displayContent().mAppCompatCameraPolicy.mCameraStateMonitor
+ .mAppCompatCameraStateStrategy.trackOnCameraClosed(cameraId);
+ }
+
+ private void maybeNotifyPolicyCameraClosed(@NonNull String cameraId) {
+ for (FakeAppCompatCameraStatePolicy policy : mRegisteredPolicies) {
+ maybeNotifyPolicyCameraClosed(getExpectedCameraAppInfo(cameraId),
+ policy);
+ }
+ }
+
+ private boolean maybeNotifyPolicyCameraClosed(@NonNull CameraAppInfo cameraAppInfo,
+ @NonNull AppCompatCameraStatePolicy policy) {
+ return activity().displayContent().mAppCompatCameraPolicy.mCameraStateMonitor
+ .mAppCompatCameraStateStrategy.notifyPolicyCameraClosedIfNeeded(cameraAppInfo,
+ policy);
+ }
+
+ private CameraAppInfo getExpectedCameraAppInfo(@NonNull String cameraId) {
+ return new CameraAppInfo(cameraId,
+ activity().top().app.getPid(),
+ activity().top().getTask().mTaskId,
+ activity().top().packageName);
+ }
+
+ private CameraStateMonitor getCameraStateMonitor() {
+ return activity().top().mDisplayContent.mAppCompatCameraPolicy.mCameraStateMonitor;
+ }
+ }
+}
diff --git a/services/tests/wmtests/src/com/android/server/wm/CameraIdAppPairsSetTests.java b/services/tests/wmtests/src/com/android/server/wm/CameraIdAppPairsSetTests.java
new file mode 100644
index 0000000..28f18d7
--- /dev/null
+++ b/services/tests/wmtests/src/com/android/server/wm/CameraIdAppPairsSetTests.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2024 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.wm;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Tests for {@link CameraIdAppPairsSet}.
+ *
+ * Build/Install/Run:
+ * atest WmTests:CameraIdAppPairsSetTests
+ */
+@Presubmit
+@RunWith(WindowTestRunner.class)
+public class CameraIdAppPairsSetTests {
+ private CameraIdAppPairsSet mMapping;
+
+ private static final String TEST_PACKAGE_1 = "com.test.package.one";
+ private static final String TEST_PACKAGE_2 = "com.test.package.two";
+ private static final int TASK_ID_1 = 1;
+ private static final int TASK_ID_2 = 2;
+ private static final int PID_1 = 101;
+ private static final int PID_2 = 102;
+ private static final String CAMERA_ID_1 = "1234";
+ private static final String CAMERA_ID_2 = "5678";
+ private final CameraAppInfo mCameraAppInfo1Camera1 =
+ new CameraAppInfo(CAMERA_ID_1, PID_1, TASK_ID_1, TEST_PACKAGE_1);
+ private final CameraAppInfo mCameraAppInfo1Camera2 =
+ new CameraAppInfo(CAMERA_ID_2, PID_1, TASK_ID_1, TEST_PACKAGE_1);
+ private final CameraAppInfo mCameraAppInfo2Camera1 =
+ new CameraAppInfo(CAMERA_ID_1, PID_2, TASK_ID_2, TEST_PACKAGE_2);
+ private final CameraAppInfo mCameraAppInfo2Camera2 =
+ new CameraAppInfo(CAMERA_ID_2, PID_2, TASK_ID_2, TEST_PACKAGE_2);
+
+ @Before
+ public void setUp() {
+ mMapping = new CameraIdAppPairsSet();
+ }
+
+ @Test
+ public void mappingEmptyAtStart() {
+ assertTrue(mMapping.isEmpty());
+ }
+
+ @Test
+ public void addTaskAndCameraId_containsCameraIdAndTask() {
+ mMapping.add(mCameraAppInfo1Camera1);
+
+ assertTrue(mMapping.containsCameraIdAndTask(CAMERA_ID_1, mCameraAppInfo1Camera1.mTaskId));
+ }
+
+ @Test
+ public void addTwoTasksAndCameraIds_containsCameraIdsAndTasks() {
+ mMapping.add(mCameraAppInfo1Camera1);
+ mMapping.add(mCameraAppInfo2Camera2);
+
+ assertTrue(mMapping.containsCameraIdAndTask(CAMERA_ID_1, mCameraAppInfo1Camera1.mTaskId));
+ assertTrue(mMapping.containsCameraIdAndTask(CAMERA_ID_2, mCameraAppInfo2Camera2.mTaskId));
+ }
+
+ @Test
+ public void addTwoTasksAndCameraIds_checkContainsCameraIdAndTaskFromDifferentPair_false() {
+ mMapping.add(mCameraAppInfo1Camera1);
+ mMapping.add(mCameraAppInfo2Camera2);
+
+ assertFalse(mMapping.containsCameraIdAndTask(CAMERA_ID_1, mCameraAppInfo2Camera2.mTaskId));
+ }
+
+ @Test
+ public void addTwoTasksForTheSameCamera_bothExist() {
+ mMapping.add(mCameraAppInfo1Camera1);
+ mMapping.add(mCameraAppInfo2Camera2);
+
+ assertTrue(mMapping.containsCameraIdAndTask(CAMERA_ID_1, mCameraAppInfo1Camera1.mTaskId));
+ assertTrue(mMapping.containsCameraIdAndTask(CAMERA_ID_2, mCameraAppInfo2Camera2.mTaskId));
+ }
+
+ @Test
+ public void addTwoCamerasForTheSameTask_bothExist() {
+ mMapping.add(mCameraAppInfo1Camera1);
+ mMapping.add(mCameraAppInfo1Camera2);
+
+ assertTrue(mMapping.containsCameraIdAndTask(CAMERA_ID_1, mCameraAppInfo1Camera1.mTaskId));
+ assertTrue(mMapping.containsCameraIdAndTask(CAMERA_ID_2, mCameraAppInfo1Camera2.mTaskId));
+ }
+
+ @Test
+ public void addTwoTasksForTheSameCamera_returnsAnyTask() {
+ mMapping.add(mCameraAppInfo1Camera1);
+ mMapping.add(mCameraAppInfo2Camera1);
+
+ assertNotNull(mMapping.getAnyCameraAppStateForCameraId(CAMERA_ID_1));
+ }
+
+ @Test
+ public void addTwoCamerasForTheSameTask_containsAnyCamera() {
+ mMapping.add(mCameraAppInfo1Camera1);
+ mMapping.add(mCameraAppInfo1Camera2);
+
+ assertTrue(mMapping.containsAnyCameraForTaskId(mCameraAppInfo1Camera1.mTaskId));
+ }
+
+ @Test
+ public void addAndRemoveCameraId_containsOtherCameraAndTask() {
+ mMapping.add(mCameraAppInfo1Camera1);
+ mMapping.add(mCameraAppInfo2Camera2);
+
+ mMapping.remove(mCameraAppInfo1Camera1);
+
+ assertTrue(mMapping.containsCameraIdAndTask(CAMERA_ID_2, mCameraAppInfo2Camera2.mTaskId));
+ }
+
+ @Test
+ public void addAndRemoveOnlyCameraId_empty() {
+ mMapping.add(mCameraAppInfo1Camera1);
+ mMapping.remove(mCameraAppInfo1Camera1);
+
+ assertTrue(mMapping.isEmpty());
+ }
+
+ @Test
+ public void addAndRemoveOnlyCameraIdUsingEqualObject_empty() {
+ mMapping.add(mCameraAppInfo1Camera1);
+ mMapping.remove(new CameraAppInfo(mCameraAppInfo1Camera1.mCameraId,
+ mCameraAppInfo1Camera1.mPid, mCameraAppInfo1Camera1.mTaskId,
+ mCameraAppInfo1Camera1.mPackageName));
+
+ assertTrue(mMapping.isEmpty());
+ }
+}