Import theming code from Android 12 Extensions

This is a dynamic theme engine that takes a single input color and
generates a Material You color palette, which is useful for implementing
dynamic wallpaper-based theming ("Monet") for Android 12.

Color science overview:
  - Colors manipulated with ZCAM color appearance model
  - Lightness taken from Material You targets
  - Chroma clamped between 0 (grayscale) and Material You targets (per
    swatch)
  - Lightness- and hue-preserving gamut mapping via gamut intersection

If accurateShades is set to true (default), the resulting colors are
guaranteed to follow Material You lightness targets and thus contrast is
guaranteed if the palette is used correctly.

Code imported from Android 12 Extensions v8.0.0 [1].

Demo screenshots: https://twitter.com/kdrag0n/status/1445584174790832134

[1] https://github.com/kdrag0n/android12-extensions
diff --git a/Android.bp b/Android.bp
new file mode 100644
index 0000000..80012af
--- /dev/null
+++ b/Android.bp
@@ -0,0 +1,9 @@
+java_library {
+    name: "themelib",
+    static_libs: [
+        "colorkt",
+    ],
+    srcs: [
+        "src/main/**/*.kt",
+    ],
+}
diff --git a/src/main/kotlin/dev/kdrag0n/monet/theme/ColorScheme.kt b/src/main/kotlin/dev/kdrag0n/monet/theme/ColorScheme.kt
new file mode 100644
index 0000000..a61c8e0
--- /dev/null
+++ b/src/main/kotlin/dev/kdrag0n/monet/theme/ColorScheme.kt
@@ -0,0 +1,20 @@
+package dev.kdrag0n.monet.theme
+
+import dev.kdrag0n.colorkt.Color
+
+typealias ColorSwatch = Map<Int, Color>
+
+abstract class ColorScheme {
+    abstract val neutral1: ColorSwatch
+    abstract val neutral2: ColorSwatch
+
+    abstract val accent1: ColorSwatch
+    abstract val accent2: ColorSwatch
+    abstract val accent3: ColorSwatch
+
+    // Helpers
+    val neutralColors: List<ColorSwatch>
+        get() = listOf(neutral1, neutral2)
+    val accentColors: List<ColorSwatch>
+        get() = listOf(accent1, accent2, accent3)
+}
diff --git a/src/main/kotlin/dev/kdrag0n/monet/theme/DynamicColorScheme.kt b/src/main/kotlin/dev/kdrag0n/monet/theme/DynamicColorScheme.kt
new file mode 100644
index 0000000..2bab8cc
--- /dev/null
+++ b/src/main/kotlin/dev/kdrag0n/monet/theme/DynamicColorScheme.kt
@@ -0,0 +1,92 @@
+package dev.kdrag0n.monet.theme
+
+import dev.kdrag0n.colorkt.Color
+import dev.kdrag0n.colorkt.cam.Zcam
+import dev.kdrag0n.colorkt.cam.Zcam.Companion.toZcam
+import dev.kdrag0n.colorkt.conversion.ConversionGraph.convert
+import dev.kdrag0n.colorkt.gamut.LchGamut
+import dev.kdrag0n.colorkt.gamut.LchGamut.clipToLinearSrgb
+import dev.kdrag0n.colorkt.rgb.Srgb
+import dev.kdrag0n.colorkt.tristimulus.CieXyz
+import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs.Companion.toAbs
+
+class DynamicColorScheme(
+    targets: ColorScheme,
+    seedColor: Color,
+    chromaFactor: Double = 1.0,
+    private val cond: Zcam.ViewingConditions,
+    private val accurateShades: Boolean = true,
+) : ColorScheme() {
+    private val seedNeutral = seedColor.convert<CieXyz>().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false).let { lch ->
+        lch.copy(chroma = lch.chroma * chromaFactor)
+    }
+    private val seedAccent = seedNeutral
+
+    // Main accent color. Generally, this is close to the seed color.
+    override val accent1 = transformSwatch(targets.accent1, seedAccent, targets.accent1)
+    // Secondary accent color. Darker shades of accent1.
+    override val accent2 = transformSwatch(targets.accent2, seedAccent, targets.accent1)
+    // Tertiary accent color. Seed color shifted to the next secondary color via hue offset.
+    override val accent3 = transformSwatch(
+        swatch = targets.accent3,
+        seed = seedAccent.copy(hue = seedAccent.hue + ACCENT3_HUE_SHIFT_DEGREES),
+        referenceSwatch = targets.accent1
+    )
+
+    // Main background color. Tinted with the seed color.
+    override val neutral1 = transformSwatch(targets.neutral1, seedNeutral, targets.neutral1)
+    // Secondary background color. Slightly tinted with the seed color.
+    override val neutral2 = transformSwatch(targets.neutral2, seedNeutral, targets.neutral1)
+
+    private fun transformSwatch(
+        swatch: ColorSwatch,
+        seed: Zcam,
+        referenceSwatch: ColorSwatch,
+    ): ColorSwatch {
+        return swatch.map { (shade, color) ->
+            val target = color as? Zcam
+                ?: color.convert<CieXyz>().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false)
+            val reference = referenceSwatch[shade]!! as? Zcam
+                ?: color.convert<CieXyz>().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false)
+            val newLch = transformColor(target, seed, reference)
+            val newSrgb = newLch.convert<Srgb>()
+
+            shade to newSrgb
+        }.toMap()
+    }
+
+    private fun transformColor(target: Zcam, seed: Zcam, reference: Zcam): Color {
+        // Keep target lightness.
+        val lightness = target.lightness
+        // Allow colorless gray and low-chroma colors by clamping.
+        // To preserve chroma ratios, scale chroma by the reference (A-1 / N-1).
+        val scaleC = if (reference.chroma == 0.0) {
+            // Zero reference chroma won't have chroma anyway, so use 0 to avoid a divide-by-zero
+            0.0
+        } else {
+            // Non-zero reference chroma = possible chroma scale
+            seed.chroma.coerceIn(0.0, reference.chroma) / reference.chroma
+        }
+        val chroma = target.chroma * scaleC
+        // Use the seed color's hue, since it's the most prominent feature of the theme.
+        val hue = seed.hue
+
+        val newColor = Zcam(
+            lightness = lightness,
+            chroma = chroma,
+            hue = hue,
+            viewingConditions = cond,
+        )
+        return if (accurateShades) {
+            newColor.clipToLinearSrgb(LchGamut.ClipMethod.PRESERVE_LIGHTNESS)
+        } else {
+            newColor.clipToLinearSrgb(LchGamut.ClipMethod.ADAPTIVE_TOWARDS_MID, alpha = 5.0)
+        }
+    }
+
+    companion object {
+        // Hue shift for the tertiary accent color (accent3), in degrees.
+        // 60 degrees = shifting by a secondary color
+        private const val ACCENT3_HUE_SHIFT_DEGREES = 60.0
+    }
+}
diff --git a/src/main/kotlin/dev/kdrag0n/monet/theme/MaterialYouTargets.kt b/src/main/kotlin/dev/kdrag0n/monet/theme/MaterialYouTargets.kt
new file mode 100644
index 0000000..f403ab3
--- /dev/null
+++ b/src/main/kotlin/dev/kdrag0n/monet/theme/MaterialYouTargets.kt
@@ -0,0 +1,127 @@
+package dev.kdrag0n.monet.theme
+
+import dev.kdrag0n.colorkt.Color
+import dev.kdrag0n.colorkt.cam.Zcam
+import dev.kdrag0n.colorkt.cam.Zcam.Companion.toZcam
+import dev.kdrag0n.colorkt.rgb.LinearSrgb.Companion.toLinear
+import dev.kdrag0n.colorkt.rgb.Srgb
+import dev.kdrag0n.colorkt.tristimulus.CieXyz.Companion.toXyz
+import dev.kdrag0n.colorkt.tristimulus.CieXyzAbs.Companion.toAbs
+import dev.kdrag0n.colorkt.ucs.lab.CieLab
+
+/*
+ * Default target colors, conforming to Material You standards.
+ *
+ * Derived from AOSP and Pixel defaults.
+ */
+class MaterialYouTargets(
+    private val chromaFactor: Double = 1.0,
+    useLinearLightness: Boolean,
+    val cond: Zcam.ViewingConditions,
+) : ColorScheme() {
+    companion object {
+        // Linear ZCAM lightness
+        private val LINEAR_LIGHTNESS_MAP = mapOf(
+            0    to 100.0,
+            10   to  99.0,
+            20   to  98.0,
+            50   to  95.0,
+            100  to  90.0,
+            200  to  80.0,
+            300  to  70.0,
+            400  to  60.0,
+            500  to  50.0,
+            600  to  40.0,
+            650  to  35.0,
+            700  to  30.0,
+            800  to  20.0,
+            900  to  10.0,
+            950  to   5.0,
+            1000 to   0.0,
+        )
+
+        // CIELAB lightness from AOSP defaults
+        private val CIELAB_LIGHTNESS_MAP = LINEAR_LIGHTNESS_MAP
+            .map { it.key to if (it.value == 50.0) 49.6 else it.value }
+            .toMap()
+
+        // Accent colors from Pixel defaults
+        private val REF_ACCENT1_COLORS = listOf(
+            0xd3e3fd,
+            0xa8c7fa,
+            0x7cacf8,
+            0x4c8df6,
+            0x1b6ef3,
+            0x0b57d0,
+            0x0842a0,
+            0x062e6f,
+            0x041e49,
+        )
+
+        private const val ACCENT1_REF_CHROMA_FACTOR = 1.2
+    }
+
+    override val neutral1: ColorSwatch
+    override val neutral2: ColorSwatch
+
+    override val accent1: ColorSwatch
+    override val accent2: ColorSwatch
+    override val accent3: ColorSwatch
+
+    init {
+        val lightnessMap = if (useLinearLightness) {
+            LINEAR_LIGHTNESS_MAP
+        } else {
+            CIELAB_LIGHTNESS_MAP
+                .map { it.key to cielabL(it.value) }
+                .toMap()
+        }
+
+        // Accent chroma from Pixel defaults
+        // We use the most chromatic color as the reference
+        // A-1 chroma = avg(default Pixel Blue shades 100-900)
+        // Excluding very bright variants (10, 50) to avoid light bias
+        // A-1 > A-3 > A-2
+        val accent1Chroma = calcAccent1Chroma() * ACCENT1_REF_CHROMA_FACTOR
+        val accent2Chroma = accent1Chroma / 3
+        val accent3Chroma = accent2Chroma * 2
+
+        // Custom neutral chroma
+        val neutral1Chroma = accent1Chroma / 8
+        val neutral2Chroma = accent1Chroma / 5
+
+        neutral1 = shadesWithChroma(neutral1Chroma, lightnessMap)
+        neutral2 = shadesWithChroma(neutral2Chroma, lightnessMap)
+
+        accent1 = shadesWithChroma(accent1Chroma, lightnessMap)
+        accent2 = shadesWithChroma(accent2Chroma, lightnessMap)
+        accent3 = shadesWithChroma(accent3Chroma, lightnessMap)
+    }
+
+    private fun cielabL(l: Double) = CieLab(
+        L = l,
+        a = 0.0,
+        b = 0.0,
+    ).toXyz().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false).lightness
+
+    private fun calcAccent1Chroma() = REF_ACCENT1_COLORS
+        .map { Srgb(it).toLinear().toXyz().toAbs(cond.referenceWhite.y).toZcam(cond, include2D = false).chroma }
+        .average()
+
+    private fun shadesWithChroma(
+        chroma: Double,
+        lightnessMap: Map<Int, Double>,
+    ): Map<Int, Color> {
+        // Adjusted chroma
+        val chromaAdj = chroma * chromaFactor
+
+        return lightnessMap.map {
+            it.key to Zcam(
+                lightness = it.value,
+                chroma = chromaAdj,
+                hue = 0.0,
+                viewingConditions = cond,
+            )
+        }.toMap()
+    }
+}