1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
package social.grain
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import social.grain.ui.theme.GrainTheme
import java.io.File
/**
* The fallback palettes — what Android 11 and below get, and anyone with
* dynamic colour off.
*
* A ColorScheme role that is never passed silently keeps Material's baseline
* value, and the baseline neutrals are tinted toward Material's own purple. So
* leaving surfaceContainer unset didn't fail anything; it just made the
* navigation bar and the comment sheet faintly lavender against surfaces that
* are otherwise pure white or near-black. Nothing catches that but eyes on an
* old device, which is what this test replaces.
*/
@RunWith(AndroidJUnit4::class)
class FallbackPaletteTest {
@get:Rule val composeRule = createComposeRule()
/**
* The surface and outline roles Grain means to own. Every one of these
* draws a real surface in the app — surfaceContainer is the navigation
* bar, surfaceContainerLow the comment sheet, surfaceContainerHighest a
* filled text field, outline the chip and outlined-button borders.
*/
private val ownedRoles: List<Pair<String, ColorScheme.() -> Color>> = listOf(
"surfaceContainerLowest" to { surfaceContainerLowest },
"surfaceContainerLow" to { surfaceContainerLow },
"surfaceContainer" to { surfaceContainer },
"surfaceContainerHigh" to { surfaceContainerHigh },
"surfaceContainerHighest" to { surfaceContainerHighest },
"surfaceBright" to { surfaceBright },
"surfaceDim" to { surfaceDim },
"inverseSurface" to { inverseSurface },
"inverseOnSurface" to { inverseOnSurface },
"outline" to { outline },
)
/**
* The subset that paints a surface. `outline` is excluded on purpose: it
* is a border, not a background, and Grain's own is a deliberately cool
* grey well past the neutrality bar below.
*/
private val surfaceRoles = ownedRoles.filterNot { it.first == "outline" }
/** Both fallback schemes, captured in one composition. */
private fun schemes(): Pair<ColorScheme, ColorScheme> {
lateinit var light: ColorScheme
lateinit var dark: ColorScheme
composeRule.setContent {
GrainTheme(darkTheme = false, dynamicColor = false) {
light = MaterialTheme.colorScheme
}
GrainTheme(darkTheme = true, dynamicColor = false) {
dark = MaterialTheme.colorScheme
}
}
composeRule.waitForIdle()
return light to dark
}
/**
* How far apart a colour's strongest and weakest channels are — 0 is a
* true grey. A cheap stand-in for chroma, and unlike HSV saturation it
* stays meaningful down at the near-black end of the dark palette.
*/
private fun Color.channelSpread(): Int {
val argb = toArgb()
val r = (argb shr 16) and 255
val g = (argb shr 8) and 255
val b = argb and 255
return maxOf(r, g, b) - minOf(r, g, b)
}
private fun assertSurfacesAreNeutral(scheme: ColorScheme, label: String) {
val tinted = surfaceRoles
.map { (name, role) -> name to scheme.role().channelSpread() }
.filter { (_, spread) -> spread > MAX_SPREAD }
assertTrue(
"$label surfaces carry a colour cast: " +
tinted.joinToString { "${it.first} spread ${it.second}" },
tinted.isEmpty(),
)
}
@Test
fun fallbackSurfacesAreNeutral() {
val (light, dark) = schemes()
assertSurfacesAreNeutral(light, "light")
assertSurfacesAreNeutral(dark, "dark")
}
/**
* Dumps both palettes beside Material's baseline so the difference can be
* looked at rather than only asserted. Pull it with:
*
* adb shell run-as social.grain.debug cat files/fallback_palette.png
*/
@Test
fun dumpPaletteComparison() {
val (lightScheme, darkScheme) = schemes()
val rows = listOf(
"light grain" to lightScheme,
"light baseline" to lightColorScheme(),
"dark grain" to darkScheme,
"dark baseline" to darkColorScheme(),
)
val swatch = 120
val labelWidth = 420
val bitmap = Bitmap.createBitmap(
labelWidth + swatch * ownedRoles.size,
swatch * rows.size,
Bitmap.Config.ARGB_8888,
)
val canvas = Canvas(bitmap)
canvas.drawColor(android.graphics.Color.DKGRAY)
val text = Paint().apply {
color = android.graphics.Color.WHITE
textSize = 32f
isAntiAlias = true
}
rows.forEachIndexed { row, (label, scheme) ->
val top = row * swatch
canvas.drawText(label, 16f, top + swatch / 2f + 12f, text)
ownedRoles.forEachIndexed { col, (_, role) ->
val left = labelWidth + col * swatch
canvas.drawRect(
left.toFloat(), top.toFloat(),
(left + swatch).toFloat(), (top + swatch).toFloat(),
Paint().apply { color = scheme.role().toArgb() },
)
}
}
val context = InstrumentationRegistry.getInstrumentation().targetContext
File(context.filesDir, "fallback_palette.png").outputStream()
.use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
}
private companion object {
/**
* Grain's surface roles measure 0-6 apart; Material's baseline ones
* measure 8-10 in light (surfaceContainer #F3EDF7, surfaceBright
* #FEF7FF) and 5-9 in dark. Six separates the two, so dropping any of
* the overrides fails this rather than merely looking slightly wrong
* on a device nobody tests on.
*/
const val MAX_SPREAD = 6
}
}