diff options
5 files changed, 496 insertions, 0 deletions
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/DevicePostureController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/common/DevicePostureController.java index 22587f4c6456..8b4ac1a8dc79 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/common/DevicePostureController.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/DevicePostureController.java @@ -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 = { diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/TabletopModeController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/common/TabletopModeController.java new file mode 100644 index 000000000000..bf226283ae54 --- /dev/null +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/TabletopModeController.java @@ -0,0 +1,208 @@ +/* + * 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); + } +} diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java index 72dc771ee08c..ef21c7e9ec0c 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellBaseModule.java @@ -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; @@ -171,6 +172,18 @@ public abstract class WMShellBaseModule { @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, ShellInit shellInit, ShellController shellController, diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/protolog/ShellProtoLogGroup.java b/libs/WindowManager/Shell/src/com/android/wm/shell/protolog/ShellProtoLogGroup.java index 75f9a4c33af9..c9b3a1af6507 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/protolog/ShellProtoLogGroup.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/protolog/ShellProtoLogGroup.java @@ -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; diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/TabletopModeControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/TabletopModeControllerTest.java new file mode 100644 index 000000000000..96d202ce3a85 --- /dev/null +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/common/TabletopModeControllerTest.java @@ -0,0 +1,270 @@ +/* + * 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)); + } +} |