@@ -0,0 +1,923 @@
1+package org.telegram.messenger.multigram;
2+
3+import android.app.Activity;
4+import android.content.Context;
5+import android.content.SharedPreferences;
6+import android.text.TextUtils;
7+
8+import org.telegram.messenger.ApplicationLoader;
9+import org.telegram.messenger.FileLog;
10+import org.telegram.messenger.MessagesController;
11+import org.telegram.messenger.NotificationCenter;
12+import org.telegram.ui.ActionBar.Theme;
13+
14+import java.nio.ByteBuffer;
15+import java.security.SecureRandom;
16+import java.util.ArrayList;
17+import java.util.LinkedHashMap;
18+import java.util.LinkedHashSet;
19+import java.util.Locale;
20+import java.util.concurrent.CopyOnWriteArrayList;
21+import java.util.zip.CRC32;
22+
23+/**
24+ * Per-install random style: every fresh install gets its own day and night style, picked by a random seed from
25+ * the bundled style table ({@link StyleTable}). Design and privacy notes: multigram/random-style/README.md.
26+ *
27+ * <ul>
28+ * <li>Stage A, {@link #onApplicationCreate}: runs once per install, before anything touches Theme. On a fresh
29+ * install it creates the seed and picks the style; installs that already have theme settings are marked
30+ * {@link #STATE_EXISTING} and are never restyled automatically.</li>
31+ * <li>Stage B, {@link #onThemeInit}: inside Theme's static initializer, after the saved accents are loaded. On a
32+ * fresh install it turns the picked style into two runtime accents (day and night) and selects them, so the
33+ * first frame already shows the style.</li>
34+ * <li>{@link #shuffle}: "Shuffle my style" in Chat Settings: new seed, new style, applied at once, with
35+ * {@link #undoShuffle} for a few seconds afterwards. {@link #resetToGeneratedStyle}: "Reset to defaults" also
36+ * returns to the install's generated style (stock Chat Settings only resets text size and bubble radius).</li>
37+ * </ul>
38+ *
39+ * The generated accents are local only: {@link #isGenerated} marks them, and the upload and share paths refuse them.
40+ * Nothing in the "rebrand_style" preferences file is ever sent anywhere.
41+ */
42+public final class RandomStyle {
43+
44+ /** SharedPreferences file of this feature. Not in SettingsBackup's export and not in Android backup. */
45+ public static final String PREFS = "rebrand_style";
46+
47+ /** Fresh install, style picked by Stage A, not yet turned into accents by Stage B. */
48+ public static final String STATE_PENDING = "pending";
49+ /** A generated style has been applied (automatically on a fresh install, or by Shuffle). */
50+ public static final String STATE_APPLIED = "applied";
51+ /** Fresh install whose automatic style could not be applied: it keeps the stock look until Shuffle. */
52+ public static final String STATE_FAILED = "failed";
53+ /** The install had theme settings before this feature: never restyled automatically, Shuffle still works. */
54+ public static final String STATE_EXISTING = "existing";
55+
56+ /** {@link Listener} reasons. */
57+ public static final int REASON_SHUFFLE = 1;
58+ public static final int REASON_RESET = 2;
59+ /** Undo after a Shuffle: the previous seed and style are back. */
60+ public static final int REASON_UNDO = 3;
61+
62+ /** Version of the pick algorithm below (seed -> table entries). */
63+ public static final int GENERATOR_VERSION = 1;
64+
65+ private static final String KEY_STATE = "state";
66+ private static final String KEY_FRESH = "fresh";
67+ private static final String KEY_SEED = "seed";
68+ private static final String KEY_VERSION = "version";
69+ private static final String KEY_TABLE_CRC = "table_crc";
70+ private static final String KEY_GENERATED = "generated";
71+ /**
72+ * Copy of the generated-accent records in themeconfig, next to the accents they describe: Forkgram's settings
73+ * export (SettingsBackup) carries themeconfig but not this file, so an imported generated accent stays marked.
74+ */
75+ private static final String THEMECONFIG_GENERATED = "multigram_generated_accents";
76+ private static final String DAY = "day";
77+ private static final String NIGHT = "night";
78+ private static final String SUFFIX_THEME = "_theme";
79+ private static final String SUFFIX_STYLE = "_style";
80+ private static final String SUFFIX_INDEX = "_index";
81+ private static final String SUFFIX_ACCENT = "_accent";
82+
83+ private static final long GOLDEN_GAMMA = 0x9E3779B97F4A7C15L;
84+ private static final long REMOVE_KEY = 0x100000000L;
85+ /** Record tag: the accent stays local whatever its colours (the accent editor may be changing them). */
86+ private static final String ANY = "*";
87+
88+ /** Notified on the UI thread after Shuffle, Undo or Reset applied a style. */
89+ public interface Listener {
90+ void onStyleChanged(long seed, int reason);
91+ }
92+
93+ private static final CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
94+
95+ /** What {@link #undoShuffle} returns to; memory only, because Undo is offered for a few seconds after a Shuffle. */
96+ private static Previous undo;
97+
98+ private RandomStyle() {
99+ }
100+
101+ // ---------------------------------------------------------------------------------------------
102+ // Queries (safe before Theme is initialised)
103+ // ---------------------------------------------------------------------------------------------
104+
105+ /** True once this install has a seed: it was born with a generated style, or the user pressed Shuffle. */
106+ public static boolean hasSeed() {
107+ return prefs().contains(KEY_SEED);
108+ }
109+
110+ /** The install's current style seed (changes on Shuffle); 0 when {@link #hasSeed()} is false. */
111+ public static long getSeed() {
112+ return prefs().getLong(KEY_SEED, 0);
113+ }
114+
115+ /**
116+ * True when Stage A found no theme settings, i.e. this install gets the generated style automatically.
117+ * False for installs that existed before this feature (they are never restyled without Shuffle).
118+ */
119+ public static boolean isFreshInstall() {
120+ return prefs().getBoolean(KEY_FRESH, false);
121+ }
122+
123+ /** One of the STATE_* values, or null before Stage A ran. */
124+ public static String getState() {
125+ return prefs().getString(KEY_STATE, null);
126+ }
127+
128+ /** True when this install has a generated style that Reset returns to. */
129+ public static boolean hasGeneratedStyle() {
130+ SharedPreferences p = prefs();
131+ return STATE_APPLIED.equals(p.getString(KEY_STATE, null)) && loadStyle(p, false) != null && loadStyle(p, true) != null;
132+ }
133+
134+ /** The install's current day (night = false) or night style, or null. */
135+ public static StyleTable.Entry getStyle(boolean night) {
136+ return loadStyle(prefs(), night);
137+ }
138+
139+ /**
140+ * An independent 64-bit value for one knob, derived from the seed and the knob's name (FNV-1a 64 of the name's
141+ * UTF-16 units, XOR the seed, then the SplitMix64 finaliser). Deterministic, so the same seed always gives the
142+ * same knob value.
143+ */
144+ public static long deriveSeed(long seed, String knob) {
145+ long h = 0xcbf29ce484222325L;
146+ for (int i = 0; i < knob.length(); i++) {
147+ h ^= knob.charAt(i);
148+ h *= 0x100000001b3L;
149+ }
150+ return mix64(seed ^ h);
151+ }
152+
153+ public static void addListener(Listener listener) {
154+ listeners.addIfAbsent(listener);
155+ }
156+
157+ public static void removeListener(Listener listener) {
158+ listeners.remove(listener);
159+ }
160+
161+ // ---------------------------------------------------------------------------------------------
162+ // Privacy
163+ // ---------------------------------------------------------------------------------------------
164+
165+ /**
166+ * True for an accent this install generated, every later edit of it and every copy made from it in the accent
167+ * editor: such accents never leave the device. Recognised by a record of its theme key and id that also holds
168+ * its colours' fingerprint (or {@link #ANY} while the editor may change them), so a different accent that got
169+ * the same id, for example from a settings import, is not mistaken for it.
170+ */
171+ public static boolean isGenerated(Theme.ThemeAccent accent) {
172+ if (accent == null || accent.info != null || accent.id <= PaletteFix.LAST_STOCK_ACCENT_ID || accent.parentTheme == null) {
173+ return false;
174+ }
175+ try {
176+ Record r = readRecords(prefs()).get(recordKey(accent.parentTheme.getKey(), accent.id));
177+ return r != null && (r.tags.contains(ANY) || r.tags.contains(fingerprint(accent)));
178+ } catch (Throwable e) {
179+ FileLog.e(e);
180+ return true; // unsure: keep it on the device
181+ }
182+ }
183+
184+ /**
185+ * ThemePreviewActivity hook, right after the accent editor got its accent (copied = a new accent, otherwise the
186+ * current accent is edited in place). A new accent ("+" in the accent list) starts as a copy of the theme's
187+ * current accent (Theme.ThemeInfo.getAccent(true) sets prevAccentId to it), and saving uploads it. A copy of a
188+ * generated accent, and a generated accent opened for editing, are therefore marked {@link #ANY}: they stay on
189+ * the device whatever colours the editor gives them. The save adds the new fingerprint ({@link #isUploadBlocked})
190+ * and the next start narrows the record to the colours on disk. Never throws.
191+ */
192+ public static void onAccentCopied(Theme.ThemeInfo theme, Theme.ThemeAccent accent, boolean copied) {
193+ try {
194+ if (theme == null || accent == null) {
195+ return;
196+ }
197+ if (copied) {
198+ if (theme.themeAccentsMap == null || theme.prevAccentId == -1) {
199+ return;
200+ }
201+ Theme.ThemeAccent source = theme.themeAccentsMap.get(theme.prevAccentId);
202+ if (source == null || source == accent || !isGenerated(source)) {
203+ return;
204+ }
205+ } else if (!isGenerated(accent)) {
206+ return;
207+ }
208+ SharedPreferences p = prefs();
209+ LinkedHashMap<String, Record> records = readRecords(p);
210+ if (addTag(records, theme, accent, ANY)) {
211+ writeRecords(p, records, true);
212+ }
213+ } catch (Throwable e) {
214+ FileLog.e(e);
215+ }
216+ }
217+
218+ /**
219+ * MessagesController.saveThemeToServer hook: true when the upload must not happen. Saving an edited accent
220+ * reaches it too, so it also records the accent's colours as they are now.
221+ */
222+ public static boolean isUploadBlocked(Theme.ThemeInfo themeInfo, Theme.ThemeAccent accent) {
223+ if (!isGenerated(accent)) {
224+ return false;
225+ }
226+ try {
227+ SharedPreferences p = prefs();
228+ LinkedHashMap<String, Record> records = readRecords(p);
229+ if (addTag(records, accent.parentTheme, accent, fingerprint(accent))) {
230+ writeRecords(p, records, true);
231+ }
232+ } catch (Throwable e) {
233+ FileLog.e(e);
234+ }
235+ FileLog.d("MultiGram: a generated style stays on this device; theme upload skipped");
236+ return true;
237+ }
238+
239+ // ---------------------------------------------------------------------------------------------
240+ // Stage A: ApplicationLoader.onCreate
241+ // ---------------------------------------------------------------------------------------------
242+
243+ /**
244+ * Classifies the install once (fresh or existing) and, on a fresh install, creates the seed and picks the style.
245+ * Must run before anything touches Theme. Never throws.
246+ */
247+ public static void onApplicationCreate(Context context) {
248+ try {
249+ SharedPreferences p = context.getSharedPreferences(PREFS, Activity.MODE_PRIVATE);
250+ if (p.contains(KEY_STATE)) {
251+ return;
252+ }
253+ SharedPreferences.Editor editor = p.edit();
254+ if (hasThemeSettings(context)) {
255+ editor.putBoolean(KEY_FRESH, false).putString(KEY_STATE, STATE_EXISTING);
256+ } else {
257+ long seed = new SecureRandom().nextLong();
258+ editor.putBoolean(KEY_FRESH, true).putLong(KEY_SEED, seed).putInt(KEY_VERSION, GENERATOR_VERSION);
259+ boolean picked = false;
260+ try {
261+ picked = pick(StyleTable.load(context), seed, editor);
262+ } catch (Throwable e) {
263+ FileLog.e(e);
264+ }
265+ editor.putString(KEY_STATE, picked ? STATE_PENDING : STATE_FAILED);
266+ }
267+ editor.commit();
268+ } catch (Throwable e) {
269+ FileLog.e(e);
270+ }
271+ }
272+
273+ /**
274+ * Whether the install already has theme settings. Theme writes themeconfig 'remote_version' on every start, so
275+ * any themeconfig key means Theme has run before; mainconfig 'theme' / 'nighttheme' are the saved theme choices.
276+ */
277+ private static boolean hasThemeSettings(Context context) {
278+ SharedPreferences themeConfig = context.getSharedPreferences("themeconfig", Activity.MODE_PRIVATE);
279+ if (!themeConfig.getAll().isEmpty()) {
280+ return true;
281+ }
282+ SharedPreferences mainConfig = context.getSharedPreferences("mainconfig", Activity.MODE_PRIVATE);
283+ return mainConfig.contains("theme") || mainConfig.contains("nighttheme") || mainConfig.contains("selectedAutoNightType");
284+ }
285+
286+ // ---------------------------------------------------------------------------------------------
287+ // Stage B: Theme's static initializer
288+ // ---------------------------------------------------------------------------------------------
289+
290+ /**
291+ * Called by Theme's static initializer after the saved accents are loaded and before the auto-night settings
292+ * are read. On a fresh install it creates and selects the generated day and night accents and returns the day
293+ * theme to apply; otherwise it returns applyingTheme unchanged. Never throws: on any error the install keeps
294+ * the stock style.
295+ */
296+ public static Theme.ThemeInfo onThemeInit(Theme.ThemeInfo applyingTheme) {
297+ try {
298+ SharedPreferences p = prefs();
299+ pruneRecords(p, true);
300+ if (!STATE_PENDING.equals(p.getString(KEY_STATE, null))) {
301+ return applyingTheme;
302+ }
303+ if (Theme.getActiveTheme() != null || Theme.getCurrentNightTheme() == null) {
304+ return applyingTheme; // not inside Theme's initializer as expected (nothing may be applied yet)
305+ }
306+ // Decided once: if anything below fails (even fatally) the next start keeps the stock style.
307+ p.edit().putString(KEY_STATE, STATE_FAILED).commit();
308+
309+ Pair pair = createPair(p, loadStyle(p, false), loadStyle(p, true), true);
310+ if (pair == null) {
311+ return applyingTheme;
312+ }
313+ Theme.setCurrentNightTheme(pair.nightTheme); // no visible effect: nothing is applied yet
314+ ApplicationLoader.applicationContext.getSharedPreferences("mainconfig", Activity.MODE_PRIVATE).edit()
315+ .putString("theme", pair.dayTheme.getKey())
316+ .putString("nighttheme", pair.nightTheme.getKey())
317+ .apply();
318+ saveSelection(pair);
319+ putPair(p.edit(), pair).putString(KEY_STATE, STATE_APPLIED).commit();
320+ return pair.dayTheme;
321+ } catch (Throwable e) {
322+ FileLog.e(e);
323+ return applyingTheme;
324+ }
325+ }
326+
327+ // ---------------------------------------------------------------------------------------------
328+ // Shuffle and Reset (UI thread)
329+ // ---------------------------------------------------------------------------------------------
330+
331+ /**
332+ * "Shuffle my style": a new seed, a new day and night style, applied at once (the variant that is showing is
333+ * animated in, the other one is switched silently). The previous generated accents are removed unless the user
334+ * edited them. Custom themes and the user's own accents are never touched. When the generated style was showing,
335+ * {@link #undoShuffle} can bring it back afterwards ({@link #canUndoShuffle}). Returns false on failure, in which
336+ * case nothing changed.
337+ */
338+ public static boolean shuffle(Context context) {
339+ try {
340+ StyleTable table = StyleTable.load(context);
341+ long seed = new SecureRandom().nextLong();
342+ int dayIndex = pickIndex(seed, false, table.getCount(false));
343+ int nightIndex = pickIndex(seed, true, table.getCount(true));
344+ StyleTable.Entry day = table.get(false, dayIndex);
345+ StyleTable.Entry night = table.get(true, nightIndex);
346+ SharedPreferences p = prefs();
347+ Previous previous = generatedStyleShowing(p) ? capture(p) : null;
348+ Pair pair = replacePair(p, day, night);
349+ if (pair == null) {
350+ return false;
351+ }
352+ SharedPreferences.Editor editor = p.edit()
353+ .putLong(KEY_SEED, seed)
354+ .putInt(KEY_VERSION, GENERATOR_VERSION)
355+ .putInt(KEY_TABLE_CRC, table.getCrc());
356+ putStyle(editor, DAY, day, dayIndex);
357+ putStyle(editor, NIGHT, night, nightIndex);
358+ putPair(editor, pair).putString(KEY_STATE, STATE_APPLIED).commit();
359+ if (previous != null) {
360+ previous.shuffledSeed = seed;
361+ }
362+ undo = previous;
363+ show(pair);
364+ notifyListeners(seed, REASON_SHUFFLE);
365+ return true;
366+ } catch (Throwable e) {
367+ FileLog.e(e);
368+ return false;
369+ }
370+ }
371+
372+ /** True right after a Shuffle that replaced the generated style that was showing: {@link #undoShuffle} brings it back. */
373+ public static boolean canUndoShuffle() {
374+ Previous u = undo;
375+ return u != null && prefs().getLong(KEY_SEED, 0) == u.shuffledSeed;
376+ }
377+
378+ /**
379+ * Undo for the last Shuffle: the previous seed and generated style come back (as new accents; the shuffled ones
380+ * are removed unless edited). Returns false, with nothing changed, when there is nothing to undo or it failed.
381+ */
382+ public static boolean undoShuffle() {
383+ Previous u = undo;
384+ undo = null;
385+ if (u == null) {
386+ return false;
387+ }
388+ try {
389+ SharedPreferences p = prefs();
390+ if (p.getLong(KEY_SEED, 0) != u.shuffledSeed || !STATE_APPLIED.equals(p.getString(KEY_STATE, null))) {
391+ return false;
392+ }
393+ Pair pair = replacePair(p, u.day, u.night);
394+ if (pair == null) {
395+ return false;
396+ }
397+ SharedPreferences.Editor editor = p.edit()
398+ .putLong(KEY_SEED, u.seed)
399+ .putInt(KEY_VERSION, u.version)
400+ .putInt(KEY_TABLE_CRC, u.tableCrc);
401+ putStyle(editor, DAY, u.day, u.dayIndex);
402+ putStyle(editor, NIGHT, u.night, u.nightIndex);
403+ putPair(editor, pair).commit();
404+ show(pair);
405+ notifyListeners(u.seed, REASON_UNDO);
406+ return true;
407+ } catch (Throwable e) {
408+ FileLog.e(e);
409+ return false;
410+ }
411+ }
412+
413+ /**
414+ * "Reset to defaults" in Chat Settings. Stock Chat Settings only resets text size and bubble radius there (its
415+ * colour branch needs the theme list, which that screen no longer has), so this adds the colours: back to this
416+ * install's generated style (the one the seed picked), recreating its accents so that edits made to them are
417+ * left alone. It leaves a custom, cloud or Monet theme the user chose alone. Returns false, with nothing changed,
418+ * when the install has no generated style or such a theme is selected.
419+ */
420+ public static boolean resetToGeneratedStyle() {
421+ try {
422+ undo = null;
423+ if (!hasGeneratedStyle() || !isColorTheme(Theme.getCurrentTheme()) || !isColorTheme(Theme.getCurrentNightTheme())
424+ || !isColorTheme(Theme.getActiveTheme())) {
425+ return false;
426+ }
427+ SharedPreferences p = prefs();
428+ Pair pair = replacePair(p, loadStyle(p, false), loadStyle(p, true));
429+ if (pair == null) {
430+ return false;
431+ }
432+ putPair(p.edit(), pair).commit();
433+ show(pair);
434+ notifyListeners(p.getLong(KEY_SEED, 0), REASON_RESET);
435+ return true;
436+ } catch (Throwable e) {
437+ FileLog.e(e);
438+ return false;
439+ }
440+ }
441+
442+ /** A bundled colour theme (Blue, Day, Dark Blue, ...), not a custom or cloud theme file and not Monet. */
443+ private static boolean isColorTheme(Theme.ThemeInfo theme) {
444+ return theme != null && theme.assetName != null && !theme.isMonet();
445+ }
446+
447+ /** Whether the theme showing now uses the generated day or night accent. */
448+ private static boolean generatedStyleShowing(SharedPreferences p) {
449+ Theme.ThemeInfo active = Theme.getActiveTheme();
450+ if (active == null || !hasGeneratedStyle()) {
451+ return false;
452+ }
453+ String key = active.getKey();
454+ return key.equals(p.getString(DAY + SUFFIX_THEME, null)) && active.currentAccentId == p.getInt(DAY + SUFFIX_ACCENT, -1)
455+ || key.equals(p.getString(NIGHT + SUFFIX_THEME, null)) && active.currentAccentId == p.getInt(NIGHT + SUFFIX_ACCENT, -1);
456+ }
457+
458+ private static Previous capture(SharedPreferences p) {
459+ Previous u = new Previous();
460+ u.seed = p.getLong(KEY_SEED, 0);
461+ u.version = p.getInt(KEY_VERSION, GENERATOR_VERSION);
462+ u.tableCrc = p.getInt(KEY_TABLE_CRC, 0);
463+ u.day = loadStyle(p, false);
464+ u.night = loadStyle(p, true);
465+ u.dayIndex = p.getInt(DAY + SUFFIX_INDEX, -1);
466+ u.nightIndex = p.getInt(NIGHT + SUFFIX_INDEX, -1);
467+ return u.day != null && u.night != null ? u : null;
468+ }
469+
470+ /**
471+ * Creates the new pair, then deletes the previous generated pair unless the user edited it (it is theirs now;
472+ * it stays marked as generated, so it still never leaves the device). Returns null, with nothing changed, on
473+ * failure.
474+ */
475+ private static Pair replacePair(SharedPreferences p, StyleTable.Entry day, StyleTable.Entry night) {
476+ ArrayList<Old> old = new ArrayList<>(2);
477+ addOld(old, p, false);
478+ addOld(old, p, true);
479+ Pair pair = createPair(p, day, night, false);
480+ if (pair == null) {
481+ return null;
482+ }
483+ ArrayList<Theme.ThemeInfo> changed = new ArrayList<>(4);
484+ changed.add(pair.dayTheme);
485+ changed.add(pair.nightTheme);
486+ for (Old o : old) {
487+ Theme.ThemeInfo theme = Theme.getTheme(o.themeKey);
488+ if (theme == null || theme.themeAccentsMap == null) {
489+ continue;
490+ }
491+ Theme.ThemeAccent accent = theme.themeAccentsMap.get(o.id);
492+ if (accent != null && accent != pair.dayAccent && accent != pair.nightAccent && isUnedited(accent, o.style)) {
493+ Theme.deleteThemeAccent(theme, accent, false); // local accent: no server call; saved below
494+ if (!changed.contains(theme)) {
495+ changed.add(theme);
496+ }
497+ }
498+ }
499+ for (Theme.ThemeInfo theme : changed) {
500+ Theme.saveThemeAccents(theme, true, false, false, false);
501+ }
502+ pruneRecords(p, false);
503+ saveSelection(pair);
504+ return pair;
505+ }
506+
507+ /** Applies the variant that is showing (animated, stock path) and switches the other one silently. */
508+ private static void show(Pair pair) {
509+ Theme.ThemeInfo active = Theme.getActiveTheme();
510+ boolean darkShowing = active != null && active.isDark();
511+ boolean autoNightShowing = darkShowing && Theme.selectedAutoNightType != Theme.AUTO_NIGHT_TYPE_NONE && Theme.isCurrentThemeNight();
512+ SharedPreferences.Editor main = MessagesController.getGlobalMainSettings().edit();
513+ main.putString("nighttheme", pair.nightTheme.getKey());
514+ // Only records the night theme: the variants are both dark or both light here, so nothing switches.
515+ Theme.setCurrentNightTheme(pair.nightTheme);
516+ if (!darkShowing) {
517+ // Day showing: the day style becomes the day theme (the apply writes mainconfig 'theme').
518+ post(pair.dayTheme, false, pair.dayAccent.id);
519+ } else if (autoNightShowing) {
520+ // Night showing through auto-night: the night style is shown, the day style waits for the next switch.
521+ Theme.setCurrentDayTheme(pair.dayTheme);
522+ main.putString("theme", pair.dayTheme.getKey());
523+ post(pair.nightTheme, true, pair.nightAccent.id);
524+ } else {
525+ // A dark theme is the day theme (manual night mode): stay dark with the new night style; the day/night
526+ // toggle switches to the new day style (themeconfig 'lastDayTheme').
527+ post(pair.nightTheme, false, pair.nightAccent.id);
528+ }
529+ main.apply();
530+ }
531+
532+ private static void post(Theme.ThemeInfo theme, boolean night, int accentId) {
533+ NotificationCenter.getGlobalInstance().postNotificationName(NotificationCenter.needSetDayNightTheme, theme, night, null, accentId);
534+ }
535+
536+ private static void notifyListeners(long seed, int reason) {
537+ for (Listener l : listeners) {
538+ try {
539+ l.onStyleChanged(seed, reason);
540+ } catch (Throwable e) {
541+ FileLog.e(e);
542+ }
543+ }
544+ }
545+
546+ // ---------------------------------------------------------------------------------------------
547+ // Accents
548+ // ---------------------------------------------------------------------------------------------
549+
550+ private static final class Pair {
551+ Theme.ThemeInfo dayTheme;
552+ Theme.ThemeInfo nightTheme;
553+ Theme.ThemeAccent dayAccent;
554+ Theme.ThemeAccent nightAccent;
555+ }
556+
557+ private static final class Old {
558+ String themeKey;
559+ int id;
560+ StyleTable.Entry style;
561+ }
562+
563+ private static final class Previous {
564+ long shuffledSeed; // the seed that Shuffle created; Undo applies only while it is current
565+ long seed;
566+ int version;
567+ int tableCrc;
568+ StyleTable.Entry day;
569+ StyleTable.Entry night;
570+ int dayIndex;
571+ int nightIndex;
572+ }
573+
574+ /**
575+ * Creates the day and night accents from two table entries, makes each its theme's current accent, records
576+ * them as generated and saves them (never uploaded). Returns null, with nothing changed, when either cannot be
577+ * created.
578+ */
579+ private static Pair createPair(SharedPreferences p, StyleTable.Entry day, StyleTable.Entry night, boolean themeInit) {
580+ Theme.ThemeInfo dayTheme = baseTheme(day, false);
581+ Theme.ThemeInfo nightTheme = baseTheme(night, true);
582+ if (dayTheme == null || nightTheme == null) {
583+ return null;
584+ }
585+ int dayCurrent = dayTheme.currentAccentId;
586+ int nightCurrent = nightTheme.currentAccentId;
587+ Pair pair = new Pair();
588+ pair.dayTheme = dayTheme;
589+ pair.nightTheme = nightTheme;
590+ try {
591+ pair.dayAccent = addAccent(dayTheme, day);
592+ pair.nightAccent = pair.dayAccent != null ? addAccent(nightTheme, night) : null;
593+ } catch (Throwable e) {
594+ FileLog.e(e);
595+ }
596+ if (pair.dayAccent == null || pair.nightAccent == null) {
597+ removeAccent(dayTheme, pair.dayAccent, dayCurrent);
598+ removeAccent(nightTheme, pair.nightAccent, nightCurrent);
599+ return null;
600+ }
601+ // Recorded before saving, so a generated accent is never on disk without its local-only mark.
602+ LinkedHashMap<String, Record> records = readRecords(p);
603+ addTag(records, dayTheme, pair.dayAccent, fingerprint(pair.dayAccent));
604+ addTag(records, nightTheme, pair.nightAccent, fingerprint(pair.nightAccent));
605+ writeRecords(p, records, true);
606+ if (themeInit) {
607+ // Inside Theme's static initializer: no notifications (migration = true), no upload.
608+ Theme.saveThemeAccents(dayTheme, true, false, false, false, true);
609+ Theme.saveThemeAccents(nightTheme, true, false, false, false, true);
610+ }
611+ return pair;
612+ }
613+
614+ private static Theme.ThemeInfo baseTheme(StyleTable.Entry entry, boolean night) {
615+ if (entry == null || entry.night != night) {
616+ return null;
617+ }
618+ Theme.ThemeInfo theme = Theme.getTheme(entry.themeKey);
619+ if (theme == null || theme.assetName == null || theme.isMonet() || theme.isDark() != night
620+ || theme.themeAccents == null || theme.themeAccentsMap == null) {
621+ return null;
622+ }
623+ return theme;
624+ }
625+
626+ /**
627+ * A new runtime accent (next free id above 100, info == null, no pattern) on theme, set up exactly as the table
628+ * was validated (multigram/tools/README.md, "Applying an entry") and made the theme's current accent.
629+ */
630+ private static Theme.ThemeAccent addAccent(Theme.ThemeInfo theme, StyleTable.Entry e) {
631+ // getAccent(true) copies the current accent's own wallpaper files into the new accent; it gets none.
632+ Theme.OverrideWallpaperInfo wallpaper = theme.overrideWallpaper;
633+ theme.overrideWallpaper = null;
634+ Theme.ThemeAccent a = theme.getAccent(true);
635+ if (a == null) {
636+ theme.overrideWallpaper = wallpaper;
637+ return null;
638+ }
639+ a.accentColor = e.accent;
640+ a.accentColor2 = 0;
641+ a.myMessagesAccentColor = e.bubble;
642+ a.myMessagesGradientAccentColor1 = e.bubbleGradient1;
643+ a.myMessagesGradientAccentColor2 = e.bubbleGradient2;
644+ a.myMessagesGradientAccentColor3 = e.bubbleGradient3;
645+ a.myMessagesAnimated = e.isAnimated();
646+ a.backgroundOverrideColor = e.wallpaper1;
647+ a.backgroundGradientOverrideColor1 = e.wallpaper2;
648+ a.backgroundGradientOverrideColor2 = e.wallpaper3 != 0 ? e.wallpaper3 : REMOVE_KEY;
649+ a.backgroundGradientOverrideColor3 = e.wallpaper4 != 0 ? e.wallpaper4 : REMOVE_KEY;
650+ a.backgroundRotation = e.rotation;
651+ a.patternSlug = ""; // no pattern: nothing to download, nothing to install on the server
652+ a.patternIntensity = 0f;
653+ a.patternMotion = e.isMotion();
654+ a.pattern = null;
655+ a.overrideWallpaper = null;
656+ a.info = null;
657+ a.isDefault = false;
658+ theme.overrideWallpaper = null;
659+ return a;
660+ }
661+
662+ private static void removeAccent(Theme.ThemeInfo theme, Theme.ThemeAccent accent, int previousCurrentId) {
663+ if (accent == null) {
664+ return;
665+ }
666+ theme.themeAccentsMap.remove(accent.id);
667+ theme.themeAccents.remove(accent);
668+ theme.prevAccentId = -1;
669+ theme.setCurrentAccentId(previousCurrentId);
670+ }
671+
672+ /**
673+ * True when the accent is still exactly as the style made it: the same colours, no pattern and no chat background
674+ * of its own ("Change chat background" attaches one to the current accent; deleting the accent deletes it too).
675+ */
676+ private static boolean isUnedited(Theme.ThemeAccent a, StyleTable.Entry e) {
677+ return e != null && a.info == null && a.overrideWallpaper == null
678+ && a.accentColor == e.accent
679+ && a.accentColor2 == 0
680+ && a.myMessagesAccentColor == e.bubble
681+ && a.myMessagesGradientAccentColor1 == e.bubbleGradient1
682+ && a.myMessagesGradientAccentColor2 == e.bubbleGradient2
683+ && a.myMessagesGradientAccentColor3 == e.bubbleGradient3
684+ && a.myMessagesAnimated == e.isAnimated()
685+ && (int) a.backgroundOverrideColor == e.wallpaper1
686+ && (int) a.backgroundGradientOverrideColor1 == e.wallpaper2
687+ && (int) a.backgroundGradientOverrideColor2 == e.wallpaper3
688+ && (int) a.backgroundGradientOverrideColor3 == e.wallpaper4
689+ && a.backgroundRotation == e.rotation
690+ && a.patternMotion == e.isMotion()
691+ && Math.abs(a.patternIntensity) < 0.001f
692+ && TextUtils.isEmpty(a.patternSlug);
693+ }
694+
695+ /** Remembers the chosen pair where the stock day/night toggle and the chat-theme strip look for it. */
696+ private static void saveSelection(Pair pair) {
697+ themeConfig().edit()
698+ .putString("lastDayTheme", pair.dayTheme.getKey())
699+ .putString("lastDarkTheme", pair.nightTheme.getKey())
700+ .putString("lastDayCustomTheme", pair.dayTheme.getKey())
701+ .putInt("lastDayCustomThemeAccentId", pair.dayAccent.id)
702+ .putString("lastDarkCustomTheme", pair.nightTheme.getKey())
703+ .putInt("lastDarkCustomThemeAccentId", pair.nightAccent.id)
704+ .apply();
705+ }
706+
707+ // ---------------------------------------------------------------------------------------------
708+ // Seed -> table entries, stored style
709+ // ---------------------------------------------------------------------------------------------
710+
711+ /** SplitMix64 finaliser. */
712+ private static long mix64(long z) {
713+ z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L;
714+ z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL;
715+ return z ^ (z >>> 31);
716+ }
717+
718+ /** Index among the day (or night) entries: SplitMix64 outputs 1 (day) and 2 (night) of the seed, uniform. */
719+ private static int pickIndex(long seed, boolean night, int count) {
720+ long r = mix64(seed + (night ? 2 : 1) * GOLDEN_GAMMA);
721+ return (int) ((r >>> 1) % count);
722+ }
723+
724+ /** Picks the day and night entries for seed and stores them (not committed) in editor. */
725+ private static boolean pick(StyleTable table, long seed, SharedPreferences.Editor editor) {
726+ int dayIndex = pickIndex(seed, false, table.getCount(false));
727+ int nightIndex = pickIndex(seed, true, table.getCount(true));
728+ StyleTable.Entry day = table.get(false, dayIndex);
729+ StyleTable.Entry night = table.get(true, nightIndex);
730+ if (day == null || night == null) {
731+ return false;
732+ }
733+ editor.putInt(KEY_TABLE_CRC, table.getCrc());
734+ putStyle(editor, DAY, day, dayIndex);
735+ putStyle(editor, NIGHT, night, nightIndex);
736+ return true;
737+ }
738+
739+ private static void putStyle(SharedPreferences.Editor editor, String variant, StyleTable.Entry e, int index) {
740+ editor.putString(variant + SUFFIX_THEME, e.themeKey)
741+ .putString(variant + SUFFIX_STYLE, e.toHex())
742+ .putInt(variant + SUFFIX_INDEX, index)
743+ .remove(variant + SUFFIX_ACCENT);
744+ }
745+
746+ private static SharedPreferences.Editor putPair(SharedPreferences.Editor editor, Pair pair) {
747+ return editor.putString(DAY + SUFFIX_THEME, pair.dayTheme.getKey())
748+ .putInt(DAY + SUFFIX_ACCENT, pair.dayAccent.id)
749+ .putString(NIGHT + SUFFIX_THEME, pair.nightTheme.getKey())
750+ .putInt(NIGHT + SUFFIX_ACCENT, pair.nightAccent.id);
751+ }
752+
753+ private static StyleTable.Entry loadStyle(SharedPreferences p, boolean night) {
754+ String variant = night ? NIGHT : DAY;
755+ return StyleTable.Entry.fromHex(p.getString(variant + SUFFIX_THEME, null), night, p.getString(variant + SUFFIX_STYLE, null));
756+ }
757+
758+ private static void addOld(ArrayList<Old> out, SharedPreferences p, boolean night) {
759+ String variant = night ? NIGHT : DAY;
760+ Old o = new Old();
761+ o.themeKey = p.getString(variant + SUFFIX_THEME, null);
762+ o.id = p.getInt(variant + SUFFIX_ACCENT, -1);
763+ o.style = loadStyle(p, night);
764+ if (o.themeKey != null && o.id > PaletteFix.LAST_STOCK_ACCENT_ID && o.style != null) {
765+ out.add(o);
766+ }
767+ }
768+
769+ // ---------------------------------------------------------------------------------------------
770+ // Generated-accent records: "<theme key>|<accent id>|<tags>" separated by ';'. Tags, separated by ',', are
771+ // fingerprints of the accent's colours (8 hex digits) or ANY.
772+ // ---------------------------------------------------------------------------------------------
773+
774+ private static final class Record {
775+ final String themeKey;
776+ final int id;
777+ final LinkedHashSet<String> tags = new LinkedHashSet<>();
778+
779+ Record(String themeKey, int id) {
780+ this.themeKey = themeKey;
781+ this.id = id;
782+ }
783+
784+ @Override
785+ public String toString() {
786+ return recordKey(themeKey, id) + "|" + TextUtils.join(",", tags);
787+ }
788+ }
789+
790+ private static String recordKey(String themeKey, int id) {
791+ return themeKey + "|" + id;
792+ }
793+
794+ /** CRC-32 of the values that make up an accent's style, as 8 hex digits. */
795+ static String fingerprint(Theme.ThemeAccent a) {
796+ ByteBuffer b = ByteBuffer.allocate(64);
797+ b.putInt(a.accentColor).putInt(a.accentColor2).putInt(a.myMessagesAccentColor)
798+ .putInt(a.myMessagesGradientAccentColor1).putInt(a.myMessagesGradientAccentColor2).putInt(a.myMessagesGradientAccentColor3)
799+ .put((byte) (a.myMessagesAnimated ? 1 : 0))
800+ .putLong(a.backgroundOverrideColor).putLong(a.backgroundGradientOverrideColor1)
801+ .putLong(a.backgroundGradientOverrideColor2).putLong(a.backgroundGradientOverrideColor3)
802+ .putInt(a.backgroundRotation);
803+ CRC32 crc = new CRC32();
804+ crc.update(b.array(), 0, b.position());
805+ return String.format(Locale.US, "%08x", crc.getValue());
806+ }
807+
808+ private static void parseRecords(String s, LinkedHashMap<String, Record> out) {
809+ if (TextUtils.isEmpty(s)) {
810+ return;
811+ }
812+ for (String entry : s.split(";")) {
813+ String[] parts = entry.split("\\|", -1);
814+ if (parts.length < 2 || parts.length > 3 || parts[0].isEmpty()) {
815+ continue;
816+ }
817+ int id;
818+ try {
819+ id = Integer.parseInt(parts[1]);
820+ } catch (NumberFormatException e) {
821+ continue;
822+ }
823+ String key = recordKey(parts[0], id);
824+ Record r = out.get(key);
825+ if (r == null) {
826+ r = new Record(parts[0], id);
827+ out.put(key, r);
828+ }
829+ boolean tagged = false;
830+ if (parts.length == 3) {
831+ for (String tag : parts[2].split(",")) {
832+ if (!tag.isEmpty()) {
833+ r.tags.add(tag);
834+ tagged = true;
835+ }
836+ }
837+ }
838+ if (!tagged) {
839+ r.tags.add(ANY); // no fingerprint recorded: keep it on the device
840+ }
841+ }
842+ }
843+
844+ /** The records of this file and of themeconfig's copy together (after a settings import they can differ). */
845+ private static LinkedHashMap<String, Record> readRecords(SharedPreferences p) {
846+ LinkedHashMap<String, Record> out = new LinkedHashMap<>();
847+ parseRecords(p.getString(KEY_GENERATED, null), out);
848+ parseRecords(themeConfig().getString(THEMECONFIG_GENERATED, null), out);
849+ return out;
850+ }
851+
852+ /** Adds tag to the accent's record, creating it; returns whether anything changed. */
853+ private static boolean addTag(LinkedHashMap<String, Record> records, Theme.ThemeInfo theme, Theme.ThemeAccent accent, String tag) {
854+ String key = recordKey(theme.getKey(), accent.id);
855+ Record r = records.get(key);
856+ if (r == null) {
857+ r = new Record(theme.getKey(), accent.id);
858+ records.put(key, r);
859+ }
860+ return r.tags.add(tag);
861+ }
862+
863+ /**
864+ * Writes the records to this file and to themeconfig's copy (only that key: other themeconfig keys are kept).
865+ * Adding a record is committed (sync), so a generated accent is never on disk without its mark; dropping one
866+ * can be applied lazily.
867+ */
868+ private static void writeRecords(SharedPreferences p, LinkedHashMap<String, Record> records, boolean sync) {
869+ String joined = TextUtils.join(";", records.values());
870+ SharedPreferences.Editor own = p.edit().putString(KEY_GENERATED, joined);
871+ SharedPreferences.Editor copy = themeConfig().edit().putString(THEMECONFIG_GENERATED, joined);
872+ if (sync) {
873+ own.commit();
874+ copy.commit();
875+ } else {
876+ own.apply();
877+ copy.apply();
878+ }
879+ }
880+
881+ /**
882+ * Drops records of accents that no longer exist (deleted by the user or by Shuffle). At start (Theme's
883+ * initializer, before anything can upload) it also checks each record against the accent as saved: ANY and
884+ * matching records are narrowed to the saved colours' fingerprint, and a record whose accent has other colours
885+ * is dropped, because that accent is not the one that was generated (a settings import replaced it).
886+ */
887+ private static void pruneRecords(SharedPreferences p, boolean atStart) {
888+ try {
889+ LinkedHashMap<String, Record> records = readRecords(p);
890+ LinkedHashMap<String, Record> kept = new LinkedHashMap<>();
891+ for (Record r : records.values()) {
892+ Theme.ThemeInfo theme = Theme.getTheme(r.themeKey);
893+ Theme.ThemeAccent accent = theme != null && theme.themeAccentsMap != null ? theme.themeAccentsMap.get(r.id) : null;
894+ if (accent == null || accent.info != null) {
895+ continue;
896+ }
897+ if (atStart) {
898+ String fingerprint = fingerprint(accent);
899+ if (!r.tags.contains(ANY) && !r.tags.contains(fingerprint)) {
900+ continue;
901+ }
902+ r.tags.clear();
903+ r.tags.add(fingerprint);
904+ }
905+ kept.put(recordKey(r.themeKey, r.id), r);
906+ }
907+ String joined = TextUtils.join(";", kept.values());
908+ if (!joined.equals(p.getString(KEY_GENERATED, "")) || !joined.equals(themeConfig().getString(THEMECONFIG_GENERATED, ""))) {
909+ writeRecords(p, kept, false);
910+ }
911+ } catch (Throwable e) {
912+ FileLog.e(e);
913+ }
914+ }
915+
916+ private static SharedPreferences prefs() {
917+ return ApplicationLoader.applicationContext.getSharedPreferences(PREFS, Activity.MODE_PRIVATE);
918+ }
919+
920+ private static SharedPreferences themeConfig() {
921+ return ApplicationLoader.applicationContext.getSharedPreferences("themeconfig", Activity.MODE_PRIVATE);
922+ }
923+}