1 /*
<lambda>null2  * Copyright 2022 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.screenshot
18 
19 import android.app.UiAutomation
20 import android.app.UiModeManager
21 import android.content.Context
22 import android.os.Build
23 import android.os.UserHandle
24 import android.view.Display
25 import android.view.WindowManagerGlobal
26 import androidx.test.platform.app.InstrumentationRegistry
27 import org.junit.Assume
28 import org.junit.rules.TestRule
29 import org.junit.runner.Description
30 import org.junit.runners.model.Statement
31 
32 /**
33  * Rule to be added to a screenshot test to simulate a device with the given [spec].
34  *
35  * This rule takes care of setting up the environment by:
36  * - emulating a display size and density, taking the device orientation into account.
37  * - setting the test app in dark/light mode.
38  *
39  * Important: This rule should usually be the first rule in your test, so that all the display and
40  * app reconfiguration happens *before* your test starts doing any work, like launching an Activity.
41  *
42  * @see DeviceEmulationSpec
43  */
44 class DeviceEmulationRule(private val spec: DeviceEmulationSpec) : TestRule {
45 
46     private val instrumentation = InstrumentationRegistry.getInstrumentation()
47     private val uiAutomation = instrumentation.uiAutomation
48     private val isRoblectric = Build.FINGERPRINT.contains("robolectric")
49 
50     companion object {
51         var prevDensity: Int? = -1
52         var prevWidth: Int? = -1
53         var prevHeight: Int? = -1
54         var prevNightMode: Int? = UiModeManager.MODE_NIGHT_AUTO
55         var initialized: Boolean = false
56     }
57 
58     override fun apply(base: Statement, description: Description): Statement {
59         val skipDarkTheme: String =
60             InstrumentationRegistry.getArguments().getString("skip-dark-theme", "unknown")
61 
62         // The statement which calls beforeTest() before running the test.
63         return object : Statement() {
64             override fun evaluate() {
65                 Assume.assumeFalse(
66                     "Skipping test: ${description.displayName}, because skip-dark-theme is true.",
67                     skipDarkTheme == "true" && spec.isDarkTheme
68                 )
69                 beforeTest()
70                 base.evaluate()
71             }
72         }
73     }
74 
75     private fun beforeTest() {
76         // Emulate the display size and density.
77         val display = spec.display
78         val density = display.densityDpi
79         val (width, height) = getEmulatedDisplaySize()
80 
81         if (isRoblectric) {
82             // For Robolectric tests use RuntimeEnvironment.setQualifiers until wm is shadowed
83             // b/275751037  to address this issue.
84             val runtimeEnvironment = Class.forName("org.robolectric.RuntimeEnvironment")
85             val setQualifiers =
86                 runtimeEnvironment.getDeclaredMethod("setQualifiers", String::class.java)
87             val scaledWidth = width * 160 / density
88             val scaledHeight = height * 160 / density
89             val darkMode = if (spec.isDarkTheme) "night" else "notnight"
90             val qualifier = "w${scaledWidth}dp-h${scaledHeight}dp-${darkMode}-${density}dpi"
91             setQualifiers.invoke(null, qualifier)
92         } else {
93             val curNightMode =
94                 if (spec.isDarkTheme) {
95                     UiModeManager.MODE_NIGHT_YES
96                 } else {
97                     UiModeManager.MODE_NIGHT_NO
98                 }
99 
100             if (initialized) {
101                 if (prevDensity != density) {
102                     setDisplayDensity(density)
103                 }
104                 if (prevWidth != width || prevHeight != height) {
105                     setDisplaySize(width, height)
106                 }
107                 if (prevNightMode != curNightMode) {
108                     setNightMode(curNightMode)
109                 }
110             } else {
111                 // Make sure that we are in natural orientation (rotation 0) before we set the
112                 // screen size.
113                 uiAutomation.setRotation(UiAutomation.ROTATION_FREEZE_0)
114 
115                 setDisplayDensity(density)
116                 setDisplaySize(width, height)
117 
118                 // Force the dark/light theme.
119                 setNightMode(curNightMode)
120 
121                 // Make sure that all devices are in touch mode to avoid screenshot differences
122                 // in focused elements when in keyboard mode.
123                 instrumentation.setInTouchMode(true)
124 
125                 // Set the initialization fact.
126                 initialized = true
127             }
128         }
129     }
130 
131     /** Get the emulated display size for [spec]. */
132     private fun getEmulatedDisplaySize(): Pair<Int, Int> {
133         val display = spec.display
134         val isPortraitNaturalPosition = display.width < display.height
135         return if (spec.isLandscape == isPortraitNaturalPosition) {
136             display.height to display.width
137         } else {
138             display.width to display.height
139         }
140     }
141 
142     private fun setDisplayDensity(density: Int) {
143         val wm =
144             WindowManagerGlobal.getWindowManagerService()
145                 ?: error("Unable to acquire WindowManager")
146         wm.setForcedDisplayDensityForUser(Display.DEFAULT_DISPLAY, density, UserHandle.myUserId())
147         prevDensity = density
148     }
149 
150     private fun setDisplaySize(width: Int, height: Int) {
151         val wm =
152             WindowManagerGlobal.getWindowManagerService()
153                 ?: error("Unable to acquire WindowManager")
154         wm.setForcedDisplaySize(Display.DEFAULT_DISPLAY, width, height)
155         prevWidth = width
156         prevHeight = height
157     }
158 
159     private fun setNightMode(nightMode: Int) {
160         val uiModeManager =
161             InstrumentationRegistry.getInstrumentation()
162                 .targetContext
163                 .getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
164         uiModeManager.setApplicationNightMode(nightMode)
165         prevNightMode = nightMode
166     }
167 }
168 
169 /** The specification of a device display to be used in a screenshot test. */
170 data class DisplaySpec(
171     val name: String,
172     val width: Int,
173     val height: Int,
174     val densityDpi: Int,
175 )
176 
177 /** The specification of a device emulation. */
178 data class DeviceEmulationSpec(
179     val display: DisplaySpec,
180     val isDarkTheme: Boolean = false,
181     val isLandscape: Boolean = false,
182 ) {
183     companion object {
184         /**
185          * Return a list of [DeviceEmulationSpec] for each of the [displays].
186          *
187          * If [isDarkTheme] is null, this will create a spec for both light and dark themes, for
188          * each of the orientation.
189          *
190          * If [isLandscape] is null, this will create a spec for both portrait and landscape, for
191          * each of the light/dark themes.
192          */
forDisplaysnull193         fun forDisplays(
194             vararg displays: DisplaySpec,
195             isDarkTheme: Boolean? = null,
196             isLandscape: Boolean? = null,
197         ): List<DeviceEmulationSpec> {
198             return displays.flatMap { display ->
199                 buildList {
200                     fun addDisplay(isLandscape: Boolean) {
201                         if (isDarkTheme != true) {
202                             add(DeviceEmulationSpec(display, isDarkTheme = false, isLandscape))
203                         }
204 
205                         if (isDarkTheme != false) {
206                             add(DeviceEmulationSpec(display, isDarkTheme = true, isLandscape))
207                         }
208                     }
209 
210                     if (isLandscape != true) {
211                         addDisplay(isLandscape = false)
212                     }
213 
214                     if (isLandscape != false) {
215                         addDisplay(isLandscape = true)
216                     }
217                 }
218             }
219         }
220     }
221 
<lambda>null222     override fun toString(): String = buildString {
223         // This string is appended to PNGs stored in the device, so let's keep it simple.
224         append(display.name)
225         if (isDarkTheme) append("_dark")
226         if (isLandscape) append("_landscape")
227     }
228 }
229