Multi-shade foundation - data layer (1/5).

Early foundation for the multi-shade framework.

Data layer for the multi-shade foundation. Includes cross-layer shared
models, a proxy for sending touch events into the framework, and a
repository to store and retrieve application state.

Bug: 272130181
Test: includes unit tests for code in this layer. Tested end-to-end in
the last CL in this chain.

Change-Id: Ic1dfd8deb0bdde95288d824f91986e4749810ca2
This commit is contained in:
Alejandro Nijamkin
2023-03-07 17:32:58 -08:00
committed by Ale Nijamkin
parent fb818bedbe
commit 00b49c070a
9 changed files with 646 additions and 0 deletions

View File

@@ -844,4 +844,44 @@
<!-- Configuration to set Learn more in device logs as URL link -->
<bool name="log_access_confirmation_learn_more_as_link">true</bool>
<!-- [START] MULTI SHADE -->
<!-- Whether the device should use dual shade. If false, the device uses single shade. -->
<bool name="dual_shade_enabled">true</bool>
<!--
When in dual shade, where should the horizontal split be on the screen to help determine whether
the user is pulling down the left shade or the right shade. Must be between 0.0 and 1.0,
inclusive. In other words: how much of the left-hand side of the screen, when pulled down on,
would reveal the left-hand side shade.
More concretely:
A value of 0.67 means that the left two-thirds of the screen are dedicated to the left-hand side
shade and the remaining one-third of the screen on the right is dedicated to the right-hand side
shade.
-->
<dimen name="dual_shade_split_fraction">0.67</dimen>
<!-- Width of the left-hand side shade. -->
<dimen name="left_shade_width">436dp</dimen>
<!-- Width of the right-hand side shade. -->
<dimen name="right_shade_width">436dp</dimen>
<!--
Opaque version of the scrim that shows up behind dual shades. The alpha channel is driven
programmatically.
-->
<color name="opaque_scrim">#D9D9D9</color>
<!-- Maximum opacity when the scrim that shows up behind the dual shades is fully visible. -->
<dimen name="dual_shade_scrim_alpha">0.1</dimen>
<!--
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.
-->
<dimen name="shade_swipe_expand_threshold">0.5</dimen>
<!--
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.
-->
<dimen name="shade_swipe_collapse_threshold">0.5</dimen>
<!-- [END] MULTI SHADE -->
</resources>

View File

@@ -0,0 +1,28 @@
/*
* 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.data.model
import com.android.systemui.multishade.shared.model.ShadeId
/** Models the current interaction with one of the shades. */
data class MultiShadeInteractionModel(
/** The ID of the shade that the user is currently interacting with. */
val shadeId: ShadeId,
/** Whether the interaction is proxied (as in: coming from an external app or different UI). */
val isProxied: Boolean,
)

View File

@@ -0,0 +1,47 @@
/*
* 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.data.remoteproxy
import com.android.systemui.multishade.shared.model.ProxiedInputModel
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* Acts as a hub for routing proxied user input into the multi shade system.
*
* "Proxied" user input is coming through a proxy; typically from an external app or different UI.
* In other words: it's not user input that's occurring directly on the shade UI itself. This class
* is that proxy.
*/
@Singleton
class MultiShadeInputProxy @Inject constructor() {
private val _proxiedTouch =
MutableSharedFlow<ProxiedInputModel>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val proxiedInput: Flow<ProxiedInputModel> = _proxiedTouch.asSharedFlow()
fun onProxiedInput(proxiedInput: ProxiedInputModel) {
_proxiedTouch.tryEmit(proxiedInput)
}
}

View File

@@ -0,0 +1,157 @@
/*
* 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.data.repository
import android.content.Context
import androidx.annotation.FloatRange
import com.android.systemui.R
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.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.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Encapsulates application state for all shades. */
@SysUISingleton
class MultiShadeRepository
@Inject
constructor(
@Application private val applicationContext: Context,
inputProxy: MultiShadeInputProxy,
) {
/**
* Remote input coming from sources outside of system UI (for example, swiping down on the
* Launcher or from the status bar).
*/
val proxiedInput: Flow<ProxiedInputModel> = inputProxy.proxiedInput
/** Width of the left-hand side shade, in pixels. */
private val leftShadeWidthPx =
applicationContext.resources.getDimensionPixelSize(R.dimen.left_shade_width)
/** Width of the right-hand side shade, in pixels. */
private val rightShadeWidthPx =
applicationContext.resources.getDimensionPixelSize(R.dimen.right_shade_width)
/**
* 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.
*
* This is a fraction between `0` and `1`.
*/
private val swipeCollapseThreshold =
checkInBounds(applicationContext.resources.getFloat(R.dimen.shade_swipe_collapse_threshold))
/**
* 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.
*
* This is a fraction between `0` and `1`.
*/
private val swipeExpandThreshold =
checkInBounds(applicationContext.resources.getFloat(R.dimen.shade_swipe_expand_threshold))
/**
* Maximum opacity when the scrim that shows up behind the dual shades is fully visible.
*
* This is a fraction between `0` and `1`.
*/
private val dualShadeScrimAlpha =
checkInBounds(applicationContext.resources.getFloat(R.dimen.dual_shade_scrim_alpha))
/** The current configuration of the shade system. */
val shadeConfig: StateFlow<ShadeConfig> =
MutableStateFlow(
if (applicationContext.resources.getBoolean(R.bool.dual_shade_enabled)) {
ShadeConfig.DualShadeConfig(
leftShadeWidthPx = leftShadeWidthPx,
rightShadeWidthPx = rightShadeWidthPx,
swipeCollapseThreshold = swipeCollapseThreshold,
swipeExpandThreshold = swipeExpandThreshold,
splitFraction =
applicationContext.resources.getFloat(
R.dimen.dual_shade_split_fraction
),
scrimAlpha = dualShadeScrimAlpha,
)
} else {
ShadeConfig.SingleShadeConfig(
swipeCollapseThreshold = swipeCollapseThreshold,
swipeExpandThreshold = swipeExpandThreshold,
)
}
)
.asStateFlow()
private val _forceCollapseAll = MutableStateFlow(false)
/** Whether all shades should be collapsed. */
val forceCollapseAll: StateFlow<Boolean> = _forceCollapseAll.asStateFlow()
private val _shadeInteraction = MutableStateFlow<MultiShadeInteractionModel?>(null)
/** The current shade interaction or `null` if no shade is interacted with currently. */
val shadeInteraction: StateFlow<MultiShadeInteractionModel?> = _shadeInteraction.asStateFlow()
private val stateByShade = mutableMapOf<ShadeId, MutableStateFlow<ShadeModel>>()
/** The model for the shade with the given ID. */
fun getShade(
shadeId: ShadeId,
): StateFlow<ShadeModel> {
return getMutableShade(shadeId).asStateFlow()
}
/** Sets the expansion amount for the shade with the given ID. */
fun setExpansion(
shadeId: ShadeId,
@FloatRange(from = 0.0, to = 1.0) expansion: Float,
) {
getMutableShade(shadeId).let { mutableState ->
mutableState.value = mutableState.value.copy(expansion = expansion)
}
}
/** Sets whether all shades should be immediately forced to collapse. */
fun setForceCollapseAll(isForced: Boolean) {
_forceCollapseAll.value = isForced
}
/** Sets the current shade interaction; use `null` if no shade is interacted with currently. */
fun setShadeInteraction(shadeInteraction: MultiShadeInteractionModel?) {
_shadeInteraction.value = shadeInteraction
}
private fun getMutableShade(id: ShadeId): MutableStateFlow<ShadeModel> {
return stateByShade.getOrPut(id) { MutableStateFlow(ShadeModel(id)) }
}
/** Asserts that the given [Float] is in the range of `0` and `1`, inclusive. */
private fun checkInBounds(float: Float): Float {
check(float in 0f..1f) { "$float isn't between 0 and 1." }
return float
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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.shared.model
import androidx.annotation.FloatRange
/**
* Models a part of an ongoing proxied user input gesture.
*
* "Proxied" user input is coming through a proxy; typically from an external app or different UI.
* In other words: it's not user input that's occurring directly on the shade UI itself.
*/
sealed class ProxiedInputModel {
/** The user is dragging their pointer. */
data class OnDrag(
/**
* The relative position of the pointer as a fraction of its container width where `0` is
* all the way to the left and `1` is all the way to the right.
*/
@FloatRange(from = 0.0, to = 1.0) val xFraction: Float,
/** The amount that the pointer was dragged, in pixels. */
val yDragAmountPx: Float,
) : ProxiedInputModel()
/** The user finished dragging by lifting up their pointer. */
object OnDragEnd : ProxiedInputModel()
/**
* The drag gesture has been canceled. Usually because the pointer exited the draggable area.
*/
object OnDragCancel : ProxiedInputModel()
/** The user has tapped (clicked). */
object OnTap : ProxiedInputModel()
}

View File

@@ -0,0 +1,79 @@
/*
* 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.shared.model
import androidx.annotation.FloatRange
/** Enumerates the various possible configurations of the shade system. */
sealed class ShadeConfig(
/** IDs of the shade(s) in this configuration. */
open val shadeIds: List<ShadeId>,
/**
* 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.
*/
@FloatRange(from = 0.0, to = 1.0) open val swipeCollapseThreshold: Float,
/**
* 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.
*/
@FloatRange(from = 0.0, to = 1.0) open val swipeExpandThreshold: Float,
) {
/** There is a single shade. */
data class SingleShadeConfig(
@FloatRange(from = 0.0, to = 1.0) override val swipeCollapseThreshold: Float,
@FloatRange(from = 0.0, to = 1.0) override val swipeExpandThreshold: Float,
) :
ShadeConfig(
shadeIds = listOf(ShadeId.SINGLE),
swipeCollapseThreshold = swipeCollapseThreshold,
swipeExpandThreshold = swipeExpandThreshold,
)
/** There are two shades arranged side-by-side. */
data class DualShadeConfig(
/** Width of the left-hand side shade. */
val leftShadeWidthPx: Int,
/** Width of the right-hand side shade. */
val rightShadeWidthPx: Int,
@FloatRange(from = 0.0, to = 1.0) override val swipeCollapseThreshold: Float,
@FloatRange(from = 0.0, to = 1.0) override val swipeExpandThreshold: Float,
/**
* The position of the "split" between interaction areas for each of the shades, as a
* fraction of the width of the container.
*
* Interactions that occur on the start-side (left-hand side in left-to-right languages like
* English) affect the start-side shade. Interactions that occur on the end-side (right-hand
* side in left-to-right languages like English) affect the end-side shade.
*/
@FloatRange(from = 0.0, to = 1.0) val splitFraction: Float,
/** Maximum opacity when the scrim that shows up behind the dual shades is fully visible. */
@FloatRange(from = 0.0, to = 1.0) val scrimAlpha: Float,
) :
ShadeConfig(
shadeIds = listOf(ShadeId.LEFT, ShadeId.RIGHT),
swipeCollapseThreshold = swipeCollapseThreshold,
swipeExpandThreshold = swipeExpandThreshold,
)
}

View File

@@ -0,0 +1,28 @@
/*
* 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.shared.model
/** Enumerates all known shade IDs. */
enum class ShadeId {
/** ID of the shade on the left in dual shade configurations. */
LEFT,
/** ID of the shade on the right in dual shade configurations. */
RIGHT,
/** ID of the single shade in single shade configurations. */
SINGLE,
}

View File

@@ -0,0 +1,26 @@
/*
* 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.shared.model
import androidx.annotation.FloatRange
/** Models the current state of a shade. */
data class ShadeModel(
val id: ShadeId,
@FloatRange(from = 0.0, to = 1.0) val expansion: Float = 0f,
)

View File

@@ -0,0 +1,191 @@
/*
* 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.data.repository
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.model.MultiShadeInteractionModel
import com.android.systemui.multishade.data.remoteproxy.MultiShadeInputProxy
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.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.map
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 MultiShadeRepositoryTest : SysuiTestCase() {
private lateinit var inputProxy: MultiShadeInputProxy
@Before
fun setUp() {
inputProxy = MultiShadeInputProxy()
}
@Test
fun proxiedInput() = runTest {
val underTest = create()
val latest: ProxiedInputModel? by collectLastValue(underTest.proxiedInput)
assertWithMessage("proxiedInput should start with null").that(latest).isNull()
inputProxy.onProxiedInput(ProxiedInputModel.OnTap)
assertThat(latest).isEqualTo(ProxiedInputModel.OnTap)
inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0f, 100f))
assertThat(latest).isEqualTo(ProxiedInputModel.OnDrag(0f, 100f))
inputProxy.onProxiedInput(ProxiedInputModel.OnDrag(0f, 120f))
assertThat(latest).isEqualTo(ProxiedInputModel.OnDrag(0f, 120f))
inputProxy.onProxiedInput(ProxiedInputModel.OnDragEnd)
assertThat(latest).isEqualTo(ProxiedInputModel.OnDragEnd)
}
@Test
fun shadeConfig_dualShadeEnabled() = runTest {
overrideResource(R.bool.dual_shade_enabled, true)
val underTest = create()
val shadeConfig: ShadeConfig? by collectLastValue(underTest.shadeConfig)
assertThat(shadeConfig).isInstanceOf(ShadeConfig.DualShadeConfig::class.java)
}
@Test
fun shadeConfig_dualShadeNotEnabled() = runTest {
overrideResource(R.bool.dual_shade_enabled, false)
val underTest = create()
val shadeConfig: ShadeConfig? by collectLastValue(underTest.shadeConfig)
assertThat(shadeConfig).isInstanceOf(ShadeConfig.SingleShadeConfig::class.java)
}
@Test
fun forceCollapseAll() = runTest {
val underTest = create()
val forceCollapseAll: Boolean? by collectLastValue(underTest.forceCollapseAll)
assertWithMessage("forceCollapseAll should start as false!")
.that(forceCollapseAll)
.isFalse()
underTest.setForceCollapseAll(true)
assertThat(forceCollapseAll).isTrue()
underTest.setForceCollapseAll(false)
assertThat(forceCollapseAll).isFalse()
}
@Test
fun shadeInteraction() = runTest {
val underTest = create()
val shadeInteraction: MultiShadeInteractionModel? by
collectLastValue(underTest.shadeInteraction)
assertWithMessage("shadeInteraction should start as null!").that(shadeInteraction).isNull()
underTest.setShadeInteraction(
MultiShadeInteractionModel(shadeId = ShadeId.LEFT, isProxied = false)
)
assertThat(shadeInteraction)
.isEqualTo(MultiShadeInteractionModel(shadeId = ShadeId.LEFT, isProxied = false))
underTest.setShadeInteraction(
MultiShadeInteractionModel(shadeId = ShadeId.RIGHT, isProxied = true)
)
assertThat(shadeInteraction)
.isEqualTo(MultiShadeInteractionModel(shadeId = ShadeId.RIGHT, isProxied = true))
underTest.setShadeInteraction(null)
assertThat(shadeInteraction).isNull()
}
@Test
fun expansion() = runTest {
val underTest = create()
val leftExpansion: Float? by
collectLastValue(underTest.getShade(ShadeId.LEFT).map { it.expansion })
val rightExpansion: Float? by
collectLastValue(underTest.getShade(ShadeId.RIGHT).map { it.expansion })
val singleExpansion: Float? by
collectLastValue(underTest.getShade(ShadeId.SINGLE).map { it.expansion })
assertWithMessage("expansion should start as 0!").that(leftExpansion).isZero()
assertWithMessage("expansion should start as 0!").that(rightExpansion).isZero()
assertWithMessage("expansion should start as 0!").that(singleExpansion).isZero()
underTest.setExpansion(
shadeId = ShadeId.LEFT,
0.4f,
)
assertThat(leftExpansion).isEqualTo(0.4f)
assertThat(rightExpansion).isEqualTo(0f)
assertThat(singleExpansion).isEqualTo(0f)
underTest.setExpansion(
shadeId = ShadeId.RIGHT,
0.73f,
)
assertThat(leftExpansion).isEqualTo(0.4f)
assertThat(rightExpansion).isEqualTo(0.73f)
assertThat(singleExpansion).isEqualTo(0f)
underTest.setExpansion(
shadeId = ShadeId.LEFT,
0.1f,
)
underTest.setExpansion(
shadeId = ShadeId.SINGLE,
0.88f,
)
assertThat(leftExpansion).isEqualTo(0.1f)
assertThat(rightExpansion).isEqualTo(0.73f)
assertThat(singleExpansion).isEqualTo(0.88f)
}
private fun create(): MultiShadeRepository {
return create(
context = context,
inputProxy = inputProxy,
)
}
companion object {
fun create(
context: Context,
inputProxy: MultiShadeInputProxy,
): MultiShadeRepository {
return MultiShadeRepository(
applicationContext = context,
inputProxy = inputProxy,
)
}
}
}