Extract FoldableDeviceStateProvider to a library
Moves FoldableDeviceStateProvider to a separate
java library, so it could shared between
physical devices and cuttlefish devices.
Also makes it public (moves to AOSP).
No changes are made to the functionality,
this is just a move.
Bug: 284266229
Test: manual compile and check on a physical device
Test: presubmit
Flag: none
Change-Id: Ie7f7d334a4904bfbb19baa5dc814291828a8c9eb
diff --git a/services/foldables/devicestateprovider/Android.bp b/services/foldables/devicestateprovider/Android.bp
new file mode 100644
index 0000000..34737ef
--- /dev/null
+++ b/services/foldables/devicestateprovider/Android.bp
@@ -0,0 +1,13 @@
+package {
+ default_applicable_licenses: ["frameworks_base_license"],
+}
+
+java_library {
+ name: "foldable-device-state-provider",
+ srcs: [
+ "src/**/*.java"
+ ],
+ libs: [
+ "services",
+ ],
+}
diff --git a/services/foldables/devicestateprovider/README.md b/services/foldables/devicestateprovider/README.md
new file mode 100644
index 0000000..90174c0
--- /dev/null
+++ b/services/foldables/devicestateprovider/README.md
@@ -0,0 +1,3 @@
+# Foldable Device State Provider library
+
+This library provides foldable-specific classes that could be used to implement a custom DeviceStateProvider.
\ No newline at end of file
diff --git a/services/foldables/devicestateprovider/TEST_MAPPING b/services/foldables/devicestateprovider/TEST_MAPPING
new file mode 100644
index 0000000..cd0d851
--- /dev/null
+++ b/services/foldables/devicestateprovider/TEST_MAPPING
@@ -0,0 +1,12 @@
+{
+ "presubmit": [
+ {
+ "name": "foldable-services-tests",
+ "options": [
+ {
+ "exclude-annotation": "androidx.test.filters.FlakyTest"
+ }
+ ]
+ }
+ ]
+}
diff --git a/services/foldables/devicestateprovider/src/com/android/server/policy/FoldableDeviceStateProvider.java b/services/foldables/devicestateprovider/src/com/android/server/policy/FoldableDeviceStateProvider.java
new file mode 100644
index 0000000..aea46d1
--- /dev/null
+++ b/services/foldables/devicestateprovider/src/com/android/server/policy/FoldableDeviceStateProvider.java
@@ -0,0 +1,537 @@
+/*
+ * 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.server.policy;
+
+import static android.hardware.SensorManager.SENSOR_DELAY_FASTEST;
+import static android.hardware.devicestate.DeviceStateManager.INVALID_DEVICE_STATE;
+import static android.hardware.devicestate.DeviceStateManager.MAXIMUM_DEVICE_STATE;
+import static android.hardware.devicestate.DeviceStateManager.MINIMUM_DEVICE_STATE;
+import static android.view.Display.DEFAULT_DISPLAY;
+
+import android.annotation.IntRange;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.PowerManager;
+import android.hardware.display.DisplayManager;
+import android.os.Trace;
+import android.util.Slog;
+import android.util.SparseArray;
+import android.view.Display;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.Preconditions;
+import com.android.server.devicestate.DeviceState;
+import com.android.server.devicestate.DeviceStateProvider;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.function.BooleanSupplier;
+import java.util.function.Function;
+
+/**
+ * Device state provider for foldable devices.
+ *
+ * It is an implementation of {@link DeviceStateProvider} tailored specifically for
+ * foldable devices and allows simple callback-based configuration with hall sensor
+ * and hinge angle sensor values.
+ */
+public final class FoldableDeviceStateProvider implements DeviceStateProvider,
+ SensorEventListener, PowerManager.OnThermalStatusChangedListener,
+ DisplayManager.DisplayListener {
+
+ private static final String TAG = "FoldableDeviceStateProvider";
+ private static final boolean DEBUG = false;
+
+ // Lock for internal state.
+ private final Object mLock = new Object();
+
+ // List of supported states in ascending order based on their identifier.
+ private final DeviceState[] mOrderedStates;
+
+ // Map of state identifier to a boolean supplier that returns true when all required conditions
+ // are met for the device to be in the state.
+ private final SparseArray<BooleanSupplier> mStateConditions = new SparseArray<>();
+
+ private final Sensor mHingeAngleSensor;
+ private final DisplayManager mDisplayManager;
+ private final Sensor mHallSensor;
+
+ @Nullable
+ @GuardedBy("mLock")
+ private Listener mListener = null;
+ @GuardedBy("mLock")
+ private int mLastReportedState = INVALID_DEVICE_STATE;
+ @GuardedBy("mLock")
+ private SensorEvent mLastHingeAngleSensorEvent = null;
+ @GuardedBy("mLock")
+ private SensorEvent mLastHallSensorEvent = null;
+ @GuardedBy("mLock")
+ private @PowerManager.ThermalStatus
+ int mThermalStatus = PowerManager.THERMAL_STATUS_NONE;
+ @GuardedBy("mLock")
+ private boolean mIsScreenOn = false;
+
+ @GuardedBy("mLock")
+ private boolean mPowerSaveModeEnabled;
+
+ public FoldableDeviceStateProvider(@NonNull Context context,
+ @NonNull SensorManager sensorManager,
+ @NonNull Sensor hingeAngleSensor,
+ @NonNull Sensor hallSensor,
+ @NonNull DisplayManager displayManager,
+ @NonNull DeviceStateConfiguration[] deviceStateConfigurations) {
+
+ Preconditions.checkArgument(deviceStateConfigurations.length > 0,
+ "Device state configurations array must not be empty");
+
+ mHingeAngleSensor = hingeAngleSensor;
+ mHallSensor = hallSensor;
+ mDisplayManager = displayManager;
+
+ sensorManager.registerListener(this, mHingeAngleSensor, SENSOR_DELAY_FASTEST);
+ sensorManager.registerListener(this, mHallSensor, SENSOR_DELAY_FASTEST);
+
+ mOrderedStates = new DeviceState[deviceStateConfigurations.length];
+ for (int i = 0; i < deviceStateConfigurations.length; i++) {
+ final DeviceStateConfiguration configuration = deviceStateConfigurations[i];
+ mOrderedStates[i] = configuration.mDeviceState;
+
+ if (mStateConditions.get(configuration.mDeviceState.getIdentifier()) != null) {
+ throw new IllegalArgumentException("Device state configurations must have unique"
+ + " device state identifiers, found duplicated identifier: " +
+ configuration.mDeviceState.getIdentifier());
+ }
+
+ mStateConditions.put(configuration.mDeviceState.getIdentifier(), () ->
+ configuration.mPredicate.apply(this));
+ }
+
+ mDisplayManager.registerDisplayListener(
+ /* listener = */ this,
+ /* handler= */ null,
+ /* eventsMask= */ DisplayManager.EVENT_FLAG_DISPLAY_CHANGED);
+
+ Arrays.sort(mOrderedStates, Comparator.comparingInt(DeviceState::getIdentifier));
+
+ PowerManager powerManager = context.getSystemService(PowerManager.class);
+ if (powerManager != null) {
+ // If any of the device states are thermal sensitive, i.e. it should be disabled when
+ // the device is overheating, then we will update the list of supported states when
+ // thermal status changes.
+ if (hasThermalSensitiveState(deviceStateConfigurations)) {
+ powerManager.addThermalStatusListener(this);
+ }
+
+ // If any of the device states are power sensitive, i.e. it should be disabled when
+ // power save mode is enabled, then we will update the list of supported states when
+ // power save mode is toggled.
+ if (hasPowerSaveSensitiveState(deviceStateConfigurations)) {
+ IntentFilter filter = new IntentFilter(
+ PowerManager.ACTION_POWER_SAVE_MODE_CHANGED_INTERNAL);
+ BroadcastReceiver receiver = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ if (PowerManager.ACTION_POWER_SAVE_MODE_CHANGED_INTERNAL.equals(
+ intent.getAction())) {
+ onPowerSaveModeChanged(powerManager.isPowerSaveMode());
+ }
+ }
+ };
+ context.registerReceiver(receiver, filter);
+ }
+ }
+ }
+
+ @Override
+ public void setListener(Listener listener) {
+ synchronized (mLock) {
+ if (mListener != null) {
+ throw new RuntimeException("Provider already has a listener set.");
+ }
+ mListener = listener;
+ }
+ notifySupportedStatesChanged(SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED);
+ notifyDeviceStateChangedIfNeeded();
+ }
+
+ /** Notifies the listener that the set of supported device states has changed. */
+ private void notifySupportedStatesChanged(@SupportedStatesUpdatedReason int reason) {
+ List<DeviceState> supportedStates = new ArrayList<>();
+ Listener listener;
+ synchronized (mLock) {
+ if (mListener == null) {
+ return;
+ }
+ listener = mListener;
+ for (DeviceState deviceState : mOrderedStates) {
+ if (isThermalStatusCriticalOrAbove(mThermalStatus)
+ && deviceState.hasFlag(
+ DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL)) {
+ continue;
+ }
+ if (mPowerSaveModeEnabled && deviceState.hasFlag(
+ DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE)) {
+ continue;
+ }
+ supportedStates.add(deviceState);
+ }
+ }
+
+ listener.onSupportedDeviceStatesChanged(
+ supportedStates.toArray(new DeviceState[supportedStates.size()]), reason);
+ }
+
+ /** Computes the current device state and notifies the listener of a change, if needed. */
+ void notifyDeviceStateChangedIfNeeded() {
+ int stateToReport = INVALID_DEVICE_STATE;
+ Listener listener;
+ synchronized (mLock) {
+ if (mListener == null) {
+ return;
+ }
+
+ listener = mListener;
+
+ int newState = INVALID_DEVICE_STATE;
+ for (int i = 0; i < mOrderedStates.length; i++) {
+ int state = mOrderedStates[i].getIdentifier();
+ if (DEBUG) {
+ Slog.d(TAG, "Checking conditions for " + mOrderedStates[i].getName() + "("
+ + i + ")");
+ }
+ boolean conditionSatisfied;
+ try {
+ conditionSatisfied = mStateConditions.get(state).getAsBoolean();
+ } catch (IllegalStateException e) {
+ // Failed to compute the current state based on current available data. Continue
+ // with the expectation that notifyDeviceStateChangedIfNeeded() will be called
+ // when a callback with the missing data is triggered. May trigger another state
+ // change if another state is satisfied currently.
+ Slog.w(TAG, "Unable to check current state = " + state, e);
+ dumpSensorValues();
+ continue;
+ }
+
+ if (conditionSatisfied) {
+ if (DEBUG) {
+ Slog.d(TAG, "Device State conditions satisfied, transition to " + state);
+ }
+ newState = state;
+ break;
+ }
+ }
+ if (newState == INVALID_DEVICE_STATE) {
+ Slog.e(TAG, "No declared device states match any of the required conditions.");
+ dumpSensorValues();
+ }
+
+ if (newState != INVALID_DEVICE_STATE && newState != mLastReportedState) {
+ mLastReportedState = newState;
+ stateToReport = newState;
+ }
+ }
+
+ if (stateToReport != INVALID_DEVICE_STATE) {
+ listener.onStateChanged(stateToReport);
+ }
+ }
+
+ @Override
+ public void onSensorChanged(SensorEvent event) {
+ synchronized (mLock) {
+ if (event.sensor == mHallSensor) {
+ mLastHallSensorEvent = event;
+ } else if (event.sensor == mHingeAngleSensor) {
+ mLastHingeAngleSensorEvent = event;
+ }
+ }
+ notifyDeviceStateChangedIfNeeded();
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
+ // Do nothing.
+ }
+
+ private float getSensorValue(@Nullable SensorEvent sensorEvent) {
+ if (sensorEvent == null) {
+ throw new IllegalStateException("Have not received sensor event.");
+ }
+
+ if (sensorEvent.values.length < 1) {
+ throw new IllegalStateException("Values in the sensor event are empty");
+ }
+
+ return sensorEvent.values[0];
+ }
+
+ @GuardedBy("mLock")
+ private void dumpSensorValues() {
+ Slog.i(TAG, "Sensor values:");
+ dumpSensorValues("Hall Sensor", mHallSensor, mLastHallSensorEvent);
+ dumpSensorValues("Hinge Angle Sensor",mHingeAngleSensor, mLastHingeAngleSensorEvent);
+ Slog.i(TAG, "isScreenOn: " + isScreenOn());
+ }
+
+ @GuardedBy("mLock")
+ private void dumpSensorValues(String sensorType, Sensor sensor, @Nullable SensorEvent event) {
+ String sensorString = sensor == null ? "null" : sensor.getName();
+ String eventValues = event == null ? "null" : Arrays.toString(event.values);
+ Slog.i(TAG, sensorType + " : " + sensorString + " : " + eventValues);
+ }
+
+ @Override
+ public void onDisplayAdded(int displayId) {
+
+ }
+
+ @Override
+ public void onDisplayRemoved(int displayId) {
+
+ }
+
+ @Override
+ public void onDisplayChanged(int displayId) {
+ if (displayId == DEFAULT_DISPLAY) {
+ // Could potentially be moved to the background if needed.
+ try {
+ Trace.beginSection("FoldableDeviceStateProvider#onDisplayChanged()");
+ int displayState = mDisplayManager.getDisplay(displayId).getState();
+ synchronized (mLock) {
+ mIsScreenOn = displayState == Display.STATE_ON;
+ }
+ } finally {
+ Trace.endSection();
+ }
+ }
+ }
+
+ /**
+ * Configuration for a single device state, contains information about the state like
+ * identifier, name, flags and a predicate that should return true if the state should
+ * be selected.
+ */
+ public static class DeviceStateConfiguration {
+ private final DeviceState mDeviceState;
+ private final Function<FoldableDeviceStateProvider, Boolean> mPredicate;
+
+ private DeviceStateConfiguration(DeviceState deviceState,
+ Function<FoldableDeviceStateProvider, Boolean> predicate) {
+ mDeviceState = deviceState;
+ mPredicate = predicate;
+ }
+
+ public static DeviceStateConfiguration createConfig(
+ @IntRange(from = MINIMUM_DEVICE_STATE, to = MAXIMUM_DEVICE_STATE) int identifier,
+ @NonNull String name,
+ @DeviceState.DeviceStateFlags int flags,
+ Function<FoldableDeviceStateProvider, Boolean> predicate
+ ) {
+ return new DeviceStateConfiguration(new DeviceState(identifier, name, flags),
+ predicate);
+ }
+
+ public static DeviceStateConfiguration createConfig(
+ @IntRange(from = MINIMUM_DEVICE_STATE, to = MAXIMUM_DEVICE_STATE) int identifier,
+ @NonNull String name,
+ Function<FoldableDeviceStateProvider, Boolean> predicate
+ ) {
+ return new DeviceStateConfiguration(new DeviceState(identifier, name, /* flags= */ 0),
+ predicate);
+ }
+
+ /**
+ * Creates a device state configuration for a closed tent-mode aware state.
+ *
+ * During tent mode:
+ * - The inner display is OFF
+ * - The outer display is ON
+ * - The device is partially unfolded (left and right edges could be on the table)
+ * In this mode the device the device so it could be used in a posture where both left
+ * and right edges of the unfolded device are on the table.
+ *
+ * The predicate returns false after the hinge angle reaches
+ * {@code tentModeSwitchAngleDegrees}. Then it switches back only when the hinge angle
+ * becomes less than {@code maxClosedAngleDegrees}. Hinge angle is 0 degrees when the device
+ * is fully closed and 180 degrees when it is fully unfolded.
+ *
+ * For example, when tentModeSwitchAngleDegrees = 90 and maxClosedAngleDegrees = 5 degrees:
+ * - when unfolding the device from fully closed posture (last state == closed or it is
+ * undefined yet) this state will become not matching after reaching the angle
+ * of 90 degrees, it allows the device to switch the outer display to the inner display
+ * only when reaching this threshold
+ * - when folding (last state != 'closed') this state will become matching after reaching
+ * the angle less than 5 degrees and when hall sensor detected that the device is closed,
+ * so the switch from the inner display to the outer will become only when the device
+ * is fully closed.
+ *
+ * @param identifier state identifier
+ * @param name state name
+ * @param flags state flags
+ * @param minClosedAngleDegrees minimum (inclusive) hinge angle value for the closed state
+ * @param maxClosedAngleDegrees maximum (non-inclusive) hinge angle value for the closed
+ * state
+ * @param tentModeSwitchAngleDegrees the angle when this state should switch when unfolding
+ * @return device state configuration
+ */
+ public static DeviceStateConfiguration createTentModeClosedState(
+ @IntRange(from = MINIMUM_DEVICE_STATE, to = MAXIMUM_DEVICE_STATE) int identifier,
+ @NonNull String name,
+ @DeviceState.DeviceStateFlags int flags,
+ int minClosedAngleDegrees,
+ int maxClosedAngleDegrees,
+ int tentModeSwitchAngleDegrees
+ ) {
+ return new DeviceStateConfiguration(new DeviceState(identifier, name, flags),
+ (stateContext) -> {
+ final boolean hallSensorClosed = stateContext.isHallSensorClosed();
+ final float hingeAngle = stateContext.getHingeAngle();
+ final int lastState = stateContext.getLastReportedDeviceState();
+ final boolean isScreenOn = stateContext.isScreenOn();
+
+ final int switchingDegrees =
+ isScreenOn ? tentModeSwitchAngleDegrees : maxClosedAngleDegrees;
+
+ final int closedDeviceState = identifier;
+ final boolean isLastStateClosed = lastState == closedDeviceState
+ || lastState == INVALID_DEVICE_STATE;
+
+ final boolean shouldBeClosedBecauseTentMode = isLastStateClosed
+ && hingeAngle >= minClosedAngleDegrees
+ && hingeAngle < switchingDegrees;
+
+ final boolean shouldBeClosedBecauseFullyShut = hallSensorClosed
+ && hingeAngle >= minClosedAngleDegrees
+ && hingeAngle < maxClosedAngleDegrees;
+
+ return shouldBeClosedBecauseFullyShut || shouldBeClosedBecauseTentMode;
+ });
+ }
+ }
+
+ /**
+ * @return Whether the screen is on.
+ */
+ public boolean isScreenOn() {
+ synchronized (mLock) {
+ return mIsScreenOn;
+ }
+ }
+ /**
+ * @return current hinge angle value of a foldable device
+ */
+ public float getHingeAngle() {
+ synchronized (mLock) {
+ return getSensorValue(mLastHingeAngleSensorEvent);
+ }
+ }
+
+ /**
+ * @return true if hall sensor detected that the device is closed (fully shut)
+ */
+ public boolean isHallSensorClosed() {
+ synchronized (mLock) {
+ return getSensorValue(mLastHallSensorEvent) > 0f;
+ }
+ }
+
+ /**
+ * @return last reported device state
+ */
+ public int getLastReportedDeviceState() {
+ synchronized (mLock) {
+ return mLastReportedState;
+ }
+ }
+
+ @VisibleForTesting
+ void onPowerSaveModeChanged(boolean isPowerSaveModeEnabled) {
+ synchronized (mLock) {
+ if (mPowerSaveModeEnabled != isPowerSaveModeEnabled) {
+ mPowerSaveModeEnabled = isPowerSaveModeEnabled;
+ notifySupportedStatesChanged(
+ isPowerSaveModeEnabled ? SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_ENABLED
+ : SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_DISABLED);
+ }
+ }
+ }
+
+ @Override
+ public void onThermalStatusChanged(@PowerManager.ThermalStatus int thermalStatus) {
+ int previousThermalStatus;
+ synchronized (mLock) {
+ previousThermalStatus = mThermalStatus;
+ mThermalStatus = thermalStatus;
+ }
+
+ boolean isThermalStatusCriticalOrAbove = isThermalStatusCriticalOrAbove(thermalStatus);
+ boolean isPreviousThermalStatusCriticalOrAbove =
+ isThermalStatusCriticalOrAbove(previousThermalStatus);
+ if (isThermalStatusCriticalOrAbove != isPreviousThermalStatusCriticalOrAbove) {
+ Slog.i(TAG, "Updating supported device states due to thermal status change."
+ + " isThermalStatusCriticalOrAbove: " + isThermalStatusCriticalOrAbove);
+ notifySupportedStatesChanged(
+ isThermalStatusCriticalOrAbove
+ ? SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_CRITICAL
+ : SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_NORMAL);
+ }
+ }
+
+ private static boolean isThermalStatusCriticalOrAbove(
+ @PowerManager.ThermalStatus int thermalStatus) {
+ switch (thermalStatus) {
+ case PowerManager.THERMAL_STATUS_CRITICAL:
+ case PowerManager.THERMAL_STATUS_EMERGENCY:
+ case PowerManager.THERMAL_STATUS_SHUTDOWN:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static boolean hasThermalSensitiveState(DeviceStateConfiguration[] deviceStates) {
+ for (int i = 0; i < deviceStates.length; i++) {
+ DeviceStateConfiguration state = deviceStates[i];
+ if (state.mDeviceState
+ .hasFlag(DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean hasPowerSaveSensitiveState(DeviceStateConfiguration[] deviceStates) {
+ for (int i = 0; i < deviceStates.length; i++) {
+ if (deviceStates[i].mDeviceState
+ .hasFlag(DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/services/foldables/devicestateprovider/tests/Android.bp b/services/foldables/devicestateprovider/tests/Android.bp
new file mode 100644
index 0000000..a8db05e
--- /dev/null
+++ b/services/foldables/devicestateprovider/tests/Android.bp
@@ -0,0 +1,30 @@
+package {
+ default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_test {
+ name: "foldable-device-state-provider-tests",
+ srcs: ["src/**/*.java"],
+ libs: [
+ "android.test.runner",
+ "android.test.base",
+ "android.test.mock",
+ ],
+ jni_libs: [
+ "libdexmakerjvmtiagent",
+ "libmultiplejvmtiagentsinterferenceagent",
+ "libstaticjvmtiagent",
+ ],
+ static_libs: [
+ "services",
+ "foldable-device-state-provider",
+ "androidx.test.rules",
+ "junit",
+ "truth-prebuilt",
+ "mockito-target-extended-minus-junit4",
+ "androidx.test.uiautomator_uiautomator",
+ "androidx.test.ext.junit",
+ "testables",
+ ],
+ test_suites: ["device-tests"]
+}
diff --git a/services/foldables/devicestateprovider/tests/AndroidManifest.xml b/services/foldables/devicestateprovider/tests/AndroidManifest.xml
new file mode 100644
index 0000000..736613d
--- /dev/null
+++ b/services/foldables/devicestateprovider/tests/AndroidManifest.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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.
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.foldablesdevicestatelib.tests">
+
+ <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30" />
+
+ <application android:debuggable="true">
+ <uses-library android:name="android.test.runner" />
+ </application>
+
+ <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:targetPackage="com.android.foldablesdevicestatelib.tests"
+ android:label="Tests for foldable-device-state-provider library">
+ </instrumentation>
+
+</manifest>
\ No newline at end of file
diff --git a/services/foldables/devicestateprovider/tests/AndroidTest.xml b/services/foldables/devicestateprovider/tests/AndroidTest.xml
new file mode 100644
index 0000000..55e0d9c
--- /dev/null
+++ b/services/foldables/devicestateprovider/tests/AndroidTest.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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.
+ -->
+<configuration description="foldable-device-state-provider tests">
+ <target_preparer class="com.android.tradefed.targetprep.suite.SuiteApkInstaller">
+ <option name="cleanup-apks" value="true" />
+ <option name="install-arg" value="-t" />
+ <option name="test-file-name" value="foldable-device-state-provider-tests.apk" />
+ </target_preparer>
+
+ <test class="com.android.tradefed.testtype.AndroidJUnitTest">
+ <option name="package" value="com.android.foldableslib.tests" />
+ <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
+ <option name="hidden-api-checks" value="false" />
+ </test>
+</configuration>
\ No newline at end of file
diff --git a/services/foldables/devicestateprovider/tests/src/com/android/server/policy/FoldableDeviceStateProviderTest.java b/services/foldables/devicestateprovider/tests/src/com/android/server/policy/FoldableDeviceStateProviderTest.java
new file mode 100644
index 0000000..8fa4ce5
--- /dev/null
+++ b/services/foldables/devicestateprovider/tests/src/com/android/server/policy/FoldableDeviceStateProviderTest.java
@@ -0,0 +1,519 @@
+/*
+ * 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.server.policy;
+
+
+import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED;
+import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_DISABLED;
+import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_ENABLED;
+import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_CRITICAL;
+import static com.android.server.devicestate.DeviceStateProvider.SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_NORMAL;
+import com.android.server.policy.FoldableDeviceStateProvider.DeviceStateConfiguration;
+
+import static android.view.Display.DEFAULT_DISPLAY;
+import static android.view.Display.STATE_OFF;
+import static android.view.Display.STATE_ON;
+
+import static com.android.server.policy.FoldableDeviceStateProvider.DeviceStateConfiguration.createConfig;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.nullable;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorManager;
+import android.hardware.display.DisplayManager;
+import android.hardware.input.InputSensorInfo;
+import android.os.PowerManager;
+import android.os.Handler;
+import android.testing.AndroidTestingRunner;
+import android.view.Display;
+
+import com.android.server.devicestate.DeviceState;
+import com.android.server.devicestate.DeviceStateProvider;
+import com.android.server.devicestate.DeviceStateProvider.Listener;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.mockito.internal.util.reflection.FieldSetter;
+
+/**
+ * Unit tests for {@link FoldableDeviceStateProvider}.
+ * <p/>
+ * Run with <code>atest FoldableDeviceStateProviderTest</code>.
+ */
+@RunWith(AndroidTestingRunner.class)
+public final class FoldableDeviceStateProviderTest {
+
+ private final ArgumentCaptor<DeviceState[]> mDeviceStateArrayCaptor = ArgumentCaptor.forClass(
+ DeviceState[].class);
+ @Captor
+ private ArgumentCaptor<Integer> mIntegerCaptor;
+ @Captor
+ private ArgumentCaptor<DisplayManager.DisplayListener> mDisplayListenerCaptor;
+ @Mock
+ private SensorManager mSensorManager;
+ @Mock
+ private Context mContext;
+ @Mock
+ private InputSensorInfo mInputSensorInfo;
+ private Sensor mHallSensor;
+ private Sensor mHingeAngleSensor;
+ @Mock
+ private DisplayManager mDisplayManager;
+ private FoldableDeviceStateProvider mProvider;
+
+ @Before
+ public void setup() {
+ MockitoAnnotations.initMocks(this);
+
+ mHallSensor = new Sensor(mInputSensorInfo);
+ mHingeAngleSensor = new Sensor(mInputSensorInfo);
+ }
+
+ @Test
+ public void create_emptyConfiguration_throwsException() {
+ assertThrows(IllegalArgumentException.class, this::createProvider);
+ }
+
+ @Test
+ public void create_duplicatedDeviceStateIdentifiers_throwsException() {
+ assertThrows(IllegalArgumentException.class,
+ () -> createProvider(
+ createConfig(
+ /* identifier= */ 0, /* name= */ "ONE", (c) -> true),
+ createConfig(
+ /* identifier= */ 0, /* name= */ "TWO", (c) -> true)
+ ));
+ }
+
+ @Test
+ public void create_allMatchingStatesDefaultsToTheFirstIdentifier() {
+ createProvider(
+ createConfig(
+ /* identifier= */ 1, /* name= */ "ONE", (c) -> true),
+ createConfig(
+ /* identifier= */ 2, /* name= */ "TWO", (c) -> true),
+ createConfig(
+ /* identifier= */ 3, /* name= */ "THREE", (c) -> true)
+ );
+
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+
+ verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED));
+ final DeviceState[] expectedStates = new DeviceState[]{
+ new DeviceState(1, "ONE", /* flags= */ 0),
+ new DeviceState(2, "TWO", /* flags= */ 0),
+ new DeviceState(3, "THREE", /* flags= */ 0),
+ };
+ assertArrayEquals(expectedStates, mDeviceStateArrayCaptor.getValue());
+
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(1, mIntegerCaptor.getValue().intValue());
+ }
+
+ @Test
+ public void create_multipleMatchingStatesDefaultsToTheLowestIdentifier() {
+ createProvider(
+ createConfig(
+ /* identifier= */ 1, /* name= */ "ONE", (c) -> false),
+ createConfig(
+ /* identifier= */ 3, /* name= */ "THREE", (c) -> false),
+ createConfig(
+ /* identifier= */ 4, /* name= */ "FOUR", (c) -> true),
+ createConfig(
+ /* identifier= */ 2, /* name= */ "TWO", (c) -> true)
+ );
+
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(2, mIntegerCaptor.getValue().intValue());
+ }
+
+ @Test
+ public void test_hingeAngleUpdatedFirstTime_switchesToMatchingState() throws Exception {
+ createProvider(createConfig(/* identifier= */ 1, /* name= */ "ONE",
+ (c) -> c.getHingeAngle() < 90f),
+ createConfig(/* identifier= */ 2, /* name= */ "TWO",
+ (c) -> c.getHingeAngle() >= 90f));
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+ verify(listener, never()).onStateChanged(anyInt());
+ clearInvocations(listener);
+
+ sendSensorEvent(mHingeAngleSensor, /* value= */ 100f);
+
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(2, mIntegerCaptor.getValue().intValue());
+ }
+
+ @Test
+ public void test_hallSensorUpdatedFirstTime_switchesToMatchingState() throws Exception {
+ createProvider(createConfig(/* identifier= */ 1, /* name= */ "ONE",
+ (c) -> !c.isHallSensorClosed()),
+ createConfig(/* identifier= */ 2, /* name= */ "TWO",
+ FoldableDeviceStateProvider::isHallSensorClosed));
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+ verify(listener, never()).onStateChanged(anyInt());
+ clearInvocations(listener);
+
+ // Hall sensor value '1f' is for the closed state
+ sendSensorEvent(mHallSensor, /* value= */ 1f);
+
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(2, mIntegerCaptor.getValue().intValue());
+ }
+
+ @Test
+ public void test_hingeAngleUpdatedSecondTime_switchesToMatchingState() throws Exception {
+ createProvider(createConfig(/* identifier= */ 1, /* name= */ "ONE",
+ (c) -> c.getHingeAngle() < 90f),
+ createConfig(/* identifier= */ 2, /* name= */ "TWO",
+ (c) -> c.getHingeAngle() >= 90f));
+ sendSensorEvent(mHingeAngleSensor, /* value= */ 30f);
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(1, mIntegerCaptor.getValue().intValue());
+ clearInvocations(listener);
+
+ sendSensorEvent(mHingeAngleSensor, /* value= */ 100f);
+
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(2, mIntegerCaptor.getValue().intValue());
+ }
+
+ @Test
+ public void test_hallSensorUpdatedSecondTime_switchesToMatchingState() throws Exception {
+ createProvider(createConfig(/* identifier= */ 1, /* name= */ "ONE",
+ (c) -> !c.isHallSensorClosed()),
+ createConfig(/* identifier= */ 2, /* name= */ "TWO",
+ FoldableDeviceStateProvider::isHallSensorClosed));
+ sendSensorEvent(mHallSensor, /* value= */ 0f);
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(1, mIntegerCaptor.getValue().intValue());
+ clearInvocations(listener);
+
+ // Hall sensor value '1f' is for the closed state
+ sendSensorEvent(mHallSensor, /* value= */ 1f);
+
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(2, mIntegerCaptor.getValue().intValue());
+ }
+
+ @Test
+ public void test_invalidSensorValues_onStateChangedIsNotTriggered() throws Exception {
+ createProvider(createConfig(/* identifier= */ 1, /* name= */ "ONE",
+ (c) -> c.getHingeAngle() < 90f),
+ createConfig(/* identifier= */ 2, /* name= */ "TWO",
+ (c) -> c.getHingeAngle() >= 90f));
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+ clearInvocations(listener);
+
+ // First, switch to a non-default state.
+ sendSensorEvent(mHingeAngleSensor, /* value= */ 100f);
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(2, mIntegerCaptor.getValue().intValue());
+
+ clearInvocations(listener);
+
+ // Then, send an invalid sensor event, verify that onStateChanged() is not triggered.
+ sendInvalidSensorEvent(mHingeAngleSensor);
+
+ verify(listener, never()).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED));
+ verify(listener, never()).onStateChanged(mIntegerCaptor.capture());
+ }
+
+ @Test
+ public void test_nullSensorValues_noExceptionThrown() throws Exception {
+ createProvider(createConfig( /* identifier= */ 1, /* name= */ "ONE",
+ (c) -> c.getHingeAngle() < 90f));
+ sendInvalidSensorEvent(null);
+ }
+
+ @Test
+ public void test_flagDisableWhenThermalStatusCritical() throws Exception {
+ createProvider(createConfig(/* identifier= */ 1, /* name= */ "CLOSED",
+ (c) -> c.getHingeAngle() < 5f),
+ createConfig(/* identifier= */ 2, /* name= */ "HALF_OPENED",
+ (c) -> c.getHingeAngle() < 90f),
+ createConfig(/* identifier= */ 3, /* name= */ "OPENED",
+ (c) -> c.getHingeAngle() < 180f),
+ createConfig(/* identifier= */ 4, /* name= */ "THERMAL_TEST",
+ DeviceState.FLAG_EMULATED_ONLY
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE,
+ (c) -> true));
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+ verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED));
+ assertArrayEquals(
+ new DeviceState[]{
+ new DeviceState(1, "CLOSED", 0 /* flags */),
+ new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+ new DeviceState(3, "OPENED", 0 /* flags */),
+ new DeviceState(4, "THERMAL_TEST",
+ DeviceState.FLAG_EMULATED_ONLY
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE)},
+ mDeviceStateArrayCaptor.getValue());
+ clearInvocations(listener);
+
+ mProvider.onThermalStatusChanged(PowerManager.THERMAL_STATUS_MODERATE);
+ verify(listener, never()).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED));
+ clearInvocations(listener);
+
+ // The THERMAL_TEST state should be disabled.
+ mProvider.onThermalStatusChanged(PowerManager.THERMAL_STATUS_CRITICAL);
+ verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_CRITICAL));
+ assertArrayEquals(
+ new DeviceState[]{
+ new DeviceState(1, "CLOSED", 0 /* flags */),
+ new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+ new DeviceState(3, "OPENED", 0 /* flags */)},
+ mDeviceStateArrayCaptor.getValue());
+ clearInvocations(listener);
+
+ // The THERMAL_TEST state should be re-enabled.
+ mProvider.onThermalStatusChanged(PowerManager.THERMAL_STATUS_LIGHT);
+ verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_THERMAL_NORMAL));
+ assertArrayEquals(
+ new DeviceState[]{
+ new DeviceState(1, "CLOSED", 0 /* flags */),
+ new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+ new DeviceState(3, "OPENED", 0 /* flags */),
+ new DeviceState(4, "THERMAL_TEST",
+ DeviceState.FLAG_EMULATED_ONLY
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE)},
+ mDeviceStateArrayCaptor.getValue());
+ }
+
+ @Test
+ public void test_flagDisableWhenPowerSaveEnabled() throws Exception {
+ createProvider(createConfig(/* identifier= */ 1, /* name= */ "CLOSED",
+ (c) -> c.getHingeAngle() < 5f),
+ createConfig(/* identifier= */ 2, /* name= */ "HALF_OPENED",
+ (c) -> c.getHingeAngle() < 90f),
+ createConfig(/* identifier= */ 3, /* name= */ "OPENED",
+ (c) -> c.getHingeAngle() < 180f),
+ createConfig(/* identifier= */ 4, /* name= */ "THERMAL_TEST",
+ DeviceState.FLAG_EMULATED_ONLY
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE,
+ (c) -> true));
+ mProvider.onPowerSaveModeChanged(false /* isPowerSaveModeEnabled */);
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+
+ verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED));
+ assertArrayEquals(
+ new DeviceState[]{
+ new DeviceState(1, "CLOSED", 0 /* flags */),
+ new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+ new DeviceState(3, "OPENED", 0 /* flags */),
+ new DeviceState(4, "THERMAL_TEST",
+ DeviceState.FLAG_EMULATED_ONLY
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE) },
+ mDeviceStateArrayCaptor.getValue());
+ clearInvocations(listener);
+
+ mProvider.onPowerSaveModeChanged(false /* isPowerSaveModeEnabled */);
+ verify(listener, never()).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_INITIALIZED));
+ clearInvocations(listener);
+
+ // The THERMAL_TEST state should be disabled due to power save being enabled.
+ mProvider.onPowerSaveModeChanged(true /* isPowerSaveModeEnabled */);
+ verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_ENABLED));
+ assertArrayEquals(
+ new DeviceState[]{
+ new DeviceState(1, "CLOSED", 0 /* flags */),
+ new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+ new DeviceState(3, "OPENED", 0 /* flags */) },
+ mDeviceStateArrayCaptor.getValue());
+ clearInvocations(listener);
+
+ // The THERMAL_TEST state should be re-enabled.
+ mProvider.onPowerSaveModeChanged(false /* isPowerSaveModeEnabled */);
+ verify(listener).onSupportedDeviceStatesChanged(mDeviceStateArrayCaptor.capture(),
+ eq(SUPPORTED_DEVICE_STATES_CHANGED_POWER_SAVE_DISABLED));
+ assertArrayEquals(
+ new DeviceState[]{
+ new DeviceState(1, "CLOSED", 0 /* flags */),
+ new DeviceState(2, "HALF_OPENED", 0 /* flags */),
+ new DeviceState(3, "OPENED", 0 /* flags */),
+ new DeviceState(4, "THERMAL_TEST",
+ DeviceState.FLAG_EMULATED_ONLY
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_THERMAL_STATUS_CRITICAL
+ | DeviceState.FLAG_UNSUPPORTED_WHEN_POWER_SAVE_MODE) },
+ mDeviceStateArrayCaptor.getValue());
+ }
+
+ @Test
+ public void test_previousStateBasedPredicate() {
+ // Create a configuration where state TWO could be matched only if
+ // the previous state was 'THREE'
+ createProvider(
+ createConfig(
+ /* identifier= */ 1, /* name= */ "ONE", (c) -> c.getHingeAngle() < 30f),
+ createConfig(
+ /* identifier= */ 2, /* name= */ "TWO",
+ (c) -> c.getLastReportedDeviceState() == 3 && c.getHingeAngle() > 120f),
+ createConfig(
+ /* identifier= */ 3, /* name= */ "THREE",
+ (c) -> c.getHingeAngle() > 90f)
+ );
+ sendSensorEvent(mHingeAngleSensor, /* value= */ 0f);
+ Listener listener = mock(Listener.class);
+ mProvider.setListener(listener);
+
+ // Check that the initial state is 'ONE'
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(1, mIntegerCaptor.getValue().intValue());
+ clearInvocations(listener);
+
+ // Should not match state 'TWO', it should match only state 'THREE'
+ // (because the previous state is not 'THREE', it is 'ONE')
+ sendSensorEvent(mHingeAngleSensor, /* value= */ 180f);
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(3, mIntegerCaptor.getValue().intValue());
+ clearInvocations(listener);
+
+ // Now it should match state 'TWO'
+ // (because the previous state is 'THREE' now)
+ sendSensorEvent(mHingeAngleSensor, /* value= */ 180f);
+ verify(listener).onStateChanged(mIntegerCaptor.capture());
+ assertEquals(2, mIntegerCaptor.getValue().intValue());
+ }
+
+ @Test
+ public void isScreenOn_afterDisplayChangedToOn_returnsTrue() {
+ createProvider(
+ createConfig(
+ /* identifier= */ 1, /* name= */ "ONE", (c) -> true)
+ );
+
+ setScreenOn(true);
+
+ assertThat(mProvider.isScreenOn()).isTrue();
+ }
+
+ @Test
+ public void isScreenOn_afterDisplayChangedToOff_returnsFalse() {
+ createProvider(
+ createConfig(
+ /* identifier= */ 1, /* name= */ "ONE", (c) -> true)
+ );
+
+ setScreenOn(false);
+
+ assertThat(mProvider.isScreenOn()).isFalse();
+ }
+
+ @Test
+ public void isScreenOn_afterDisplayChangedToOnThenOff_returnsFalse() {
+ createProvider(
+ createConfig(
+ /* identifier= */ 1, /* name= */ "ONE", (c) -> true)
+ );
+
+ setScreenOn(true);
+ setScreenOn(false);
+
+ assertThat(mProvider.isScreenOn()).isFalse();
+ }
+
+ private void setScreenOn(boolean isOn) {
+ Display mockDisplay = mock(Display.class);
+ int state = isOn ? STATE_ON : STATE_OFF;
+ when(mockDisplay.getState()).thenReturn(state);
+ when(mDisplayManager.getDisplay(eq(DEFAULT_DISPLAY))).thenReturn(mockDisplay);
+ mDisplayListenerCaptor.getValue().onDisplayChanged(DEFAULT_DISPLAY);
+ }
+
+ private void sendSensorEvent(Sensor sensor, float value) {
+ SensorEvent event = mock(SensorEvent.class);
+ event.sensor = sensor;
+ try {
+ FieldSetter.setField(event, event.getClass().getField("values"),
+ new float[]{value});
+ } catch (NoSuchFieldException e) {
+ e.printStackTrace();
+ }
+
+ mProvider.onSensorChanged(event);
+ }
+
+ private void sendInvalidSensorEvent(Sensor sensor) {
+ SensorEvent event = mock(SensorEvent.class);
+ event.sensor = sensor;
+ try {
+ // Set empty values array to make the event invalid
+ FieldSetter.setField(event, event.getClass().getField("values"),
+ new float[]{});
+ } catch (NoSuchFieldException e) {
+ e.printStackTrace();
+ }
+ mProvider.onSensorChanged(event);
+ }
+
+ private void createProvider(DeviceStateConfiguration... configurations) {
+ mProvider = new FoldableDeviceStateProvider(mContext, mSensorManager, mHingeAngleSensor,
+ mHallSensor, mDisplayManager, configurations);
+ verify(mDisplayManager)
+ .registerDisplayListener(
+ mDisplayListenerCaptor.capture(),
+ nullable(Handler.class),
+ anyLong());
+ }
+}