diff --git a/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt new file mode 100644 index 0000000000000..ce6ab977dea26 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModel.kt @@ -0,0 +1,112 @@ +/* + * 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.systemui.multishade.ui.viewmodel + +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeConfig +import com.android.systemui.multishade.shared.model.ShadeId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** Models UI state for UI that supports multi (or single) shade. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MultiShadeViewModel( + viewModelScope: CoroutineScope, + private val interactor: MultiShadeInteractor, +) { + /** Models UI state for the single shade. */ + val singleShade = + ShadeViewModel( + viewModelScope, + ShadeId.SINGLE, + interactor, + ) + + /** Models UI state for the shade on the left-hand side. */ + val leftShade = + ShadeViewModel( + viewModelScope, + ShadeId.LEFT, + interactor, + ) + + /** Models UI state for the shade on the right-hand side. */ + val rightShade = + ShadeViewModel( + viewModelScope, + ShadeId.RIGHT, + interactor, + ) + + /** The amount of alpha that the scrim should have. This is a value between `0` and `1`. */ + val scrimAlpha: StateFlow = + combine( + interactor.maxShadeExpansion, + interactor.shadeConfig + .map { it as? ShadeConfig.DualShadeConfig } + .map { dualShadeConfigOrNull -> dualShadeConfigOrNull?.scrimAlpha ?: 0f }, + ::Pair, + ) + .map { (anyShadeExpansion, scrimAlpha) -> + (anyShadeExpansion * scrimAlpha).coerceIn(0f, 1f) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = 0f, + ) + + /** Whether the scrim should accept touch events. */ + val isScrimEnabled: StateFlow = + interactor.shadeConfig + .flatMapLatest { shadeConfig -> + when (shadeConfig) { + // In the dual shade configuration, the scrim is enabled when the expansion is + // greater than zero on any one of the shades. + is ShadeConfig.DualShadeConfig -> + interactor.maxShadeExpansion + .map { expansion -> expansion > 0 } + .distinctUntilChanged() + // No scrim in the single shade configuration. + is ShadeConfig.SingleShadeConfig -> flowOf(false) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + /** Notifies that the scrim has been touched. */ + fun onScrimTouched(proxiedInput: ProxiedInputModel) { + if (!isScrimEnabled.value) { + return + } + + interactor.sendProxiedInput(proxiedInput) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt new file mode 100644 index 0000000000000..e828dbdc6c62f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModel.kt @@ -0,0 +1,150 @@ +/* + * 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.systemui.multishade.ui.viewmodel + +import androidx.annotation.FloatRange +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeConfig +import com.android.systemui.multishade.shared.model.ShadeId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** Models UI state for a single shade. */ +class ShadeViewModel( + viewModelScope: CoroutineScope, + private val shadeId: ShadeId, + private val interactor: MultiShadeInteractor, +) { + /** Whether the shade is visible. */ + val isVisible: StateFlow = + interactor + .isVisible(shadeId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + /** Whether swiping on the shade UI is currently enabled. */ + val isSwipingEnabled: StateFlow = + interactor + .isNonProxiedInputAllowed(shadeId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + /** Whether the shade must be collapsed immediately. */ + val isForceCollapsed: Flow = + interactor.isForceCollapsed(shadeId).distinctUntilChanged() + + /** The width of the shade. */ + val width: StateFlow = + interactor.shadeConfig + .map { shadeWidth(it) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = shadeWidth(interactor.shadeConfig.value), + ) + + /** + * The amount that the user must swipe up when the shade is fully expanded to automatically + * collapse once the user lets go of the shade. If the user swipes less than this amount, the + * shade will automatically revert back to fully expanded once the user stops swiping. + */ + val swipeCollapseThreshold: StateFlow = + interactor.shadeConfig + .map { it.swipeCollapseThreshold } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = interactor.shadeConfig.value.swipeCollapseThreshold, + ) + + /** + * The amount that the user must swipe down when the shade is fully collapsed to automatically + * expand once the user lets go of the shade. If the user swipes less than this amount, the + * shade will automatically revert back to fully collapsed once the user stops swiping. + */ + val swipeExpandThreshold: StateFlow = + interactor.shadeConfig + .map { it.swipeExpandThreshold } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = interactor.shadeConfig.value.swipeExpandThreshold, + ) + + /** + * Proxied input affecting the shade. This is input coming from sources outside of system UI + * (for example, swiping down on the Launcher or from the status bar) or outside the UI of any + * shade (for example, the scrim that's shown behind the shades). + */ + val proxiedInput: Flow = + interactor + .proxiedInput(shadeId) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = null, + ) + + /** Notifies that the expansion amount for the shade has changed. */ + fun onExpansionChanged( + expansion: Float, + ) { + interactor.setExpansion(shadeId, expansion.coerceIn(0f, 1f)) + } + + /** Notifies that a drag gesture has started. */ + fun onDragStarted() { + interactor.onUserInteractionStarted(shadeId) + } + + /** Notifies that a drag gesture has ended. */ + fun onDragEnded() { + interactor.onUserInteractionEnded(shadeId = shadeId) + } + + private fun shadeWidth(shadeConfig: ShadeConfig): Size { + return when (shadeId) { + ShadeId.LEFT -> + Size.Pixels((shadeConfig as? ShadeConfig.DualShadeConfig)?.leftShadeWidthPx ?: 0) + ShadeId.RIGHT -> + Size.Pixels((shadeConfig as? ShadeConfig.DualShadeConfig)?.rightShadeWidthPx ?: 0) + ShadeId.SINGLE -> Size.Fraction(1f) + } + } + + sealed class Size { + data class Fraction( + @FloatRange(from = 0.0, to = 1.0) val fraction: Float, + ) : Size() + data class Pixels( + val pixels: Int, + ) : Size() + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt new file mode 100644 index 0000000000000..0484515e38bd1 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/MultiShadeViewModelTest.kt @@ -0,0 +1,127 @@ +/* + * 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.systemui.multishade.ui.viewmodel + +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractorTest +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class MultiShadeViewModelTest : SysuiTestCase() { + + private lateinit var testScope: TestScope + private lateinit var inputProxy: MultiShadeInputProxy + + @Before + fun setUp() { + testScope = TestScope() + inputProxy = MultiShadeInputProxy() + } + + @Test + fun scrim_whenDualShadeCollapsed() = + testScope.runTest { + val alpha = 0.5f + overrideResource(R.dimen.dual_shade_scrim_alpha, alpha) + overrideResource(R.bool.dual_shade_enabled, true) + + val underTest = create() + val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha) + val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled) + + assertThat(scrimAlpha).isZero() + assertThat(isScrimEnabled).isFalse() + } + + @Test + fun scrim_whenDualShadeExpanded() = + testScope.runTest { + val alpha = 0.5f + overrideResource(R.dimen.dual_shade_scrim_alpha, alpha) + overrideResource(R.bool.dual_shade_enabled, true) + val underTest = create() + val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha) + val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled) + assertThat(scrimAlpha).isZero() + assertThat(isScrimEnabled).isFalse() + + underTest.leftShade.onExpansionChanged(0.5f) + assertThat(scrimAlpha).isEqualTo(alpha * 0.5f) + assertThat(isScrimEnabled).isTrue() + + underTest.rightShade.onExpansionChanged(1f) + assertThat(scrimAlpha).isEqualTo(alpha * 1f) + assertThat(isScrimEnabled).isTrue() + } + + @Test + fun scrim_whenSingleShadeCollapsed() = + testScope.runTest { + val alpha = 0.5f + overrideResource(R.dimen.dual_shade_scrim_alpha, alpha) + overrideResource(R.bool.dual_shade_enabled, false) + + val underTest = create() + val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha) + val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled) + + assertThat(scrimAlpha).isZero() + assertThat(isScrimEnabled).isFalse() + } + + @Test + fun scrim_whenSingleShadeExpanded() = + testScope.runTest { + val alpha = 0.5f + overrideResource(R.dimen.dual_shade_scrim_alpha, alpha) + overrideResource(R.bool.dual_shade_enabled, false) + val underTest = create() + val scrimAlpha: Float? by collectLastValue(underTest.scrimAlpha) + val isScrimEnabled: Boolean? by collectLastValue(underTest.isScrimEnabled) + + underTest.singleShade.onExpansionChanged(0.95f) + + assertThat(scrimAlpha).isZero() + assertThat(isScrimEnabled).isFalse() + } + + private fun create(): MultiShadeViewModel { + return MultiShadeViewModel( + viewModelScope = testScope.backgroundScope, + interactor = + MultiShadeInteractorTest.create( + testScope = testScope, + context = context, + inputProxy = inputProxy, + ), + ) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt new file mode 100644 index 0000000000000..e32aac596e5bd --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/ui/viewmodel/ShadeViewModelTest.kt @@ -0,0 +1,226 @@ +/* + * 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.systemui.multishade.ui.viewmodel + +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractor +import com.android.systemui.multishade.domain.interactor.MultiShadeInteractorTest +import com.android.systemui.multishade.shared.model.ProxiedInputModel +import com.android.systemui.multishade.shared.model.ShadeId +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class ShadeViewModelTest : SysuiTestCase() { + + private lateinit var testScope: TestScope + private lateinit var inputProxy: MultiShadeInputProxy + private var interactor: MultiShadeInteractor? = null + + @Before + fun setUp() { + testScope = TestScope() + inputProxy = MultiShadeInputProxy() + } + + @Test + fun isVisible_dualShadeConfig() = + testScope.runTest { + overrideResource(R.bool.dual_shade_enabled, true) + val isLeftShadeVisible: Boolean? by collectLastValue(create(ShadeId.LEFT).isVisible) + val isRightShadeVisible: Boolean? by collectLastValue(create(ShadeId.RIGHT).isVisible) + val isSingleShadeVisible: Boolean? by collectLastValue(create(ShadeId.SINGLE).isVisible) + + assertThat(isLeftShadeVisible).isTrue() + assertThat(isRightShadeVisible).isTrue() + assertThat(isSingleShadeVisible).isFalse() + } + + @Test + fun isVisible_singleShadeConfig() = + testScope.runTest { + overrideResource(R.bool.dual_shade_enabled, false) + val isLeftShadeVisible: Boolean? by collectLastValue(create(ShadeId.LEFT).isVisible) + val isRightShadeVisible: Boolean? by collectLastValue(create(ShadeId.RIGHT).isVisible) + val isSingleShadeVisible: Boolean? by collectLastValue(create(ShadeId.SINGLE).isVisible) + + assertThat(isLeftShadeVisible).isFalse() + assertThat(isRightShadeVisible).isFalse() + assertThat(isSingleShadeVisible).isTrue() + } + + @Test + fun isSwipingEnabled() = + testScope.runTest { + val underTest = create(ShadeId.LEFT) + val isSwipingEnabled: Boolean? by collectLastValue(underTest.isSwipingEnabled) + assertWithMessage("isSwipingEnabled should start as true!") + .that(isSwipingEnabled) + .isTrue() + + // Need to collect proxied input so the flows become hot as the gesture cancelation code + // logic sits in side the proxiedInput flow for each shade. + collectLastValue(underTest.proxiedInput) + collectLastValue(create(ShadeId.RIGHT).proxiedInput) + + // Starting a proxied interaction on the LEFT shade disallows non-proxied interaction on + // the + // same shade. + inputProxy.onProxiedInput( + ProxiedInputModel.OnDrag(xFraction = 0f, yDragAmountPx = 123f) + ) + assertThat(isSwipingEnabled).isFalse() + + // Registering the end of the proxied interaction re-allows it. + inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd) + assertThat(isSwipingEnabled).isTrue() + + // Starting a proxied interaction on the RIGHT shade force-collapses the LEFT shade, + // disallowing non-proxied input on the LEFT shade. + inputProxy.onProxiedInput( + ProxiedInputModel.OnDrag(xFraction = 1f, yDragAmountPx = 123f) + ) + assertThat(isSwipingEnabled).isFalse() + + // Registering the end of the interaction on the RIGHT shade re-allows it. + inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd) + assertThat(isSwipingEnabled).isTrue() + } + + @Test + fun isForceCollapsed_whenOtherShadeInteractionUnderway() = + testScope.runTest { + val leftShade = create(ShadeId.LEFT) + val rightShade = create(ShadeId.RIGHT) + val isLeftShadeForceCollapsed: Boolean? by collectLastValue(leftShade.isForceCollapsed) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(rightShade.isForceCollapsed) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(create(ShadeId.SINGLE).isForceCollapsed) + + assertWithMessage("isForceCollapsed should start as false!") + .that(isLeftShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isRightShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isSingleShadeForceCollapsed) + .isFalse() + + // Registering the start of an interaction on the RIGHT shade force-collapses the LEFT + // shade. + rightShade.onDragStarted() + assertThat(isLeftShadeForceCollapsed).isTrue() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the end of the interaction on the RIGHT shade re-allows it. + rightShade.onDragEnded() + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the start of an interaction on the LEFT shade force-collapses the RIGHT + // shade. + leftShade.onDragStarted() + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the end of the interaction on the LEFT shade re-allows it. + leftShade.onDragEnded() + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + } + + @Test + fun onTapOutside_collapsesAll() = + testScope.runTest { + val isLeftShadeForceCollapsed: Boolean? by + collectLastValue(create(ShadeId.LEFT).isForceCollapsed) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(create(ShadeId.RIGHT).isForceCollapsed) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(create(ShadeId.SINGLE).isForceCollapsed) + + assertWithMessage("isForceCollapsed should start as false!") + .that(isLeftShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isRightShadeForceCollapsed) + .isFalse() + assertWithMessage("isForceCollapsed should start as false!") + .that(isSingleShadeForceCollapsed) + .isFalse() + + inputProxy.onProxiedInput(ProxiedInputModel.OnTap) + assertThat(isLeftShadeForceCollapsed).isTrue() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isTrue() + } + + @Test + fun proxiedInput_ignoredWhileNonProxiedGestureUnderway() = + testScope.runTest { + val underTest = create(ShadeId.RIGHT) + val proxiedInput: ProxiedInputModel? by collectLastValue(underTest.proxiedInput) + underTest.onDragStarted() + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f)) + assertThat(proxiedInput).isNull() + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.8f, 110f)) + assertThat(proxiedInput).isNull() + + underTest.onDragEnded() + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f)) + assertThat(proxiedInput).isNotNull() + } + + private fun create( + shadeId: ShadeId, + ): ShadeViewModel { + return ShadeViewModel( + viewModelScope = testScope.backgroundScope, + shadeId = shadeId, + interactor = interactor + ?: MultiShadeInteractorTest.create( + testScope = testScope, + context = context, + inputProxy = inputProxy, + ) + .also { interactor = it }, + ) + } +}