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