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