1 /*
2 * 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.content.Context
20 import android.os.Build
21 import com.android.internal.util.Preconditions.checkArgument
22 import java.io.File
23
24 private const val BRAND_TAG = "brand"
25 private const val MODEL_TAG = "model"
26 private const val API_TAG = "api"
27 private const val SIZE_TAG = "size"
28 private const val RESOLUTION_TAG = "resolution"
29 private const val DISPLAY_TAG = "display"
30 private const val THEME_TAG = "theme"
31 private const val ORIENTATION_TAG = "orientation"
32
33 /**
34 * Class to manage Directory structure of golden files.
35 *
36 * When you run a AR Diff test, different attributes/dimensions of the platform you are running on,
37 * such as build, screen resolution, orientation etc. will/may render differently and therefore may
38 * require a different golden file to compare against. You can manage these multiple golden files
39 * related to your test using this utility class. It supports both device-less or device based
40 * configurations. Please see GoldenPathManagerTest for detailed examples.
41 *
42 * There are two ways to modify how the golden files are stored and retrieved for your test: A.
43 * (Recommended) Create your own PathConfig object which takes a series of [PathElement]s Each path
44 * element represents a dimension such as screen resolution that affects the golden file. This
45 * dimension will be embedded either into the directory structure or into the filename itself. Your
46 * test can also provide its own custom implementation of [PathElement] if the dimension your test
47 * needs to rely on, is not supported. B. If you have a completely unique way of managing your
48 * golden files repository and corresponding local cache, implement a derived class and override the
49 * goldenIdentifierResolver function.
50 *
51 * NOTE: This class does not determine what combinations of attributes / dimensions your test code
52 * will run for. That decision/configuration is part of your test configuration.
53 */
54 open class GoldenPathManager
55 @JvmOverloads
56 constructor(
57 val appContext: Context,
58 val assetsPathRelativeToBuildRoot: String = "assets",
59 var deviceLocalPath: String = getDeviceOutputDirectory(appContext),
60 val pathConfig: PathConfig = getSimplePathConfig()
61 ) {
62
63 init {
64 val robolectricOverride = System.getProperty("robolectric.artifacts.dir")
65 if (Build.FINGERPRINT.contains("robolectric") && !robolectricOverride.isNullOrEmpty()) {
66 deviceLocalPath = robolectricOverride
67 }
68 }
69
goldenImageIdentifierResolvernull70 fun goldenImageIdentifierResolver(testName: String) =
71 goldenIdentifierResolver(testName, IMAGE_EXTENSION)
72
73 /*
74 * Uses [pathConfig] and [testName] to construct the full path to the golden file.
75 */
76 open fun goldenIdentifierResolver(testName: String, extension: String): String {
77 val relativePath = pathConfig.resolveRelativePath(appContext)
78 return "$relativePath$testName.$extension"
79 }
80
81 companion object {
82 const val IMAGE_EXTENSION = "png"
83 }
84 }
85
86 /*
87 * Every dimension that impacts the golden file needs to be a part of the path/filename
88 * that is used to access the golden. There are two types of attributes / dimensions.
89 * One that depend on the device context and the once that are context agnostic.
90 */
91 sealed class PathElementBase {
92 abstract val attr: String
93 abstract val isDir: Boolean
94 }
95
96 /*
97 * For dimensions that do not need access to the device context e.g.
98 * Build.MODEL, please instantiate the no context class.
99 */
100 data class PathElementNoContext(
101 override val attr: String,
102 override val isDir: Boolean,
103 val func: (() -> String)
104 ) : PathElementBase()
105
106 /*
107 * For dimensions that do not need to the device context e.g.
108 * and / or can change during run-time, please instantiate this class.
109 * e.g. screen orientation.
110 */
111 data class PathElementWithContext(
112 override val attr: String,
113 override val isDir: Boolean,
114 val func: ((Context) -> String)
115 ) : PathElementBase()
116
117 /*
118 * Converts an ordered list of PathElements into a relative path on filesystem.
119 * The relative path is then combined with either repo path of local cache path
120 * to get the full path to golden file.
121 */
122 class PathConfig(vararg elems: PathElementBase) {
123 val data = listOf(*elems)
124
resolveRelativePathnull125 public fun resolveRelativePath(context: Context): String {
126 return data
127 .map {
128 when (it) {
129 is PathElementWithContext -> it.func(context)
130 is PathElementNoContext -> it.func()
131 } + if (it.isDir) "/" else "_"
132 }
133 .joinToString("")
134 }
135 }
136
137 /*
138 * This is the PathConfig that will be used by default.
139 * An example directory structure using this config would be
140 * /google/pixel6/api32/600_400/
141 */
getDefaultPathConfignull142 fun getDefaultPathConfig(): PathConfig {
143 return PathConfig(
144 PathElementNoContext(BRAND_TAG, true, ::getDeviceBrand),
145 PathElementNoContext(MODEL_TAG, true, ::getDeviceModel),
146 PathElementNoContext(API_TAG, true, ::getAPIVersion),
147 PathElementWithContext(SIZE_TAG, true, ::getScreenSize),
148 PathElementWithContext(RESOLUTION_TAG, true, ::getScreenResolution)
149 )
150 }
151
getSimplePathConfignull152 fun getSimplePathConfig(): PathConfig {
153 return PathConfig(PathElementNoContext(MODEL_TAG, true, ::getDeviceModel))
154 }
155
156 /**
157 * Path config with device model and variant. Variant distinguishes different versions of golden
158 * (due to flag difference).
159 *
160 * Example: pixel_6_pro/trunk_staging/testCase.png
161 */
getDeviceVariantPathConfignull162 fun getDeviceVariantPathConfig(variant: String): PathConfig {
163 checkArgument(variant.isNotEmpty(), "variant can't be empty")
164 return PathConfig(
165 PathElementNoContext(MODEL_TAG, isDir = true, ::getDeviceModel),
166 PathElementNoContext("variant", isDir = true) { variant },
167 )
168 }
169
170 /** The [PathConfig] that should be used when emulating a device using the [DeviceEmulationRule]. */
getEmulatedDevicePathConfignull171 fun getEmulatedDevicePathConfig(emulationSpec: DeviceEmulationSpec): PathConfig {
172 // Returns a path of the form
173 // "/display_name/(light|dark)_(portrait|landscape)_golden_identifier.png".
174 return PathConfig(
175 PathElementNoContext(DISPLAY_TAG, isDir = true) { emulationSpec.display.name },
176 PathElementNoContext(THEME_TAG, isDir = false) {
177 if (emulationSpec.isDarkTheme) "dark" else "light"
178 },
179 PathElementNoContext(ORIENTATION_TAG, isDir = false) {
180 if (emulationSpec.isLandscape) "landscape" else "portrait"
181 },
182 )
183 }
184
185 /*
186 * Default output directory where all files generated as part of the test are stored.
187 */
getDeviceOutputDirectorynull188 fun getDeviceOutputDirectory(context: Context) =
189 File(context.filesDir, "platform_screenshots").toString()
190
191 /* Standard implementations for the usual list of dimensions that affect a golden file. */
192 fun getDeviceModel(): String {
193 var model = Build.MODEL.lowercase()
194 arrayOf("phone", "x86_64", "x86", "x64", "gms", "wear").forEach {
195 model = model.replace(it, "")
196 }
197 return model.trim().replace(" ", "_")
198 }
199
getDeviceBrandnull200 fun getDeviceBrand(): String {
201 var brand = Build.BRAND.lowercase()
202 arrayOf("phone", "x86_64", "x86", "x64", "gms", "wear").forEach {
203 brand = brand.replace(it, "")
204 }
205 return brand.trim().replace(" ", "_")
206 }
207
getAPIVersionnull208 fun getAPIVersion() = "API" + Build.VERSION.SDK_INT.toString()
209
210 fun getScreenResolution(context: Context) =
211 context.resources.displayMetrics.densityDpi.toString() + "dpi"
212
213 fun getScreenOrientation(context: Context) = context.resources.configuration.orientation.toString()
214
215 fun getScreenSize(context: Context): String {
216 val heightdp = context.resources.configuration.screenHeightDp.toString()
217 val widthdp = context.resources.configuration.screenWidthDp.toString()
218 return "${heightdp}_$widthdp"
219 }
220
221 /*
222 * If the dimension that your golden depends on, is not supported,
223 * Please add its implementations here.
224 */
225