diff --git a/services/core/java/com/android/server/wm/utils/StateMachine.java b/services/core/java/com/android/server/wm/utils/StateMachine.java new file mode 100644 index 0000000000000..91a5dc43a69dc --- /dev/null +++ b/services/core/java/com/android/server/wm/utils/StateMachine.java @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2022 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.utils; + +import android.annotation.IntRange; +import android.annotation.Nullable; +import android.util.IntArray; +import android.util.Slog; +import android.util.SparseArray; + +import com.android.internal.util.AnnotationValidations; + +import java.util.ArrayDeque; +import java.util.Queue; + +/** + * Simple hierarchical state machine. + * + * The state is represented by an integer value. The root state has a value {@code 0x0}, and top + * level state has a value in range {@code 0x1} to {@code 0xF}. To indicate a state B is a sub state + * of a state A, assign an integer state_value(B) = state_value(A) << 4 + (0x0 .. 0xF). + */ +public class StateMachine { + private static final String TAG = "StateMachine"; + + /** + * Interface for implementing state specific actions. + */ + public interface Handler { + /** + * Called when state machine changes its state to this state. + */ + default void enter() {} + + /** + * Called when state machine changes its state from this state to other state. + */ + default void exit() {} + + /** + * @param event type of this event. + * @param param parameter passed to {@link StateMachine#handle(int, Object)} + * @return {@code true} if the event was handled in this handler, so we don't need to + * check the parent state. Otherwise, handle() of the parent state is triggered. + */ + default boolean handle(int event, @Nullable Object param) { + return false; + } + } + + /** + * The most recent state requested by transit() call. + * + * @note When transit() is called recursively, this might not be same value as mState until + * transit() finishes. + */ + private int mLastRequestedState; + + /** + * The current state of this state machine. + */ + private int mState; + + private final IntArray mTmp = new IntArray(); + private final SparseArray mStateHandlers = new SparseArray<>(); + + /** + * Actions which need to execute to finish requested transition. + */ + private final Queue mCommands = new ArrayDeque<>(); + + protected static class Command { + static final int COMMIT = 1; + static final int ENTER = 2; + static final int EXIT = 3; + + final int mType; + final int mState; + + private Command(int type, @IntRange(from = 0) int state) { + mType = type; + AnnotationValidations.validate(IntRange.class, null, state, "from", 0); + mState = state; + } + + static Command newCommit(int state) { + return new Command(COMMIT, state); + } + + static Command newEnter(int state) { + return new Command(ENTER, state); + } + + static Command newExit(int state) { + return new Command(EXIT, state); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("Command{ type: "); + switch (mType) { + case COMMIT: + sb.append("commit"); + break; + case ENTER: + sb.append("enter"); + break; + case EXIT: + sb.append("exit"); + break; + default: + sb.append("UNKNOWN("); + sb.append(mType); + sb.append(")"); + break; + } + sb.append(" state: "); + sb.append(Integer.toHexString(mState)); + sb.append(" }"); + return sb.toString(); + } + } + + public StateMachine() { + this(0); + } + + public StateMachine(@IntRange(from = 0) int initialState) { + mState = initialState; + AnnotationValidations.validate(IntRange.class, null, initialState, "from", 0); + mLastRequestedState = initialState; + } + + /** + * @see #mLastRequestedState + */ + public int getState() { + return mLastRequestedState; + } + + protected int getCurrentState() { + return mState; + } + + protected Command[] getCommands() { + final Command[] commands = new Command[mCommands.size()]; + mCommands.toArray(commands); + return commands; + } + + /** + * Add a handler for a specific state. + * + * @param state State which the given handler processes. + * @param handler A handler which runs entry, exit actions and processes events. + * @return Previous state handler if it's already registered, or {@code null}. + */ + @Nullable public Handler addStateHandler(int state, @Nullable Handler handler) { + final Handler handlerOld = mStateHandlers.get(state); + mStateHandlers.put(state, handler); + return handlerOld; + } + + /** + * Process an event. Search handler for a given event and {@link Handler#handle(int)}. If the + * handler cannot handle the event, delegate it to a handler for a parent of the given state. + * + * @param event Type of an event. + */ + public void handle(int event, @Nullable Object param) { + int state = mState; + while (state != 0) { + final Handler h = mStateHandlers.get(state); + if (h != null && h.handle(event, param)) { + return; + } + state >>= 4; + } + } + + protected void enter(@IntRange(from = 0) int state) { + AnnotationValidations.validate(IntRange.class, null, state, "from", 0); + final Handler h = mStateHandlers.get(state); + if (h != null) { + h.enter(); + } + } + + protected void exit(@IntRange(from = 0) int state) { + AnnotationValidations.validate(IntRange.class, null, state, "from", 0); + final Handler h = mStateHandlers.get(state); + if (h != null) { + h.exit(); + } + } + + /** + * @return {@code true} if a given sub state is a descendant of a given super state. + */ + public static boolean isIn(int subState, int superState) { + while (subState > superState) { + subState >>= 4; + } + return subState == superState; + } + + /** + * Check if the last requested state is a sub state of a given state. + * + * @return {@code true} if the last requested state (via {@link #transit(int)}) is a sub state + * of a given state. + */ + public boolean isIn(int state) { + return isIn(mLastRequestedState, state); + } + + /** + * Change state to the requested state. + * + * @param newState The new state that the state machine should be changed. + */ + public void transit(@IntRange(from = 0) int newState) { + AnnotationValidations.validate(IntRange.class, null, newState, "from", 0); + + // entry and exit action might start another transition, so this transit() function can be + // called recursively. In order to guarantee entry and exit actions in expected order, + // we first compute the sequence and push them into a queue, then process them later. + mCommands.add(Command.newCommit(newState)); + if (mLastRequestedState == newState) { + mCommands.add(Command.newExit(newState)); + mCommands.add(Command.newEnter(newState)); + } else { + // mLastRequestedState to least common ancestor + for (int s = mLastRequestedState; !isIn(newState, s); s >>= 4) { + mCommands.add(Command.newExit(s)); + } + // least common ancestor to newState + mTmp.clear(); + for (int s = newState; !isIn(mLastRequestedState, s); s >>= 4) { + mTmp.add(s); + } + for (int i = mTmp.size() - 1; i >= 0; --i) { + mCommands.add(Command.newEnter(mTmp.get(i))); + } + } + mLastRequestedState = newState; + while (!mCommands.isEmpty()) { + final Command cmd = mCommands.remove(); + switch (cmd.mType) { + case Command.EXIT: + exit(cmd.mState); + break; + case Command.ENTER: + enter(cmd.mState); + break; + case Command.COMMIT: + mState = cmd.mState; + break; + default: + Slog.e(TAG, "Unknown command type: " + cmd.mType); + break; + } + } + } +} diff --git a/services/core/java/com/android/server/wm/utils/TEST_MAPPING b/services/core/java/com/android/server/wm/utils/TEST_MAPPING new file mode 100644 index 0000000000000..aa69d2a189481 --- /dev/null +++ b/services/core/java/com/android/server/wm/utils/TEST_MAPPING @@ -0,0 +1,18 @@ +{ + "presubmit": [ + { + "name": "WmTests", + "options": [ + { + "include-filter": "com.android.server.wm.utils" + }, + { + "include-annotation": "android.platform.test.annotations.Presubmit" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] + } + ] +} diff --git a/services/tests/wmtests/src/com/android/server/wm/utils/StateMachineTest.java b/services/tests/wmtests/src/com/android/server/wm/utils/StateMachineTest.java new file mode 100644 index 0000000000000..e82a7c222b8f5 --- /dev/null +++ b/services/tests/wmtests/src/com/android/server/wm/utils/StateMachineTest.java @@ -0,0 +1,237 @@ +/* + * Copyright (C) 2022 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.utils; + +import static com.android.server.wm.utils.StateMachine.isIn; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.platform.test.annotations.Presubmit; + +import androidx.test.filters.SmallTest; + +import org.junit.Test; + +/** + * Build/Install/Run: + * atest WmTests:StateMachineTest + */ +@SmallTest +@Presubmit +public class StateMachineTest { + static class LoggingHandler implements StateMachine.Handler { + final int mState; + final StringBuffer mStringBuffer; + // True if process #handle + final boolean mHandleSelf; + + LoggingHandler(int state, StringBuffer sb, boolean handleSelf) { + mHandleSelf = handleSelf; + mState = state; + mStringBuffer = sb; + } + + LoggingHandler(int state, StringBuffer sb) { + this(state, sb, true /* handleSelf */); + } + + @Override + public void enter() { + mStringBuffer.append('i'); + mStringBuffer.append(Integer.toHexString(mState)); + mStringBuffer.append(';'); + } + + @Override + public void exit() { + mStringBuffer.append('o'); + mStringBuffer.append(Integer.toHexString(mState)); + mStringBuffer.append(';'); + } + + @Override + public boolean handle(int event, Object param) { + if (mHandleSelf) { + mStringBuffer.append('h'); + mStringBuffer.append(Integer.toHexString(mState)); + mStringBuffer.append(';'); + } + return mHandleSelf; + } + } + + static class LoggingHandlerTransferInExit extends LoggingHandler { + final StateMachine mStateMachine; + final int mStateToTransit; + + LoggingHandlerTransferInExit(int state, StringBuffer sb, StateMachine stateMachine, + int stateToTransit) { + super(state, sb); + mStateMachine = stateMachine; + mStateToTransit = stateToTransit; + } + + @Override + public void exit() { + super.exit(); + mStateMachine.transit(mStateToTransit); + } + } + + @Test + public void testStateMachineIsIn() { + assertTrue(isIn(0x112, 0x1)); + assertTrue(isIn(0x112, 0x11)); + assertTrue(isIn(0x112, 0x112)); + + assertFalse(isIn(0x1, 0x112)); + assertFalse(isIn(0x12, 0x2)); + } + + @Test + public void testStateMachineInitialState() { + StateMachine stateMachine = new StateMachine(); + assertEquals(0, stateMachine.getState()); + + stateMachine = new StateMachine(0x23); + assertEquals(0x23, stateMachine.getState()); + } + + @Test + public void testStateMachineTransitToChild() { + final StringBuffer log = new StringBuffer(); + + StateMachine stateMachine = new StateMachine(); + stateMachine.addStateHandler(0x1, new LoggingHandler(0x1, log)); + stateMachine.addStateHandler(0x12, new LoggingHandler(0x12, log)); + stateMachine.addStateHandler(0x123, new LoggingHandler(0x123, log)); + stateMachine.addStateHandler(0x1233, new LoggingHandler(0x1233, log)); + + // 0x0 -> 0x12 + stateMachine.transit(0x12); + assertEquals("i1;i12;", log.toString()); + assertEquals(0x12, stateMachine.getState()); + + // 0x12 -> 0x1233 + log.setLength(0); + stateMachine.transit(0x1233); + assertEquals(0x1233, stateMachine.getState()); + assertEquals("i123;i1233;", log.toString()); + } + + @Test + public void testStateMachineTransitToParent() { + final StringBuffer log = new StringBuffer(); + + StateMachine stateMachine = new StateMachine(0x253); + stateMachine.addStateHandler(0x2, new LoggingHandler(0x2, log)); + stateMachine.addStateHandler(0x25, new LoggingHandler(0x25, log)); + stateMachine.addStateHandler(0x253, new LoggingHandler(0x253, log)); + + // 0x253 -> 0x2 + stateMachine.transit(0x2); + assertEquals(0x2, stateMachine.getState()); + assertEquals("o253;o25;", log.toString()); + } + + @Test + public void testStateMachineTransitSelf() { + final StringBuffer log = new StringBuffer(); + + StateMachine stateMachine = new StateMachine(0x253); + stateMachine.addStateHandler(0x2, new LoggingHandler(0x2, log)); + stateMachine.addStateHandler(0x25, new LoggingHandler(0x25, log)); + stateMachine.addStateHandler(0x253, new LoggingHandler(0x253, log)); + + // 0x253 -> 0x253 + stateMachine.transit(0x253); + assertEquals(0x253, stateMachine.getState()); + assertEquals("o253;i253;", log.toString()); + } + + @Test + public void testStateMachineTransitGeneral() { + final StringBuffer log = new StringBuffer(); + + StateMachine stateMachine = new StateMachine(0x1351); + stateMachine.addStateHandler(0x1, new LoggingHandler(0x1, log)); + stateMachine.addStateHandler(0x13, new LoggingHandler(0x13, log)); + stateMachine.addStateHandler(0x132, new LoggingHandler(0x132, log)); + stateMachine.addStateHandler(0x1322, new LoggingHandler(0x1322, log)); + stateMachine.addStateHandler(0x1322, new LoggingHandler(0x1322, log)); + stateMachine.addStateHandler(0x135, new LoggingHandler(0x135, log)); + stateMachine.addStateHandler(0x1351, new LoggingHandler(0x1351, log)); + + // 0x1351 -> 0x1322 + // least common ancestor = 0x13 + stateMachine.transit(0x1322); + assertEquals(0x1322, stateMachine.getState()); + assertEquals("o1351;o135;i132;i1322;", log.toString()); + } + + @Test + public void testStateMachineTriggerStateAction() { + final StringBuffer log = new StringBuffer(); + + StateMachine stateMachine = new StateMachine(0x253); + stateMachine.addStateHandler(0x2, new LoggingHandler(0x2, log)); + stateMachine.addStateHandler(0x25, new LoggingHandler(0x25, log)); + stateMachine.addStateHandler(0x253, new LoggingHandler(0x253, log)); + + // state 0x253 handles the message itself + stateMachine.handle(0, null); + assertEquals("h253;", log.toString()); + } + + @Test + public void testStateMachineTriggerStateActionDelegate() { + final StringBuffer log = new StringBuffer(); + + StateMachine stateMachine = new StateMachine(0x253); + stateMachine.addStateHandler(0x2, new LoggingHandler(0x2, log)); + stateMachine.addStateHandler(0x25, new LoggingHandler(0x25, log)); + stateMachine.addStateHandler(0x253, + new LoggingHandler(0x253, log, false /* handleSelf */)); + + // state 0x253 delegate the message handling to its parent state + stateMachine.handle(0, null); + assertEquals("h25;", log.toString()); + } + + @Test + public void testStateMachineNestedTransition() { + final StringBuffer log = new StringBuffer(); + + StateMachine stateMachine = new StateMachine(0x25); + stateMachine.addStateHandler(0x1, new LoggingHandler(0x1, log)); + + // Force transit to state 0x3 in exit() + stateMachine.addStateHandler(0x2, + new LoggingHandlerTransferInExit(0x2, log, stateMachine, 0x3)); + stateMachine.addStateHandler(0x25, new LoggingHandler(0x25, log)); + stateMachine.addStateHandler(0x3, new LoggingHandler(0x3, log)); + + stateMachine.transit(0x1); + // Start transit to 0x1 + // 0x25 -> 0x2 [transit(0x3) requested] -> 0x1 + // 0x1 -> 0x3 + // Immediately set the status to 0x1, no enter/exit + assertEquals("o25;o2;i1;o1;i3;", log.toString()); + } +}