diff --git a/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt new file mode 100644 index 0000000000000..b9f6d83d8406f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractor.kt @@ -0,0 +1,322 @@ +/* + * 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.domain.interactor + +import androidx.annotation.FloatRange +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.multishade.data.model.MultiShadeInteractionModel +import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy +import com.android.systemui.multishade.data.repository.MultiShadeRepository +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 com.android.systemui.multishade.shared.model.ShadeModel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow +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.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.yield + +/** Encapsulates business logic related to interactions with the multi-shade system. */ +@OptIn(ExperimentalCoroutinesApi::class) +@SysUISingleton +class MultiShadeInteractor +@Inject +constructor( + @Application private val applicationScope: CoroutineScope, + private val repository: MultiShadeRepository, + private val inputProxy: MultiShadeInputProxy, +) { + /** The current configuration of the shade system. */ + val shadeConfig: StateFlow = repository.shadeConfig + + /** The expansion of the shade that's most expanded. */ + val maxShadeExpansion: Flow = + repository.shadeConfig.flatMapLatest { shadeConfig -> + combine(allShades(shadeConfig)) { shadeModels -> + shadeModels.maxOfOrNull { it.expansion } ?: 0f + } + } + + /** + * A _processed_ version of the proxied input flow. + * + * All internal dependencies on the proxied input flow *must* use this one for two reasons: + * 1. It's a [SharedFlow] so we only do the upstream work once, no matter how many usages we + * actually have. + * 2. It actually does some preprocessing as the proxied input events stream through, handling + * common things like recording the current state of the system based on incoming input + * events. + */ + private val processedProxiedInput: SharedFlow = + combine( + repository.shadeConfig, + repository.proxiedInput.distinctUntilChanged(), + ::Pair, + ) + .map { (shadeConfig, proxiedInput) -> + if (proxiedInput !is ProxiedInputModel.OnTap) { + // If the user is interacting with any other gesture type (for instance, + // dragging), + // we no longer want to force collapse all shades. + repository.setForceCollapseAll(false) + } + + when (proxiedInput) { + is ProxiedInputModel.OnDrag -> { + val affectedShadeId = affectedShadeId(shadeConfig, proxiedInput.xFraction) + // This might be the start of a new drag gesture, let's update our + // application + // state to record that fact. + onUserInteractionStarted( + shadeId = affectedShadeId, + isProxied = true, + ) + } + is ProxiedInputModel.OnTap -> { + // Tapping outside any shade collapses all shades. This code path is not hit + // for + // taps that happen _inside_ a shade as that input event is directly applied + // through the UI and is, hence, not a proxied input. + collapseAll() + } + else -> Unit + } + + proxiedInput + } + .shareIn( + scope = applicationScope, + started = SharingStarted.Eagerly, + replay = 1, + ) + + /** Whether the shade with the given ID should be visible. */ + fun isVisible(shadeId: ShadeId): Flow { + return repository.shadeConfig.map { shadeConfig -> shadeConfig.shadeIds.contains(shadeId) } + } + + /** Whether direct user input is allowed on the shade with the given ID. */ + fun isNonProxiedInputAllowed(shadeId: ShadeId): Flow { + return combine( + isForceCollapsed(shadeId), + repository.shadeInteraction, + ::Pair, + ) + .map { (isForceCollapsed, shadeInteraction) -> + !isForceCollapsed && shadeInteraction?.isProxied != true + } + } + + /** Whether the shade with the given ID is forced to collapse. */ + fun isForceCollapsed(shadeId: ShadeId): Flow { + return combine( + repository.forceCollapseAll, + repository.shadeInteraction.map { it?.shadeId }, + ::Pair, + ) + .map { (collapseAll, userInteractedShadeIdOrNull) -> + val counterpartShadeIdOrNull = + when (shadeId) { + ShadeId.SINGLE -> null + ShadeId.LEFT -> ShadeId.RIGHT + ShadeId.RIGHT -> ShadeId.LEFT + } + + when { + // If all shades have been told to collapse (by a tap outside, for example), + // then this shade is collapsed. + collapseAll -> true + // A shade that doesn't have a counterpart shade cannot be force-collapsed by + // interactions on the counterpart shade. + counterpartShadeIdOrNull == null -> false + // If the current user interaction is on the counterpart shade, then this shade + // should be force-collapsed. + else -> userInteractedShadeIdOrNull == counterpartShadeIdOrNull + } + } + } + + /** + * Proxied input affecting the shade with the given ID. 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). + */ + fun proxiedInput(shadeId: ShadeId): Flow { + return combine( + processedProxiedInput, + isForceCollapsed(shadeId).distinctUntilChanged(), + repository.shadeInteraction, + ::Triple, + ) + .map { (proxiedInput, isForceCollapsed, shadeInteraction) -> + when { + // If the shade is force-collapsed, we ignored proxied input on it. + isForceCollapsed -> null + // If the proxied input does not belong to this shade, ignore it. + shadeInteraction?.shadeId != shadeId -> null + // If there is ongoing non-proxied user input on any shade, ignore the + // proxied input. + !shadeInteraction.isProxied -> null + // Otherwise, send the proxied input downstream. + else -> proxiedInput + } + } + .onEach { proxiedInput -> + // We use yield() to make sure that the following block of code happens _after_ + // downstream collectors had a chance to process the proxied input. Otherwise, we + // might change our state to clear the current UserInteraction _before_ those + // downstream collectors get a chance to process the proxied input, which will make + // them ignore it (since they ignore proxied input when the current user interaction + // doesn't match their shade). + yield() + + if ( + proxiedInput is ProxiedInputModel.OnDragEnd || + proxiedInput is ProxiedInputModel.OnDragCancel + ) { + onUserInteractionEnded(shadeId = shadeId, isProxied = true) + } + } + } + + /** Sets the expansion amount for the shade with the given ID. */ + fun setExpansion( + shadeId: ShadeId, + @FloatRange(from = 0.0, to = 1.0) expansion: Float, + ) { + repository.setExpansion(shadeId, expansion) + } + + /** Collapses all shades. */ + fun collapseAll() { + repository.setForceCollapseAll(true) + } + + /** + * Notifies that a new non-proxied interaction may have started. Safe to call multiple times for + * the same interaction as it won't overwrite an existing interaction. + * + * Existing interactions can be cleared by calling [onUserInteractionEnded]. + */ + fun onUserInteractionStarted(shadeId: ShadeId) { + onUserInteractionStarted( + shadeId = shadeId, + isProxied = false, + ) + } + + /** + * Notifies that the current non-proxied interaction has ended. + * + * Safe to call multiple times, even if there's no current interaction or even if the current + * interaction doesn't belong to the given shade or is proxied as the code is a no-op unless + * there's a match between the parameters and the current interaction. + */ + fun onUserInteractionEnded( + shadeId: ShadeId, + ) { + onUserInteractionEnded( + shadeId = shadeId, + isProxied = false, + ) + } + + fun sendProxiedInput(proxiedInput: ProxiedInputModel) { + inputProxy.onProxiedInput(proxiedInput) + } + + /** + * Notifies that a new interaction may have started. Safe to call multiple times for the same + * interaction as it won't overwrite an existing interaction. + * + * Existing interactions can be cleared by calling [onUserInteractionEnded]. + */ + private fun onUserInteractionStarted( + shadeId: ShadeId, + isProxied: Boolean, + ) { + if (repository.shadeInteraction.value != null) { + return + } + + repository.setShadeInteraction( + MultiShadeInteractionModel( + shadeId = shadeId, + isProxied = isProxied, + ) + ) + } + + /** + * Notifies that the current interaction has ended. + * + * Safe to call multiple times, even if there's no current interaction or even if the current + * interaction doesn't belong to the given shade or [isProxied] value as the code is a no-op + * unless there's a match between the parameters and the current interaction. + */ + private fun onUserInteractionEnded( + shadeId: ShadeId, + isProxied: Boolean, + ) { + repository.shadeInteraction.value?.let { (interactionShadeId, isInteractionProxied) -> + if (shadeId == interactionShadeId && isProxied == isInteractionProxied) { + repository.setShadeInteraction(null) + } + } + } + + /** + * Returns the ID of the shade that's affected by user input at a given coordinate. + * + * @param config The shade configuration being used. + * @param xFraction The horizontal position of the user input as a fraction along the width of + * its container where `0` is all the way to the left and `1` is all the way to the right. + */ + private fun affectedShadeId( + config: ShadeConfig, + @FloatRange(from = 0.0, to = 1.0) xFraction: Float, + ): ShadeId { + return if (config is ShadeConfig.DualShadeConfig) { + if (xFraction <= config.splitFraction) { + ShadeId.LEFT + } else { + ShadeId.RIGHT + } + } else { + ShadeId.SINGLE + } + } + + /** Returns the list of flows of all the shades in the given configuration. */ + private fun allShades( + config: ShadeConfig, + ): List> { + return config.shadeIds.map { shadeId -> repository.getShade(shadeId) } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt new file mode 100644 index 0000000000000..415e68f6013df --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/multishade/domain/interactor/MultiShadeInteractorTest.kt @@ -0,0 +1,301 @@ +/* + * 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.domain.interactor + +import android.content.Context +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.data.repository.MultiShadeRepositoryTest +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 MultiShadeInteractorTest : SysuiTestCase() { + + private lateinit var testScope: TestScope + private lateinit var inputProxy: MultiShadeInputProxy + + @Before + fun setUp() { + testScope = TestScope() + inputProxy = MultiShadeInputProxy() + } + + @Test + fun maxShadeExpansion() = + testScope.runTest { + val underTest = create() + val maxShadeExpansion: Float? by collectLastValue(underTest.maxShadeExpansion) + assertWithMessage("maxShadeExpansion must start with 0.0!") + .that(maxShadeExpansion) + .isEqualTo(0f) + + underTest.setExpansion(shadeId = ShadeId.LEFT, expansion = 0.441f) + assertThat(maxShadeExpansion).isEqualTo(0.441f) + + underTest.setExpansion(shadeId = ShadeId.RIGHT, expansion = 0.442f) + assertThat(maxShadeExpansion).isEqualTo(0.442f) + + underTest.setExpansion(shadeId = ShadeId.RIGHT, expansion = 0f) + assertThat(maxShadeExpansion).isEqualTo(0.441f) + + underTest.setExpansion(shadeId = ShadeId.LEFT, expansion = 0f) + assertThat(maxShadeExpansion).isEqualTo(0f) + } + + @Test + fun isVisible_dualShadeConfig() = + testScope.runTest { + overrideResource(R.bool.dual_shade_enabled, true) + val underTest = create() + val isLeftShadeVisible: Boolean? by collectLastValue(underTest.isVisible(ShadeId.LEFT)) + val isRightShadeVisible: Boolean? by + collectLastValue(underTest.isVisible(ShadeId.RIGHT)) + val isSingleShadeVisible: Boolean? by + collectLastValue(underTest.isVisible(ShadeId.SINGLE)) + + assertThat(isLeftShadeVisible).isTrue() + assertThat(isRightShadeVisible).isTrue() + assertThat(isSingleShadeVisible).isFalse() + } + + @Test + fun isVisible_singleShadeConfig() = + testScope.runTest { + overrideResource(R.bool.dual_shade_enabled, false) + val underTest = create() + val isLeftShadeVisible: Boolean? by collectLastValue(underTest.isVisible(ShadeId.LEFT)) + val isRightShadeVisible: Boolean? by + collectLastValue(underTest.isVisible(ShadeId.RIGHT)) + val isSingleShadeVisible: Boolean? by + collectLastValue(underTest.isVisible(ShadeId.SINGLE)) + + assertThat(isLeftShadeVisible).isFalse() + assertThat(isRightShadeVisible).isFalse() + assertThat(isSingleShadeVisible).isTrue() + } + + @Test + fun isNonProxiedInputAllowed() = + testScope.runTest { + val underTest = create() + val isLeftShadeNonProxiedInputAllowed: Boolean? by + collectLastValue(underTest.isNonProxiedInputAllowed(ShadeId.LEFT)) + assertWithMessage("isNonProxiedInputAllowed should start as true!") + .that(isLeftShadeNonProxiedInputAllowed) + .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(ShadeId.LEFT)) + collectLastValue(underTest.proxiedInput(ShadeId.RIGHT)) + + // 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(isLeftShadeNonProxiedInputAllowed).isFalse() + + // Registering the end of the proxied interaction re-allows it. + inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd) + assertThat(isLeftShadeNonProxiedInputAllowed).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(isLeftShadeNonProxiedInputAllowed).isFalse() + + // Registering the end of the interaction on the RIGHT shade re-allows it. + inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd) + assertThat(isLeftShadeNonProxiedInputAllowed).isTrue() + } + + @Test + fun isForceCollapsed_whenOtherShadeInteractionUnderway() = + testScope.runTest { + val underTest = create() + val isLeftShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.LEFT)) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.RIGHT)) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.SINGLE)) + + 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. + underTest.onUserInteractionStarted(ShadeId.RIGHT) + assertThat(isLeftShadeForceCollapsed).isTrue() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the end of the interaction on the RIGHT shade re-allows it. + underTest.onUserInteractionEnded(ShadeId.RIGHT) + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the start of an interaction on the LEFT shade force-collapses the RIGHT + // shade. + underTest.onUserInteractionStarted(ShadeId.LEFT) + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isFalse() + + // Registering the end of the interaction on the LEFT shade re-allows it. + underTest.onUserInteractionEnded(ShadeId.LEFT) + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isFalse() + assertThat(isSingleShadeForceCollapsed).isFalse() + } + + @Test + fun collapseAll() = + testScope.runTest { + val underTest = create() + val isLeftShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.LEFT)) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.RIGHT)) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.SINGLE)) + + 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() + + underTest.collapseAll() + assertThat(isLeftShadeForceCollapsed).isTrue() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isTrue() + + // Receiving proxied input on that's not a tap gesture, on the left-hand side resets the + // "collapse all". Note that now the RIGHT shade is force-collapsed because we're + // interacting with the LEFT shade. + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0f, 0f)) + assertThat(isLeftShadeForceCollapsed).isFalse() + assertThat(isRightShadeForceCollapsed).isTrue() + assertThat(isSingleShadeForceCollapsed).isFalse() + } + + @Test + fun onTapOutside_collapsesAll() = + testScope.runTest { + val underTest = create() + val isLeftShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.LEFT)) + val isRightShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.RIGHT)) + val isSingleShadeForceCollapsed: Boolean? by + collectLastValue(underTest.isForceCollapsed(ShadeId.SINGLE)) + + 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() + val proxiedInput: ProxiedInputModel? by + collectLastValue(underTest.proxiedInput(ShadeId.RIGHT)) + underTest.onUserInteractionStarted(shadeId = ShadeId.RIGHT) + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f)) + assertThat(proxiedInput).isNull() + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.8f, 110f)) + assertThat(proxiedInput).isNull() + + underTest.onUserInteractionEnded(shadeId = ShadeId.RIGHT) + + inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0.9f, 100f)) + assertThat(proxiedInput).isNotNull() + } + + private fun create(): MultiShadeInteractor { + return create( + testScope = testScope, + context = context, + inputProxy = inputProxy, + ) + } + + companion object { + fun create( + testScope: TestScope, + context: Context, + inputProxy: MultiShadeInputProxy, + ): MultiShadeInteractor { + return MultiShadeInteractor( + applicationScope = testScope.backgroundScope, + repository = + MultiShadeRepositoryTest.create( + context = context, + inputProxy = inputProxy, + ), + inputProxy = inputProxy, + ) + } + } +}