1 /*
<lambda>null2  * Copyright (C) 2021 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 package com.android.systemui.monet
17 
18 import android.util.Log
19 import androidx.test.ext.junit.runners.AndroidJUnit4
20 import androidx.test.filters.SmallTest
21 import androidx.test.platform.app.InstrumentationRegistry
22 import com.android.systemui.monet.ColorScheme.GOOGLE_BLUE
23 import com.google.ux.material.libmonet.hct.Hct
24 import com.google.ux.material.libmonet.scheme.SchemeTonalSpot
25 import java.io.File
26 import java.io.FileWriter
27 import java.io.StringWriter
28 import javax.xml.parsers.DocumentBuilderFactory
29 import javax.xml.transform.OutputKeys
30 import javax.xml.transform.TransformerException
31 import javax.xml.transform.TransformerFactory
32 import javax.xml.transform.dom.DOMSource
33 import javax.xml.transform.stream.StreamResult
34 import kotlin.math.abs
35 import org.junit.Test
36 import org.junit.runner.RunWith
37 import org.w3c.dom.Document
38 import org.w3c.dom.Element
39 import org.w3c.dom.Node
40 
41 private const val CONTRAST = 0.0
42 
43 private const val IS_FIDELITY_ENABLED = false
44 
45 private const val fileHeader =
46     """
47   ~ Copyright (C) 2022 The Android Open Source Project
48   ~
49   ~ Licensed under the Apache License, Version 2.0 (the "License");
50   ~ you may not use this file except in compliance with the License.
51   ~ You may obtain a copy of the License at
52   ~
53   ~      http://www.apache.org/licenses/LICENSE-2.0
54   ~
55   ~ Unless required by applicable law or agreed to in writing, software
56   ~ distributed under the License is distributed on an "AS IS" BASIS,
57   ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
58   ~ See the License for the specific language governing permissions and
59   ~ limitations under the License.
60 """
61 
62 private fun testName(name: String): String {
63     return "Auto generated by: atest ColorSchemeTest#$name"
64 }
65 
66 private const val commentRoles =
67     "Colors used in Android system, from design system. These " +
68         "values can be overlaid at runtime by OverlayManager RROs."
69 
70 private const val commentOverlay = "This value can be overlaid at runtime by OverlayManager RROs."
71 
commentWhitenull72 private fun commentWhite(paletteName: String): String {
73     return "Lightest shade of the $paletteName color used by the system. White. $commentOverlay"
74 }
75 
commentBlacknull76 private fun commentBlack(paletteName: String): String {
77     return "Darkest shade of the $paletteName color used by the system. Black. $commentOverlay"
78 }
79 
commentShadenull80 private fun commentShade(paletteName: String, tone: Int): String {
81     return "Shade of the $paletteName system color at $tone% perceptual luminance (L* in L*a*b* " +
82         "color space). $commentOverlay"
83 }
84 
85 @SmallTest
86 @RunWith(AndroidJUnit4::class)
87 class ColorSchemeTest {
88     @Test
generateThemeStylesnull89     fun generateThemeStyles() {
90         val document = buildDoc<Any>()
91 
92         val themes = document.createElement("themes")
93         document.appendWithBreak(themes)
94 
95         var hue = 0.0
96         while (hue < 360) {
97             val sourceColor = Hct.from(hue, 50.0, 50.0)
98             val sourceColorHex = sourceColor.toInt().toRGBHex()
99 
100             val theme = document.createElement("theme")
101             theme.setAttribute("color", sourceColorHex)
102             themes.appendChild(theme)
103 
104             for (styleValue in Style.values()) {
105                 if (
106                     styleValue == Style.CLOCK ||
107                         styleValue == Style.CLOCK_VIBRANT ||
108                         styleValue == Style.CONTENT
109                 ) {
110                     continue
111                 }
112 
113                 val style = document.createElement(Style.name(styleValue).lowercase())
114                 val colorScheme = ColorScheme(sourceColor.toInt(), false, styleValue)
115 
116                 style.appendChild(
117                     document.createTextNode(
118                         listOf(
119                                 colorScheme.accent1,
120                                 colorScheme.accent2,
121                                 colorScheme.accent3,
122                                 colorScheme.neutral1,
123                                 colorScheme.neutral2,
124                                 colorScheme.error,
125                             )
126                             .flatMap { a -> listOf(*a.allShades.toTypedArray()) }
127                             .joinToString(",", transform = Int::toRGBHex)
128                     )
129                 )
130                 theme.appendChild(style)
131             }
132 
133             hue += 60
134         }
135 
136         saveFile(document, "themes.xml")
137     }
138 
139     @Test
generateDefaultValuesnull140     fun generateDefaultValues() {
141         val document = buildDoc<Any>()
142 
143         val resources = document.createElement("resources")
144         document.appendWithBreak(resources)
145 
146         // shade colors
147         val colorScheme = ColorScheme(GOOGLE_BLUE, false)
148         arrayOf(
149                 Triple("accent1", "Primary", colorScheme.accent1),
150                 Triple("accent2", "Secondary", colorScheme.accent2),
151                 Triple("accent3", "Tertiary", colorScheme.accent3),
152                 Triple("neutral1", "Neutral", colorScheme.neutral1),
153                 Triple("neutral2", "Secondary Neutral", colorScheme.neutral2),
154                 Triple("error", "Error", colorScheme.error),
155             )
156             .forEach {
157                 val (paletteName, readable, palette) = it
158                 palette.allShadesMapped.toSortedMap().entries.forEachIndexed {
159                     index,
160                     (shade, colorValue) ->
161                     val comment =
162                         when (index) {
163                             0 -> commentWhite(readable)
164                             palette.allShadesMapped.entries.size - 1 -> commentBlack(readable)
165                             else -> commentShade(readable, abs(shade / 10 - 100))
166                         }
167                     resources.createColorEntry("system_${paletteName}_$shade", colorValue, comment)
168                 }
169             }
170 
171         resources.appendWithBreak(document.createComment(commentRoles), 2)
172 
173         // dynamic colors
174         arrayOf(false, true).forEach { isDark ->
175             val suffix = if (isDark) "_dark" else "_light"
176             val dynamicScheme = SchemeTonalSpot(Hct.fromInt(GOOGLE_BLUE), isDark, CONTRAST)
177             DynamicColors.getAllDynamicColorsMapped(IS_FIDELITY_ENABLED).forEach {
178                 resources.createColorEntry(
179                     "system_${it.first}$suffix",
180                     it.second.getArgb(dynamicScheme),
181                 )
182             }
183         }
184 
185         // fixed colors
186         val dynamicScheme = SchemeTonalSpot(Hct.fromInt(GOOGLE_BLUE), false, CONTRAST)
187         DynamicColors.getFixedColorsMapped(IS_FIDELITY_ENABLED).forEach {
188             resources.createColorEntry("system_${it.first}", it.second.getArgb(dynamicScheme))
189         }
190 
191         // custom colors
192         arrayOf(false, true).forEach { isDark ->
193             val suffix = if (isDark) "_dark" else "_light"
194             val dynamicScheme = SchemeTonalSpot(Hct.fromInt(GOOGLE_BLUE), isDark, CONTRAST)
195             DynamicColors.getCustomColorsMapped(IS_FIDELITY_ENABLED).forEach {
196                 resources.createColorEntry(
197                     "system_${it.first}$suffix",
198                     it.second.getArgb(dynamicScheme),
199                 )
200             }
201         }
202 
203         saveFile(document, "colors.xml")
204     }
205 
206     @Test
generateSymbolsnull207     fun generateSymbols() {
208         val document = buildDoc<Any>()
209 
210         val resources = document.createElement("resources")
211         document.appendWithBreak(resources)
212 
213         (DynamicColors.getAllDynamicColorsMapped(IS_FIDELITY_ENABLED) +
214                 DynamicColors.getFixedColorsMapped(IS_FIDELITY_ENABLED))
215             .forEach {
216                 val newName = ("material_color_" + it.first).snakeToLowerCamelCase()
217 
218                 resources.createEntry(
219                     "java-symbol",
220                     arrayOf(Pair("name", newName), Pair("type", "color")),
221                     null,
222                 )
223             }
224 
225         DynamicColors.getCustomColorsMapped(IS_FIDELITY_ENABLED).forEach {
226             val newName = ("custom_color_" + it.first).snakeToLowerCamelCase()
227 
228             resources.createEntry(
229                 "java-symbol",
230                 arrayOf(Pair("name", newName), Pair("type", "color")),
231                 null,
232             )
233         }
234 
235         arrayOf("_light", "_dark").forEach { suffix ->
236             DynamicColors.getCustomColorsMapped(IS_FIDELITY_ENABLED).forEach {
237                 val newName = "system_" + it.first + suffix
238 
239                 resources.createEntry(
240                     "java-symbol",
241                     arrayOf(Pair("name", newName), Pair("type", "color")),
242                     null,
243                 )
244             }
245         }
246 
247         saveFile(document, "symbols.xml")
248     }
249 
250     @Test
generateDynamicColorsnull251     fun generateDynamicColors() {
252         arrayOf(false, true).forEach { isDark ->
253             val document = buildDoc<Any>()
254 
255             val resources = document.createElement("resources")
256             document.appendWithBreak(resources)
257 
258             (DynamicColors.getAllDynamicColorsMapped(IS_FIDELITY_ENABLED) +
259                     DynamicColors.getFixedColorsMapped(IS_FIDELITY_ENABLED))
260                 .forEach {
261                     val newName = ("material_color_" + it.first).snakeToLowerCamelCase()
262 
263                     val suffix = if (isDark) "_dark" else "_light"
264                     val colorValue =
265                         "@color/system_" + it.first + if (it.first.contains("fixed")) "" else suffix
266 
267                     resources.createColorEntry(newName, colorValue)
268                 }
269 
270             val suffix = if (isDark) "_dark" else "_light"
271 
272             DynamicColors.getCustomColorsMapped(IS_FIDELITY_ENABLED).forEach {
273                 val newName = ("custom_color_" + it.first).snakeToLowerCamelCase()
274                 resources.createColorEntry(newName, "@color/system_" + it.first + suffix)
275             }
276 
277             saveFile(document, "colors_dynamic_$suffix.xml")
278         }
279     }
280 
281     @Test
generatePublicnull282     fun generatePublic() {
283         val document = buildDoc<Any>()
284 
285         val resources = document.createElement("resources")
286 
287         val group = document.createElement("staging-public-group")
288         resources.appendChild(group)
289 
290         document.appendWithBreak(resources)
291 
292         val context = InstrumentationRegistry.getInstrumentation().targetContext
293         val res = context.resources
294 
295         val rClass = com.android.internal.R.color::class.java
296         val existingFields = rClass.declaredFields.map { it.name }.toSet()
297 
298         arrayOf("_light", "_dark").forEach { suffix ->
299             DynamicColors.getAllDynamicColorsMapped(IS_FIDELITY_ENABLED).forEach {
300                 val name = "system_" + it.first + suffix
301                 if (!existingFields.contains(name)) {
302                     group.createEntry("public", arrayOf(Pair("name", name)), null)
303                 }
304             }
305         }
306 
307         DynamicColors.getFixedColorsMapped(IS_FIDELITY_ENABLED).forEach {
308             val name = "system_${it.first}"
309             if (!existingFields.contains(name)) {
310                 group.createEntry("public", arrayOf(Pair("name", name)), null)
311             }
312         }
313 
314         saveFile(document, "public.xml")
315     }
316 
317     // Helper Functions
318 
buildDocnull319     private inline fun <reified T> buildDoc(): Document {
320         val functionName = T::class.simpleName + ""
321         val factory = DocumentBuilderFactory.newInstance()
322         val builder = factory.newDocumentBuilder()
323         val document = builder.newDocument()
324 
325         document.appendWithBreak(document.createComment(fileHeader))
326         document.appendWithBreak(document.createComment(testName(functionName)))
327 
328         return document
329     }
330 
documentToStringnull331     private fun documentToString(document: Document): String {
332         try {
333             val transformerFactory = TransformerFactory.newInstance()
334             val transformer = transformerFactory.newTransformer()
335             transformer.setOutputProperty(OutputKeys.MEDIA_TYPE, "application/xml")
336             transformer.setOutputProperty(OutputKeys.METHOD, "xml")
337             transformer.setOutputProperty(OutputKeys.INDENT, "yes")
338             transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4")
339 
340             val stringWriter = StringWriter()
341             transformer.transform(DOMSource(document), StreamResult(stringWriter))
342             return stringWriter.toString()
343         } catch (e: TransformerException) {
344             throw RuntimeException("Error transforming XML", e)
345         }
346     }
347 
saveFilenull348     private fun saveFile(document: Document, fileName: String) {
349         val context = InstrumentationRegistry.getInstrumentation().context
350         val outPath = context.filesDir.path + "/" + fileName
351         Log.d("ColorSchemeXml", "Artifact $fileName created")
352         val writer = FileWriter(File(outPath))
353         writer.write(documentToString(document))
354         writer.close()
355     }
356 }
357 
createColorEntrynull358 private fun Element.createColorEntry(name: String, value: Int, comment: String? = null) {
359     this.createColorEntry(name, "#" + value.toRGBHex(), comment)
360 }
361 
createColorEntrynull362 private fun Element.createColorEntry(name: String, value: String, comment: String? = null) {
363     this.createEntry("color", arrayOf(Pair("name", name)), value, comment)
364 }
365 
createEntrynull366 private fun Element.createEntry(
367     tagName: String,
368     attrs: Array<Pair<String, String>>,
369     value: String?,
370     comment: String? = null,
371 ) {
372     val doc = this.ownerDocument
373 
374     if (comment != null) {
375         this.appendChild(doc.createComment(comment))
376     }
377 
378     val child = doc.createElement(tagName)
379     this.appendChild(child)
380 
381     attrs.forEach { child.setAttribute(it.first, it.second) }
382 
383     if (value !== null) {
384         child.appendChild(doc.createTextNode(value))
385     }
386 }
387 
Nodenull388 private fun Node.appendWithBreak(child: Node, lineBreaks: Int = 1): Node {
389     val doc = if (this is Document) this else this.ownerDocument
390     val node = doc.createTextNode("\n".repeat(lineBreaks))
391     this.appendChild(node)
392     return this.appendChild(child)
393 }
394 
toRGBHexnull395 private fun Int.toRGBHex(): String {
396     return "%06X".format(0xFFFFFF and this)
397 }
398 
snakeToLowerCamelCasenull399 private fun String.snakeToLowerCamelCase(): String {
400     val pattern = "_[a-z]".toRegex()
401     return replace(pattern) { it.value.last().uppercase() }
402 }
403