Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 8e44b47e authored by Mina Granic's avatar Mina Granic
Browse files

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
parent 1de9e82b
Loading
Loading
Loading
Loading
+283 −0
Original line number Diff line number Diff line
/*
 * 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);
        }
    }
}
+83 −0
Original line number Diff line number Diff line
/*
 * 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();
    }
}
+4 −1
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ import android.os.Handler;

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 @@ class CameraStateMonitor {
        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. */
+325 −0

File added.

Preview size limit exceeded, changes collapsed.

+152 −0
Original line number Diff line number Diff line
/*
 * 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());
    }
}