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