Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 7aa59a35 authored by Alejandro Nijamkin's avatar Alejandro Nijamkin Committed by Ale Nijamkin
Browse files

Multi-shade foundation - UI layer viewmodel (3/5).

Early foundation for the multi-shade framework.

View models in the UI layer for the multi-shade foundation. Includes two view-models:
1. A top level MultiShadeViewModel to coordinate between the individual
   shades and the scrim
2. A ShadeViewModel to describe UI state for a single shade

Bug: 272130181
Test: includes integration tests for code in this layer together with the domain and data layers. Tested end-to-end in
the last CL in this chain.

Change-Id: I85ff1c24a780ffae66a7e352cf75acdbdbc749ba
parent 664a12de
Loading
Loading
Loading
Loading
+112 −0
Original line number Diff line number Diff line
/*
 * 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<Float> =
        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<Boolean> =
        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)
    }
}
+150 −0
Original line number Diff line number Diff line
/*
 * 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<Boolean> =
        interactor
            .isVisible(shadeId)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = false,
            )

    /** Whether swiping on the shade UI is currently enabled. */
    val isSwipingEnabled: StateFlow<Boolean> =
        interactor
            .isNonProxiedInputAllowed(shadeId)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = false,
            )

    /** Whether the shade must be collapsed immediately. */
    val isForceCollapsed: Flow<Boolean> =
        interactor.isForceCollapsed(shadeId).distinctUntilChanged()

    /** The width of the shade. */
    val width: StateFlow<Size> =
        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<Float> =
        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<Float> =
        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<ProxiedInputModel?> =
        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()
    }
}
+127 −0
Original line number Diff line number Diff line
/*
 * 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,
                ),
        )
    }
}
+226 −0
Original line number Diff line number Diff line
/*
 * 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 },
        )
    }
}