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