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
This commit is contained in:
Hongwei Wang
2023-02-23 14:50:47 -08:00
parent a88017c32f
commit 6cf33c6d0b
5 changed files with 496 additions and 0 deletions

View File

@@ -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 = {

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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));
}
}