diff options
3 files changed, 515 insertions, 98 deletions
diff --git a/libs/WindowManager/Jetpack/src/androidx/window/common/DeviceStateManagerFoldingFeatureProducer.java b/libs/WindowManager/Jetpack/src/androidx/window/common/DeviceStateManagerFoldingFeatureProducer.java index 37f0067de453..089613853555 100644 --- a/libs/WindowManager/Jetpack/src/androidx/window/common/DeviceStateManagerFoldingFeatureProducer.java +++ b/libs/WindowManager/Jetpack/src/androidx/window/common/DeviceStateManagerFoldingFeatureProducer.java @@ -16,12 +16,11 @@ package androidx.window.common; -import static android.hardware.devicestate.DeviceStateManager.INVALID_DEVICE_STATE_IDENTIFIER; +import static android.hardware.devicestate.DeviceStateManager.INVALID_DEVICE_STATE; import static androidx.window.common.layout.CommonFoldingFeature.COMMON_STATE_UNKNOWN; import static androidx.window.common.layout.CommonFoldingFeature.parseListFromString; -import android.annotation.NonNull; import android.content.Context; import android.hardware.devicestate.DeviceState; import android.hardware.devicestate.DeviceStateManager; @@ -31,16 +30,23 @@ import android.text.TextUtils; import android.util.Log; import android.util.SparseIntArray; +import androidx.annotation.BinderThread; +import androidx.annotation.GuardedBy; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import androidx.window.common.layout.CommonFoldingFeature; import androidx.window.common.layout.DisplayFoldFeatureCommon; import com.android.internal.R; +import com.android.window.flags.Flags; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.Executor; import java.util.function.Consumer; /** @@ -55,13 +61,6 @@ public final class DeviceStateManagerFoldingFeatureProducer private static final boolean DEBUG = false; /** - * Emulated device state - * {@link DeviceStateManager.DeviceStateCallback#onDeviceStateChanged(DeviceState)} to - * {@link CommonFoldingFeature.State} map. - */ - private final SparseIntArray mDeviceStateToPostureMap = new SparseIntArray(); - - /** * Device state received via * {@link DeviceStateManager.DeviceStateCallback#onDeviceStateChanged(DeviceState)}. * The identifier returned through {@link DeviceState#getIdentifier()} may not correspond 1:1 @@ -71,23 +70,40 @@ public final class DeviceStateManagerFoldingFeatureProducer * "rear display". Concurrent mode for example is activated via public API and can be active in * both the "open" and "half folded" device states. */ - private DeviceState mCurrentDeviceState = new DeviceState( - new DeviceState.Configuration.Builder(INVALID_DEVICE_STATE_IDENTIFIER, - "INVALID").build()); + // TODO: b/337820752 - Add @GuardedBy("mCurrentDeviceStateLock") after flag cleanup. + private DeviceState mCurrentDeviceState = INVALID_DEVICE_STATE; - private List<DeviceState> mSupportedStates; + /** + * Lock to synchronize access to {@link #mCurrentDeviceState}. + * + * <p>This lock is used to ensure thread-safety when accessing and modifying the + * {@link #mCurrentDeviceState} field. It is acquired by both the binder thread (if + * {@link Flags#wlinfoOncreate()} is enabled) and the main thread (if + * {@link Flags#wlinfoOncreate()} is disabled) to prevent race conditions and + * ensure data consistency. + */ + private final Object mCurrentDeviceStateLock = new Object(); @NonNull private final RawFoldingFeatureProducer mRawFoldSupplier; - private final boolean mIsHalfOpenedSupported; - - private final DeviceStateCallback mDeviceStateCallback = new DeviceStateCallback() { + @NonNull + private final DeviceStateMapper mDeviceStateMapper; + + @VisibleForTesting + final DeviceStateCallback mDeviceStateCallback = new DeviceStateCallback() { + // The GuardedBy analysis is intra-procedural, meaning it doesn’t consider the getData() + // implementation. See https://errorprone.info/bugpattern/GuardedBy for limitations. + @SuppressWarnings("GuardedBy") + @BinderThread // When Flags.wlinfoOncreate() is enabled. + @MainThread // When Flags.wlinfoOncreate() is disabled. @Override public void onDeviceStateChanged(@NonNull DeviceState state) { - mCurrentDeviceState = state; - mRawFoldSupplier.getData(DeviceStateManagerFoldingFeatureProducer - .this::notifyFoldingFeatureChange); + synchronized (mCurrentDeviceStateLock) { + mCurrentDeviceState = state; + mRawFoldSupplier.getData(DeviceStateManagerFoldingFeatureProducer.this + ::notifyFoldingFeatureChangeLocked); + } } }; @@ -95,41 +111,14 @@ public final class DeviceStateManagerFoldingFeatureProducer @NonNull RawFoldingFeatureProducer rawFoldSupplier, @NonNull DeviceStateManager deviceStateManager) { mRawFoldSupplier = rawFoldSupplier; - String[] deviceStatePosturePairs = context.getResources() - .getStringArray(R.array.config_device_state_postures); - mSupportedStates = deviceStateManager.getSupportedDeviceStates(); - boolean isHalfOpenedSupported = false; - for (String deviceStatePosturePair : deviceStatePosturePairs) { - String[] deviceStatePostureMapping = deviceStatePosturePair.split(":"); - if (deviceStatePostureMapping.length != 2) { - if (DEBUG) { - Log.e(TAG, "Malformed device state posture pair: " - + deviceStatePosturePair); - } - continue; - } + mDeviceStateMapper = + new DeviceStateMapper(context, deviceStateManager.getSupportedDeviceStates()); - int deviceState; - int posture; - try { - deviceState = Integer.parseInt(deviceStatePostureMapping[0]); - posture = Integer.parseInt(deviceStatePostureMapping[1]); - } catch (NumberFormatException e) { - if (DEBUG) { - Log.e(TAG, "Failed to parse device state or posture: " - + deviceStatePosturePair, - e); - } - continue; - } - isHalfOpenedSupported = isHalfOpenedSupported - || posture == CommonFoldingFeature.COMMON_STATE_HALF_OPENED; - mDeviceStateToPostureMap.put(deviceState, posture); - } - mIsHalfOpenedSupported = isHalfOpenedSupported; - if (mDeviceStateToPostureMap.size() > 0) { + if (!mDeviceStateMapper.isDeviceStateToPostureMapEmpty()) { + final Executor executor = + Flags.wlinfoOncreate() ? Runnable::run : context.getMainExecutor(); Objects.requireNonNull(deviceStateManager) - .registerCallback(context.getMainExecutor(), mDeviceStateCallback); + .registerCallback(executor, mDeviceStateCallback); } } @@ -137,50 +126,51 @@ public final class DeviceStateManagerFoldingFeatureProducer * Add a callback to mCallbacks if there is no device state. This callback will be run * once a device state is set. Otherwise,run the callback immediately. */ - private void runCallbackWhenValidState(@NonNull Consumer<List<CommonFoldingFeature>> callback, - String displayFeaturesString) { - if (isCurrentStateValid()) { - callback.accept(calculateFoldingFeature(displayFeaturesString)); + private void runCallbackWhenValidState(@NonNull DeviceState state, + @NonNull Consumer<List<CommonFoldingFeature>> callback, + @NonNull String displayFeaturesString) { + if (mDeviceStateMapper.isDeviceStateValid(state)) { + callback.accept(calculateFoldingFeature(state, displayFeaturesString)); } else { // This callback will be added to mCallbacks and removed once it runs once. - AcceptOnceConsumer<List<CommonFoldingFeature>> singleRunCallback = + final AcceptOnceConsumer<List<CommonFoldingFeature>> singleRunCallback = new AcceptOnceConsumer<>(this, callback); addDataChangedCallback(singleRunCallback); } } - /** - * Checks to find {@link DeviceStateManagerFoldingFeatureProducer#mCurrentDeviceState} in the - * {@link DeviceStateManagerFoldingFeatureProducer#mDeviceStateToPostureMap} which was - * initialized in the constructor of {@link DeviceStateManagerFoldingFeatureProducer}. - * Returns a boolean value of whether the device state is valid. - */ - private boolean isCurrentStateValid() { - // If the device state is not found in the map, indexOfKey returns a negative number. - return mDeviceStateToPostureMap.indexOfKey(mCurrentDeviceState.getIdentifier()) >= 0; - } - + // The GuardedBy analysis is intra-procedural, meaning it doesn’t consider the implementation of + // addDataChangedCallback(). See https://errorprone.info/bugpattern/GuardedBy for limitations. + @SuppressWarnings("GuardedBy") @Override protected void onListenersChanged() { super.onListenersChanged(); - if (hasListeners()) { - mRawFoldSupplier.addDataChangedCallback(this::notifyFoldingFeatureChange); - } else { - mCurrentDeviceState = new DeviceState( - new DeviceState.Configuration.Builder(INVALID_DEVICE_STATE_IDENTIFIER, - "INVALID").build()); - mRawFoldSupplier.removeDataChangedCallback(this::notifyFoldingFeatureChange); + synchronized (mCurrentDeviceStateLock) { + if (hasListeners()) { + mRawFoldSupplier.addDataChangedCallback(this::notifyFoldingFeatureChangeLocked); + } else { + mCurrentDeviceState = INVALID_DEVICE_STATE; + mRawFoldSupplier.removeDataChangedCallback(this::notifyFoldingFeatureChangeLocked); + } + } + } + + @NonNull + private DeviceState getCurrentDeviceState() { + synchronized (mCurrentDeviceStateLock) { + return mCurrentDeviceState; } } @NonNull @Override public Optional<List<CommonFoldingFeature>> getCurrentData() { - Optional<String> displayFeaturesString = mRawFoldSupplier.getCurrentData(); - if (!isCurrentStateValid()) { + final Optional<String> displayFeaturesString = mRawFoldSupplier.getCurrentData(); + final DeviceState state = getCurrentDeviceState(); + if (!mDeviceStateMapper.isDeviceStateValid(state) || displayFeaturesString.isEmpty()) { return Optional.empty(); } else { - return displayFeaturesString.map(this::calculateFoldingFeature); + return Optional.of(calculateFoldingFeature(state, displayFeaturesString.get())); } } @@ -191,7 +181,7 @@ public final class DeviceStateManagerFoldingFeatureProducer */ @NonNull public List<CommonFoldingFeature> getFoldsWithUnknownState() { - Optional<String> optionalFoldingFeatureString = mRawFoldSupplier.getCurrentData(); + final Optional<String> optionalFoldingFeatureString = mRawFoldSupplier.getCurrentData(); if (optionalFoldingFeatureString.isPresent()) { return CommonFoldingFeature.parseListFromString( @@ -201,7 +191,6 @@ public final class DeviceStateManagerFoldingFeatureProducer return Collections.emptyList(); } - /** * Returns the list of supported {@link DisplayFoldFeatureCommon} calculated from the * {@link DeviceStateManagerFoldingFeatureProducer}. @@ -218,16 +207,16 @@ public final class DeviceStateManagerFoldingFeatureProducer return foldFeatures; } - /** * Returns {@code true} if the device supports half-opened mode, {@code false} otherwise. */ public boolean isHalfOpenedSupported() { - return mIsHalfOpenedSupported; + return mDeviceStateMapper.mIsHalfOpenedSupported; } /** * Adds the data to the storeFeaturesConsumer when the data is ready. + * * @param storeFeaturesConsumer a consumer to collect the data when it is first available. */ @Override @@ -236,38 +225,123 @@ public final class DeviceStateManagerFoldingFeatureProducer if (TextUtils.isEmpty(displayFeaturesString)) { storeFeaturesConsumer.accept(new ArrayList<>()); } else { - runCallbackWhenValidState(storeFeaturesConsumer, displayFeaturesString); + final DeviceState state = getCurrentDeviceState(); + runCallbackWhenValidState(state, storeFeaturesConsumer, displayFeaturesString); } }); } - private void notifyFoldingFeatureChange(String displayFeaturesString) { - if (!isCurrentStateValid()) { + @GuardedBy("mCurrentDeviceStateLock") + private void notifyFoldingFeatureChangeLocked(String displayFeaturesString) { + final DeviceState state = mCurrentDeviceState; + if (!mDeviceStateMapper.isDeviceStateValid(state)) { return; } if (TextUtils.isEmpty(displayFeaturesString)) { notifyDataChanged(new ArrayList<>()); } else { - notifyDataChanged(calculateFoldingFeature(displayFeaturesString)); + notifyDataChanged(calculateFoldingFeature(state, displayFeaturesString)); } } - private List<CommonFoldingFeature> calculateFoldingFeature(String displayFeaturesString) { - return parseListFromString(displayFeaturesString, currentHingeState()); + @NonNull + private List<CommonFoldingFeature> calculateFoldingFeature(@NonNull DeviceState deviceState, + @NonNull String displayFeaturesString) { + @CommonFoldingFeature.State + final int hingeState = mDeviceStateMapper.getHingeState(deviceState); + return parseListFromString(displayFeaturesString, hingeState); } - @CommonFoldingFeature.State - private int currentHingeState() { - @CommonFoldingFeature.State - int posture = mDeviceStateToPostureMap.get(mCurrentDeviceState.getIdentifier(), - COMMON_STATE_UNKNOWN); + /** + * Internal class to map device states to corresponding postures. + * + * <p>This class encapsulates the logic for mapping device states to postures. The mapping is + * immutable after initialization to ensure thread safety. + */ + private static class DeviceStateMapper { + /** + * Emulated device state + * {@link DeviceStateManager.DeviceStateCallback#onDeviceStateChanged(DeviceState)} to + * {@link CommonFoldingFeature.State} map. + * + * <p>This map must be immutable after initialization to ensure thread safety, as it may be + * accessed from multiple threads. Modifications should only occur during object + * construction. + */ + private final SparseIntArray mDeviceStateToPostureMap = new SparseIntArray(); + + /** + * The list of device states that are supported. + * + * <p>This list must be immutable after initialization to ensure thread safety. + */ + @NonNull + private final List<DeviceState> mSupportedStates; + + final boolean mIsHalfOpenedSupported; + + DeviceStateMapper(@NonNull Context context, @NonNull List<DeviceState> supportedStates) { + mSupportedStates = supportedStates; + + final String[] deviceStatePosturePairs = context.getResources() + .getStringArray(R.array.config_device_state_postures); + boolean isHalfOpenedSupported = false; + for (String deviceStatePosturePair : deviceStatePosturePairs) { + final String[] deviceStatePostureMapping = deviceStatePosturePair.split(":"); + if (deviceStatePostureMapping.length != 2) { + if (DEBUG) { + Log.e(TAG, "Malformed device state posture pair: " + + deviceStatePosturePair); + } + continue; + } - if (posture == CommonFoldingFeature.COMMON_STATE_USE_BASE_STATE) { - posture = mDeviceStateToPostureMap.get( - DeviceStateUtil.calculateBaseStateIdentifier(mCurrentDeviceState, - mSupportedStates), COMMON_STATE_UNKNOWN); + final int deviceState; + final int posture; + try { + deviceState = Integer.parseInt(deviceStatePostureMapping[0]); + posture = Integer.parseInt(deviceStatePostureMapping[1]); + } catch (NumberFormatException e) { + if (DEBUG) { + Log.e(TAG, "Failed to parse device state or posture: " + + deviceStatePosturePair, + e); + } + continue; + } + isHalfOpenedSupported = isHalfOpenedSupported + || posture == CommonFoldingFeature.COMMON_STATE_HALF_OPENED; + mDeviceStateToPostureMap.put(deviceState, posture); + } + mIsHalfOpenedSupported = isHalfOpenedSupported; + } + + boolean isDeviceStateToPostureMapEmpty() { + return mDeviceStateToPostureMap.size() == 0; + } + + /** + * Validates if the provided deviceState exists in the {@link #mDeviceStateToPostureMap} + * which was initialized in the constructor of {@link DeviceStateMapper}. + * Returns a boolean value of whether the device state is valid. + */ + boolean isDeviceStateValid(@NonNull DeviceState deviceState) { + // If the device state is not found in the map, indexOfKey returns a negative number. + return mDeviceStateToPostureMap.indexOfKey(deviceState.getIdentifier()) >= 0; } - return posture; + @CommonFoldingFeature.State + int getHingeState(@NonNull DeviceState deviceState) { + @CommonFoldingFeature.State + final int posture = + mDeviceStateToPostureMap.get(deviceState.getIdentifier(), COMMON_STATE_UNKNOWN); + if (posture != CommonFoldingFeature.COMMON_STATE_USE_BASE_STATE) { + return posture; + } + + final int baseStateIdentifier = + DeviceStateUtil.calculateBaseStateIdentifier(deviceState, mSupportedStates); + return mDeviceStateToPostureMap.get(baseStateIdentifier, COMMON_STATE_UNKNOWN); + } } } diff --git a/libs/WindowManager/Jetpack/tests/unittest/Android.bp b/libs/WindowManager/Jetpack/tests/unittest/Android.bp index bd430c0e610b..09185ee203b8 100644 --- a/libs/WindowManager/Jetpack/tests/unittest/Android.bp +++ b/libs/WindowManager/Jetpack/tests/unittest/Android.bp @@ -29,6 +29,7 @@ android_test { srcs: [ "**/*.java", + "**/*.kt", ], static_libs: [ @@ -41,6 +42,7 @@ android_test { "androidx.test.ext.junit", "flag-junit", "mockito-target-extended-minus-junit4", + "mockito-kotlin-nodeps", "truth", "testables", "platform-test-annotations", diff --git a/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/common/DeviceStateManagerFoldingFeatureProducerTest.kt b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/common/DeviceStateManagerFoldingFeatureProducerTest.kt new file mode 100644 index 000000000000..90887a747a6f --- /dev/null +++ b/libs/WindowManager/Jetpack/tests/unittest/src/androidx/window/common/DeviceStateManagerFoldingFeatureProducerTest.kt @@ -0,0 +1,341 @@ +/* + * Copyright (C) 2024 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 androidx.window.common + +import android.content.Context +import android.content.res.Resources +import android.hardware.devicestate.DeviceState +import android.hardware.devicestate.DeviceStateManager +import android.platform.test.annotations.DisableFlags +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.window.common.layout.CommonFoldingFeature +import androidx.window.common.layout.CommonFoldingFeature.COMMON_STATE_FLAT +import androidx.window.common.layout.CommonFoldingFeature.COMMON_STATE_HALF_OPENED +import androidx.window.common.layout.CommonFoldingFeature.COMMON_STATE_NO_FOLDING_FEATURES +import androidx.window.common.layout.CommonFoldingFeature.COMMON_STATE_UNKNOWN +import androidx.window.common.layout.CommonFoldingFeature.COMMON_STATE_USE_BASE_STATE +import androidx.window.common.layout.DisplayFoldFeatureCommon +import androidx.window.common.layout.DisplayFoldFeatureCommon.DISPLAY_FOLD_FEATURE_PROPERTY_SUPPORTS_HALF_OPENED +import androidx.window.common.layout.DisplayFoldFeatureCommon.DISPLAY_FOLD_FEATURE_TYPE_SCREEN_FOLD_IN +import com.android.internal.R +import com.android.window.flags.Flags +import com.google.common.truth.Truth.assertThat +import java.util.Optional +import java.util.concurrent.Executor +import java.util.function.Consumer +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify + +/** + * Test class for [DeviceStateManagerFoldingFeatureProducer]. + * + * Build/Install/Run: + * atest WMJetpackUnitTests:DeviceStateManagerFoldingFeatureProducerTest + */ +@RunWith(AndroidJUnit4::class) +class DeviceStateManagerFoldingFeatureProducerTest { + @get:Rule + val setFlagsRule: SetFlagsRule = SetFlagsRule() + + private val mMockDeviceStateManager = mock<DeviceStateManager>() + private val mMockResources = mock<Resources> { + on { getStringArray(R.array.config_device_state_postures) } doReturn DEVICE_STATE_POSTURES + } + private val mMockContext = mock<Context> { + on { resources } doReturn mMockResources + } + private val mRawFoldSupplier = mock<RawFoldingFeatureProducer> { + on { currentData } doReturn Optional.of(DISPLAY_FEATURES) + on { getData(any<Consumer<String>>()) } doAnswer { invocation -> + val callback = invocation.getArgument(0) as Consumer<String> + callback.accept(DISPLAY_FEATURES) + } + } + + @Test + @DisableFlags(Flags.FLAG_WLINFO_ONCREATE) + fun testRegisterCallback_whenWlinfoOncreateIsDisabled_usesMainExecutor() { + DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + verify(mMockDeviceStateManager).registerCallback(eq(mMockContext.mainExecutor), any()) + } + + @Test + @EnableFlags(Flags.FLAG_WLINFO_ONCREATE) + fun testRegisterCallback_whenWlinfoOncreateIsEnabled_usesRunnableRun() { + val executorCaptor = ArgumentCaptor.forClass(Executor::class.java) + val runnable = mock<Runnable>() + + DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + verify(mMockDeviceStateManager).registerCallback(executorCaptor.capture(), any()) + executorCaptor.value.execute(runnable) + verify(runnable).run() + } + + @Test + fun testGetCurrentData_validCurrentState_returnsFoldingFeatureWithState() { + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + ffp.mDeviceStateCallback.onDeviceStateChanged(DEVICE_STATE_HALF_OPENED) + + val currentData = ffp.getCurrentData() + + assertThat(currentData).isPresent() + assertThat(currentData.get()).containsExactlyElementsIn(HALF_OPENED_FOLDING_FEATURES) + } + + @Test + fun testGetCurrentData_invalidCurrentState_returnsEmptyOptionalFoldingFeature() { + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + val currentData = ffp.getCurrentData() + + assertThat(currentData).isEmpty() + } + + @Test + fun testGetFoldsWithUnknownState_validFoldingFeature_returnsFoldingFeaturesWithUnknownState() { + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + val result = ffp.getFoldsWithUnknownState() + + assertThat(result).containsExactlyElementsIn(UNKNOWN_STATE_FOLDING_FEATURES) + } + + @Test + fun testGetFoldsWithUnknownState_emptyFoldingFeature_returnsEmptyList() { + mRawFoldSupplier.stub { + on { currentData } doReturn Optional.empty() + } + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + val result = ffp.getFoldsWithUnknownState() + + assertThat(result).isEmpty() + } + + @Test + fun testGetDisplayFeatures_validFoldingFeature_returnsDisplayFoldFeatures() { + mRawFoldSupplier.stub { + on { currentData } doReturn Optional.of(DISPLAY_FEATURES_HALF_OPENED_HINGE) + } + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + val result = ffp.displayFeatures + + assertThat(result).containsExactly( + DisplayFoldFeatureCommon( + DISPLAY_FOLD_FEATURE_TYPE_SCREEN_FOLD_IN, + setOf(DISPLAY_FOLD_FEATURE_PROPERTY_SUPPORTS_HALF_OPENED), + ), + ) + } + + @Test + fun testIsHalfOpenedSupported_withHalfOpenedPostures_returnsTrue() { + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + assertThat(ffp.isHalfOpenedSupported).isTrue() + } + + @Test + fun testIsHalfOpenedSupported_withEmptyPostures_returnsFalse() { + mMockResources.stub { + on { getStringArray(R.array.config_device_state_postures) } doReturn emptyArray() + } + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + assertThat(ffp.isHalfOpenedSupported).isFalse() + } + + @Test + fun testGetData_emptyDisplayFeaturesString_callsConsumerWithEmptyList() { + mRawFoldSupplier.stub { + on { getData(any<Consumer<String>>()) } doAnswer { invocation -> + val callback = invocation.getArgument(0) as Consumer<String> + callback.accept("") + } + } + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + val storeFeaturesConsumer = mock<Consumer<List<CommonFoldingFeature>>>() + + ffp.getData(storeFeaturesConsumer) + + verify(storeFeaturesConsumer).accept(emptyList()) + } + + @Test + fun testGetData_validState_callsConsumerWithFoldingFeatures() { + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + ffp.mDeviceStateCallback.onDeviceStateChanged(DEVICE_STATE_HALF_OPENED) + val storeFeaturesConsumer = mock<Consumer<List<CommonFoldingFeature>>>() + + ffp.getData(storeFeaturesConsumer) + + verify(storeFeaturesConsumer).accept(HALF_OPENED_FOLDING_FEATURES) + } + + @Test + fun testGetData_invalidState_addsAcceptOnceConsumerToDataChangedCallback() { + val ffp = DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + val storeFeaturesConsumer = mock<Consumer<List<CommonFoldingFeature>>>() + + ffp.getData(storeFeaturesConsumer) + + verify(storeFeaturesConsumer, never()).accept(any()) + ffp.mDeviceStateCallback.onDeviceStateChanged(DEVICE_STATE_HALF_OPENED) + ffp.mDeviceStateCallback.onDeviceStateChanged(DEVICE_STATE_OPENED) + verify(storeFeaturesConsumer).accept(HALF_OPENED_FOLDING_FEATURES) + } + + @Test + fun testDeviceStateMapper_malformedDeviceStatePosturePair_skipsPair() { + val malformedDeviceStatePostures = arrayOf( + // Missing the posture. + "0", + // Empty string. + "", + // Too many elements. + "0:1:2", + ) + mMockResources.stub { + on { getStringArray(R.array.config_device_state_postures) } doReturn + malformedDeviceStatePostures + } + + DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + verify(mMockDeviceStateManager, never()).registerCallback(any(), any()) + } + + @Test + fun testDeviceStateMapper_invalidNumberFormat_skipsPair() { + val invalidNumberFormatDeviceStatePostures = arrayOf("a:1", "0:b", "a:b", ":1") + mMockResources.stub { + on { getStringArray(R.array.config_device_state_postures) } doReturn + invalidNumberFormatDeviceStatePostures + } + + DeviceStateManagerFoldingFeatureProducer( + mMockContext, + mRawFoldSupplier, + mMockDeviceStateManager, + ) + + verify(mMockDeviceStateManager, never()).registerCallback(any(), any()) + } + + companion object { + // Supported device states configuration. + private enum class SupportedDeviceStates { + CLOSED, HALF_OPENED, OPENED, REAR_DISPLAY, CONCURRENT; + + override fun toString() = ordinal.toString() + + fun toDeviceState(): DeviceState = + DeviceState(DeviceState.Configuration.Builder(ordinal, name).build()) + } + + // Map of supported device states supplied by DeviceStateManager to WM Jetpack posture. + private val DEVICE_STATE_POSTURES = + arrayOf( + "${SupportedDeviceStates.CLOSED}:$COMMON_STATE_NO_FOLDING_FEATURES", + "${SupportedDeviceStates.HALF_OPENED}:$COMMON_STATE_HALF_OPENED", + "${SupportedDeviceStates.OPENED}:$COMMON_STATE_FLAT", + "${SupportedDeviceStates.REAR_DISPLAY}:$COMMON_STATE_NO_FOLDING_FEATURES", + "${SupportedDeviceStates.CONCURRENT}:$COMMON_STATE_USE_BASE_STATE", + ) + private val DEVICE_STATE_HALF_OPENED = SupportedDeviceStates.HALF_OPENED.toDeviceState() + private val DEVICE_STATE_OPENED = SupportedDeviceStates.OPENED.toDeviceState() + + // WindowsManager Jetpack display features. + private val DISPLAY_FEATURES = "fold-[1104,0,1104,1848]" + private val DISPLAY_FEATURES_HALF_OPENED_HINGE = "$DISPLAY_FEATURES-half-opened" + private val HALF_OPENED_FOLDING_FEATURES = CommonFoldingFeature.parseListFromString( + DISPLAY_FEATURES, + COMMON_STATE_HALF_OPENED, + ) + private val UNKNOWN_STATE_FOLDING_FEATURES = CommonFoldingFeature.parseListFromString( + DISPLAY_FEATURES, + COMMON_STATE_UNKNOWN, + ) + } +} |