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