1 /*
2 * Copyright (C) 2025 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package platform.test.motion.compose
18
19 import android.content.res.Configuration
20 import android.util.DisplayMetrics
21 import android.view.ContextThemeWrapper
22 import androidx.compose.runtime.Composable
23 import androidx.compose.runtime.CompositionLocalProvider
24 import androidx.compose.runtime.remember
25 import androidx.compose.ui.platform.LocalConfiguration
26 import androidx.compose.ui.platform.LocalContext
27 import androidx.compose.ui.platform.LocalDensity
28 import androidx.compose.ui.platform.LocalViewConfiguration
29 import androidx.compose.ui.platform.ViewConfiguration
30 import androidx.compose.ui.test.ExperimentalTestApi
31 import androidx.compose.ui.test.junit4.ComposeContentTestRule
32 import androidx.compose.ui.test.junit4.createComposeRule
33 import androidx.compose.ui.unit.Density
34 import androidx.compose.ui.unit.Dp
35 import androidx.compose.ui.unit.dp
36 import kotlin.math.floor
37 import kotlinx.coroutines.Dispatchers
38 import kotlinx.coroutines.test.TestScope
39 import org.junit.rules.RuleChain
40 import platform.test.motion.MotionTestRule
41 import platform.test.screenshot.GoldenPathManager
42
43 /**
44 * @param density Sets the [LocalDensity], allowing for a deterministic calculation of `dp -> px ->
45 * dp`.
46 * @param touchSlop Sets the [ViewConfiguration.touchSlop], allowing for a deterministic gesture
47 * start.
48 */
49 data class FixedConfiguration(
50 val density: Density = DefaultDensity,
51 val touchSlop: Dp = DefaultTouchSlop,
52 ) {
53
54 companion object {
55 /**
56 * Default density for tests.
57 *
58 * Using a uneven number to reveal issues with rounding.
59 */
60 val DefaultDensity = Density(2.5f)
61
62 /**
63 * Touch slop for gestures.
64 *
65 * Using the cuttlefish default value.
66 */
67 val DefaultTouchSlop = 12.dp
68 }
69 }
70
71 /**
72 * Convenience to create a [MotionTestRule], including the required setup.
73 *
74 * NOTE: The [configuration] applies to the complete content, EXCEPT the root node returned by the
75 * `isRoot()` [SemanticMatcher]. This can produce unexpected results when dispatching gestures on
76 * the root node. To work around this, dispatch the gestures on a node owned by the composable under
77 * test.
78 *
79 * In addition to the [MotionTestRule], this function also creates a [ComposeContentTestRule], which
80 * is run as part of the [MotionTestRule].
81 */
82 @OptIn(ExperimentalTestApi::class)
createFixedConfigurationComposeMotionTestRulenull83 fun createFixedConfigurationComposeMotionTestRule(
84 goldenPathManager: GoldenPathManager,
85 testScope: TestScope = TestScope(),
86 configuration: FixedConfiguration = FixedConfiguration(),
87 ): MotionTestRule<ComposeToolkit> {
88 val composeRule = createComposeRule(testScope.coroutineContext + Dispatchers.Main)
89
90 return MotionTestRule(
91 ComposeToolkit(composeRule, testScope, configuration),
92 goldenPathManager,
93 extraRules = RuleChain.outerRule(composeRule),
94 )
95 }
96
97 @Composable
FixedConfigurationProvidernull98 internal fun FixedConfigurationProvider(
99 fixedConfiguration: FixedConfiguration,
100 content: @Composable () -> Unit,
101 ) {
102 val baseLocalConfiguration = LocalConfiguration.current
103 val configuration =
104 remember(baseLocalConfiguration, fixedConfiguration) {
105 Configuration().apply {
106 updateFrom(baseLocalConfiguration)
107 densityDpi =
108 floor(fixedConfiguration.density.density * DisplayMetrics.DENSITY_DEFAULT)
109 .toInt()
110 fontScale = fixedConfiguration.density.fontScale
111 }
112 }
113 val previousContext = LocalContext.current
114 val context =
115 remember(previousContext, configuration) {
116 ContextThemeWrapper(previousContext, 0).apply {
117 applyOverrideConfiguration(configuration)
118 }
119 }
120
121 val baseViewConfiguration = LocalViewConfiguration.current
122 val customViewConfiguration =
123 remember(baseViewConfiguration, fixedConfiguration) {
124 object : ViewConfiguration by baseViewConfiguration {
125 override val touchSlop: Float =
126 with(fixedConfiguration.density) { fixedConfiguration.touchSlop.toPx() }
127 }
128 }
129
130 CompositionLocalProvider(
131 LocalContext provides context,
132 LocalDensity provides fixedConfiguration.density,
133 LocalConfiguration provides configuration,
134 LocalViewConfiguration provides customViewConfiguration,
135 content = content,
136 )
137 }
138