~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-android

1 collaborator
0
1
main
167 lines · 6.4 KB Raw History
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
    }
}