@@ -0,0 +1,362 @@
1+package org.telegram.messenger.multigram;
2+
3+import android.content.Context;
4+import android.util.SparseIntArray;
5+
6+import org.telegram.messenger.ApplicationLoader;
7+import org.telegram.ui.ActionBar.Theme;
8+
9+import java.io.File;
10+import java.util.HashMap;
11+import java.util.HashSet;
12+import java.util.Set;
13+
14+/**
15+ * Palette fix for generated accents ("overlay A", see multigram/palette-fix/README.md).
16+ *
17+ * Stock Telegram colours already fail WCAG contrast on many text/background pairs, so a randomly
18+ * generated accent is only readable on top of this fix. It has three parts:
19+ * <ul>
20+ * <li>static colour changes (ThemeColors defaults, bundled .attheme lines) and extra
21+ * accent-exclusion keys ({@link #addAccentExclusions}), which apply to every accent;</li>
22+ * <li>the on-accent rule: marks drawn on accent-coloured fills (compose pencil, unread digits,
23+ * filled-button text, checkmarks, send and record glyphs...) become white or
24+ * {@link #ON_ACCENT_DARK}, whichever has the higher contrast with the fill, and the muted unread
25+ * badge gets a neutral fill that passes with the chosen digit colour. It runs only for runtime
26+ * accents ({@link #isRuntimeAccent}: generated by MultiGram or made on this device), never for
27+ * stock presets, Telegram's server themes, other people's cloud themes or Monet themes
28+ * ({@link #isActiveFor}); per-chat themes follow the global state ({@link #applyToColorMap});</li>
29+ * <li>the out-bubble text guard in Theme.ThemeAccent.fillAccentColors (runtime accents only).</li>
30+ * </ul>
31+ */
32+public final class PaletteFix {
33+
34+ /**
35+ * Stock preset accents have ids up to 99 (id 100 is a custom accent migrated from very old app
36+ * versions, treated as stock). Every accent a ThemeInfo creates later gets ++lastAccentId, so 101 and
37+ * up: MultiGram's generated accents, accents the user makes in the theme editor, and also accents of
38+ * Telegram's server themes and of cloud themes, which {@link #isRuntimeAccent} excludes by their info.
39+ */
40+ public static final int LAST_STOCK_ACCENT_ID = 100;
41+
42+ public static final int ON_ACCENT_LIGHT = 0xffffffff;
43+ /** The lightest near-black for which max(contrast(white), contrast(this)) >= 4.5 on any fill (min 4.51). */
44+ public static final int ON_ACCENT_DARK = 0xff050505;
45+
46+ /** Contrast targets with headroom over WCAG 4.5 (text) and 3.0 (icons) for accent solvers. */
47+ public static final double TARGET_TEXT_CONTRAST = 4.6;
48+ public static final double TARGET_ICON_CONTRAST = 3.1;
49+
50+ private static final double[] SRGB_LIN = new double[256];
51+ /** Theme key -> {muted badge fill with white digits, muted badge fill with near-black digits}. */
52+ private static final HashMap<String, int[]> MUTED_FILL = new HashMap<>();
53+ /** Bundled assets whose cached copy was already checked in this process ({@link #isStaleAssetCopy}). */
54+ private static final HashSet<String> CHECKED_ASSETS = new HashSet<>();
55+ private static long appUpdateTime = -1;
56+
57+ static {
58+ for (int i = 0; i < 256; i++) {
59+ double c = i / 255.0;
60+ SRGB_LIN[i] = c <= 0.04045 ? c / 12.92 : StrictMath.pow((c + 0.055) / 1.055, 2.4);
61+ }
62+ int[] light = {0xff73777b, 0xffbec3c7};
63+ MUTED_FILL.put("Blue", light);
64+ MUTED_FILL.put("Arctic Blue", light);
65+ MUTED_FILL.put("Day", light);
66+ MUTED_FILL.put("Dark Blue", new int[]{0xff3e5263, 0xff63798b});
67+ MUTED_FILL.put("Night", new int[]{0xff454545, 0xff777676});
68+ }
69+
70+ /**
71+ * Theme.key_* are assigned with colorsCount++ in Theme's static initializer, so they are not
72+ * compile-time constants. This holder is initialised on first use, after those fields are set.
73+ */
74+ private static final class Keys {
75+ /** (content, fill) pairs. */
76+ static final int[] ON_ACCENT = {
77+ Theme.key_chats_actionIcon, Theme.key_chats_actionBackground,
78+ Theme.key_chats_unreadCounterText, Theme.key_chats_unreadCounter,
79+ Theme.key_featuredStickers_buttonText, Theme.key_featuredStickers_addButton,
80+ Theme.key_checkboxSquareCheck, Theme.key_checkboxSquareBackground,
81+ Theme.key_dialogRoundCheckBoxCheck, Theme.key_dialogRoundCheckBox,
82+ Theme.key_chat_goDownButtonCounter, Theme.key_chat_goDownButtonCounterBackground,
83+ Theme.key_dialogCheckboxSquareCheck, Theme.key_dialogCheckboxSquareBackground,
84+ Theme.key_chat_attachCheckBoxCheck, Theme.key_chat_attachCheckBoxBackground,
85+ Theme.key_chat_inMediaIcon, Theme.key_chat_inLoader,
86+ Theme.key_chat_inContactIcon, Theme.key_chat_inContactBackground,
87+ Theme.key_picker_badgeText, Theme.key_picker_badge,
88+ Theme.key_dialogFloatingIcon, Theme.key_dialogFloatingButton,
89+ // not in overlay A: mic/camera/send glyphs of the voice-record circle and the
90+ // send-as menu button, drawn on chat_messagePanelVoiceBackground
91+ Theme.key_chat_messagePanelVoicePressed, Theme.key_chat_messagePanelVoiceBackground
92+ };
93+ /** Keys that fall back to chats_unreadCounterMuted; they keep their colour when it changes. */
94+ static final int[] MUTED_PINS = {
95+ Theme.key_avatar_backgroundArchived,
96+ Theme.key_chats_archivePinBackground,
97+ Theme.key_chats_archivePullDownBackground,
98+ Theme.key_checkboxDisabled
99+ };
100+ /** content key -> fill key. */
101+ static final SparseIntArray FILL_OF = new SparseIntArray();
102+
103+ static {
104+ for (int i = 0; i < ON_ACCENT.length; i += 2) {
105+ FILL_OF.put(ON_ACCENT[i], ON_ACCENT[i + 1]);
106+ }
107+ }
108+ }
109+
110+ private static volatile boolean globalActive;
111+
112+ private PaletteFix() {
113+ }
114+
115+ /**
116+ * Keys that must not follow the accent re-tint (neutral greys and the grey settings background).
117+ * Called once from Theme's static initializer with Theme.themeAccentExclusionKeys.
118+ */
119+ public static void addAccentExclusions(Set<Integer> exclusions) {
120+ exclusions.add(Theme.key_windowBackgroundGray);
121+ exclusions.add(Theme.key_windowBackgroundWhiteGrayText);
122+ exclusions.add(Theme.key_windowBackgroundWhiteGrayText2);
123+ exclusions.add(Theme.key_windowBackgroundWhiteGrayText4);
124+ exclusions.add(Theme.key_chats_message);
125+ exclusions.add(Theme.key_chats_date);
126+ exclusions.add(Theme.key_actionBarDefaultSubtitle);
127+ exclusions.add(Theme.key_chat_inTimeText);
128+ exclusions.add(Theme.key_chat_inFileInfoText);
129+ exclusions.add(Theme.key_chat_inViews);
130+ exclusions.add(Theme.key_chat_messagePanelHint);
131+ exclusions.add(Theme.key_chat_messagePanelIcons);
132+ exclusions.add(Theme.key_chat_topPanelMessage);
133+ }
134+
135+ /**
136+ * Whether the accent was generated by MultiGram or made by the user on this device: id above
137+ * {@link #LAST_STOCK_ACCENT_ID} and no server theme behind it, or a server theme this user created
138+ * (a custom accent keeps the rule after the user shares it). Accents of Telegram's own server themes
139+ * (Chat Settings carousel, account.getThemes) and of cloud themes installed from someone else's
140+ * link also get ids above 100 but carry their TL_theme in accent.info, so they stay stock.
141+ * Also the guard of the out-bubble black/white text block in Theme.ThemeAccent.fillAccentColors.
142+ */
143+ public static boolean isRuntimeAccent(Theme.ThemeAccent accent) {
144+ return accent != null && accent.id > LAST_STOCK_ACCENT_ID && (accent.info == null || accent.info.creator);
145+ }
146+
147+ /**
148+ * Whether the on-accent rule applies to colours built from this accent of this theme: the accent is
149+ * a runtime one ({@link #isRuntimeAccent}), the theme is not a Monet theme, and Theme.getColor would
150+ * not serve the stock defaults for it anyway (on Blue, an accent with the default accent colour, see
151+ * ThemeInfo.isDefaultMainAccent). Stock presets, server themes and chat-theme accents give false.
152+ *
153+ * @param theme the theme the colours are built for, or null to use accent.parentTheme
154+ */
155+ public static boolean isActiveFor(Theme.ThemeInfo theme, Theme.ThemeAccent accent) {
156+ if (!isRuntimeAccent(accent)) {
157+ return false;
158+ }
159+ if (theme == null) {
160+ theme = accent.parentTheme;
161+ }
162+ if (theme == null) {
163+ return true;
164+ }
165+ if (theme.isMonet()) {
166+ return false;
167+ }
168+ if (theme.firstAccentIsDefault && theme.themeAccentsMap != null) {
169+ Theme.ThemeAccent defaultAccent = theme.themeAccentsMap.get(Theme.DEFALT_THEME_ACCENT_ID);
170+ if (defaultAccent != null && defaultAccent.accentColor == accent.accentColor) {
171+ return false;
172+ }
173+ }
174+ return true;
175+ }
176+
177+ /** Whether the global colours (Theme.getColor) currently carry the on-accent rule. */
178+ public static boolean isGlobalActive() {
179+ return globalActive;
180+ }
181+
182+ /**
183+ * Global hook. Called at the end of Theme.refreshThemeColors, after fillAccentColors and the
184+ * calculated colours, with the new currentColors. Fills are read through
185+ * Theme.getColor(key, null, true), so theme-switch animation values are ignored.
186+ */
187+ public static void applyToCurrentColors(Theme.ThemeInfo theme, Theme.ThemeAccent accent, SparseIntArray currentColors) {
188+ boolean active = isActiveFor(theme, accent);
189+ globalActive = active;
190+ if (active) {
191+ apply(theme, currentColors, true);
192+ }
193+ }
194+
195+ /**
196+ * Map-based hook. Call right after accent.fillAccentColors(noAccent, colors) wherever a
197+ * standalone colour map is built for a theme (chat themes, previews, exported theme files).
198+ * Missing keys resolve like Theme.getColor on that map (fallback key, then default colour).
199+ * <ul>
200+ * <li>A map of an installed theme's accent gets the rule iff {@link #isActiveFor}(theme, accent),
201+ * i.e. exactly when that accent gets it globally, so maps of stock accents stay stock.</li>
202+ * <li>A per-chat theme (an accent in theme.chatAccentsByThemeId, built by EmojiThemes.createColors)
203+ * gets the rule iff the global rule is active and the map is for the current light/dark mode,
204+ * so a themed chat's marks follow the user's style: stock when the user's accent is stock,
205+ * readable on the chat theme's own fills when the style is generated.</li>
206+ * </ul>
207+ */
208+ public static void applyToColorMap(Theme.ThemeInfo theme, Theme.ThemeAccent accent, SparseIntArray colors) {
209+ if (colors == null || accent == null) {
210+ return;
211+ }
212+ if (theme == null) {
213+ theme = accent.parentTheme;
214+ }
215+ if (isActiveFor(theme, accent) || isChatThemeAccent(theme, accent) && isGlobalActiveFor(theme)) {
216+ apply(theme, colors, false);
217+ }
218+ }
219+
220+ private static boolean isChatThemeAccent(Theme.ThemeInfo theme, Theme.ThemeAccent accent) {
221+ return theme != null && theme.chatAccentsByThemeId != null && theme.chatAccentsByThemeId.indexOfValue(accent) >= 0;
222+ }
223+
224+ private static boolean isGlobalActiveFor(Theme.ThemeInfo theme) {
225+ if (!globalActive || theme.isMonet()) {
226+ return false;
227+ }
228+ Theme.ThemeInfo active = Theme.getActiveTheme();
229+ return active != null && active.isDark() == theme.isDark();
230+ }
231+
232+ /**
233+ * For upstream code that draws a glyph or text in a fixed colour (usually white) on an accent fill,
234+ * such as the send buttons (ChatActivityEnterView.SendButton). Returns 0, meaning "keep the stock
235+ * colour", unless the global on-accent rule is active; then white or {@link #ON_ACCENT_DARK},
236+ * whichever contrasts more with the fill.
237+ *
238+ * @param fill the colour the glyph is drawn on, or 0 when it is not drawn on a fill
239+ */
240+ public static int getGlyphColorOnFill(int fill) {
241+ if (!globalActive || fill == 0) {
242+ return 0;
243+ }
244+ return onAccentContent(fill, Theme.getColor(Theme.key_windowBackgroundWhite) | 0xff000000);
245+ }
246+
247+ /**
248+ * Theme.getAssetFile hook: whether the copy of a bundled asset cached in filesDir is older than the
249+ * installed app. getAssetFile re-copies an asset only when its size changes, and the palette fix
250+ * changes some .attheme values without changing the file size (arctic, bluebubbles), so an update
251+ * over an older build would keep the old values. Answers true at most once per file and process,
252+ * so a wrong device clock costs one extra copy per start at worst.
253+ */
254+ public static boolean isStaleAssetCopy(File file) {
255+ synchronized (CHECKED_ASSETS) {
256+ if (!CHECKED_ASSETS.add(file.getName())) {
257+ return false;
258+ }
259+ if (appUpdateTime < 0) {
260+ try {
261+ Context context = ApplicationLoader.applicationContext;
262+ appUpdateTime = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).lastUpdateTime;
263+ } catch (Throwable e) {
264+ appUpdateTime = 0;
265+ }
266+ }
267+ return file.exists() && file.lastModified() < appUpdateTime;
268+ }
269+ }
270+
271+ /**
272+ * For resources providers that override some colours on top of the global theme: returns the
273+ * colour for a key the override map does not contain. When the global on-accent rule is active
274+ * and the map overrides the fill under this content key, the content is recomputed for that fill
275+ * instead of taking the global value (which was chosen for a different fill).
276+ */
277+ public static int getOverrideFallbackColor(SparseIntArray overrides, int key) {
278+ if (globalActive && overrides != null) {
279+ int fill = Keys.FILL_OF.get(key, -1);
280+ if (fill != -1) {
281+ int index = overrides.indexOfKey(fill);
282+ if (index >= 0) {
283+ int pageIndex = overrides.indexOfKey(Theme.key_windowBackgroundWhite);
284+ int page = pageIndex >= 0 ? overrides.valueAt(pageIndex) : Theme.getColor(Theme.key_windowBackgroundWhite);
285+ return onAccentContent(overrides.valueAt(index), page | 0xff000000);
286+ }
287+ }
288+ }
289+ return Theme.getColor(key);
290+ }
291+
292+ private static void apply(Theme.ThemeInfo theme, SparseIntArray colors, boolean global) {
293+ int[] pairs = Keys.ON_ACCENT;
294+ int page = resolve(colors, Theme.key_windowBackgroundWhite, global) | 0xff000000;
295+ int[] content = new int[pairs.length / 2];
296+ for (int i = 0; i < pairs.length; i += 2) {
297+ content[i / 2] = onAccentContent(resolve(colors, pairs[i + 1], global), page);
298+ }
299+ for (int i = 0; i < pairs.length; i += 2) {
300+ colors.put(pairs[i], content[i / 2]);
301+ }
302+ int[] muted = theme != null ? MUTED_FILL.get(theme.getKey()) : null;
303+ if (muted != null) {
304+ for (int key : Keys.MUTED_PINS) {
305+ if (colors.indexOfKey(key) < 0) {
306+ colors.put(key, resolve(colors, key, global));
307+ }
308+ }
309+ boolean whiteDigits = colors.get(Theme.key_chats_unreadCounterText) == ON_ACCENT_LIGHT;
310+ colors.put(Theme.key_chats_unreadCounterMuted, whiteDigits ? muted[0] : muted[1]);
311+ }
312+ }
313+
314+ private static int resolve(SparseIntArray colors, int key, boolean global) {
315+ if (global) {
316+ return Theme.getColor(key, null, true);
317+ }
318+ int index = colors.indexOfKey(key);
319+ if (index >= 0) {
320+ return colors.valueAt(index);
321+ }
322+ int fallbackKey = Theme.getFallbackKeys().get(key, -1);
323+ if (fallbackKey != -1) {
324+ index = colors.indexOfKey(fallbackKey);
325+ if (index >= 0) {
326+ return colors.valueAt(index);
327+ }
328+ }
329+ return Theme.getDefaultColor(key);
330+ }
331+
332+ /** White or {@link #ON_ACCENT_DARK}, whichever contrasts more with fill composited over page; ties go to white. */
333+ public static int onAccentContent(int fill, int page) {
334+ int f = compositeOver(fill, page);
335+ return contrast(ON_ACCENT_LIGHT, f) >= contrast(ON_ACCENT_DARK, f) ? ON_ACCENT_LIGHT : ON_ACCENT_DARK;
336+ }
337+
338+ /** Integer src-over of fg on an opaque bg, (a*x + (255-a)*y + 127) / 255 per channel; result is opaque. */
339+ public static int compositeOver(int fg, int bg) {
340+ int a = fg >>> 24;
341+ if (a == 255) {
342+ return fg;
343+ }
344+ int ia = 255 - a;
345+ int r = (((fg >> 16) & 255) * a + ((bg >> 16) & 255) * ia + 127) / 255;
346+ int g = (((fg >> 8) & 255) * a + ((bg >> 8) & 255) * ia + 127) / 255;
347+ int b = ((fg & 255) * a + (bg & 255) * ia + 127) / 255;
348+ return 0xff000000 | (r << 16) | (g << 8) | b;
349+ }
350+
351+ /** WCAG 2.2 relative luminance of the RGB part of color (alpha ignored). */
352+ public static double luminance(int color) {
353+ return 0.2126 * SRGB_LIN[(color >> 16) & 255] + 0.7152 * SRGB_LIN[(color >> 8) & 255] + 0.0722 * SRGB_LIN[color & 255];
354+ }
355+
356+ /** WCAG 2.2 contrast ratio of two opaque colours, 1.0 to 21.0. */
357+ public static double contrast(int color1, int color2) {
358+ double l1 = luminance(color1);
359+ double l2 = luminance(color2);
360+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
361+ }
362+}