fix(edit): curate oversized CalDAV event-colour palettes (#22)
CalDAV sync adapters (DAVx5) publish all ~147 CSS3 named colours into CalendarContract.Colors, so the picker showed a full screen of alphabetically-scrambled, partly duplicated swatches. Palettes are now deduped by value, thinned to perceptually distinct colours (CIE76 deltaE >= 25 in Lab) when oversized, and sorted around the hue wheel with achromatics last. Small palettes (Google's curated set) pass through untouched, and every surviving swatch keeps its provider colour key so picks still round-trip through sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.curatedForPicker
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
@@ -57,7 +58,10 @@ interface CalendarDataSource {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The event-colour palette the calendar's account publishes
|
* The event-colour palette the calendar's account publishes
|
||||||
* (`CalendarContract.Colors`, `TYPE_EVENT`), sorted by key. Empty when the
|
* (`CalendarContract.Colors`, `TYPE_EVENT`), curated for display — deduped,
|
||||||
|
* thinned to visually distinct swatches when oversized (CalDAV adapters
|
||||||
|
* publish all ~147 CSS3 names, #22) and hue-sorted; see
|
||||||
|
* [curatedForPicker]. Empty when the
|
||||||
* account exposes no palette (most local calendars, some CalDAV) — the
|
* account exposes no palette (most local calendars, some CalDAV) — the
|
||||||
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
* signal that a custom colour can only be written as a raw `EVENT_COLOR`,
|
||||||
* which a synced calendar may drop on its next sync.
|
* which a synced calendar may drop on its next sync.
|
||||||
@@ -420,7 +424,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
c.mapAll { EventColorOption(key = it.getString(0).orEmpty(), argb = it.getInt(1)) }
|
||||||
}
|
}
|
||||||
?.filter { it.key.isNotEmpty() }
|
?.filter { it.key.isNotEmpty() }
|
||||||
?.sortedBy { it.key }
|
?.curatedForPicker()
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cbrt
|
||||||
|
import kotlin.math.hypot
|
||||||
|
import kotlin.math.pow
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curates an account's published event palette for the colour picker.
|
||||||
|
*
|
||||||
|
* Sync adapters differ wildly in what they publish: Google exposes a
|
||||||
|
* hand-picked two-dozen set, while CalDAV adapters (DAVx5) dump all ~147 CSS3
|
||||||
|
* named colours — including exact-value aliases (aqua/cyan, the gray/grey
|
||||||
|
* spelling pairs) and dozens of visually indistinguishable whites and grays
|
||||||
|
* (#22). Three steps tame that without breaking sync round-tripping (every
|
||||||
|
* surviving option keeps its provider [EventColorOption.key]):
|
||||||
|
*
|
||||||
|
* 1. Exact duplicates collapse to one swatch (alphabetically-first key wins,
|
||||||
|
* deterministically).
|
||||||
|
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) are thinned to visually
|
||||||
|
* distinct colours: most vivid first, a colour is kept only when at least
|
||||||
|
* [MIN_DELTA_E] (CIE76, Lab) from every colour already kept. Small
|
||||||
|
* palettes are already curated by their adapter and pass through whole.
|
||||||
|
* 3. The result is ordered like a rainbow — hue buckets, light-to-dark
|
||||||
|
* within each, achromatics last — instead of the provider's key order,
|
||||||
|
* which for CSS3 names scatters hues alphabetically.
|
||||||
|
*/
|
||||||
|
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||||
|
val distinct = sortedBy { it.key }.distinctBy { it.argb }
|
||||||
|
val kept = if (distinct.size <= CURATION_TRIGGER_SIZE) distinct else thin(distinct)
|
||||||
|
return kept
|
||||||
|
.map { it to Lab.of(it.argb) }
|
||||||
|
.sortedWith(
|
||||||
|
compareBy(
|
||||||
|
{ (_, lab) -> if (lab.chroma < ACHROMATIC_MAX_CHROMA) 1 else 0 },
|
||||||
|
{ (_, lab) -> if (lab.chroma < ACHROMATIC_MAX_CHROMA) 0 else lab.hueBucket },
|
||||||
|
{ (_, lab) -> lab.l },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map { (option, _) -> option }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||||
|
private fun thin(options: List<EventColorOption>): List<EventColorOption> {
|
||||||
|
val byVividness = options
|
||||||
|
.map { it to Lab.of(it.argb) }
|
||||||
|
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||||
|
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||||
|
for (candidate in byVividness) {
|
||||||
|
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||||
|
}
|
||||||
|
return kept.map { it.first }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Palettes at most this big skip the distinctness thinning (Google's ~26 pass through). */
|
||||||
|
private const val CURATION_TRIGGER_SIZE = 36
|
||||||
|
|
||||||
|
/** Minimum CIE76 ΔE between surviving swatches; 25 keeps ~42 of CSS3's 147. */
|
||||||
|
private const val MIN_DELTA_E = 25.0
|
||||||
|
|
||||||
|
/** Below this Lab chroma a colour reads as gray and sorts into the trailing bucket. */
|
||||||
|
private const val ACHROMATIC_MAX_CHROMA = 12.0
|
||||||
|
|
||||||
|
/** Sorting granularity around the hue wheel. */
|
||||||
|
private const val HUE_BUCKETS = 12
|
||||||
|
|
||||||
|
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||||
|
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||||
|
val chroma: Double get() = hypot(a, b)
|
||||||
|
|
||||||
|
val hueBucket: Int
|
||||||
|
get() {
|
||||||
|
val degrees = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||||
|
return (degrees * HUE_BUCKETS / 360.0).toInt().coerceAtMost(HUE_BUCKETS - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deltaE(other: Lab): Double =
|
||||||
|
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun of(argb: Int): Lab {
|
||||||
|
fun linear(shift: Int): Double {
|
||||||
|
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||||
|
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||||
|
}
|
||||||
|
val r = linear(16)
|
||||||
|
val g = linear(8)
|
||||||
|
val b = linear(0)
|
||||||
|
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||||
|
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||||
|
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||||
|
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
||||||
|
val fy = f(y)
|
||||||
|
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1712,12 +1712,20 @@ private fun ColorPickerDialog(
|
|||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
if (palette.isNotEmpty()) {
|
if (palette.isNotEmpty()) {
|
||||||
|
// The event's current colour may not be in the curated
|
||||||
|
// palette (thinned near-duplicate, or a raw colour set
|
||||||
|
// elsewhere) — append it so the selection ring has a home.
|
||||||
|
val swatches = palette.map { it.argb }.let {
|
||||||
|
if (selected != null && selected !in it) it + selected else it
|
||||||
|
}
|
||||||
ColorSwatchRow(
|
ColorSwatchRow(
|
||||||
colors = palette.map { it.argb },
|
colors = swatches,
|
||||||
selected = selected,
|
selected = selected,
|
||||||
onSelect = { argb ->
|
onSelect = { argb ->
|
||||||
palette.firstOrNull { it.argb == argb }
|
val option = palette.firstOrNull { it.argb == argb }
|
||||||
?.let { onPickKey(it.key, it.argb) }
|
// The appended current colour has no key to write —
|
||||||
|
// it is already the event's colour, so just close.
|
||||||
|
if (option != null) onPickKey(option.key, option.argb) else onDismiss()
|
||||||
},
|
},
|
||||||
dark = dark,
|
dark = dark,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.domain
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class EventColorPaletteTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty palette stays empty`() {
|
||||||
|
assertThat(emptyList<EventColorOption>().curatedForPicker()).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exact duplicate values collapse to the alphabetically first key`() {
|
||||||
|
val curated = listOf(
|
||||||
|
EventColorOption("cyan", 0xFF00FFFF.toInt()),
|
||||||
|
EventColorOption("aqua", 0xFF00FFFF.toInt()),
|
||||||
|
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||||
|
).curatedForPicker()
|
||||||
|
|
||||||
|
assertThat(curated.map { it.key }).containsExactly("aqua", "red")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `small palettes pass through whole, so Google's curated set is untouched`() {
|
||||||
|
// A Google-like palette: two dozen distinct hand-picked colours.
|
||||||
|
val palette = (0 until 24).map {
|
||||||
|
val hue = it * 15
|
||||||
|
EventColorOption("$it", hsvArgb(hue.toFloat()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val curated = palette.curatedForPicker()
|
||||||
|
|
||||||
|
assertThat(curated).containsExactlyElementsIn(palette)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `oversized CSS3 palette thins to a pickable number of distinct swatches`() {
|
||||||
|
val curated = css3Palette().curatedForPicker()
|
||||||
|
|
||||||
|
// The whole point of #22: ~147 published colours become a single
|
||||||
|
// manageable grid instead of a full screen.
|
||||||
|
assertThat(curated.size).isAtLeast(30)
|
||||||
|
assertThat(curated.size).isAtMost(60)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `curation never invents colours or drops keys`() {
|
||||||
|
val source = css3Palette()
|
||||||
|
val curated = source.curatedForPicker()
|
||||||
|
|
||||||
|
assertThat(source).containsAtLeastElementsIn(curated)
|
||||||
|
assertThat(curated.map { it.argb }).containsNoDuplicates()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `spelling-alias pairs never both survive`() {
|
||||||
|
val keys = css3Palette().curatedForPicker().map { it.key }.toSet()
|
||||||
|
|
||||||
|
val aliasPairs = listOf(
|
||||||
|
"aqua" to "cyan",
|
||||||
|
"fuchsia" to "magenta",
|
||||||
|
"gray" to "grey",
|
||||||
|
"darkgray" to "darkgrey",
|
||||||
|
"dimgray" to "dimgrey",
|
||||||
|
"lightgray" to "lightgrey",
|
||||||
|
"slategray" to "slategrey",
|
||||||
|
"lightslategray" to "lightslategrey",
|
||||||
|
"darkslategray" to "darkslategrey",
|
||||||
|
)
|
||||||
|
aliasPairs.forEach { (a, b) ->
|
||||||
|
assertThat(keys.contains(a) && keys.contains(b)).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `chromatic colours sort as a rainbow with achromatics last`() {
|
||||||
|
val curated = listOf(
|
||||||
|
EventColorOption("gray", 0xFF808080.toInt()),
|
||||||
|
EventColorOption("blue", 0xFF0000FF.toInt()),
|
||||||
|
EventColorOption("black", 0xFF000000.toInt()),
|
||||||
|
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||||
|
).curatedForPicker()
|
||||||
|
|
||||||
|
// Red's Lab hue angle precedes blue's; grays trail, darkest first.
|
||||||
|
assertThat(curated.map { it.key })
|
||||||
|
.containsExactly("red", "blue", "black", "gray")
|
||||||
|
.inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `CSS3 survivors cover the full hue range`() {
|
||||||
|
val curated = css3Palette().curatedForPicker().map { it.key }.toSet()
|
||||||
|
|
||||||
|
// One representative from every major hue family must survive thinning.
|
||||||
|
assertThat(curated).contains("red")
|
||||||
|
assertThat(curated).contains("yellow")
|
||||||
|
assertThat(curated).contains("lime")
|
||||||
|
assertThat(curated).contains("blue")
|
||||||
|
assertThat(curated).contains("black")
|
||||||
|
assertThat(curated.any { it.contains("orange") }).isTrue()
|
||||||
|
assertThat(curated.any { it.contains("pink") }).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hsvArgb(hue: Float): Int {
|
||||||
|
val h = hue / 60f
|
||||||
|
val sector = h.toInt() % 6
|
||||||
|
val f = h - h.toInt()
|
||||||
|
val q = ((1 - f) * 255).toInt()
|
||||||
|
val t = (f * 255).toInt()
|
||||||
|
return when (sector) {
|
||||||
|
0 -> argb(255, t, 0)
|
||||||
|
1 -> argb(q, 255, 0)
|
||||||
|
2 -> argb(0, 255, t)
|
||||||
|
3 -> argb(0, q, 255)
|
||||||
|
4 -> argb(t, 0, 255)
|
||||||
|
else -> argb(255, 0, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun argb(r: Int, g: Int, b: Int): Int =
|
||||||
|
(0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
|
||||||
|
/** The exact set ical4android/DAVx5 publishes: CSS3's 147 named colours. */
|
||||||
|
private fun css3Palette(): List<EventColorOption> = CSS3.map { (name, rgb) ->
|
||||||
|
EventColorOption(name, 0xFF000000.toInt() or rgb)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val CSS3 = mapOf(
|
||||||
|
"aliceblue" to 0xF0F8FF, "antiquewhite" to 0xFAEBD7, "aqua" to 0x00FFFF,
|
||||||
|
"aquamarine" to 0x7FFFD4, "azure" to 0xF0FFFF, "beige" to 0xF5F5DC,
|
||||||
|
"bisque" to 0xFFE4C4, "black" to 0x000000, "blanchedalmond" to 0xFFEBCD,
|
||||||
|
"blue" to 0x0000FF, "blueviolet" to 0x8A2BE2, "brown" to 0xA52A2A,
|
||||||
|
"burlywood" to 0xDEB887, "cadetblue" to 0x5F9EA0, "chartreuse" to 0x7FFF00,
|
||||||
|
"chocolate" to 0xD2691E, "coral" to 0xFF7F50, "cornflowerblue" to 0x6495ED,
|
||||||
|
"cornsilk" to 0xFFF8DC, "crimson" to 0xDC143C, "cyan" to 0x00FFFF,
|
||||||
|
"darkblue" to 0x00008B, "darkcyan" to 0x008B8B, "darkgoldenrod" to 0xB8860B,
|
||||||
|
"darkgray" to 0xA9A9A9, "darkgreen" to 0x006400, "darkgrey" to 0xA9A9A9,
|
||||||
|
"darkkhaki" to 0xBDB76B, "darkmagenta" to 0x8B008B, "darkolivegreen" to 0x556B2F,
|
||||||
|
"darkorange" to 0xFF8C00, "darkorchid" to 0x9932CC, "darkred" to 0x8B0000,
|
||||||
|
"darksalmon" to 0xE9967A, "darkseagreen" to 0x8FBC8F, "darkslateblue" to 0x483D8B,
|
||||||
|
"darkslategray" to 0x2F4F4F, "darkslategrey" to 0x2F4F4F, "darkturquoise" to 0x00CED1,
|
||||||
|
"darkviolet" to 0x9400D3, "deeppink" to 0xFF1493, "deepskyblue" to 0x00BFFF,
|
||||||
|
"dimgray" to 0x696969, "dimgrey" to 0x696969, "dodgerblue" to 0x1E90FF,
|
||||||
|
"firebrick" to 0xB22222, "floralwhite" to 0xFFFAF0, "forestgreen" to 0x228B22,
|
||||||
|
"fuchsia" to 0xFF00FF, "gainsboro" to 0xDCDCDC, "ghostwhite" to 0xF8F8FF,
|
||||||
|
"gold" to 0xFFD700, "goldenrod" to 0xDAA520, "gray" to 0x808080,
|
||||||
|
"green" to 0x008000, "greenyellow" to 0xADFF2F, "grey" to 0x808080,
|
||||||
|
"honeydew" to 0xF0FFF0, "hotpink" to 0xFF69B4, "indianred" to 0xCD5C5C,
|
||||||
|
"indigo" to 0x4B0082, "ivory" to 0xFFFFF0, "khaki" to 0xF0E68C,
|
||||||
|
"lavender" to 0xE6E6FA, "lavenderblush" to 0xFFF0F5, "lawngreen" to 0x7CFC00,
|
||||||
|
"lemonchiffon" to 0xFFFACD, "lightblue" to 0xADD8E6, "lightcoral" to 0xF08080,
|
||||||
|
"lightcyan" to 0xE0FFFF, "lightgoldenrodyellow" to 0xFAFAD2, "lightgray" to 0xD3D3D3,
|
||||||
|
"lightgreen" to 0x90EE90, "lightgrey" to 0xD3D3D3, "lightpink" to 0xFFB6C1,
|
||||||
|
"lightsalmon" to 0xFFA07A, "lightseagreen" to 0x20B2AA, "lightskyblue" to 0x87CEFA,
|
||||||
|
"lightslategray" to 0x778899, "lightslategrey" to 0x778899, "lightsteelblue" to 0xB0C4DE,
|
||||||
|
"lightyellow" to 0xFFFFE0, "lime" to 0x00FF00, "limegreen" to 0x32CD32,
|
||||||
|
"linen" to 0xFAF0E6, "magenta" to 0xFF00FF, "maroon" to 0x800000,
|
||||||
|
"mediumaquamarine" to 0x66CDAA, "mediumblue" to 0x0000CD, "mediumorchid" to 0xBA55D3,
|
||||||
|
"mediumpurple" to 0x9370DB, "mediumseagreen" to 0x3CB371, "mediumslateblue" to 0x7B68EE,
|
||||||
|
"mediumspringgreen" to 0x00FA9A, "mediumturquoise" to 0x48D1CC,
|
||||||
|
"mediumvioletred" to 0xC71585, "midnightblue" to 0x191970, "mintcream" to 0xF5FFFA,
|
||||||
|
"mistyrose" to 0xFFE4E1, "moccasin" to 0xFFE4B5, "navajowhite" to 0xFFDEAD,
|
||||||
|
"navy" to 0x000080, "oldlace" to 0xFDF5E6, "olive" to 0x808000,
|
||||||
|
"olivedrab" to 0x6B8E23, "orange" to 0xFFA500, "orangered" to 0xFF4500,
|
||||||
|
"orchid" to 0xDA70D6, "palegoldenrod" to 0xEEE8AA, "palegreen" to 0x98FB98,
|
||||||
|
"paleturquoise" to 0xAFEEEE, "palevioletred" to 0xDB7093, "papayawhip" to 0xFFEFD5,
|
||||||
|
"peachpuff" to 0xFFDAB9, "peru" to 0xCD853F, "pink" to 0xFFC0CB,
|
||||||
|
"plum" to 0xDDA0DD, "powderblue" to 0xB0E0E6, "purple" to 0x800080,
|
||||||
|
"red" to 0xFF0000, "rosybrown" to 0xBC8F8F, "royalblue" to 0x4169E1,
|
||||||
|
"saddlebrown" to 0x8B4513, "salmon" to 0xFA8072, "sandybrown" to 0xF4A460,
|
||||||
|
"seagreen" to 0x2E8B57, "seashell" to 0xFFF5EE, "sienna" to 0xA0522D,
|
||||||
|
"silver" to 0xC0C0C0, "skyblue" to 0x87CEEB, "slateblue" to 0x6A5ACD,
|
||||||
|
"slategray" to 0x708090, "slategrey" to 0x708090, "snow" to 0xFFFAFA,
|
||||||
|
"springgreen" to 0x00FF7F, "steelblue" to 0x4682B4, "tan" to 0xD2B48C,
|
||||||
|
"teal" to 0x008080, "thistle" to 0xD8BFD8, "tomato" to 0xFF6347,
|
||||||
|
"turquoise" to 0x40E0D0, "violet" to 0xEE82EE, "wheat" to 0xF5DEB3,
|
||||||
|
"white" to 0xFFFFFF, "whitesmoke" to 0xF5F5F5, "yellow" to 0xFFFF00,
|
||||||
|
"yellowgreen" to 0x9ACD32,
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user