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

Commit 6cf33c6d authored by Hongwei Wang's avatar Hongwei Wang
Browse files

Add TabletopModeController in WMShell

Populate the resource config that defines the horizontal fold and
together with the device posture to determine if the foldable device is
in tabletop mode or not.

Bug: 260871991
Test: atest WMShellUnitTests:TabletopModeControllerTest
Change-Id: I7878ec9ef95be39c2f1dc7ada7e7c871a018fa6e
parent a88017c3
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -39,6 +39,9 @@ import java.util.List;
 *
 * Note that most of the implementation here inherits from
 * {@link com.android.systemui.statusbar.policy.DevicePostureController}.
 *
 * Use the {@link TabletopModeController} if you are interested in tabletop mode change only,
 * which is more common.
 */
public class DevicePostureController {
    @IntDef(prefix = {"DEVICE_POSTURE_"}, value = {
+208 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.wm.shell.common;

import static android.view.Display.DEFAULT_DISPLAY;

import static com.android.wm.shell.common.DevicePostureController.DEVICE_POSTURE_HALF_OPENED;
import static com.android.wm.shell.common.DevicePostureController.DEVICE_POSTURE_UNKNOWN;
import static com.android.wm.shell.protolog.ShellProtoLogGroup.WM_SHELL_FOLDABLE;

import android.annotation.NonNull;
import android.app.WindowConfiguration;
import android.content.Context;
import android.content.res.Configuration;
import android.util.ArraySet;
import android.view.Surface;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.protolog.common.ProtoLog;
import com.android.wm.shell.common.annotations.ShellMainThread;
import com.android.wm.shell.sysui.ShellInit;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

/**
 * Wrapper class to track the tabletop (aka. flex) mode change on Fold-ables.
 * See also <a
 * href="https://developer.android.com/guide/topics/large-screens/learn-about-foldables
 * #foldable_postures">Foldable states and postures</a> for reference.
 *
 * Use the {@link DevicePostureController} for more detailed posture changes.
 */
public class TabletopModeController implements
        DevicePostureController.OnDevicePostureChangedListener,
        DisplayController.OnDisplaysChangedListener {
    private static final long TABLETOP_MODE_DELAY_MILLIS = 1_000;

    private final Context mContext;

    private final DevicePostureController mDevicePostureController;

    private final DisplayController mDisplayController;

    private final ShellExecutor mMainExecutor;

    private final Set<Integer> mTabletopModeRotations = new ArraySet<>();

    private final List<OnTabletopModeChangedListener> mListeners = new ArrayList<>();

    @VisibleForTesting
    final Runnable mOnEnterTabletopModeCallback = () -> {
        if (isInTabletopMode()) {
            // We are still in tabletop mode, go ahead.
            mayBroadcastOnTabletopModeChange(true /* isInTabletopMode */);
        }
    };

    @DevicePostureController.DevicePostureInt
    private int mDevicePosture = DEVICE_POSTURE_UNKNOWN;

    @Surface.Rotation
    private int mDisplayRotation = WindowConfiguration.ROTATION_UNDEFINED;

    /**
     * Track the last callback value for {@link OnTabletopModeChangedListener}.
     * This is to avoid duplicated {@code false} callback to {@link #mListeners}.
     */
    private Boolean mLastIsInTabletopModeForCallback;

    public TabletopModeController(Context context,
            ShellInit shellInit,
            DevicePostureController postureController,
            DisplayController displayController,
            @ShellMainThread ShellExecutor mainExecutor) {
        mContext = context;
        mDevicePostureController = postureController;
        mDisplayController = displayController;
        mMainExecutor = mainExecutor;
        shellInit.addInitCallback(this::onInit, this);
    }

    @VisibleForTesting
    void onInit() {
        mDevicePostureController.registerOnDevicePostureChangedListener(this);
        mDisplayController.addDisplayWindowListener(this);
        // Aligns with what's in {@link com.android.server.wm.DisplayRotation}.
        final int[] deviceTabletopRotations = mContext.getResources().getIntArray(
                com.android.internal.R.array.config_deviceTabletopRotations);
        if (deviceTabletopRotations == null || deviceTabletopRotations.length == 0) {
            ProtoLog.e(WM_SHELL_FOLDABLE,
                    "No valid config_deviceTabletopRotations, can not tell"
                            + " tabletop mode in WMShell");
            return;
        }
        for (int angle : deviceTabletopRotations) {
            switch (angle) {
                case 0:
                    mTabletopModeRotations.add(Surface.ROTATION_0);
                    break;
                case 90:
                    mTabletopModeRotations.add(Surface.ROTATION_90);
                    break;
                case 180:
                    mTabletopModeRotations.add(Surface.ROTATION_180);
                    break;
                case 270:
                    mTabletopModeRotations.add(Surface.ROTATION_270);
                    break;
                default:
                    ProtoLog.e(WM_SHELL_FOLDABLE,
                            "Invalid surface rotation angle in "
                                    + "config_deviceTabletopRotations: %d",
                            angle);
                    break;
            }
        }
    }

    /** Register {@link OnTabletopModeChangedListener} to listen for tabletop mode change. */
    public void registerOnTabletopModeChangedListener(
            @NonNull OnTabletopModeChangedListener listener) {
        if (listener == null || mListeners.contains(listener)) return;
        mListeners.add(listener);
        listener.onTabletopModeChanged(isInTabletopMode());
    }

    /** Unregister {@link OnTabletopModeChangedListener} for tabletop mode change. */
    public void unregisterOnTabletopModeChangedListener(
            @NonNull OnTabletopModeChangedListener listener) {
        mListeners.remove(listener);
    }

    @Override
    public void onDevicePostureChanged(@DevicePostureController.DevicePostureInt int posture) {
        if (mDevicePosture != posture) {
            onDevicePostureOrDisplayRotationChanged(posture, mDisplayRotation);
        }
    }

    @Override
    public void onDisplayConfigurationChanged(int displayId, Configuration newConfig) {
        final int newDisplayRotation = newConfig.windowConfiguration.getDisplayRotation();
        if (displayId == DEFAULT_DISPLAY && newDisplayRotation != mDisplayRotation) {
            onDevicePostureOrDisplayRotationChanged(mDevicePosture, newDisplayRotation);
        }
    }

    private void onDevicePostureOrDisplayRotationChanged(
            @DevicePostureController.DevicePostureInt int newPosture,
            @Surface.Rotation int newDisplayRotation) {
        final boolean wasInTabletopMode = isInTabletopMode();
        mDevicePosture = newPosture;
        mDisplayRotation = newDisplayRotation;
        final boolean couldBeInTabletopMode = isInTabletopMode();
        mMainExecutor.removeCallbacks(mOnEnterTabletopModeCallback);
        if (!wasInTabletopMode && couldBeInTabletopMode) {
            // May enter tabletop mode, but we need to wait for additional time since this
            // could be an intermediate state.
            mMainExecutor.executeDelayed(mOnEnterTabletopModeCallback, TABLETOP_MODE_DELAY_MILLIS);
        } else {
            // Cancel entering tabletop mode if any condition's changed.
            mayBroadcastOnTabletopModeChange(false /* isInTabletopMode */);
        }
    }

    private boolean isHalfOpened(@DevicePostureController.DevicePostureInt int posture) {
        return posture == DEVICE_POSTURE_HALF_OPENED;
    }

    private boolean isInTabletopMode() {
        return isHalfOpened(mDevicePosture) && mTabletopModeRotations.contains(mDisplayRotation);
    }

    private void mayBroadcastOnTabletopModeChange(boolean isInTabletopMode) {
        if (mLastIsInTabletopModeForCallback == null
                || mLastIsInTabletopModeForCallback != isInTabletopMode) {
            mListeners.forEach(l -> l.onTabletopModeChanged(isInTabletopMode));
            mLastIsInTabletopModeForCallback = isInTabletopMode;
        }
    }

    /**
     * Listener interface for tabletop mode change.
     */
    public interface OnTabletopModeChangedListener {
        /**
         * Callback when tabletop mode changes. Expect duplicated callbacks with {@code false}.
         * @param isInTabletopMode {@code true} if enters tabletop mode, {@code false} otherwise.
         */
        void onTabletopModeChanged(boolean isInTabletopMode);
    }
}
+13 −0
Original line number Diff line number Diff line
@@ -51,6 +51,7 @@ import com.android.wm.shell.common.FloatingContentCoordinator;
import com.android.wm.shell.common.ShellExecutor;
import com.android.wm.shell.common.SyncTransactionQueue;
import com.android.wm.shell.common.SystemWindows;
import com.android.wm.shell.common.TabletopModeController;
import com.android.wm.shell.common.TaskStackListenerImpl;
import com.android.wm.shell.common.TransactionPool;
import com.android.wm.shell.common.annotations.ShellAnimationThread;
@@ -169,6 +170,18 @@ public abstract class WMShellBaseModule {
        return new DevicePostureController(context, shellInit, mainExecutor);
    }

    @WMSingleton
    @Provides
    static TabletopModeController provideTabletopModeController(
            Context context,
            ShellInit shellInit,
            DevicePostureController postureController,
            DisplayController displayController,
            @ShellMainThread ShellExecutor mainExecutor) {
        return new TabletopModeController(
                context, shellInit, postureController, displayController, mainExecutor);
    }

    @WMSingleton
    @Provides
    static DragAndDropController provideDragAndDropController(Context context,
+2 −0
Original line number Diff line number Diff line
@@ -50,6 +50,8 @@ public enum ShellProtoLogGroup implements IProtoLogGroup {
            Consts.TAG_WM_SHELL),
    WM_SHELL_FLOATING_APPS(Consts.ENABLE_DEBUG, Consts.ENABLE_LOG_TO_PROTO_DEBUG, false,
            Consts.TAG_WM_SHELL),
    WM_SHELL_FOLDABLE(Consts.ENABLE_DEBUG, Consts.ENABLE_LOG_TO_PROTO_DEBUG, false,
            Consts.TAG_WM_SHELL),
    TEST_GROUP(true, true, false, "WindowManagerShellProtoLogTest");

    private final boolean mEnabled;
+270 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.wm.shell.common;

import static android.view.Display.DEFAULT_DISPLAY;

import static com.android.wm.shell.common.DevicePostureController.DEVICE_POSTURE_CLOSED;
import static com.android.wm.shell.common.DevicePostureController.DEVICE_POSTURE_HALF_OPENED;
import static com.android.wm.shell.common.DevicePostureController.DEVICE_POSTURE_OPENED;

import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyZeroInteractions;
import static org.mockito.Mockito.when;

import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;
import android.view.Surface;

import androidx.test.filters.SmallTest;

import com.android.wm.shell.ShellTestCase;
import com.android.wm.shell.TestShellExecutor;
import com.android.wm.shell.sysui.ShellInit;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

/**
 * Tests for {@link TabletopModeController}.
 */
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper
@SmallTest
public class TabletopModeControllerTest extends ShellTestCase {
    // It's considered tabletop mode if the display rotation angle matches what's in this array.
    // It's defined as com.android.internal.R.array.config_deviceTabletopRotations on real devices.
    private static final int[] TABLETOP_MODE_ROTATIONS = new int[] {
            90 /* Surface.ROTATION_90 */,
            270 /* Surface.ROTATION_270 */
    };

    private TestShellExecutor mMainExecutor;

    private Configuration mConfiguration;

    private TabletopModeController mPipTabletopController;

    @Mock
    private Context mContext;

    @Mock
    private ShellInit mShellInit;

    @Mock
    private Resources mResources;

    @Mock
    private DevicePostureController mDevicePostureController;

    @Mock
    private DisplayController mDisplayController;

    @Mock
    private TabletopModeController.OnTabletopModeChangedListener mOnTabletopModeChangedListener;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        when(mResources.getIntArray(com.android.internal.R.array.config_deviceTabletopRotations))
                .thenReturn(TABLETOP_MODE_ROTATIONS);
        when(mContext.getResources()).thenReturn(mResources);
        mMainExecutor = new TestShellExecutor();
        mConfiguration = new Configuration();
        mPipTabletopController = new TabletopModeController(mContext, mShellInit,
                mDevicePostureController, mDisplayController, mMainExecutor);
        mPipTabletopController.onInit();
    }

    @Test
    public void instantiateController_addInitCallback() {
        verify(mShellInit, times(1)).addInitCallback(any(), eq(mPipTabletopController));
    }

    @Test
    public void registerOnTabletopModeChangedListener_notInTabletopMode_callbackFalse() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_CLOSED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_0);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.registerOnTabletopModeChangedListener(
                mOnTabletopModeChangedListener);

        verify(mOnTabletopModeChangedListener, times(1))
                .onTabletopModeChanged(false);
    }

    @Test
    public void registerOnTabletopModeChangedListener_inTabletopMode_callbackTrue() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.registerOnTabletopModeChangedListener(
                mOnTabletopModeChangedListener);

        verify(mOnTabletopModeChangedListener, times(1))
                .onTabletopModeChanged(true);
    }

    @Test
    public void registerOnTabletopModeChangedListener_notInTabletopModeTwice_callbackOnce() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_CLOSED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.registerOnTabletopModeChangedListener(
                mOnTabletopModeChangedListener);
        clearInvocations(mOnTabletopModeChangedListener);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_0);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        verifyZeroInteractions(mOnTabletopModeChangedListener);
    }

    // Test cases starting from folded state (DEVICE_POSTURE_CLOSED)
    @Test
    public void foldedRotation90_halfOpen_scheduleTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_CLOSED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);

        assertTrue(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }

    @Test
    public void foldedRotation0_halfOpen_noScheduleTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_CLOSED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_0);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);

        assertFalse(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }

    @Test
    public void foldedRotation90_halfOpenThenUnfold_cancelTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_CLOSED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_OPENED);

        assertFalse(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }

    @Test
    public void foldedRotation90_halfOpenThenFold_cancelTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_CLOSED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_CLOSED);

        assertFalse(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }

    @Test
    public void foldedRotation90_halfOpenThenRotate_cancelTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_CLOSED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_0);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        assertFalse(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }

    // Test cases starting from unfolded state (DEVICE_POSTURE_OPENED)
    @Test
    public void unfoldedRotation90_halfOpen_scheduleTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_OPENED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);

        assertTrue(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }

    @Test
    public void unfoldedRotation0_halfOpen_noScheduleTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_OPENED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_0);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);

        assertFalse(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }

    @Test
    public void unfoldedRotation90_halfOpenThenUnfold_cancelTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_OPENED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_OPENED);

        assertFalse(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }

    @Test
    public void unfoldedRotation90_halfOpenThenFold_cancelTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_OPENED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_CLOSED);

        assertFalse(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }

    @Test
    public void unfoldedRotation90_halfOpenThenRotate_cancelTabletopModeChange() {
        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_OPENED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_90);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        mPipTabletopController.onDevicePostureChanged(DEVICE_POSTURE_HALF_OPENED);
        mConfiguration.windowConfiguration.setDisplayRotation(Surface.ROTATION_0);
        mPipTabletopController.onDisplayConfigurationChanged(DEFAULT_DISPLAY, mConfiguration);

        assertFalse(mMainExecutor.hasCallback(mPipTabletopController.mOnEnterTabletopModeCallback));
    }
}